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
`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` |
| Equality | `== !=` (polymorphic, codegen-dispatched) | (same — codegen arm extended) |
| Ordering | `< <= > >=` (Int-only) | `flt fle fgt fge` |
| `+` | `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` |
| `!=` | 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:
- AILang already rejects operator overloading
(`docs/DESIGN.md` "Feature-acceptance criterion"). A
polymorphic `+ : forall a. (a, a) -> a` would either need
ad-hoc constraints (the typeclass story, out of scope here) or
a hard-coded Int|Float type spec-case in the typechecker. Both
cost more than two-name disambiguation.
- The `==`/`!=` codegen-dispatch path already exists
(`crates/ailang-check/src/builtins.rs:78`,
`crates/ailang-codegen/src/lib.rs:2244-2266`). Extending the
arm is one branch addition; introducing `feq`/`fne` would be a
duplication of an already-polymorphic site for no semantic
gain. IEEE-conformant `==` (NaN == NaN ⇒ false) falls out of
LLVM `fcmp oeq` directly.
- `<`/`<=`/`>`/`>=` on Int are monomorphic today, so widening
them to polymorphic-Int|Float would require a *new* dispatch
path. Cheaper and more honest to add `flt`/`fle`/`fgt`/`fge`
alongside.
- `fmod` is **excluded** for this milestone. IEEE 754-2008
remainder semantics (`fmod` vs `remainder`) are subtle enough
to deserve their own decision; not blocking the milestone on
it.
- **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.
Builtin signatures (added to
`crates/ailang-check/src/builtins.rs::install`):
Other new builtins:
```
fadd : (Float, Float) -> Float
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
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
@@ -269,37 +336,66 @@ the plan for iteration boundaries.
- `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) — install all twelve new entries (per
A3). Extend `list()` accordingly so `ail builtins` reflects
them. The `==` polymorphic entry needs no change here — the
signature stays `forall a. (a, a) -> Bool`; the codegen
side (below) handles the new Float arm.
- `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/`
- `lib.rs` (2877 LoC) — six new lowering paths:
- `lib.rs` (2877 LoC) — six lowering changes:
1. **Float literal** (`Literal::Float { bits }`) → emit
`double` constant via LLVM hex-float syntax
`0x1p+0`-style or via `bitcast i64 0x... to double`.
Sites: every place `Literal::Int { value }` is matched
today (lines 918, 1215).
2. **Arithmetic builtins** `fadd/fsub/fmul/fdiv/fneg` →
LLVM `fadd double`, `fsub double`, `fmul double`,
`fdiv double`, `fneg double`. Site: parallel to the
`t_add` / `+` lowering (line 1745 region).
3. **Comparison builtins** `flt/fle/fgt/fge` → LLVM
`fcmp olt`, `fcmp ole`, `fcmp ogt`, `fcmp oge`
(ordered, so NaN compares yield `false`).
2. **Widen arithmetic dispatch.** `+`/`-`/`*`/`/` are
today lowered as monomorphic Int instructions
(line 1745 region). 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"`.
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
(line 2263 region) → `fcmp oeq` / `fcmp one` for the
`Float` case. Add the case alongside the existing
`"Int"` / `"Bool"` / `"Str"` / `"Unit"` arms.
5. **Conversions** `int_to_float` → `sitofp i64 to double`;
`float_to_int_truncate` → `call double @llvm.fptosi.sat.i64`
(or the legalised equivalent).
6. **`io/print_float`** effect-op lowering parallel to
`io/print_int` (line 2150 region) → call into runtime C
glue.
`Float` case, alongside the existing arms.
5. **`neg` polymorphic** — Int arm `sub i64 0, %x`, Float
arm `fneg double %x` (correct for `-0.0`, unlike a
hypothetical `(- 0.0 x)` desugar). Reject other types.
6. **Conversions and IO**:
- `int_to_float` → `sitofp i64 ... to double`.
- `float_to_int_truncate` → `@llvm.fptosi.sat.i64.f64`
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
existing 8-byte slot used for `i64` and `ptr`. **No layout
change.** Confirm this by inspecting how `List<Int>` lowers
@@ -315,13 +411,21 @@ the plan for iteration boundaries.
### `runtime/`
- New file `runtime/print_float.c` (or extend an existing C
file) — `void ail_print_float(double v)` that writes the
shortest round-trippable decimal followed by a newline. We
vendor a small `ryu`-style implementation, or — if simpler —
use `printf("%.17g\n", v)` for the milestone and revisit if
the redundancy is a problem.
- The RC runtime (`rc.c`, `bump.c`) is **not touched**. Float
is a value type with no heap allocation per literal.
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)
@@ -437,20 +541,20 @@ fixes the *property set* that must be covered.
### 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
`0x3fd3333333333333`). A single `fadd` cannot be FMA-contracted
(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)
returns `false`. `!=` on the same NaN literal returns `true`.
- `flt`, `fle`, `fgt`, `fge` all return `false` when either
argument is NaN (ordered comparisons).
- `(fdiv 1.0 0.0)` produces `+inf` (bits `7ff0000000000000`);
`(fdiv -1.0 0.0)` produces `-inf` (bits `fff0000000000000`);
`(fdiv 0.0 0.0)` produces *some* NaN (test asserts
`(fne result result) == true` per IEEE NaN-detection idiom,
**not** specific NaN bits — A5 leaves the qNaN payload
unspecified).
- `(== 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 `==`.
@@ -491,8 +595,14 @@ The milestone is closeable when **all** of the following hold:
2. `Literal::Float { bits: u64 }` is serialised as
`{"bits":"<16-hex>","kind":"float"}` in canonical JSON. The
round-trip test passes.
3. The twelve new builtins (per A3) appear in
`ail builtins` output.
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).
@@ -519,15 +629,12 @@ Out of scope, explicitly:
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`).
- 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`.
- 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.
- `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:
@@ -540,25 +647,40 @@ Out of scope, explicitly:
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 builtins (`flt`/`fle`/...) and
the NaN-detection idiom `(fne x x)`.
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.
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. RED-first by
typecheck unit test on `(fadd 1.0 2.0) : Float`.
- **Iteration 4**: Codegen (literal + arithmetic + comparison +
conversion + IO). RED-first by E2E
`examples/floats.ail.json`.
- **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.