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.
24 KiB
Operator routing through Eq / Ord — Design Spec
Date: 2026-05-20 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude
Resolves Gitea #1. Realises the "P2 follow-up" called out in
examples/prelude.ail:9 — routing the surface comparator names
through the prelude.Eq / prelude.Ord classes instead of the
hard-coded built-in comparator table.
Goal
Today AILang has two parallel paths for comparison:
- Built-in operators
==,!=,<,<=,>,>=— special- cased in codegen with hardcoded polymorphism over fixed primitive type sets (==over{Int, Bool, Str, Unit, Float}; the other five over{Int, Float}). User ADTs are rejected at codegen with a generic error. - Typeclass methods
prelude.Eq.eqandprelude.Ord.comparewith instances for{Int, Bool, Str}, plus the polymorphic free- fn helpersne/lt/le/gt/ge. Float and Unit have no Eq/Ord instance by design (Float: partial orderability per float-semantics; Unit: oversight that today's(app == () ())builtin path hides).
The two paths are redundant — instance Eq Int body literally
calls (app == x y), so the class layer today delegates back to
the builtin. The redundancy is structurally observable: every
release pays the cost of maintaining both. User ADTs cannot
participate in == at all, even when the LLM-author has written
instance Eq Point — the operator is sealed at the primitive
types.
After this milestone, the LLM-author writes (app eq p1 p2) for
user types and gets the natural typeclass-dispatched behaviour;
==/!=/</<=/>/>= as identifiers are gone from the
language; Float comparison is named explicitly via float_eq /
float_lt / etc. (no IEEE-NaN-bug-class smuggled in via a
polymorphic eq).
Architecture
Three layers shift in lockstep:
Surface / parser: unchanged. The Form-A tokens == / != / <
/ <= / > / >= remain legal identifier strings at the parser
level — there is no syntactic change. They are simply not
recognised at typecheck or codegen.
Typechecker: the entries for == / != / < / <= / > /
>= in the builtin signature table
(crates/ailang-check/src/builtins.rs::install — comparator loop
at lines 96-98, polymorphic == Forall block at lines 109-125,
list-side mirror at lines 303-308) are removed. Surface use of these
names resolves like any other unknown identifier — first via the
class-method-dispatch index per
method-dispatch, which
finds no candidate (the method name is eq, not ==), then via
fn lookup, which also finds none, then fails with the standard
unknown variable diagnostic. The diagnostic does NOT special-case
the comparator names with a "did you mean eq?" hint — the
milestone is a clean break, not a transition aid; AILang has no
external users requiring migration.
All (app eq …) / (app compare …) / (app ne …) / (app lt …)
/ etc. resolve through the existing class-method-dispatch machinery
documented in
method-dispatch.
No new dispatch rule.
Codegen: try_emit_primitive_instance_body in
crates/ailang-codegen/src/lib.rs (the intercept already used for
eq__Str, compare__Int, compare__Bool, compare__Str)
gains three new arms — eq__Int, eq__Bool, eq__Unit. Every
primitive instance body emitted via this intercept carries an
alwaysinline attribute on the generated LLVM function so the
-O0 build path collapses the trivial body to its single
icmp/fcmp/call instruction at every use site, matching
today's direct-emit IR shape under -O0 as well as -O2.
Six new prelude free functions ship for Float comparison:
float_eq, float_ne, float_lt, float_le, float_gt,
float_ge, each Float -> Float -> Bool without a class
constraint. Their bodies are intercept-lowered to the corresponding
fcmp instruction (oeq / une / olt / ole / ogt / oge),
preserving the bit-exact IEEE semantics
float-semantics
guarantees today for == / < etc. on Float — the guarantee is
transferred from the deleted operator names to the named Float
fns.
The lit-pattern desugar in
crates/ailang-core/src/desugar.rs::build_eq (line 1099) rewrites
(pat-lit "hi") to (if (eq sv "hi") body fall_k) instead of the
current (if (== sv "hi") body fall_k) — the literal symbol
Term::Var { name: "==" } at line 1109 becomes name: "eq".
This is the single live ==-emitting desugar site; everything
else mechanically migrates as fixture or test-scaffold work, not
as desugar-pass changes. This is the non-obvious mechanical
follow: lit patterns inherit equality dispatch by construction.
Concrete code shapes
The LLM-author program this milestone enables
(module eq_user_adt_smoke
(data Point
(ctor Point (con Int) (con Int)))
(instance
(class prelude.Eq)
(type (con Point))
(method eq
(body (lam (params (typed p1 (con Point)) (typed p2 (con Point))) (ret (con Bool))
(body (match p1
(case (pat-ctor Point a1 b1)
(match p2
(case (pat-ctor Point a2 b2)
(if (app eq a1 a2)
(app eq b1 b2)
false))))))))))
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(let p1 (term-ctor Point 1 2)
(let p2 (term-ctor Point 1 2)
(let p3 (term-ctor Point 1 3)
(seq (app print (app eq p1 p2))
(app print (app eq p1 p3)))))))))
Expected stdout: true\nfalse\n. This is the feature-acceptance
clause-1 evidence: the LLM-natural form that is structurally
impossible to write today. Today (app == p1 p2) is rejected at
codegen (Point is not in the polymorphic == type set);
(app eq p1 p2) fires NoInstance Eq Point even after the author
writes the instance, because the body's nested (app eq a1 a2) on
Int delegates to (app == x y) which then has to thread back via
the polymorphic-== mechanism — the chain works but the author
gets the same outcome as writing == directly, no gain.
After this milestone, both the outer (app eq p1 p2) and the
nested (app eq a1 a2) resolve via the canonical class-dispatch
path: the outer to eq_user_adt_smoke.eq__Point (the user
instance), the nested to prelude.eq__Int (the primitive
instance, intercept-lowered to icmp eq i64 and inlined back to
the call site).
The Klausel-3 discriminator (must fail at typecheck)
(module eq_float_must_fail
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app eq 1.5 1.5)))))
Must produce a typecheck-time diagnostic — NoInstance Eq Float
with a follow-up sentence Float has no Eq instance by design (partial orderability per design/contracts/0005-float-semantics.md); use float_eq for explicit IEEE-aware comparison. The diagnostic is the
existing NoInstance channel; the Float-aware addendum is the
existing Float-specific hint in crates/ailang-check/src/lib.rs
around line 860 (which already special-cases Eq/Ord-at-Float
diagnostics), extended to name float_eq / float_lt as the
explicit alternative.
The Float-with-named-fn happy path
(module float_compare_smoke
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body
(seq (app print (app float_eq 1.5 1.5))
(seq (app print (app float_lt 1.0 2.0))
(app print (app float_eq 1.0 0.0)))))))
Expected stdout: true\ntrue\nfalse\n. Demonstrates that Float
comparison remains available via the named-fn route. The
implementation lowers each float_* call to a single fcmp
instruction via the intercept mechanism — IR-byte-equivalent to
today's (app == 1.5 1.5) after the operator name is removed.
Migration pattern for existing fixtures
; examples/eq_demo.ail (current)
(seq (app print (app == 5 5))
(seq (app print (app == true false))
(app print (app == "hi" "hi"))))
; examples/eq_demo.ail (after this milestone)
(seq (app print (app eq 5 5))
(seq (app print (app eq true false))
(app print (app eq "hi" "hi"))))
≈10 fixtures under examples/ use one or more of the deleted
operator names. Each migrates to the corresponding class-method
or named-fn (Float case): == → eq, != → ne, < → lt,
<= → le, > → gt, >= → ge. The Float-operator uses in
bench_compute_collatz.ail (and any other Float-touching bench
fixture) migrate to float_lt / float_eq / etc. The exact
fixture list is enumerated by the planner via
grep -rln '(app == \|(app != \|(app < \|(app <= \|(app > \|(app >=' examples/.
Implementation shape (secondary — supporting detail)
The load-bearing code changes (planner derives exact paths/line numbers; this section establishes shape):
crates/ailang-codegen/src/synth.rs::builtin_binop_typed — the
comparator arms vanish:
// Before — 10 arms for ==/!=/</<=/>/>= over Int+Float
match (name, is_int, is_float) {
("+", true, _) => Some(("add", "i64", "i64")),
("+", _, true) => Some(("fadd", "double", "double")),
// ... arithmetic ...
("!=", true, _) => Some(("icmp ne", "i64", "i1")),
("<", true, _) => Some(("icmp slt", "i64", "i1")),
("<=", true, _) => Some(("icmp sle", "i64", "i1")),
(">", true, _) => Some(("icmp sgt", "i64", "i1")),
(">=", true, _) => Some(("icmp sge", "i64", "i1")),
("!=", _, true) => Some(("fcmp une", "double", "i1")),
("<", _, true) => Some(("fcmp olt", "double", "i1")),
("<=", _, true) => Some(("fcmp ole", "double", "i1")),
(">", _, true) => Some(("fcmp ogt", "double", "i1")),
(">=", _, true) => Some(("fcmp oge", "double", "i1")),
_ => None,
}
// After — arithmetic only
match (name, is_int, is_float) {
("+", true, _) => Some(("add", "i64", "i64")),
("+", _, true) => Some(("fadd", "double", "double")),
("-", true, _) => Some(("sub", "i64", "i64")),
("-", _, true) => Some(("fsub", "double", "double")),
("*", true, _) => Some(("mul", "i64", "i64")),
("*", _, true) => Some(("fmul", "double", "double")),
("/", true, _) => Some(("sdiv", "i64", "i64")),
("/", _, true) => Some(("fdiv", "double", "double")),
("%", true, _) => Some(("srem", "i64", "i64")),
_ => None,
}
crates/ailang-codegen/src/lib.rs — the ==-polymorphic-dispatch
special case (lib.rs around 2947 plus the Int/Bool/Str/Unit
matching) is gone; replaced by try_emit_primitive_instance_body
arms for the new symbol set:
// New arms in try_emit_primitive_instance_body's match
match instance_key {
"eq__Int" => emit_icmp_eq("i64"), // new
"eq__Bool" => emit_icmp_eq("i1"), // new
"eq__Unit" => emit_const_i1_true(), // new
"eq__Str" => /* existing — @ail_str_eq */,
"compare__Int" | "compare__Bool" | "compare__Str" => /* existing */,
// 6 new Float-named-fn arms
"float_eq" => emit_fcmp("oeq", "double"),
"float_ne" => emit_fcmp("une", "double"),
"float_lt" => emit_fcmp("olt", "double"),
"float_le" => emit_fcmp("ole", "double"),
"float_gt" => emit_fcmp("ogt", "double"),
"float_ge" => emit_fcmp("oge", "double"),
_ => return None,
}
Each emit_* helper adds attributes #N = { alwaysinline … } to
the generated function header so the -O0 build path inlines the
single-instruction body at every call site, matching today's
direct-emit shape.
examples/prelude.ail — three additions, three rewrites:
; New: Eq Unit instance (preserves today's `==`-on-Unit capability).
(instance
(class Eq)
(type (con Unit))
(doc "Eq Unit. Body is constant true — Unit is single-inhabitant
so all values compare equal. Lowered via
try_emit_primitive_instance_body::eq__Unit to `ret i1 1`.")
(method eq
(body (lam (params (typed x a) (typed y a)) (ret (con Bool))
(body true)))))
; Eq Int — body becomes a placeholder (lambda returning false);
; codegen intercept overrides to icmp eq i64. Matches Ord Int's
; existing placeholder pattern.
(instance
(class Eq)
(type (con Int))
(doc "Eq Int. Body is placeholder for round-trip stability;
codegen intercept emits `icmp eq i64` with alwaysinline.")
(method eq
(body (lam (params (typed x a) (typed y a)) (ret (con Bool))
(body false)))))
; Eq Bool — same placeholder shape.
; Eq Str — body unchanged in shape (already placeholder-style),
; intercept already exists.
; New Float-fns: six entries.
(fn float_eq
(doc "IEEE-aware Float equality. `float_eq x y` returns true iff
both operands are non-NaN and bit-equal. Lowered to
`fcmp oeq double`. Replaces the milestone-deleted `(app == x y)`
on Float.")
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
(params x y)
(body (lam (params (typed x (con Float)) (typed y (con Float))) (ret (con Bool))
(body false))))
; ... float_ne / float_lt / float_le / float_gt / float_ge analogous.
; Line-9 comment on `class Eq` (the "P2 follow-up" note) is removed —
; routing-through-Eq.eq is no longer follow-up, it's the present.
Components
The milestone touches seven layers, all in one cohesive iter:
-
Typechecker builtin table (
crates/ailang-check/src/builtins.rs) — six operator-name entries removed (installlines 96-98 + 109-125); list-side mirror at lines 303-308 trimmed; the in- sourcemod testshelperseq_appand the polymorphic-==regression tests (lib.rs:6092, lib.rs:6220 — both inside#[cfg(test)]) migrate to theeqsymbol so the test suite stays green over the new dispatch path. -
Codegen builtin binop table (
crates/ailang-codegen/src/synth.rs) — ten comparator arms removed frombuiltin_binop_typed; the table reduces to the arithmetic core. -
Codegen primitive-instance intercept (
crates/ailang-codegen/src/lib.rs) —try_emit_primitive_instance_bodygains three Eq arms (eq__Int,eq__Bool,eq__Unit) and six Float-fn arms (float_eq/float_ne/float_lt/float_le/float_gt/float_ge); each generated function header carriesalwaysinline. The existing==-polymorphic-dispatch lowering (lib.rs around 2947, the Int/Bool/Str/Unit/Float matching for==) is deleted. -
Lit-pattern desugar (
crates/ailang-core/src/desugar.rs) —build_eq(line 1099) substituteseqfor==in the generated(if … body fall_k)form. This is the only live desugar-pass change. Float-pattern rejection perfloat-semantics.mdis unaffected (still hard-rejected at typecheck, before desugar runs).In-source test scaffolds in the same file (
desugar.rs:2414, inside#[cfg(test)] mod tests) and incrates/ailang-check/src/lib.rs(lines 6092 and 6220, also#[cfg(test)]) construct hand-builtTerm::Var { name: "==" }in AST literals to drive==-specific assertions; these migrate toname: "eq"alongside the production change. Counted as test-scaffold migration, not desugar-pass work. -
Prelude module (
examples/prelude.ail) — addsinstance Eq Unit; rewritesEq Int/Eq Boolbodies to placeholder pattern (codegen intercept does the work); adds sixfloat_*free fns; removes the "P2 follow-up" comment onclass Eq. Prelude module hash will shift; the hash pin incrates/ailang-surface/tests/prelude_module_hash_pin.rsregenerates as part of the iter. -
Fixture migration — ≈10 files under
examples/rewritten from operator-form to method-form. The planner enumerates the exact set via grep and assigns it as a discrete task. -
Contracts — three updates:
design/contracts/0005-float-semantics.mdlines 10-14: the guarantees about+/-/etc. and==/</!=lowering to single LLVM instructions are retained for arithmetic but transferred from==/</!=tofloat_eq/float_lt/float_ne/etc. for the comparison set. The NaN-spelling caveat is unchanged.design/contracts/0017-prelude-classes.md: instance list extends withEq Unit; a new paragraph documents the sixfloat_*fns as the Float-comparison surface; the Float-no-Eq/Ord clause gains a→ use float_eqcross-reference.design/contracts/0013-typeclasses.md: no change. Class schema, dispatch rule, and diagnostics are stable; the milestone only activates existing machinery foreq/compareover more types.
Data flow
A surface (app eq p1 p2) flows through the existing pipeline:
- Parse —
Term::App { fn: Var "eq", args: [p1, p2] }. No change from today. - Typecheck — class-method-dispatch (per
design/contracts/0016-method-dispatch.md) consultsmethod_to_candidate_classes["eq"]→{prelude.Eq}. Singleton class survivor; type-driven filter against the workspace registry for the resolved type:p1, p2 : Int→prelude.eq__Intp1, p2 : Point→eq_user_adt_smoke.eq__Pointp1, p2 : Float→ no instance →NoInstance Eq Floatwith Float-aware hint pointing atfloat_eq.
- Mono — synth produces the instance-body symbol (existing
pass; per
design/contracts/0013-typeclasses.mdinvariants 1-3). - Codegen — emits a call to the resolved instance fn. For
primitive instances, the body is intercept-lowered to a single
icmp/fcmp/call;
alwaysinlineattribute ensures the call folds at every use site under-O0as well as-O2. For user-ADT instances, codegen emits the full lambda body normally; the call to the instance fn is normal (no intercept).
The data-flow is conceptually identical to today's show /
compare paths — the milestone unifies eq into the same shape.
Error handling
Three diagnostic situations:
NoInstance Eq Float / NoInstance Ord Float — fires at
typecheck for any (app eq …) or (app compare …) (or
ne/lt/le/gt/ge) on Float. The existing Float-aware
addendum (lib.rs:860-873) is extended:
Eq has no instance at Float — Float has no Eq/Ord instance by
design (partial orderability per design/contracts/0005-float-semantics.md);
use float_eq for explicit IEEE-aware comparison.
compare/Ord use case names float_lt / float_compare (the
latter doesn't ship as a fn — compare returns Ordering, no
Float equivalent — so the hint says "use float_lt / float_eq for
explicit IEEE-aware comparison").
NoInstance Eq <UserType> — fires when the LLM-author calls
(app eq p1 p2) on a user type without having written the
instance. Standard NoInstance channel; no special-case wording.
The author writes instance Eq <UserType> by hand (no deriving).
unknown variable: == (et al.) — fires when the LLM-author
writes (app == 5 5) after the milestone. The diagnostic does
NOT carry a "did you mean eq?" hint. This is a clean break, not
a transition; AILang has no external users to migrate. The lack of
hint is documented in the prelude-classes.md contract update
("comparator-operator names are not part of the language").
Testing strategy
Five test artefacts, all in crates/ail/tests/:
eq_user_adt_smoke_e2e.rs— drives the north-star fixtureexamples/eq_user_adt_smoke.ailthroughail build+ binary execution; asserts stdout"true\nfalse\n". This is the milestone-defining E2E. Protects: user-ADT-Eq path works end-to- end, including cross-module reference from user-instance body toprelude.eq__Int.eq_float_must_fail_pin.rs— drives the must-fail fixtureexamples/eq_float_must_fail.ail; assertsail checkexits non-zero with stderr containingNoInstance Eq FloatANDfloat_eq. Protects: the Klausel-3 discriminator stays discriminative; Float-aware diagnostic stays informative.float_compare_smoke_e2e.rs— drives the Float-named-fn fixture; asserts stdout"true\ntrue\nfalse\n". Protects: the sixfloat_*fns lower correctly to fcmp; replacement surface for the deleted Float operators stays functional.operator_names_unbound_pin.rs— drives a minimal fixture(app == 5 5); assertsail checkexits non-zero with stderr containingunknown variable: ==. Protects: the operator-name removal is durable; no codegen path silently re-introduces them.prelude_eq_alwaysinline_ir_pin.rs— compiles(app eq 5 5)with--emit-ir --opt=O0; asserts the resulting IR contains eithericmp eq i64directly at the call site OR thealwaysinlineattribute on@prelude_eq__Intsuch that the inliner is guaranteed to fold under-O2. Protects: thealwaysinlinemitigation is wired (not silently dropped); the bench-gate risk is structurally bounded.
Plus: the bench corpus (latency + throughput + cross-lang) runs as
the acceptance gate. The expectation is 0/0/0 regressions; if
regressions appear, the alwaysinline-mitigation needs
investigation (likely codegen-bug) or the spec needs revisiting
toward α (call-site intercept).
Existing tests stay green by virtue of the fixture migration —
every ==/</etc. in test fixtures is rewritten alongside the
language change. The lit-pattern regression suite
(if exists in crates/ailang-check/tests/ per planner-recon)
exercises (case (pat-lit "hi") …) paths after the desugar
rewrites to eq; these stay green provided the desugar is updated
correctly.
Acceptance criteria
The milestone ships when all of the following hold:
cargo test --workspace --quiet— all binaries0 failed.examples/eq_user_adt_smoke.ailexists and builds and runs and prints"true\nfalse\n"; the corresponding E2E is green.examples/eq_float_must_fail.ailexists andail checkrejects it withNoInstance Eq Floatand afloat_eqhint.examples/float_compare_smoke.ailexists and runs and prints"true\ntrue\nfalse\n".grep -rn '"=="\|"<"\|"<="\|">"\|">="\|"!="' crates/ailang-check/src/builtins.rs crates/ailang-codegen/src/synth.rs crates/ailang-codegen/src/lib.rs crates/ailang-core/src/desugar.rsreturns matches only in comment / doc context, not as liveenv.globals.insert(...),Type::Var { name: "==" }, orSome((..., "...))table entries. Documentation comments referring historically to the removed names are permitted only in commit-bodies and contract-history sections, not as live code paths.grep -rln '(app == \|(app != \|(app < \|(app <= \|(app > \|(app >=' examples/returns zero matches (full fixture migration complete).examples/prelude.ailcontainsinstance Eq Unitand the sixfloat_*fns; the line-9 "P2 follow-up" comment onclass Eqis removed.bench/check.pyexit 0;bench/compile_check.pyexit 0;bench/cross_lang.pyexit 0 (full bench-corpus green against the post-milestone baseline regenerated by the iter).design/contracts/0005-float-semantics.mdupdated to namefloat_eq/float_lt/float_ne/ etc. as the comparison guarantees (comparison guarantees on==/</!=removed; arithmetic guarantees on+/-/*//unchanged).design/contracts/0017-prelude-classes.mdupdated:Eq Unitin the instance list; new section on the six Float-named comparison fns; the Float-no-Eq/Ord clause carries a→ use float_eqcross-reference.- Closes Gitea #1 via the iter-commit
closes #1trailer.
Out of scope (tracked separately):
- Deriving for Eq/Ord —
typeclasses.md:177"No deriving" stands. LLM-author writes instance bodies by hand. If deriving becomes a real friction point post-milestone, that is its own spec (likely tied to Gitea #2 "22c typeclass corpus expansion"). - Parameterised-ADT instances (
instance Eq (List a), etc.) — requires multi-parameter-class / constraint-propagation infrastructure, part of Gitea #2. Eq/Ordfor theOrderingADT itself — no use case;Orderingis consumed viamatch, not compared.and/oras Builtins — north-star fixture uses(if … … false)for short-circuit conjunction. Addingand/oras separate fns or operators is a separate concern; no forcing here.