spec: floats — operators polymorphic over {Int, Float} + NaN/Inf constants + is_nan

Mainstream-aligns the Float surface: + - * / < <= > >= become
forall-quantified with codegen-dispatch on Int|Float (parallel to
today's == path), instead of the original draft's wort-style
fadd/flt/etc. Adds nan/inf/neg_inf as builtin constants and
is_nan : (Float) -> Bool. % stays Int-only (no fmod yet).

Iteration plan now flags iter 3 and iter 4 as widening passes
that must hold the no-Int-regression line.
This commit is contained in:
2026-05-10 14:08:35 +02:00
parent 406bde0efc
commit f7e2c3ee7a
+216 -94
View File
@@ -101,54 +101,121 @@ Negative float literals follow today's negative-int rule
digit as part of a numeric token), so `-1.5` lexes as a single digit as part of a numeric token), so `-1.5` lexes as a single
`Tok::Float`. `Tok::Float`.
### A3 — Builtin operators: no overloading, explicit f-prefix ### A3 — Builtin operators: polymorphic over `{Int, Float}` via codegen-dispatch
| Domain | Int (existing) | Float (new) | 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 |
|---|---|---| |---|---|---|
| Arithmetic | `+ - * / %` | `fadd fsub fmul fdiv fneg` | | `+` | `add i64` | `fadd double` |
| Equality | `== !=` (polymorphic, codegen-dispatched) | (same — codegen arm extended) | | `-` | `sub i64` | `fsub double` |
| Ordering | `< <= > >=` (Int-only) | `flt fle fgt fge` | | `*` | `mul i64` | `fmul double` |
| `/` | `sdiv i64` | `fdiv double` |
| `==` | existing `icmp eq i64` / `i1` / `@strcmp` / `i1 1` | `fcmp oeq double` |
| `!=` | derived from `==` | `fcmp one 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` |
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: Rationale:
- AILang already rejects operator overloading - **Mainstream alignment.** Every LLM-relevant programming
(`docs/DESIGN.md` "Feature-acceptance criterion"). A language (Rust, Java, C, Python, JavaScript, Haskell, Swift,
polymorphic `+ : forall a. (a, a) -> a` would either need OCaml-via-typeclass-like-extension) accepts `1.5 + 2.5`. An
ad-hoc constraints (the typeclass story, out of scope here) or AILang where the LLM-author has to write `(fadd 1.5 2.5)` is
a hard-coded Int|Float type spec-case in the typechecker. Both a high-friction first-try-error surface. The user-facing
cost more than two-name disambiguation. decision (this conversation, 2026-05-10) chose Mainstream
- The `==`/`!=` codegen-dispatch path already exists alignment over the explicit-naming purity of an earlier draft.
(`crates/ailang-check/src/builtins.rs:78`, - **Precedent already in tree.** The `==` builtin is
`crates/ailang-codegen/src/lib.rs:2244-2266`). Extending the `forall a. (a, a) -> Bool` with a hardcoded Int|Bool|Str|Unit
arm is one branch addition; introducing `feq`/`fne` would be a codegen dispatch and explicit reject for ADT/Fn equality
duplication of an already-polymorphic site for no semantic (`builtins.rs:75-77`). The widening of `+`/`-`/etc. uses the
gain. IEEE-conformant `==` (NaN == NaN ⇒ false) falls out of *same* mechanism — no new architectural surface, just more
LLVM `fcmp oeq` directly. arms in the existing dispatch helper.
- `<`/`<=`/`>`/`>=` on Int are monomorphic today, so widening - **Typeclass migration story.** When typeclasses ship
them to polymorphic-Int|Float would require a *new* dispatch (Post-22 Prelude + 22c), the hardcoded `Int|Float` spec-case
path. Cheaper and more honest to add `flt`/`fle`/`fgt`/`fge` in the typechecker can be cleanly replaced by a `Num a`
alongside. constraint. Surface (`(+ 1.5 2.5)`) does not change. The
- `fmod` is **excluded** for this milestone. IEEE 754-2008 spec-case is transitional, not load-bearing.
remainder semantics (`fmod` vs `remainder`) are subtle enough - **`%` stays monomorphic.** IEEE `frem` (LLVM op for `fmod`)
to deserve their own decision; not blocking the milestone on has subtle semantics (sign-of-result-follows-dividend,
it. ±0 / ±Inf edge cases) that deserve their own decision; out
of scope for this milestone.
Builtin signatures (added to Other new builtins:
`crates/ailang-check/src/builtins.rs::install`):
``` ```
fadd : (Float, Float) -> Float neg : forall a. (a) -> a -- Int|Float; reject otherwise
fsub : (Float, Float) -> Float
fmul : (Float, Float) -> Float
fdiv : (Float, Float) -> Float
fneg : (Float) -> Float
flt : (Float, Float) -> Bool
fle : (Float, Float) -> Bool
fgt : (Float, Float) -> Bool
fge : (Float, Float) -> Bool
int_to_float : (Int) -> Float int_to_float : (Int) -> Float
float_to_int_truncate : (Float) -> Int float_to_int_truncate : (Float) -> Int
float_to_str : (Float) -> Str 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 ### A4 — Conversions: total, foot-gun-free
@@ -269,37 +336,66 @@ the plan for iteration boundaries.
- `lib.rs` (4457 LoC) — `Literal::Float` is well-typed at - `lib.rs` (4457 LoC) — `Literal::Float` is well-typed at
`Type::float()`. Add the one-arm extension to whatever `Type::float()`. Add the one-arm extension to whatever
`Term::Lit` typing helper exists. `Term::Lit` typing helper exists.
- `builtins.rs` (183 LoC) — install all twelve new entries (per - `builtins.rs` (183 LoC) — three classes of change:
A3). Extend `list()` accordingly so `ail builtins` reflects 1. **Widen** the existing arithmetic and comparison entries
them. The `==` polymorphic entry needs no change here — the `+`/`-`/`*`/`/`/`<`/`<=`/`>`/`>=` from monomorphic Int to
signature stays `forall a. (a, a) -> Bool`; the codegen `forall a. (a, a) -> a` (or `... -> Bool`) — same shape
side (below) handles the new Float arm. 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/` ### `crates/ailang-codegen/`
- `lib.rs` (2877 LoC) — six new lowering paths: - `lib.rs` (2877 LoC) — six lowering changes:
1. **Float literal** (`Literal::Float { bits }`) → emit 1. **Float literal** (`Literal::Float { bits }`) → emit
`double` constant via LLVM hex-float syntax `double` constant via LLVM hex-float syntax
`0x1p+0`-style or via `bitcast i64 0x... to double`. `0x1p+0`-style or via `bitcast i64 0x... to double`.
Sites: every place `Literal::Int { value }` is matched Sites: every place `Literal::Int { value }` is matched
today (lines 918, 1215). today (lines 918, 1215).
2. **Arithmetic builtins** `fadd/fsub/fmul/fdiv/fneg` → 2. **Widen arithmetic dispatch.** `+`/`-`/`*`/`/` are
LLVM `fadd double`, `fsub double`, `fmul double`, today lowered as monomorphic Int instructions
`fdiv double`, `fneg double`. Site: parallel to the (line 1745 region). Convert the lowering site into a
`t_add` / `+` lowering (line 1745 region). type-dispatched helper analogous to the existing `==`
3. **Comparison builtins** `flt/fle/fgt/fge` → LLVM dispatch (`lib.rs:2244-2266`): Int arm emits
`fcmp olt`, `fcmp ole`, `fcmp ogt`, `fcmp oge` `add/sub/mul/sdiv i64`, Float arm emits
(ordered, so NaN compares yield `false`). `fadd/fsub/fmul/fdiv double`, anything else fails with
`"<op> not supported for type X"`.
3. **Widen comparison dispatch.** `<`/`<=`/`>`/`>=` get the
same treatment: Int arm `icmp s{lt,le,gt,ge} i64`, Float
arm `fcmp o{lt,le,gt,ge} double`, reject otherwise.
4. **`==` / `!=` Float arm** in the existing dispatch 4. **`==` / `!=` Float arm** in the existing dispatch
(line 2263 region) → `fcmp oeq` / `fcmp one` for the (line 2263 region) → `fcmp oeq` / `fcmp one` for the
`Float` case. Add the case alongside the existing `Float` case, alongside the existing arms.
`"Int"` / `"Bool"` / `"Str"` / `"Unit"` arms. 5. **`neg` polymorphic** — Int arm `sub i64 0, %x`, Float
5. **Conversions** `int_to_float` → `sitofp i64 to double`; arm `fneg double %x` (correct for `-0.0`, unlike a
`float_to_int_truncate` → `call double @llvm.fptosi.sat.i64` hypothetical `(- 0.0 x)` desugar). Reject other types.
(or the legalised equivalent). 6. **Conversions and IO**:
6. **`io/print_float`** effect-op lowering parallel to - `int_to_float` → `sitofp i64 ... to double`.
`io/print_int` (line 2150 region) → call into runtime C - `float_to_int_truncate` → `@llvm.fptosi.sat.i64.f64`
glue. intrinsic (or the legalised cast sequence if the
target backend doesn't expose it directly).
- `is_nan` → `fcmp uno double %x, %x` (returns `i1`,
which already coerces to AILang `Bool`).
- `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 to compile-time `double`
constants (the codegen path that resolves `Var "x"` to
the SSA value of a global gets a builtin-constant arm,
same way `__unreachable__` resolves to `unreachable`).
- ADT / container layout — `double` is 8 bytes, fits the - ADT / container layout — `double` is 8 bytes, fits the
existing 8-byte slot used for `i64` and `ptr`. **No layout existing 8-byte slot used for `i64` and `ptr`. **No layout
change.** Confirm this by inspecting how `List<Int>` lowers change.** Confirm this by inspecting how `List<Int>` lowers
@@ -315,13 +411,21 @@ the plan for iteration boundaries.
### `runtime/` ### `runtime/`
- New file `runtime/print_float.c` (or extend an existing C - New file `runtime/print_float.c` (or extend an existing C
file) — `void ail_print_float(double v)` that writes the file) — two functions:
shortest round-trippable decimal followed by a newline. We - `void ail_print_float(double v)` — write shortest
vendor a small `ryu`-style implementation, or — if simpler — round-trippable decimal + newline.
use `printf("%.17g\n", v)` for the milestone and revisit if - `const char *ail_float_to_str(double v)` — return a
the redundancy is a problem. heap-allocated AILang `Str` of the shortest
- The RC runtime (`rc.c`, `bump.c`) is **not touched**. Float round-trippable decimal (no newline). Refcounted as any
is a value type with no heap allocation per literal. 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) ### `crates/ail/` (CLI)
@@ -437,20 +541,20 @@ fixes the *property set* that must be covered.
### IEEE-conformance properties ### IEEE-conformance properties
- `(fadd 0.1 0.2)` produces the exact `bits` of - `(+ 0.1 0.2)` produces the exact `bits` of
`0.1_f64 + 0.2_f64` (i.e. `bits == 0x3fd3333333333334`, not `0.1_f64 + 0.2_f64` (i.e. `bits == 0x3fd3333333333334`, not
`0x3fd3333333333333`). A single `fadd` cannot be FMA-contracted `0x3fd3333333333333`). A single `fadd` cannot be FMA-contracted
(no `c` operand to absorb), so the result is bit-stable per A5. (no `c` operand to absorb), so the result is bit-stable per A5.
- `==` on the same NaN bit pattern (passed in as a Float literal) - `(== nan nan)` returns `false`. `(!= nan nan)` returns `true`.
returns `false`. `!=` on the same NaN literal returns `true`. - `<`, `<=`, `>`, `>=` all return `false` when either
- `flt`, `fle`, `fgt`, `fge` all return `false` when either argument is NaN (ordered comparisons via `fcmp o*`).
argument is NaN (ordered comparisons). - `(/ 1.0 0.0)` produces `+inf` (bits `7ff0000000000000`);
- `(fdiv 1.0 0.0)` produces `+inf` (bits `7ff0000000000000`); `(/ -1.0 0.0)` produces `-inf` (bits `fff0000000000000`);
`(fdiv -1.0 0.0)` produces `-inf` (bits `fff0000000000000`); `(/ 0.0 0.0)` produces *some* NaN (test asserts
`(fdiv 0.0 0.0)` produces *some* NaN (test asserts `(is_nan result) == true`, **not** specific NaN bits —
`(fne result result) == true` per IEEE NaN-detection idiom, A5 leaves the qNaN payload unspecified).
**not** specific NaN bits — A5 leaves the qNaN payload - `(is_nan nan) == true`. `(is_nan 1.0) == false`.
unspecified). `(is_nan inf) == false`. `(is_nan neg_inf) == false`.
- `+0.0` and `-0.0` hash distinctly in Form-A but compare equal - `+0.0` and `-0.0` hash distinctly in Form-A but compare equal
via `==`. via `==`.
@@ -491,8 +595,14 @@ The milestone is closeable when **all** of the following hold:
2. `Literal::Float { bits: u64 }` is serialised as 2. `Literal::Float { bits: u64 }` is serialised as
`{"bits":"<16-hex>","kind":"float"}` in canonical JSON. The `{"bits":"<16-hex>","kind":"float"}` in canonical JSON. The
round-trip test passes. round-trip test passes.
3. The twelve new builtins (per A3) appear in 3. `ail builtins` output reflects:
`ail builtins` output. (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 4. `examples/floats.ail.json` builds, runs, and produces the
expected stdout (printed floats round-trip the literal expected stdout (printed floats round-trip the literal
forms). forms).
@@ -519,15 +629,12 @@ Out of scope, explicitly:
gets `Show` in the next milestone; `Eq` and `Ord` will gets `Show` in the next milestone; `Eq` and `Ord` will
*intentionally* not exist for `Float` because of A5 + *intentionally* not exist for `Float` because of A5 +
user-fixed constraint #2.) user-fixed constraint #2.)
- Polymorphic numeric class (`Num`). - 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`. - `f32`.
- `fmod` / IEEE-`remainder`. - `fmod` / IEEE-`remainder`. `%` stays Int-only this milestone.
- NaN-detection builtin (`is_nan`); NaN-construction builtin.
Workaround in scope: `(fne x x)` is `true` iff `x` is NaN
(the canonical IEEE NaN test). Documented in
§"Float semantics".
- `Float.nan` / `Float.infinity` named constants — these come
with the eventual Float stdlib, not the primitive milestone.
- `print` rewiring through `Show.show` — that is Post-22 - `print` rewiring through `Show.show` — that is Post-22
Prelude. Prelude.
- Pattern-matching on Float literals (`Pattern::Lit { lit: - Pattern-matching on Float literals (`Pattern::Lit { lit:
@@ -540,25 +647,40 @@ Out of scope, explicitly:
IEEE-`==` semantics, which the LLM-author has to remember. IEEE-`==` semantics, which the LLM-author has to remember.
Either way: this milestone explicitly does NOT introduce a Either way: this milestone explicitly does NOT introduce a
Float-pattern *idiom* — the documented way to discriminate Float-pattern *idiom* — the documented way to discriminate
Floats is via the comparison builtins (`flt`/`fle`/...) and Floats is via the comparison operators (`<`/`>`/...) and
the NaN-detection idiom `(fne x x)`. `(is_nan x)`.
## Notes for the plan ## Notes for the plan
The plan should sequence iterations roughly: The plan should sequence iterations roughly:
- **Iteration 1**: AST + canonical + primitives (the schema - **Iteration 1**: AST + canonical + primitives (the schema
layer). Pure schema, RED-first by canonical-bytes test. layer). Pure schema, RED-first by canonical-bytes test for
`Literal::Float`.
- **Iteration 2**: Surface lex + parse + print round-trip. - **Iteration 2**: Surface lex + parse + print round-trip.
RED-first by `lex(print(L)) == L` property. RED-first by `lex(print(L)) == L` property.
- **Iteration 3**: Type + builtins install. RED-first by - **Iteration 3**: Type + builtins install. Two distinct
typecheck unit test on `(fadd 1.0 2.0) : Float`. sub-tasks:
- **Iteration 4**: Codegen (literal + arithmetic + comparison + (a) **widen** existing `+`/`-`/`*`/`/`/`<`/`<=`/`>`/`>=` to
conversion + IO). RED-first by E2E polymorphic; **don't break** existing Int-only fixtures;
`examples/floats.ail.json`. (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 + - **Iteration 5**: Prose + DESIGN.md + drift-test extension +
rustdoc. 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 Iteration boundaries are the plan's call. The five-cluster
shape above is the spec's scope estimate, not a binding shape above is the spec's scope estimate, not a binding
sequence. sequence.