# Floats — Design Spec **Date:** 2026-05-10 **Status:** Draft — awaiting user spec review **Authors:** Brummel (orchestrator) + Claude ## Goal Introduce `Float` as a fourth zero-arity primitive type, alongside `Int`, `Bool`, `Str`, `Unit`. `Float` is **IEEE-754 binary64** (LLVM `double`). One float type ships, no `f32` variant. Status quo (2026-05-10): - `crates/ailang-core/src/primitives.rs:17` lists primitives as `Int | Bool | Str | Unit` — no Float. - `crates/ailang-core/src/canonical.rs:14` doc comment explicitly says "no floats" because `serde_json::Number::to_string` is not bit-stable across versions for floating-point values. - `docs/DESIGN.md:1625` mentions `Float` once in an annotation example — dead text with no implementation behind it. After this milestone: - An LLM-author can write `1.5`, `1.5e3`, `(fadd x y)`, `(int_to_float n)` in surface form (B); the canonical form (A) carries float literals as bit-pattern hex strings; codegen emits IEEE-754 binary64 ops via LLVM `double`. - The eventual Post-22 Prelude milestone can ship `Show Float`, but **not** `Eq Float` or `Ord Float` — Float's IEEE-conformant equality (NaN ≠ NaN) and partial ordering are real and surface through builtins, not through total typeclass instances. This milestone is **scoped to the primitive itself** — literal shape, lexer/parser/printer, type, builtins, codegen, IO, documentation. Typeclass wiring (`Show` / `Eq` / `Ord` instances, `Num` polymorphism) is out of scope and lives in the next milestone(s). ## Architecture Five load-bearing decisions shape every component below. ### A1 — Form-A literal shape: bit-pattern hex string ```json { "kind": "float", "bits": "4014000000000000" } ``` `bits` is the lowercase 16-character hex representation of the IEEE-754 binary64 bit pattern as a `u64` (big-endian byte ordering). This is the *only* permitted form; serialising `1.5` as a JSON number is forbidden. Rationale: - **Hash stability.** Form-A bytes feed `def_hash` / `module_hash` via `canonical::to_bytes`. Today's number path uses `serde_json::Number::to_string`, which is not bit-stable across `serde_json` versions for floats and which cannot represent NaN / ±Inf in JSON at all (they collapse to `null`). Routing floats through the *string* path preserves bit-exact determinism without changing `canonical.rs`'s number arm. - **NaN / -0 / Inf are explicit.** `+0.0` is `0000000000000000`, `-0.0` is `8000000000000000`, the two are distinct bit patterns and therefore distinct Form-A literals; quiet NaN is `7ff8000000000000`; ±Inf are `7ff0000000000000` / `fff0000000000000`. All preserved bit-for-bit by hash. - **No ambiguity.** `0.1` parsed in surface lex becomes `bits = "3fb999999999999a"` once; round-tripping back to surface `0.1` is the printer's job, but the canonical truth is always the bits. The `canonical.rs:14` "no floats" comment will be updated to "floats only via the string path (bit-pattern hex)". ### A2 — Surface lexer: decimal-point or exponent required A float literal in form (B) is one of: - `.` — e.g. `1.5`, `0.0`, `100.0` - `.e[+-]?` — e.g. `1.5e3`, `2.0e-10` - `e[+-]?` — e.g. `1e10`, `3e-5` Out of scope (rejected at lex time): - Hex floats `0x1.8p4` — adds lexer complexity for no LLM-author benefit (GPT/Claude don't reach for hex float literals unprompted). - Bare leading/trailing dot: `.5`, `5.` — the unambiguous form (`0.5`, `5.0`) is one keystroke longer; rejecting the short forms removes a class of "is this a float or a member access?" ambiguity. - NaN / Inf literals — these are bit patterns, not surface syntax. A future Float stdlib can expose `Float.nan` / `Float.infinity` constants; for now an LLM that needs a NaN literal in form-A can write the hex bits directly. Negative float literals follow today's negative-int rule (`crates/ailang-surface/src/lex.rs:175` accepts `-` followed by a digit as part of a numeric token), so `-1.5` lexes as a single `Tok::Float`. ### A3 — Builtin operators: polymorphic over `{Int, Float}` via codegen-dispatch The arithmetic and comparison operators that today are monomorphic-Int (`+`, `-`, `*`, `/`, `<`, `<=`, `>`, `>=`) are **widened to polymorphic** with the same shape and dispatch mechanism the existing `==` / `!=` already use: ``` + : forall a. (a, a) -> a -- Int|Float; reject otherwise - : forall a. (a, a) -> a -- Int|Float * : forall a. (a, a) -> a -- Int|Float / : forall a. (a, a) -> a -- Int|Float < : forall a. (a, a) -> Bool -- Int|Float <= : forall a. (a, a) -> Bool -- Int|Float > : forall a. (a, a) -> Bool -- Int|Float >= : forall a. (a, a) -> Bool -- Int|Float == : forall a. (a, a) -> Bool -- existing; Float arm added != : forall a. (a, a) -> Bool -- existing; Float arm added % : (Int, Int) -> Int -- stays monomorphic (no fmod yet) ``` Codegen dispatches on the resolved argument type at the call site, parallel to today's `==` path (`crates/ailang-codegen/src/ lib.rs:2244-2266`): | Op | Int arm | Float arm | |---|---|---| | `+` | `add i64` | `fadd double` | | `-` | `sub i64` | `fsub double` | | `*` | `mul i64` | `fmul double` | | `/` | `sdiv i64` | `fdiv double` | | `==` | existing `icmp eq i64` / `i1` / `@strcmp` / `i1 1` | `fcmp oeq double` | | `!=` | existing `icmp ne i64` etc. | `fcmp une double` | | `<` | `icmp slt i64` | `fcmp olt double` | | `<=` | `icmp sle i64` | `fcmp ole double` | | `>` | `icmp sgt i64` | `fcmp ogt double` | | `>=` | `icmp sge i64` | `fcmp oge double` | **Note on `!=`:** the LLVM `fcmp` predicate for IEEE-`!=` is `une` ("unordered or not equal"), **not** `one` ("ordered and not equal"). `one` would return `false` for `nan != nan`, which contradicts user-fixed constraint #2 (NaN ≠ NaN per IEEE). `une` matches Rust's `f64::ne` and IEEE 754 `!=` exactly. **Note on `==`:** `fcmp oeq` returns `false` whenever either operand is NaN, which is the IEEE-`==` semantics we want. Bit-level NaN-detection is separate and uses `is_nan` (below) or `fcmp uno`. Non-{Int,Float} resolutions for `+`/`-`/`*`/`/`/`<`/`<=`/`>`/`>=` get rejected at codegen with the same diagnostic shape the `==` path uses today (`"+ not supported for type X"`). The reject keeps the polymorphism honest — there is no silent fallback, no string-concatenation-via-`+`, no boolean-arithmetic-via-`+`. Rationale: - **Mainstream alignment.** Every LLM-relevant programming language (Rust, Java, C, Python, JavaScript, Haskell, Swift, OCaml-via-typeclass-like-extension) accepts `1.5 + 2.5`. An AILang where the LLM-author has to write `(fadd 1.5 2.5)` is a high-friction first-try-error surface. The user-facing decision (this conversation, 2026-05-10) chose Mainstream alignment over the explicit-naming purity of an earlier draft. - **Precedent already in tree.** The `==` builtin is `forall a. (a, a) -> Bool` with a hardcoded Int|Bool|Str|Unit codegen dispatch and explicit reject for ADT/Fn equality (`builtins.rs:75-77`). The widening of `+`/`-`/etc. uses the *same* mechanism — no new architectural surface, just more arms in the existing dispatch helper. - **Typeclass migration story.** When typeclasses ship (Post-22 Prelude + 22c), the hardcoded `Int|Float` spec-case in the typechecker can be cleanly replaced by a `Num a` constraint. Surface (`(+ 1.5 2.5)`) does not change. The spec-case is transitional, not load-bearing. - **`%` stays monomorphic.** IEEE `frem` (LLVM op for `fmod`) has subtle semantics (sign-of-result-follows-dividend, ±0 / ±Inf edge cases) that deserve their own decision; out of scope for this milestone. Other new builtins: ``` neg : forall a. (a) -> a -- Int|Float; reject otherwise int_to_float : (Int) -> Float float_to_int_truncate : (Float) -> Int float_to_str : (Float) -> Str is_nan : (Float) -> Bool -- LLVM `fcmp uno double %x, %x` ``` The unary `neg` is widened the same way arithmetic ops are (today there is no `neg` builtin — unary minus is desugared from the surface lexer's `-` rule into a literal or via `(- 0 x)`-style desugar; the spec adds an explicit `neg` so Float negation has a name without the `(- 0.0 x)` workaround, which is wrong for `-0.0`). The plan picks the desugar/builtin boundary. New builtin float constants (always-in-scope `Term::Var` references, parallel to today's `__unreachable__`): ``` nan : Float -- bits 7ff8000000000000 (canonical quiet NaN) inf : Float -- bits 7ff0000000000000 neg_inf : Float -- bits fff0000000000000 ``` Rationale for the constants: - Every Mainstream language exposes them (`f64::NAN`, `Double.NaN`, `float('nan')`, `NaN`, `Double.nan`). Withholding them while shipping the IEEE-conformant semantics that *make them necessary* is a pointless friction. - Hex bits via Form-A still works as the ground truth, but surface-level `(var nan)` lets the LLM-author write code that reads like every other language's float code. - Names are lowercase (`nan`, not `NaN`) so they're regular identifiers, no surface-lex special-case. `neg_inf` instead of `-inf` because `-` cannot start a value identifier (the surface-lex `-` rule applies to numeric literals only). Effect ops (added): ``` io/print_float : (Float) -> Unit !IO -- new ``` ### A4 — Conversions: total, foot-gun-free - `int_to_float : (Int) -> Float` — LLVM `sitofp double`. Exact for `|n| < 2^53`, round-to-nearest-even otherwise. - `float_to_int_truncate : (Float) -> Int` — saturating truncation toward zero; `NaN → 0`. Matches Rust `as i64` semantics (since 1.45). Total, no UB, no `Maybe Int` wrapper. Lowered as LLVM `fptosi.sat.i64`. - `float_to_str : (Float) -> Str` — shortest round-trippable decimal. The runtime C glue calls a `dtoa`-style routine (we link an existing `ryu`-equivalent implementation; vendoring vs upstream is a plan-time decision). A hypothetical `float_to_int_checked : (Float) -> Maybe Int` variant would be safer than an unchecked `fptosi`, but the saturating truncate is *also* safe and is one less ADT construction at the call site. If a future use case needs the distinction (e.g. NaN-detection without a dedicated `is_nan` builtin), revisit then. ### A5 — Determinism contract Spec language for `docs/DESIGN.md` "Float semantics" section (added by this milestone): - **Guaranteed:** every individual builtin (`fadd`/`fsub`/`fmul`/`fdiv`/`fneg`/`flt`/...) lowers to a single LLVM IR instruction (`fadd double`, `fsub double`, ...). On a fixed `(target triple, LLVM version)` pair, the bit pattern of the result of any single op is reproducible. - **Guaranteed:** NaN and ±Inf propagate per IEEE 754 (no silent collapse to zero, no trap). - **Guaranteed:** -0 and +0 are distinct bit patterns at the literal / hash / equality-bit level (`==` returns `true` comparing `-0.0 == 0.0` per IEEE, but they hash distinctly in Form-A — this is the correct asymmetry). - **Unspecified:** FMA contraction (LLVM may fold `fadd (fmul a b) c` into `fma a b c`); reassociation; subnormal flushing modes if the target enables FTZ/DAZ; the exact NaN bit pattern produced by an op (any qNaN bit pattern is conformant). These are the Rust / Swift / standard-LLVM defaults, not research-grade reproducibility guarantees. The stronger guarantee (e.g. Pythonic `float.fromhex`-level bit reproducibility across ops) would require LLVM `-ffp-contract=off` plus per-op intrinsic selection — out of scope; revisit only if a real use case appears. ## Components Touched files, by crate. Sizes given for scope estimation; see the plan for iteration boundaries. ### `crates/ailang-core/` - `ast.rs` (785 LoC) — add `Literal::Float { bits: u64 }` variant. Serde `rename_all = "lowercase"` already gives us the `"kind": "float"` discriminator. The `bits` field needs a custom `Serialize`/`Deserialize` to emit/parse the 16-character lowercase hex string (Serde's default for `u64` is decimal). Add `Type::float()` convenience constructor alongside `Type::int()`/`Type::bool_()`/`Type::unit()`. - `primitives.rs` (52 LoC) — append `"Float"` to both `is_primitive_name` and `primitive_surface_name`. The lockstep test there enforces both are updated; extend the test sample to include `"Float"`. - `canonical.rs` (200+ LoC) — **no logic change**. The float literal goes through the `String` path because `bits` is serialised as a hex string, not a JSON number. Update the module-level "no floats" doc comment to reflect the new reality ("floats encoded as bit-pattern hex strings; the number path is still int-only"). - `desugar.rs` (2889 LoC) — add `Literal::Float` branch to whatever match-arms handle `Literal::Int` for desugar passthrough (likely a small, mechanical change). - `pretty.rs` (204 LoC) — add `Literal::Float` arm; render as shortest round-trippable decimal (mirrors what `float_to_str` will do at runtime). ### `crates/ailang-surface/` - `lex.rs` (264 LoC) — extend the digit-lead lexer arm. Today it parses `` as `Tok::Int(i64)`. New: peek for `.` or `e`/`E` after the integer part; if present, parse the full float form (per A2), `f64::from_str`, then `f64::to_bits()` to produce `Tok::Float(u64)`. New error variant `LexError::InvalidFloat { literal, start }` parallel to the existing `InvalidInteger`. - `parse.rs` (2406 LoC) — accept `Tok::Float(bits)` where a literal is allowed; wrap as `Term::Lit { lit: Literal::Float { bits } }`. - `print.rs` (734 LoC) — `write_lit` arm for `Literal::Float`: reconstruct `f64::from_bits(bits)`, then write shortest round-trippable decimal. **Output must be unambiguously a float in the surface lexer** — the printer always emits at least one of `.` or `e`/`E`, so `1.0` prints as `"1.0"` (or `"1e0"`), never as `"1"`. Otherwise `lex(print(1.0))` would re-parse as `Tok::Int(1)` and the round-trip property breaks. Round-trip property: `lex(print(L)) == L` for every Float literal — this becomes a regression test. Special-case bit patterns: - `+inf`, `-inf`, NaN cannot be expressed in surface form (per A2 — no NaN/Inf surface literal). The printer for these cases emits a clear non-round-trippable token like `(float-bits "7ff0000000000000")` or panics; the milestone picks the panic path, since a Float literal that arrived in the AST as NaN/Inf must have come from Form-A directly (surface lex cannot produce it), and the printer's job is surface output, not a Form-A escape hatch. Plan-time refinement. ### `crates/ailang-check/` - `lib.rs` (4457 LoC) — `Literal::Float` is well-typed at `Type::float()`. Add the one-arm extension to whatever `Term::Lit` typing helper exists. - `builtins.rs` (183 LoC) — three classes of change: 1. **Widen** the existing arithmetic and comparison entries `+`/`-`/`*`/`/`/`<`/`<=`/`>`/`>=` from monomorphic Int to `forall a. (a, a) -> a` (or `... -> Bool`) — same shape as today's `==` entry. Existing `==`/`!=` entries unchanged in signature; their dispatch arm gets extended codegen-side. 2. **Install** the new builtins: `neg` (polymorphic), `int_to_float`, `float_to_int_truncate`, `float_to_str`, `is_nan`, plus the three constants `nan`, `inf`, `neg_inf` (each a `Term::Var`-resolvable global at `Type::float()`, parallel to `__unreachable__`). 3. **Install** the new effect op `io/print_float`. Extend `list()` accordingly so `ail builtins` reflects every added entry. The `list()` strings serve as the LLM-author's contract — they must be accurate after this iteration. ### `crates/ailang-codegen/` - `synth.rs` (218 LoC) — five sites mirroring the Float primitive into the codegen-side type/op tables: 1. `llvm_type` (line 14): add `"Float" => Ok("double".into())` arm parallel to the existing Int/Bool/Str/Unit arms. 2. `builtin_ail_type` (line 61): widen the `+`/`-`/`*`/`/`, `<`/`<=`/`>`/`>=`, `!=` entries from `int_int_int` / `int_int_bool` to the polymorphic `forall a. (a, a) -> a` / `forall a. (a, a) -> Bool` shape (mirrors what `check/builtins.rs` installs). Add new entries for `neg` (polymorphic), `int_to_float`, `float_to_int_truncate`, `float_to_str`, `is_nan`, `nan`/`inf`/`neg_inf` (the constants resolve as zero-arg "values", not fns — the branch returns the bare `Type::float()` like `__unreachable__` returns `forall a. a`). 3. `builtin_effect_op_ret` (line 118): add `"io/print_float" => Type::unit()`. 4. `type_descriptor` (line 129): add `"Float" => "Fl"` arm. Two letters because the single letter `F` is taken as the ADT-name prefix (`FList`, `FFoo`); `Fl` is unambiguous and short. 5. `builtin_binop` (line 167): this monomorphic-Int table becomes a transitional artefact. Either convert it into a type-dispatched helper that takes the operand `Type` and returns the matching `(instr, llvm_ty)` pair, or remove the table entirely and let the new dispatch helper in `lib.rs` handle every site. Plan-time call. - `lib.rs` (2877 LoC) — seven lowering changes: 1. **Float literal** (`Literal::Float { bits }`) → emit a `double` constant. The idiomatic LLVM form for a bit-exact float constant is the hex-float literal `double 0x4014000000000000` (LangRef: "the only time hexadecimal floating point constants are required ... is when a floating point constant must be emitted but it cannot be represented as a decimal floating point number in a reasonable number of digits", which covers NaN, ±Inf, and any non-trivial decimal). Use this form uniformly so every Float literal in the IR has the same shape. Sites: every place `Literal::Int { value }` is matched today (lines 918, 1215). 2. **Widen arithmetic dispatch.** `+`/`-`/`*`/`/` are today lowered as monomorphic Int instructions (line 1745 region) using the `synth::builtin_binop` table. Convert the lowering site into a type-dispatched helper analogous to the existing `==` dispatch (`lib.rs:2244-2266`): Int arm emits `add/sub/mul/sdiv i64`, Float arm emits `fadd/fsub/fmul/fdiv double`, anything else fails with `" not supported for type X"`. The existing test at `lib.rs:2627-2630` (which pins `define i64 @ail_t_add(i64 %arg_a, i64 %arg_b)` and `add i64 %arg_a, %arg_b`) must stay GREEN. 3. **Widen comparison dispatch.** `<`/`<=`/`>`/`>=`/`!=` get the same treatment: Int arm `icmp s{lt,le,gt,ge} i64` / `icmp ne i64`, Float arm `fcmp o{lt,le,gt,ge} double` / **`fcmp une double` for `!=`** (NOT `fcmp one` — `one` is "ordered and not equal" and returns `false` for `nan != nan`, which violates IEEE; `une` is "unordered or not equal" and matches IEEE / Rust). 4. **`==` Float arm** in the existing dispatch (line 2263 region) → `fcmp oeq double` for the `Float` case, alongside the existing arms. (`!=` is handled under #3 above.) 5. **`neg` polymorphic** — Int arm `sub i64 0, %x`, Float arm `fneg double %x`. The `fneg` instruction (LLVM 8+) correctly handles `-0.0` — `0.0 - 0.0` is `+0.0` per IEEE rounding, so a `fsub double 0.0, %x` desugar would wrongly produce `+0.0` for `neg(+0.0)` instead of `-0.0`. Reject other types with the dispatch's standard diagnostic. 6. **Conversions and IO**: - `int_to_float` → `sitofp i64 ... to double`. - `float_to_int_truncate` → `@llvm.fptosi.sat.i64.f64` intrinsic (LLVM 12+; AILang targets clang 22, so always available). Spec semantics — NaN → 0, +∞ → i64::MAX, -∞ → i64::MIN, finite-out-of-range saturates, finite-in-range truncates toward zero — are exactly the intrinsic's documented semantics, no wrapping needed. - `is_nan` → `fcmp uno double %x, %x` (returns `i1`, which is AILang `Bool` directly per `synth.rs:18`). - `float_to_str` → call into runtime C glue `@ail_float_to_str`. - `io/print_float` → parallel to `io/print_int` (line 2150 region) → call into runtime C glue `@ail_print_float`. 7. **Float constants** `nan`/`inf`/`neg_inf` lower as `Term::Var` references resolving directly to a `double` SSA constant at the use site (LLVM hex-float literal): - `nan` → `double 0x7FF8000000000000` (canonical qNaN) - `inf` → `double 0x7FF0000000000000` - `neg_inf` → `double 0xFFF0000000000000` This is **unlike** the `__unreachable__` lowering, which emits the `unreachable` *terminator instruction*. Constants are SSA *values*, so the dispatch arm in `lower_var` (or wherever `Var` resolution lives) returns `(value-string, "double")` directly without going through a global declaration. - **ADT / container slot layout** — both `i64` and `double` occupy 8 bytes, but they are distinct LLVM types. Two cases: - **Monomorphisation produces concrete slot types.** If the mono pass (`crates/ailang-check/src/mono.rs`) lowers `List` to a concrete `List_Fl` whose field type is `double`, no bitcast is needed. The `type_descriptor` extension above (`Fl`) ensures the mangled name is distinct from `List` (`List_I`). - **Polymorphic / type-erased slots, if any.** If any container path uses `i64` or `ptr` as a generic 8-byte slot (e.g. an erased `Box` that doesn't get monomorphised), Float values traversing it need `bitcast double to i64` going in and `bitcast i64 to double` coming out. The plan verifies which case applies by reading `mono.rs` and `lib.rs::lower_ctor` / `lower_field_access`. The expected outcome is "fully monomorphised, no bitcasts needed", but this is a *check*, not an assumption. ### `crates/ailang-prose/` - `lib.rs` (2125 LoC) — Form-A ↔ Form-B prose mapping for `Literal::Float`. Form-B prose says e.g. "the number 1.5"; Form-A round-trips through bits. Mirrors the existing `Literal::Int` prose treatment. ### `runtime/` - New file `runtime/print_float.c` (or extend an existing C file) — two functions: - `void ail_print_float(double v)` — write shortest round-trippable decimal + newline. - `const char *ail_float_to_str(double v)` — return a heap-allocated AILang `Str` of the shortest round-trippable decimal (no newline). Refcounted as any other `Str` in the runtime. Implementation: vendor a small `ryu`-style routine, or — if simpler — use `snprintf("%.17g", v)` for the milestone and revisit if the redundancy is a problem. Plan-time call. - The RC runtime (`rc.c`, `bump.c`) is **not touched** by the Float value path itself. Float is a value type with no heap allocation per literal. The `ail_float_to_str` glue allocates a `Str`, but uses the existing `Str` runtime path. ### `crates/ail/` (CLI) - `ail builtins` automatically picks up the twelve new entries via `builtins::list()` — no CLI-level change needed. ### `docs/DESIGN.md` - §"Data model" `Literal` enumeration: add the `float` kind with the `bits` hex-string field documented. - §"Data model" — extend the §"Type constructors" / primitives list to include `Float`. - §"Float semantics" — new top-level subsection (~50-80 lines) containing A5 (determinism contract) and pointers to the builtins for arithmetic / comparison / conversion / IO. - §"Form-B surface" — extend the literal grammar production to include the float forms from A2. - §"Built-in operations" — add the twelve new entries. ### `docs/JOURNAL.md` - Per-iteration entries during implementation. - Milestone-close entry summarising what landed. ## Data flow The path of a float literal through the pipeline, end-to-end: ``` surface text: 1.5 ↓ ailang-surface/lex.rs Tok::Float(0x3ff8000000000000) ↓ ailang-surface/parse.rs Term::Lit { lit: Literal::Float { bits: 0x3ff8000000000000 } } ↓ ailang-surface/print.rs (round-trip) surface text: 1.5 ↓ ailang-core/canonical.rs Form-A bytes: {"lit":{"bits":"3ff8000000000000","kind":"float"},"t":"lit"} ↓ ailang-core/hash.rs def_hash: <16-hex stable> ↓ ailang-check/lib.rs Type::float() ↓ ailang-codegen/lib.rs LLVM IR: store double 0x3FF8000000000000, ptr %slot ↓ clang -O2 machine code: movsd xmm0, qword ptr [rip + .LCPI...] ``` Every stage observable; every stage round-trippable; every canonical-JSON byte stable across runs and `serde_json` versions. For an arithmetic op `(fadd a b)`: ``` surface text: (fadd a b) ↓ Term::App { fn: Var "fadd", args: [a, b] } ↓ ailang-check resolves "fadd" → builtin (Float, Float) -> Float ↓ ailang-codegen sees builtin "fadd" → emit `%r = fadd double %a, %b` ``` No magic dispatch — the builtin name is the dispatch key, like `int_add` aka `+` is today. ## Error handling Lexer errors: - `LexError::InvalidFloat { literal, start }` — raised when a digit-lead token contains `.` or `e/E` but `f64::from_str` rejects it (e.g. `1..5`, `1.5e`, `1.5e++3`). Message: `"invalid float literal ``"`. Type errors: - Float operators on non-Float arguments: standard "expected Float, got X" unification error from the existing typechecker — no new diagnostic needed. Codegen errors: - A Float literal whose `bits` cannot fit in 16 hex characters is impossible by construction (`u64` is exactly 16 hex characters), so no runtime check is added. - Form-A deserialisation rejects malformed hex (length ≠ 16, non-hex characters) at the serde layer; the resulting `serde_json::Error` propagates up the existing `ail check` error path. Runtime errors: - None per literal. Arithmetic operations follow IEEE: division by zero produces `±Inf` or `NaN`; overflow produces `±Inf`; invalid ops produce NaN. **No traps**, no Rust-style panics. This is the IEEE-conformant choice committed in user-fixed constraint #2. ## Testing strategy Per-iteration RED-first tests in `tests/` (unit) and `bench/`-adjacent E2E. The plan will sequence these; the spec fixes the *property set* that must be covered. ### Round-trip properties (lex/parse/print/hash) - `lex(print(L)) == L` for every Float literal — covers shortest-round-trippable decimal output. - Form-A bytes for `Literal::Float { bits }` are exactly `{"bits":"<16-hex>","kind":"float"}` — string-encoded, never JSON-number-encoded. - `def_hash` of a module containing a Float literal is stable across runs and across `serde_json` patch versions (mirrors the existing Int regression test). ### IEEE-conformance properties - `(+ 0.1 0.2)` produces the exact `bits` of `0.1_f64 + 0.2_f64` (i.e. `bits == 0x3fd3333333333334`, not `0x3fd3333333333333`). A single `fadd` cannot be FMA-contracted (no `c` operand to absorb), so the result is bit-stable per A5. - `(== nan nan)` returns `false`. `(!= nan nan)` returns `true`. - `<`, `<=`, `>`, `>=` all return `false` when either argument is NaN (ordered comparisons via `fcmp o*`). - `(/ 1.0 0.0)` produces `+inf` (bits `7ff0000000000000`); `(/ -1.0 0.0)` produces `-inf` (bits `fff0000000000000`); `(/ 0.0 0.0)` produces *some* NaN (test asserts `(is_nan result) == true`, **not** specific NaN bits — A5 leaves the qNaN payload unspecified). - `(is_nan nan) == true`. `(is_nan 1.0) == false`. `(is_nan inf) == false`. `(is_nan neg_inf) == false`. - `+0.0` and `-0.0` hash distinctly in Form-A but compare equal via `==`. ### Conversion properties - `int_to_float` exact for `|n| < 2^53`, rounds half-to-even beyond. - `float_to_int_truncate` total: `nan → 0`, `+inf → i64::MAX`, `-inf → i64::MIN`, finite values truncate toward zero. ### Codegen / runtime smoke - An `examples/floats.ail.json` fixture that exercises literal, arithmetic, comparison, conversion, and `io/print_float`; runs through the full `ail build → executable → stdout-check` chain. ### Bench regression - `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py` must stay GREEN. Float doesn't touch the Int code paths, so regressions are not expected; running the harnesses is a sanity gate. ### Spec-drift test - `crates/ailang-core/tests/design_schema_drift.rs` — extend the anchor list to include the new `"kind":"float"` and `"Float"` primitive name. The drift test guards against removing them silently from `DESIGN.md`. ## Acceptance criteria The milestone is closeable when **all** of the following hold: 1. `crates/ailang-core/src/primitives.rs::is_primitive_name` returns `true` for `"Float"`. The lockstep test passes. 2. `Literal::Float { bits: u64 }` is serialised as `{"bits":"<16-hex>","kind":"float"}` in canonical JSON. The round-trip test passes. 3. `ail builtins` output reflects: (a) widened signatures for `+`, `-`, `*`, `/`, `<`, `<=`, `>`, `>=` (now `forall a. (a, a) -> a` / `forall a. (a, a) -> Bool`); (b) new builtins: `neg`, `int_to_float`, `float_to_int_truncate`, `float_to_str`, `is_nan`; (c) new constants: `nan`, `inf`, `neg_inf`; (d) new effect op: `io/print_float`. 4. `examples/floats.ail.json` builds, runs, and produces the expected stdout (printed floats round-trip the literal forms). 5. `cargo test --workspace` is GREEN. 6. `bench/check.py`, `bench/compile_check.py`, `bench/cross_lang.py` exit 0. 7. `docs/DESIGN.md` carries the new §"Float semantics" subsection, the extended `Literal` enumeration, the extended primitives list, and the twelve builtin signatures. The `design_schema_drift.rs` test extends to cover the new anchors. 8. `cargo doc --no-deps` warning count does not increase from the milestone-open baseline (16 warnings; this is the queued rustdoc-sweep baseline). 9. JOURNAL milestone-close entry written. 10. `docs/roadmap.md` Floats entry flipped from `[~]` to `[x]`; Post-22 Prelude unblocked (its `depends on: Floats` line can be removed in the same commit or in the Prelude's brainstorm step — orchestrator call). Out of scope, explicitly: - `Show Float` / `Eq Float` / `Ord Float` instances. (Float gets `Show` in the next milestone; `Eq` and `Ord` will *intentionally* not exist for `Float` because of A5 + user-fixed constraint #2.) - Polymorphic numeric class (`Num`). The hardcoded `{Int, Float}` spec-case in the typechecker is transitional scaffolding; replaced by a proper `Num a` constraint when typeclass machinery picks up arithmetic in 22c. - `f32`. - `fmod` / IEEE-`remainder`. `%` stays Int-only this milestone. - `print` rewiring through `Show.show` — that is Post-22 Prelude. - Pattern-matching on Float literals (`Pattern::Lit { lit: Literal::Float { ... } }`). Semantically dubious — IEEE `==` semantics mean `NaN` never matches any literal pattern, and `0.1` in a pattern matches *only* the parsed bit pattern `0x3fb999999999999a`, not "approximately 0.1". The plan decides between (a) hard-reject Float in `Pattern::Lit` at the typechecker (recommended), or (b) allow it with an IEEE-`==` semantics, which the LLM-author has to remember. Either way: this milestone explicitly does NOT introduce a Float-pattern *idiom* — the documented way to discriminate Floats is via the comparison operators (`<`/`>`/...) and `(is_nan x)`. ## Notes for the plan The plan should sequence iterations roughly: - **Iteration 1**: AST + canonical + primitives (the schema layer). Pure schema, RED-first by canonical-bytes test for `Literal::Float`. - **Iteration 2**: Surface lex + parse + print round-trip. RED-first by `lex(print(L)) == L` property. - **Iteration 3**: Type + builtins install. Two distinct sub-tasks: (a) **widen** existing `+`/`-`/`*`/`/`/`<`/`<=`/`>`/`>=` to polymorphic; **don't break** existing Int-only fixtures; (b) install new builtins (`neg`, conversions, `is_nan`, constants) and `io/print_float`. RED-first by typecheck unit test on `(+ 1.0 2.0) : Float` AND `(+ 1 2) : Int` (regression). - **Iteration 4**: Codegen widening + Float lowering paths. Same widen-vs-add split as iteration 3 — codegen for `+`/`-`/`*`/`/` becomes type-dispatched, plus Float arms for comparison, conversion, IO, and constants. RED-first by E2E `examples/floats.ail.json` AND existing Int E2E fixtures staying GREEN (regression gate). - **Iteration 5**: Prose + DESIGN.md + drift-test extension + rustdoc. Note the regression-gate emphasis on iterations 3 and 4: this milestone *widens* operators that are today monomorphic. Every existing Int-using fixture must keep working bit-identical. The plan should pin a "no Int regression" property explicitly. Iteration boundaries are the plan's call. The five-cluster shape above is the spec's scope estimate, not a binding sequence.