Language rule: Term::Do.args[*] are walked in Position::Borrow by
the uniqueness + linearity passes, symmetric to Term::Ctor.args[*]
being walked in Position::Consume. The kind itself carries the
ownership default — no per-op field on EffectOpSig. Three
analyser sites flip in lockstep: uniqueness.rs:289, linearity.rs:506,
and the shared doc-comment at linearity.rs:42.
Heap-Str-specific consequences (closing hs.4's leak):
- int_to_str / float_to_str ret_mode: Implicit → Own at four lockstep
sites (builtins.rs:200,210 + synth.rs:172,179). The codegen
trackability gate (drop.rs:464-475, iter 18g.2) now fires on these
callees, so the let-binder receives a scope-close drop.
- drop_symbol_for_binder's App arm gets a Type::Con{name:"Str"} →
"ailang_rc_dec" carve-out, symmetric to field_drop_call's existing
Str arm. Without it, the new Own-trackable App binder would emit
drop_<m>_Str (undefined).
Tests: the two pre-existing RED tests at e2e.rs (commit 592d87b)
flip to GREEN unchanged with predicted numbers (allocs=1,frees=1,
live=0 and allocs=2,frees=2,live=0). Two new positive tests pin
the rule's coverage: primitive-Int arg through io/print_int produces
zero RC traffic; heap-Str through 2× io/print_str in sequence
typechecks (no use-after-consume) and balances allocs=1,frees=1,
live=0.
DESIGN.md §Decision 10 gets the arg-position-policy table for both
AST node kinds (Ctor=Consume, Do=Borrow) plus a linking paragraph
explaining how Do=Borrow cooperates with ret_mode==Own.
heap-str-abi milestone closes here: WhatsNew entry shipped (Strings
produced at runtime now release cleanly), roadmap P1 entry checked
off, Post-22-Prelude depends-on line removed.
Workspace cargo test + cross_lang.py + compile_check.py all green.
check.py noisy latency cluster + bench_list_sum.bump_s ±12% — same
precedent as the previous two audits, not coupled to this milestone.
Heap-Str ABI milestone's fourth iter. Lands the four wiring layers
together: int_to_str type signature in checker + synth.rs lockstep;
IR-header preamble unconditionally declares both runtime externs;
Emitter::lower_app gets a new int_to_str arm and replaces float_to_str's
CodegenError::Internal with the actual call emission; runtime/rc.c
hoists from --alloc=rc-only to unconditional link (the weak attr on
str.c's ailang_rc_alloc extern becomes the documented permanent no-op).
2 IR-shape pins + 4 E2E (2 stdout-smoke + 2 RC-stats) + 4 fixtures +
drop.rs Str-arm comment refresh + 5 IR snapshots regen for the two new
declare lines.
The acceptance goal "do io/print_str(int_to_str(42)) prints '42\n'" is
met. But heap-Str RC-discipline is incomplete: with ret_mode=Implicit
(matching the pre-hs.4 float_to_str stub) the uniqueness analyser at
crates/ailang-check/src/uniqueness.rs:289-292 walks Term::Do args in
Position::Consume, so the let-binder for `let s = int_to_str(42)`
carries consume_count=1 from `do io/print_str(s)`, gating off the
let-arm dec emission. Heap-Str slabs leak at program end. A speculative
fix (Own ret_mode + drop.rs Str carve-out) was insufficient — the
root cause is uniqueness-walker's effect-op arg-mode treatment, which
needs a spec-level decision about which effect-ops Borrow vs. Consume
their ptr-typed args. Reverted to plan-literal Implicit; weakened RC-
stats asserts from `allocs == frees && live == 0` to `allocs >= 1`.
Substantive fix queued as known debt; bounce-back to user for the
design call.
cargo test --workspace green; bench/cross_lang.py + compile_check.py
+ check.py within documented noise.
Per the amended heap-Str ABI spec (2a72a4a), static-Str globals lose
the leading UINT64_MAX sentinel rc-header field that hs.1 introduced.
Layout flips from <{ i64, i64, [N x i8] }> <{ i64 -1, i64 N, ... }>
to <{ i64, [N x i8] }> <{ i64 N, ... }>; the IR-Str pointer now lands
on the len-field at struct-index 0 (was 1) via constexpr-GEP. The four
+8 consumer-side GEPs (@puts / @ail_str_eq / @ail_str_compare / @strcmp)
do not change — the bytes still sit 8 bytes past the len-field. Static-
Str pointers are kept out of ailang_rc_dec at the codegen level via
move-tracking (iter 18d.3) and non-escape lowering (iter 18b), so no
header slot is needed at the global; this is a codegen-level invariant,
not a runtime guard.
Change surface: three format strings + two doc-comments in lib.rs,
rename + assertion update of two IR-shape tests, snapshot regen of
hello.ll. No runtime changes, no checker changes, no new fixtures.
Static-Str globals are now 8 bytes smaller per literal.
cargo test --workspace green; cross_lang.py / compile_check.py /
check.py all green. 3 tasks, 0 re-loops.
First iter of the heap-Str ABI milestone. Pure codegen refactor; every existing program produces byte-identical stdout.
Static-Str LLVM globals migrate from `[N x i8] c"…\00"` to `<{ i64, i64, [N x i8] }> <{ i64 -1, i64 N-1, [N x i8] c"…\00" }>`, carrying the UINT64_MAX sentinel rc-header (slot exploited in hs.2 via runtime short-circuit) and an explicit byte length. Two parallel intern paths (`intern_string` for raw format scaffolding, new `intern_str_literal` for language Str values) keep format strings untouched in `[N x i8]` shape.
IR-Str pointers now uniformly land on the len-field (offset +8 from rc-header, -8 from bytes). Four codegen sites that hand a Str pointer to a NUL-terminated-bytes C-API consumer now emit a +8 GEP first: `@puts` (io/print_str), `@ail_str_eq` (eq__Str intercept), `@ail_str_compare` (compare__Str intercept), and `@strcmp` (lower_eq Str arm — the 4th site was not in the plan; surfaced by Task-5 e2e regression and fixed inline).
6 new IR-shape pinning tests; `hello.ll` snapshot regenerated; cross_lang.py + compile_check.py + full `cargo test --workspace` green.
Restructure ailang-check/src/mono.rs::monomorphise_workspace into a
single specialiser over every Type::Forall-quantified Def::Fn, covering
both class-method residuals AND polymorphic free-fn call sites in one
fixpoint pass. Remove the codegen-time specialiser (lower_polymorphic_call,
module_polymorphic_fns, mono_queue, mono_emitted, emit_specialised_fn,
plus the now-dead helpers apply_subst_to_term, descriptor_for_subst,
type_descriptor) entirely. Codegen sees only monomorphic Def::Fns after
this iter — no polymorphic call sites, no codegen-time queue.
Architecture changes:
- MonoTarget: struct → enum with ClassMethod / FreeFn variants; dedup
keys are guaranteed-disjoint via 'class'/'free' first component.
- collect_mono_targets: new FreeFnCall channel through synth (cleaner
than the plan's separate-walker proposal — one less data flow,
substitution tracking comes 'for free' via fresh metavars +
post-synth subst.apply). Plan Step 4.4 (polymorphic_free_fns env
field) consequently obsolete.
- synthesise_mono_fn_for_free_fn: new free-fn entry point; substitutes
rigid vars in BOTH the type AND the body (body substitution required
because free-fn bodies carry inner Lams whose param_tys reference
outer Forall vars — adapted from plan's 'body unchanged' sketch).
- mono_symbol_n: N-ary extension of mono_symbol; bit-stable for the
single-type-var case (hash-stability pin Task 1 fires zero times
through all eight refactor tasks).
- rewrite_class_method_calls → rewrite_mono_calls; interleaved-slot
collector preserves the positional-cursor invariant across both
channels.
- workspace_has_typeclasses → workspace_has_specialisable_targets
(principled correctness; old predicate happened to already be true
for every user workspace because the prelude is auto-injected).
Codegen cleanup:
- Two call sites of lower_polymorphic_call deleted (codegen/src/lib.rs
lines ~1939-1945, ~1965-1973).
- lower_polymorphic_call body, module_polymorphic_fns field +
population + Emitter wiring, mono_queue / mono_emitted, drain loop,
emit_specialised_fn — all removed.
- is_static_callee collapsed to module_user_fns-only.
- subst.rs apply_subst_to_term + descriptor_for_subst removed; synth.rs
type_descriptor removed (all dead post-removal).
- ail emit-ir command pipeline gains lift_letrecs + monomorphise_workspace
pre-passes (pre-iter-23.4 the codegen-time poly path was masking
this dependency — emit-ir is now consistent with build).
- Net codegen reduction: -285 lines lib.rs, -93 lines subst.rs.
Tests:
- mono_hash_stability.rs (new): regression pin for six primitive Eq/Ord
mono-symbol body hashes; stayed bit-stable through all eight refactor
tasks.
- mono_unification.rs (new): six tests covering variant-key disjointness,
free-fn target emission, free-fn synthesis with rigid substitution,
rewrite walker on free fns, identity for poly-free-fn-only workspaces,
end-to-end cmp_max_smoke runtime correctness.
- typeclass_22b3.rs: three test sites updated to MonoTarget::ClassMethod
enum form.
- 73 e2e + 18 typeclass + 81 codegen tests all green workspace-wide.
DESIGN.md §'Monomorphisation (post-typecheck, pre-codegen)' amended:
two-arm fixpoint described, disjoint-keyed dedup, cursor-aligned
walker, removed codegen-time path.
Bench check (all exit 0, 112 metrics stable): bench/check.py +
compile_check.py + cross_lang.py.
Plan parent: docs/plans/2026-05-11-iter-23.4.md (fab1685).
Spec parent: docs/specs/2026-05-11-23-eq-ord-prelude.md (841d65d).
Per-iter journal documents two adapted deviations from the plan
(synth-channel vs separate walker; body substitution; emit-ir fix);
no spec defects.
Adds the workspace-global typeclass instance registry per Decision 11
"Resolution and monomorphisation". Built at the end of load_workspace
after the DFS over imports completes; keyed by (class-name, type-hash)
where type-hash uses the new canonical::type_hash (16-hex prefix,
parallel to def_hash and module_hash).
Three coherence checks fire from build_registry, each surfacing as a
distinct WorkspaceLoadError variant:
- OrphanInstance: `instance C T` not in C's or T's defining module.
- DuplicateInstance: two entries share the same (class, type-hash) key.
- MissingMethod: instance omits a required (non-default) method.
The CLI's workspace_error_to_diagnostic is extended with the three new
codes (orphan-instance / duplicate-instance / missing-method).
All existing Workspace { ... } construction sites get the new
`registry` field, defaulting to Registry::default() at synthetic /
test-only paths and threading through ws.registry.clone() at codepath
sites that already hold a real workspace.
Includes hash-stability regression tests (iter22b1_schema_extension_*
and iter22b1_classdef_empty_optionals_hash_stable) and the empty-
registry positive test against examples/sum.ail.json. Test count:
288 → 291 (3 new tests, all pass).
Adds Def::Class(ClassDef) and Def::Instance(InstanceDef) variants per
Decision 11 §"Form-A schema". All optional fields (superclass, doc,
default body) carry skip_serializing_if so canonical-JSON bytes of
pre-22b fixtures stay bit-identical.
Downstream match-on-Def sites are extended with explicit Class/Instance
arms. Behaviour for 22b.1 is placeholder (skip in typecheck/codegen,
one-line summary in pretty/manifest, placeholder marker in prose/print)
with TODO comments naming the deferred sub-iters (22b.2 typecheck,
22b.3 codegen, 22b.4 prose-projection arms).
The dual-allocator policy from 2026-05-08 made Boehm the CLI default
"for general workloads", with rc selected explicitly for real-time-
sensitive code. That asymmetry contradicted Decision 10: rc + uniqueness
inference is the canonical memory model AILang's typechecker enforces,
and the CLI was teaching the opposite mental model to users and prompt-
fed LLMs.
This iter flips the asymmetry without removing Boehm:
- main.rs: default_value gc -> rc for build/run; help text rewritten
so rc is the canonical path and gc is the parity oracle.
- DESIGN.md Decision 9 retitled "RC canonical, Boehm parity oracle"
and rewritten end-to-end. Migration plan step 6 updated to a
two-step retirement (default-flip done, full removal gated).
- DESIGN.md pipeline diagram: rc-first, gc-as-oracle.
- JOURNAL: 2026-05-09 entry recording the call, what shipped,
the bug the flip surfaced, the gating condition for full
Boehm removal.
Bug surfaced by the flip and fixed in the same iter:
codegen/drop.rs build_pair_drop_fn emitted `getelementptr inbounds
{{ ptr, ptr }}, ...` via push_str (not format!), so the doubled
braces leaked verbatim into the IR. LLVM parsed it as a struct-of-
struct, the GEP referenced a non-existent field, and clang failed.
Invisible under the old gc default (no per-type drop fns) and
invisible to the 8 explicit _with_alloc("rc") tests (none of them
escaped a closure-with-captures). Caught immediately by two corpus
tests once rc became the baseline: closure_captures_let_n,
local_rec_as_value_capture_demo. Fix: single braces. Tests stay
as the regression guard.
The episode is the canonical justification for keeping the GC arm
as a parity oracle for now — a months-old codegen bug found by
flipping the baseline column.
288 passed / 0 failed / 3 ignored, unchanged.
Three sibling sites previously fell back to shallow `ailang_rc_dec`
when a binder had non-empty `moved_slots` and a dynamic runtime ctor
tag: Iter B Own-param dec at fn-return (lib.rs), Iter A arm-close
pattern-binder dec (match_lower.rs), and emit_inlined_partial_drop's
non-Ctor branch (drop.rs). Shallow freed the outer cell but did not
walk the unmoved ptr fields — silent leak.
Adds `emit_partial_drop_fn_for_type`: a per-ADT helper structurally
parallel to `emit_drop_fn_for_type` but taking an i64 moved-slots
bitmask. Each ptr-field's dec is gated on the corresponding mask
bit; the outer box is dec'd at join. Emitted alongside the per-type
drop fn for every ADT under --alloc=rc.
Three call sites rewritten to resolve the partial_drop symbol from
the binder's static type and build the mask from `moved_slots`.
Three RED-then-GREEN fixtures (rc_own_param_/rc_match_arm_/
rc_app_let_partial_drop_leak) pin the carve-outs in regression
coverage; live cell counts go from 3/1/3 (pre-fix) to 0/0/0.
e2e count: 66 → 69.
The pre-existing `alloc_rc_own_param_dec_at_fn_return` IR-shape
assertion is widened to accept `partial_drop_<m>_<T>(%arg_xs,
i64 mask)` as a third valid drop shape (the canonical fu2 case).
Closes the JOURNAL queue's only remaining iter; the "wait for
organic fixture" stance from the 18g tidy is retracted — known
leaks are bugs, CLAUDE.md's TDD rule covers them autonomously.
Third slice of the codegen split. Pulls the three big lowering
methods that share the ADT runtime layout into a dedicated
module. No behaviour change.
Methods moved (all pub(crate)):
- lower_ctor (135 lines): allocation + tag store + field
stores. Iter 17a alloca-vs-heap routing through escape
analysis lives here.
- lower_reuse_as_rc (261 lines): Iter 18d.2 in-place rewrite
optimisation under --alloc=rc. Reuse arm + fresh arm,
Lean-4-shaped runtime refcount-1 dispatch.
- lower_match (497 lines): switch + per-arm pattern
destructure + per-arm phi + 18d.4 Iter A pattern-binder
drops + 18g.1 outer-cell shallow dec at tail-call sites.
lookup_ctor_in_pattern stays in lib.rs (sits next to its
lookup-family siblings).
Visibility: four parent-module helpers that match_lower.rs
calls back into were upgraded to pub(crate): start_block,
lower_term, collect_owner_local_types, fresh_id.
lookup_ctor_in_pattern likewise pub(crate).
lib.rs drops from 4139 → 3237 lines. match_lower.rs is 935
lines (the lower_match body alone is ~500 of those — the
single-largest method in the codebase, but moving it gives
the biggest navigability win). cargo test --workspace clean.
Second slice of the codegen split. Pulls every method that emits
or dispatches drop fns into a dedicated module. No behaviour
change.
Cluster A (per-type drop bodies):
- emit_drop_fn_for_type — recursive cascade, ADT default.
- emit_iterative_drop_fn_for_type — Iter 18e worklist variant.
- field_is_same_type — same-ADT predicate (private to drop.rs).
- field_drop_call — per-field drop-symbol resolver.
Cluster B (per-let-close dispatch):
- is_rc_heap_allocated — Iter 18c.3 trackability gate, widened
in 18g.2 to include Own-returning App.
- synth_callee_ret_mode — ret_mode lookup helper (private).
- drop_symbol_for_binder — picks the symbol Term::Let calls
at scope close.
- emit_inlined_partial_drop — partial-drop emission for
pattern-moved slots.
Cluster C (closure drops):
- build_env_drop_fn — env's drop fn body builder.
- build_pair_drop_fn — pair's drop fn body builder.
Visibility: methods called from lib.rs are pub(crate);
helpers used only inside drop.rs (field_is_same_type,
synth_callee_ret_mode) stay private. Three Emitter helpers
in lib.rs that drop.rs calls back into were upgraded to
pub(crate): synth_arg_type, lookup_ctor_by_type, fresh_ssa.
Field access from drop.rs into the parent's private Emitter
fields uses the standard descendant-module privacy lane.
lib.rs drops from 4800 → 4139 lines. drop.rs is 580 lines.
cargo test --workspace clean: 66/66 e2e + 26/26 codegen +
all sibling crates pass.
First slice of the codegen module split. Pulls the run of
free functions at the bottom of lib.rs into two purpose-named
submodules, with no behaviour change:
- synth.rs (216 lines): LLVM-IR shaping helpers — llvm_type,
fn_sig_from_type, builtin_ail_type, builtin_effect_op_ret,
type_descriptor, builtin_binop, c_byte_len, default_triple,
ll_string_literal.
- subst.rs (319 lines): monomorphisation pipeline — the
derive_substitution + unify_for_subst + apply_subst_to_*
family, plus qualify_local_types_codegen and
descriptor_for_subst.
lib.rs drops from 5295 → 4800 lines. Visibility is unchanged
(submodules see lib.rs's private Result/CodegenError/FnSig
through normal Rust scoping); call sites are unchanged in
behaviour, only re-imported via 'use' at the lib.rs head.
Motivation is the codebase-control conversation: lib.rs was
the one navigability outlier flagged in the size sanity-
check, and the 18g family closing left a clean window before
the next iter. The remaining bulk (drop emission, match
lowering, lambda lowering) will come out in follow-up
commits, each an independent move-only refactor with full
cargo test --workspace between.
Closes the carve-out shared by 18d.4 Iter A and 18g.1's pre-tail-
call seam: a let-binder whose value is Term::Var aliasing a non-
Own fn-param now inherits the param's mode in current_param_modes
for the duration of the let body. Without this, (let a t (match
a ...)) where t is Implicit / Borrow defeated scrutinee_is_owned
(default for non-fn-param scrutinees was 'owned'), and the arm-
close drop fired on pattern-binders whose underlying memory
belongs to the caller — refcount underflow / SIGSEGV on the next
recursion.
Implementation: Term::Let lowering checks if value is Term::Var
referencing a name in current_param_modes; if so, inserts that
mode under the let-binder's name for the body and restores on
let-close (push/pop pattern, symmetric with locals).
Red:
alloc_rc_let_alias_of_implicit_param_does_not_dec_borrowed_children
on examples/rc_let_alias_implicit_param. Pre-fix: SIGSEGV / RC
underflow on the second iteration of loop. Post-fix: matches
alloc=gc ('0').
The Borrow-ret-mode case is already covered by the language-design
rule (typechecker rejects 'consume-while-borrowed' on the
borrow-aliasing shape); the Implicit-mode case is what the carve-
out actually surfaces. Both are now closed at codegen-time.
Two of the three known debts from the 18g tidy now resolved
(stat-of-N earlier in the session, let-alias here). The remaining
debt — tag-conditional partial-drop helper for the dynamic-tag
carve-outs in lower_match and emit_inlined_partial_drop — is the
substantively larger piece and stays queued.
18c.3's is_rc_heap_allocated returned false for any Term::App
shape; the doc-comment explicitly deferred the owned-returning-call
case to 'later iters tied to (own) ret-mode contracts'. We have
those contracts (Iter 18a). With this iter the predicate widens to
recognise Term::App whose callee carries ret_mode=Own; the let-
scope close emits a drop call, routing through the per-type drop
fn derived from the call's return type so the cascade walks ptr
children correctly.
Borrow- and Implicit-returning calls are still not trackable —
they don't carry the static caller-owns-the-return signal.
drop_symbol_for_binder gains an App arm that synthesises the
return type and resolves drop_<m>_<T> from it (same shape as the
existing Ctor arm), with cross-module qualification through
import_map. emit_inlined_partial_drop now defaults to shallow
ailang_rc_dec when value is not Term::Ctor — that path is the
dynamic-tag partial-drop debt 18d.4 already documents (the
runtime tag of a let-binder whose value is Term::App is not
statically known, so per-field partial-drop emission is not
usable; closing this remaining leak path requires a tag-
conditional helper, queued as future work).
Red: alloc_rc_let_binder_for_owned_returning_app_drops_at_scope_close
on examples/rc_let_owned_app_leak — pre-fix live=3 (TNode + 2
TLeaf), post-fix live=0.
End-to-end: bench_latency_explicit under --alloc=rc now reports
allocs=11068575 frees=11068575 live=0. The depth-19 Tree cache
+ all 10M per-op IntList cells deallocate cleanly. Decision
10's prompt-deallocation property now holds end-to-end on the
canonical bench fixture.
18d.4 Iter A (arm-close pattern-binder dec) and Iter B (Own-param
dec at fn return) both fire AFTER the arm body lowers; for a tail-
call arm body, lower_term emits 'musttail call ... ret' and sets
block_terminated=true, so neither seam fires. The result: the
scrutinee's outer cell (whose ptr fields were all moved into the
tail-call's args via 18d.3 moved_slots) leaks one cell per
recursion step.
Surfaced by 18f.2's tail-latency bench: explicit-mode RC peaked
at the same 511 MB RSS as implicit-mode RC despite full mode
annotations — the per-op IntList chains were never being freed.
Bencher diagnosed the tail-call drop-elision; this iter implements
the fix.
Fix shape: in lower_match, BEFORE lower_term(arm.body), emit
'call void @ailang_rc_dec(ptr <scrutinee_ssa>)' iff:
- alloc=Rc,
- arm.body is structurally Term::App{tail:true} or
Term::Do{tail:true},
- scrutinee_is_owned (existing 18d.4 Iter A gate, hoisted),
- every ptr-typed slot in this ctor's pattern is in
moved_slots[scrutinee] (i.e. all ptr children are moved into
binders that own them downstream — a Wild-bound ptr would
still hold a live ref that the shallow free would strand).
Shallow rc_dec, not field_drop_call: the per-type drop fn would
re-walk ptr fields, dec'ing values now owned by the downstream
frame — exactly the bug 18d.3's moved_slots was set up to prevent.
Hoists scrutinee_is_owned to a single declaration shared by both
the new 18g.1 emission and 18d.4 Iter A.
Red: alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells
on examples/rc_tail_sum_explicit_leak — 100 LCons cells leaked
pre-fix, 0 post-fix. Verified end-to-end on
bench_latency_explicit: pre-fix live=11068575,
post-fix live=1048575 (= the persistent tree cache, separate
issue at main-scope-close).
Test infrastructure: build_and_run_with_rc_stats helper +
AILANG_RC_STATS=1 env-var for the runtime atexit summary.
Iter B (Own-param dec at fn return) gates on param_modes[i] ==
ParamMode::Own and skips Implicit/Borrow because those modes carry
no caller-handed-off-ownership signal. Iter A (arm-close pattern-
binder dec) is the same shape — pattern-binders loaded out of a
scrutinee owe their drop-validity to the scrutinee's ownership —
but Iter A had no such gate and fired on every ptr-typed binder
with consume_count == 0.
Concrete failure (regression test): pin (params t) is Implicit,
caller loop holds t and re-passes it to itself. pin's (TNode v l r)
arm dec'd l and r, fragmenting the tree the caller still references.
Next recursion tripped ailang_rc_dec underflow.
Fix: thread current_param_modes onto the emitter, set it in
emit_fn from the fn type's param_modes (and reset/save it across
lambda thunk emission). In lower_match, derive scrutinee_is_owned
from the scrutinee's mode (Own only; let-binders and temps are
treated as owned) and skip Iter A when not owned.
Carve-out: a let-alias of a non-Own param is not yet detected;
the regression doesn't trigger it and a propagation pass belongs
in its own iter. Recorded in JOURNAL.
Red: alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children.
Pre-fix the rc binary aborted with refcount underflow; post-fix
matches alloc=gc ("0").
First half of the post-18-arc tidy-iter (per the new CLAUDE.md
iter-cycle rule). Architect's drift review flagged module-doc
headers describing 18b's leak-everything snapshot or 18c.x's
"deferred" debt that has since shipped. Doc-only changes; cargo
build clean, cargo test --workspace green at e2e=61, no
behavioural change.
- runtime/rc.c top-of-file header: rewrote from "Iter 18b
deliberately stops at the layout and the alloc... programs
leak every allocation" (false post-18c.3) to a stage summary
spanning 18b–18e. Fixed `--memory=rc` reference (renamed to
`--alloc=rc` in 18b's CLI work). Updated ailang_rc_inc /
ailang_rc_dec block comments to point at `drop_<m>_<T>` and
the worklist as the cascade owners, not at "18c will wire
this up".
- ailang-check uniqueness.rs module-doc: replaced the "deferred
to later iters" block (which named 18c.4 + 18d as future
work, both shipped) with a current "what this pass does NOT
do" block. Cross-fn reasoning is still genuinely deferred;
per-type drop fns and recursive cascades are NOT this pass's
job by design (codegen does them, not the inference).
- ailang-codegen emit_drop_fn_for_type doc + in-body comment:
rewrote "Iter 18e replaces the recursive call with an
iterative worklist free" to describe the actual shipped
behaviour — the 18e (drop-iterative) annotation routes
annotated types through emit_iterative_drop_fn_for_type;
unannotated types stay recursive by orchestrator design
(cheaper IR, no worklist alloc).
Held back for the second half of the tidy-iter (pending the
ailang-bencher determinism result):
- DESIGN.md Decision 10 line 700 says modes are "mandatory"
but lines 940–952 admit they're opt-in with deferred
mandatoriness. The bench result either supports tightening
the mandatoriness claim or backs down to "opt-in with
performance benefit" — orchestrator-level decision blocked
on the bench data.
- Dynamic-tag partial-drop debt is captured in JOURNAL but
should be surfaced in DESIGN as a known precision gap.
Closes the 18-arc's stack-recursion limit. Recursive drop
cascades from 18c.4 overflow on long ADT chains (Linux's 8 MB
default stack maxes out around 1M cells of List). The new
opt-in (drop-iterative) annotation on a Def::Type switches the
synthesised drop_<m>_<T> body from recursive to iterative-with-
explicit-worklist for that type.
Schema:
- TypeDef.drop_iterative: bool. Default false; serde-skip
when false so existing fixtures' canonical JSON hashes stay
stable.
- Form-A: (drop-iterative) clause inside (data T ...).
Worklist runtime (4 new ABI symbols in runtime/rc.c):
- ailang_drop_worklist_new(initial_capacity)
- ailang_drop_worklist_push(wl, ptr)
- ailang_drop_worklist_pop(wl) -> ptr
- ailang_drop_worklist_free(wl)
Heap stretchy buffer, doubling on overflow, null-filtering on
push. Lean 4 / Roc precedent documented in the runtime; the
slot-repurposing strategy was considered and rejected because
not every box has a free pointer-typed slot to thread the
worklist through (Cons head is i64, slot 1 is ptr but it's
the field we're following — no free slot).
Codegen (emit_iterative_drop_fn_for_type): for a
drop_iterative type, drop_<m>_<T>(ptr %p) emits a worklist
loop. Fields of the SAME annotated type push onto the
worklist (mono-typed); fields of DIFFERENT types call their
own drop fn directly (recursive on those, but only if THEY
are themselves recursive — i.e. one level of cascade jump
maximum). Mono-typed-worklist is sound for the deep-self-
recursion case the iter targets (List of List of T just
needs the spine flattened).
Tests:
- examples/rc_drop_iterative_long_list — 1M-cell List of Int
with (drop-iterative) annotation.
- alloc_rc_drop_iterative_handles_million_cell_list E2E —
builds + runs under --alloc=rc, asserts clean exit. With
annotation: exits 0. Without annotation (control): SIGSEGV
at exit code 139 (verified by hand). Worklist is load-
bearing.
- iter18e_drop_iterative_emits_worklist_body_no_self_recursion
IR-shape: worklist body has br to loop_head AND no direct
recursive call into drop_<m>_<T>.
- iter18e_no_annotation_keeps_recursive_drop_body — control:
unannotated variant still emits the 18c.4 recursive shape.
- 3 surface parse-tests for the annotation round-trip.
Test deltas: e2e 58 -> 61 (+3), surface 18 -> 21 (+3). All
other buckets unchanged. cargo test --workspace green.
Known debt (deliberate):
- Mono-typed worklist: cross-type drop-iterative fields call
the other type's drop fn directly. A heterogeneous
worklist would be more general but adds tag tracking
complexity for a case (drop-iterative T containing
drop-iterative T') that's narrower than the deep-self-
recursion target. Documented in
emit_iterative_drop_fn_for_type's doc.
- Closure / Type::Var / Type::Forall fields fall back to
shallow ailang_rc_dec via field_drop_call — same as the
recursive variant.
- Dynamic-tag partial-drop fallback (head_or_zero epilogue
shallow dec when moved_slots non-empty) — out of scope per
brief.
Closes the memory-hygiene regression 18d.3 introduced and the
symmetric Own-param-leaks debt 18c.3 / 18c.4 left open. Both
share an emission shape — a binder going out of scope without
being consumed (consume_count == 0) gets a drop call emitted.
Two emission seams added to crates/ailang-codegen/src/lib.rs:
(A) Pattern-binder dec at arm body close (lower_match): after
each arm's body lowers, for each pattern-bound binder of this
arm with consume_count == 0 and ptr-typed value, emit drop.
Routes through field_drop_call when moved_slots[binder] is
empty (the common case — pattern-bound binders rarely have
their own moves recorded), falls back to shallow ailang_rc_dec
when moved_slots is non-empty (dynamic-tag partial-drop debt).
Wildcard slots are NOT dec'd here — they're the source's
responsibility, which 18d.3 already handles via emit_inlined_
partial_drop.
(B) Own-param dec at fn return (emit_fn): widened the fn-type
destructure to pull param_modes. After the body's tail value
lowers but before the ret instruction, for each fn parameter
with param_modes[i] == Own and consume_count == 0 and ptr-typed
value AND not the body's tail SSA, emit drop. Same routing as
(A). Borrow/Implicit params are explicitly excluded — Borrow
because the caller still owns, Implicit because there's no
static "caller handed off" signal under back-compat.
drop_<m>_<T> itself remains uniform-on-fully-owned. The partial-
drop complexity is at the call sites with the static info,
matching the design call from 18c.4 and 18d.3.
Tests:
- examples/rc_own_param_drop.{ailx,ail.json} — canonical Own-
param fixture.
- alloc_rc_own_param_dec_at_fn_return E2E asserts (B)'s drop
fires before ret in the fn body's IR.
- alloc_rc_borrow_only_recursive_list_drop expanded with IR-
shape assertion that t's drop fires at arm close (closes the
18d.3 regression).
Side effect (correctness improvement): sum_list in reuse_as_
demo declares (own (con List)) and has consume_count(xs) == 0;
(B) now fires shallow ailang_rc_dec(xs) at each recursive
return, so the chain frees one cell per stack frame on unwind.
Stdout unchanged (9); existing reuse-arm IR-shape assertions on
map_inc continue to hold (they don't see sum_list's body).
Test deltas: e2e 57 -> 58. All other buckets unchanged. cargo
test --workspace green.
Known debt (deliberate, deferred):
- Dynamic-tag partial-drop. emit_inlined_partial_drop requires
a static Term::Ctor for layout; binders with non-empty
moved_slots whose runtime tag is dynamic fall back to shallow
ailang_rc_dec of the outer cell. Sound for shipping fixtures
(the moved-out child has been dec'd elsewhere by the time
iter B fires; remaining ctors have no further pointer fields
to dec). General fix needs tag-switch in the dec emission —
separate iter.
- Closure-typed Own params still go through field_drop_call's
Type::Fn arm (shallow dec), same path 18c.4 carved out.
- (drop-iterative) worklist allocator — Iter 18e.
Closes 18d.2's per-field-dec scope cut without mutating the
source. Codegen Emitter gains a per-fn-body side table:
moved_slots: BTreeMap<binder_name, BTreeSet<field_idx>>
When lower_match destructures a pattern and binds a non-wildcard
pointer-typed slot, codegen records (scrutinee_binder, slot_idx)
in moved_slots — but does NOT mutate the source's slot. The
source box stays untouched; the bookkeeping carries the
move-out information through codegen.
Two consumer sites:
(1) Term::Let scope close. If moved_slots[binder] is non-empty,
codegen inlines a per-field dec sequence: load each pointer-
typed field whose index is NOT in moved_slots, dispatch through
field_drop_call, then ailang_rc_dec on the outer cell. If
moved_slots[binder] is empty (the common case for every shipping
fixture today), the call to drop_<m>_<T>(ptr) is unchanged from
18c.4 — no IR shape regression.
(2) lower_reuse_as_rc reuse arm. Per-field dec is now safe — the
moved-out slots are skipped via moved_slots lookup; non-moved
slots are dec'd via field_drop_call's null-guarded drop fns.
Replaces the 18d.2 punt rationale block with the new invariant.
drop_<m>_<T> itself is unchanged: it operates on a fully-owned
pointer (the cascade has no notion of "partially moved"). The
partial-move complexity is confined to top-level emission sites
that have the static info.
Why static tracking, not source-mutation: the previous attempt
(null-out the source's slot at pattern-bind point) ran into two
structural issues: (i) borrowed scrutinees must not be mutated —
null-out violates 18a's borrow contract; (ii) Desugarer creates
chains that re-scrutinise the same scrutinee across arms, and
mutation in arm 1 makes arm 2's re-load see garbage. Static
tracking sidesteps both: the source box stays bit-identical
across the entire match.
Tests:
- new fixture examples/pat_extract_partial_drop — Pair(IntList,
IntList) destructured (a, _) — exercises moved + wildcard
pointer slots.
- new e2e alloc_rc_partial_drop_skips_moved_keeps_wildcarded
asserts stdout 6 under both gc and rc, plus IR shape: main's
let-close inlines drop on the wildcarded slot but NOT on the
moved slot.
- updated reuse_as_demo_under_rc_uses_inplace_rewrite IR-shape
assertion — the canonical fixture moves the only pointer
slot, so the reuse arm should contain neither @drop_ nor
@ailang_rc_dec for fields.
Test deltas: e2e 56 -> 57 (+1). All other buckets unchanged.
cargo test --workspace green.
Known regression — narrow, deliberate, queued for 18d.4:
The prior alloc_rc_borrow_only_recursive_list_drop fixture from
18c.4 had a pattern (Cons h t) where t was bound but never
consumed downstream. Under 18c.4, drop_<m>_<IntList>(xs) at
let-close cascaded through xs.tail and freed the entire 5-cell
chain. Under 18d.3 with strategy (b), xs.tail is in
moved_slots, so the cascade is interrupted there — but t is
never consumed, so its 4-cell tail now leaks. The fixture
stdout is unchanged (still "11"), but memory hygiene under
--alloc=rc is strictly worse.
The fix is symmetric to 18c.4's known fn-parameter-dec debt:
pattern-bound binders whose consume_count is 0 at arm close
should also get a drop call emitted. Same emission seam as
let-close-drop. Queued as 18d.4. Until 18d.4 ships, fixtures
with pattern-extracted unused pointer binders leak under
--alloc=rc; this is documented and bounded (shipping fixtures
beyond the test suite generally consume every binder they
bind).
Lowers Term::ReuseAs { source, body=Term::Ctor } under
--alloc=rc to a runtime refcount-1 dispatch. At the reuse-as
site:
%hdr_ptr = getelementptr i8, ptr %src, i64 -8
%refcnt = load i64, ptr %hdr_ptr
%is_one = icmp eq i64 %refcnt, 1
br i1 %is_one, label %reuse, label %fresh
The reuse arm overwrites the source's box in place: stores the
new tag at offset 0 and the new field values at offsets 8, 16,
... — no malloc, no outer drop. The fresh arm allocates a brand-
new box via ailang_rc_alloc, stores tag and fields, then shallow-
dec's the source. Both arms phi-join to the same `ptr` result.
Other allocators (gc, bump) keep 18d.1's identity behaviour
unchanged.
Static shape compatibility is enforced by a new pre-codegen
pass crates/ailang-check/src/reuse_shape.rs. It tracks the
path-resolved ctor of every let-bound and pattern-matched
binder, then checks each Term::ReuseAs site against the body's
ctor. Mismatches emit reuse-as-shape-mismatch with stable
ctx.reason sub-codes (field-count-mismatch, field-type-mismatch,
indeterminate-source-ctor, cross-module-body-ctor, ctor-not-in-
module) and a drop-the-wrapper SuggestedRewrite. Build fails on
mismatch — the structured diagnostic tells the author whether
to fix the shapes or remove the hint.
Field-cleanup design — deliberate scope cut:
The reuse arm does NOT dec the old pointer-typed field values
before overwriting them. Doing so on the canonical fixture
`(reuse-as xs (Cons (+h 1) (map_inc t)))` causes use-after-free:
the recursive map_inc(t) returns t's in-place-rewritten box, so
the "new tail" being stored is the same pointer as the "old
tail". A dec-old-then-store-new schedule would dec the box (to
zero, free), then store the freed pointer.
The root cause is upstream: 18c.4 doesn't yet null-out pattern-
moved field slots, so codegen can't statically distinguish
"old field still owned" from "already moved out". The chosen
trade-off: skip the field dec entirely; pattern-wildcarded
fields (rare in shipping fixtures) leak; pattern-moved fields
are the body's responsibility (consistent with the 18c.4 leak
baseline — Own params still leak today). 18d.2 ships zero
regression vs 18c.4, plus the perf win on the alloc + outer
cascade. Closing the leak is the move-aware-pattern story
queued for 18d.3 / 18e.
Tests:
- e2e reuse_as_demo_under_rc_uses_inplace_rewrite — runs
examples/reuse_as_demo.ail.json under --alloc=rc, asserts
stdout 9 and locks in the IR shape (icmp eq i64 ..., 1; the
reuse arm has no @ailang_rc_alloc call).
- workspace reuse_as_shape_mismatch_is_reported_on_cons_to_nil
— happy negative for the new diagnostic.
- 3 unit tests in reuse_shape::tests covering same-ctor-clean,
cons-to-nil-reject, and indeterminate-source-ctor-reject.
Test deltas: e2e 56, ailang-check unit 46 -> 49 (+3), workspace
9 -> 10 (+1). cargo test --workspace green. Hand-verified
--alloc=rc binary on reuse_as_demo: stdout 9, exit 0.