tidy: 18g sub-arc — DESIGN ratification, latency bench, negative coverage

Resolves the architect's drift report on the 18g sub-arc:

- DESIGN.md: ratify mode-metadata's codegen role. param_modes /
  ret_mode were already on Type::Fn since 18a; with 18d.4 / 18g
  they became load-bearing for drop-emission decisions in
  codegen. The new 'Mode metadata is load-bearing for codegen'
  subsection records the four seams (Iter A + Iter B + 18g.1 +
  18g.2) and names the let-alias-of-borrow carve-out the gates
  do not propagate through.

- bench/run.sh: post-throughput, invoke bench/latency_harness.py
  on the three canonical arms (Implicit @ gc, explicit @ rc,
  Implicit @ rc control). The Boehm-retirement bench numbers
  are now reproducible by anyone running the harness, not just
  by hand on a specific host.

- Negative coverage: examples/rc_let_implicit_returning_app
  + alloc_rc_let_binder_for_implicit_returning_app_does_not_drop
  pin the asymmetry to the (own)-ret-mode test (live=0 there,
  live=1 here). The Borrow-ret-mode case is covered by the
  language design itself — typechecker rejects 'borrow-
  passthrough' shapes with consume-while-borrowed.

JOURNAL entry records four items as deferred known debt:
emit_inlined_partial_drop dynamic-tag fallback, carve-out
diagnostics surface, bench-number stat-of-N, and the
cross-family ordering observation about CLAUDE.md's tidy-iter
rule.

The 18-arc is formally closed with this tidy. Next iter is
the orchestrator's Boehm-retirement decision (Path A vs Path B
from JOURNAL 2026-05-08 18f entry, joined by the post-18g.2
re-bench numbers).
This commit is contained in:
2026-05-08 14:37:36 +02:00
parent 97e793dd62
commit 3113258680
6 changed files with 345 additions and 0 deletions
+72
View File
@@ -1038,6 +1038,78 @@ Codegen for `Term::Ctor` / `Term::Lam` env / closure pair under
inference is wired up. Until then, `--alloc=rc` deliberately
leaks like the pre-Boehm era; this is purely about plumbing.
### Mode metadata is load-bearing for codegen (Iter 18d18g)
`param_modes` and `ret_mode` on `Type::Fn` are not merely
typechecker metadata — codegen consults both to decide where to
emit drop calls. The 18d.4 / 18g shipping work moved them from
"annotation that the typechecker enforces" to "annotation that
codegen reads to keep RC correct". Recorded here so the schema
metadata's role is explicit:
**`param_modes` — drop-emission gates.**
- **Iter B: Own-param dec at fn return.** When a fn body
fall-throughs to a `ret` (no tail-call), every parameter with
`param_modes[i] == Own` is dec'd before the `ret` iff its
uniqueness `consume_count == 0` and the ret value is not the
param itself. `Borrow` and `Implicit` parameters are skipped:
`Borrow` retains the caller's ownership by contract;
`Implicit` carries no static caller-handed-off-ownership
signal (it's the back-compat lane).
- **Iter A: arm-close pattern-binder dec.** When a match-arm's
body terminates without a tail-call, every ptr-typed
pattern-bound binder pushed by the arm is dec'd at arm close
iff its `consume_count == 0` and it is not the arm's tail
value, **gated on the scrutinee's static ownership**. If the
scrutinee is a fn-param, only `Own`-mode scrutinees enable
the dec — `Borrow` and `Implicit` scrutinees would let the
arm dec memory the caller still references.
- **Iter 18g.1: pre-tail-call shallow-dec.** When a match-arm's
body IS a tail call, both Iter A and Iter B are skipped (the
block is terminated). A separate seam in `lower_match` emits
a shallow `ailang_rc_dec` on the scrutinee outer cell BEFORE
the tail call, gated identically on the scrutinee mode plus
the requirement that every ptr-typed slot in the active
ctor's pattern is in `moved_slots[scrutinee]`.
**`ret_mode` — let-binder trackability.**
- **Iter 18g.2: `Term::App` drop at let-scope close.** A
let-binder whose value is `Term::App { callee, .. }` is
trackable for scope-close drop iff the callee's
`ret_mode == Own`. The signal is the callee's static
contract that ownership of the freshly heap-allocated cell
flows to the caller. `Borrow`-returning calls remain
non-trackable (the callee retains ownership; the caller
holds a view, not an own ref). `Implicit`-returning calls
remain non-trackable (back-compat lane).
The drop fn's symbol resolution for an Own-returning App:
synthesise the call's return type, resolve `Type::Con { name }`
to `drop_<owner>_<T>` (with cross-module qualification through
the import map). Falls back to shallow `ailang_rc_dec` for
returns that are not `Type::Con` (e.g. unresolved type vars on
a polymorphic call's pre-monomorphisation site; the
monomorphised copies resolve to concrete drop fns).
**What this widening does NOT do.**
- Does not change the canonical hash. `param_modes` /
`ret_mode` were already hash-load-bearing as of Iter 18a; the
18d18g iters add codegen consumers, not new schema fields.
- Does not introduce a new `Type` variant. Mode metadata stays
flat on `Type::Fn` (see "Schema additions" above on why).
- Does not cover let-aliases of borrowed values. A let-binder
whose value is `Term::Var` referencing a `Borrow`-mode
param is not yet propagated through; the param-mode gates
treat such a binder as "owned" (its `current_param_modes`
lookup misses, default = owned). This is a known carve-out
shared by Iter A and 18g.1; closing it is a propagation pass
through let-bindings that has not shipped yet.
### Migration plan
1. **Iter 18a:** `(borrow T)` / `(own T)` annotations as a
+135
View File
@@ -8105,3 +8105,138 @@ remaining open items are:
After the orchestrator's Boehm-retirement decision lands,
the tidy-iter is the right next step.
## 2026-05-08 — 18g sub-arc tidy-iter
`ailang-architect` ran a drift review of the 18g sub-arc
(commits `e8c6e99` through `97e793d`) and reported seven
items, prioritised. Resolved:
### Ratified into DESIGN.md
**Item 1: `param_modes` / `ret_mode` codegen role.** DESIGN.md
§ "Codegen contract" gained a "Mode metadata is load-bearing
for codegen (Iter 18d18g)" subsection that lays out the four
seams that consume mode metadata: Iter B (Own-param dec at fn
return), Iter A (arm-close pattern-binder dec gated on
scrutinee mode), Iter 18g.1 (pre-tail-call shallow-dec), and
Iter 18g.2 (let-binder trackability for Own-returning App).
Also names the let-alias-of-borrow carve-out the gates do not
yet propagate through.
The schema is unchanged (mode metadata was already on
`Type::Fn` since 18a); what's new is that codegen's drop-
emission behaviour now depends on those fields. Recording the
dependency in DESIGN.md was overdue — this entry closes that
gap.
### Wired into the harness
**Item 2: `bench/latency_harness.py` not in `bench/run.sh`.**
`run.sh` now invokes the latency harness on the three
canonical arms (Implicit @ gc Boehm-fair, explicit @ rc
RC-fair, Implicit @ rc control) after the throughput table.
Each invocation prints the median / p99 / p99.9 / max block
the harness already produces. The Boehm-retirement bench
numbers are now reproducible by anyone running `bench/run.sh`,
not just by hand on a specific host.
The harness output is intentionally not folded into a single
table by the script — the per-arm block carries its own noise-
floor and read-coalescing context that the orchestrator should
see verbatim when capturing into a JOURNAL entry.
### Negative coverage test added
**Item 3 (partial): negative-side test for `Implicit`-ret-mode
App.** New fixture
`examples/rc_let_implicit_returning_app.ailx` and test
`alloc_rc_let_binder_for_implicit_returning_app_does_not_drop`.
The fixture is a let-binder whose value is a default-mode
(`Implicit` ret) App; the test asserts the binary exits
cleanly with `live = 1` (the cell leaks but does not crash).
This pins the asymmetry to the (own)-ret-mode test
(`live = 0`) and would fail loudly if a future iter
mistakenly widened `is_rc_heap_allocated` to all App shapes.
The `Borrow`-ret-mode case was attempted but the typechecker
correctly rejects the only minimal repro shape (a
"borrow-passthrough" returning a borrowed view of an arg)
with `consume-while-borrowed` — meaning the language already
forbids the shape that would have been the negative test. The
gate is therefore covered by language-design constraint
rather than by a regression test, and a comment in the
fixture documents that path.
### Carry-over (deferred, recorded as known debt)
**Item 4: `emit_inlined_partial_drop` shallow-dec fallback
silent-leak path.** The fallback fires when an App-bound let-
binder accumulates `moved_slots` (the body pattern-matches
the binder). No fixture currently exercises that shape; if
one lands without surfacing the leak, the carve-out's debt
will compound silently. Queued as: a tag-conditional
partial-drop runtime helper that takes the dynamic ctor tag
as input and dispatches accordingly. Same family as 18d.4's
match-arm dynamic-tag carve-out; both close together.
**Item 5: carve-outs accumulating without diagnostic
surface.** Three live carve-outs as of 18g.2 — let-aliases of
borrowed scrutinees (18d.4 fix), dynamic-tag pattern-binder
partial-drop (18d.4), dynamic-tag App-binder partial-drop
(18g.2). All three silently leak rather than diagnose. The
typechecker's `consume-while-borrowed` rule prevents the
worst class of these (e.g. the borrow-passthrough fixture
above), but the codegen-time carve-outs are not surfaced to
the user. Closing this requires a structured diagnostic
emitted from codegen when a carve-out path fires — feasible
within the existing `suggested_rewrites` framework. Queued
for the carve-out unification iter.
**Item 6: bench numbers vs methodology.** The 18g.2 latency
table was a single-host hand-run on AMD 5900X; the harness
captures one run, not a stat-of-N. The qualitative claim
("Boehm has STW pauses, RC doesn't; RC is RSS-lower than
Boehm on this fixture") is robust at a 23× signal margin and
not at risk from run-to-run variance. The quantitative
numbers are not regression-locked. To make them so, the next
re-bench should record N runs and median + variance per cell;
the harness can be extended to do that without re-shaping the
output. Recorded as known limitation rather than fixed in
this tidy because the orchestrator's Boehm-retirement
decision (still open) does not require sub-percent precision
to resolve.
### CLAUDE.md tidy-iter ordering
**Item 7: Boehm retirement decision is staged ahead of the
tidy-iter in the 18g.2 entry.** CLAUDE.md "Tidy-iter at
family boundaries" requires the next iter after a family
closes to BE the tidy-iter, with explicit deferral
documented. The 18g.2 closing did not name the deferral
explicitly — implicit by the retirement-decision framing.
This tidy-iter (the entry you are reading) is in fact what
follows the 18g family, in order; the retirement decision
remains queued for the orchestrator's call. The ordering is
preserved in retrospect by this entry; future families
should not stage cross-family decisions before the tidy.
### What this tidy ships
- `docs/DESIGN.md` § "Mode metadata is load-bearing for
codegen" (~80 lines added).
- `bench/run.sh` post-throughput latency harness invocations.
- `examples/rc_let_implicit_returning_app.{ailx,ail.json}` +
one e2e test.
- This JOURNAL entry, which both closes the 18g sub-arc and
acknowledges the four deferred items as known debt.
### Status of the 18-arc
The 18-arc (a + b + c.14 + d.14 + e + f) was reported as
"complete on the correctness property" in the 18g.2 entry,
joined by sub-arc 18g (g.0 + g.1 + g.2). With this tidy
entry the family is formally closed; the next iter is the
orchestrator's call between Boehm-retirement Path A and
Path B (see 18g.2 entry for the framing). Three known debts
travel forward as queue-items, not as 18-arc loose ends.