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.
44 KiB
Floats Iteration 4 — Codegen + Runtime (Implementation Plan)
Parent spec:
docs/specs/0005-floats.md(committede37366f, approved 2026-05-10) — sections A3 (codegen-side dispatch), A4 (conversions), A5 (determinism), and thecrates/ailang-codegen/+runtime/Components subsections.For agentic workers: REQUIRED SUB-SKILL: use
skills/implementto 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_typeadd"Float" => "double",type_descriptoradd"Float" => "Fl",builtin_binopeither becomes type-dispatched or is replaced bybuiltin_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 currentlyunimplemented!), type-dispatched arithmetic / comparison (line 1737),lower_eqFloat arm (line 2263), Float constantVar-intercept (line ~1228), 4 new fn-builtin lowering arms,io/print_floateffect-op arm (line 2152 region),float_to_strnamed-iterationunimplemented!. - 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_typeline ~14,type_descriptorline ~129). -
Modify:
crates/ailang-codegen/src/lib.rslines 918 + 1215 (replace 2unimplemented!("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"tosynth.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"tosynth.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 fnbuiltin_binop_typedand routelower_appthrough 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_binopshape
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_binopto 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_appat 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 i64regression 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_typedwith 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_appdispatch 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 afloat_to_str => unimplemented!()deferral marker. -
Modify:
crates/ailang-codegen/src/lib.rsIR-header section (line ~461 where@strcmpand@printfare 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.f64intrinsic
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_termTerm::Var arm — interceptnan/inf/neg_infbefore 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.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:
/// 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_floatarm
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.5 + 2.5 = 4(printf%gfor 4.0 produces4)int_to_float(42) = 42(printf%gfor 42.0 produces42)neg(1.5) = -1.5(printf%gfor -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 --workspaceis clean.cargo test --workspaceis 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.jsonbuilds viaail buildand 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_strcodegen arm carries an honest deferredunimplemented!("Floats milestone iter 5+: ...")marker — no silent fallback.lib.rs::tests::lowers_t_add_int(the existingadd i64regression) 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_strruntime-Str allocation if the orchestrator chooses to scope it in) is the next dispatch.