spec: operator-routing-eq-ord — drop comparator builtins, route through Eq/Ord
Resolves Gitea #1. Realises the "P2 follow-up" called out in examples/prelude.ail line 9. Approach A (single-iter atomic milestone): one cohesive cut across seven layers in one iter, plus the regenerated prelude module hash pin. Three design forks resolved with the user via brainstorm Q&A: (1) Operator-name surface: `==` `!=` `<` `<=` `>` `>=` die from the language. The LLM-author writes only the class-method names `eq` `ne` `lt` `le` `gt` `ge` `compare`. One mental model: AILang has class-dispatch, operators are not a separate concept. Option 2 (keep names as surface aliases) was rejected because it adds a second spelling for an identical operation — the redundancy the milestone is supposed to remove gets reintroduced syntactically. Option 3 (two-track: primitive Built-in for Int/Bool/Str/Unit, class for user-types) was rejected because the two-pathy state is precisely what this milestone exists to dismantle. (2) Float comparison surface: Float keeps comparison capability via six named prelude fns (`float_eq` `float_ne` `float_lt` `float_le` `float_gt` `float_ge`), no `Eq Float` / `Ord Float` instance. Motivation: all three feature-acceptance clauses simultaneously satisfied — clause 1 (LLM-natural via Library-Convention-Pattern like Python's `math.isclose` or Rust's `approx`-crates), clause 2 (within the polymorphic surface there is one path; Float is honestly stamped "non-polymorphic"), clause 3 (the NaN-comparison anti-pattern stays visible in code as `float_eq` rather than hiding behind `eq`). Option A (Float loses all comparison) was rejected as overstretch — workarounds via `is_nan` + arithmetic are more bug-prone than a named fn that emits the right fcmp directly. Option C (Eq Float with IEEE semantics) was rejected as clause-3 violation — it would reintroduce the silent NaN-comparison bug class that the existing Float-no-Eq/Ord design exists to prevent. (3) Codegen mechanism for primitive Eq/Ord instances: option β (always-call through dispatch, with `alwaysinline` attribute on intercept-emitted bodies as pre-emptive `-O0` mitigation, and option α — call-site intercept — held as the bench-gate fallback if measurements show real regression). Motivation: semantic honesty (class methods ARE calls, optimiser folds primitives uniformly at Int and User-Point alike), parity with `Show` (which is also full-call today with no intercept), smaller IR-shape contract for the existing pins. Under `-O2` the inliner deterministically collapses 2- instruction bodies; bench corpus runs `-O2`. Under `-O0` `alwaysinline` overrides the no-inline default, giving the same per-call IR shape as today. α was the initial-framed Recommendation but was scrutinised in user pushback ("spricht irgendwas FÜR β?") and after substantive re-balancing β came out coherent. Grounding-check (ailang-grounding-check) PASS on re-dispatch — 11 load-bearing assumptions ratified by: `crates/ail/tests/e2e.rs::eq_demo` + `::lit_pat_demo`, `crates/ail/tests/eq_ord_e2e.rs::eq_ord_polymorphic_runs_end_to_end` + `::eq_ord_user_adt_runs_end_to_end` + `::eq_ord_user_adt_eq_intbox_hash_stable`, `crates/ail/tests/eq_float_noinstance.rs::eq_at_float_fires_float_aware_noinstance`, `crates/ail/tests/prelude_free_fns.rs::ne_at_int_produces_mono_symbol` (+4 siblings), `crates/ailang-surface/tests/prelude_module_hash_pin.rs::prelude_parse_yields_canonical_hash`, plus in-source `#[cfg(test)] mod tests` ratifiers in `crates/ailang-check/src/lib.rs` (`eq_typechecks_at_int/bool/str/unit`, `mq2_env_method_to_candidate_classes_built`) and `crates/ailang-codegen/src/lib.rs` (`lower_eq_str_calls_strcmp_with_bytes_pointer`). The `alwaysinline` LLVM attribute is correctly classified as new feature-work commitment (no live occurrences today), not as a load-bearing assumption about present state. First grounding-check pass BLOCKED on one spec defect — the spec mischaracterised `crates/ailang-core/src/desugar.rs:2414` as "a separate desugar pass" when it is in fact a `#[cfg(test)] mod tests` AST-literal scaffold. Fixed inline: §Architecture and §Components/4 now enumerate only `build_eq` (desugar.rs:1099) as the sole production desugar site; `desugar.rs:2414`, `lib.rs:6092`, `lib.rs:6220` are framed as test-scaffold migrations alongside the production change, not as desugar-pass work. Re-dispatch PASS. Out of scope (tracked separately): - Deriving for Eq/Ord — `typeclasses.md:177` "No deriving" stands; instance bodies remain hand-written. - Parameterised-ADT instances (`instance Eq (List a)` etc.) — requires constraint-propagation infrastructure, belongs to Gitea #2 ("22c typeclass corpus expansion"). - Eq/Ord for the `Ordering` ADT itself — no use case; consumed via match, not compared. - `and` / `or` as Builtins — north-star fixture uses `(if … … false)` for short-circuit conjunction; separate concern. refs #1
This commit is contained in:
@@ -0,0 +1,546 @@
|
||||
# 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.eq` and `prelude.Ord.compare`
|
||||
with instances for `{Int, Bool, Str}`, plus the polymorphic free-
|
||||
fn helpers `ne`/`lt`/`le`/`gt`/`ge`. Float and Unit have no Eq/Ord
|
||||
instance by design (Float: partial orderability per
|
||||
[float-semantics](../../design/contracts/float-semantics.md);
|
||||
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](../../design/contracts/method-dispatch.md), 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](../../design/contracts/method-dispatch.md).
|
||||
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](../../design/contracts/float-semantics.md)
|
||||
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 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/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:
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
1. **Typechecker builtin table** (`crates/ailang-check/src/builtins.rs`)
|
||||
— six operator-name entries removed (`install` lines 96-98 +
|
||||
109-125); list-side mirror at lines 303-308 trimmed; the in-
|
||||
source `mod tests` helpers `eq_app` and the polymorphic-`==`
|
||||
regression tests (lib.rs:6092, lib.rs:6220 — both inside
|
||||
`#[cfg(test)]`) migrate to the `eq` symbol so the test suite
|
||||
stays green over the new dispatch path.
|
||||
2. **Codegen builtin binop table** (`crates/ailang-codegen/src/synth.rs`)
|
||||
— ten comparator arms removed from `builtin_binop_typed`; the
|
||||
table reduces to the arithmetic core.
|
||||
3. **Codegen primitive-instance intercept**
|
||||
(`crates/ailang-codegen/src/lib.rs`) — `try_emit_primitive_instance_body`
|
||||
gains 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 carries `alwaysinline`.
|
||||
The existing `==`-polymorphic-dispatch lowering (lib.rs around
|
||||
2947, the Int/Bool/Str/Unit/Float matching for `==`) is deleted.
|
||||
4. **Lit-pattern desugar** (`crates/ailang-core/src/desugar.rs`)
|
||||
— `build_eq` (line 1099) substitutes `eq` for `==` in the
|
||||
generated `(if … body fall_k)` form. This is the only live
|
||||
desugar-pass change. Float-pattern rejection per
|
||||
`float-semantics.md` is 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 in
|
||||
`crates/ailang-check/src/lib.rs` (lines 6092 and 6220, also
|
||||
`#[cfg(test)]`) construct hand-built `Term::Var { name: "==" }`
|
||||
in AST literals to drive `==`-specific assertions; these
|
||||
migrate to `name: "eq"` alongside the production change.
|
||||
Counted as test-scaffold migration, not desugar-pass work.
|
||||
5. **Prelude module** (`examples/prelude.ail`) — adds `instance Eq Unit`;
|
||||
rewrites `Eq Int` / `Eq Bool` bodies to placeholder pattern
|
||||
(codegen intercept does the work); adds six `float_*` free fns;
|
||||
removes the "P2 follow-up" comment on `class Eq`. Prelude module
|
||||
hash will shift; the hash pin in
|
||||
`crates/ailang-surface/tests/prelude_module_hash_pin.rs`
|
||||
regenerates as part of the iter.
|
||||
6. **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.
|
||||
7. **Contracts** — three updates:
|
||||
- `design/contracts/float-semantics.md` lines 10-14: the
|
||||
guarantees about `+`/`-`/etc. and `==`/`<`/`!=` lowering to
|
||||
single LLVM instructions are retained for arithmetic but
|
||||
transferred from `==`/`<`/`!=` to `float_eq`/`float_lt`/`float_ne`/etc.
|
||||
for the comparison set. The NaN-spelling caveat is unchanged.
|
||||
- `design/contracts/prelude-classes.md`: instance list extends
|
||||
with `Eq Unit`; a new paragraph documents the six `float_*` fns
|
||||
as the Float-comparison surface; the Float-no-Eq/Ord clause
|
||||
gains a `→ use float_eq` cross-reference.
|
||||
- `design/contracts/typeclasses.md`: no change. Class schema,
|
||||
dispatch rule, and diagnostics are stable; the milestone only
|
||||
activates existing machinery for `eq`/`compare` over more
|
||||
types.
|
||||
|
||||
## Data flow
|
||||
|
||||
A surface `(app eq p1 p2)` flows through the existing pipeline:
|
||||
|
||||
1. **Parse** — `Term::App { fn: Var "eq", args: [p1, p2] }`. No
|
||||
change from today.
|
||||
2. **Typecheck** — class-method-dispatch (per
|
||||
`design/contracts/method-dispatch.md`) consults
|
||||
`method_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__Int`
|
||||
- `p1, p2 : Point` → `eq_user_adt_smoke.eq__Point`
|
||||
- `p1, p2 : Float` → no instance → `NoInstance Eq Float` with
|
||||
Float-aware hint pointing at `float_eq`.
|
||||
3. **Mono** — synth produces the instance-body symbol (existing
|
||||
pass; per `design/contracts/typeclasses.md` invariants 1-3).
|
||||
4. **Codegen** — emits a call to the resolved instance fn. For
|
||||
primitive instances, the body is intercept-lowered to a single
|
||||
icmp/fcmp/call; `alwaysinline` attribute ensures the call folds
|
||||
at every use site under `-O0` as 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/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/`:
|
||||
|
||||
1. **`eq_user_adt_smoke_e2e.rs`** — drives the north-star fixture
|
||||
`examples/eq_user_adt_smoke.ail` through `ail 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
|
||||
to `prelude.eq__Int`.
|
||||
2. **`eq_float_must_fail_pin.rs`** — drives the must-fail fixture
|
||||
`examples/eq_float_must_fail.ail`; asserts `ail check` exits
|
||||
non-zero with stderr containing `NoInstance Eq Float` AND
|
||||
`float_eq`. Protects: the Klausel-3 discriminator stays
|
||||
discriminative; Float-aware diagnostic stays informative.
|
||||
3. **`float_compare_smoke_e2e.rs`** — drives the Float-named-fn
|
||||
fixture; asserts stdout `"true\ntrue\nfalse\n"`. Protects: the
|
||||
six `float_*` fns lower correctly to fcmp; replacement surface
|
||||
for the deleted Float operators stays functional.
|
||||
4. **`operator_names_unbound_pin.rs`** — drives a minimal fixture
|
||||
`(app == 5 5)`; asserts `ail check` exits non-zero with stderr
|
||||
containing `unknown variable: ==`. Protects: the operator-name
|
||||
removal is durable; no codegen path silently re-introduces
|
||||
them.
|
||||
5. **`prelude_eq_alwaysinline_ir_pin.rs`** — compiles
|
||||
`(app eq 5 5)` with `--emit-ir --opt=O0`; asserts the resulting
|
||||
IR contains either `icmp eq i64` directly at the call site OR
|
||||
the `alwaysinline` attribute on `@prelude_eq__Int` such that the
|
||||
inliner is guaranteed to fold under `-O2`. Protects: the
|
||||
`alwaysinline` mitigation 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:
|
||||
|
||||
1. `cargo test --workspace --quiet` — all binaries `0 failed`.
|
||||
2. `examples/eq_user_adt_smoke.ail` exists and builds and runs and
|
||||
prints `"true\nfalse\n"`; the corresponding E2E is green.
|
||||
3. `examples/eq_float_must_fail.ail` exists and `ail check` rejects
|
||||
it with `NoInstance Eq Float` and a `float_eq` hint.
|
||||
4. `examples/float_compare_smoke.ail` exists and runs and prints
|
||||
`"true\ntrue\nfalse\n"`.
|
||||
5. `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.rs`
|
||||
returns matches only in comment / doc context, not as live
|
||||
`env.globals.insert(...)`, `Type::Var { name: "==" }`, or
|
||||
`Some((..., "...))` 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.
|
||||
6. `grep -rln '(app == \|(app != \|(app < \|(app <= \|(app > \|(app >=' examples/`
|
||||
returns zero matches (full fixture migration complete).
|
||||
7. `examples/prelude.ail` contains `instance Eq Unit` and the six
|
||||
`float_*` fns; the line-9 "P2 follow-up" comment on `class Eq`
|
||||
is removed.
|
||||
8. `bench/check.py` exit 0; `bench/compile_check.py` exit 0;
|
||||
`bench/cross_lang.py` exit 0 (full bench-corpus green against
|
||||
the post-milestone baseline regenerated by the iter).
|
||||
9. `design/contracts/float-semantics.md` updated to name `float_eq`
|
||||
/ `float_lt` / `float_ne` / etc. as the comparison guarantees
|
||||
(comparison guarantees on `==`/`<`/`!=` removed; arithmetic
|
||||
guarantees on `+`/`-`/`*`/`/` unchanged).
|
||||
10. `design/contracts/prelude-classes.md` updated: `Eq Unit` in the
|
||||
instance list; new section on the six Float-named comparison
|
||||
fns; the Float-no-Eq/Ord clause carries a `→ use float_eq`
|
||||
cross-reference.
|
||||
11. Closes Gitea #1 via the iter-commit `closes #1` trailer.
|
||||
|
||||
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` / `Ord` for the `Ordering` ADT itself** — no use case;
|
||||
`Ordering` is consumed via `match`, not compared.
|
||||
- **`and` / `or` as Builtins** — north-star fixture uses
|
||||
`(if … … false)` for short-circuit conjunction. Adding `and` /
|
||||
`or` as separate fns or operators is a separate concern; no
|
||||
forcing here.
|
||||
Reference in New Issue
Block a user