plan: intrinsic-bodies.2-migration-lock — 4-task prelude migration + bijection pin (refs #9)
Decomposes intrinsic-bodies.2 (parent spec § Architecture points 6-8)
into 4 tasks + a final gate:
Task 1 — migrate the 13 authored prelude dummy bodies to (intrinsic):
7 Eq/Ord instance methods (inner (body false)/(body true)/
(body (term-ctor Ordering EQ)) → (intrinsic), Design X lambda-body
placement) + 6 float_* fns ((body false) → (intrinsic)). Gated by
round-trip + the 4 eq/compare/float E2E ratifiers staying green
(behaviour-preserving: the intercepts emit identical IR).
Task 2 — rebaseline the two moving hash pins: prelude_module_hash_pin
(prelude module hash) and mono_hash_stability's 6 eq/compare
def-hashes (capture-from-failure, replace the constant). The 4
show__* pins do NOT move.
Task 3 — replace the one-directional registry_contains_all_legacy_arms
pin with intercepts_bijection_with_intrinsic_markers: an in-source
test that walks prelude + kernel_stub pre-mono for Term::Intrinsic
markers, recovers mangled names (mono_symbol for instance methods,
fn name for top-level), and asserts the bijection over the
intrinsic-backed class (14) with the 5-name *__Int optimisation-only
allowlist. Both directions + a stale-allowlist guard.
Task 4 — confirm no codegen path lowers an intercepted body (already
true post-.1: intercept-by-name precedes lower_term), delete the
stale dummy-body comment at lib.rs:1310-1319, and conditionally edit
0007-honesty-rule.md ONLY if it names the dummies (recon: it does
not — a general rule; no forced edit).
Recon-driven plan decisions:
1. The bijection pin lives in intercepts.rs's in-source #[cfg(test)]
mod tests, NOT a crates/ail/tests integration test. Visibility
forces it: the pin needs INTERCEPTS (pub(crate) in codegen, in-source
only) AND the parse hops (ailang-surface dev-dep). Verified
ailang-surface does not dep ailang-codegen, so there is no
dev-dep cycle blocking the in-source test from calling parse_prelude
(the dev-dep-cycle hazard that bit pd.2.4/pd.3.1 does not apply
here — that was the ailang-core <-> ailang-surface edge).
2. The pin walks PRE-mono (parse_prelude/parse_kernel_stub) and
reconstructs mangled names via mono_symbol, rather than walking
post-mono. Post-mono only synthesises USED mono symbols, so a
post-mono walk would miss any instance method the pin's entry
fixture does not exercise (e.g. eq__Unit). Pre-mono + mono_symbol
sees all 14 markers unconditionally.
3. Task 2's hashes are capture-from-failure, not pre-computed — the new
prelude bytes determine them. The old->new values get recorded in
the iter commit body by the Boss.
All verification filter strings checked against the tree: the 4 E2E
ratifier fn names, the mono pin name, and the prelude pin target all
resolve to ≥1 real test. No ail/ail-json/ll fenced block in the plan
(the migrated-form snippets are scheme fragments — the (intrinsic) form
is already ratified by .1's kernel_intrinsic_smoke.ail), so the
parse-bytes gate is a documented no-op.
Handoff target: skills/implement on docs/plans/0107-intrinsic-bodies.2-migration-lock.md
This commit is contained in:
@@ -0,0 +1,363 @@
|
||||
# intrinsic-bodies.2 — migration + lock — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0055-intrinsic-bodies.md` (committed 8301ca3; .1 shipped at 52ff873)
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement`
|
||||
> skill to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Migrate the 13 authored prelude dummy bodies to `(intrinsic)`,
|
||||
rebaseline the two hash pins that move, and replace the one-directional
|
||||
registry pin with a bijection over the intrinsic-backed class (with the
|
||||
5-name optimisation-only allowlist) — closing the honesty-rule
|
||||
infraction the milestone exists to fix.
|
||||
|
||||
**Architecture:** The `(intrinsic)` mechanism shipped in .1; this
|
||||
iteration is source migration + a stronger lock. The prelude's 7 Eq/Ord
|
||||
instance-method bodies and 6 `float_*` fn bodies stop carrying
|
||||
placeholder terms and become `Term::Intrinsic`. Behaviour is unchanged —
|
||||
codegen already intercepts these by name before the body is inspected
|
||||
(verified .1 reality), so the placeholder was already dead. Two hash
|
||||
pins move (prelude module hash; the 6 post-mono eq/compare def-hashes)
|
||||
and rebaseline. The registry pin upgrades from "the 18 legacy names
|
||||
resolve" to a bijection: every workspace `(intrinsic)` marker has an
|
||||
`INTERCEPTS` entry, and every non-allowlisted entry has a marker.
|
||||
|
||||
**Tech Stack:** `examples/prelude.ail` (13 body edits),
|
||||
`crates/ailang-surface/tests/prelude_module_hash_pin.rs` +
|
||||
`crates/ail/tests/mono_hash_stability.rs` (rebaselines),
|
||||
`crates/ailang-codegen/src/intercepts.rs` (bijection pin),
|
||||
`crates/ailang-codegen/src/lib.rs` (stale-comment cleanup),
|
||||
`design/contracts/0007-honesty-rule.md` (conditional).
|
||||
|
||||
---
|
||||
|
||||
## Files this plan creates or modifies
|
||||
|
||||
- Modify: `examples/prelude.ail` — 13 dummy bodies → `(intrinsic)` (lines 18,24,30,36,48,54,60 instance methods; 128,133,138,143,148,153 float fns).
|
||||
- Modify: `crates/ailang-surface/tests/prelude_module_hash_pin.rs:36` — rebaseline prelude module hash.
|
||||
- Modify: `crates/ail/tests/mono_hash_stability.rs:56-61` — rebaseline 6 eq/compare def-hashes.
|
||||
- Modify: `crates/ailang-codegen/src/intercepts.rs:461-506` — replace the one-directional pin with the bijection pin.
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1310-1319` — delete the now-stale dummy-body comment; confirm no body-lowering of an intercepted def.
|
||||
- Modify (conditional): `design/contracts/0007-honesty-rule.md` — only if it names the prelude dummies.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Migrate the 13 authored prelude dummy bodies
|
||||
|
||||
**Files:**
|
||||
- Modify: `examples/prelude.ail`
|
||||
|
||||
The migration rule by site kind:
|
||||
- **Instance method** (Eq/Ord): the body is
|
||||
`(body (lam (params ...) (ret ...) (body <dummy>)))`. Replace the
|
||||
*inner* `(body <dummy>)` with `(intrinsic)`, leaving the lambda's
|
||||
`(params ...)` and `(ret ...)` — the local signature — intact (Design X).
|
||||
- **Top-level `float_*` fn**: the body clause is `(body false)`. Replace
|
||||
the whole `(body false)` clause with `(intrinsic)` (a fn carries
|
||||
`(intrinsic)` in the body slot, no `(body ...)` wrapper).
|
||||
|
||||
- [ ] **Step 1: Migrate the 7 Eq/Ord instance methods**
|
||||
|
||||
In `examples/prelude.ail`, at each of these inner-body sites, replace
|
||||
the inner `(body <dummy>)` with `(intrinsic)`:
|
||||
|
||||
| line | def | inner clause today → becomes |
|
||||
|---|---|---|
|
||||
| 18 | Eq Int / eq | `(body false)` → `(intrinsic)` |
|
||||
| 24 | Eq Bool / eq | `(body false)` → `(intrinsic)` |
|
||||
| 30 | Eq Str / eq | `(body false)` → `(intrinsic)` |
|
||||
| 36 | Eq Unit / eq | `(body true)` → `(intrinsic)` |
|
||||
| 48 | Ord Int / compare | `(body (term-ctor Ordering EQ))` → `(intrinsic)` |
|
||||
| 54 | Ord Bool / compare | `(body (term-ctor Ordering EQ))` → `(intrinsic)` |
|
||||
| 60 | Ord Str / compare | `(body (term-ctor Ordering EQ))` → `(intrinsic)` |
|
||||
|
||||
Each line becomes the shape (Eq Int shown):
|
||||
|
||||
```scheme
|
||||
(method eq
|
||||
(body (lam (params (typed x a) (typed y a)) (ret (con Bool)) (intrinsic)))))
|
||||
```
|
||||
|
||||
(The inner `(body false)` appears on the eq Int/Bool/Str lines AND on
|
||||
the float lines — edit by the specific line number / surrounding
|
||||
`(type (con Int|Bool|Str|Unit))` context so the right occurrence is
|
||||
changed. The `(intrinsic)` form is already ratified by .1's
|
||||
`examples/kernel_intrinsic_smoke.ail`; this only changes existing
|
||||
bodies to that form.)
|
||||
|
||||
- [ ] **Step 2: Migrate the 6 float_* fns**
|
||||
|
||||
At each float fn, replace the `(body false)` clause with `(intrinsic)`:
|
||||
|
||||
| line | def |
|
||||
|---|---|
|
||||
| 128 | float_eq |
|
||||
| 133 | float_ne |
|
||||
| 138 | float_lt |
|
||||
| 143 | float_le |
|
||||
| 148 | float_gt |
|
||||
| 153 | float_ge |
|
||||
|
||||
Each becomes the shape (float_eq shown):
|
||||
|
||||
```scheme
|
||||
(fn float_eq
|
||||
(doc "IEEE Float equality. ...")
|
||||
(type (fn-type (params (con Float) (con Float)) (ret (con Bool))))
|
||||
(params x y)
|
||||
(intrinsic))
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Parse + check the migrated prelude indirectly**
|
||||
|
||||
Run: `cargo run -q -p ail --bin ail -- check examples/hello.ail`
|
||||
Expected: `ok (...)` exit 0 — any consumer build auto-injects the
|
||||
prelude, so a parse/check failure in the migrated prelude surfaces here.
|
||||
(The prelude is embedded via `parse_prelude`; there is no standalone
|
||||
`ail check examples/prelude.ail` path.)
|
||||
|
||||
- [ ] **Step 4: Round-trip gate green**
|
||||
|
||||
Run: `cargo test -p ailang-surface --test round_trip`
|
||||
Expected: PASS — the migrated prelude bodies round-trip parse∘print=id.
|
||||
|
||||
- [ ] **Step 5: Behaviour-preservation ratifiers green**
|
||||
|
||||
Run: `cargo test -p ail --test e2e eq_primitives_smoke_compiles_and_runs compare_primitives_smoke_prints_1_2_3_thrice float_compare_smoke_prints_true_true_false eq_ord_polymorphic_runs_end_to_end`
|
||||
Expected: all PASS — the intercepts emit the same IR; only the now-dead
|
||||
source body changed. If any of these go red, the migration changed
|
||||
behaviour and is wrong — STOP.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Rebaseline the two moving hash pins
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-surface/tests/prelude_module_hash_pin.rs:36`
|
||||
- Modify: `crates/ail/tests/mono_hash_stability.rs:56-61`
|
||||
|
||||
The new hashes are not knowable in advance — they are determined by the
|
||||
migrated prelude bytes. The procedure is: run the pin, read the captured
|
||||
hash from the failure message, replace the pinned constant. This is
|
||||
deterministic mechanics, not a placeholder.
|
||||
|
||||
- [ ] **Step 1: Capture + rebaseline the prelude module hash**
|
||||
|
||||
Run: `cargo test -p ailang-surface --test prelude_module_hash_pin`
|
||||
Expected: FAIL with a message naming the new hash (the pin asserts
|
||||
`== "af372f28c726f29f"`; it now reports the captured new hash). Replace
|
||||
the constant at `prelude_module_hash_pin.rs:36` with the captured value.
|
||||
|
||||
- [ ] **Step 2: Re-run the prelude hash pin green**
|
||||
|
||||
Run: `cargo test -p ailang-surface --test prelude_module_hash_pin`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 3: Capture + rebaseline the 6 eq/compare mono def-hashes**
|
||||
|
||||
Run: `cargo test -p ail --test mono_hash_stability primitive_eq_ord_mono_symbol_hashes_stay_bit_identical`
|
||||
Expected: FAIL — each of `eq__Int/eq__Bool/eq__Str/compare__Int/compare__Bool/compare__Str`
|
||||
reports a drifted hash (the synthesised body flipped from the dummy term
|
||||
to `Term::Intrinsic`). For each `(sym, pin)` pair at lines 56-61, replace
|
||||
the pinned hash with the captured value from the failure message. The
|
||||
4 `show__*` pins (lines 100-103) are NOT touched — Show bodies are real
|
||||
and unchanged.
|
||||
|
||||
- [ ] **Step 4: Re-run the mono hash pin green**
|
||||
|
||||
Run: `cargo test -p ail --test mono_hash_stability`
|
||||
Expected: PASS (both `primitive_eq_ord_*` and `primitive_show_*`).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Bijection pin over the intrinsic-backed class
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/intercepts.rs` (the `#[cfg(test)] mod tests`, currently 461-506)
|
||||
|
||||
- [ ] **Step 1: Replace the one-directional pin with the bijection pin**
|
||||
|
||||
In `crates/ailang-codegen/src/intercepts.rs`, the in-source
|
||||
`#[cfg(test)] mod tests` currently holds `registry_contains_all_legacy_arms`
|
||||
(uses only `super::lookup`). Replace that test with the bijection pin
|
||||
below. It walks the two kernel-tier source modules (prelude +
|
||||
kernel_stub) pre-mono for `Term::Intrinsic` markers, recovers each
|
||||
marker's mangled name (`mono_symbol` for instance methods; the fn name
|
||||
directly for top-level fns), and asserts the bijection against
|
||||
`INTERCEPTS` modulo the optimisation-only allowlist.
|
||||
|
||||
Dependencies used (all verified reachable from this in-source test:
|
||||
`ailang-surface` is a dev-dep with no cycle back to codegen;
|
||||
`ailang-check` is a normal dep with `pub mod mono`; `ailang-core` is a
|
||||
normal dep):
|
||||
|
||||
```rust
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{lookup, INTERCEPTS};
|
||||
use ailang_check::mono::mono_symbol;
|
||||
use ailang_core::ast::{Def, Term};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// INTERCEPTS entries that intercept the monomorphised `__Int`
|
||||
/// specialisation of a polymorphic free fn carrying a REAL body
|
||||
/// (`ne = not (eq x y)`; `lt/le/gt/ge = match compare ...`). These
|
||||
/// are an optimisation class, not a compiler-supplied body — they
|
||||
/// legitimately have no `(intrinsic)` marker. Any change here is a
|
||||
/// deliberate registry-policy decision, not drift.
|
||||
const OPTIMISATION_ONLY: &[&str] =
|
||||
&["lt__Int", "le__Int", "gt__Int", "ge__Int", "ne__Int"];
|
||||
|
||||
/// Collect the mangled name of every `(intrinsic)` marker reachable
|
||||
/// in the kernel-tier source modules (prelude + kernel_stub — the
|
||||
/// only modules where an intrinsic body is legal today).
|
||||
fn workspace_intrinsic_markers() -> BTreeSet<String> {
|
||||
let mut markers = BTreeSet::new();
|
||||
for module in [
|
||||
ailang_surface::parse_prelude(),
|
||||
ailang_surface::parse_kernel_stub(),
|
||||
] {
|
||||
for def in &module.defs {
|
||||
match def {
|
||||
// Top-level intrinsic fn: name is already the symbol
|
||||
// (float_eq, answer, ...).
|
||||
Def::Fn(f) if matches!(f.body, Term::Intrinsic) => {
|
||||
markers.insert(f.name.clone());
|
||||
}
|
||||
// Instance method whose lambda body is intrinsic:
|
||||
// the codegen symbol is mono_symbol(method, type).
|
||||
Def::Instance(inst) => {
|
||||
for m in &inst.methods {
|
||||
if let Term::Lam { body, .. } = &m.body {
|
||||
if matches!(**body, Term::Intrinsic) {
|
||||
markers.insert(mono_symbol(&m.name, &inst.type_));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
markers
|
||||
}
|
||||
|
||||
/// Bijection over the intrinsic-backed class:
|
||||
/// (A) every workspace intrinsic marker resolves to an INTERCEPTS entry;
|
||||
/// (B) every INTERCEPTS entry not on the optimisation-only allowlist
|
||||
/// has a workspace intrinsic marker.
|
||||
#[test]
|
||||
fn intercepts_bijection_with_intrinsic_markers() {
|
||||
let markers = workspace_intrinsic_markers();
|
||||
let registry: BTreeSet<String> =
|
||||
INTERCEPTS.iter().map(|i| i.name.to_string()).collect();
|
||||
|
||||
// (A) no intrinsic marker without a codegen intercept
|
||||
let orphan_markers: Vec<&String> =
|
||||
markers.iter().filter(|m| lookup(m).is_none()).collect();
|
||||
assert!(
|
||||
orphan_markers.is_empty(),
|
||||
"intrinsic markers with no INTERCEPTS entry: {orphan_markers:?}"
|
||||
);
|
||||
|
||||
// (B) no non-allowlisted intercept without an intrinsic marker
|
||||
let orphan_entries: Vec<&String> = registry
|
||||
.iter()
|
||||
.filter(|n| !OPTIMISATION_ONLY.contains(&n.as_str()))
|
||||
.filter(|n| !markers.contains(*n))
|
||||
.collect();
|
||||
assert!(
|
||||
orphan_entries.is_empty(),
|
||||
"INTERCEPTS entries with no intrinsic marker (and not optimisation-only): {orphan_entries:?}"
|
||||
);
|
||||
|
||||
// Guard: the allowlist names must actually be in the registry —
|
||||
// a stale allowlist entry (name removed from INTERCEPTS) is drift.
|
||||
let stale_allow: Vec<&&str> = OPTIMISATION_ONLY
|
||||
.iter()
|
||||
.filter(|n| lookup(n).is_none())
|
||||
.collect();
|
||||
assert!(
|
||||
stale_allow.is_empty(),
|
||||
"optimisation-only allowlist names not in INTERCEPTS: {stale_allow:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the bijection pin**
|
||||
|
||||
Run: `cargo test -p ailang-codegen --lib intercepts::tests::intercepts_bijection_with_intrinsic_markers`
|
||||
Expected: PASS. After Task 1, the 13 prelude markers + `answer` (14
|
||||
intrinsic-backed) cover the 14 non-allowlisted INTERCEPTS entries, and
|
||||
the 5 `*__Int` are allowlisted. If (B) reports an orphan entry, a
|
||||
prelude site was missed in Task 1; if (A) reports an orphan marker, a
|
||||
marker has no registered intercept (a real bug).
|
||||
|
||||
- [ ] **Step 3: Confirm the old pin name is gone / no dangling reference**
|
||||
|
||||
Run: `grep -rn "registry_contains_all_legacy_arms" crates/`
|
||||
Expected: no matches (the test was replaced). If any doc comment or
|
||||
other test referenced it by name, update that reference.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Dead-path confirmation + stale-comment cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/ailang-codegen/src/lib.rs:1310-1319`
|
||||
- Modify (conditional): `design/contracts/0007-honesty-rule.md`
|
||||
|
||||
- [ ] **Step 1: Confirm no codegen path lowers an intercepted body**
|
||||
|
||||
Read `crates/ailang-codegen/src/lib.rs:1310-1340`. Confirm the shape:
|
||||
`try_emit_primitive_instance_body(&f.name, ...)` is called first
|
||||
(name-based intercept), and `lower_term(&f.body)` is only reached under
|
||||
`if !body_was_intercepted`. Since every migrated def's body is now
|
||||
`Term::Intrinsic` AND its name is in INTERCEPTS, the intercept always
|
||||
fires first and `lower_term` is never reached for it. No code change is
|
||||
required for the path itself — this is the confirmation the spec
|
||||
(§ Architecture point 8) calls for.
|
||||
|
||||
- [ ] **Step 2: Delete the now-stale dummy-body comment**
|
||||
|
||||
At `lib.rs:1310-1319`, the comment block describes skipping "the normal
|
||||
body-lowering block below" for intercepted bodies and references the
|
||||
historical dummy. Update it to state the current reality: an intrinsic
|
||||
body is intercepted by name; a `Term::Intrinsic` reaching `lower_term`
|
||||
is the internal-error guard (added in .1), not a body to lower. Remove
|
||||
any sentence implying a live dummy body is lowered-then-discarded.
|
||||
|
||||
- [ ] **Step 3: Check whether the honesty-rule contract names the dummies**
|
||||
|
||||
Run: `grep -n "dummy\|placeholder\|body false\|intrinsic" design/contracts/0007-honesty-rule.md`
|
||||
Expected: determine whether 0007 references the prelude dummy bodies
|
||||
specifically. If it does NOT (recon's finding — it is a general
|
||||
present-tense honesty rule), make NO edit and note in the iter that
|
||||
0007 needed none. If it DOES name them, update that sentence to reflect
|
||||
that the infraction is closed (the bodies are now `(intrinsic)`,
|
||||
honestly compiler-supplied). Do not invent an edit where none is needed.
|
||||
|
||||
- [ ] **Step 4: Build clean**
|
||||
|
||||
Run: `cargo build --workspace`
|
||||
Expected: compiles, 0 errors (Step 2 is a comment edit; no behavioural change).
|
||||
|
||||
---
|
||||
|
||||
## Final gate
|
||||
|
||||
- [ ] **Step F1: Full workspace test**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: all green. Baseline 669 (post-.1). This iteration replaces one
|
||||
test (registry_contains_all_legacy_arms → intercepts_bijection_with_intrinsic_markers,
|
||||
net 0) and rebaselines hashes (no count change). Expected ≈669 passing,
|
||||
0 failed. The behaviour-preservation ratifiers (Task 1 Step 5) and both
|
||||
hash pins (Task 2) are green.
|
||||
|
||||
- [ ] **Step F2: Regression scripts**
|
||||
|
||||
Run: `bench/check.py && bench/compile_check.py`
|
||||
Expected: no regression versus baseline — the migration is
|
||||
behaviour-preserving (identical emitted IR).
|
||||
Reference in New Issue
Block a user