Files
AILang/docs/specs/0005-floats.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

33 KiB

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

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

  • <digits>.<digits> — e.g. 1.5, 0.0, 100.0
  • <digits>.<digits>e[+-]?<digits> — e.g. 1.5e3, 2.0e-10
  • <digits>e[+-]?<digits> — 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 -<digits> 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 -<digits> 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 <digits> 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 "<op> 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 oneone 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.00.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_floatsitofp 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_nanfcmp 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):

      • nandouble 0x7FF8000000000000 (canonical qNaN)
      • infdouble 0x7FF0000000000000
      • neg_infdouble 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<Float> 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<Int> (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<a> 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.