# 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<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.
