From cdc9d64169bd18bbc709f2e492e6d02fb0f680a8 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 10 May 2026 14:26:17 +0200 Subject: [PATCH] =?UTF-8?q?plan:=20floats=20iter=201=20=E2=80=94=20schema?= =?UTF-8?q?=20layer=20(Literal::Float=20+=20canonical=20hex=20serde=20+=20?= =?UTF-8?q?primitive=20registration)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/2026-05-10-floats-1-schema.md | 719 +++++++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 docs/plans/2026-05-10-floats-1-schema.md diff --git a/docs/plans/2026-05-10-floats-1-schema.md b/docs/plans/2026-05-10-floats-1-schema.md new file mode 100644 index 0000000..b30c7da --- /dev/null +++ b/docs/plans/2026-05-10-floats-1-schema.md @@ -0,0 +1,719 @@ +# Floats Iteration 1 — Schema Layer (Implementation Plan) + +> **Parent spec:** `docs/specs/2026-05-10-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.