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.
21 KiB
23 — Eq / Ord Prelude — Design Spec (revised)
Date: 2026-05-11 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude
Supersedes: docs/specs/2026-05-10-23-eq-ord-prelude.md (retired
2026-05-11 in iter gc.1; commit f6d4ba3). Iter 23.1–23.3 shipped
under the original spec and remain in effect; this revised spec
re-frames the remaining work on a corrected architecture.
Goal
Complete milestone 23 by shipping the five free top-level utility
functions ne, lt, le, gt, ge in the auto-loaded prelude,
plus the E2E and DESIGN.md amendments that close the Eq/Ord
surface. Establish the partial-Eq/Ord story for Float as a lived
reality: Float has neither instance, so polymorphic eq x y /
compare x y over a Float fires NoInstance at typecheck — exactly
what DESIGN.md §"Float semantics" prescribes.
The re-brainstorm exists because the original spec's architecture
section silently assumed that polymorphic free fns with class
constraints would be mono-specialised the same way class methods
are. They are not — the typecheck-time mono pass
(crates/ailang-check/src/mono.rs, iter 22b.3) operates only on
class methods, while polymorphic free fns are specialised at codegen
time by crates/ailang-codegen/src/lib.rs::lower_polymorphic_call
(iter 12b). A polymorphic free fn whose body invokes class methods
falls between the two passes. Iter 23.4 BLOCKED three times on this
seam.
The corrected architecture unifies the two specialisers (see Architecture below) and ships the remaining free fns on top of the unified pass.
Prep-commit status (verified against main 2026-05-11 after iter/23.4 branch teardown): prep1 (linearity + checker bare-name) landed on main on 2026-05-11 via cherry-pick from the stranded iter/23.4 branch; it stays load-bearing. prep2 and prep3 (codegen bare-name fall-throughs) were attempted on the stranded iter/23.4 branch and never reached main; with iter/23.4 deleted, they are abandoned as git history without a rollback step. Their architectural intent — make codegen handle bare polymorphic free-fn names directly — is superseded by the mono-unification. The unified pass rewrites bare polymorphic call sites to mono symbols before codegen runs, making the fall-throughs structurally unnecessary.
Show, operator routing, print-rewire, and the heap-Str ABI
remain out of scope; each is its own milestone with substantive
reasons (see "Out of scope" — unchanged from original spec).
Architecture
The milestone no longer consumes the 22b.3 typeclass machinery unchanged. Three artefacts:
1. Mono-Pass unification (the corrected centrepiece)
crates/ailang-check/src/mono.rs::monomorphise_workspace is
restructured into a single specialiser for every
Type::Forall-quantified Def::Fn whose call sites supply
fully-concrete substitutions. Two thin source-body entry points:
- Class-method entry: look up the resolved instance body via
Registry::entries[(class, type-hash)]. Unchanged behaviour from iter 22b.3. - Free-fn entry: take the source body directly from the
polymorphic
Def::Fn, apply rigid-var substitution.
Shared core mechanics — identical for both arms — are: rigid-var
substitution via crate::substitute_rigids, fixpoint loop (mono.rs
line 88) that keeps collecting targets until a round adds nothing
new, dedup cache keyed by (name, type-hash), and the
call-site-rewrite walker.
Output: synthesised Def::Fn entries with naming
<name>__<typesurfacename> (extended to
<name>__<type1>__<type2>__… for N-ary type vars in declaration
order; concrete format is an Open commitment), appended to the
defining module. The mono pass becomes the only specialiser; codegen
sees no class machinery and no polymorphic call sites.
The current codegen-time specialiser is retired in the same iter:
crates/ailang-codegen/src/lib.rs::lower_polymorphic_call,
module_polymorphic_fns (the per-module FnDef map), and mono_queue
(the deduplicated emission queue) are removed. All today's call
sites of lower_polymorphic_call (lines 1944, 1972) become direct
calls to mono symbols after the typecheck-mono walker has rewritten
them.
2. Prelude AILang module
examples/prelude.ail.json already contains, from iters 23.1–23.3:
the Ordering ADT, the Eq class with Eq Int/Bool/Str
instances, the Ord class with Ord Int/Bool/Str instances.
Iter 23.5 adds the five free fns:
ne : forall a. Eq a => (a borrow, a borrow) -> Bool, bodynot (eq x y).lt,le,gt,ge:forall a. Ord a => (a borrow, a borrow) -> Bool, bodies pattern-matchingcompareagainst the appropriateOrderingshapes.
3. C-runtime functions
runtime/str.c shipped its two functions in iter 23.2 and 23.3:
ail_str_eq(a, b) -> bool (byte-equality on constant-string layouts)
and ail_str_compare(a, b) -> i32 (lex comparison returning
-1/0/+1). No further runtime work.
Existing primitive operators (==, <, <=, >, >=) remain
primitive and per-type-routed at lowering, unchanged. Routing them
through the class methods is the declared P2 follow-up; the
redundancy between primitive == and class eq x y on
Int/Bool/Str is acknowledged and ratified as transitional.
Components (iterations)
| Iter | Status | Scope |
|---|---|---|
| 23.1 | shipped (2026-05-10) | Ordering ADT + prelude skeleton + auto-load via check_workspace. |
| 23.2 | shipped (2026-05-10) | class Eq + Eq Int/Bool/Str + ail_str_eq. |
| 23.3 | shipped (2026-05-10) | class Ord extends Eq + Ord Int/Bool/Str + ail_str_compare. |
| 23.4-prep | shipped to main 2026-05-11 (cherry-picked from stranded iter/23.4), kept | commits on main: 0caaced (linearity registers Def::Class method types in globals) + 923dd8c (check bare-name fall-through to implicit-imported free fns). Both resolve names before mono; independent of B1. |
| 23.4-prep2 / 23.4-prep3 | abandoned 2026-05-11 with iter/23.4 deletion — never reached main | Attempted-but-stranded commits were a06159d + c42a0f5 (codegen bare-name fall-throughs); they were anticipatory patches for an approach (codegen-time bare-name handling) the mono-unification supersedes. No rollback step is needed because no rollback target exists on main. |
| 23.4 (revised) | upcoming | Mono-Pass unification. Restructure crates/ailang-check/src/mono.rs::monomorphise_workspace into one specialiser with two source-body entry points (class-method via Registry::entries, free-fn direct). Extend collect_mono_targets to also produce targets for polymorphic free-fn calls with fully-concrete substitutions. Remove lower_polymorphic_call + module_polymorphic_fns + mono_queue from codegen. Amend DESIGN.md §"Resolution and monomorphisation" to describe the pass as covering all Type::Forall-Defs. Tests: existing polymorphic fixtures (poly_id, poly_apply, poly_rec_capture) green; new fixture cmp_max_smoke proves cross-cutting (cmp_max : forall a. Ord a => (a, a) -> a at Int synthesises both cmp_max__Int and nested compare__Int in one fixpoint). |
| 23.5 (revised) | upcoming | Prelude free fns + E2E. Add ne/lt/le/gt/ge to examples/prelude.ail.json. E2E fixtures: (a) positive polymorphic helper at Int/Bool/Str, (b) negative — eq f g at Float typecheck-rejects with Float-aware diagnostic, (c) user-ADT — IntBox with Eq/Ord instances + polymorphic helper monomorphises to the user instance. Amend DESIGN.md §"Decision 11 / Prelude classes" — Eq/Ord shipped; the "Milestone 22 ships no built-in Prelude classes" wording stays as historical accuracy with a forward-pointing addendum. Roadmap: P1 Post-22 Prelude entry from [~] to [x] for Eq/Ord half; Show half stays. |
Data flow
Two trajectories show the unification at work.
Class-method call (eq x y) with x : Int (unchanged)
- Typecheck.
eqresolves toforall a. Eq a => (a borrow, a borrow) -> Bool. Constraint solver matchesEq Intagainstinstance Eq Int; constraint discharged. - Mono (class-method entry).
Registry::entries[(Eq, Int)]lookup, body fromInstanceMethod, rigid-var substa → Int, syntheq__Int. Call siteeq x y→eq__Int x y. - Codegen.
eq__Intlowers toicmp eq i64+zext i1 i8.
Polymorphic free-fn call (ne x y) with x : Int (new with B1)
- Typecheck.
neresolves to a polymorphicDef::Fnwithforall a. Eq a => (a borrow, a borrow) -> Bool. Bare-name lookup reaches the prelude via prep1'senv.module_globalsfall-through. ConstraintEq Intdischarged as above. - Mono (free-fn entry). Body taken directly from
Def::Fn.body, rigid-var substa → Int, synthne__Int. In the same fixpoint round, the body walker detects the nestedeqcall at concreteIntand schedules it as a class-method target. The next round synthesiseseq__Int(or cache-hits).ne__Int's body refers directly toeq__Int. Call sitene x y→ne__Int x y. - Codegen.
ne__Intlowers as any monomorphic fn would; the nestedeq__Intis a direct call. Nolower_polymorphic_callpath involved — the codegen-time specialiser no longer exists.
The fixpoint loop in monomorphise_workspace (line 88) already
iterates until a round adds nothing new. The change is in the
target collector: it must additionally emit MonoTargets for
"call site to polymorphic free-fn Def::Fn with fully-concrete
substitution", not only for class-constraint residuals.
Error handling
Unchanged from original spec:
NoInstance Eq Float/NoInstance Ord Float— fired at typecheck when class resolution selects Float. Diagnostic must cross-reference DESIGN.md §"Float semantics" so the LLM-author immediately learns the partial-Float story.NoInstance Eq <UserType>/NoInstance Ord <UserType>— re-uses 22's existing diagnostic path.OrphanInstance— Decision 11 unchanged. Prelude instances are non-orphan.
New categories under B1:
- Polymorphic-free-fn specialisation failures are
CheckError::Internal. The mono pass runs post-typecheck, so every substitution must have been validated by then. A failure here is a caller-contract violation; mirrors the existing convention insynthesise_mono_fn(mono.rs line 549). - Recursive polymorphic free fns with concrete substitutions —
the existing fixpoint loop closes transitively, same mechanism
used for class-method bodies calling other class methods (e.g.
default ne x y = not (eq x y)). - Codegen-side errors remain not expected. The unification
reduces codegen's responsibility (removes
lower_polymorphic_call), it does not expand it. Any fall-through that survives must beCodegenError::Internal-shaped per the iter-4.4 Floats precedent — neverunimplemented!()panic.
Testing strategy
23.4 — Mono unification
- Regression: existing polymorphic fixtures stay green.
examples/poly_id.ail.json,examples/poly_apply.ail.json,examples/poly_rec_capture.ail.json, and every 22b.3 typeclass fixture must compile and run unchanged. These are the load-bearing proof that codegen-time specialisation was structurally replaceable. - New: composition fixture.
examples/cmp_max_smoke.ail.jsonwithfn cmp_max : forall a. Ord a => (a, a) -> a(body:match compare x y with LT -> y | _ -> x), invoked at Int. The test asserts that after mono, the workspace contains bothcmp_max__Intandcompare__IntasDef::Fn, both produced by the unified pass. This pins the cross-cutting case that iter-23.4-prep3 unsuccessfully tried to patch. - Hash-stability of existing mono symbols.
eq__Int,compare__Str, and the four other already-synthesised symbols must produce identical bodies (and therefore identical hashes) under the unified pass. Pin test in the style ofcrates/ailang-core/src/hash.rs::ct4_migrated_fixtures_have_canonical_form_hashes. - Rollback proof.
cargo test --workspacegreen after removal oflower_polymorphic_call+module_polymorphic_fns+mono_queue. No test relies on the removed symbols. - Bench regression check.
bench/check.py/bench/compile_check.pyre-run after 23.4. The mono pass does more work (two entry points, larger target sets per round); a measurable compile-time shift is expected and ratified per audit convention or rebaselined.
23.5 — Prelude free fns + E2E
- Workspace tests per free fn:
ne,lt,le,gt,geproduce correctBoolon Int/Bool/Str, agree with their primitive-operator counterparts on a small smoke suite. - Positive E2E
examples/eq_ord_polymorphic.ail.json: polymorphic helper invoked at three primitives, prints expected output, compiles and runs end-to-end. - Negative E2E:
eq f gat Float typecheck-rejects with a Float-aware diagnostic; gold-standard test pins the message verbatim. - User-ADT integration:
examples/eq_ord_user_adt.ail.json—data IntBox = MkIntBox Int+instance Eq IntBox+instance Ord IntBox+ polymorphic helper monomorphises to the user instances. - Round-trip: prelude module + every new fixture passes Form-A parser + canonicaliser bit-stable.
- Bench regression check at milestone close: the three bench scripts exit 0 or audit-ratified.
Acceptance criteria
The milestone closes when:
- 23.4 has landed:
crates/ailang-check/src/mono.rs::monomorphise_workspacesynthesises both class-method and polymorphic-free-fn mono Defs in one fixpoint loop.crates/ailang-codegen/src/lib.rs::lower_polymorphic_call+module_polymorphic_fns+mono_queueare removed. (prep2 + prep3 were never on main; no rollback step in scope.) - DESIGN.md §"Resolution and monomorphisation" amended: the
pass description covers all
Type::Forall-quantified Defs, not only class methods. - 23.5 has landed:
examples/prelude.ail.jsoncontainsne,lt,le,gt,gewith correct constraint signatures and bodies. - DESIGN.md §"Decision 11 / Prelude classes" amended: reflects Eq/Ord shipping in milestone 23; the "Milestone 22 ships no built-in Prelude classes" wording stays as historical accuracy with a forward-pointing addendum.
- Tests pass:
cargo test --workspacegreen; existing polymorphic fixtures (poly_id,poly_apply,poly_rec_capture) unchanged; new composition fixture (cmp_max_smoke) green; E2E fixtures from 23.5 green. - Bench regression ratified:
bench/check.py && bench/compile_check.py && bench/cross_lang.pyexit 0 or audit-ratified with journal entry. - Surface acceptance from original spec: a polymorphic helper
forall a. Ord a => (a, a) -> Boolcompiles, monomorphises at three primitive types, runs, and prints the expected output for at least Int and Str; the same helper on Float firesNoInstance Ord Floatat typecheck with a Float-aware diagnostic. - Roadmap update: P1 Post-22 Prelude entry from
[~]to[x]for Eq/Ord half; Show half stays as a separate entry.
Out of scope (deferred, with substantive rationale)
Unchanged from original spec:
Showtypeclass. Defers behind a heap-Str-ABI milestone.instance Show Intneedsint_to_strto allocate a heap-Str — a runtime primitive AILang does not have today.- Operator routing through
Eq/Ord.==,<, etc. stay primitive this milestone. Routing them through the class methods is the declared P2 todo, gated on bench rebaseline confirmation. print-rewire throughShow.show. Same heap-Str dependency.Eq/Ordon Float. No instance per DESIGN.md §"Float semantics". A future iter exposing a partialcompare : Float -> Float -> Maybe Orderingis a separate brainstorm.deriving (Eq, Ord)for user ADTs. No auto-derivation.Ord Orderinginstance. Cute but no concrete demand.
New under B1 unification:
- Higher-rank polymorphism. Stays out of scope per DESIGN.md §"Polymorphism via Type::Forall" (line 2138-2140). The unified mono pass requires fully-concrete substitution at each call site; the same constraint as today, not weakened.
- Polymorphic constants (
Type::ForallonConstDef). The mono pass operates onDef::Fn;const_as_pseudo_fn(mono.rs line 261) is the onlyConstDefbridge and stays unchanged. - User-customisable mono-symbol naming. The naming format is implementation-finalised in 23.4; no user-facing knob.
Open commitments (23.4 / 23.5 implementer)
- Mono-symbol naming for N-ary type vars. Today's class-method
naming
<method>__<typesurfacename>is single-type-var. Polymorphic free fns can have N type vars (e.g.poly_apply : forall a b.). Default proposal:<name>__<typename1>__<typename2>__…in Forall-vars declaration order, hash-stable, covered by the canonical-type-names layer. Implementer may raise an alternative viaNEEDS_CONTEXT. - Target-collector heuristic.
collect_mono_targets(mono.rs line 422) today filters on residual class-constraints. For polymorphic free fns, the collector must additionally detect "call site to polymorphic free-fnDef::Fnwith fully-concrete substitution, regardless of constraints". Implementer decides between extending the existingsynth-residual path or adding a separate walker; both shapes are structurally viable. - Original Free-Fn
Def::Fnretention post-mono.Def::Class/Def::Instanceare kept in the workspace after mono (mono.rs line 40-42, forail describe). Polymorphic Free-FnDef::Fns should be kept analogously — both the source-Def and the synthesised mono-Defs visible. - Float-NoInstance diagnostic wording. Implementer drafts a one-line Float-aware message; orchestrator reviews in spec-compliance phase.
Known costs
- 23.4 mono refactor: estimated +150 / −50 LOC in
crates/ailang-check/src/mono.rs(target-collector extension, source-body entry points, naming helper). - 23.4 codegen cleanup: estimated −200 LOC in
crates/ailang-codegen/src/lib.rs(lower_polymorphic_call,module_polymorphic_fnsfields,mono_queuemachinery). Net code reduction expected. - 23.5 prelude additions:
examples/prelude.ail.json+30-40 lines (five free fns with Forall-constraint signatures and bodies). Three new E2E.ail.jsonfixtures, ~60 lines JSON. Diagnostic test ~20 lines Rust. - Compile-time shift: mono pass does more work (additional
body walks over free fns, potentially larger fixpoint
convergence). Quantified by
bench/compile_check.pypost-23.4; audit-ratified or rebaselined. - DESIGN.md amendments: §"Resolution and monomorphisation" (line 1513+) extends to cover polymorphic free fns; §"Decision 11 / Prelude classes" (line 1659+) reflects Eq/Ord shipping.
Load-bearing assumptions about current behaviour
Surfaced explicitly so the Step 7.5 grounding-check agent has a canonical list to ratify. Each assumption is something this spec relies on as currently-true.
crates/ailang-check/src/mono.rs::monomorphise_workspacetoday is the only typecheck-time specialiser, and it operates exclusively on class-method residuals (no free-fn handling).crates/ailang-codegen/src/lib.rs::lower_polymorphic_calltoday is the only codegen-time specialiser, and it operates exclusively on polymorphic free-fn calls (no class-method handling).- The mono pass produces
Def::Fnentries in the workspace; the codegen-time specialiser emits LLVM symbols without corresponding workspace Defs. examples/prelude.ail.jsontoday contains no free fns (only ADT, classes, instances).- prep2 and prep3 (the codegen bare-name fall-through patches) never reached main. They lived on the stranded iter/23.4 branch which was deleted on 2026-05-11. The current main contains no codegen bare-name fall-through for polymorphic free-fn calls — codegen rejects bare polymorphic free-fn calls today, which is exactly what makes the original iter-23.4 BLOCK.
- Commits on main
0caaced(prep.1, linearity registersDef::Classmethod types in globals) and923dd8c(prep.2, check bare-name fall-through to implicit-imported free fns) operate at typecheck / linearity time — before any mono pass — and are load-bearing for any prelude free-fn resolution regardless of mono architecture. (Originals from stranded branch:8d39f13andaef4ab8; cherry-picked onto main 2026-05-11.) crates/ailang-codegen/src/lib.rs::lower_polymorphic_callis invoked from two call sites today (lines 1944, 1972), both of which become direct mono-symbol calls after 23.4.examples/poly_id.ail.jsonandexamples/poly_apply.ail.jsontoday rely on the codegen-time specialiser path; under B1 they transition to the typecheck-time mono pass without surface behavioural change.- The mono pass's fixpoint loop (mono.rs line 88) iterates until a round adds nothing new, and already supports transitive specialisation across nested class-method calls; the same mechanism handles a free-fn body whose specialisation triggers a class-method specialisation.
- The canonical-form layer (per
docs/specs/0007-canonical-type-names.md, ct.4 closure) treats mono Defs as post-typecheck artefacts not inCheckedModule.symbols, so renaming or extending mono symbols does not affect surface-level hash pins.