diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index fb6654d..b661deb 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -7128,3 +7128,179 @@ above and the symmetric `(own T)` parameter-dec gap from a pattern arm, but the emission seam is the same shape). After 18d.4, the move-aware-pattern story is complete and the 18-arc moves to 18e (worklist). + +## Iter 18d.4 — pattern-binder + Own-param dec at scope close + +Closes 18d.3's known memory-hygiene regression AND the +symmetric Own-param-leaks debt 18c.3 / 18c.4 left open. Both +emission sites share the same shape: a binder going out of +scope without being consumed (`consume_count == 0`) gets a +drop call emitted, gated on the value being `ptr`-typed and +the current block being open. + +### Two emission seams, one shape + +**(A) Pattern-binder dec at arm body close.** Inside +`lower_match`, after each arm's body lowers, the codegen +walks the arm's pattern-bound binders. For each whose value +is `ptr`-typed and uniqueness side table reports +`consume_count == 0`, emit a drop: + +- If `moved_slots[binder]` is empty (the common case — + pattern-bound binders rarely have moves of their own), + emit `call void @drop__(ptr %binder)` via + `field_drop_call` — exactly the 18c.4 path. +- If `moved_slots[binder]` is non-empty, fall back to shallow + `ailang_rc_dec` of the outer cell only. See "dynamic-tag + partial-drop" below. + +Wildcard slots are NOT dec'd here — they have no binder. The +source's drop (handled by 18d.3's `emit_inlined_partial_drop` +or the uniform `drop__`) takes care of them. + +**(B) Own-param dec at fn return.** Inside `emit_fn`, the +fn-type destructure now also pulls `param_modes`. After the +body's tail value lowers but before the `ret` instruction, +the codegen walks the parameter list. For each parameter `p` +where: + +- `param_modes[i] == ParamMode::Own`, +- the parameter type lowers to `ptr`, +- the uniqueness side table reports `consume_count == 0`, +- AND the parameter's SSA is NOT the body's tail value (that + would transfer ownership to the caller — caller dec's), + +emit a drop with the same routing as (A). + +`Borrow`-mode parameters are explicitly excluded — caller +still owns. `Implicit`-mode parameters are also excluded — +no static "caller handed off ownership" signal under +back-compat (a future iter could choose to opt-in this case +via uniqueness inference). + +### Why both in one iter + +The emission shape is uniform: "binder leaves scope, no one +consumed it, drop." Pattern-arm-close and fn-return are just +two lifecycle points where this fires. Splitting them into +separate iters would have produced two iters with effectively +the same emission code at different binder-lifecycle points; +the implementer noted no friction in handling them together. + +### `drop__` remains unchanged + +Same call as 18c.4 emits — same uniform per-type drop fn. +The partial-move complexity stays at the call sites with the +static info. The cascade has no notion of "partially moved"; +when a parent's drop walks into a child via +`field_drop_call`, the child is fully owned at that point. + +### Dynamic-tag partial-drop — debt continues + +`emit_inlined_partial_drop` (18d.3) requires a static +`Term::Ctor` for layout — its per-field load sequence is +keyed against a fixed ctor's field shape. Pattern-bound +binders and Own params often have only their static *type* +known at the dec site, not their runtime ctor (e.g. an `xs: +List` whose ctor is determined by the runtime value). When +`moved_slots[binder]` is non-empty for such a binder, the +partial-drop emission falls back to **shallow +`ailang_rc_dec`** of the outer cell only — it doesn't try to +walk fields. + +For 18d.4's shipping fixtures, this fallback is sound: + +- `rc_list_drop_borrow` (the 18d.3 regression): `t`'s + `moved_slots` is empty (the regression fixture doesn't + re-scrutinise `t`), so the per-type + `drop__(t)` fires cleanly via the (A) path. + Closes the leak. +- `rc_own_param_drop`: `xs` has `moved_slots[xs] = {1}`, but + the active ctor's only ptr field (`Cons.tail`) has already + been dec'd by the (A) path on `t`, and the `Nil` runtime + alternative has no ptr fields anyway. Shallow dec of the + outer cell is correct on every reachable runtime path. + +A general dynamic-tag partial-drop would emit a tag-switch +at the dec site (or pass a moved-mask parameter into a +parameterised `drop__`). That's a separate iter; not +load-bearing for the shipping fixtures of the 18-arc. + +### Side effect on `reuse_as_demo` (correctness improvement) + +`sum_list` in `examples/reuse_as_demo` has signature `(fn +((own (con List))) → Int)` and `consume_count(xs) == 0` +(match scrutinee is Borrow). (B) now fires shallow +`ailang_rc_dec(%arg_xs)` at each recursive return. Combined +with the recursive `sum_list(t)` chain, this dec's one cell +per stack frame on unwind, freeing the entire chain before +`main` exits. + +Stdout unchanged (`9`). The existing IR-shape assertions on +`map_inc`'s reuse arm continue to hold — they scope between +`reuse.:` and `\nfresh.:` labels and don't see +`sum_list`'s body. + +### Test deltas + +- E2E: 57 → 58 (+1: `alloc_rc_own_param_dec_at_fn_return`). +- All other buckets unchanged. +- `cargo test --workspace` green. +- The 18d.3 regression on `alloc_rc_borrow_only_recursive_list_drop` + is now closed: the IR-shape assertion confirms `t`'s drop + fires at arm close. Stdout unchanged (still `11`); memory + hygiene strictly improved. + +### Known debt (deliberate, deferred) + +- **Dynamic-tag partial-drop** — described above. Falls back + to shallow `ailang_rc_dec` for binders with both non-empty + `moved_slots` AND dynamic runtime ctor. +- **Closure-typed Own params** — fall through to + `field_drop_call`'s `Type::Fn` arm (shallow dec). Same path + 18c.4 carved out for closure-typed ADT fields. +- **Implicit-mode params with `consume_count == 0`** — not + dec'd. `Implicit` is the back-compat opt-out lane; its + parameters carry no caller-ownership signal. +- **`(drop-iterative)` worklist allocator** — Iter 18e. + +### What 18d.4 closes structurally + +The move-aware-pattern story spanning 18d.3 + 18d.4 is now +complete for the static cases: + +1. Pattern destructure of an owned scrutinee marks the slot + as moved (18d.3's `moved_slots`). +2. Drop of the source at let-close inlines a per-field dec + sequence skipping moved slots (18d.3's + `emit_inlined_partial_drop`). +3. The reuse-as reuse-arm dec's old non-moved fields safely + (18d.3, since moves are tracked statically and source is + never mutated). +4. Pattern-bound binders whose new homes don't consume them + get drop-emission at arm close (18d.4 (A)). +5. `(own T)` parameters that aren't consumed inside the fn + get drop-emission at fn return (18d.4 (B)). + +Together: the move-aware story closes the 18d.2 explicit +scope cut AND the implicit 18c.3 / 18c.4 Own-param-leaks gap. +The remaining hygiene gap is dynamic-tag partial-drop, which +is structurally orthogonal to the move-aware story (it'd +exist even with no moves anywhere — it's about not knowing +the runtime ctor at a partial-drop site). + +### Next + +Iter 18e: `(drop-iterative)` annotation + worklist allocator. +The recursive cascade in `drop__` overflows the stack +on long lists (millions of cells). The annotation switches +the synthesised drop fn from recursive to iterative-with- +explicit-worklist, allowing arbitrarily deep ADT chains to +free without stack growth. The IR infrastructure overlaps +with the dynamic-tag-partial-drop story (both want a +per-call-site customised free), so 18e may also be the +natural place to close the dynamic-tag fallback. + +After 18e, Iter 18f closes the 18-arc with the RC validation +bench. The CLAUDE.md tidy-iter rule then fires: `ailang-architect` +drift cleanup before 19 begins.