# Floats Iteration 1 — Schema Layer (Implementation Plan) > **Parent spec:** `docs/specs/0005-floats.md` (committed > `e37366f`, approved 2026-05-10) > > **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` > to run this plan. Steps use `- [ ]` checkboxes for tracking. **Goal:** Add the `Literal::Float { bits: u64 }` variant to the AST, serialise it as `{"bits":"<16-lowercase-hex>","kind":"float"}` in canonical JSON, register `"Float"` in the primitive-name set, and keep the workspace compiling by adding exhaustive `Literal::Float` arms across all downstream `match Literal { … }` sites with named-iteration `unimplemented!` placeholders where later iterations own the semantics. **Architecture:** The variant carries a `u64` for the IEEE-754 binary64 bit pattern. A private `hex_u64` serde helper module on `ast.rs` routes the field through the JSON *string* path (16-lowercase-hex), bypassing `serde_json::Number` so canonical bytes stay bit-stable across `serde_json` versions and so NaN / ±Inf are representable (they are not, as JSON numbers). Adding the variant breaks Rust's enum exhaustiveness at eight downstream `match Literal` sites — each gets either the right semantic arm (typecheck → `Type::float()`) or a named-iteration `unimplemented!("Floats iter N: ")` arm. **Tech Stack:** `ailang-core` (ast.rs, canonical.rs, primitives.rs, desugar.rs, pretty.rs); compile-completeness arms in `ailang-check`, `ailang-codegen`, `ailang-surface`, `ailang-prose`. --- ## Files this plan creates or modifies - Modify: `crates/ailang-core/src/ast.rs` — add private `hex_u64` serde helper module, `Literal::Float { #[serde(with = "hex_u64")] bits: u64 }` variant, `pub fn Type::float() -> Type` constructor. - Modify: `crates/ailang-core/src/canonical.rs` — refresh module-doc "no floats" comment; add canonical-bytes RED test plus bit-stability tests (-0/+0 distinct, NaN/Inf preserved, serde round-trip) in the `tests` mod. - Modify: `crates/ailang-core/src/primitives.rs` — append `"Float"` to both `is_primitive_name` and `primitive_surface_name`; extend the `predicate_and_surface_name_agree` lockstep test name list to include `"Float"`. - Modify: `crates/ailang-core/src/desugar.rs` — extend the `build_eq` OR-pattern at line 1061 to include `Literal::Float`. - Modify: `crates/ailang-core/src/pretty.rs` — add `Literal::Float { bits } => format!("(float-bits 0x{:016x})", bits)` arm at `lit_to_string` line 109. - Modify: `crates/ailang-check/src/lib.rs` — add `Literal::Float { .. } => Type::float()` arm at the two `Literal` match sites (lines 1697-1700 and 2307-2310). - Modify: `crates/ailang-codegen/src/lib.rs` — add `Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen")` at the three `Literal` match sites (lines 918-928, 1215-1227, 2357-2361). - Modify: `crates/ailang-surface/src/print.rs` — add `Literal::Float { .. } => unimplemented!("Floats milestone iter 2: surface print")` at the two `Literal` match sites (lines 564-567 and 580-588). - Modify: `crates/ailang-prose/src/lib.rs` — add `Literal::Float { .. } => unimplemented!("Floats milestone iter 5: prose")` at line 913-916. - Modify: `docs/JOURNAL.md` — append iteration-close entry. --- ## Task 1: `Literal::Float` variant + canonical hex serialization **Files:** - Modify: `crates/ailang-core/src/ast.rs:540-551` (Literal enum) and near `Type::str_()` at line 632 (Type::float() constructor) - Modify: `crates/ailang-core/src/canonical.rs:105-135` (tests mod) - Modify: 8 downstream `Literal` match sites for compile-completeness (paths listed above) - [ ] **Step 1: Write the RED canonical-bytes test** In `crates/ailang-core/src/canonical.rs`, inside `mod tests`, append: ```rust /// Iter 22-floats.1 RED: a `Literal::Float` carries the IEEE-754 /// binary64 bit pattern as a `u64`; canonical JSON encodes it as /// `{"bits":"<16-lowercase-hex>","kind":"float"}` — string path, /// NOT through `serde_json::Number` (which is not bit-stable for /// floats and cannot represent NaN / ±Inf at all). /// /// Pinned literal: `1.5_f64` → bit pattern `0x3FF8000000000000` → /// hex string `"3ff8000000000000"`. Sorted-key order is /// `bits` < `kind` lexicographically. #[test] fn float_literal_canonical_bytes() { use crate::ast::Literal; let lit = Literal::Float { bits: 0x3ff8_0000_0000_0000u64, }; let bytes = to_bytes(&lit); let s = std::str::from_utf8(&bytes).unwrap(); assert_eq!(s, r#"{"bits":"3ff8000000000000","kind":"float"}"#); } ``` - [ ] **Step 2: Run test to verify it fails (RED)** Run: `cargo test -p ailang-core float_literal_canonical_bytes` Expected: COMPILE FAIL with rustc error E0599 / E0432 / similar naming `Literal::Float` (the variant does not yet exist). - [ ] **Step 3: Add the `hex_u64` serde helper module to `ast.rs`** In `crates/ailang-core/src/ast.rs`, before the `Literal` enum declaration (around line 536), add: ```rust /// Private serde helper: emits a `u64` as a 16-character lowercase /// hex JSON string and parses the same shape back. The /// 16-character invariant covers the full `u64` range zero-padded /// (`format!("{:016x}", 0u64) == "0000000000000000"`, /// `format!("{:016x}", u64::MAX) == "ffffffffffffffff"`). Used by /// [`Literal::Float`] so float bit patterns flow through the /// canonical-JSON *string* path — guaranteeing bit stability across /// `serde_json` versions and surfacing NaN / ±Inf, which JSON /// numbers cannot represent. mod hex_u64 { use serde::{de::Error as DeError, Deserialize, Deserializer, Serializer}; pub fn serialize(value: &u64, s: S) -> Result { s.serialize_str(&format!("{:016x}", value)) } pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { let s = <&str>::deserialize(d)?; if s.len() != 16 { return Err(D::Error::custom(format!( "Float bits: expected 16 hex chars, got {}", s.len() ))); } u64::from_str_radix(s, 16).map_err(D::Error::custom) } } ``` - [ ] **Step 4: Add the `Literal::Float` variant** In `crates/ailang-core/src/ast.rs`, replace the `Literal` enum declaration: ```rust /// A literal value. /// /// The JSON discriminator is the `kind` field. `Unit` carries no /// payload and serializes as `{"kind":"unit"}`. `Float` carries a /// `u64` IEEE-754 binary64 bit pattern, serialized via the /// [`hex_u64`] helper as a 16-character lowercase hex string — /// canonical-JSON bytes therefore stay bit-stable for hashing and /// can represent NaN / ±Inf (which JSON numbers cannot). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum Literal { /// Signed 64-bit integer. Int { value: i64 }, /// Boolean. Bool { value: bool }, /// UTF-8 string. Str { value: String }, /// The unit value `()`. Unit, /// IEEE-754 binary64 (LLVM `double`) carried as its bit pattern. /// See [`hex_u64`] for the serialization rationale. Float { #[serde(with = "hex_u64")] bits: u64, }, } ``` - [ ] **Step 5: Add the `Type::float()` convenience constructor** In `crates/ailang-core/src/ast.rs`, in the `impl Type { … }` block (immediately after `pub fn str_()` at line 632), append: ```rust /// Convenience constructor for `Float` (no args). IEEE-754 /// binary64. Parallel to [`Type::int`] / [`Type::bool_`]. pub fn float() -> Type { Type::Con { name: "Float".into(), args: vec![] } } ``` - [ ] **Step 6: Add compile-completeness arm in `desugar.rs`** In `crates/ailang-core/src/desugar.rs`, line 1061, replace the existing OR-pattern: ```rust Literal::Int { .. } | Literal::Bool { .. } | Literal::Str { .. } => Term::App { ``` with: ```rust Literal::Int { .. } | Literal::Bool { .. } | Literal::Str { .. } | Literal::Float { .. } => Term::App { ``` - [ ] **Step 7: Add compile-completeness arm in `pretty.rs`** In `crates/ailang-core/src/pretty.rs`, line 109's `lit_to_string`, add the `Literal::Float` arm before the closing brace: ```rust fn lit_to_string(l: &Literal) -> String { match l { Literal::Int { value } => value.to_string(), Literal::Bool { value } => value.to_string(), Literal::Str { value } => { // serde_json escapes for us; the result is a valid // JSON string literal, which is enough as our canonical form. serde_json::to_string(value).unwrap() } Literal::Unit => "()".to_string(), Literal::Float { bits } => format!("(float-bits 0x{:016x})", bits), } } ``` - [ ] **Step 8: Add compile-completeness arms in `crates/ailang-check/src/lib.rs`** Two sites. At line 1697-1700: ```rust Literal::Int { .. } => Type::int(), Literal::Bool { .. } => Type::bool_(), Literal::Str { .. } => Type::str_(), Literal::Unit => Type::unit(), Literal::Float { .. } => Type::float(), ``` At line 2307-2310 (same shape — typecheck literal → type): ```rust Literal::Int { .. } => Type::int(), Literal::Bool { .. } => Type::bool_(), Literal::Str { .. } => Type::str_(), Literal::Unit => Type::unit(), Literal::Float { .. } => Type::float(), ``` - [ ] **Step 9: Add compile-completeness arms in `crates/ailang-codegen/src/lib.rs`** Three sites. At line 918-928 (constant lowering): ```rust let (val_ty, val) = match lit { Literal::Int { value } => ("i64".to_string(), value.to_string()), Literal::Bool { value } => ( "i1".to_string(), if *value { "true".into() } else { "false".into() }, ), Literal::Unit => ("i8".to_string(), "0".to_string()), Literal::Str { value } => { let g = self.intern_string("str", value); ("ptr".to_string(), format!("@{g}")) } Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"), }; ``` At line 1215-1227 (term lowering): ```rust Term::Lit { lit } => Ok(match lit { Literal::Int { value } => (value.to_string(), "i64".into()), Literal::Bool { value } => ( if *value { "true".into() } else { "false".into() }, "i1".into(), ), Literal::Str { value } => { // Create global constant; in opaque-pointer LLVM, // `@name` is directly a valid `ptr`. let g = self.intern_string("str", value); (format!("@{g}"), "ptr".into()) } Literal::Unit => ("0".into(), "i8".into()), Literal::Float { .. } => unimplemented!("Floats milestone iter 4: codegen"), }), ``` At line 2357-2361 (synth_with_extras → Type — semantic arm, not placeholder): ```rust Term::Lit { lit } => Ok(match lit { Literal::Int { .. } => Type::int(), Literal::Bool { .. } => Type::bool_(), Literal::Str { .. } => Type::str_(), Literal::Unit => Type::unit(), Literal::Float { .. } => Type::float(), }), ``` - [ ] **Step 10: Add compile-completeness arms in `crates/ailang-surface/src/print.rs`** Two sites. At line 564-567: ```rust fn write_lit(out: &mut String, lit: &Literal) { match lit { Literal::Int { value } => out.push_str(&value.to_string()), Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }), Literal::Str { value } => write_string_lit(out, value), Literal::Unit => out.push_str("(lit-unit)"), Literal::Float { .. } => unimplemented!("Floats milestone iter 2: surface print"), } } ``` At line 580-588 (inside `write_pattern` Pattern::Lit arm): ```rust match lit { Literal::Int { value } => out.push_str(&value.to_string()), Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }), Literal::Str { value } => write_string_lit(out, value), Literal::Unit => { // Unit is not a valid pat-lit; fall back to a bare unit-lit // form. This is unreachable for any well-formed AST that // came through the typechecker. out.push_str("(lit-unit)"); } Literal::Float { .. } => unimplemented!("Floats milestone iter 2: surface print"), } ``` - [ ] **Step 11: Add compile-completeness arm in `crates/ailang-prose/src/lib.rs`** At line 913-916: ```rust Literal::Int { value } => out.push_str(&value.to_string()), Literal::Bool { value } => out.push_str(if *value { "true" } else { "false" }), Literal::Str { value } => write_string_lit(out, value), Literal::Unit => out.push_str("()"), Literal::Float { .. } => unimplemented!("Floats milestone iter 5: prose"), ``` - [ ] **Step 12: Verify the workspace builds** Run: `cargo build --workspace` Expected: clean build, zero new warnings. - [ ] **Step 13: Verify the RED test now passes (GREEN)** Run: `cargo test -p ailang-core float_literal_canonical_bytes` Expected: PASS — exactly one test reported, GREEN. - [ ] **Step 14: Verify no regression across the workspace** Run: `cargo test --workspace` Expected: all existing tests PASS. The pre-existing `def_hash` regression tests in `crates/ailang-core/src/hash.rs` (which pin `db33f57cb329935e` for `sum.sum` and `b082192bd0c99202` for `IntList`) MUST stay GREEN — adding `Literal::Float` to the enum must not change the canonical bytes of any pre-existing fixture (neither `sum.ail.json` nor `list.ail.json` contains a Float literal). - [ ] **Step 15: Commit** ```bash git add crates/ailang-core/src/ast.rs \ crates/ailang-core/src/canonical.rs \ crates/ailang-core/src/desugar.rs \ crates/ailang-core/src/pretty.rs \ crates/ailang-check/src/lib.rs \ crates/ailang-codegen/src/lib.rs \ crates/ailang-surface/src/print.rs \ crates/ailang-prose/src/lib.rs git commit -m "floats iter 1.1: Literal::Float variant + canonical hex serialization" ``` --- ## Task 2: Register `"Float"` as a primitive type name **Files:** - Modify: `crates/ailang-core/src/primitives.rs` (entire file is 52 LoC; both functions plus the lockstep test). - [ ] **Step 1: Extend the lockstep test (RED)** In `crates/ailang-core/src/primitives.rs`, modify the test name list at line 44 to include `"Float"`: ```rust #[test] fn predicate_and_surface_name_agree() { for name in ["Int", "Bool", "Str", "Unit", "Float", "List", "Foo", "", "int"] { assert_eq!( is_primitive_name(name), primitive_surface_name(name).is_some(), "lockstep violation for {name:?}" ); } } ``` - [ ] **Step 2: Run test to verify it fails (RED)** Run: `cargo test -p ailang-core predicate_and_surface_name_agree` Expected: FAIL with `lockstep violation for "Float"` — `is_primitive_name("Float") == false` but the test now expects it to be `true` AND requires `primitive_surface_name("Float")` to be `Some(_)`. The exact assertion that fires: the `assert_eq!` line (`false != true.is_some()` evaluates `false != false = false` — wait, both are `false`, so this would not actually fail until we also flip the test expectation; instead, the test design is "they must AGREE", and since both are currently `false` for `"Float"`, agreement still holds. Therefore the RED test must be tightened: append a line that *explicitly asserts* `"Float"` is recognised. Append after the loop: ```rust // Float is a primitive (Floats milestone, iter 1). assert!(is_primitive_name("Float"), "Float must be a primitive"); assert_eq!( primitive_surface_name("Float"), Some("Float"), "Float surface name must be \"Float\"" ); ``` Re-run: `cargo test -p ailang-core predicate_and_surface_name_agree` Expected: FAIL on `assert!(is_primitive_name("Float"))`. - [ ] **Step 3: Add `"Float"` to both functions (GREEN)** In `crates/ailang-core/src/primitives.rs`, line 17: ```rust pub fn is_primitive_name(name: &str) -> bool { matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float") } ``` In `crates/ailang-core/src/primitives.rs`, line 24: ```rust pub fn primitive_surface_name(name: &str) -> Option<&'static str> { match name { "Int" => Some("Int"), "Bool" => Some("Bool"), "Str" => Some("Str"), "Unit" => Some("Unit"), "Float" => Some("Float"), _ => None, } } ``` - [ ] **Step 4: Verify the test now passes (GREEN)** Run: `cargo test -p ailang-core predicate_and_surface_name_agree` Expected: PASS. Run also: `cargo test --workspace` Expected: all green. (Adding a primitive name should not change any existing fixture — no existing fixture references `"Float"` as a type constructor.) - [ ] **Step 5: Commit** ```bash git add crates/ailang-core/src/primitives.rs git commit -m "floats iter 1.2: register Float as a primitive type name" ``` --- ## Task 3: Bit-stability tests for `Literal::Float` **Files:** - Modify: `crates/ailang-core/src/canonical.rs` `tests` mod — append four tests pinning the spec's IEEE-bit-stability guarantees (A1 + A5). - [ ] **Step 1: Add the four bit-stability tests** In `crates/ailang-core/src/canonical.rs`, inside `mod tests`, append: ```rust /// A5 guarantee: -0.0 and +0.0 are distinct bit patterns and /// therefore distinct Form-A literals (they hash distinctly even /// though IEEE-`==` will report them equal at the value level /// when arithmetic comparison ships). #[test] fn negative_zero_and_positive_zero_serialise_distinctly() { use crate::ast::Literal; let pos = Literal::Float { bits: 0x0u64 }; let neg = Literal::Float { bits: 0x8000_0000_0000_0000u64 }; let pos_bytes = to_bytes(&pos); let neg_bytes = to_bytes(&neg); assert_ne!(pos_bytes, neg_bytes, "+0 and -0 must hash distinctly"); assert_eq!( std::str::from_utf8(&pos_bytes).unwrap(), r#"{"bits":"0000000000000000","kind":"float"}"# ); assert_eq!( std::str::from_utf8(&neg_bytes).unwrap(), r#"{"bits":"8000000000000000","kind":"float"}"# ); } /// A1 guarantee: a NaN bit pattern survives canonicalisation /// without collapse to JSON `null` (which is what the `serde_json` /// number path does for non-finite floats — and the reason /// `Literal::Float` routes through the *string* path). #[test] fn nan_bits_preserved() { use crate::ast::Literal; let qnan = Literal::Float { bits: 0x7ff8_0000_0000_0000u64 }; let bytes = to_bytes(&qnan); assert_eq!( std::str::from_utf8(&bytes).unwrap(), r#"{"bits":"7ff8000000000000","kind":"float"}"# ); } /// A1 guarantee: ±Inf bit patterns survive canonicalisation — /// JSON numbers cannot represent infinity at all, the string path /// must. #[test] fn inf_bits_preserved() { use crate::ast::Literal; let pos_inf = Literal::Float { bits: 0x7ff0_0000_0000_0000u64 }; let neg_inf = Literal::Float { bits: 0xfff0_0000_0000_0000u64 }; assert_eq!( std::str::from_utf8(&to_bytes(&pos_inf)).unwrap(), r#"{"bits":"7ff0000000000000","kind":"float"}"# ); assert_eq!( std::str::from_utf8(&to_bytes(&neg_inf)).unwrap(), r#"{"bits":"fff0000000000000","kind":"float"}"# ); } /// Round-trip: serialise then deserialise via `serde_json` — bits /// preserved bit-for-bit. #[test] fn float_literal_serde_roundtrip() { use crate::ast::Literal; let cases = [ 0x0u64, 0x8000_0000_0000_0000u64, 0x3ff8_0000_0000_0000u64, // 1.5 0x7ff8_0000_0000_0000u64, // qNaN 0x7ff0_0000_0000_0000u64, // +Inf 0xfff0_0000_0000_0000u64, // -Inf 0xffff_ffff_ffff_ffffu64, // saturated ]; for &bits in &cases { let lit = Literal::Float { bits }; let json = serde_json::to_string(&lit).unwrap(); let back: Literal = serde_json::from_str(&json).unwrap(); match back { Literal::Float { bits: got } => assert_eq!( got, bits, "round-trip lost bits for {:#018x}: json={}", bits, json ), other => panic!("expected Literal::Float, got {:?}", other), } } } ``` - [ ] **Step 2: Verify all four tests PASS (no impl change needed)** Run: `cargo test -p ailang-core --test '*' canonical -- \ negative_zero_and_positive_zero_serialise_distinctly \ nan_bits_preserved inf_bits_preserved \ float_literal_serde_roundtrip` Or simpler: Run: `cargo test -p ailang-core canonical::tests` Expected: all canonical tests GREEN, including the four new ones. - [ ] **Step 3: Commit** ```bash git add crates/ailang-core/src/canonical.rs git commit -m "floats iter 1.3: bit-stability tests for Literal::Float" ``` --- ## Task 4: Refresh `canonical.rs` module-doc comment **Files:** - Modify: `crates/ailang-core/src/canonical.rs:13-14` (module-level doc comment). - [ ] **Step 1: Replace the "no floats" sentence** In `crates/ailang-core/src/canonical.rs`, lines 12-14, replace: ``` //! number-formatting follows whatever `serde_json::Number::to_string` //! does, which is fine for the AILang AST (integers and short strings; //! no floats). ``` with: ``` //! Number formatting follows whatever `serde_json::Number::to_string` //! does — used only for the AST integer literals. Float literals do //! not go through the number path; [`crate::ast::Literal::Float`] //! carries its IEEE-754 bit pattern as a `u64` and serialises via a //! 16-character lowercase hex *string*, which the canonical writer //! treats like any other string. The string path is what makes float //! canonical bytes bit-stable across `serde_json` versions and what //! lets NaN / ±Inf round-trip at all (they cannot, as JSON numbers). ``` - [ ] **Step 2: Verify rustdoc still builds** Run: `cargo doc --no-deps -p ailang-core 2>&1 | head -40` Expected: no NEW warnings on `canonical.rs` (the module's pre-existing warning baseline must not increase). - [ ] **Step 3: Commit** ```bash git add crates/ailang-core/src/canonical.rs git commit -m "floats iter 1.4: refresh canonical.rs 'no floats' doc comment" ``` --- ## Task 5: Iteration close — JOURNAL entry **Files:** - Modify: `docs/JOURNAL.md` — append entry at top under today's date heading. - [ ] **Step 1: Append the iteration-close entry** In `docs/JOURNAL.md`, append a new section under today's date: ```markdown ### Floats milestone — iter 1: schema layer Added `Literal::Float { bits: u64 }` as the fifth `Literal` variant. Bits are an IEEE-754 binary64 bit pattern, serialised as a 16-character lowercase hex *string* (`{"bits":"","kind":"float"}`) via a private `hex_u64` serde helper on `ast.rs`. Routing through the JSON string path bypasses `serde_json::Number::to_string` (not bit-stable across `serde_json` versions for floats) and lets NaN / ±Inf survive canonicalisation at all (they collapse to `null` as JSON numbers). Adding the variant broke Rust enum exhaustiveness at eight downstream `match Literal { … }` sites. Each got either the permanent semantic arm (`ailang-check` typecheck: `Literal::Float { .. } => Type::float()`) or a named-iteration `unimplemented!("Floats milestone iter N: ")` arm (`ailang-codegen` → iter 4, `ailang-surface` print → iter 2, `ailang-prose` → iter 5). The arms are honest about which iteration owns the semantics. `Float` is now registered as a primitive type name in `primitives.rs`; the lockstep test is extended to cover it. The four bit-stability tests pin the A1 / A5 spec guarantees: `-0` ≠ `+0` at the canonical-bytes level, NaN bits preserved, ±Inf bits preserved, serde round-trip is bit-exact for the saturating boundary values. Pre-existing `def_hash` regression hashes (`db33f57cb329935e` / `b082192bd0c99202`) stay GREEN — adding a Literal variant does not perturb any pre-existing canonical bytes. ``` - [ ] **Step 2: Commit** ```bash git add docs/JOURNAL.md git commit -m "floats iter 1: JOURNAL — iteration close" ``` --- ## Iteration acceptance - [ ] `cargo build --workspace` is clean. - [ ] `cargo test --workspace` is GREEN (no pre-existing test regressed; the pre-existing `def_hash` regression hashes stay bit-identical). - [ ] `cargo test -p ailang-core float_literal_canonical_bytes` and the four bit-stability tests are GREEN. - [ ] `cargo test -p ailang-core predicate_and_surface_name_agree` is GREEN with `"Float"` in the recognised set. - [ ] `crates/ailang-core/src/canonical.rs:14` no longer says "no floats". - [ ] JOURNAL entry committed. - [ ] No file touched outside the nine listed in the file map above. When all five tasks are committed and the acceptance checklist is green, hand back to the orchestrator. Iteration 2 (Surface lex / parse / print round-trip) is the next dispatch.