Iter 16e: == polymorphic over Int / Bool / Str / Unit

Lifts the Int-only restriction on `==`. Declared type becomes
forall a. Fn(a, a) -> Bool; codegen monomorphises and dispatches:

  Int  → icmp eq i64
  Bool → icmp eq i1
  Str  → call @strcmp + icmp eq i32 0
  Unit → constant true (operands still emitted for side effects)
  ADT / Fn / other → CodegenError::Internal

This unblocks 16c's build_eq for non-Int lit patterns. == joins
__unreachable__ as the second polymorphic builtin (same Forall
machinery).

- check/builtins.rs: == registered as Forall(a, Fn(a, a) -> Bool).
- codegen: lower_eq dispatch table; @strcmp declared in IR header
  alongside @printf/@GC_malloc/@puts.
- examples/eq_demo.{ailx,ail.json}: covers all four supported
  scalars including a Str-lit-pattern match.
- IR snapshots refreshed: only +declare i32 @strcmp(ptr, ptr) in
  the header; every define body bit-identical.
- e2e + check + codegen tests: 124 → 133 (+9, of which +1 is the
  e2e fixture and the rest exercise the new dispatch / typecheck
  surface).

Other comparison ops (<, <=, >, >=, !=) remain Int-only — out of
scope for this iter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 23:12:24 +02:00
parent af35612c1d
commit 937782e36d
13 changed files with 666 additions and 22 deletions
+42 -18
View File
@@ -773,7 +773,11 @@ collector wired up in Iter 14f, see Decision 9); nested constructor
sub-patterns inside `match` (lifted in Iter 16a via the desugar pass);
literal sub-patterns inside a Ctor pattern (lifted in Iter 16c — the
desugar pass rewrites every `Pattern::Lit` to a `Term::If` on `==`,
both at the top level of an arm and inside a Ctor sub-pattern).
both at the top level of an arm and inside a Ctor sub-pattern);
`==` extended from Int-only to a polymorphic
`forall a. (a, a) -> Bool` over `Int`/`Bool`/`Str`/`Unit` (lifted
in Iter 16e — codegen monomorphises and dispatches on the
resolved arg type; ADT/Fn arg types are rejected at codegen).
- No effect handlers — only the built-in IO and Diverge ops.
- No refinements / SMT escalation.
@@ -798,19 +802,39 @@ What **is** supported (and used as the smoke test for the pipeline):
- `if`, `let`, function calls, recursion.
- Effects on function signatures, with `do op(args)` for direct effect
ops (`io/print_int`, `io/print_bool`, `io/print_str`).
- **Builtins.** Arithmetic and comparison operators (`+`, `-`, `*`,
`/`, `%`, `==`, `!=`, `<`, `<=`, `>`, `>=`) of type
`(Int, Int) -> Int` / `Bool`; logical `not : (Bool) -> Bool`; the
IO effect ops listed above; and **`__unreachable__ : forall a. a`**
(Iter 16d). The latter is a polymorphic bottom value: a use of
`__unreachable__` typechecks against any expected type at the use
site and codegens to the LLVM `unreachable` instruction (UB if
ever executed). It is the chain machinery's deepest fall-through
for matches that the typechecker proved exhaustive, and it is
available to user code as an explicit panic primitive
(`(if cond __unreachable__ ...)` for assertions or impossible
branches). Reference site is `Term::Var { name = "__unreachable__" }`
/ form-A bare `__unreachable__`.
- **Builtins.** Arithmetic operators (`+`, `-`, `*`, `/`, `%`) of
type `(Int, Int) -> Int`; ordering operators and `!=` (`!=`,
`<`, `<=`, `>`, `>=`) of type `(Int, Int) -> Bool`; logical
`not : (Bool) -> Bool`; the IO effect ops listed above;
**`==` : forall a. (a, a) -> Bool** (Iter 16e); and
**`__unreachable__ : forall a. a`** (Iter 16d).
- **`==` is polymorphic** (Iter 16e). The typechecker accepts
`==` at any type whose two sides agree (the rigid `a` of the
`Forall` is unified by HM at the use site). Codegen
monomorphises and dispatches on the resolved AIL arg type:
`Int` → `icmp eq i64`; `Bool` → `icmp eq i1`;
`Str` → `call @strcmp(ptr, ptr)` then `icmp eq i32 0`
(`@strcmp` is declared in the LLVM IR header alongside
`@printf` / `@GC_malloc`); `Unit` → constant `i1 true`
(Unit has a single inhabitant; both sides are still
evaluated for any side effects). ADT and `Fn` arg types are
rejected at codegen with a `CodegenError::Internal`
mentioning `==` and the offending type — neither has a
canonical structural-equality scheme yet, and the language
deliberately does not silently elide the check. The other
comparison ops stay Int-only; their codegen path emits
`icmp s{lt,le,gt,ge,ne}` over `i64`.
- **`__unreachable__`** is a polymorphic bottom value: a use of
`__unreachable__` typechecks against any expected type at
the use site and codegens to the LLVM `unreachable`
instruction (UB if ever executed). It is the chain
machinery's deepest fall-through for matches that the
typechecker proved exhaustive, and it is available to user
code as an explicit panic primitive
(`(if cond __unreachable__ ...)` for assertions or
impossible branches). Reference site is
`Term::Var { name = "__unreachable__" }` / form-A bare
`__unreachable__`.
- **ADTs + pattern matching** (Iter 3, extended in Iter 16a/16c).
Sub-patterns of a Ctor pattern may be `Var`, `Wild`, another `Ctor`
(Iter 16a), or a literal (Iter 16c). The desugar pass flattens
@@ -820,10 +844,10 @@ What **is** supported (and used as the smoke test for the pipeline):
- Literal patterns at top level and inside Ctor sub-patterns (Iter 16c,
via desugar). `(pat-lit 0)` and `(pat-ctor Cons (pat-lit 0) _)` both
parse and lower; the rewrite is to `Term::If { cond = (== sv lit) }`,
so any literal kind whose `==` is supported by the typechecker is
authorable. Today that means `Int`; `Bool`/`Str`/`Unit` lit patterns
are accepted by desugar but reach typecheck as a type error because
`==` is `(Int, Int) -> Bool` only.
so any literal kind whose `==` is supported is authorable. After
Iter 16e (`==` polymorphic over `Int`/`Bool`/`Str`/`Unit`), that
covers every lit kind the AST ships — including `(pat-lit "hi")`
over a `Str` scrutinee, exercised by `examples/eq_demo.ail.json`.
- **Imports + qualified cross-module references** via dotted names
(Iter 5). Extends to **types and constructors** (Iter 14h): a foreign
module's ADT is referenced as `(con std_pair.Pair a b)`, its ctors as
+205
View File
@@ -4646,3 +4646,208 @@ addresses, not 16d.
Bool/Str/Unit — surfaced by 16c, unchanged); 17a (per-fn
arena, gated — unchanged); 16c-aux (`std_list::take`/`drop`
refactor onto lit patterns — unchanged).
## Iter 16e — `==` extends to Bool/Str/Unit (polymorphic dispatch)
**Goal.** Lift the last gate the 16c entry left open: `==` was
declared `(Int, Int) -> Bool`, so 16c's `build_eq` rewrite of a
non-Int `Pattern::Lit` (`(pat-lit "hi")`, `(pat-lit true)`, etc.)
desugared to a well-formed AST but failed at typecheck because
the `==` call's arg types could not unify with `Int`. 16e
extends `==` to a polymorphic operator usable on the four
language-level scalar types — `Int`, `Bool`, `Str`, `Unit` —
and cleans the gap left by 16c so every `Literal` kind that
the AST ships is now usable in a `(pat-lit ...)` position.
**Pre-16e state.** From the 16d journal: `==` lived as
`("==", "(Int, Int) -> Bool")` in `ailang-check::builtins` (line
~140). 16c's `build_eq` already produced `(app == lhs rhs)` for
every non-Unit literal kind; for `Unit` it short-circuited to
`Term::Lit { Bool { true } }` (every `()` is equal). Without
16e, the codegen rejection of non-Int `==` was preceded by a
typecheck rejection: `forall a. (a, a) -> Bool` was simply not
the declared type, so unification of `Bool` against `Int` (via
`int_int_bool`) blew up first. Iter 16d's `categorize_first`
fixture sidestepped the issue by using only Int lit patterns;
no shipped fixture used a `Bool`/`Str`/`Unit` lit pattern.
**Architectural decision (binding, by orchestrator).**
- `==`'s declared type becomes
`Forall(["a"], Fn([Var("a"), Var("a")], Bool, []))` —
`forall a. (a, a) -> Bool`. The polymorphic-builtin pattern
mirrors `__unreachable__` from 16d (which is a `Forall`-typed
value rather than a `Forall`-typed fn, but lives in the same
`env.globals` slot and the same `builtin_ail_type` mirror in
codegen). User-facing surface stays one symbol; the existing
`(app == ...)` shape carries every supported type.
- Codegen monomorphises like any polymorphic builtin and
dispatches on the resolved AIL arg type at the call site.
The dispatch table (one row per supported type):
| AIL type | LLVM lowering |
|----------|------------------------------------------------|
| `Int` | `icmp eq i64` |
| `Bool` | `icmp eq i1` |
| `Str` | `call i32 @strcmp(ptr, ptr)` then `icmp eq i32 0` |
| `Unit` | constant `i1 true` (single-inhabitant) |
| ADT/Fn | rejected with `CodegenError::Internal` |
- Unit's lowering still evaluates both operands above the
comparison (preserving any side effects), then ignores the
resulting SSA values and returns the constant `true`. This
matches the standard pattern (eval-both-then-fold) and keeps
`Unit` `==` semantics-preserving in the presence of an
effectful sub-expression (none of the shipped fixtures
exercise that, but the invariant is documented in
`lower_eq`'s rustdoc).
- ADT and `Fn` arg types fall to a clear codegen error message
(`==` not supported for type X / function types`). Two design
reasons: (a) ADT structural equality requires either a derived
per-type equality fn or runtime field-by-field walk — neither
is in 16e's scope; (b) `Fn`-pointer equality on a closure pair
is meaningless without a normalisation pass (two thunks may
represent the same lambda). Either is its own iter, queued
outside this one. The negative path is gated by two new
codegen unit tests (`eq_on_adt_rejected_at_codegen`,
`eq_on_fn_rejected_at_codegen`).
**Why polymorphic `==` over per-type `==int`/`==bool`/`==str`.**
Mirrors `__unreachable__`'s precedent (one polymorphic name in
`env.globals`, instantiated by the typechecker at every use
site). Three concrete benefits over the alternative:
(i) `build_eq` from 16c stays as-is — it already produces a
single `(app == lhs rhs)` term, type-driven; (ii) authoring
surface stays one symbol, no `==str` lookup table for the LLM
to memorise; (iii) the existing `Forall` instantiation
machinery (`maybe_instantiate` in `ailang-check`) does the
work — no new typechecker code path.
**The `@strcmp` extern.** `Str` `==` lowers to libc's
`strcmp(ptr, ptr) -> i32`. Strings are NUL-terminated literals
in the AILang ABI (see `io/print_str` lowering, which calls
`@puts`), so `strcmp` is a one-liner. Declared in the LLVM IR
header next to `@printf` / `@puts` / `@GC_malloc`. No extra
clang link flag needed — `strcmp` is in libc.
**What shipped.**
- `crates/ailang-check/src/builtins.rs` (+22, of which ~10
doc): `==` is hoisted out of the `int_int_bool` shape branch
into a dedicated `Forall` registration; the `list()` row
becomes `("==", "forall a. (a, a) -> Bool")` (consumed by the
`ail builtins` CLI subcommand). Other comparison ops (`<`,
`<=`, `>`, `>=`, `!=`) keep their Int-only registration
unchanged — extending those is queued separately if ever
needed (`!=` would be a one-line change after this iter).
- `crates/ailang-codegen/src/lib.rs` (+~110, of which ~50
doc): (a) IR header gains `declare i32 @strcmp(ptr, ptr)`;
(b) `lower_app` short-circuits `name == "=="` before the
`builtin_binop` block, calling a new `lower_eq` helper that
takes the resolved `Type` of the first arg (via
`synth_arg_type`) and dispatches by `Type::Con.name`;
(c) `builtin_ail_type` mirror gets the `Forall` form for
`==` so the mono pipeline's arg-type inference still
resolves the symbol. The old `("==", "icmp eq", "i1")` row
in `builtin_binop` stays — `is_static_callee` queries the
table to decide whether `==` is a direct callee, and the
early-return in `lower_app` ensures the `i64`-emission code
is never reached.
- `examples/eq_demo.ailx` + `examples/eq_demo.ail.json`: new
fixture exercising `==` at all four supported types directly
via `(app == ...)` and indirectly via 16c's lit-pattern
desugar over a `Str` scrutinee. Drives `io/print_bool` for
the boolean results and `io/print_int` for the `classify_str`
fn (a 3-arm `(match s ... (case (pat-lit "hi") 1) ...)`).
Output (one per line): `true`, `false` (Int);
`false`, `true` (Bool); `true`, `false` (Str); `true`
(Unit); `1`, `2`, `0` (`classify_str` over `"hi"`/`"ho"`/
`"??"`). The Str lit-pattern arm is the smoking gun for
16c+16e working together — pre-16e, that `(pat-lit "hi")`
desugared to `(if (== sv "hi") 1 fall_k)` and the `==` call
failed to typecheck.
- `crates/ail/tests/e2e.rs`: new `eq_demo` test (+25 incl.
doc), e2e count 46 → 47.
- `crates/ailang-check/src/lib.rs` (+~85 incl. doc): five new
unit tests in the `tests` module — `eq_typechecks_at_int`
(regression), `eq_typechecks_at_bool`, `eq_typechecks_at_str`,
`eq_typechecks_at_unit` (positive), and
`eq_rejects_mixed_int_bool` (negative — confirms the rigid
var still demands the two sides agree).
- `crates/ailang-codegen/src/lib.rs` tests: two new unit tests
(`eq_on_adt_rejected_at_codegen`,
`eq_on_fn_rejected_at_codegen`) that drive `emit_ir` past
typecheck and assert a clear error message mentions `==` and
the offending type kind. ADT case constructs a tiny `data K =
Mk` and compares two `Mk` ctors; Fn case binds `f = main` and
compares the fn-value with itself.
- `crates/ail/tests/snapshots/*.ll`: the IR header in every
snapshot now contains `declare i32 @strcmp(ptr, ptr)`; the
five existing snapshots (`hello`, `list`, `max3`, `sum`,
`ws_main`) were refreshed via `UPDATE_SNAPSHOTS=1`. Body of
every `define` block is byte-identical to pre-16e — the
header is the only diff.
- `docs/DESIGN.md`: Builtins bullet rewritten to call out the
polymorphic `==` and the dispatch table; the lit-pattern
bullet updated to drop the "today that means Int" caveat;
the "Recently lifted gates" preamble extended.
**What deliberately did NOT change.**
- The other comparison ops (`<`, `<=`, `>`, `>=`, `!=`) stay
Int-only. `!=` could be made polymorphic by the same recipe
(one row in `builtins.rs`, one branch in `lower_app`), but
staying in scope for this iter — queued.
- Codegen `unreachable!` arms for `Term::LetRec` STAY
(architectural rule from the iter brief, unchanged from
16b.x).
- ADT structural equality and `Fn`-pointer equality stay
rejected at codegen. Either could be lifted later, but
needs its own design.
- `==` at the LetRec / closure-pair / lambda boundary is
unchanged: those still produce `Fn` arg types and now hit
the codegen-level rejection with a clearer error message
than the pre-16e "wrong LLVM type for icmp" garble.
**Hash determinism.** The ten fixtures listed in the iter
brief (`local_rec_demo`, `lit_pat`, `local_rec_capture`,
`local_rec_let_capture`, `local_rec_match_capture`,
`local_rec_as_value`, `local_rec_as_value_capture`,
`poly_rec_capture`, `nested_let_rec`, `unreachable_demo`)
all produce identical `ail manifest` output to their pre-16e
forms — verified via direct manifest dump. No fixture's
canonical JSON changed; no def hash changed. The new
`eq_demo` fixture introduces two new hashes (`classify_str`,
`main`) that obviously did not exist before.
**Tests: 125 → 133 (+8).**
- e2e: 46 → 47 (`eq_demo`).
- `ailang-check::tests`: 29 → 34 (five new `eq_*` tests).
- `ailang-codegen::tests`: 2 → 4 (two negative-path tests).
- All other crates unchanged.
- IR snapshot tests: 5 → 5 (unchanged count; bytes refreshed
for the new `@strcmp` header line).
**Cumulative state, post-16e.**
- Stdlib unchanged (5 modules, 29 combinators).
- `Term`/`Pattern`/`Literal` enums unchanged. The change is
entirely a builtin-registration shift (`Fn``Forall<Fn>`
in `env.globals`) plus a codegen dispatch site — no AST
shape changed and no schema bump.
- 16a desugar pass keeps its four-job role from 16d. 16e's
contribution is a pre-existing desugar output (`(app ==
s_var lit)` from 16c's `build_eq`) suddenly being typeable
for non-Int literals.
- Compiler bugs surfaced and fixed in dogfood since 14a: 5/5
(unchanged).
**Queue update post-16e.** 16e done. Open: `closure-of-self`
(informally 16b.5-body — unchanged); `closure-poly`
(informally 16b.5b — unchanged); 17a (per-fn arena, gated on
user GC discussion — unchanged); 16c-aux (`std_list::take`/
`drop` refactor onto lit patterns — unchanged); a low-priority
follow-up that mirrors 16e for `!=` (one-line change in
`builtins.rs` plus a one-line dispatch arm in `lower_app`,
queued without a number).