Files
AILang/docs/plans/0016-floats-1-schema.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

25 KiB

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

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

/// 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<S: Serializer>(value: &u64, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&format!("{:016x}", value))
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
        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:

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

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

        Literal::Int { .. } | Literal::Bool { .. } | Literal::Str { .. } => Term::App {

with:

        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:

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:

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

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

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

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

            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:

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

            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:

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

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

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

pub fn is_primitive_name(name: &str) -> bool {
    matches!(name, "Int" | "Bool" | "Str" | "Unit" | "Float")
}

In crates/ailang-core/src/primitives.rs, line 24:

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

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

### 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":"<hex>","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: <subsystem>")` 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
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.