Hypothesis-driven measurement of "did monomorphisation actually
buy us performance?" on a 100M-iter LCG hot loop, AILang mono'd
code vs. four C reference variants (direct-inlinable, direct-
noinline, indirect-monomorphic, indirect-polymorphic). Zen 3,
clang -O2, median-of-15.
Headline: H1 supported, but the mechanism is inlining, not
dispatch shape. AILang mono = hand-C direct (1.000x). Indirect-
monomorphic = direct-noinline (1.000x) — saturating branch
predictor makes the indirect-call cost vanish on this hardware.
Inlining is the actual 3.31x win; polymorphic indirect adds
another 21% predictor-miss penalty.
DESIGN.md Decision 11 gains a rationale paragraph reframing mono
as inlining-enabler rather than indirect-call-eliminator, with
explicit pointer to the bench. JOURNAL entry records the full
methodology, ratios, limitations, and the side-effect mono-pass
env.globals-seeding bug surfaced while building the AILang fixture
(separate RED-first debug iter to follow).
Adds three end-to-end tests for the 22b.3 mono pass, each with a
matching one-property fixture under examples/. Defends mono-pass
invariants 3, 4, and 6 from docs/specs/2026-05-09-22-typeclasses.md
at the binary level (stdout-asserting, not AST-asserting):
- coherence_two_instances_pick_correct_body_at_runtime — class R
with instances at Int and Bool, both reachable from one main.
Stdout `11\n22` proves both mono fns are synthesised AND each
call site picks the matching body (no collapse).
- class_default_method_runs_and_prints_default_value — empty
instance body + class-level `default` clause must lower, link,
and print the default value (`99`). Promotes the existing
synthesise_mono_fn unit test to a binary-level guarantee.
- cross_module_class_method_runs_and_prints_correct_value —
class+instance in module A, called from module B's main. The
qualified-name rewrite + cross-module synth-def placement was
AST-tested in 22b.3.6; this pins down the emit-and-link path.
All 18 typeclass_22b3 tests pass; full workspace green; 3 bench
gates 0/0/0.
Four new E2E tests in typeclass_22b2.rs covering invariants the
per-task fixtures (all single-module, single-fn) did not exercise:
- cross_module_class_method_resolves_and_fires_no_instance:
module B's call to `show` (declared in module A) reaches the
no-instance arm, proving Env.class_methods is merged workspace-
wide and not restricted to the local module.
- cross_module_polymorphic_fn_without_constraint_fires_missing_constraint:
same merge guarantees the missing-constraint arm sees the residual
produced by a call to A's class method from inside B.
- cross_module_instance_satisfies_concrete_call: the workspace
registry built at load time over all modules must populate keys
visible from B; a concrete call resolves clean cross-module.
- two_fns_each_missing_constraint_produce_two_diagnostics: pins
check_in_workspace's per-def aggregation — two ill-formed fns
produce two diagnostics (one per fn), not one.
Six new fixtures (xmod_* trio + two_fns_*).
Adds the missing positive counterpart to the no-instance test:
`show 42` with `instance Show Int` PRESENT must typecheck green.
This guards against registry-keying or threading bugs that would
otherwise sneak past the negative-only test (an empty registry at
typecheck time, or a key shape mismatch, would silently fire
no-instance even when the instance exists).
Also drops the dead `let _ = ws;` and its stale comment in
`check_in_workspace` — the line above already consumes
`ws.registry`, so the explicit drop is redundant. The remaining
comment is folded into the registry-threading comment so the
"registry is the only thing threaded" rationale is preserved.
Adds seven fixtures under examples/test_22b1_* exercising the
workspace-load coherence checks plus four positive/negative tests in
ailang-core/src/workspace.rs:
- iter22b1_instance_in_class_module_loads_clean: positive case;
test_22b1_orphan_class.ail.json declares Show + instance Show Int
in the same module (the class's). Registry has one entry.
- iter22b1_orphan_instance_fires_diagnostic: declares Show in
test_22b1_orphan_third_classmod, declares instance Show Int in
test_22b1_orphan_third (a third module). Fires OrphanInstance.
- iter22b1_duplicate_instance_fires_diagnostic: per the JOURNAL hint,
module A defines class Show + instance Show MyInt (legal, A is
class's module), module B defines type MyInt + instance Show MyInt
(legal, B is type's module). Entry imports both. Fires
DuplicateInstance with first/second_module distinct.
- iter22b1_missing_method_fires_diagnostic: class Eq with two
non-default methods (eq, ne), instance Eq Int specifies only ne.
Fires MissingMethod { method: "eq" }.
The surface round-trip gate (ailang-surface/tests/round_trip.rs)
filters out test_22b1_* fixtures because the Form-B parser arms for
ClassDef/InstanceDef are deferred to 22b.4. Without the filter, every
fixture's printed text would re-parse-fail on the unknown `class` /
`instance` head.
Test count: 291 → 295 (4 new workspace tests, all green).
Closes the apples-to-apples gap from 21'e. Adds:
- examples/bench_list_sum_explicit.ailx — same algorithm and sizes
as bench_list_sum, fully (borrow)/(own)/(drop-iterative)
annotated so codegen emits proper inc/dec instrumentation.
- bench/reference/list_sum_explicit_free.c — same algorithm
with explicit free() walking the chain after sum.
The full alloc+dec vs malloc+free comparison reveals two non-
trivial conclusions:
1. AILang's full RC pipeline is only 26% slower than glibc
malloc+free on this workload (rc/c = 1.26x). The implicit-
mode comparison's 1.42x was misleading — it counted neither
pipeline's free path. The fair ratio is 1.26x, materially
better than the previous read.
2. RC's dec is cheaper per cell than glibc free(). AILang
dec-tax: ~3 ns/cell. C free-tax: ~5.5 ns/cell. Plausible
cause: ailang_rc_dec operates on a known-shape cell with a
fixed-offset refcount and a static per-type drop fn — no
free-list bucketing, no header introspection, no global lock.
bump's advantage expresses fully: bench_list_sum_explicit.bump/c
= 0.42x means AILang at bump is 2.4x faster than C malloc+free.
Sets a useful upper bound on a slab/pool RC allocator's potential.
The 21'-family arc — bench-regression infrastructure — is now
substantively complete: 21'a (bench/check.py), 21'b (corpus
widening), 21'c (compile_check.py), 21'd (pure-compute fixtures
+ harness hardening), 21'e (cross-language hand-C), 21'f (explicit
apples-to-apples). 63 runtime metrics + 18 compile metrics + 25
cross-lang metrics under regression coverage. Any future iter
that regresses any axis beyond tolerance gets caught at the next
family close.
Remaining queue is back to substantive language work — Family 21
(typeclasses / polymorphic ADTs at runtime / pattern-binding
generalisation) is now an orchestrator-level fork that needs
direct user input.
Closes the third corpus blind spot (heap-allocation-only) by
adding two fixtures with no allocation pressure: bench_compute_
intsum (tail-recursive integer accumulator) and bench_compute_
collatz (Collatz step-counter, branchy).
Surprise on intsum: 50M-iteration loop runs in 1ms wall under
all three allocators. LLVM's induction-variable analysis applies
the closed-form triangular-sum reduction to AILang's IR — a
positive codegen finding (the IR composes with LLVM's optimizer
at the same level a hand-C loop would) but it makes intsum
useless as a runtime regression bench. Excluded from run.sh's
fixtures array; kept in examples/ as reference and as a future
cross-language comparison anchor.
Collatz survives optimization (data-dependent control flow). At
56ms wall, gc/bump/rc all within 2% — the canonical "pure-compute
is allocator-invariant" data point this fixture is meant to
prove. If a future codegen change leaks an allocation into the
inner loop, the 1.00x / 1.02x ratios diverge visibly.
Two infrastructure fixes the new fixtures forced:
- 6-decimal precision in run.sh's Python timing helper and median
averager (was 3-decimal; sub-ms times rounded to 0.000 and
crashed the ratio awk with Division durch Null).
- Zero-guard in the ratio awk (defensive even with the precision
bump, since LLVM-eliminated workloads can still hit zero).
Latency baseline: implicit_at_rc.max_us tolerance 25% -> 30%.
Three captures today (477 / 456 / 609 µs) show natural run-to-run
dispersion wider than the original tolerance accounts for. Not a
softening to dodge regression — the original baseline was the
first capture; a fairer tolerance across natural max-of-1000-
samples width is what the harness needed from the start.
Baseline file: 47 -> 55 metrics. 21'e (cross-language reference,
clang -O2 hand-C ratios) is the natural next dispatch.
Two new throughput fixtures targeting blind spots in the 21'a
corpus:
- bench_closure_chain exercises the build_pair_drop_fn codegen
path (the 18c.4 doubled-braces trigger). Each iteration of
run_loop allocates a {thunk, env} closure pair via the
let-rec-name-as-value escape route. Sizes 10k / 100k / 500k.
rc/bump = 4.14x — materially worse than the 2.91x / 2.59x of
the linear/tree fixtures, exposing that closure work pays the
RC alloc tax twice (pair + env-struct).
- bench_hof_pipeline exercises poly-ADT instantiation and
indirect dispatch via fold_with_fn over List<a>. Sizes 100k /
1M / 3M elements. Ratios essentially match bench_list_sum,
confirming the 13b static-template-plus-ctor-inline design
has zero measurable overhead at this scale.
Baseline file extends from 31 to 47 metrics. The two new fixtures
build clean under all three allocators; the rc-arm build exercises
the per-type drop fn for the closure-pair, providing a tripwire
for any future 18c.4-class IR malformedness.
JOURNAL records both surprises (4.14x closure tax, ~zero HOF/poly
overhead) and explicitly notes the dispersion observation on
explicit_at_rc.p99 — three captures today (357.5 / 294.6 / 251.5)
confirm wide run-to-run variance on that fixture. Methodology
upgrade (n>=10 captures or tighter fixture) deferred to 21'c.
bench/run.sh fixtures array updated. bench/check.py needed no
changes — its parser handles the wider table by metric name.
reuse-as: render `(reuse-as src body)` as `reuse src as body` with
adaptive braces — inline for single-line bodies (the 99% Ctor case),
braced for multi-line (Let, Seq, nested Match). Reads as English
subject-verb-object instead of compound keyword + always-block.
let-inlining: `let x = rhs; <body using x once>` collapses to
`<body[x := rhs]>` when (a) rhs is not a `Term::Do` (keep effect
sequencing visible), (b) x is used exactly once (no work duplication),
(c) rhs renders single-line AND ≤ 40 chars (no line smearing). Lossy
projection — round-trip mediator sees the original .ail.json and can
re-introduce a let when the mode model demands it.
Helpers `count_free_var` and `subst_var_with_term` are render-local;
both respect inner shadowing (let, lam, match arm pattern binders,
let rec). Pre-render value at current level + check for newline + len
budget; on inline, write_term_prec gets passed parent_prec so the
substituted term still wraps correctly inside binops.
Snapshot drift: rc_app_let_partial_drop_leak.prose.txt — boilerplate
`let p = build_pair(1)` collapsed into the match scrutinee. Improves
readability. Cons-tower in rc_own_param_drop kept its let thanks to
the length budget (52 chars > 40).
Tests: 59 unit (+ 9 new) + 4 snapshot, all green. Coverage:
inline-single-use, two-uses-keeps, zero-uses-keeps, Do-rhs-keeps,
multiline-rhs-keeps, length-budget-keeps, shadowing-not-counted,
reuse-as inline + braces.
Eleven binary builtins (+ - * / % == != < <= > >=) render as
lhs op rhs when called with two args (tail-flagged keeps prefix).
Standard Rust-aligned 4-level precedence table elides parens.
not(x) renders as !x. Long /// doc strings wrap at 80 cols on
word boundaries.
bench_list_sum: 'if ==(n, 0)' becomes 'if n == 0', '+(acc, h)'
becomes 'acc + h', tail keyword still visible on tail calls.
19 new unit tests (47 total). 4 snapshots (3 re-rendered + 1 new
bench fixture). Public API 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.
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.
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).
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.
The latency_harness.py harness spawns the bench binary on a PTY,
records monotonic_ns() per stdout line, and reports inter-arrival
gap distribution (median / p99 / p99.9 / max). Tail latency is
Decision 10's central real-time claim; total wall-time and RSS are
the wrong metrics for that question.
Paired fixtures: bench_latency_implicit (Boehm-fair, no mode
annotations, leaks under --alloc=rc) and bench_latency_explicit
(mode-annotated hot path, what RC was built for). Both use a
depth-19 balanced tree (~16 MB) as the persistent live working
set, plus per-op IntList build+sum churn forcing GC pressure
under Boehm.
Authored by ailang-bencher; ships evidence, not features.
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").
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).
Schema floor for explicit reuse hints. Adds Term::ReuseAs
{ source: Box<Term>, body: Box<Term> } with serde tag "reuse-as",
form-A (reuse-as <source> <body>). Schema choice (wrapper, not
modifier-on-Ctor) and rationale recorded in DESIGN.md.
Codegen is identity under all --alloc strategies — lower body,
drop source on the floor. Iter 18d.2 will lower this as the
in-place rewrite under --alloc=rc.
User-facing diagnostics:
- typecheck reuse-as-non-allocating-body: body must be a
Term::Ctor or Term::Lam, the two AST shapes that allocate.
Other shapes (literal, var, app, ...) emit this with a
suggested_rewrite that drops the wrapper.
- linearity reuse-as-source-not-bare-var: source must be a
Term::Var referring to an in-scope binder. Anything else
(literal, nested expression) is rejected here. Suggested
rewrite drops the wrapper.
- linearity use-after-consume at a reuse-as site: source must
not have been consumed earlier in the body. Suggested rewrite
drops the wrapper.
- Visiting reuse-as marks source consumed for the rest of the
body, so a subsequent use of the same binder flags
use-after-consume against it.
Uniqueness inference treats source as Consume — the consume
shows up in the side table for the codegen consumer.
Tests:
- 4 surface parse-tests (round-trip, no-args, one-arg, full
parse_term/term_to_form_a round-trip).
- 1 typecheck unit test (non-allocating body).
- 2 linearity unit tests (non-var source, after-consume).
- 1 integration test (happy-path map_inc in all-explicit mode
is linearity-clean).
- 1 E2E test running the canonical map_inc-via-reuse-as fixture
under --alloc=gc, asserting stdout 9.
- New fixture examples/reuse_as_demo.{ailx,ail.json}.
Test deltas: E2E 55 -> 56, ailang-check unit 43 -> 46,
ailang-check workspace 8 -> 9, surface 14 -> 18. cargo test
--workspace green. No existing fixture's canonical JSON changed.
Closes 18c.3's main scope cut. Codegen now emits a per-ADT and
per-closure drop function under --alloc=rc; the Term::Let
scope-close emission routes through these drops instead of
ailang_rc_dec directly, so a recursive ADT (List, Tree) under
--alloc=rc actually frees its tail cells when the outer binder
is dec'd.
Drop-symbol scheme:
- For every Def::Type T in module m: emit one
define void @drop_<m>_<T>(ptr %p) under --alloc=rc.
Body: null-guard, load tag, switch on tag, per-ctor arm dec's
every pointer-typed field via field_drop_call, then dec's the
outer cell and rets.
- For every escaping Term::Lam: emit drop_<m>_<lam>_env(ptr) for
the captured-free-vars block + drop_<m>_<lam>_pair(ptr) for the
{env, fn-ptr} closure pair. A new closure_drops side table on
Emitter keys closure-pair SSAs to their pair-drop symbol.
Decision (preempted in dispatch): always emit @drop_<m>_<T> for
every ADT, even ones with no boxed children. The body for
no-boxed-children ADTs is null-guard + dec + ret. Call sites are
uniform; future codegen changes that thread cleanup through the
drop seam (atomic dec under threading, etc.) have one canonical
entry per type.
field_drop_call dispatches on field type:
- Type::Con { name, .. } → drop_<owner>_<name> (recursion).
- Type::Str / Type::Fn / Type::Var → fall back to ailang_rc_dec.
These three fall-backs are 18d/18e/monomorphisation debt; they
are flagged in-source for grep.
Term::Let scope-close emission is the same as 18c.3 modulo the
target: Term::Ctor binders → drop_<owner>_<type>; Term::Lam
binders → the closure_drops-recorded pair-drop symbol. All
other emission gates from 18c.3 (consume_count == 0,
body-tail-not-binder, block-not-terminated, non-escape) are
unchanged.
Recursion is stack-recursive — drop_IntList's Cons arm calls
itself on the tail. For 18c.4's 5-element fixtures the depth is
safe; long lists are out of scope until 18e replaces the
recursive call with a worklist allocator. The recursive site is
commented for grep.
Tests:
- examples/rc_list_drop.ail.json — 5-element IntList summed via
sum_list (xs has consume_count == 1; codegen skips the let-
close drop call but the IR shape is locked in by the unit test
below).
- examples/rc_list_drop_borrow.ail.json — same 5-element IntList,
match-only consumption (consume_count == 0). Codegen emits
drop_<m>_IntList(xs) at scope close; the cascade runs over all
5 cells at runtime.
- crates/ail/tests/e2e.rs — alloc_rc_recursive_list_sum +
alloc_rc_borrow_only_recursive_list_drop. Both assert
--alloc=rc stdout matches --alloc=gc and the binaries exit
cleanly.
- crates/ailang-codegen/src/lib.rs — unit test asserting the IR
shape: define void @drop_rclist_IntList header, recursive
self-call inside, outer ailang_rc_dec at the tail; negative-
complement under --alloc=gc.
Tests: E2E 53 -> 55, codegen unit 11 -> 12. cargo test
--workspace green.
Ends the leak-everything era under --alloc=rc. Codegen now emits
ailang_rc_inc at every Term::Clone site and ailang_rc_dec at
end-of-scope of trackable RC-allocated let-binders. Default
--alloc=boehm path is byte-identical to before this iter.
Two parts:
(A) New module ailang-check::uniqueness — post-typecheck
dataflow producing UniquenessTable: BTreeMap<(def, binder),
UniquenessInfo>. consume_count is max-over-paths: at if/match
the walker saves state, walks each arm against the snapshot,
and merges by taking the per-binder max. Term::Clone is a
Borrow on its inner term — the explicit clone is the user's
signal that a fresh ref is being produced via inc.
(B) Codegen emission in ailang-codegen. Term::Clone emits inc
under --alloc=rc, gated on val_ty == "ptr" and
!val_ssa.starts_with('@') (top-level fn closure-pair globals
live in the LLVM data segment, not in runtime/rc.c's heap).
Term::Let emits dec at scope close iff the value is
rc_alloc'd (Term::Ctor / Term::Lam in escape set under
--alloc=rc), val_ty == "ptr", consume_count == 0 (no callee
or outer site already took ownership), body's tail SSA is
not the binder itself (caller-takes-ownership case), and the
block isn't already terminated.
Header declares of @ailang_rc_inc / @ailang_rc_dec are gated on
--alloc=rc so Boehm/Bump IR shape is unchanged.
Fixture examples/rc_box_drop.ail.json: single-cell Box(Int) ADT
where shallow free is sufficient (no boxed children). E2E test
alloc_rc_emits_dec_for_unique_let_bound_box runs it under both
gc and rc, asserts byte-identical stdout, clean exit, expected
output (42).
Scope cuts deferred to 18c.4: per-type drop fn / recursive dec
cascades for ADTs with boxed children (List, Tree still leak
their tails). Fn params have no dec emission yet (no static
"caller handed off ownership" signal under Implicit mode);
under explicit (own T) the signal exists but wiring is 18d.
Tests: E2E 52 -> 53, ailang-check unit 38 -> 43 (+5 uniqueness),
all other buckets unchanged. cargo test --workspace green.