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.
71 KiB
operator-routing-eq-ord.1 — Implementation Plan
Parent spec:
docs/specs/0049-operator-routing-eq-ord.mdFor agentic workers: REQUIRED SUB-SKILL: use
skills/implementto run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Drop the surface comparator names == / != / < / <= / > / >= from the language, route the LLM-author's (app eq …) / (app compare …) / (app ne|lt|le|gt|ge …) through the existing prelude.Eq / prelude.Ord class-dispatch path, ship six named Float-comparison fns (float_eq / float_ne / float_lt / float_le / float_gt / float_ge) so Float keeps comparability without an Eq Float / Ord Float instance, and emit primitive instance bodies with alwaysinline so the -O0 IR shape stays at one instruction per comparison.
Architecture: One cohesive iteration across seven layers — typchecker builtins, codegen builtin-binop table, codegen primitive-instance intercept (extended with three new Eq arms + six new float-fn arms + alwaysinline attribute emission), codegen ==-polymorphic-dispatch path (lower_eq whole fn) deleted, lit-pattern desugar (build_eq substitutes eq), prelude module (Eq Unit added, six float-fns added, primitive instance bodies become placeholders), and full migration of 60 fixtures + 8 in-source #[cfg(test)] mod tests AST-literal scaffolds + 3 hash-pin re-pins + 2 IR-snapshot regenerations + 5 contract-file updates.
Tech Stack: crates/ailang-check (typechecker, in-source mod tests), crates/ailang-codegen (intercept + fn-attribute emission + lower_eq deletion), crates/ailang-core (desugar), crates/ailang-prose (binop_info cleanup), examples/prelude.ail (canonical class declarations), full E2E test suite (crates/ail/tests/), bench corpus (bench/check.py / compile_check.py / cross_lang.py).
Files this plan creates or modifies:
- Create:
examples/eq_user_adt_smoke.ail— north-star fixture (Point ADT + userinstance Eq Point+ Unit-eq line to ensure RED today) - Create:
examples/eq_float_must_fail.ail— Klausel-3 discriminator (must fail at typecheck withNoInstance Eq Float+float_eqhint) - Create:
examples/float_compare_smoke.ail— Float named-fn happy path - Create:
examples/operator_unbound_check.ail— minimal(app == 5 5)fixture for the unbound-pin - Create:
crates/ail/tests/eq_user_adt_smoke_e2e.rs— drives the north-star, asserts"true\ntrue\nfalse\n"(Unit, then Point eq, then Point neq) - Create:
crates/ail/tests/eq_float_must_fail_pin.rs— drivesail check, asserts non-zero +NoInstance Eq FloatANDfloat_eqsubstrings - Create:
crates/ail/tests/float_compare_smoke_e2e.rs— drives the float-named-fn fixture, asserts"true\ntrue\nfalse\n" - Create:
crates/ail/tests/operator_names_unbound_pin.rs— drives(app == 5 5), assertsail checkexits non-zero with stderr containingunknown variable: == - Create:
crates/ail/tests/prelude_eq_alwaysinline_ir_pin.rs— compiles with--emit-ir --opt=O0; asserts the generateddefinefor@ail_prelude_eq__Intcarries thealwaysinlineattribute - Modify:
examples/prelude.ail:9— drop "P2 follow-up" comment onclass Eq - Modify:
examples/prelude.ail:12-29—Eq Int/Eq Bool/Eq Strinstance bodies become placeholder shape ((body false)lambda); doc-comments updated - Modify:
examples/prelude.ail(insert after Eq Str instance, before Ord) — newinstance Eq Unit - Modify:
examples/prelude.ail(insert afterprintfn, before final paren) — six newfloat_*free fns (float_eq/float_ne/float_lt/float_le/float_gt/float_ge) - Modify:
crates/ailang-check/src/builtins.rs:96-98— delete the["!=", "<", "<=", ">", ">="]comparator-loop install - Modify:
crates/ailang-check/src/builtins.rs:100-125— delete==polymorphic Forall block + its 6-line comment band - Modify:
crates/ailang-check/src/builtins.rs:303-308— delete the 6 list-side mirror rows for==/!=/</<=/>/>= - Modify:
crates/ailang-codegen/src/synth.rs:263-267— delete the 5 Int comparator arms inbuiltin_binop_typed - Modify:
crates/ailang-codegen/src/synth.rs:268-276— delete the 5 Float comparator arms (fcmp une/olt/ole/ogt/oge); the comment band at 260-262 + 268-271 rewrites to "arithmetic-only" framing - Modify:
crates/ailang-codegen/src/lib.rs:68-73—is_arithmetic_or_comparison_opreduces to arithmetic-only; rename tois_arithmetic_op+ update doc-comment lines 60-67 - Modify:
crates/ailang-codegen/src/lib.rs:2133— delete theif name == "=="short-circuit inlower_app - Modify:
crates/ailang-codegen/src/lib.rs:2529— delete thename == "=="clause inis_static_callee - Modify:
crates/ailang-codegen/src/lib.rs:2879-2964— delete the entirelower_eqfn - Modify:
crates/ailang-codegen/src/lib.rs:1213-1259—emit_fngains conditionalalwaysinlineattribute emission on thedefineline for symbols that match the alwaysinline-allowlist - Modify:
crates/ailang-codegen/src/lib.rs:2691-2806—try_emit_primitive_instance_bodygains 9 new arms (eq__Int,eq__Bool,eq__Unit,float_eq,float_ne,float_lt,float_le,float_gt,float_ge) - Modify:
crates/ailang-codegen/src/lib.rs:3263-end— in-sourcemod testsAST-literal sites at lines 3371, 3419, 3729-3734, 4354 migrate from==toeq(or delete tests whose semantics are tied to the now-deletedlower_eqrejection wording) - Modify:
crates/ailang-core/src/desugar.rs:1099—build_eqfunction emitsTerm::Var { name: "eq" }instead of"=="at line 1109; doc-comment at 1098 updated - Modify:
crates/ailang-core/src/desugar.rs:2414— in-sourcemod testsAST-literal migrates from==toeq - Modify:
crates/ailang-check/src/lib.rs:856-880— Float-aware NoInstance addendum extended to namefloat_eq/float_ltas the explicit alternative - Modify:
crates/ailang-check/src/lib.rs:5656— in-sourcemod testsAST-literal migrates from>=toge - Modify:
crates/ailang-check/src/lib.rs:6092— in-sourcemod testsAST-literal migrates from==toeq(theeq_apphelper at 6088 and callers at 6097-6128) - Modify:
crates/ailang-check/src/lib.rs:6220— second in-sourcemod testsAST-literal migrates from==toeq - Modify:
crates/ailang-check/src/builtins.rs:413-432— in-sourcemod tests<smoke tests migrate tolt(or delete if redundant with class-dispatch coverage) - Modify: 60 fixtures under
examples/— full bulk migration of(app == …)→(app eq …),(app != …)→(app ne …),(app < …)→(app lt …), etc. Float-typed operand sites migrate to(app float_eq …)/(app float_lt …)/ etc. The grepgrep -rln '(app == \|(app != \|(app < \|(app <= \|(app > \|(app >=' examples/enumerates the live set; expected count drops from 60 to 0. - Modify:
examples/eq_ord_user_adt.ail:21—(app == ai bi)→(app eq ai bi)(lockstep withcrates/ail/tests/eq_ord_e2e.rs:134body-hash re-pin) - Modify:
examples/eq_demo.ail:1-23— doc-comment header rewrites from the polymorphic-==story to the class-dispatch story - Modify:
crates/ail/tests/e2e.rs:1351-1360—eq_demotest doc-comment rewrites alongside the fixture - Modify:
crates/ailang-surface/tests/prelude_module_hash_pin.rs:16— regenerate prelude module hash literal - Modify:
crates/ail/tests/mono_hash_stability.rs:50-55— re-pin six body-hash literals foreq__Int/eq__Bool/eq__Str/compare__Int/compare__Bool/compare__Str - Modify:
crates/ail/tests/eq_ord_e2e.rs:134— re-pineq__IntBoxbody-hash literal (3c4cf040cb4e8bb2→ new value) - Modify:
crates/ail/tests/eq_float_noinstance.rs:32-44— assertion extended to includefloat_eqsubstring in the diagnostic - Modify:
crates/ail/tests/snapshots/sum.ll— regenerate viaUPDATE_SNAPSHOTS=1(sum.ail uses==in lit-pattern desugar) - Modify:
crates/ail/tests/snapshots/max3.ll— regenerate viaUPDATE_SNAPSHOTS=1(max3.ail uses>directly) - Modify:
crates/ailang-prose/src/lib.rs:546-563— delete the 6 comparator arms frombinop_info(dead code after operator-name removal) - Modify:
crates/ailang-prose/src/lib.rs:1952, 2020-2062— delete the 3 in-sourcemod teststhat pinned comparator-infix rendering - Modify:
design/contracts/0005-float-semantics.md:10-31, :98-99— transfer comparison guarantees from==/</!=tofloat_eq/float_lt/float_ne(arithmetic guarantees on+/-/*//unchanged);==/</!=mentions removed from the present-tense surface description - Modify:
design/contracts/0017-prelude-classes.md:5-13— instance list extends withEq Unit; new paragraph on the sixfloat_*fns; Float-no-Eq/Ord clause gains→ use float_eqcross-reference - Modify:
design/contracts/0011-str-abi.md:40-42— rewrite "==,<, … REMAIN primitive operators" clause to describe the class-method dispatch path - Modify:
design/contracts/0010-scope-boundaries.md:42-96— operator-name mentions andPattern::Lit-desugar-to-==clauses rewrite to the class-dispatch present - Modify:
design/models/0001-authoring-surface.md:58— drop==/<=from the operator-example list; the example becomes arithmetic-only or names class methods explicitly
Per-task expected-state map
After each task lands, the workspace test suite is in a known partial-pass state. Task 12 (acceptance gate) is the only "all green" milestone.
| After Task | New tests green? | Existing tests still green? | Notes |
|---|---|---|---|
| 1 (bootstrap) | none (all RED) | yes | New fixtures + RED tests added; no behaviour shift |
| 2 (codegen intercept + alwaysinline) | prelude_eq_alwaysinline_ir_pin GREEN |
yes | Intercept overrides today's (app == x y) body for primitive instances; output unchanged |
| 3 (prelude reshape + hash re-pin) | none new | yes | Primitive bodies become placeholders; intercept does the work; pins re-pinned |
| 4 (fixture migration) | none new | yes (all eq dispatches today) |
All 60 fixtures use eq/lt/etc.; == no longer in any fixture |
| 5 (test-scaffold migration) | none new | yes | In-source mod tests use eq in AST literals |
| 6 (lit-pattern desugar) | none new | yes | build_eq emits eq; lit-pattern paths route through class-dispatch |
| 7 (delete dead operator machinery) | operator_names_unbound_pin GREEN |
yes (all callers migrated by tasks 4-6) | == / < / etc. become unknown identifiers |
| 8 (Float NoInstance hint + assertion extend) | eq_float_must_fail_pin GREEN |
yes (existing eq_float_noinstance assertion extended) |
Diagnostic mentions float_eq |
| 9 (IR snapshot regeneration) | none new | yes | sum.ll / max3.ll regenerated |
| 10 (prose cleanup) | none new | yes | Dead binop_info arms + tests deleted |
| 11 (contract updates) | none new | yes | Honesty-rule compliance for 5 design files |
| 12 (acceptance gate) | eq_user_adt_smoke_e2e, float_compare_smoke_e2e GREEN (Unit-eq path needs Task 2's intercept arm + Task 3's prelude instance; Float-fn path needs both same; user-Point path is already covered by today's eq dispatch — see TDD note in Task 1) |
yes | Full workspace 0 failed; bench corpus exit 0 |
Task 1: Bootstrap — RED tests + new fixtures
Files:
-
Create:
examples/eq_user_adt_smoke.ail(north-star) -
Create:
examples/eq_float_must_fail.ail(Klausel-3 discriminator) -
Create:
examples/float_compare_smoke.ail(Float happy path) -
Create:
examples/operator_unbound_check.ail(unbound-==fixture) -
Create:
crates/ail/tests/eq_user_adt_smoke_e2e.rs -
Create:
crates/ail/tests/eq_float_must_fail_pin.rs -
Create:
crates/ail/tests/float_compare_smoke_e2e.rs -
Create:
crates/ail/tests/operator_names_unbound_pin.rs -
Create:
crates/ail/tests/prelude_eq_alwaysinline_ir_pin.rs -
Step 1: Write the north-star fixture
Create examples/eq_user_adt_smoke.ail:
(module eq_user_adt_smoke
(data Point
(doc "Two-Int-field record. The milestone-defining north-star: an LLM author writes `instance Eq Point` and calls `(app eq p1 p2)` end-to-end through class-dispatch.")
(ctor Point (con Int) (con Int)))
(instance
(class prelude.Eq)
(type (con Point))
(doc "Eq Point by structural comparison of the two Int fields. The nested `(app eq a1 a2)` calls dispatch to prelude.Eq.eq's Int instance.")
(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 (lit-unit) (lit-unit)))
(seq (app print (app eq p1 p2))
(app print (app eq p1 p3)))))))))
- Step 2: Write the north-star E2E test
Create crates/ail/tests/eq_user_adt_smoke_e2e.rs:
//! North-star E2E for the operator-routing-eq-ord milestone:
//! exercises Eq Unit (new instance), user-ADT Eq Point with a
//! hand-written instance, and nested-Int Eq dispatch — all
//! through the class-method path with no `==` callee anywhere.
//!
//! Today this test fires `NoInstance Eq Unit` at typecheck
//! (Unit lacks an Eq instance), proving RED-first before the
//! milestone lands.
use std::path::PathBuf;
use std::process::Command;
#[test]
fn eq_user_adt_smoke_prints_unit_then_point_eq_then_neq() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let repo_root = PathBuf::from(manifest_dir).join("../..");
let fixture = repo_root.join("examples/eq_user_adt_smoke.ail");
let bin = repo_root.join("target/debug/eq_user_adt_smoke_test_bin");
let build = Command::new(env!("CARGO_BIN_EXE_ail"))
.arg("build")
.arg(&fixture)
.arg("-o")
.arg(&bin)
.output()
.expect("ail build failed to spawn");
assert!(
build.status.success(),
"ail build failed: stderr={}",
String::from_utf8_lossy(&build.stderr)
);
let run = Command::new(&bin)
.output()
.expect("binary failed to spawn");
assert!(run.status.success(), "binary failed: stderr={}", String::from_utf8_lossy(&run.stderr));
assert_eq!(
String::from_utf8_lossy(&run.stdout).as_ref(),
"true\ntrue\nfalse\n",
"expected Unit-eq then Point-eq then Point-neq"
);
}
- Step 3: Write the must-fail fixture
Create examples/eq_float_must_fail.ail:
(module eq_float_must_fail
(fn main
(doc "Klausel-3 discriminator. After operator-routing-eq-ord, `(app eq 1.5 1.5)` must fire `NoInstance Eq Float` with a follow-up sentence naming `float_eq` as the explicit IEEE-aware alternative. Today the diagnostic already fires (no Eq Float instance) but does not name `float_eq` — the milestone adds that hint.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app eq 1.5 1.5)))))
- Step 4: Write the must-fail pin
Create crates/ail/tests/eq_float_must_fail_pin.rs:
//! Pin: `(app eq 1.5 1.5)` must fail typecheck with NoInstance
//! Eq Float AND the diagnostic must mention `float_eq` as the
//! explicit alternative. Today the NoInstance fires (per
//! eq_float_noinstance.rs) but `float_eq` is not in the message
//! — the milestone adds that hint.
use std::path::PathBuf;
use std::process::Command;
#[test]
fn eq_float_must_fail_diagnostic_names_float_eq() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let repo_root = PathBuf::from(manifest_dir).join("../..");
let fixture = repo_root.join("examples/eq_float_must_fail.ail");
let out = Command::new(env!("CARGO_BIN_EXE_ail"))
.arg("check")
.arg(&fixture)
.output()
.expect("ail check failed to spawn");
assert!(
!out.status.success(),
"expected typecheck failure, got success: stdout={}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("NoInstance") && stderr.contains("Float"),
"expected NoInstance + Float, got: {stderr}"
);
assert!(
stderr.contains("float_eq"),
"expected diagnostic to name `float_eq` as the explicit alternative; got: {stderr}"
);
}
- Step 5: Write the Float-named-fn happy-path fixture
Create examples/float_compare_smoke.ail:
(module float_compare_smoke
(fn main
(doc "Float comparison via the named-fn surface that replaces the deleted operator names. Each float_* call lowers to a single fcmp via try_emit_primitive_instance_body.")
(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)))))))
- Step 6: Write the Float-named-fn E2E
Create crates/ail/tests/float_compare_smoke_e2e.rs:
//! E2E for the six float_* named comparison fns. After the
//! milestone, Float comparability survives via float_eq / float_lt
//! / etc. (no Eq Float instance, no `==` callee). Today this test
//! fires `unknown variable: float_eq` since the symbol does not
//! exist — RED-first.
use std::path::PathBuf;
use std::process::Command;
#[test]
fn float_compare_smoke_prints_true_true_false() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let repo_root = PathBuf::from(manifest_dir).join("../..");
let fixture = repo_root.join("examples/float_compare_smoke.ail");
let bin = repo_root.join("target/debug/float_compare_smoke_test_bin");
let build = Command::new(env!("CARGO_BIN_EXE_ail"))
.arg("build")
.arg(&fixture)
.arg("-o")
.arg(&bin)
.output()
.expect("ail build failed to spawn");
assert!(
build.status.success(),
"ail build failed: stderr={}",
String::from_utf8_lossy(&build.stderr)
);
let run = Command::new(&bin)
.output()
.expect("binary failed to spawn");
assert!(run.status.success(), "binary failed: stderr={}", String::from_utf8_lossy(&run.stderr));
assert_eq!(
String::from_utf8_lossy(&run.stdout).as_ref(),
"true\ntrue\nfalse\n",
"expected float_eq then float_lt then float_eq mismatch"
);
}
- Step 7: Write the operator-unbound fixture
Create examples/operator_unbound_check.ail:
(module operator_unbound_check
(fn main
(doc "Pin fixture. After operator-routing-eq-ord, `(app == 5 5)` must fail typecheck with `unknown variable: ==` — the operator names are no longer in the language. Today this typechecks (== is polymorphic over Int) — RED-first.")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (app print (app == 5 5)))))
- Step 8: Write the operator-unbound pin
Create crates/ail/tests/operator_names_unbound_pin.rs:
//! Pin: the operator names `==` / `!=` / `<` / `<=` / `>` / `>=`
//! are NOT part of the language. Surface use of any of them must
//! resolve as `unknown variable: ==` (et al.). Today they all
//! typecheck (== polymorphic, the others monomorphic-Int) —
//! RED-first.
use std::path::PathBuf;
use std::process::Command;
#[test]
fn comparator_operator_name_is_unbound() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let repo_root = PathBuf::from(manifest_dir).join("../..");
let fixture = repo_root.join("examples/operator_unbound_check.ail");
let out = Command::new(env!("CARGO_BIN_EXE_ail"))
.arg("check")
.arg(&fixture)
.output()
.expect("ail check failed to spawn");
assert!(
!out.status.success(),
"expected typecheck failure for `(app == 5 5)`; got success: stdout={}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("unknown variable") && stderr.contains("=="),
"expected `unknown variable: ==` diagnostic, got: {stderr}"
);
}
- Step 9: Write the alwaysinline IR pin
Create crates/ail/tests/prelude_eq_alwaysinline_ir_pin.rs:
//! Pin: the codegen mechanism that emits primitive Eq/Ord
//! instance bodies (try_emit_primitive_instance_body) MUST
//! attach an `alwaysinline` attribute to the generated LLVM
//! function definition so the `-O0` build path inlines the
//! single-instruction body at every use site, matching the IR
//! shape the deleted `lower_eq` direct-emit produced. Without
//! `alwaysinline`, `ail run` (which defaults to -O0) pays a
//! function-call overhead per comparison.
use std::path::PathBuf;
use std::process::Command;
#[test]
fn prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let repo_root = PathBuf::from(manifest_dir).join("../..");
let fixture = repo_root.join("examples/eq_demo.ail");
let out = Command::new(env!("CARGO_BIN_EXE_ail"))
.arg("emit-ir")
.arg(&fixture)
.output()
.expect("ail emit-ir failed to spawn");
assert!(
out.status.success(),
"ail emit-ir failed: stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let ir = String::from_utf8_lossy(&out.stdout);
// Either form is acceptable:
// define i1 @ail_prelude_eq__Int(i64, i64) alwaysinline { ... }
// (attribute appended to the define line). The grep checks for
// both the symbol presence and the alwaysinline annotation in
// the same line as the define.
let saw_define_with_alwaysinline = ir
.lines()
.any(|line| line.contains("define") && line.contains("eq__Int") && line.contains("alwaysinline"));
assert!(
saw_define_with_alwaysinline,
"expected `define … eq__Int … alwaysinline` in emitted IR; full IR follows:\n{ir}"
);
}
- Step 10: Run all 5 new tests; verify they FAIL with the expected RED shape
Run: cargo test --workspace --quiet eq_user_adt_smoke_prints_unit_then_point_eq_then_neq eq_float_must_fail_diagnostic_names_float_eq float_compare_smoke_prints_true_true_false comparator_operator_name_is_unbound prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir 2>&1 | tail -20
Expected: all 5 tests in the run summary line as FAILED. The failure messages are:
eq_user_adt_smoke_...—ail buildfails (Unit-eq line firesNoInstance Eq Unit)eq_float_must_fail_...—ail checkfails (good) but stderr does NOT containfloat_eq→ assertion-fail on the float_eq substringfloat_compare_smoke_...—ail buildfails (unknown variable: float_eq)comparator_operator_name_is_unbound—ail checkSUCCEEDS (==typechecks today) → assertion-fail on!out.status.success()prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir—ail emit-irsucceeds but IR contains noalwaysinline→ assertion-fail on the grep
All 5 RED ratifies the start-of-milestone state. If any test passes here, the spec assumption is wrong — bounce back to brainstorm.
- Step 11: Run
cargo build --workspaceto confirm the new files compile-check cleanly
Run: cargo build --workspace --tests 2>&1 | tail -5
Expected: Finished … profile [unoptimized + debuginfo] target(s) in … (0 errors). The new test files are pure Rust + Command calls; the new .ail fixtures aren't compiled by cargo build.
Task 2: Codegen — alwaysinline emission + new intercept arms
Files:
- Modify:
crates/ailang-codegen/src/lib.rs:1213-1259(emit_fnextension foralwaysinline) - Modify:
crates/ailang-codegen/src/lib.rs:2691-2806(try_emit_primitive_instance_body9 new arms)
This task is purely additive: new intercept arms for eq__Int / eq__Bool / eq__Unit / float_eq / float_ne / float_lt / float_le / float_gt / float_ge. Existing prelude eq__Int body is today (app == x y) — once the intercept matches eq__Int, it overrides this body with a direct icmp eq i64, producing the same end result via a different path. alwaysinline attribute attaches to the generated define line for every fn whose name matches the alwaysinline-allowlist.
- Step 1: Read
emit_fnto locate the exactdefineline
Run: sed -n '1180,1260p' crates/ailang-codegen/src/lib.rs
Expected: the function definition includes the line that builds the define <ret> @<mangled_name>(<params>) { header. Identify the exact line (likely a format! or push_str call building this string) — the alwaysinline attribute is appended to it.
- Step 2: Add the alwaysinline-allowlist helper
In crates/ailang-codegen/src/lib.rs, add this fn near emit_fn (e.g. directly above it):
/// Returns `true` when the codegen should emit the LLVM
/// `alwaysinline` attribute on this fn's `define` line. The
/// allowlist is the set of primitive-instance bodies emitted by
/// `try_emit_primitive_instance_body` whose IR is so trivial
/// (one `icmp` / `fcmp` / `call` plus a `ret`) that `-O0` build
/// paths pay an unacceptable per-call overhead without inlining,
/// while `-O2` would inline these unconditionally anyway. The
/// list mirrors the intercept arm set in
/// `try_emit_primitive_instance_body` for the equality /
/// ordering / Float-comparison primitives.
fn intercept_emit_wants_alwaysinline(symbol: &str) -> bool {
matches!(
symbol,
"eq__Int" | "eq__Bool" | "eq__Str" | "eq__Unit"
| "compare__Int" | "compare__Bool" | "compare__Str"
| "float_eq" | "float_ne"
| "float_lt" | "float_le"
| "float_gt" | "float_ge"
)
}
- Step 3: Inject
alwaysinlineinto thedefineheader
In emit_fn (around line 1213-1259), at the point where the define <ret> @<mangled_name>(<params>) { line is built, append alwaysinline between the ) and the { when intercept_emit_wants_alwaysinline(<symbol>) is true. The exact patch depends on whether the header is built as a single format! or as multiple push_str calls; example for the format!-style:
// Before
self.body.push_str(&format!(
"define {ret} @{name}({params}) {{\n"
));
// After
let attrs = if intercept_emit_wants_alwaysinline(unmangled_symbol) {
" alwaysinline"
} else {
""
};
self.body.push_str(&format!(
"define {ret} @{name}({params}){attrs} {{\n"
));
The exact unmangled symbol name reachable at this site is the planner-recon-unknown — read the surrounding emit_fn code to identify it (likely a def.name or fn_def.name field already in scope; if not, the symbol is reachable from the match instance_key arm in try_emit_primitive_instance_body and the attribute should attach there instead).
- Step 4: Add the 9 new intercept arms to
try_emit_primitive_instance_body
In crates/ailang-codegen/src/lib.rs:2691-2806, locate the match instance_key (or equivalent) inside try_emit_primitive_instance_body. Today it has arms for eq__Str, compare__Int, compare__Bool, compare__Str. Add:
"eq__Int" => {
// Eq Int. Lowers to `icmp eq i64`. The `alwaysinline`
// attribute on the fn header (see `emit_fn`) collapses the
// call to a single icmp at every use site under `-O0`.
body.push_str(" %r = icmp eq i64 %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
"eq__Bool" => {
body.push_str(" %r = icmp eq i1 %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
"eq__Unit" => {
// Unit is single-inhabitant; all Units compare equal.
// Params are typed as Unit (zero-width) but the fn signature
// still carries them; we discard the operands and return true.
body.push_str(" ret i1 1\n");
Some(body)
}
"float_eq" => {
body.push_str(" %r = fcmp oeq double %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
"float_ne" => {
// `fcmp une` ("unordered or not-equal") not `one`, mirroring
// float-semantics.md's `!=` guarantee — NaN != NaN must be true.
body.push_str(" %r = fcmp une double %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
"float_lt" => {
body.push_str(" %r = fcmp olt double %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
"float_le" => {
body.push_str(" %r = fcmp ole double %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
"float_gt" => {
body.push_str(" %r = fcmp ogt double %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
"float_ge" => {
body.push_str(" %r = fcmp oge double %0, %1\n");
body.push_str(" ret i1 %r\n");
Some(body)
}
The exact identifier names (body, instance_key, params) match the surrounding match-arm pattern — read 2691-2806 first to confirm. The Unit body assumes the LLVM type for Unit is a zero-width or untouched single-bit; if the fn signature requires %0/%1 operands of a specific Unit type, adjust accordingly (recon-confirmed shape).
- Step 5: Build the workspace; expect 0 errors
Run: cargo build --workspace --tests 2>&1 | tail -5
Expected: Finished …. No errors. The new arms are pure-additive; the alwaysinline emission is conditioned on the allowlist (today, fns matching the existing arms eq__Str/compare__Int/etc.).
- Step 6: Run the alwaysinline IR pin; expect it to FLIP to GREEN
Run: cargo test --workspace --quiet prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir 2>&1 | tail -5
Expected: PASS. The eq_demo.ail fixture contains (app == 5 5) which today lowers via lower_eq (direct emit, no fn-call); after Task 2, no, wait — the prelude eq__Int body is still (app == x y) today, so eq calls dispatch via class to prelude.eq__Int, which the new intercept arm overrides. The emitted IR for @ail_prelude_eq__Int carries the alwaysinline attribute. eq_demo.ail calls (app == 5 5) directly (not (app eq 5 5)), so the IR-pin's substring check on define … eq__Int … alwaysinline is reached via the prelude's eq__Int symbol being EMITTED into the IR alongside main. If the IR doesn't reach for prelude.eq__Int at all (because eq_demo only uses ==), change the fixture in step 1 of the pin to instead invoke (app eq …) — adjust the test fixture path to a fixture that DOES call eq. Suggestion: use examples/eq_ord_user_adt.ail which calls (app eq …) on IntBox (which calls eq on Int internally).
If the test does not turn GREEN at this gate, investigate: either the allowlist is mis-keyed (compare intercept_emit_wants_alwaysinline's match arm names to the actual instance_key strings inside try_emit_primitive_instance_body), or the attribute injection site in emit_fn is bypassed for the codegen path used by intercept-emitted bodies.
- Step 7: Run the full workspace test suite; expect existing tests still green
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: same 0 failed shape as before Task 2 plus prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir flipping to PASS. Per-suite pass counts in the per-suite summary lines must match git stash-prior counts (re-derive on failure). The other 4 RED tests stay RED.
If any existing test breaks: the most likely cause is the alwaysinline allowlist matching too broadly (i.e. attaching the attribute to a fn that LLVM refuses to inline for other reasons — e.g. external linkage). Narrow the allowlist to the exact mono-symbol names in the intercept arms and re-run.
Task 3: Prelude reshape — Eq Unit, six float_* fns, placeholder bodies, hash re-pin
Files:
-
Modify:
examples/prelude.ail(multiple sites: line 9 comment drop, lines 12-29 body placeholders, insert Eq Unit, insert six float_* fns) -
Modify:
crates/ailang-surface/tests/prelude_module_hash_pin.rs:16(hash literal re-pin) -
Modify:
crates/ail/tests/mono_hash_stability.rs:50-55(six body-hash pins re-pin) -
Step 1: Drop the "P2 follow-up" comment on
class Eq
Edit examples/prelude.ail:9 — the line currently reads:
(doc "Structural equality. Ships in milestone 23 alongside Ord. The primitive `==` operator stays as the surface-level comparator this iter; routing `==` through `Eq.eq` is the declared P2 follow-up (see docs/specs/2026-05-10-23-eq-ord-prelude.md, `Out of scope`).")
Replace with:
(doc "Structural equality. The class-method `eq` is the surface-level comparator; `==` as a surface name is not part of the language. Primitive instances Eq Int / Bool / Str / Unit are lowered via try_emit_primitive_instance_body in the codegen.")
- Step 2: Rewrite Eq Int / Eq Bool instance bodies to placeholder shape
Edit examples/prelude.ail:12-23 — the Eq Int and Eq Bool instances currently have bodies (app == x y). Replace both bodies with placeholder shape mirroring the existing Ord Int's:
(instance
(class prelude.Eq)
(type (con Int))
(doc "Eq Int. Body is placeholder for round-trip stability; codegen intercept try_emit_primitive_instance_body::eq__Int emits `icmp eq i64` with the alwaysinline attribute.")
(method eq
(body (lam (params (typed x a) (typed y a)) (ret (con Bool)) (body false)))))
(instance
(class prelude.Eq)
(type (con Bool))
(doc "Eq Bool. Body is placeholder for round-trip stability; codegen intercept try_emit_primitive_instance_body::eq__Bool emits `icmp eq i1` with the alwaysinline attribute.")
(method eq
(body (lam (params (typed x a) (typed y a)) (ret (con Bool)) (body false)))))
Eq Str's body stays unchanged in shape (it's already placeholder-style with the intercept handling it).
- Step 3: Insert Eq Unit instance
Edit examples/prelude.ail, immediately after the Eq Str instance (before (class Ord …)), insert:
(instance
(class prelude.Eq)
(type (con Unit))
(doc "Eq Unit. Unit is single-inhabitant so all values compare equal. Body is placeholder; codegen intercept try_emit_primitive_instance_body::eq__Unit emits `ret i1 1`.")
(method eq
(body (lam (params (typed x a) (typed y a)) (ret (con Bool)) (body true)))))
- Step 4: Insert six float_ free fns*
Edit examples/prelude.ail, immediately after the (fn print …) block (before the final closing paren of the module), insert:
(fn float_eq
(doc "IEEE Float equality. `float_eq x y` returns true iff both operands are non-NaN and bit-equal. Lowered to `fcmp oeq double` via try_emit_primitive_instance_body::float_eq with alwaysinline. Replaces the milestone-deleted polymorphic `==` on Float.")
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
(params x y)
(body false))
(fn float_ne
(doc "IEEE Float disequality. `float_ne nan nan` returns true (unordered-or-not-equal per IEEE-754). Lowered to `fcmp une double`.")
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
(params x y)
(body false))
(fn float_lt
(doc "IEEE Float strict less-than. `float_lt nan x` returns false for any x (unordered). Lowered to `fcmp olt double`.")
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
(params x y)
(body false))
(fn float_le
(doc "IEEE Float less-than-or-equal. Lowered to `fcmp ole double`.")
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
(params x y)
(body false))
(fn float_gt
(doc "IEEE Float strict greater-than. Lowered to `fcmp ogt double`.")
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
(params x y)
(body false))
(fn float_ge
(doc "IEEE Float greater-than-or-equal. Lowered to `fcmp oge double`.")
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
(params x y)
(body false))
(Body false is placeholder; the codegen intercept overrides each with the corresponding fcmp.)
- Step 5: Re-pin the prelude module hash
Run: cargo test --workspace --quiet -p ailang-surface --test prelude_module_hash_pin prelude_parse_yields_canonical_hash 2>&1 | tail -10
Expected: FAIL with a message of the form prelude module hash drifted: expected 3abe0d3fa3c11c99, got <NEW_HASH>. Capture <NEW_HASH>.
Edit crates/ailang-surface/tests/prelude_module_hash_pin.rs:16 — replace the existing literal "3abe0d3fa3c11c99" with <NEW_HASH>. Update any accompanying comment naming the prior value to add the operator-routing-eq-ord re-pin context (e.g. // 2026-05-20 operator-routing-eq-ord re-pinned (prior: 3abe0d3fa3c11c99): Eq Int/Bool body shape flips to placeholder, Eq Unit added, six float_* fns added, line-9 comment rewritten.).
Re-run the pin: cargo test --workspace --quiet -p ailang-surface --test prelude_module_hash_pin 2>&1 | tail -5
Expected: PASS.
- Step 6: Re-pin the six body-hash literals in
mono_hash_stability.rs
Run: cargo test --workspace --quiet --test mono_hash_stability 2>&1 | tail -20
Expected: 6 assertion failures of the form eq__Int body hash drifted — see mono_hash_stability.rs for the resolution pattern; captured: <NEW_HASH_X>. Capture each.
Edit crates/ail/tests/mono_hash_stability.rs:50-55 (and surrounding context) — replace each of the six hash literals (eq__Int, eq__Bool, eq__Str, compare__Int, compare__Bool, compare__Str) with their captured values. Add a comment band noting the operator-routing-eq-ord re-pin reason: bodies become placeholder per prelude reshape.
Re-run: cargo test --workspace --quiet --test mono_hash_stability 2>&1 | tail -5
Expected: PASS.
- Step 7: Run the full workspace test suite
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed summary in the run-level line. The 4 still-RED tests from Task 1 stay RED; prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir from Task 2 stays GREEN. All other tests stay GREEN. If any existing test fails: the codegen intercept arms emit a different IR shape than the existing dispatch produces for the same call — investigate.
Task 4: Fixture migration — 60 .ail files + lockstep eq_ord_e2e body-hash re-pin
Files:
-
Modify: ~60
.ailfiles underexamples/(enumerate via grep) -
Modify:
examples/eq_ord_user_adt.ail:21((app == ai bi)→(app eq ai bi)) -
Modify:
crates/ail/tests/eq_ord_e2e.rs:134(body-hash re-pin) -
Modify:
examples/eq_demo.ail:1-23(doc-comment header rewrites) -
Modify:
crates/ail/tests/e2e.rs:1351-1360(doc-comment ofeq_demotest) -
Step 1: Enumerate the full fixture set
Run: grep -rln '(app == \|(app != \|(app < \|(app <= \|(app > \|(app >=' examples/ | sort
Expected: ~60 file paths. Save the list as the working set.
- Step 2: Per-file migration (manual substitution per file)
For each file in the list:
(app == x y) → (app eq x y)
(app != x y) → (app ne x y)
(app < x y) → (app lt x y)
(app <= x y) → (app le x y)
(app > x y) → (app gt x y)
(app >= x y) → (app ge x y)
For Float-operand call sites (operand typed Float — recon-noted candidates: bench_compute_collatz.ail, mut_sum_floats.ail, floats_1_newton_sqrt.ail, floats_3_safe_division.ail; verify each via inspecting the operand types in the fixture), use the float_* variants instead:
(app == x y) where x,y : Float → (app float_eq x y)
(app != x y) where x,y : Float → (app float_ne x y)
... etc.
The bulk-substitution is NOT a regex-replace because Float operands need the float_* form, not eq/ne/etc. Each fixture is touched once per call site; visually inspect the operand types around each call before substituting.
- Step 3: Migrate the user-ADT body in
eq_ord_user_adt.ail
Edit examples/eq_ord_user_adt.ail:21:
; Before:
(case (pat-ctor MkIntBox ai) (match b
(case (pat-ctor MkIntBox bi) (app == ai bi))))))))))
; After:
(case (pat-ctor MkIntBox ai) (match b
(case (pat-ctor MkIntBox bi) (app eq ai bi))))))))))
This shifts the user-instance body-hash, requiring the eq_ord_e2e re-pin in step 5.
- Step 4: Rewrite
eq_demo.aildoc-comment
Edit examples/eq_demo.ail:1-23 — the current 23-line doc-comment describes the polymorphic-== story (iter 16e). Rewrite the doc-comment to describe the class-dispatch story: eq dispatches via prelude.Eq.eq's primitive instances for Int / Bool / Str / Unit; compare dispatches via prelude.Ord.compare for Int / Bool / Str. Strip references to lower_eq, polymorphic ==, 16e. Keep the expected-stdout block at the end.
- Step 5: Rewrite the
eq_demotest doc-comment ine2e.rs
Edit crates/ail/tests/e2e.rs:1351-1360 — rewrite the doc-comment to match the new fixture story. The test body is unchanged (stdout pin is identical).
- Step 6: Re-pin the eq__IntBox body hash
Run: cargo test --workspace --quiet --test eq_ord_e2e eq_ord_user_adt_eq_intbox_hash_stable 2>&1 | tail -10
Expected: FAIL with eq__IntBox body hash drifted — ...; captured: <NEW_HASH>. Capture <NEW_HASH>.
Edit crates/ail/tests/eq_ord_e2e.rs:134 — replace "3c4cf040cb4e8bb2" with <NEW_HASH>. Add comment context: // 2026-05-20 operator-routing-eq-ord re-pinned (prior: 3c4cf040cb4e8bb2): user-instance body's inner == migrates to eq.
Re-run: cargo test --workspace --quiet --test eq_ord_e2e 2>&1 | tail -5
Expected: PASS.
- Step 7: Verify zero remaining operator-form occurrences
Run: grep -rln '(app == \|(app != \|(app < \|(app <= \|(app > \|(app >=' examples/ | wc -l
Expected: 0. If non-zero, identify the missed files and re-run step 2 on each.
- Step 8: Run the workspace test suite; expect
0 failed
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed across all binaries. The four remaining RED tests from Task 1 stay RED (Unit-eq path / Float-fn path / unknown-== / alwaysinline are state from earlier tasks). All other tests stay GREEN — every fixture's (app eq …) / (app lt …) / etc. now dispatches via the class layer that has been working since well before this milestone.
Task 5: Test-scaffold migration — all 8 in-source #[cfg(test)] AST-literal sites
Files:
-
Modify:
crates/ailang-check/src/lib.rs:5656(>=→ge) -
Modify:
crates/ailang-check/src/lib.rs:6088-6092(eq_apphelper + caller) -
Modify:
crates/ailang-check/src/lib.rs:6216-6220(second==literal site) -
Modify:
crates/ailang-check/src/builtins.rs:413-432(<smoke tests at lines 416, 428 — recon-noted) -
Modify:
crates/ailang-core/src/desugar.rs:2414 -
Modify:
crates/ailang-codegen/src/lib.rs:3371(in-sourcemod tests==literal) -
Modify:
crates/ailang-codegen/src/lib.rs:3419(in-sourcemod tests==literal) -
Modify:
crates/ailang-codegen/src/lib.rs:3729-3734(cmp("==", …)Float-arith tests) -
Modify:
crates/ailang-codegen/src/lib.rs:4354(in-sourcemod tests==literal) -
Step 1: Migrate
crates/ailang-check/src/lib.rs:5656
Locate Term::Var { name: ">=".into() } at line 5656; replace ">=" with "ge". The surrounding test is in #[cfg(test)] mod tests so the migration changes the test's AST-literal-construction without changing what it tests semantically (post-milestone, ge resolves via class-dispatch the way >= does today).
- Step 2: Migrate
crates/ailang-check/src/lib.rs:6088-6092eq_apphelper
Locate the eq_app fn helper at line ~6088 and its callers; replace Term::Var { name: "==".into() } with name: "eq".into() at line 6092.
- Step 3: Migrate
crates/ailang-check/src/lib.rs:6220
Locate the second AST-literal site at line 6220; replace name: "==".into() with name: "eq".into().
- Step 4: Migrate
crates/ailang-check/src/builtins.rs:413-432<smoke tests
Locate the in-source mod tests at lines 413-432 using app("<", …). Per recon, these are <-builtin smoke tests against synth_in_builtins_env. After Task 7 deletes < from the builtin table, these tests fail at typecheck because < is no longer a known identifier.
Two options:
(a) Migrate app("<", …) to app("lt", …) — tests then exercise the class-dispatch path for lt (which is itself the spec-intended behaviour for the new surface).
(b) Delete the tests as redundant with class-dispatch coverage from eq_ord_e2e.
Choose (a) for continuity (more pin coverage). Apply: replace each app("<", …) with app("lt", …). Doc-comments referencing < in the test body update alongside.
- Step 5: Migrate
crates/ailang-core/src/desugar.rs:2414
Locate the AST-literal site at line 2414 inside #[cfg(test)] mod tests (mod-block starts at line 1604). The literal is Term::Var { name: "==".into() }. Replace "==" with "eq". The surrounding test constructs an AST sample; semantic outcome of the test is unaffected — it pins a desugar-result shape downstream of where ==/eq is purely a symbol name.
- Step 6: Migrate
crates/ailang-codegen/src/lib.rs:3371, :3419, :4354
Locate each of the three in-source mod tests AST-literal sites. Per recon, these tests pin lower_eq-rejection-error wording for ADT/Fn types being passed to ==. After Task 7 deletes lower_eq and == from the builtin table, there is no lower_eq-rejection path to test.
Two options:
(a) Migrate the test fixtures to use eq and update assertions to assert the new NoInstance Eq <ADT> typecheck-time diagnostic (which fires earlier and replaces the codegen rejection).
(b) Delete the three tests as obsolete — the rejection mechanism the tests pinned is gone by structure.
Choose (b) — delete the three tests as obsolete. Rationale: the user-ADT NoInstance diagnostic is already pinned via eq_ord_e2e's negative case (no instance → fail at typecheck), so deletion does not lose coverage. Add a one-line // removed YYYY-MM-DD per operator-routing-eq-ord: lower_eq deletion makes this rejection path unreachable; coverage moves to NoInstance at typecheck comment at each deletion site (commit-body documentation only — these are deletions in test code, no live-narrative impact).
- Step 7: Migrate
crates/ailang-codegen/src/lib.rs:3729-3734
Locate the cmp("==", …) and cmp("<", …) calls in the Float-arith smoke tests at lines 3729-3734. These tests pin the synth builtin_binop_typed table arms for Float comparators. After Task 8 deletes those arms, the tests fail.
Migrate to the new symbol-name surface: replace cmp("==", …) with app("float_eq", …) and cmp("<", …) with app("float_lt", …). The test's helper fn cmp may need to be renamed or adjusted depending on its signature; if the helper hard-codes Int/Float dispatch, replace its call with a direct app(…) builder. Adjust the assertion to expect the IR shape produced by the try_emit_primitive_instance_body::float_eq/float_lt intercept (i.e., the fcmp oeq double / fcmp olt double lines remain, but emerge from a generated @ail_prelude_float_eq / @ail_prelude_float_lt body, not from a direct-emit ==/< path).
If migration is intractable for these specific tests (i.e. the test fundamentally pins direct-emit IR shape that no longer exists), delete the two tests and add the same one-line comment as step 6.
- Step 8: Build + run full workspace
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed. All migrated in-source tests pass under their new class-dispatch semantics (or are deleted). The 4 still-RED tests from Task 1 stay RED.
Task 6: Lit-pattern desugar — build_eq substitutes eq for ==
Files:
-
Modify:
crates/ailang-core/src/desugar.rs:1099(build_eqfn) -
Step 1: Patch the
==symbol inbuild_eq
In crates/ailang-core/src/desugar.rs, locate build_eq at line 1099. Find line 1109 reading:
name: "==".to_string(),
Replace with:
name: "eq".to_string(),
- Step 2: Update the doc-comment on
build_eq
The doc-comment at lines ~1095-1099 currently describes the function as emitting ==. Rewrite to:
/// Builds the equality-test AST node for a literal pattern. Lowers
/// `(pat-lit <lit>)` to `(if (eq sv <lit>) <body> <fall_k>)`. The
/// emitted `eq` resolves via prelude.Eq's class-method dispatch
/// (per design/contracts/0016-method-dispatch.md) — Int/Bool/Str/Unit
/// patterns all route through the corresponding primitive
/// instance.
///
/// Float-Literal patterns are hard-rejected at typecheck
/// (`CheckError::FloatPatternNotAllowed`, per
/// design/contracts/0005-float-semantics.md) before this fn is reached,
/// so the `eq`-dispatch never encounters Float.
fn build_eq(scrutinee: Term, lit: &Literal) -> Term {
- Step 3: Run lit-pattern tests; expect 0 failures
Run: cargo test --workspace --quiet --test e2e lit_pat_demo 2>&1 | tail -10
Expected: PASS. The lit_pat_demo fixture's (case (pat-lit "hi") …) arms now desugar to (if (eq sv "hi") …), which dispatches via prelude.Eq.eq for Str — same end result, different path. The fixture's stdout pin is identical.
- Step 4: Run the full workspace suite
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed. Lit-pattern desugar's substitution is purely a symbol rename; downstream typecheck and codegen handle the new symbol identically to the old.
Task 7: Delete dead operator machinery
After Tasks 1-6 land, no live code path references == / != / < / <= / > / >= as a value-level identifier — fixtures migrated (Task 4), test scaffolds migrated (Task 5), lit-pattern desugar emits eq (Task 6). The builtin-table entries, the codegen lowering paths, and the in-source ADT-rejection tests are dead code. This task deletes all of them in one cohesive sweep.
Files:
-
Modify:
crates/ailang-check/src/builtins.rs:96-98(delete comparator install loop) -
Modify:
crates/ailang-check/src/builtins.rs:100-125(delete==polymorphic Forall + comment band) -
Modify:
crates/ailang-check/src/builtins.rs:303-308(delete list-side mirror rows) -
Modify:
crates/ailang-codegen/src/synth.rs:263-276(delete 10 comparator arms inbuiltin_binop_typed) -
Modify:
crates/ailang-codegen/src/synth.rs:268-271(comment-band rewrite — drop pre-iter-4 historical reference, frame as arithmetic-only table) -
Modify:
crates/ailang-codegen/src/lib.rs:68-73(is_arithmetic_or_comparison_op→is_arithmetic_op; comment update at 60-67) -
Modify:
crates/ailang-codegen/src/lib.rs:2133(deleteif name == "=="short-circuit inlower_app) -
Modify:
crates/ailang-codegen/src/lib.rs:2529(deletename == "=="clause inis_static_callee) -
Modify:
crates/ailang-codegen/src/lib.rs:2879-2964(deletelower_eqwhole fn) -
Step 1: Delete typchecker builtin entries
In crates/ailang-check/src/builtins.rs:
Lines 96-98 currently read:
for op in ["!=", "<", "<=", ">", ">="] {
env.globals.insert(op.into(), poly_a_a_to_bool());
}
Delete these three lines.
Lines 100-125 currently read (the == Forall block plus 7-line comment band):
// `==` is polymorphic — `forall a. (a, a) -> Bool`. Codegen
// dispatches on the resolved arg type at the call site:
// `Int` → `icmp eq i64`, `Bool` → `icmp eq i1`, `Str` → `@strcmp`,
// `Unit` → constant `i1 1`, `Float` → `fcmp oeq double`. ADT/Fn
// equality is rejected at codegen with a clear "== not supported
// for type X" error. The ordering ops (`<`, `<=`, `>`, `>=`, `!=`)
// share the same polymorphic shape but with `Bool` return; `==`
// stays in its own arm only because the codegen-side dispatch
// table has historically been per-op.
env.globals.insert(
"==".into(),
Type::Forall {
vars: vec!["a".into()],
constraints: vec![],
body: Box::new(Type::Fn {
params: vec![
Type::Var { name: "a".into() },
Type::Var { name: "a".into() },
],
ret: Box::new(Type::bool_()),
effects: vec![],
param_modes: vec![],
ret_mode: ailang_core::ast::ParamMode::Implicit,
}),
},
);
Delete all 26 lines (the comment band + the env.globals.insert("==", …) block).
Lines 303-308 (inside pub fn list()) currently read:
("==", "forall a. (a, a) -> Bool"),
("!=", "forall a. (a, a) -> Bool"),
("<", "forall a. (a, a) -> Bool"),
("<=", "forall a. (a, a) -> Bool"),
(">", "forall a. (a, a) -> Bool"),
(">=", "forall a. (a, a) -> Bool"),
Delete all six rows.
- Step 2: Delete codegen builtin_binop_typed comparator arms
In crates/ailang-codegen/src/synth.rs:263-276, delete the 10 comparator arms (5 Int + 5 Float). The arithmetic arms at lines 251-259 remain.
In the same fn's preceding comment band (lines 260-272), rewrite the historical reference. Current text discusses the iter-4.2/4.3 split where Int comparators stayed and Float comparators were added; replace with:
// `builtin_binop_typed` is now arithmetic-only. Comparators
// (`==`/`!=`/`<`/`<=`/`>`/`>=`) were removed in iter
// operator-routing-eq-ord.1 — surface comparison routes through
// the prelude.Eq/Ord class-method dispatch, with primitive
// instance bodies emitted via try_emit_primitive_instance_body
// in lib.rs (Eq Int/Bool/Str/Unit → icmp; Ord Int/Bool/Str
// → icmp + ctor; float_eq/float_ne/float_lt/le/gt/ge → fcmp).
- Step 3: Update
is_arithmetic_or_comparison_op
In crates/ailang-codegen/src/lib.rs:68-73, the fn currently includes == / != / < / <= / > / >= in its match. After deletion of the comparator arms, rename the fn to is_arithmetic_op and reduce its match to + / - / * / / / %. Update the doc-comment at lines 60-67 alongside (drop the comparator-related sentences).
Find all callers of is_arithmetic_or_comparison_op via git grep -n 'is_arithmetic_or_comparison_op' crates/. Update each call site to the new name.
- Step 4: Delete
==short-circuit inlower_app
In crates/ailang-codegen/src/lib.rs:2126-2144, locate the if name == "==" short-circuit (the recon-noted line at 2133). Delete the entire if-block (and its body — the recon hint is "lines 2126-2144").
After deletion, lower_app falls through to the standard fn-call path for any name; since == is no longer a known identifier (deleted from builtins), the typechecker rejects (app == …) at an earlier stage, so this branch can never be reached.
- Step 5: Delete
name == "=="clause inis_static_callee
In crates/ailang-codegen/src/lib.rs:2529, the predicate currently lists name == "==" as one of the static-callee patterns. Delete that clause.
- Step 6: Delete
lower_eqwhole fn
In crates/ailang-codegen/src/lib.rs:2879-2964, delete the entire lower_eq fn (signature + body). Also delete its doc-comment (the preceding /// lines).
Find any leftover callers (should be none after step 4):
git grep -n 'lower_eq\b' crates/
Expected: 0 hits (the only caller was the == short-circuit in lower_app, deleted in step 4). If any remain, delete them.
- Step 7: Build the workspace; expect 0 errors
Run: cargo build --workspace --tests 2>&1 | tail -10
Expected: Finished … (0 errors). Compile-gate ratifies: nothing in the production tree calls lower_eq, references the deleted builtin-globals entries, or references the deleted builtin_binop_typed comparator arms. The deletions are fully internally coherent.
If error[E0061] or error[E0425] (cannot find function) appears: a caller was missed in steps 4-6 — git grep the missing symbol and update.
- Step 8: Run the full workspace test suite
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed. After deletion, the operator_names_unbound_pin test flips to GREEN — (app == 5 5) typechecks to unknown variable: ==. The four other Task-1 tests:
eq_user_adt_smoke_e2e— depends on Eq Unit instance (added Task 3); should now GREEN (the inner(app eq a1 a2)on Int still works via class-dispatch with Task 2's intercept)float_compare_smoke_e2e— depends on float_* fns (added Task 3); should now GREENeq_float_must_fail_pin— still RED (diagnostic does not yet mentionfloat_eq— Task 8 handles)prelude_eq_int_carries_alwaysinline_attribute_in_emitted_ir— stays GREEN (was Task 2)
If eq_user_adt_smoke_e2e or float_compare_smoke_e2e is still RED: the class-dispatch path for eq__Unit or float_eq isn't reaching the intercept. Investigate the mono-symbol naming convention (do mono-pass-emitted user instances really resolve to prelude.eq__Unit / prelude.float_eq as the intercept arms key on?).
Task 8: Float-aware NoInstance addendum — name float_eq / float_lt as the alternative
Files:
-
Modify:
crates/ailang-check/src/lib.rs:856-880(extend Float-aware diagnostic) -
Modify:
crates/ail/tests/eq_float_noinstance.rs:32-44(extend assertion to requirefloat_eqsubstring) -
Step 1: Extend the Float-aware diagnostic wording
In crates/ailang-check/src/lib.rs:856-880, the existing addendum reads (around line 868-872):
if (class == "prelude.Eq" || class == "prelude.Ord") && at_type == "Float" {
d.message = format!(
"{} — Float has no Eq/Ord instance by design (partial \
orderability per IEEE-754); see design/contracts/0005-float-semantics.md.",
d.message
);
} else if class == "prelude.Show" {
Extend the wording to mention float_eq and float_lt:
if (class == "prelude.Eq" || class == "prelude.Ord") && at_type == "Float" {
d.message = format!(
"{} — Float has no Eq/Ord instance by design (partial \
orderability per IEEE-754); use float_eq / float_lt \
(and siblings) for explicit IEEE-aware comparison. \
See design/contracts/0005-float-semantics.md.",
d.message
);
} else if class == "prelude.Show" {
- Step 2: Run
eq_float_must_fail_pin; expect GREEN
Run: cargo test --workspace --quiet --test eq_float_must_fail_pin 2>&1 | tail -10
Expected: PASS. The diagnostic now contains float_eq substring.
- Step 3: Extend the existing
eq_float_noinstance.rsassertion
In crates/ail/tests/eq_float_noinstance.rs:32-44, the existing test asserts the diagnostic contains Float and the float-semantics.md doc reference. Extend the assertion to also require float_eq:
// Existing (around line 38-44):
assert!(
stderr.contains("Float") && stderr.contains("design/contracts/0005-float-semantics.md"),
"expected Float-aware addendum, got: {stderr}"
);
// Replace with:
assert!(
stderr.contains("Float") && stderr.contains("design/contracts/0005-float-semantics.md") && stderr.contains("float_eq"),
"expected Float-aware addendum with float_eq hint, got: {stderr}"
);
Run: cargo test --workspace --quiet --test eq_float_noinstance 2>&1 | tail -5
Expected: PASS.
- Step 4: Run the full workspace test suite
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed summary line. All 5 RED-bootstrap tests from Task 1 are now GREEN.
Task 9: IR snapshot regeneration
Files:
- Modify:
crates/ail/tests/snapshots/sum.ll - Modify:
crates/ail/tests/snapshots/max3.ll
sum.ail uses == (lit-pattern desugar) and max3.ail uses > directly. After Tasks 4 and 6 land, the desugar emits eq and the source fixtures use eq/gt — the resulting IR shape shifts from direct icmp to a call into @ail_prelude_eq__Int (or @ail_prelude_gt) with the alwaysinline attribute. The checked-in snapshots no longer match; they regenerate.
- Step 1: Regenerate snapshots via UPDATE_SNAPSHOTS=1
Run: UPDATE_SNAPSHOTS=1 cargo test --workspace --quiet -p ail --test ir_snapshot 2>&1 | tail -10
Expected: regeneration completes; the 5 snapshot files (hello.ll, list.ll, max3.ll, sum.ll, ws_main.ll) are touched. Of these, sum.ll and max3.ll shift content meaningfully (they use comparison surface); hello.ll, list.ll, ws_main.ll are likely unchanged (they don't use comparisons) but the test framework may re-write them anyway — that's fine.
- Step 2: Re-run the snapshot test without UPDATE_SNAPSHOTS
Run: cargo test --workspace --quiet -p ail --test ir_snapshot 2>&1 | tail -5
Expected: PASS.
- Step 3: Manual visual inspection
Read crates/ail/tests/snapshots/sum.ll and max3.ll. Confirm the comparison IR shape is now:
%r = call i1 @ail_prelude_eq__Int(i64 ..., i64 ...) ...
; or with alwaysinline attribute on the define for eq__Int:
define i1 @ail_prelude_eq__Int(i64, i64) alwaysinline { ... }
And the lower_eq direct-emit path (icmp eq i64 inline in main's body) is GONE.
If the snapshot still contains an inline icmp from a direct-emit path: a callsite was missed in Task 7. Investigate.
Task 10: Prose-projection cleanup — delete dead comparator binop_info arms
Files:
- Modify:
crates/ailang-prose/src/lib.rs:546-563(delete 6 comparator arms inbinop_info) - Modify:
crates/ailang-prose/src/lib.rs:1952(delete first comparator-infix test) - Modify:
crates/ailang-prose/src/lib.rs:2020-2062(delete second & third comparator-infix tests)
After the operator-name deletion, Term::App { fn: Var "==" } (et al.) can only appear in JSON state that bypasses typecheck (hand-crafted, error-path). Prose-render's binop_info table currently treats these as infix operators — but the language no longer recognises them as such, so prose-rendering them as infix is misleading. Falling through to the standard fn-call form is more honest.
- Step 1: Delete the 6 comparator arms in
binop_info
In crates/ailang-prose/src/lib.rs:546-563, locate fn binop_info (or the equivalent table-keyed-on-operator-name structure). Delete the 6 arms for == / != / < / <= / > / >=. The arithmetic arms (+ / - / * / / / %) remain unchanged.
- Step 2: Delete the 3 comparator-infix tests in
mod tests
Locate the in-source #[cfg(test)] mod tests at lines 1952 and 2020-2062. Identify the 3 tests that pin comparator-infix rendering — recon notes them at line 1952 and the 2020-2062 range. Delete each test fn (signature + body + the #[test] attribute line). Add a one-line // removed YYYY-MM-DD per operator-routing-eq-ord: ==/!=/</<=/>/>= are no longer surface operators; their AST forms (if reachable via error paths) render via the standard fn-call shape, not infix. comment at the deletion site of the first test.
- Step 3: Run the prose-suite tests
Run: cargo test --workspace --quiet -p ailang-prose 2>&1 | tail -10
Expected: 0 failed. The arithmetic-infix tests stay GREEN. The deleted comparator-infix tests are gone (no replacement).
- Step 4: Run the full workspace test suite
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed.
Task 11: Contract updates — 5 design files
Files:
-
Modify:
design/contracts/0005-float-semantics.md:10-31, :98-99 -
Modify:
design/contracts/0017-prelude-classes.md:5-13 -
Modify:
design/contracts/0011-str-abi.md:40-42 -
Modify:
design/contracts/0010-scope-boundaries.md:42-96(operator-name mentions + Pattern::Lit-to-==clause) -
Modify:
design/models/0001-authoring-surface.md:58 -
Step 1: Update
float-semantics.md
In design/contracts/0005-float-semantics.md:10-14, the bullet currently reads:
- Every individual builtin (`+`/`-`/`*`/`/`/`neg`/`<`/`==`/...) lowers
to a single LLVM IR instruction on the Float arm:
`fadd/fsub/fmul/fdiv double`, `fneg double`, `fcmp olt/ole/ogt/oge
double`, `fcmp oeq double`, `fcmp une double` (for `!=`). On a
fixed `(target triple, LLVM version)` pair, the bit pattern of
the result of any single op is reproducible.
Rewrite as:
- Every individual arithmetic builtin (`+`/`-`/`*`/`/`/`neg`) lowers
to a single LLVM IR instruction on the Float arm: `fadd/fsub/fmul/fdiv
double`, `fneg double`. Float comparison goes through the named
prelude fns `float_eq` / `float_ne` / `float_lt` / `float_le` /
`float_gt` / `float_ge` (Float has no Eq/Ord instance — see
[Prelude (built-in) classes](prelude-classes.md)); each fn lowers
to a single `fcmp` instruction (`oeq` / `une` / `olt` / `ole` /
`ogt` / `oge` respectively) via the codegen intercept with the
`alwaysinline` attribute, so the call folds to one instruction
at every use site. On a fixed `(target triple, LLVM version)`
pair, the bit pattern of the result of any single op is
reproducible.
Lines 22-31 — the present-tense ==/!= paragraphs (NaN-==-returns-false; != returns true on NaN) — rewrite to name float_eq / float_ne instead of the operators:
- `float_eq` returns `false` whenever either operand is NaN
(`fcmp oeq` is the ordered-equal predicate; ordered = both
operands non-NaN).
- `float_ne` returns `true` whenever either operand is NaN
(`fcmp une` is the unordered-or-not-equal predicate). This matches Rust
`f64::ne` and IEEE-`!=` exactly.
Line 98-99 (NaN-rendering caveat) — strip the ==/< mentions; reword the cross-reference to use the named-fn surface.
- Step 2: Update
prelude-classes.md
In design/contracts/0017-prelude-classes.md:5-13, the current text describes Eq/Ord with primitive instances for Int/Bool/Str. Update:
-
Extend the instance list with Eq Unit.
-
Add a paragraph after the existing Eq/Ord/Show description naming the six
float_*fns as the Float-comparison surface (eachFloat -> Float -> Boolwithout a class constraint; lowered to a singlefcmpvia codegen intercept; replaces the deleted polymorphic==/</etc. operators on Float). -
The Float-no-Eq/Ord clause gains
→ use float_eq / float_lt for explicit IEEE-aware comparisonas a one-line cross-reference. -
Step 3: Update
str-abi.md:40-42
In design/contracts/0011-str-abi.md:40-42, the current clause reads (paraphrase from recon): "==, <, <=, >, >= REMAIN primitive operators". Rewrite to describe the class-method dispatch path: equality on Str dispatches via prelude.Eq.eq (Str instance, lowered to @ail_str_eq via try_emit_primitive_instance_body::eq__Str); ordering on Str dispatches via prelude.Ord.compare (Str instance, lowered to @ail_str_compare).
- Step 4: Update
scope-boundaries.md:42-96
Read design/contracts/0010-scope-boundaries.md:42-96 in full. Per recon, multiple clauses describe the dying surface:
- "
==is polymorphic" - "
!=for Float usesfcmp UNE" - "
Pattern::LittoTerm::Ifon==" - "any literal kind whose
==is supported"
For each:
-
"
==is polymorphic" → rewrite to "eq(class-method) dispatches viaprelude.Eqper arg type" -
"
!=for Float usesfcmp UNE" → rewrite to "float_nelowers tofcmp une" -
"
Pattern::LittoTerm::Ifon==" → rewrite to "Pattern::Litdesugars to(if (eq sv lit) body fall_k)via desugar::build_eq" -
"any literal kind whose
==is supported" → rewrite to "any literal kind whoseeqdispatch resolves to a known primitive instance (Int/Bool/Str/Unit; Float Patterns are hard-rejected per float-semantics.md)" -
Step 5: Update
authoring-surface.md:58
In design/models/0001-authoring-surface.md:58, the current example lists ==, <= among operators. Drop == and <= from the example; the operator list becomes arithmetic-only (+, -, *, /, %). Add a follow-up sentence: "Comparison and equality are class methods (eq / compare), not operators — see Prelude classes."
- Step 6: Verify honesty-rule pin still passes
Run: cargo test --workspace --quiet --test docs_honesty_pin 2>&1 | tail -10
Expected: PASS. The honesty-pin scans for forward-intent / future-tense / aspirational language; the contract updates introduce no such language (all rewrites are present-tense and describe present behaviour).
If a new failure surfaces (e.g. a token like polymorphic == is somehow flagged), fix the wording inline.
- Step 7: Run the full workspace test suite
Run: cargo test --workspace --quiet 2>&1 | tail -10
Expected: 0 failed.
Task 12: Acceptance gate — workspace + bench + spec criteria
Files:
-
Read-only verification across the whole working tree.
-
Step 1: Full workspace test suite
Run: cargo test --workspace 2>&1 | tail -30
Expected: every binary line ends 0 failed. Total pass-count delta vs. the milestone start equals the 5 new tests added in Task 1, minus any test-scaffold deletions in Tasks 5/10. Capture the per-binary pass counts for the iter commit body.
- Step 2: Bench corpus — throughput + latency
Run: bench/check.py 2>&1 | tail -20
Expected: exit 0; 0 regressed across all metrics. The expectation is the post-alwaysinline IR shape collapses primitive-eq calls to single icmp instructions under -O2 (deterministic), matching the pre-milestone direct-emit performance.
If regressed metrics > 0: the alwaysinline mitigation is not folding the call at the expected use site. Investigate:
- Inspect the regressed workload's
--emit-ir --opt=O2output; confirm the call is inlined (nocall @ail_prelude_eq__Intremains). - If the call is inlined but performance is worse: there's a different codegen-shape change (e.g. extra
phinode, missed dead-block elimination); deeper investigation. - If the call is NOT inlined:
alwaysinlineattribute is silently being stripped — investigateemit_fn's attribute emission for that symbol.
- Step 3: Bench corpus — compile
Run: bench/compile_check.py 2>&1 | tail -10
Expected: exit 0; 0 regressed.
- Step 4: Bench corpus — cross-language
Run: bench/cross_lang.py 2>&1 | tail -10
Expected: exit 0; 0 regressed.
- Step 5: Acceptance grep — no live
==/</ etc. in deleted layers
Run:
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 2>&1 | grep -v '^\s*//' | head -30
Expected: zero live (non-comment) hits in env.globals.insert("==", …), Type::Var { name: "==" }, or Some((..., "...)) table-entry shapes. Comment-only hits (historical references in commit-body documentation) are acceptable.
If live hits remain: a deletion site was missed in Task 7 — find and remove.
- Step 6: Acceptance grep — no live operator-form in any fixture
Run: grep -rln '(app == \|(app != \|(app < \|(app <= \|(app > \|(app >=' examples/ | wc -l
Expected: 0.
- Step 7: Prelude content check
Run: grep -E 'instance.*Eq.*Unit|fn float_eq|P2 follow-up' examples/prelude.ail
Expected:
-
(class prelude.Eq)followed within 5 lines by(type (con Unit))—instance Eq Unitpresent -
(fn float_eqpresent -
NO
P2 follow-up(the line-9 comment has been removed) -
Step 8: Spec acceptance criteria 1-11 cross-check
Walk the spec's ## Acceptance criteria section (criteria 1-11) one-by-one. Each criterion has a corresponding step above OR is structurally guaranteed by the per-task gates. Confirm each. If any criterion is unmet, the iter is BLOCKED — write to BLOCKED.md per the implement-skill protocol with the specific unmet criterion named.
- Step 9: Write the iter stats file
The implement-orchestrator writes the per-iter stats file directly. Path: bench/orchestrator-stats/2026-05-20-iter-operator-routing-eq-ord.1.json. The implementer skill specifies the format.
- Step 10: Hand off to Boss for commit
Working tree contains the iter's full set of changes. Boss inspects via git status / git diff --stat, drafts iter commit body, commits as iter operator-routing-eq-ord.1 (DONE N/N): drop comparator builtins, route through Eq/Ord with closes #1 trailer.