Files
AILang/docs/plans/0019-floats-4-codegen.md
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

1240 lines
44 KiB
Markdown

# Floats Iteration 4 — Codegen + Runtime (Implementation Plan)
> **Parent spec:** `docs/specs/0005-floats.md` (committed
> `e37366f`, approved 2026-05-10) — sections A3 (codegen-side
> dispatch), A4 (conversions), A5 (determinism), and the
> `crates/ailang-codegen/` + `runtime/` Components subsections.
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Lower every Float-related typecheck primitive that iter 3
installed into LLVM IR. Float literals → hex-float `double` SSA
constants. Arithmetic and comparison ops dispatch on the resolved
arg type — Int → existing `add i64` / `icmp slt`, Float → new
`fadd double` / `fcmp olt double` / **`fcmp une double` for `!=`**.
Conversions (`int_to_float`, `float_to_int_truncate`) lower via
`sitofp` and the `@llvm.fptosi.sat.i64.f64` intrinsic. `is_nan`
lowers via `fcmp uno double %x, %x`. Float constants `nan` / `inf`
/ `neg_inf` resolve to direct hex-float `double` SSA values at use
site. `io/print_float` lowers via `printf("%g\n", v)` inline,
parallel to `io/print_int`. The end-to-end fixture
`examples/floats.ail.json` exercises literal, arithmetic,
comparison, conversion, and `io/print_float` in a single program
that builds via `ail build` and produces predicted stdout.
`float_to_str` is deferred to iter 5+ because it requires
runtime-allocated Str — AILang's Str is currently only static
`@.str_*` globals (no malloc-backed dynamic str path). The
codegen arm gets a named-iteration `unimplemented!("Floats
milestone iter 5+: float_to_str needs dynamic Str allocation")`.
**Architecture:** `synth::builtin_binop` becomes type-dispatched —
either replaced by a new helper `builtin_binop_typed(name, &Type)`
returning the right `(instruction, llvm_type)` pair, or extended
in-place. The `lower_app` site at lib.rs:1737 calls
`synth_arg_type` (parallel to how `==` dispatch already works at
line 1729-1733) to resolve the arg type, then dispatches. Float
constant-`Var` resolution intercepts at `lower_term`'s `Term::Var`
arm (lib.rs:1228) before the global-lookup fallback. The runtime
glue is **inline `printf` + libc `puts`-style** — no new C file
needed for `io/print_float`.
**Tech Stack:** `crates/ailang-codegen/` (`lib.rs`, `synth.rs`),
`examples/floats.ail.json` (new fixture), `crates/ail/tests/`
(new E2E test). No runtime C file changes.
**No-regression invariant:** every existing Int-using
`examples/*.ail.json` end-to-end test stays GREEN. The `add i64`
regression test at lib.rs:2627-2633 stays GREEN. The 395 workspace
tests at iter-3 close stay GREEN.
---
## Files this plan creates or modifies
- Modify: `crates/ailang-codegen/src/synth.rs`
`llvm_type` add `"Float" => "double"`,
`type_descriptor` add `"Float" => "Fl"`,
`builtin_binop` either becomes type-dispatched or is replaced
by `builtin_binop_typed(name, &Type)` (Task 2 picks the shape).
- Modify: `crates/ailang-codegen/src/lib.rs` — Float literal
lowering (2 sites at line 918/1215 currently `unimplemented!`),
type-dispatched arithmetic / comparison (line 1737),
`lower_eq` Float arm (line 2263), Float constant `Var`-intercept
(line ~1228), 4 new fn-builtin lowering arms, `io/print_float`
effect-op arm (line 2152 region), `float_to_str`
named-iteration `unimplemented!`.
- Create: `examples/floats.ail.json` — E2E fixture exercising
literal + arithmetic + comparison + conversion + io/print_float.
- Create: `crates/ail/tests/floats_e2e.rs``ail build` + run +
stdout-check for the new fixture.
- Modify: `docs/JOURNAL.md` — append iteration-close entry.
No file outside this list is touched.
---
## Task 1: Codegen primitive registration + Float literal lowering
**Files:**
- Modify: `crates/ailang-codegen/src/synth.rs` (`llvm_type` line ~14,
`type_descriptor` line ~129).
- Modify: `crates/ailang-codegen/src/lib.rs` lines 918 + 1215
(replace 2 `unimplemented!("Floats milestone iter 4: codegen")`
arms).
- [ ] **Step 1: Write the RED codegen test for Float literal lowering**
In `crates/ailang-codegen/src/lib.rs`, inside `mod tests` (around
line 2576), append:
```rust
/// Floats iter 4.1 RED: a `Literal::Float { bits: 0x3ff8_0000_0000_0000 }`
/// (= `1.5_f64`) lowers in a `Const` definition as an LLVM hex-float
/// `double` SSA constant. The exact IR snippet pinned: `@ail_t_k =
/// constant double 0x3FF8000000000000`.
#[test]
fn lowers_float_const_to_hex_double() {
use ailang_core::ast::*;
let m = Module {
schema: ailang_core::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![
Def::Const(ConstDef {
name: "k".into(),
ty: Type::float(),
value: Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } },
doc: None,
}),
Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::unit()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body: Term::Lit { lit: Literal::Unit },
suppress: vec![],
doc: None,
}),
],
};
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("@ail_t_k = constant double 0x3FF8000000000000"),
"ir missing the Float literal lowering: {ir}"
);
}
```
- [ ] **Step 2: Run test to verify RED**
Run: `cargo test -p ailang-codegen --lib lowers_float_const_to_hex_double`
Expected: PANIC with the iter-1 `unimplemented!("Floats milestone
iter 4: codegen")` message originating from `crates/ailang-codegen/
src/lib.rs:928`.
- [ ] **Step 3: Add `"Float"` to `synth.rs::llvm_type`**
In `crates/ailang-codegen/src/synth.rs`, find `fn llvm_type` (around
line 14) and add a `"Float"` arm parallel to existing primitives:
Read the function body first; the existing structure is `match name {
"Int" => "i64", ... }`. Add `"Float" => "double"` to that match.
- [ ] **Step 4: Add `"Float"` to `synth.rs::type_descriptor`**
In `synth.rs::type_descriptor` (line ~129), add `"Float" => "Fl"`
arm. Two letters because single-letter `F` collides with the ADT-name
prefix convention (`F<TypeName>`); `Fl` is unambiguous.
- [ ] **Step 5: Replace `unimplemented!` arm at lib.rs:918 (constant lowering)**
In `crates/ailang-codegen/src/lib.rs`, line 928, replace:
```rust
Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"),
```
with:
```rust
Literal::Float { bits } => ("double".to_string(), format!("0x{:016X}", bits)),
```
(The match returns `(val_ty, val)``("double", "0x...")` is the
right shape for the constant lowering site.)
- [ ] **Step 6: Replace `unimplemented!` arm at lib.rs:1215 (term lowering)**
In `crates/ailang-codegen/src/lib.rs`, line 1228, replace:
```rust
Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"),
```
with:
```rust
Literal::Float { bits } => (format!("0x{:016X}", bits), "double".into()),
```
(The match returns `(value, llvm_ty)` — note the field-order swap
relative to the Step 5 site, parallel to how `Literal::Int` at
line 1215 is `(value, "i64")` not `("i64", value)`.)
- [ ] **Step 7: Verify GREEN**
Run: `cargo test -p ailang-codegen --lib lowers_float_const_to_hex_double`
Expected: PASS.
Run: `cargo test --workspace`
Expected: 396+ passed (= 395 prior + 1 new), 0 failed.
- [ ] **Step 8: Commit**
```bash
git add crates/ailang-codegen/src/synth.rs crates/ailang-codegen/src/lib.rs
git commit -m "floats iter 4.1: codegen primitive registration + Float literal hex-double lowering"
```
---
## Task 2: Codegen widening of arithmetic ops (`+`/`-`/`*`/`/`)
**Files:**
- Modify: `crates/ailang-codegen/src/synth.rs::builtin_binop`
(line ~167) — convert from monomorphic-Int table to
type-dispatched, OR add a new sibling fn `builtin_binop_typed`
and route `lower_app` through it.
- Modify: `crates/ailang-codegen/src/lib.rs::lower_app`
(line 1737) — pass the resolved arg type to the new
type-dispatched helper.
- [ ] **Step 1: Inspect current `builtin_binop` shape**
Run: `grep -A 20 "fn builtin_binop" crates/ailang-codegen/src/synth.rs | head -25`
Expected: a match returning `Option<(&'static str, &'static str)>`
(instruction + return-llvm-type) keyed on operator name. The
current Int hardcoding is `"+" => Some(("add", "i64"))`, etc.
- [ ] **Step 2: Write the RED test for Float arithmetic lowering**
In `crates/ailang-codegen/src/lib.rs::tests`, append:
```rust
/// Floats iter 4.2 RED: `(+ 1.5 2.5)` lowers as `fadd double`,
/// not `add i64`. The Int regression `(+ 1 2)` still lowers as
/// `add i64`. Both lowerings live in one IR for one workspace
/// build.
#[test]
fn lowers_float_arithmetic_dispatched() {
use ailang_core::ast::*;
fn fn_def(name: &str, body: Term, ret_ty: Type) -> Def {
Def::Fn(FnDef {
name: name.into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(ret_ty),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body,
suppress: vec![],
doc: None,
})
}
let plus_int = Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Lit { lit: Literal::Int { value: 1 } },
Term::Lit { lit: Literal::Int { value: 2 } },
],
tail: false,
};
let plus_float = Term::App {
callee: Box::new(Term::Var { name: "+".into() }),
args: vec![
Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } },
Term::Lit { lit: Literal::Float { bits: 0x4004_0000_0000_0000u64 } },
],
tail: false,
};
let m = Module {
schema: ailang_core::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![
fn_def("ai", plus_int, Type::int()),
fn_def("af", plus_float, Type::float()),
fn_def("main", Term::Lit { lit: Literal::Unit }, Type::unit()),
],
};
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("add i64"),
"Int arithmetic regressed (no `add i64` in IR): {ir}"
);
assert!(
ir.contains("fadd double"),
"Float arithmetic missing (no `fadd double` in IR): {ir}"
);
}
```
- [ ] **Step 3: Verify RED**
Run: `cargo test -p ailang-codegen --lib lowers_float_arithmetic_dispatched`
Expected: FAIL — `(+ 1.5 2.5)` lowers as `add i64 0x3ff8..., 0x4004...`
(wrong instruction, wrong type) because the current `builtin_binop`
returns hardcoded `i64` regardless of arg type. The assertion on
`fadd double` fires.
- [ ] **Step 4: Convert `builtin_binop` to type-dispatched**
In `crates/ailang-codegen/src/synth.rs`, replace `fn builtin_binop`
(read the current body to find the exact match arms first). The new
shape:
```rust
/// Floats iter 4.2: arithmetic / comparison ops are type-dispatched
/// over `{Int, Float}`. Caller (`lower_app`) resolves the arg type
/// via `synth_arg_type` and passes it here. Returns the
/// `(instruction, llvm_type)` pair to emit. `%` stays Int-only.
pub(crate) fn builtin_binop_typed(name: &str, arg_ty: &Type) -> Option<(&'static str, &'static str)> {
let is_int = matches!(arg_ty, Type::Con { name, .. } if name == "Int");
let is_float = matches!(arg_ty, Type::Con { name, .. } if name == "Float");
match (name, is_int, is_float) {
("+", true, _) => Some(("add", "i64")),
("+", _, true) => Some(("fadd", "double")),
("-", true, _) => Some(("sub", "i64")),
("-", _, true) => Some(("fsub", "double")),
("*", true, _) => Some(("mul", "i64")),
("*", _, true) => Some(("fmul", "double")),
("/", true, _) => Some(("sdiv", "i64")),
("/", _, true) => Some(("fdiv", "double")),
("%", true, _) => Some(("srem", "i64")),
// Comparison ops are wired in Task 3 — return None here so
// `lower_app`'s comparison branch handles them. (Or merge
// both passes into a single switch in Task 3.)
_ => None,
}
}
```
KEEP the original `builtin_binop(name)` fn IF other call sites
depend on it (search via `grep -rn "builtin_binop" crates/`). If
the only consumer is `lower_app` at lib.rs:1737, REMOVE the old fn
and migrate the import.
- [ ] **Step 5: Update `lower_app` at lib.rs:1737 to dispatch on arg type**
In `crates/ailang-codegen/src/lib.rs`, line 1737-1755, replace:
```rust
if let Some((instr, ret_ty)) = builtin_binop(name) {
if args.len() != 2 {
return Err(CodegenError::Internal(format!(
"builtin `{name}` expected 2 args"
)));
}
let (a, _) = self.lower_term(&args[0])?;
let (b, _) = self.lower_term(&args[1])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = {instr} i64 {a}, {b}\n"
));
let _ = tail;
return Ok((dst, ret_ty.into()));
}
```
with:
```rust
// Floats iter 4.2: arithmetic / comparison are now polymorphic
// over `{Int, Float}`. Resolve the arg type, then dispatch via
// `builtin_binop_typed`. Same shape as the `==` dispatch above.
if matches!(name, "+" | "-" | "*" | "/" | "%") {
if args.len() != 2 {
return Err(CodegenError::Internal(format!(
"builtin `{name}` expected 2 args"
)));
}
let arg_ty = self.synth_arg_type(&args[0])?;
let (instr, ll_ty) = builtin_binop_typed(name, &arg_ty)
.ok_or_else(|| CodegenError::Internal(format!(
"`{name}` not supported for type `{}`",
ailang_core::pretty::type_to_string(&arg_ty)
)))?;
let (a, _) = self.lower_term(&args[0])?;
let (b, _) = self.lower_term(&args[1])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = {instr} {ll_ty} {a}, {b}\n"
));
let _ = tail;
return Ok((dst, ll_ty.into()));
}
```
Also update the second `builtin_binop` reference at line 2076 (`if
builtin_binop(name).is_some() || name == "not"`) to:
```rust
if matches!(name, "+" | "-" | "*" | "/" | "%") || name == "not" {
```
Also update the imports at line 56 to drop `builtin_binop` (or keep
both imports if the old fn is retained).
- [ ] **Step 6: Verify the existing `add i64` regression test stays GREEN**
Run: `cargo test -p ailang-codegen --lib`
Expected: the existing `lib::tests::lowers_t_add_int` (or however
the test at line 2627-2633 is named) stays GREEN — `add i64
%arg_a, %arg_b` is still emitted for the Int-arg `+`.
- [ ] **Step 7: Verify the new RED test now GREEN**
Run: `cargo test -p ailang-codegen --lib lowers_float_arithmetic_dispatched`
Expected: PASS.
- [ ] **Step 8: Verify no workspace regression**
Run: `cargo test --workspace`
Expected: 397+ passed, 0 failed.
- [ ] **Step 9: Commit**
```bash
git add crates/ailang-codegen/src/synth.rs crates/ailang-codegen/src/lib.rs
git commit -m "floats iter 4.2: codegen arithmetic dispatch on arg type — fadd/fsub/fmul/fdiv double"
```
---
## Task 3: Codegen widening of comparison ops + `==`/`!=` Float arms
**Files:**
- Modify: `crates/ailang-codegen/src/synth.rs::builtin_binop_typed`
(extend with comparison arms).
- Modify: `crates/ailang-codegen/src/lib.rs::lower_app`
(extend the dispatch block from Task 2 to cover comparison ops).
- Modify: `crates/ailang-codegen/src/lib.rs::lower_eq`
(line 2263) — add `"Float"` arm.
- [ ] **Step 1: Write the RED test for Float comparison + `!=`**
In `crates/ailang-codegen/src/lib.rs::tests`, append:
```rust
/// Floats iter 4.3 RED: `(< 1.5 2.5)` lowers as `fcmp olt double`;
/// `(!= 1.5 1.5)` lowers as `fcmp UNE double` (NOT `one`); `(== 1.5
/// 1.5)` lowers as `fcmp oeq double`. Int regressions still emit
/// `icmp slt i64` / `icmp ne i64` / `icmp eq i64`.
#[test]
fn lowers_float_comparison_dispatched() {
use ailang_core::ast::*;
fn fn_def(name: &str, body: Term) -> Def {
Def::Fn(FnDef {
name: name.into(),
ty: Type::Fn {
params: vec![],
ret: Box::new(Type::bool_()),
effects: vec![],
param_modes: vec![],
ret_mode: ParamMode::Implicit,
},
params: vec![],
body,
suppress: vec![],
doc: None,
})
}
fn cmp(op: &str, a: Term, b: Term) -> Term {
Term::App {
callee: Box::new(Term::Var { name: op.into() }),
args: vec![a, b],
tail: false,
}
}
let f1 = Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } };
let f2 = Term::Lit { lit: Literal::Float { bits: 0x4004_0000_0000_0000u64 } };
let i1 = Term::Lit { lit: Literal::Int { value: 1 } };
let i2 = Term::Lit { lit: Literal::Int { value: 2 } };
let main_def = Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![], ret: Box::new(Type::unit()), effects: vec![],
param_modes: vec![], ret_mode: ParamMode::Implicit,
},
params: vec![], body: Term::Lit { lit: Literal::Unit },
suppress: vec![], doc: None,
});
let m = Module {
schema: ailang_core::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![
fn_def("flt_f", cmp("<", f1.clone(), f2.clone())),
fn_def("fne_f", cmp("!=", f1.clone(), f1.clone())),
fn_def("feq_f", cmp("==", f1.clone(), f1.clone())),
fn_def("flt_i", cmp("<", i1.clone(), i2.clone())),
fn_def("fne_i", cmp("!=", i1.clone(), i2.clone())),
fn_def("feq_i", cmp("==", i1.clone(), i2.clone())),
main_def,
],
};
let ir = emit_ir(&m).unwrap();
assert!(ir.contains("fcmp olt double"), "missing `fcmp olt double`: {ir}");
assert!(ir.contains("fcmp une double"), "missing `fcmp une double` (note: `une` not `one`): {ir}");
assert!(ir.contains("fcmp oeq double"), "missing `fcmp oeq double`: {ir}");
assert!(ir.contains("icmp slt i64"), "Int `<` regressed: {ir}");
assert!(ir.contains("icmp ne i64"), "Int `!=` regressed: {ir}");
assert!(ir.contains("icmp eq i64"), "Int `==` regressed: {ir}");
}
```
- [ ] **Step 2: Verify RED**
Run: `cargo test -p ailang-codegen --lib lowers_float_comparison_dispatched`
Expected: FAIL — Float `<`/`!=` currently fall through (Task 2's
arithmetic-only dispatch returned None for them, leading to a
fallback path that doesn't recognise them).
- [ ] **Step 3: Extend `builtin_binop_typed` with comparison arms**
In `crates/ailang-codegen/src/synth.rs::builtin_binop_typed`,
extend the match:
```rust
// Comparison ops (Floats iter 4.3). Note: `!=` Float uses
// `fcmp une` ("unordered or not equal") — NOT `one` ("ordered
// and not equal"), which would return `false` for `nan != nan`
// and violate IEEE / spec A5.
("!=", true, _) => Some(("icmp ne", "i64")),
("!=", _, true) => Some(("fcmp une", "double")),
("<", true, _) => Some(("icmp slt", "i64")),
("<", _, true) => Some(("fcmp olt", "double")),
("<=", true, _) => Some(("icmp sle", "i64")),
("<=", _, true) => Some(("fcmp ole", "double")),
(">", true, _) => Some(("icmp sgt", "i64")),
(">", _, true) => Some(("fcmp ogt", "double")),
(">=", true, _) => Some(("icmp sge", "i64")),
(">=", _, true) => Some(("fcmp oge", "double")),
```
For comparison ops, the "return type" in the emitted instruction is
the LLVM type of the OPERANDS (not the result, which is always
`i1`). The tuple `(instr, ll_ty)` therefore carries the operand
type for the format-string; the result is always `i1`. The caller
must override the return-llvm-type to `"i1"` for comparison ops.
- [ ] **Step 4: Update `lower_app` dispatch to cover comparison ops**
In `crates/ailang-codegen/src/lib.rs`, extend the match-pattern
from Task 2's Step 5 to include comparison ops, AND fix the result
type:
```rust
if matches!(name, "+" | "-" | "*" | "/" | "%" | "!=" | "<" | "<=" | ">" | ">=") {
if args.len() != 2 {
return Err(CodegenError::Internal(format!(
"builtin `{name}` expected 2 args"
)));
}
let arg_ty = self.synth_arg_type(&args[0])?;
let (instr, ll_ty) = builtin_binop_typed(name, &arg_ty)
.ok_or_else(|| CodegenError::Internal(format!(
"`{name}` not supported for type `{}`",
ailang_core::pretty::type_to_string(&arg_ty)
)))?;
let (a, _) = self.lower_term(&args[0])?;
let (b, _) = self.lower_term(&args[1])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = {instr} {ll_ty} {a}, {b}\n"
));
let _ = tail;
// Comparison ops return i1; arithmetic returns the operand type.
let result_ll_ty = if matches!(name, "!=" | "<" | "<=" | ">" | ">=") {
"i1".into()
} else {
ll_ty.into()
};
return Ok((dst, result_ll_ty));
}
```
Also extend the line-2076 reference identically:
```rust
if matches!(name, "+" | "-" | "*" | "/" | "%" | "!=" | "<" | "<=" | ">" | ">=") || name == "not" {
```
- [ ] **Step 5: Add Float arm in `lower_eq`**
In `crates/ailang-codegen/src/lib.rs::lower_eq` (line 2263), add a
`"Float"` arm to the existing `Type::Con { name }` match:
```rust
"Float" => {
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = fcmp oeq double {a}, {b}\n"
));
Ok((dst, "i1".into()))
}
```
Place it immediately after the `"Unit"` arm and before the
fallback `other => Err(...)`.
- [ ] **Step 6: Verify GREEN**
Run: `cargo test -p ailang-codegen --lib lowers_float_comparison_dispatched`
Expected: PASS.
Run: `cargo test --workspace`
Expected: 398+ passed, 0 failed.
- [ ] **Step 7: Commit**
```bash
git add crates/ailang-codegen/src/synth.rs crates/ailang-codegen/src/lib.rs
git commit -m "floats iter 4.3: codegen comparison dispatch — fcmp olt/ole/ogt/oge/oeq/UNE double"
```
---
## Task 4: Codegen new fn builtins (`neg`, `int_to_float`, `float_to_int_truncate`, `is_nan`) + `float_to_str` deferral
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs::lower_app` — add 4 new
arms (one per builtin) plus a `float_to_str => unimplemented!()`
deferral marker.
- Modify: `crates/ailang-codegen/src/lib.rs` IR-header section
(line ~461 where `@strcmp` and `@printf` are declared) — declare
`@llvm.fptosi.sat.i64.f64`.
- [ ] **Step 1: Write the RED test for the four lowerings**
In `crates/ailang-codegen/src/lib.rs::tests`, append:
```rust
/// Floats iter 4.4 RED: four new fn-builtins lower to the spec'd
/// LLVM ops. `neg` polymorphic dispatches to `sub i64 0, %x` for
/// Int and `fneg double %x` for Float (NOT `fsub 0.0, %x`, which
/// is wrong for `-0.0`). `int_to_float` → `sitofp`. `is_nan` →
/// `fcmp uno double %x, %x`. `float_to_int_truncate` →
/// `@llvm.fptosi.sat.i64.f64` intrinsic call.
#[test]
fn lowers_float_fn_builtins() {
use ailang_core::ast::*;
fn fn_def(name: &str, body: Term, ret_ty: Type) -> Def {
Def::Fn(FnDef {
name: name.into(),
ty: Type::Fn {
params: vec![], ret: Box::new(ret_ty), effects: vec![],
param_modes: vec![], ret_mode: ParamMode::Implicit,
},
params: vec![], body, suppress: vec![], doc: None,
})
}
fn app1(callee: &str, arg: Term) -> Term {
Term::App {
callee: Box::new(Term::Var { name: callee.into() }),
args: vec![arg], tail: false,
}
}
let neg_int = app1("neg", Term::Lit { lit: Literal::Int { value: 5 } });
let neg_float = app1("neg", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } });
let i2f = app1("int_to_float", Term::Lit { lit: Literal::Int { value: 5 } });
let f2i = app1("float_to_int_truncate", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } });
let isnan = app1("is_nan", Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } });
let main_def = fn_def("main", Term::Lit { lit: Literal::Unit }, Type::unit());
let m = Module {
schema: ailang_core::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![
fn_def("ni", neg_int, Type::int()),
fn_def("nf", neg_float, Type::float()),
fn_def("c1", i2f, Type::float()),
fn_def("c2", f2i, Type::int()),
fn_def("isn", isnan, Type::bool_()),
main_def,
],
};
let ir = emit_ir(&m).unwrap();
assert!(ir.contains("sub i64 0,"), "neg Int missing: {ir}");
assert!(ir.contains("fneg double"), "neg Float missing (must use fneg, not fsub-from-zero): {ir}");
assert!(ir.contains("sitofp i64"), "int_to_float missing: {ir}");
assert!(ir.contains("@llvm.fptosi.sat.i64.f64"), "float_to_int_truncate intrinsic missing: {ir}");
assert!(ir.contains("fcmp uno double"), "is_nan missing (must be fcmp uno x, x): {ir}");
}
```
- [ ] **Step 2: Verify RED**
Run: `cargo test -p ailang-codegen --lib lowers_float_fn_builtins`
Expected: FAIL — first failing assertion will be `neg Int missing`
or similar (none of the 4 builtins have lowering arms yet).
- [ ] **Step 3: Declare the `@llvm.fptosi.sat.i64.f64` intrinsic**
In `crates/ailang-codegen/src/lib.rs` around line 461 where
`@strcmp` and `@printf` are declared, ADD:
```rust
// Floats iter 4.4: saturating fp-to-int intrinsic for
// float_to_int_truncate. NaN → 0, +Inf → i64::MAX, -Inf →
// i64::MIN, finite-out-of-range saturates, finite-in-range
// truncates toward zero. LLVM 12+, always available with
// clang 22.
out.push_str("declare i64 @llvm.fptosi.sat.i64.f64(double)\n\n");
```
- [ ] **Step 4: Add the 5 lowering arms in `lower_app`**
In `crates/ailang-codegen/src/lib.rs::lower_app`, add (location:
after the existing comparison/arithmetic dispatch from Tasks 2-3,
before the cross-module call dispatch at line 1769):
```rust
// Floats iter 4.4: polymorphic neg + 3 monomorphic fn builtins.
if name == "neg" {
if args.len() != 1 {
return Err(CodegenError::Internal("neg arity".into()));
}
let arg_ty = self.synth_arg_type(&args[0])?;
let (a, _) = self.lower_term(&args[0])?;
let dst = self.fresh_ssa();
match &arg_ty {
Type::Con { name, .. } if name == "Int" => {
self.body.push_str(&format!(" {dst} = sub i64 0, {a}\n"));
return Ok((dst, "i64".into()));
}
Type::Con { name, .. } if name == "Float" => {
// LLVM 8+ `fneg` correctly handles -0.0.
self.body.push_str(&format!(" {dst} = fneg double {a}\n"));
return Ok((dst, "double".into()));
}
other => return Err(CodegenError::Internal(format!(
"`neg` not supported for type `{}`",
ailang_core::pretty::type_to_string(other)
))),
}
}
if name == "int_to_float" {
if args.len() != 1 {
return Err(CodegenError::Internal("int_to_float arity".into()));
}
let (a, _) = self.lower_term(&args[0])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(" {dst} = sitofp i64 {a} to double\n"));
return Ok((dst, "double".into()));
}
if name == "float_to_int_truncate" {
if args.len() != 1 {
return Err(CodegenError::Internal("float_to_int_truncate arity".into()));
}
let (a, _) = self.lower_term(&args[0])?;
let dst = self.fresh_ssa();
self.body.push_str(&format!(
" {dst} = call i64 @llvm.fptosi.sat.i64.f64(double {a})\n"
));
return Ok((dst, "i64".into()));
}
if name == "is_nan" {
if args.len() != 1 {
return Err(CodegenError::Internal("is_nan arity".into()));
}
let (a, _) = self.lower_term(&args[0])?;
let dst = self.fresh_ssa();
// `fcmp uno x, x` returns `i1 1` iff `x` is NaN — only
// NaN compares unordered against itself.
self.body.push_str(&format!(" {dst} = fcmp uno double {a}, {a}\n"));
return Ok((dst, "i1".into()));
}
if name == "float_to_str" {
// Deferred to iter 5+: needs runtime-allocated Str
// (currently the codegen Str path uses only static
// `@.str_*` globals; no malloc-backed dynamic-Str
// infrastructure). The typecheck path in iter 3
// installs `float_to_str : (Float) -> Str`; until the
// runtime gets a Str allocator, calling it crashes
// codegen. This is intentional: don't ship an
// unimplemented call to the LLM-author surface.
unimplemented!("Floats milestone iter 5+: float_to_str needs dynamic Str allocation");
}
```
- [ ] **Step 5: Verify GREEN**
Run: `cargo test -p ailang-codegen --lib lowers_float_fn_builtins`
Expected: PASS.
Run: `cargo test --workspace`
Expected: 399+ passed, 0 failed.
- [ ] **Step 6: Commit**
```bash
git add crates/ailang-codegen/src/lib.rs
git commit -m "floats iter 4.4: codegen neg/int_to_float/float_to_int_truncate/is_nan + float_to_str-deferred"
```
---
## Task 5: Codegen Float constants (`nan`, `inf`, `neg_inf`)
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs::lower_term`
Term::Var arm — intercept `nan`/`inf`/`neg_inf` before
global-lookup fallback.
- [ ] **Step 1: Find the Term::Var lowering site**
Run: `grep -n "Term::Var.*=>" crates/ailang-codegen/src/lib.rs | head -5`
Locate the `Term::Var { name } => { ... }` arm in `lower_term`.
There may be several `Term::Var` matches in the file; the load-bearing
one is in `lower_term` (around line 1228).
- [ ] **Step 2: Write the RED test**
In `crates/ailang-codegen/src/lib.rs::tests`, append:
```rust
/// Floats iter 4.5 RED: `nan`/`inf`/`neg_inf` resolve as bare
/// `Term::Var` references and lower to direct hex-float `double`
/// SSA values at the use site (no global definition emitted —
/// they are values, not unreachable-style terminators).
#[test]
fn lowers_float_constants() {
use ailang_core::ast::*;
fn fn_def(name: &str, body: Term) -> Def {
Def::Fn(FnDef {
name: name.into(),
ty: Type::Fn {
params: vec![], ret: Box::new(Type::float()), effects: vec![],
param_modes: vec![], ret_mode: ParamMode::Implicit,
},
params: vec![], body, suppress: vec![], doc: None,
})
}
let main_def = Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![], ret: Box::new(Type::unit()), effects: vec![],
param_modes: vec![], ret_mode: ParamMode::Implicit,
},
params: vec![], body: Term::Lit { lit: Literal::Unit },
suppress: vec![], doc: None,
});
let m = Module {
schema: ailang_core::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![
fn_def("k_nan", Term::Var { name: "nan".into() }),
fn_def("k_inf", Term::Var { name: "inf".into() }),
fn_def("k_neg_inf", Term::Var { name: "neg_inf".into() }),
main_def,
],
};
let ir = emit_ir(&m).unwrap();
assert!(ir.contains("0x7FF8000000000000"), "nan bit pattern missing: {ir}");
assert!(ir.contains("0x7FF0000000000000"), "+inf bit pattern missing: {ir}");
assert!(ir.contains("0xFFF0000000000000"), "-inf bit pattern missing: {ir}");
}
```
- [ ] **Step 3: Verify RED**
Run: `cargo test -p ailang-codegen --lib lowers_float_constants`
Expected: FAIL — `nan` etc. fall through `Term::Var` to the
global-lookup path, which doesn't have them.
- [ ] **Step 4: Intercept the three constants in `lower_term`'s Term::Var arm**
In `crates/ailang-codegen/src/lib.rs::lower_term`, find the
`Term::Var { name }` arm (around line 1228). At the TOP of that arm,
BEFORE any other resolution attempt, add:
```rust
// Floats iter 4.5: bare-value Float constants resolve
// directly to LLVM hex-float `double` SSA values at
// the use site — no global declaration, no
// intern-global path. Parallel to how `__unreachable__`
// is intercepted, but as a value rather than a
// terminator (constants are SSA values; the
// unreachable-instruction path doesn't apply).
match name.as_str() {
"nan" => return Ok(("0x7FF8000000000000".into(), "double".into())),
"inf" => return Ok(("0x7FF0000000000000".into(), "double".into())),
"neg_inf" => return Ok(("0xFFF0000000000000".into(), "double".into())),
_ => {}
}
```
- [ ] **Step 5: Verify GREEN**
Run: `cargo test -p ailang-codegen --lib lowers_float_constants`
Expected: PASS.
Run: `cargo test --workspace`
Expected: 400+ passed, 0 failed.
- [ ] **Step 6: Commit**
```bash
git add crates/ailang-codegen/src/lib.rs
git commit -m "floats iter 4.5: codegen Float constants nan/inf/neg_inf as hex-double SSA values"
```
---
## Task 6: Codegen `io/print_float` + E2E fixture `examples/floats.ail.json`
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs::lower_do`
(line ~2152 region) — add `"io/print_float"` arm.
- Create: `examples/floats.ail.json` — small Float E2E fixture.
- Create: `crates/ail/tests/floats_e2e.rs``ail build` + run +
stdout-check.
- [ ] **Step 1: Write the RED test for io/print_float lowering**
In `crates/ailang-codegen/src/lib.rs::tests`, append:
```rust
/// Floats iter 4.6 RED: `(do io/print_float 1.5)` lowers via
/// `printf("%g\n", v)`, parallel to `io/print_int` at line 2152.
#[test]
fn lowers_io_print_float() {
use ailang_core::ast::*;
let body = Term::Do {
op: "io/print_float".into(),
args: vec![Term::Lit { lit: Literal::Float { bits: 0x3ff8_0000_0000_0000u64 } }],
tail: false,
};
let m = Module {
schema: ailang_core::SCHEMA.to_string(),
name: "t".into(),
imports: vec![],
defs: vec![Def::Fn(FnDef {
name: "main".into(),
ty: Type::Fn {
params: vec![], ret: Box::new(Type::unit()), effects: vec!["IO".into()],
param_modes: vec![], ret_mode: ParamMode::Implicit,
},
params: vec![], body, suppress: vec![], doc: None,
})],
};
let ir = emit_ir(&m).unwrap();
assert!(
ir.contains("call i32 (ptr, ...) @printf"),
"io/print_float not emitting printf: {ir}"
);
assert!(
ir.contains("double 0x3FF8000000000000"),
"Float arg not threaded through io/print_float: {ir}"
);
// Verify the format string `%g\n` is interned.
assert!(
ir.contains("%g") || ir.contains("\\67"), // `g` ASCII = 67 = 0x47
"format string `%g\\n` not interned: {ir}"
);
}
```
- [ ] **Step 2: Verify RED**
Run: `cargo test -p ailang-codegen --lib lowers_io_print_float`
Expected: FAIL — `io/print_float` op is unhandled in `lower_do`.
- [ ] **Step 3: Add the `io/print_float` arm**
In `crates/ailang-codegen/src/lib.rs::lower_do` (around line 2152),
add immediately AFTER the `io/print_int` arm:
```rust
"io/print_float" => {
if args.len() != 1 {
return Err(CodegenError::Internal(
"io/print_float arity".into(),
));
}
let (v, vty) = self.lower_term(&args[0])?;
if vty != "double" {
return Err(CodegenError::Internal(
"io/print_float needs double".into(),
));
}
let fmt = self.intern_string("fmt_float", "%g\n");
self.body.push_str(&format!(
" {call_kw} i32 (ptr, ...) @printf(ptr @{fmt}, double {v})\n"
));
if tail {
self.body.push_str(" ret i8 0\n");
self.block_terminated = true;
}
Ok(("0".into(), "i8".into()))
}
```
- [ ] **Step 4: Verify the unit test now GREEN**
Run: `cargo test -p ailang-codegen --lib lowers_io_print_float`
Expected: PASS.
- [ ] **Step 5: Author the E2E fixture `examples/floats.ail.json`**
Create `examples/floats.ail.json` with the following content:
```json
{
"schema": "ailang/v0",
"name": "floats",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": ["IO"]
},
"params": [],
"body": {
"t": "seq",
"lhs": {
"t": "do",
"op": "io/print_float",
"args": [
{
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "lit", "lit": { "kind": "float", "bits": "3ff8000000000000" } },
{ "t": "lit", "lit": { "kind": "float", "bits": "4004000000000000" } }
]
}
]
},
"rhs": {
"t": "seq",
"lhs": {
"t": "do",
"op": "io/print_float",
"args": [
{
"t": "app",
"fn": { "t": "var", "name": "int_to_float" },
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 42 } }
]
}
]
},
"rhs": {
"t": "do",
"op": "io/print_float",
"args": [
{
"t": "app",
"fn": { "t": "var", "name": "neg" },
"args": [
{ "t": "lit", "lit": { "kind": "float", "bits": "3ff8000000000000" } }
]
}
]
}
}
}
}
]
}
```
This program prints three lines to stdout:
1. `1.5 + 2.5 = 4` (printf `%g` for 4.0 produces `4`)
2. `int_to_float(42) = 42` (printf `%g` for 42.0 produces `42`)
3. `neg(1.5) = -1.5` (printf `%g` for -1.5 produces `-1.5`)
Expected stdout:
```
4
42
-1.5
```
- [ ] **Step 6: Author the E2E test file**
Create `crates/ail/tests/floats_e2e.rs`:
```rust
//! Floats milestone iter 4.6 — end-to-end fixture.
//!
//! `examples/floats.ail.json` is built via the public `ail build`
//! CLI and run; stdout is matched against the expected three
//! lines (`4`, `42`, `-1.5`). This test exercises the full
//! pipeline — Float literal lowering, polymorphic `+` Float arm,
//! `int_to_float` (sitofp), `neg` Float (fneg), `io/print_float`
//! (printf `%g\n`).
use std::path::PathBuf;
use std::process::Command;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent().unwrap()
.parent().unwrap()
.to_path_buf()
}
#[test]
fn floats_example_prints_expected_stdout() {
let root = workspace_root();
let example = root.join("examples").join("floats.ail.json");
assert!(example.exists(), "expected fixture at {:?}", example);
// Build via `cargo run -p ail -- build` to use the in-tree CLI.
let target_bin = std::env::temp_dir().join(format!(
"ailang-floats-e2e-{}",
std::process::id()
));
let build = Command::new("cargo")
.current_dir(&root)
.args([
"run", "--quiet", "-p", "ail", "--",
"build",
example.to_str().unwrap(),
"-o", target_bin.to_str().unwrap(),
])
.output()
.expect("cargo run -p ail -- build");
assert!(
build.status.success(),
"ail build failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr),
);
let run = Command::new(&target_bin)
.output()
.expect("run floats binary");
assert!(
run.status.success(),
"floats binary exited non-zero: status {:?}\nstdout: {}\nstderr: {}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr),
);
let stdout = String::from_utf8_lossy(&run.stdout);
let expected = "4\n42\n-1.5\n";
assert_eq!(
stdout, expected,
"stdout mismatch.\nGot:\n{}\nExpected:\n{}",
stdout, expected,
);
let _ = std::fs::remove_file(&target_bin);
}
```
Note: this test depends on the `ail` CLI's `build` subcommand
accepting the standard `<input> -o <output>` arg shape. Verify
the actual CLI shape via `cargo run -p ail -- build --help` if the
test fails to invoke the build cleanly. The test is gated on
`ail build` succeeding from a clean checkout — if the test runner
environment lacks `clang` or `libgc`/`libc`, the test will fail
on the link step. Mark with `#[ignore]` ONLY if local dev wants
to skip; CI must run it.
- [ ] **Step 7: Run the E2E test**
Run: `cargo test -p ail --test floats_e2e floats_example_prints_expected_stdout`
Expected: PASS — three lines of expected output match exactly.
If the test fails on the build step, inspect the stderr in the
panic and address the actual issue. Do NOT mark `#[ignore]` to
push past the failure.
- [ ] **Step 8: Verify no workspace regression**
Run: `cargo test --workspace`
Expected: 401+ passed (= prior + 1 new unit + 1 new E2E), 0 failed.
- [ ] **Step 9: Commit**
```bash
git add crates/ailang-codegen/src/lib.rs \
examples/floats.ail.json \
crates/ail/tests/floats_e2e.rs
git commit -m "floats iter 4.6: codegen io/print_float + examples/floats.ail.json E2E fixture"
```
---
## Task 7: Iteration close — JOURNAL entry
**Files:**
- Modify: `docs/JOURNAL.md` — append entry at file end with the
per-task SHAs from Tasks 1-6.
- [ ] **Step 1: Append the iteration-close entry**
The orchestrator will write this section directly per the
established pattern (single-file edit; `Edit` tool). The
text mirrors the per-task commit subjects, lists the deferred
known debt (`float_to_str` runtime-Str allocation), and notes the
end-to-end fixture as the milestone-acceptance gate.
- [ ] **Step 2: Commit**
```bash
git add docs/JOURNAL.md
git commit -m "floats iter 4: JOURNAL — iteration close (codegen + E2E)"
```
---
## Iteration acceptance
- [ ] `cargo build --workspace` is clean.
- [ ] `cargo test --workspace` is GREEN — pre-existing tests stay
GREEN (the iter-3 close had 395 tests; iter 4 adds 5 codegen
unit tests + 1 E2E test ≈ 401 expected).
- [ ] `examples/floats.ail.json` builds via `ail build` and
produces the expected stdout (`4\n42\n-1.5\n`).
- [ ] No `unimplemented!` arms with the `"Floats milestone iter 4: codegen"`
string remain in any crate (the iter-1 markers are all replaced).
- [ ] `float_to_str` codegen arm carries an honest deferred
`unimplemented!("Floats milestone iter 5+: ...")` marker — no
silent fallback.
- [ ] `lib.rs::tests::lowers_t_add_int` (the existing `add i64`
regression) stays GREEN.
- [ ] No file touched outside the five listed in the file map.
- [ ] JOURNAL entry committed.
When all seven tasks are committed and the acceptance checklist is
green, hand back to the orchestrator. Iteration 5 (Prose round-trip
+ DESIGN.md §"Float semantics" + line-2033 update + drift-test
extension; OPTIONALLY `float_to_str` runtime-Str allocation if the
orchestrator chooses to scope it in) is the next dispatch.