From 0c514e034bc1ba05652b0e27cadfdfa8d0624c53 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 10 May 2026 15:51:20 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20floats=20iter=204=20=E2=80=94=20codegen?= =?UTF-8?q?=20+=20runtime=20(Float=20lowering=20paths=20+=20E2E=20fixture)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-05-10-floats-4-codegen.md | 1239 +++++++++++++++++++++ 1 file changed, 1239 insertions(+) create mode 100644 docs/plans/2026-05-10-floats-4-codegen.md diff --git a/docs/plans/2026-05-10-floats-4-codegen.md b/docs/plans/2026-05-10-floats-4-codegen.md new file mode 100644 index 0000000..5c9a3c0 --- /dev/null +++ b/docs/plans/2026-05-10-floats-4-codegen.md @@ -0,0 +1,1239 @@ +# Floats Iteration 4 — Codegen + Runtime (Implementation Plan) + +> **Parent spec:** `docs/specs/2026-05-10-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`); `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 ` -o ` 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.