832375f2ac
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.
771 lines
33 KiB
Markdown
771 lines
33 KiB
Markdown
# 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:
|
|
|
|
- `<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 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<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 `<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.
|