spec: raw-buf — 3-iter base-extension milestone (refs #7)

First new milestone after kernel-extension-mechanics close.
RawBuf is the canonical kernel-tier *base* extension: mutable,
indexed, bounded-size flat buffer of primitive elements
({Int, Float, Bool}, restricted via prep.3's param-in). It
unblocks two downstream needs already in the backlog:

- series milestone #8 — library-tier ring buffer wrapping RawBuf.
- Embedding-ABI batch-FFI (subsumes closed #2) — the M5
  friction-harvest measured per-tick FFI at ~206 ns/tick on
  real EURUSD volume; RawBuf is the contiguous-slice primitive
  that amortises that per-tick cost.

The milestone also fulfils the whitepaper § "Plugin contract"
commitment: the migration of the hardcoded
try_emit_primitive_instance_body into a registry is triggered
by the first real base extension shipping.

Decomposition (R — registry-first refactor, 3 iters):

  raw-buf.1 — Intercept registry refactor. Lift the existing
  hard-coded match (eq__Str, compare__Int|Bool|Str, float_*) into
  a registry table. Zero behavioural change; ratified by the
  existing E2E suite (eq_primitives_smoke / compare_primitives_smoke
  in e2e.rs, float_compare_smoke, eq_ord_polymorphic). Pure
  refactor — the cleanest possible bisection target.

  raw-buf.2 — RawBuf kernel-tier manifest + checker side. Rename
  crates/ailang-kernel-stub/ → crates/ailang-kernel/ and reshape
  as a family-crate (src/{kernel_stub,raw_buf}/{mod.rs,source.ail});
  public surface preserved via re-exports. Consumer code with
  (con RawBuf (con Int)) passes ail check; ail build fails with
  intercept-not-registered — that diagnostic IS the ratification.

  raw-buf.3 — RawBuf codegen intercepts + Term::New desugar +
  stub retirement. Register 12 element-type-specialised entries
  (4 ops × {Int,Float,Bool}); lower via @ailang_rc_alloc +
  getelementptr + load/store. Desugar (new T args) →
  (app T.new args) so Term::New is eliminated before codegen
  (completes the prep.2 deferral). Retire the kernel_stub
  submodule + the round-trip test at design_schema_drift.rs:743;
  ailang-kernel crate stays as the family-crate for future
  series/matrix/… modules.

Ordering rationale: raw-buf.1 ships zero behavioural change so
its failure mode is "existing tests break" — cleanest bisection.
raw-buf.2 ships the checker-visible surface on an unchanged
codegen substrate, so its failure mode is checker-isolated.
raw-buf.3 lands codegen on a registry that has already absorbed
every legacy intercept, so the RawBuf entries do not co-mingle
with a registry move.

Kernel-tier crate organisation: rejected pro-modul-crate
(crates/ailang-series/, crates/ailang-matrix/, ...) for sublinear
scaling and onboarding locality; rejected sub-Cargo-crates under
crates/ailang-kernel/ (C1) for Cargo-boilerplate overhead with no
real consumer benefit at AILang's scale. C2 (one family-crate,
sub-folders per module, lib.rs re-exports) keeps Cargo dep
surface flat (ailang-surface needs one dep, not N) and stub
retirement is a submodule delete + re-export drop.

Out of scope: bounds checks (caller checks via RawBuf.size per
whitepaper — UB-on-overflow is the contract), RawBuf.fill /
.copy / .iter (deferred until series or Embedding-ABI concretely
asks), record element types (SoA — Forward Axis).

Grounding-check PASS on 10 load-bearing assumptions about
current code state (intercept dispatch site, existing E2E
ratifiers, ailang-kernel-stub crate layout, parse_kernel_stub
location).

Brainstorm → planner handoff: first iteration scope is raw-buf.1
(§ Architecture point 1, § Components row 1).
This commit is contained in:
2026-05-29 10:44:28 +02:00
parent a163c8c34a
commit 4ad003d21f
+453
View File
@@ -0,0 +1,453 @@
# raw-buf — Design Spec
**Date:** 2026-05-29
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Ship `RawBuf a` — the first kernel-tier *base* extension — as a
mutable, indexed, bounded-size flat buffer of primitive elements
(`{Int, Float, Bool}`). RawBuf is the storage primitive that
unblocks two downstream needs both already captured in the
backlog:
- **Series (milestone #8)** — a bounded ring buffer with
financial-style indexing, implemented as a *library extension*
(pure AILang) wrapping `RawBuf a` plus bookkeeping fields.
- **Embedding-ABI batch FFI (subsumes closed #2)** — the M5
friction-harvest measured per-tick FFI at ~206 ns/tick on real
EURUSD volume (~658 ms / 3.19M ticks). A contiguous primitive
slice amortises that per-tick cost via a batch crossing.
RawBuf is that primitive.
The milestone also fulfils the architectural commitment from the
kernel-extensions whitepaper § "Plugin contract":
> *The migration of existing hardcoded primitive-instance
> intercepts (`try_emit_primitive_instance_body`) into the plugin
> registry is triggered by the first real base extension shipping
> (RawBuf). One registry mechanism for all intercepts.*
This makes raw-buf both a feature ship (a new type LLM authors
can reach for) and a foundation move (the *one* plumbing path
for all current and future codegen intercepts).
## Architecture
Three layered moves, one per iteration:
1. **Intercept registry** (raw-buf.1) — Lift the existing
hard-coded `try_emit_primitive_instance_body` match
(`eq__Str`, `compare__Int|Bool|Str`, plus the `float_*`
family that lowers to `fcmp`) into a registry table
`&[Intercept { name, sig, emit }]`. The single existing
dispatch site in `lower_fn_body` (the `emit_fn` caller
that today reaches `try_emit_primitive_instance_body`)
becomes a table lookup. Zero behavioural change; every
existing test stays green. This iter is a pure refactor
— its ratification is the workspace test suite as-is.
2. **RawBuf kernel-tier manifest + checker side** (raw-buf.2) —
Rename `crates/ailang-kernel-stub/` to `crates/ailang-kernel/`
and re-shape it as a family-crate: one Cargo crate hosting
*all* kernel-tier modules as sibling Rust submodules under
`src/`, one submodule per `.ail` source. The renamed crate
carries `src/kernel_stub/{mod.rs, source.ail}` (the existing
stub, unchanged) and gains `src/raw_buf/{mod.rs, source.ail}`
(the new RawBuf module). `src/lib.rs` re-exports each
submodule's `SOURCE` const under a stable name
(`pub use kernel_stub::SOURCE as STUB_AIL;`,
`pub use raw_buf::SOURCE as RAW_BUF_AIL;`), preserving the
public-symbol surface every consumer already binds to. The
parse hops stay in `ailang-surface` (`parse_kernel_stub`
joined by `parse_raw_buf`); the acyclic dependency chain
`ailang-surface → ailang-kernel → ailang-core` is preserved.
The raw_buf module declares:
- `(data RawBuf (vars a) (ctor B (storage-tag)) (param-in
(a Int Float Bool)))` — opaque single-ctor TypeDef with
element-type restriction.
- `(fn new ...)`, `(fn get ...)`, `(fn set ...)`,
`(fn size ...)` with type signatures from the whitepaper
and placeholder bodies (same convention as the existing
`eq__Int` / `compare__Int` intercept stubs in
`examples/prelude.ail`).
Checker accepts a consumer-fixture `(con RawBuf (con Int))`
in a type slot; `ail check` passes. `ail build` fails
precisely with `intercept-not-registered: new__RawBuf__Int`
(or the existing Term::New-deferral diagnostic), because the
registry has no entry yet — that diagnostic IS the
ratification.
3. **RawBuf codegen intercepts + Term::New desugar + stub
retirement** (raw-buf.3) — Register the 12 element-type-
specialised entries (`new__RawBuf__Int|Float|Bool`,
`RawBuf_get__Int|Float|Bool`, `RawBuf_set__Int|Float|Bool`,
`RawBuf_size__Int|Float|Bool`) into the registry. Lower
each via `@ailang_rc_alloc` + getelementptr + load/store —
no new C code. Add a desugar pass `(new T args)` →
`(app T.new args)` so Term::New is eliminated before
codegen — Term::New becomes purely a checker-side construct
(the desugar is the codegen completion the prep.2 deferral
pointed at). Worked consumer program runs end-to-end. Retire
the `kernel_stub` submodule from `crates/ailang-kernel/`
since RawBuf is now the second-and-real kernel-tier
consumer that CLAUDE.md names as the stub's retirement
trigger: delete `src/kernel_stub/` and its `lib.rs`
re-export, drop `parse_kernel_stub` and its surface
injection in `ailang-surface`, and update INDEX +
whitepaper to reflect.
The ordering is not arbitrary: raw-buf.1 is the only iter with
zero behavioural change, so its failure mode is "existing tests
break" — the cleanest possible bisection target. raw-buf.2 ships
the checker-visible surface on an unchanged codegen substrate,
so its failure mode is checker-isolated. raw-buf.3 lands codegen
on a registry that has already absorbed every legacy intercept,
so the RawBuf entries do not co-mingle with a registry move.
## Concrete code shapes
### Primary — the user-facing program raw-buf delivers
```
(module raw_buf_demo
(import raw_buf)
(fn main
(type (fn-type (params) (ret (con Int))))
(params)
(body
(let buf (new RawBuf (con Int) 3)
(let buf (app RawBuf.set buf 0 10)
(let buf (app RawBuf.set buf 1 20)
(let buf (app RawBuf.set buf 2 30)
(app + (app RawBuf.get buf 0)
(app + (app RawBuf.get buf 1)
(app RawBuf.get buf 2))))))))))
; ail check && ail build && ail run → prints 60
```
This is the iter raw-buf.3 acceptance fixture (and the
feature-acceptance criterion's empirical evidence — see § Feature
acceptance below). It is exactly the program an LLM author
naturally writes to express "I need a small mutable indexed
buffer to score N values and read them back."
### Primary — the param-in reject fixture
```
(module raw_buf_reject_string
(import raw_buf)
(fn main
(type (fn-type (params) (ret (con Unit))))
(params)
(body (do io/print_str "unreached"))
(signature-hole (new RawBuf (con Str) 3)))) ; rejected at check
; ail check → param-not-in-restricted-set: type Str not in {Int, Float, Bool}
; at TypeDef raw_buf.RawBuf, var a
```
This must fail at the checker, with the diagnostic produced by
the existing `ParamNotInRestrictedSet` arm shipped in
prep.3 (per fieldtest F8). It is the must-fail evidence that the
`param-in` restriction is alive on a real-payload extension, not
just on the prep.3 stub.
### Primary — the raw-buf kernel-tier module
```
(module raw_buf
(kernel)
(data RawBuf (vars a)
(ctor B (storage-tag))
(param-in (a Int Float Bool)))
(fn new
(doc "Allocate uninitialised RawBuf of capacity n. Codegen
intercept new__RawBuf__<T> emits @ailang_rc_alloc + the
size header. Element bytes are uninitialised — caller
must (set …) before (get …).")
(type (fn-type (params (con Int)) (ret (own (RawBuf a)))))
(params n)
(body (term-ctor RawBuf B n))) ; placeholder
(fn get
(doc "Indexed read. UB if i >= (size b); caller checks
bounds. Codegen intercept RawBuf_get__<T> emits
getelementptr + load.")
(type (fn-type
(params (borrow (RawBuf a)) (con Int))
(ret (con a))))
(params b i)
(body (term-ctor RawBuf B 0))) ; placeholder
(fn set
(doc "Indexed write. Linear: own in, own out. Under
uniqueness, in-place; under shared, copy-on-write
(Issue #22 territory). Codegen intercept
RawBuf_set__<T> emits getelementptr + store.")
(type (fn-type
(params (own (RawBuf a)) (con Int) (con a))
(ret (own (RawBuf a)))))
(params b i v)
(body b)) ; placeholder
(fn size
(doc "Element count. Codegen intercept RawBuf_size__<T>
emits a single i64 load from the slab header.")
(type (fn-type (params (borrow (RawBuf a))) (ret (con Int))))
(params b)
(body 0))) ; placeholder
```
The placeholder bodies are the existing convention from
`examples/prelude.ail` (e.g. `eq__Int` ships with `(body x)`
that the round-trip preserves but codegen replaces). They exist
to satisfy the round-trip invariant; codegen never executes them.
### Secondary — registry shape (implementation only)
```rust
// crates/ailang-codegen/src/intercepts.rs (new file)
pub struct IntrinsicSig {
pub params: Vec<String>, // LLVM types as text, e.g. "i64", "ptr"
pub ret: String,
}
pub struct Intercept {
pub name: &'static str,
pub sig_check: fn(params: &[String], ret: &str) -> Result<()>,
pub emit: fn(cg: &mut Lowering) -> Result<()>,
}
pub fn lookup(name: &str) -> Option<&'static Intercept> { ... }
// Wired into crates/ailang-codegen/src/lib.rs:1321 (the existing
// try_emit dispatch site) — that match becomes one lookup call.
```
### Secondary — slab layout
RawBuf payload (post RC header) is:
```
[ size : i64 ][ elem_0 ][ elem_1 ] ... [ elem_{size-1} ]
^ ^ ^
offset 0 offset 8 offset 8 + size*sizeof(T)
```
Element widths: `Int = 8`, `Float = 8`, `Bool = 1`. Alignment to
8 bytes for header; element payload directly follows. `@ailang_rc_alloc`
is called with `8 + size * sizeof(T)` and gets back a `ptr` that
the codegen treats as the slab base. The size header is *self-
describing* so drop and `RawBuf.size` need no side-table.
Drop for RawBuf with primitive elements is trivial: a single
`@ailang_rc_release` on the slab pointer. No element-walking
because element types are primitives (no recursive drops). The
existing per-TypeDef drop generation gets a "primitive-element-
RawBuf" arm that emits the trivial drop, dispatched via the same
registry.
## Components
| Component | Iter | Files |
|-----------|------|-------|
| Intercept registry | raw-buf.1 | new file `crates/ailang-codegen/src/intercepts.rs`; edits to `crates/ailang-codegen/src/lib.rs` at the single dispatch site that today calls `try_emit_primitive_instance_body`, and removal of each absorbed match arm in that fn |
| Kernel family-crate (rename + reshape) + RawBuf submodule | raw-buf.2 | rename `crates/ailang-kernel-stub/` → `crates/ailang-kernel/`; restructure as `src/lib.rs` + `src/kernel_stub/{mod.rs, source.ail}` (existing stub re-housed) + `src/raw_buf/{mod.rs, source.ail}` (new); update `Cargo.toml` package name `ailang-kernel-stub` → `ailang-kernel`; update workspace member entry + every dependent crate's `Cargo.toml` (`ailang-surface` is the only consumer today); add `parse_raw_buf` in `crates/ailang-surface/` next to existing `parse_kernel_stub`; extend the workspace injection in `ailang-surface` to inject both modules |
| RawBuf intercept entries | raw-buf.3 | additions to `crates/ailang-codegen/src/intercepts.rs` (12 entries); desugar pass `Term::New` → `(app T.new args)` in `crates/ailang-codegen/src/synth.rs` or `crates/ailang-check/src/desugar.rs` |
| Stub retirement (submodule level) | raw-buf.3 (close) | delete `crates/ailang-kernel/src/kernel_stub/` + its re-export in `src/lib.rs`; drop `parse_kernel_stub` and its surface injection in `ailang-surface` (defn at `crates/ailang-surface/src/loader.rs:54`, re-export at `crates/ailang-surface/src/lib.rs:39`); delete the `kernel_stub_module_round_trips` test at `crates/ailang-core/tests/design_schema_drift.rs:743` (its `parse_kernel_stub` callee is gone — the analogous `raw_buf_module_round_trips` test added in raw-buf.2 carries the round-trip invariant forward); update `crates/ailang-core/tests/workspace_pin.rs` (was bumped to 4 in iter-tidy `a844de3`; raw_buf joins as the stub leaves so the count stays at 4 — see Data flow); update `design/INDEX.md` § Models row + `design/models/0007-kernel-extensions.md` § "ratifying fixture" sentence. The `ailang-kernel` crate itself stays — it is the family-crate that future kernel-tier modules (series, matrix, …) will plug into. |
## Data flow
**Build-time** (raw-buf.2 onwards):
1. CLI loads workspace → `ailang-surface` injects `raw_buf` as
a kernel-tier module (`kernel: true`) just like `prelude`
today.
2. `check` walks consumer code; `(con RawBuf (con Int))`
resolves to `raw_buf.RawBuf` via the kernel-tier auto-import
path that prep.3 shipped.
3. `param-in` restriction on RawBuf's `a` parameter rejects
non-{Int,Float,Bool} instantiations at the consumer
typecheck site, using the existing
`ParamNotInRestrictedSet` diagnostic from prep.3.
**Codegen-time** (raw-buf.3 onwards):
1. Desugar pass replaces every `Term::New {type_name, args}`
with `Term::App { callee: <type_name>.new, args }` — same
form that already lowers cleanly today.
2. For each fn in the workspace, codegen reaches the dispatch
site at `lib.rs:1321`. If the fn's mono name has a registry
entry, the entry's `emit` runs; the `.ail` placeholder body
is discarded. Otherwise, the normal AILang lowering runs.
3. For RawBuf consumers, `new__RawBuf__Int` emits the alloc
call; `RawBuf_set__Int` emits a `store i64`;
`RawBuf_get__Int` emits a `load i64`; `RawBuf_size__Int`
emits a single header load.
**Workspace-module-count interaction** (raw-buf.3 close):
The current count of 4 (`prelude` + `kernel_stub` + two
test-fixture modules per the iter-tidy commit). When
`kernel_stub` retires and `raw_buf` joins, the count stays 4.
If `raw_buf` lands *before* the stub retires, the count is 5
mid-iter — caught by the existing `workspace_pin` test, which
the iter explicitly re-baselines.
## Error handling
Two diagnostics; both already shipped, no new arms needed in
this milestone:
- **`param-not-in-restricted-set`** (prep.3, fieldtest F8).
Fires when a consumer writes `(con RawBuf (con Str))` or
any non-{Int,Float,Bool} element. Names the type, the
variable, the TypeDef, and the restricted set.
- **`new-type-not-constructible`** (prep.3, fieldtest F5).
Fires when a consumer writes `(new SomeType …)` and
`SomeType` has no `(fn new …)` in its home module. Since
raw-buf ships `(fn new …)` in the kernel-tier `raw_buf`
module, the consumer program above passes — but if a
future LLM author writes `(new Counter 0)` against a
type that lacks `new`, this diagnostic still fires
precisely.
Bounds checks are *not* in scope. Per whitepaper § "RawBuf —
the base extension": `RawBuf.get / .set` are UB if `i >=
size(b)`; caller checks. Bounds-check insertion is a future
axis tracked separately (no backlog item yet — the discipline
is "caller checks via `RawBuf.size`").
A future `intercept-not-registered` diagnostic surfaces at
raw-buf.2 — the iter raw-buf.2 acceptance is precisely that
this fires (consumer code checks clean but does not build),
demonstrating the manifest/codegen separation works. raw-buf.3
makes it stop firing for the 12 RawBuf entries.
## Testing strategy
Each iter ratified independently:
**raw-buf.1 (registry refactor):**
- All ~668 existing workspace tests pass unchanged. The
refactor is *primarily* ratified by zero behavioural
change in the existing suite — the legacy intercepts
(`eq__Str`, `compare__Int|Bool|Str`, the `float_*` family)
are exercised end-to-end by the following build-and-run
E2E tests:
- `eq_primitives_smoke_compiles_and_runs` and
`compare_primitives_smoke_prints_1_2_3_thrice` in
`crates/ail/tests/e2e.rs` — pin the `eq__Str` and
`compare__Int|Bool|Str` arms (Str path included).
- `float_compare_smoke_prints_true_true_false` in
`crates/ail/tests/float_compare_smoke_e2e.rs` — pins
the `float_*` family via `float_eq` + `float_lt`.
- `eq_ord_polymorphic_runs_end_to_end` in
`crates/ail/tests/eq_ord_e2e.rs` — secondary Str-path
ratifier via polymorphic Eq/Ord dispatch.
If the registry rewrite breaks any intercept, those tests
catch it.
- One new unit test in the new `intercepts` module:
`registry_contains_all_legacy_arms` enumerates the names
that were in the legacy match (`eq__Str`, `compare__Int`,
`compare__Bool`, `compare__Str`, and each `float_*` name)
and asserts each resolves to a registered entry. This pins
the migration as complete (a half-finished migration where
the old match still has an arm becomes a hard test fail).
**raw-buf.2 (manifest + checker):**
- New E2E test `crates/ail/tests/raw_buf_check_e2e.rs`:
consumer fixture with `(con RawBuf (con Int))` in a type
signature, `ail check` exits 0.
- New E2E test `crates/ail/tests/raw_buf_param_in_reject_e2e.rs`:
consumer fixture with `(con RawBuf (con Str))`, `ail check`
exits non-zero with `param-not-in-restricted-set` in stderr.
- New E2E test `crates/ail/tests/raw_buf_build_intercept_missing_e2e.rs`:
consumer fixture with `(new RawBuf (con Int) 3)`, `ail
build` exits non-zero with `intercept-not-registered` (or
the Term::New deferral message) — pins the manifest/codegen
separation. (This test gets deleted or inverted to PASS in
raw-buf.3.)
- New round-trip test for the embedded `RAW_BUF_AIL` —
parse → print round-trips byte-isomorphically (mirrors
`kernel_stub_module_round_trips` in
`crates/ailang-core/tests/design_schema_drift.rs`).
- Workspace module-count test bumped: 4 → 5 (mid-iter state
before stub retires).
**raw-buf.3 (codegen + stub retirement):**
- The intercept-missing E2E test from raw-buf.2 inverted:
same consumer fixture, `ail build` now exits 0 and the
produced binary, when run, prints `60`.
- New E2E `crates/ail/tests/raw_buf_float_e2e.rs`: Float
variant exercise.
- New E2E `crates/ail/tests/raw_buf_bool_e2e.rs`: Bool
variant exercise (catches the i1/i8 packing question
if any).
- Drop ratification: a consumer that allocates and lets
the RawBuf go out of scope without a leak. Re-uses the
existing leak-check harness; no new infra.
- Stub-retirement ratification: deleted-submodule diff hits
`crates/ailang-kernel/src/kernel_stub/` (folder removal) +
`crates/ailang-kernel/src/lib.rs` (re-export drop) +
`crates/ailang-surface/` (parse_kernel_stub + injection
drop) + `crates/ailang-core/tests/workspace_pin.rs` +
`design/INDEX.md` + `design/models/0007-kernel-extensions.md`.
Test `workspace_pin` re-baselines to 4 (raw_buf in,
kernel_stub out — net unchanged). The `ailang-kernel` crate
itself stays as the family-crate home for future kernel-tier
modules (series, matrix, …).
## Acceptance criteria
The milestone ships when:
1. The worked consumer program in § Concrete code shapes
end-to-end: `ail check && ail build && ail run` prints
`60`.
2. The param-in reject fixture exits at `ail check` with the
prep.3 diagnostic.
3. Every existing primitive-instance intercept (`eq__Str`,
`compare__Int|Bool|Str`, the `float_*` family) is dispatched
via the registry — *no remaining match-arm* in
`try_emit_primitive_instance_body` outside the registry path.
4. `ailang-kernel-stub` is retired from the workspace; `raw_buf`
takes its slot as the canonical kernel-tier base-extension
anchor.
5. Workspace test suite green, plus the new tests enumerated
in § Testing strategy.
## Feature-acceptance argument
Applied per `design/contracts/0004-feature-acceptance.md`:
1. **LLM author reaches for it naturally.** RawBuf is the
only path to mutable indexed storage in AILang — the
language has no other "I want to fill N slots and read
them back" primitive. The worked consumer program above is
what an LLM author writes when expressing that need;
there is no alternative shape to reach for.
2. **Measurable improvement.** Embedding-ABI M5 measured
per-tick FFI at ~206 ns/tick (~658 ms / 3.19M EURUSD ticks).
RawBuf as the batch-FFI primitive amortises that per-tick
cost across one crossing. The series milestone (#8) is also
blocked on RawBuf; shipping raw-buf removes that block.
3. **No reintroduced bug class.** Mutation discipline stays
mode-tracked (whitepaper § "RawBuf — the base extension"):
`RawBuf.set` is `own → own`, not effect-tracked. No
`RawBuf` effect declared. Uniqueness inference rewrites it
in-place; shared ownership falls back to copy-on-write
(Issue #22). The pure-core invariant — code without a
declared effect is pure — survives intact. The local-
reasoning invariant survives too: a fn that takes
`borrow (RawBuf Int)` and returns `Int` *is* pure from the
caller's view, because the buffer is not mutated.
The registry-migration aspect of the milestone (raw-buf.1)
removes redundancy structurally: the growth point of
"every new primitive intercept needs another match arm" is
collapsed into a single dispatch table. That is criterion-2
evidence for the migration as a piece.