Files
AILang/docs/plans/0019-floats-4-codegen.md
T
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

44 KiB

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.rsllvm_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.rsail 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:

/// 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:

            Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"),

with:

            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:

                Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"),

with:

                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
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:

/// 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:

/// 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:

        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:

        // 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:

        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
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:

/// 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:

        // 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:

        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:

        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:

                "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
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:

/// 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:

    // 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):

        // 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
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:

/// 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:

                // 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
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.rsail build + run + stdout-check.

  • Step 1: Write the RED test for io/print_float lowering

In crates/ailang-codegen/src/lib.rs::tests, append:

/// 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:

            "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:

{
  "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:

//! 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
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
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.