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.
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:17lists primitives asInt | Bool | Str | Unit— no Float.crates/ailang-core/src/canonical.rs:14doc comment explicitly says "no floats" becauseserde_json::Number::to_stringis not bit-stable across versions for floating-point values.docs/DESIGN.md:1625mentionsFloatonce 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 LLVMdouble. - The eventual Post-22 Prelude milestone can ship
Show Float, but notEq FloatorOrd 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_hashviacanonical::to_bytes. Today's number path usesserde_json::Number::to_string, which is not bit-stable acrossserde_jsonversions for floats and which cannot represent NaN / ±Inf in JSON at all (they collapse tonull). Routing floats through the string path preserves bit-exact determinism without changingcanonical.rs's number arm. - NaN / -0 / Inf are explicit.
+0.0is0000000000000000,-0.0is8000000000000000, the two are distinct bit patterns and therefore distinct Form-A literals; quiet NaN is7ff8000000000000; ±Inf are7ff0000000000000/fff0000000000000. All preserved bit-for-bit by hash. - No ambiguity.
0.1parsed in surface lex becomesbits = "3fb999999999999a"once; round-tripping back to surface0.1is 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.infinityconstants; 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 isforall a. (a, a) -> Boolwith 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|Floatspec-case in the typechecker can be cleanly replaced by aNum aconstraint. Surface ((+ 1.5 2.5)) does not change. The spec-case is transitional, not load-bearing. %stays monomorphic. IEEEfrem(LLVM op forfmod) 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, notNaN) so they're regular identifiers, no surface-lex special-case.neg_infinstead of-infbecause-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— LLVMsitofp double. Exact for|n| < 2^53, round-to-nearest-even otherwise.float_to_int_truncate : (Float) -> Int— saturating truncation toward zero;NaN → 0. Matches Rustas i64semantics (since 1.45). Total, no UB, noMaybe Intwrapper. Lowered as LLVMfptosi.sat.i64.float_to_str : (Float) -> Str— shortest round-trippable decimal. The runtime C glue calls adtoa-style routine (we link an existingryu-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 (
==returnstruecomparing-0.0 == 0.0per IEEE, but they hash distinctly in Form-A — this is the correct asymmetry). - Unspecified: FMA contraction (LLVM may fold
fadd (fmul a b) cintofma 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) — addLiteral::Float { bits: u64 }variant. Serderename_all = "lowercase"already gives us the"kind": "float"discriminator. Thebitsfield needs a customSerialize/Deserializeto emit/parse the 16-character lowercase hex string (Serde's default foru64is decimal). AddType::float()convenience constructor alongsideType::int()/Type::bool_()/Type::unit().primitives.rs(52 LoC) — append"Float"to bothis_primitive_nameandprimitive_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 theStringpath becausebitsis 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) — addLiteral::Floatbranch to whatever match-arms handleLiteral::Intfor desugar passthrough (likely a small, mechanical change).pretty.rs(204 LoC) — addLiteral::Floatarm; render as shortest round-trippable decimal (mirrors whatfloat_to_strwill do at runtime).
crates/ailang-surface/
-
lex.rs(264 LoC) — extend the digit-lead lexer arm. Today it parses<digits>asTok::Int(i64). New: peek for.ore/Eafter the integer part; if present, parse the full float form (per A2),f64::from_str, thenf64::to_bits()to produceTok::Float(u64). New error variantLexError::InvalidFloat { literal, start }parallel to the existingInvalidInteger. -
parse.rs(2406 LoC) — acceptTok::Float(bits)where a literal is allowed; wrap asTerm::Lit { lit: Literal::Float { bits } }. -
print.rs(734 LoC) —write_litarm forLiteral::Float: reconstructf64::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.ore/E, so1.0prints as"1.0"(or"1e0"), never as"1". Otherwiselex(print(1.0))would re-parse asTok::Int(1)and the round-trip property breaks. Round-trip property:lex(print(L)) == Lfor 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::Floatis well-typed atType::float(). Add the one-arm extension to whateverTerm::Littyping helper exists. -
builtins.rs(183 LoC) — three classes of change:- Widen the existing arithmetic and comparison entries
+/-/*///</<=/>/>=from monomorphic Int toforall a. (a, a) -> a(or... -> Bool) — same shape as today's==entry. Existing==/!=entries unchanged in signature; their dispatch arm gets extended codegen-side. - Install the new builtins:
neg(polymorphic),int_to_float,float_to_int_truncate,float_to_str,is_nan, plus the three constantsnan,inf,neg_inf(each aTerm::Var-resolvable global atType::float(), parallel to__unreachable__). - Install the new effect op
io/print_float.
Extend
list()accordingly soail builtinsreflects every added entry. Thelist()strings serve as the LLM-author's contract — they must be accurate after this iteration. - Widen the existing arithmetic and comparison entries
crates/ailang-codegen/
-
synth.rs(218 LoC) — five sites mirroring the Float primitive into the codegen-side type/op tables:llvm_type(line 14): add"Float" => Ok("double".into())arm parallel to the existing Int/Bool/Str/Unit arms.builtin_ail_type(line 61): widen the+/-/*//,</<=/>/>=,!=entries fromint_int_int/int_int_boolto the polymorphicforall a. (a, a) -> a/forall a. (a, a) -> Boolshape (mirrors whatcheck/builtins.rsinstalls). Add new entries forneg(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 bareType::float()like__unreachable__returnsforall a. a).builtin_effect_op_ret(line 118): add"io/print_float" => Type::unit().type_descriptor(line 129): add"Float" => "Fl"arm. Two letters because the single letterFis taken as the ADT-name prefix (FList,FFoo);Flis unambiguous and short.builtin_binop(line 167): this monomorphic-Int table becomes a transitional artefact. Either convert it into a type-dispatched helper that takes the operandTypeand returns the matching(instr, llvm_ty)pair, or remove the table entirely and let the new dispatch helper inlib.rshandle every site. Plan-time call.
-
lib.rs(2877 LoC) — seven lowering changes:-
Float literal (
Literal::Float { bits }) → emit adoubleconstant. The idiomatic LLVM form for a bit-exact float constant is the hex-float literaldouble 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 placeLiteral::Int { value }is matched today (lines 918, 1215). -
Widen arithmetic dispatch.
+/-/*//are today lowered as monomorphic Int instructions (line 1745 region) using thesynth::builtin_binoptable. Convert the lowering site into a type-dispatched helper analogous to the existing==dispatch (lib.rs:2244-2266): Int arm emitsadd/sub/mul/sdiv i64, Float arm emitsfadd/fsub/fmul/fdiv double, anything else fails with"<op> not supported for type X". The existing test atlib.rs:2627-2630(which pinsdefine i64 @ail_t_add(i64 %arg_a, i64 %arg_b)andadd i64 %arg_a, %arg_b) must stay GREEN. -
Widen comparison dispatch.
</<=/>/>=/!=get the same treatment: Int armicmp s{lt,le,gt,ge} i64/icmp ne i64, Float armfcmp o{lt,le,gt,ge} double/fcmp une doublefor!=(NOTfcmp one—oneis "ordered and not equal" and returnsfalsefornan != nan, which violates IEEE;uneis "unordered or not equal" and matches IEEE / Rust). -
==Float arm in the existing dispatch (line 2263 region) →fcmp oeq doublefor theFloatcase, alongside the existing arms. (!=is handled under #3 above.) -
negpolymorphic — Int armsub i64 0, %x, Float armfneg double %x. Thefneginstruction (LLVM 8+) correctly handles-0.0—0.0 - 0.0is+0.0per IEEE rounding, so afsub double 0.0, %xdesugar would wrongly produce+0.0forneg(+0.0)instead of-0.0. Reject other types with the dispatch's standard diagnostic. -
Conversions and IO:
int_to_float→sitofp i64 ... to double.float_to_int_truncate→@llvm.fptosi.sat.i64.f64intrinsic (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(returnsi1, which is AILangBooldirectly persynth.rs:18).float_to_str→ call into runtime C glue@ail_float_to_str.io/print_float→ parallel toio/print_int(line 2150 region) → call into runtime C glue@ail_print_float.
-
Float constants
nan/inf/neg_inflower asTerm::Varreferences resolving directly to adoubleSSA constant at the use site (LLVM hex-float literal):nan→double 0x7FF8000000000000(canonical qNaN)inf→double 0x7FF0000000000000neg_inf→double 0xFFF0000000000000
This is unlike the
__unreachable__lowering, which emits theunreachableterminator instruction. Constants are SSA values, so the dispatch arm inlower_var(or whereverVarresolution lives) returns(value-string, "double")directly without going through a global declaration.
-
-
ADT / container slot layout — both
i64anddoubleoccupy 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) lowersList<Float>to a concreteList_Flwhose field type isdouble, no bitcast is needed. Thetype_descriptorextension above (Fl) ensures the mangled name is distinct fromList<Int>(List_I). - Polymorphic / type-erased slots, if any. If any
container path uses
i64orptras a generic 8-byte slot (e.g. an erasedBox<a>that doesn't get monomorphised), Float values traversing it needbitcast double to i64going in andbitcast i64 to doublecoming out. The plan verifies which case applies by readingmono.rsandlib.rs::lower_ctor/lower_field_access. The expected outcome is "fully monomorphised, no bitcasts needed", but this is a check, not an assumption.
- Monomorphisation produces concrete slot types. If the
mono pass (
crates/ailang-prose/
lib.rs(2125 LoC) — Form-A ↔ Form-B prose mapping forLiteral::Float. Form-B prose says e.g. "the number 1.5"; Form-A round-trips through bits. Mirrors the existingLiteral::Intprose 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 AILangStrof the shortest round-trippable decimal (no newline). Refcounted as any otherStrin the runtime.
Implementation: vendor a small
ryu-style routine, or — if simpler — usesnprintf("%.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. Theail_float_to_strglue allocates aStr, but uses the existingStrruntime path.
crates/ail/ (CLI)
ail builtinsautomatically picks up the twelve new entries viabuiltins::list()— no CLI-level change needed.
docs/DESIGN.md
- §"Data model"
Literalenumeration: add thefloatkind with thebitshex-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.ore/Ebutf64::from_strrejects 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
bitscannot fit in 16 hex characters is impossible by construction (u64is 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::Errorpropagates up the existingail checkerror path.
Runtime errors:
- None per literal. Arithmetic operations follow IEEE: division
by zero produces
±InforNaN; 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)) == Lfor 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_hashof a module containing a Float literal is stable across runs and acrossserde_jsonpatch versions (mirrors the existing Int regression test).
IEEE-conformance properties
(+ 0.1 0.2)produces the exactbitsof0.1_f64 + 0.2_f64(i.e.bits == 0x3fd3333333333334, not0x3fd3333333333333). A singlefaddcannot be FMA-contracted (nocoperand to absorb), so the result is bit-stable per A5.(== nan nan)returnsfalse.(!= nan nan)returnstrue.<,<=,>,>=all returnfalsewhen either argument is NaN (ordered comparisons viafcmp o*).(/ 1.0 0.0)produces+inf(bits7ff0000000000000);(/ -1.0 0.0)produces-inf(bitsfff0000000000000);(/ 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.0and-0.0hash distinctly in Form-A but compare equal via==.
Conversion properties
int_to_floatexact for|n| < 2^53, rounds half-to-even beyond.float_to_int_truncatetotal:nan → 0,+inf → i64::MAX,-inf → i64::MIN, finite values truncate toward zero.
Codegen / runtime smoke
- An
examples/floats.ail.jsonfixture that exercises literal, arithmetic, comparison, conversion, andio/print_float; runs through the fullail build → executable → stdout-checkchain.
Bench regression
bench/check.py,bench/compile_check.py,bench/cross_lang.pymust 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 fromDESIGN.md.
Acceptance criteria
The milestone is closeable when all of the following hold:
crates/ailang-core/src/primitives.rs::is_primitive_namereturnstruefor"Float". The lockstep test passes.Literal::Float { bits: u64 }is serialised as{"bits":"<16-hex>","kind":"float"}in canonical JSON. The round-trip test passes.ail builtinsoutput reflects: (a) widened signatures for+,-,*,/,<,<=,>,>=(nowforall 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.examples/floats.ail.jsonbuilds, runs, and produces the expected stdout (printed floats round-trip the literal forms).cargo test --workspaceis GREEN.bench/check.py,bench/compile_check.py,bench/cross_lang.pyexit 0.docs/DESIGN.mdcarries the new §"Float semantics" subsection, the extendedLiteralenumeration, the extended primitives list, and the twelve builtin signatures. Thedesign_schema_drift.rstest extends to cover the new anchors.cargo doc --no-depswarning count does not increase from the milestone-open baseline (16 warnings; this is the queued rustdoc-sweep baseline).- JOURNAL milestone-close entry written.
docs/roadmap.mdFloats entry flipped from[~]to[x]; Post-22 Prelude unblocked (itsdepends on: Floatsline 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 Floatinstances. (Float getsShowin the next milestone;EqandOrdwill intentionally not exist forFloatbecause 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 properNum aconstraint when typeclass machinery picks up arithmetic in 22c. f32.fmod/ IEEE-remainder.%stays Int-only this milestone.printrewiring throughShow.show— that is Post-22 Prelude.- Pattern-matching on Float literals (
Pattern::Lit { lit: Literal::Float { ... } }). Semantically dubious — IEEE==semantics meanNaNnever matches any literal pattern, and0.1in a pattern matches only the parsed bit pattern0x3fb999999999999a, not "approximately 0.1". The plan decides between (a) hard-reject Float inPattern::Litat 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)) == Lproperty. - 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) andio/print_float. RED-first by typecheck unit test on(+ 1.0 2.0) : FloatAND(+ 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 E2Eexamples/floats.ail.jsonAND 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.