# Fieldtest — Floats milestone — 2026-05-10 **Status:** Draft — awaiting orchestrator triage **Author:** ailang-fieldtester (dispatched by skills/fieldtest) ## Scope The Floats milestone (closed 2026-05-10) added `Float` as a fourth zero-arity primitive type (IEEE-754 binary64). Surface accepts `1.5`, `1.5e3`, `1e10`, `-1.5`. Operators `+`/`-`/`*`/`/` and `<`/`<=`/`>`/`>=`/`!=`/`==` widened to polymorphic over `{Int,Float}` via codegen-dispatch. New builtins: `neg` (poly), `int_to_float`, `float_to_int_truncate`, `is_nan`. Constants `nan`, `inf`, `neg_inf` (bit-pattern globals). New effect op `io/print_float`. `float_to_str` is type-installed but codegen-deferred. Per DESIGN.md §"Float semantics", `Pattern::Lit::Float` is supposed to be hard-rejected at typecheck via `CheckError::FloatPatternNotAllowed`. ## Examples ### `examples/fieldtest/floats_1_newton_sqrt.ailx` — Newton's method for √2 - 20-step Newton iteration `x' = 0.5 * (x + N/x)` for `N = 2.0`, printed via `io/print_float`. - Exercises Float literals (`0.5`, `2.0`), polymorphic `*` and `+` and `/` and `==` and `-` (the loop counter is `Int`), tail- recursive `Float`-typed accumulator, `io/print_float`. - **Outcome:** typechecks, builds, runs first try. stdout: `1.41421` (matches `sqrt(2)` to 5 decimals — the IEEE-conformant truncated `%g` printf). ### `examples/fieldtest/floats_2_average_int_list.ailx` — mean of an Int list as Float - Sum and count an `IntList` with two tail-recursive accumulators, then promote both to `Float` via `int_to_float` and divide. - Exercises `int_to_float` at both numerator and denominator (the natural shape — neither operand can be left as `Int` because `+`, `-`, `*`, `/` reject mixed-type args). - **Outcome:** typechecks, builds, runs first try. stdout: `3.2` (sum 16 / count 5 = exact 3.2 since 5 is a power of 5 not representable in binary, but the printer lands on the shortest-decimal that round-trips). ### `examples/fieldtest/floats_3_safe_division.ailx` — IEEE division + `is_nan` - Three division cases: `6.0/3.0` (normal), `1.0/0.0` (+Inf), `0.0/0.0` (NaN). For each, `classify` returns `-1` if the value is NaN (via `is_nan`), `0` if infinite (via comparison against `1.0e308` and `-1.0e308`), `1` otherwise. - Exercises `is_nan`, polymorphic `>`, `<`, large literal exponents, `io/print_float` on Inf/NaN values, and the IEEE `0.0/0.0 = NaN` semantics. - **Outcome:** typechecks, builds, runs first try (after a paren- count typo fix). stdout: ``` 2 1 inf 0 -nan -1 ``` Three observations from the actual stdout vs. the expected: 1. `6.0/3.0` printed as `2`, not `2.0` (see finding F1). 2. NaN printed as `-nan` (the platform's qNaN sign-bit representation). 3. `is_nan` correctly returned `true` for the `0.0/0.0` result and `false` for `1.0/0.0`. ### `examples/fieldtest/floats_4_float_to_str_reach.ailx` — reach for `float_to_str` - `(do io/print_str (app float_to_str 3.14))` — the natural reach: an LLM-author who finds `float_to_str : (Float) -> Str` in `ail builtins` will write this, expecting a `Str`-printable Float. - **Outcome:** `ail check` succeeds (typechecks fine). `ail build` fails with the structured codegen-deferred error — the diagnostic is excellent, see finding F2. ## Findings ### [bug] B1 — `Pattern::Lit::Float` is NOT rejected at typecheck (DESIGN.md says it must be) DESIGN.md §"Float semantics" (lines 2076-2081): > **Pattern matching:** `Pattern::Lit` on `Literal::Float` is hard- > rejected at typecheck (`CheckError::FloatPatternNotAllowed`). IEEE > semantics make Float patterns semantically dubious — NaN never > matches via IEEE-`==`, and bit-exact equality is rarely what an > LLM-author wants. Use ordering operators (`<`, `>`, ...) and > `is_nan` to discriminate Floats. JOURNAL Floats.3 (line 13559-13560): > `Pattern::Lit::Float` typecheck-rejected via new > `CheckError::FloatPatternNotAllowed`. Floats.5 milestone-close JOURNAL repeats the claim. But probing with this fixture (saved at `/tmp/probe_pat_float.ailx`, not committed; reproduced below): ``` (module probe_pat_float (fn classify (type (fn-type (params (con Float)) (ret (con Int)))) (params x) (body (match x (case (pat-lit 0.0) 1) (case _ 0)))) (fn main (type (fn-type (params) (ret (con Unit)) (effects IO))) (params) (body (do io/print_int (app classify 0.0))))) ``` `ail check` reports `ok (2 symbols across 1 modules)`. `ail build` succeeds. The compiled binary returns `1` for `classify(0.0)` — the Float pattern *runs*. Multi-arm probe confirms IEEE semantics: NaN does not match `0.0` (yields the `_` branch), `-0.0` (via `(neg 0.0)`) DOES match `(pat-lit 0.0)` (yields `1`). So the underlying behaviour is "option (b)" from the spec's out-of-scope note (Floats spec line 729-731) — Float patterns work with IEEE-`==` semantics, despite DESIGN.md and JOURNAL both promising the option-(a) hard-reject. - **Repro:** the four-line probe above; check exits 0 instead of error. - **Recommended action:** `debug` — RED-first test that `(case (pat-lit 0.0) ...)` produces `CheckError::FloatPatternNotAllowed`, then implement the missing reject path in `crates/ailang-check/`. Alternative: if the team decides option (b) (silent Float-pattern with IEEE-`==`) is actually the right semantics on reflection, then DESIGN.md and JOURNAL must be amended — but this is a substantive design reversal and should go through `brainstorm`, not be silently papered over. ### [working] W1 — `float_to_str` deferred-codegen diagnostic is excellent Diagnostic from `ail build` on example 4: ``` Error: module `floats_4_float_to_str_reach`: def `main`: internal: `float_to_str` codegen lowering is not yet implemented (requires dynamic Str allocation in the runtime) ``` This is the gold-standard form for an LLM-author-facing diagnostic: - **Names the builtin** that triggered the error (`float_to_str`). - **Names the failure layer** (codegen, not typecheck — so the LLM knows the type signature is correct and not to second-guess it). - **Names the underlying blocker** (dynamic Str allocation) so the LLM can decide whether to wait for the future milestone or work around (no current workaround: there's no other Float→Str path). The reach itself (looking at `ail builtins`, finding `float_to_str : (Float) -> Str`, writing `(do io/print_str (app float_to_str 3.14))`) was natural — exactly the shape an LLM produces for "print this Float as a labeled string". The deferred-codegen state is communicated cleanly enough that the LLM gets a useful signal without reaching for the source. - **Recommended action:** `carry-on`. This is the model for how other "type-installed but codegen-deferred" features should diagnose themselves. ### [working] W2 — DESIGN.md §"Float semantics" is sufficient to guide the natural-reach for `is_nan` DESIGN.md line 2034-2037: > - `is_nan` (`fcmp uno double %x, %x`) returns `true` iff `x` is > NaN. Bit-pattern-based NaN detection without dependence on the > payload bits. Plus line 2079-2081: > Use ordering operators (`<`, `>`, ...) and `is_nan` to > discriminate Floats. Reading these alone (no compiler peek), an LLM-author writing example 3 reaches for `is_nan` directly, never considers the ill-fated `(== x x)` form, and produces a working classifier. The `nan` / `inf` / `neg_inf` constants are also discoverable from the same section (line 2135-2137), and `ail builtins` confirms them as bare-value globals — they were used as test inputs in the iteration of example 3, where the same probe showed they propagate correctly through `is_nan`, `<`, `>`. - **Recommended action:** `carry-on`. ### [friction] F1 — `io/print_float` strips trailing `.0`, making float output indistinguishable from int output Example 3 stdout includes `2` for `(/ 6.0 3.0)`, not `2.0`. The spec/JOURNAL describes the printer as "shortest round-trippable decimal" (Floats.2 entry, line 13548-13549) but that property applies to the surface printer (`Literal::Float` → surface `"2.0"`). The runtime path `io/print_float` lowers via `printf("%g\n", v)` (Floats.4 entry, line 13571), and `%g` strips trailing zeros and the trailing decimal point. Consequences: 1. The stdout of `io/print_float v` for an integer-valued `v` is indistinguishable from `io/print_int (float_to_int_truncate v)`. For an LLM author writing a test that asserts on stdout, the round-trip property "the text I see is unambiguously a Float" silently breaks. 2. The asymmetry between the surface printer (always emits `.0`) and the runtime printer (sometimes doesn't) is a hidden contract the LLM-author has to learn from running the program, not from DESIGN.md. The deeper issue: DESIGN.md says nothing about the format of `io/print_float` output. It is silent on whether the contract is "shortest round-trippable" (matching the surface printer) or "`%g`-style". An LLM-author who writes a test asserting `2.0` in stdout has no DESIGN.md anchor to consult; running the test is the only way to find out. - **Recommended action:** `plan` (tidy iteration). Either (a) switch `io/print_float` to a shortest-round-trippable format (matches the surface printer, removes the asymmetry, makes `print_float` output unambiguous as a Float), or (b) document the `%g`-style format explicitly in DESIGN.md §"Float semantics" so the contract is at least nameable. Option (a) is the LLM-utility-aligned choice: the LLM-author benefit from unambiguous output exceeds the cost of a more verbose format string for integer-valued Floats. (Note: `float_to_str`, when it ships its codegen, faces the *same* design choice — pinning the format on `io/print_float` first preserves the freedom to make `float_to_str` match.) ### [spec_gap] G1 — DESIGN.md is silent on the NaN sign-bit / payload visible to `io/print_float` Example 3 stdout shows `-nan` for `(/ 0.0 0.0)`. DESIGN.md line 2057-2060 explicitly leaves the NaN payload unspecified: > The exact NaN bit pattern produced by an op. Any quiet NaN bit > pattern is conformant; `0.0 / 0.0` may produce > `0x7ff8000000000000` on one target and a different qNaN on > another. The reading taken: since the payload (and sign bit) of the qNaN produced by `0.0/0.0` is unspecified, the *printed form* of that qNaN is also unspecified. The compiler's `printf("%g\n", v)` exposes the sign bit (`-nan` vs `nan`) — that's a downstream consequence of "NaN bit pattern unspecified". The other plausible reading: DESIGN.md guarantees the constant `nan` (line 2135) has bits `7ff8000000000000` (no sign bit set, so prints as `nan`), AND that `is_nan` correctly identifies any qNaN regardless of sign — but does NOT promise that the printed form of an op-produced NaN is normalised to `nan` (no sign). The picked reading (current behaviour) is internally consistent with A5's "NaN payload unspecified". But an LLM-author who reads DESIGN.md, sees the `nan` constant prints as `nan`, and assumes all NaN values print the same way will be surprised by `-nan` on ops that produce NaN. **Both readings are equally plausible from the text.** - **Recommended action:** `tighten DESIGN.md`. One additional sentence in §"Float semantics" Unspecified-list: "The textual form of a NaN value emitted by `io/print_float` follows `printf("%g")` and may include a sign bit (`-nan`); `is_nan` is the only reliable NaN test." Or, if the team prefers a guarantee, normalise NaN printing in the runtime to `nan` and add the corresponding Guaranteed-list entry. Either pin removes the ambiguity. ### [working] W3 — Mixed `Int + Float` produces a clear, actionable diagnostic Probe (`/tmp/probe_mixed.ailx`): ``` (do io/print_float (app + 1 1.5)) ``` `ail check` reports: ``` error: [type-mismatch] main: type mismatch: expected Int, got Float ``` The diagnostic doesn't *suggest* `int_to_float`, but the LLM-author reading "expected Int, got Float" will think "I need to coerce the Int" and either (a) write `1.0` instead of `1` (the natural fix here) or (b) reach for `int_to_float`. Both work. The diagnostic doesn't lie about the type system: `+ : forall a. (a, a) -> a` unifies under one type variable; mixing rejects. - **Recommended action:** `carry-on`. A future polish iteration could add a "did you mean to coerce one side?" hint, but for the milestone scope the diagnostic is sufficient. ## Recommendation summary | Finding | Class | Action | |---|---|---| | B1 — Pattern::Lit::Float not rejected | bug | `debug` — RED test pinning the missing `CheckError::FloatPatternNotAllowed`, then fix in `crates/ailang-check/`. Alternative: if option (b) is now preferred, run `brainstorm` to amend DESIGN.md and JOURNAL. | | W1 — `float_to_str` deferred-codegen diagnostic | working | carry-on | | W2 — `is_nan` discoverability | working | carry-on | | F1 — `io/print_float` strips `.0` | friction | `plan` (tidy iteration) — switch to shortest-round-trippable, or document `%g` contract in DESIGN.md | | G1 — NaN sign-bit print form unspecified | spec_gap | `tighten DESIGN.md` — one sentence in Unspecified-list, OR runtime normalisation + Guaranteed-list addition | | W3 — mixed Int+Float diagnostic | working | carry-on |