Adds DESIGN.md "Feature-acceptance criterion" as a top-level section:
a feature ships only if (1) an LLM author naturally produces code
that uses it, and (2) it measurably improves correctness or removes
redundancy. Aesthetic appeal and human ergonomics do not count.
Mirrored in CLAUDE.md as "Feature acceptance: LLM utility", paired
with the existing "Design rationale != implementation effort". The
two together narrow valid feature rationales to one thing: what the
LLM author gets out of the feature.
Trigger: the typeclass-design conversation around 22a. Rule was
implicit in many past decisions (Decision 1's JSON-over-text choice,
Decision 10's "what LLMs are good at" reasoning) but never stated
as a feature-gate. Codifying it now means future feature proposals
get evaluated against an articulated criterion instead of being
re-derived each time.
Documentation-only; no Rust, schema, or bench changes. Test state
288/0/3 unchanged.
Three drift items from ailang-architect, plus one false-positive
surfaced during verification:
1. DESIGN.md silent on closure-pair 4.14x finding (21'b).
Decision-10 ratified: "Workload scope of the 1.3x target"
paragraph scopes the retirement gate to linear/tree/poly-ADT
workloads; closure-pair carve-out documented as
representational cost (closure cell + env struct = 2 allocs
per step) until a slab/pool answer ships.
2. bench/compile_check.py corpus drift. Three fixtures added
(bench_compute_intsum, bench_compute_collatz,
bench_list_sum_explicit), re-baselined. Now 12 fixtures x
2 ops = 24 compile-time metrics.
3. baseline.json convention not codified. Note field gains
"max-of-distribution gets wider band than percentile"
convention discovered in 21'd.
4. (verification finding) bench_compute_intsum cross_lang
tolerances at 15%/12% fire on subprocess-spawn jitter for
sub-millisecond fixtures. Widened to 35% across all five
intsum metrics; convention recorded in baseline_cross_lang
note field (sub-ms fixtures need looser bands).
All three bench gates re-run sequentially after edits:
bench/check.py — 63 metrics; 63 stable
bench/compile_check.py — 24 metrics; 24 stable
bench/cross_lang.py — 25 metrics; 25 stable
Total: 112 metrics under regression coverage, all green.
288 tests passing, 3 ignored. No Rust changes.
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 question CLAUDE.md has carried since day one ("LLVM-
linkable, performance is extremely important") with data. Hand-C
variants of the four bench fixtures, compiled with clang -O2,
each carefully matching the AILang algorithm and explicitly
documenting representation differences (cell width, leak policy)
that affect the ratio.
Three substantive findings:
1. Pure-compute parity with C: bench_compute_collatz runs at
AILang/C = 0.99x across both allocators. AILang's IR composes
with LLVM's optimizer at the same level a hand-C source does.
This is the LLVM-linkable performance claim, backed by data
for the first time. bench_compute_intsum (1.05-1.18x) confirms.
2. AILang bump beats glibc malloc 2x on linear allocation:
bench_list_sum.bump/c = 0.50x. Bump's two-instruction inline
fastpath outperforms glibc's free-list-managed malloc on
no-free workloads. Quantitatively measured for the first time.
3. RC overhead vs C malloc quantified: bench_list_sum.rc/c =
1.49x, bench_tree_walk.rc/c = 2.61x. The 8-byte refcount
header + zero-init + libc backing add 50-160% over glibc
malloc on these implicit-mode workloads. Explicit-mode + a
free()-adding C variant (21'f, queued) will close the
apples-to-apples gap on dec-cost.
CLAUDE.md updated to list bench/cross_lang.py as the third
tidy-iter gate alongside bench/check.py and bench/compile_check.py.
20 new metrics in bench/baseline_cross_lang.json with 12-15%
tolerances (cross-language ratios are inherently noisier than
within-AILang ratios — two compiler stacks contribute variance).
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.
Closes the second axis the user named: every typechecker / codegen
perf change was previously invisible to the tidy-iter gate. With
Family 21 typeclasses (queued) and 21'b's poly-ADT additions both
pushing on the typechecker, naive substitution loops would have
landed silently and decayed the compile path.
bench/compile_check.py is a separate script from bench/check.py
because the methodology differs: sub-process spawn timing on small
workloads (1ms scale for `ail check`, 65ms for `ail build`) vs.
allocator-stress on large ones (multi-second). Tolerances differ
by an order of magnitude (25% / 20% here vs. 5-15% there).
Empirically: ail check is sub-ms across the corpus, dominated by
subprocess spawn (~5-10ms on Linux); ail build is 63-69ms,
dominated by clang's link step. The bench is a catastrophe
detector (10x slowdowns visible) — finer regressions need a
profiler. CLAUDE.md updated to list both scripts as co-equal
tidy-iter gates alongside the architect drift report; exit 0/1/2
semantics are uniform across both.
JOURNAL queue: 21'd (pure-compute fixtures) and 21'e (cross-
language reference / hand-C ratio) remain to land the LLVM-
linkable performance claim.
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.
Adds a "Performance regressions" subsection to the iter-cycle
discipline. bench/check.py runs at every family close alongside
the architect drift report; its exit code is the gate. Skipping
it requires the same explicit JOURNAL entry as skipping the
drift review.
Codifies the discipline that 21'a's JOURNAL entry recommended
and explicitly punted to the orchestrator. The tooling shipped
yesterday; the rule that makes it load-bearing ships now.
Also clarifies two non-goals: per-metric tolerances are not the
language correctness bar (Decision-10 thresholds remain in
DESIGN.md, evaluated against absolute numbers); and improvements
do not auto-update the baseline — they surface for ratification.
Closes the structural gap between bench/run.sh (one-shot capture
into JOURNAL) and a continuous tripwire. baseline.json records 31
metrics (16 throughput, 15 latency) with per-metric one-sided
tolerances tuned to absorb run-to-run noise on a quiet developer
machine; check.py spawns run.sh, parses both the throughput
pipe-table and the per-arm latency stanzas, diffs against the
baseline, prints a per-metric report, and exits non-zero on any
regression beyond tolerance.
User-facing flags: --from-file, --stdin, --baseline, and
--update-baseline (re-run + overwrite baseline.json after
intentional improvements).
Validation: captured the baseline, then re-ran end-to-end. All 31
metrics within tolerance. The harness also caught a single-capture
explicit-rc p99 spike (357.5 us) that was first read as drift vs.
yesterday's 18g.tidy.fu2 numbers but came in at 294.6 us on the
follow-up run — exactly the kind of noise the tolerance band is
there to absorb. Without 21'a we would have either chased a
phantom or buried the signal; with it, single noisy runs are data
points, not verdicts.
Tidy-iter discipline addition (run check.py at every family close
alongside the architect drift report) is recommended in JOURNAL
but not enacted in this iter — that's an orchestrator-level
update to CLAUDE.md.
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.
Architect drift review after Family 20 surfaced four items; (1) and
(2) are doc drift fixed here, (3) and (4) are recorded as acceptable.
DESIGN.md changes:
- Data model section rewritten from "Data model (MVP)" claiming
only fn/const exist to "Data model" reflecting all three Def
kinds. Added the missing schema fields the architect named:
`tail` flag on app/do, the `seq` / `clone` / `reuse-as` / `letrec`
terms, `paramModes` / `retMode` on Type::Fn, `suppress` on FnDef,
`drop-iterative` on TypeDef, `args` on Type::Con, `vars` on
TypeDef. Each additive field's hash-stable serialisation is
called out once at the top so individual mentions stay terse.
`Suppress`, `ParamMode`, `Literal`, `Pattern` get their own
sub-blocks.
- Pipeline diagram now shows `--alloc=gc` (links libgc;
transitional) and `--alloc=rc` (emits ailang_rc_inc / _dec;
canonical) as two backends sharing the same MIR.
- Ecosystem inventory gained a "Surface forms" bullet for
ailang-surface (Iter 14c, lossless Form-A printer/parser) and
ailang-prose (Family 20, lossy Form-B projection). CLI bullet
now lists parse, render, prose, merge-prose.
JOURNAL entry records the architect findings, the resolutions for
items (1) and (2), and the acceptable-drift status of (3)
FnDef::synthetic factor-out (defer to next schema-additive
FnDef field) and (4) linearity.rs spurious warning (not
corpus-triggered).
Pure documentation iter — no code changes. 288 workspace tests
unchanged and green.
Closes a design hole shipped in 20d: the merge-prose prompt instructed
the LLM to emit JSON-AST and gave a 12-line schema-essentials reminder.
JSON-AST is the canonical hashable artefact, not a writing surface; the
reminder was not a language spec. Foreign LLMs had no realistic shot.
20f makes two coupled changes:
1) The LLM now emits Form-A (the canonical authoring surface fixed by
Decision 6). merge-prose loads the original via ailang_core::load_module
and re-renders via ailang_surface::print before embedding (round-trip
is a gating contract on the surface crate, so this is lossless). The
user runs `ail parse foo.new.ailx` to recover JSON, then `ail check`.
2) crates/ailang-core/specs/form_a.md is the complete LLM-targeted
Form-A specification — grammar, every term/pattern/type/def keyword,
schema invariants, pitfall catalogue, four few-shot modules from
examples/*.ailx. Exported as ailang_core::FORM_A_SPEC via include_str!
and embedded verbatim in every merge-prose prompt.
Drift detection in crates/ailang-core/tests/spec_drift.rs: every variant
of Term, Pattern, Type, Def, Literal is reached via exhaustive `match`.
The arms are not the assertion — adding a new variant without updating
the match is a compile error before the test runs. Once matched, an
anchor string is asserted to appear in FORM_A_SPEC. 8 tests, all green.
The hand-written + mechanical-drift-test combo addresses the user's
"distance to code is too big" concern about a docs-only spec. Generator
overkill rejected: structural drift is mechanically caught, but prose
explanation, schema-invariant catalogue, pitfall list, and few-shot
corpus cannot be emitted from AST shape alone.
Tests:
- ailang-core: +8 spec_drift tests; existing 12 unchanged
- ail unit (3): rewritten in lockstep — assert (own T)/(effects IO)
landmarks, FORM-A SPECIFICATION header, FORM_A_SPEC body verbatim
- ail e2e merge_prose_prints_framed_prompt: rewritten — assert
`(module foo` + `FORM-A SPECIFICATION` instead of `ailang/v0`
- Workspace: all green
Richer integration paths (LLM tool-use, MCP server, LSP) were named
in the design discussion and deferred per "kiss". All three layer
additively on the static-prompt path; static prompt remains the
lowest-common-denominator fallback.
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.
Per CLAUDE.md tidy-iter at family boundaries. ailang-architect
flagged the canonical 'DESIGN.md silent' finding for the entire
19a-arc — same shape as family-20's 20e tidy.
Decision 10 schema-additions block gained 'Iter 19b —
FnDef.suppress' entry. New subsection 'Advisory diagnostics —
Iter 19a-arc' documents the over-strict-mode lint rule (incl.
heap-type filter rationale), Severity::Warning introduction, CLI
exit-on-Error gating, and the suppress mechanism. New subsection
'Why advisory + suppress instead of inference' pins the three
substantive reasons (annotation-states-intent, drift-bremse,
LLM-authoring forcing function) so future iters can't reopen the
decision without engaging the recorded rationale.
Migration plan extended with step 7 covering 19a/19a.1/19b.
Decision 10's mandatory-annotation rule is explicitly reaffirmed
as unchanged.
No code changes. Two architect findings deferred (codegen FnDef
fan-out, snapshot-fixture coupling). Data model (MVP) drift
predates 19b — separate iter.
Corpus-signal upgrade: orchestrator ran 19a's lint across 65
fixtures, zero warnings fired. The conservative match-scrutinee
carve-out filtered every (own T) fixture because every such body
destructures the param via match. Real-corpus signal warranted
the precise variant.
Precise rule: for an Own param with consume_count == 0, walk each
match arm whose scrutinee is the param; if any HEAP-TYPED
pattern-binder has consume_count > 0, stay silent (sub-consume
forces ownership). Otherwise fire.
Heap-type filter is the implementer's design call (not in the
brief): reading a primitive (h: Int) is load-by-value, no RC, no
heap-data move; reading heap-typed t: IntList IS a pointer move
requiring outer-cell ownership. Documented in module-doc.
Corpus signal after upgrade: 5/65 fixtures fire — all RC
codegen-test fixtures with deliberate over-strictness for the
codegen path. Justifies 19b (mode-strict-because suppression).
ailang-check test count: 53 → 55. e2e + everything else green.
Known debt: deeply-nested-match-on-sub-binder (no fixture hits).
When greedy word-wrap leaves a 1-word last line, pull the
penultimate line's tail word down so the orphan has at least
2 words. Skipped if the combined length would exceed budget
(invariant takes priority over cosmetics).
Visible on nested_let_rec.prose.txt:
before: '...captures outer's param\n/// i.'
after: '...captures outer's\n/// param i.'
Two new unit tests; existing 4 snapshots unaffected (none had
a 1-word orphan).
Family-20 tidy-iter: ailang-architect drift review surfaced
DESIGN.md silence on the new prose surface as the highest-leverage
gap. Decision 6 now has a 'Form (B) — human prose projection'
subsection naming the lossy-vs-lossless contract, the no-parser-
by-design choice, and the LLM-mediated round-trip. CLI block
gained 'ail prose' and 'ail merge-prose'.
No code changes. Form (B) does not weaken any Decision 6
invariant: JSON-AST remains the only hashable artefact, form (A)
the canonical authoring surface, 30-production grammar unchanged.
Two architect findings deferred (prompt-template lockstep test;
snapshot-fixture cross-family coupling). Family 20 closes.
JOURNAL queue: empty.
Closes family 20: prose-edit cycle is end-to-end. ail merge-prose
<original.ail.json> <edited.prose.txt> composes a shaped LLM prompt
to stdout. User pipes to their LLM (Claude/etc.), gets new .ail.json,
runs ail check, saves.
No built-in API client — AILang stays a compiler + tooling, the LLM
mediator is supplied externally. PROSE_ROUNDTRIP.md documents the
6-step cycle, the prompt contract (preserve modes/effects/tail
flags from original), failure modes, and the design choice to omit
an API client.
Family 20 now closes: 20a renderer + 20b polish + 20d mediator
shipped; 20c rolled into 20a. Three deferred polishes (let-inlining,
print sugar, doc-wrap widow control) wait for corpus signal.
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.
When a fn-param is annotated (own T) but the body never consumes
it (consume_count == 0) and never destructures it via match, the
linearity pass now emits a Severity::Warning diagnostic with a
form-A-rendered (borrow T) rewrite as suggested_rewrite. Pure
advisory, Decision 10 unchanged.
First Warning-severity diagnostic the typechecker emits; three
CLI exit paths gated on Error-only.
JOURNAL: design entry + iter 19a shipping entry.
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.
Records the four-phase reorganisation that moved 2470 lines
out of lib.rs into purpose-named sibling submodules. Captures
the visibility model (descendant-module privacy + selective
pub(crate)), the deliberate non-extractions (lower_term, the
app/call cluster), and the framing as a navigability tidy
rather than a feature-driven change.
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.
The Telegram-via-PushNotification-tool path is replaced with a
direct ~/.claude/notify.sh "Text" invocation. Same payload
discipline (actionable one-liner, skip 'hi, I' framing); the
delivery channel is now under the user's control. Tested in the
previous session.
Closes two of the three queued debts from the 18g tidy in the
same session. Tag-conditional partial-drop helper stays queued
— substantial, no observable fixture, all current fallback paths
cleanly use shallow ailang_rc_dec.
The 18-arc + 18g sub-arc + 18g tidy + post-tidy follow-ups are
all closed. Decision 9 has been re-framed to match the
orchestrator's dual-allocator stance (commit f10a77e). Next iter
is queue-driven; nothing load-bearing is open.
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.
Closes the architect's tidy follow-up item on bench-number
methodology. The harness now accepts --runs N (default 1, byte-
identical output for back-compat); with N>=2 it reports
median + min..max per cell across runs, with N>=4 it drops the
slowest run before aggregating to match bench/run.sh's drop-
slowest throughput convention.
bench/run.sh now invokes the harness with --runs 5 for each of
the three latency arms, so a single bench/run.sh run produces
both a regression-stable throughput table and a regression-stable
latency table.
The qualitative claims from JOURNAL 2026-05-08 (RC tail latency
23x better than Boehm; RC RSS lower than Boehm) hold at the new
stat-of-5 confidence level — a 5-run smoke on the explicit-rc arm
shows p99=296µs (range 289-311), p99/median=1.31x (range 1.28-1.37).
Variance is well below the signal.
The 18-arc validated the RC pipeline end-to-end; the orchestrator's
call (JOURNAL 2026-05-08) was to keep Boehm as default and RC as
the ready alternative rather than retire Boehm. Decision 9's
'transitional Boehm' framing was wrong shape for that policy —
this edit re-casts it as 'dual allocator: Boehm GC for general
workloads, RC for real-time-sensitive workloads', preserving the
rest of the section.
Decision 10 holds as the specification of the RC alternative; this
edit only changes the framing of how the two allocators relate.
User's stance on the retirement question raised in 18f / 18g.2:
keep the dual-allocator setup. Boehm = default, RC = validated
alternative ready to flip. Re-opening trigger is operational
(maintenance cost), not performance.
Concrete consequences recorded: no default flip, -lgc stays, and
Decision 9's 'transitional' framing should be re-cast as 'dual
allocator' in a follow-up DESIGN.md edit (queued). Decision 10
holds as the canonical real-time path. The three RC-side debts
from the 18g tidy remain queued and don't block the dual stance.
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).
Re-runs the latency bench post-18g.2 and records the result:
RC p99 = 311.6 µs (1.37× median), max = 348.2 µs (1.53× median);
Boehm p99 = 7249 µs (69.65× median), max = 7923 µs. RC is 23×
better on p99 and 23× better on max. RSS: RC 42 MB vs Boehm
66 MB — RC is now LOWER RSS, not higher. Median throughput: RC
is 2.18× slower (alloc-path tax).
Decision 10's real-time commitment has its evidence base. Boehm
retirement is now an orchestrator call between (A) 'retire,
accept the throughput tax until a slab allocator is built' and
(B) 'build the slab allocator first, retire after'. No commit
yet flips defaults.
Documents the 18-arc as complete on the correctness property:
explicit-mode RC reports live=0 on the canonical bench fixture
(allocs=11068575 frees=11068575). Remaining open work is
orchestrator-territory or queued (tag-conditional partial-drop,
let-alias-aware mode propagation, tidy-iter).
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.
Records the diagnosis (18f.2 bencher finding -> tail-call drop
elision in match arms whose body consumes pattern-binders into
the tail-call), the new pre-tail-call seam in lower_match, the
opt-in rc-stats counter test infrastructure (18g.0), the TDD
record, and the end-to-end verification on bench_latency_explicit
(11M -> 1M live cells; the 1M residue is the persistent Tree
cache and queues as 18g.2).
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.
Two non-atomic uint64_t counters in runtime/rc.c, incremented from
ailang_rc_alloc and the to-zero branch of ailang_rc_dec. An
__attribute__((constructor)) registers an atexit handler IFF the
AILANG_RC_STATS env var is non-empty at startup; the handler prints
ailang_rc_stats: allocs=N frees=M live=K
to stderr. Default-disabled so production binaries stay quiet.
Used by the e2e test infrastructure for assertions about RC
correctness (e.g. tail-recursive list-sum must not leak outer cells)
and by the bencher / debugger when diagnosing leak shape from a
fixture run. Single-threaded; non-atomic — same scope as the rest
of runtime/rc.c. The two unconditional increments on the hot paths
are negligible relative to the libc malloc/free already there.
Bencher ran the tail-latency bench post-RC-fix. Boehm's p99=7131µs
is 74× its median (96µs); explicit-mode RC's p99=453µs is 1.62× its
median (280µs). Decision 10's real-time claim has a first piece of
evidence on the correct axis (tail latency) — 18f's throughput
bench measured the wrong thing.
Surprise finding: explicit-mode RC leaks at the same RSS rate as
implicit-mode RC (511MB peak on both). The (own ...) +
(reuse-as) + (drop-iterative) annotations don't drive deallocation
in tail-recursive arms because pattern-binder t is consumed into
the tail-call (consume_count > 0, Iter A skips); the outer LCons
husk has all its ptr fields moved out but no drop site shallow-decs
it. Genuine 18d.4 implementation gap, queued as Iter 18g (#82).
Retirement decision (Path A vs Path B from 18f) still open; now
joined by the outer-cell-shallow-dec finding. Both feed the
eventual decision.
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").
Bug fixes are red-then-green: write a failing test that pins the
symptom, make the smallest change that turns it green, keep the
test as a permanent regression. No fix without a test.
Bug fixes do not need orchestrator permission — when a bug is
unambiguous (build broken, fixture crashes, refcount underflow,
observable wrong output), the fix is autonomous orchestrator work.
The user directs features and priorities; bug fixes are mechanical
work that ships when the bug is found.
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.
User wired up Telegram push notifications. New subsection under
"Direction freedom" pins down the discipline: only notify when
something is genuinely wanted from the user — exactly the
existing bounce-back triggers (real design judgement, genuinely
unexpected event, explicit checkpoint). Never for routine
progress or FYI updates.
The notification is the exception, not the rule. Default mode
stays autonomous. The notification body is the actionable
summary, one short line, phone-screen friendly.
The 18f bench result (rc/bump = 2.86x but gc/bump = 2.90x) showed
the existing bench measures alloc-path cost, not the determinism
question Decision 10 actually commits to. The user's framing
made this explicit: RC has to be DEMONSTRABLY better than Boehm —
the relevant axis is per-operation latency under heap pressure
(Boehm's stop-the-world vs RC's amortized constant), not total
wall-time.
This agent's mandate: design workloads against a falsifiable
hypothesis, run measurements that fit the hypothesis (latency
distribution for determinism, not throughput), pair Implicit-mode
fixtures (Boehm-fair) with explicit-mode fixtures (RC-fair) so
each allocator is benched in its native regime, and report
honestly what the data does NOT say.
Distinguished from implementer: ships evidence, not features.
Distinguished from architect: focuses on a single dimension
(memory) and on empirical measurement, not drift review.
Honesty rules built in:
- Tie => bench did not distinguish, not "they are equivalent".
- Implicit-mode RC numbers are not informative (RC leaks under
Implicit) — call out whenever reported.
- Bench-only instrumentation in runtime/rc.c must be marked
removable and not leak into production.
- No verdict statements: orchestrator decides defaults / drops.
Records the bench numbers (rc/bump 2.5–2.9x on the Implicit-mode
fixtures, missing Decision 10's 1.3x retirement target) and the
two paths forward — lower the threshold (Path A, pragmatic; rc
and gc are within 5% of each other so retirement under "RC ≤
Boehm ± 5%" qualifies) vs build a slab/pool allocator first
(Path B, larger iter, closes most of the gap to bump).
The 1.3x criterion was orchestrator-level commitment in
Decision 10 and not meeting it is a prompt for an orchestrator-
level discussion, not for the implementer to autonomously flip
the default. 18f deliberately stops without flipping default,
without dropping -lgc, without marking Decision 9 historical.
Adds rc to the modes list and an rc/bump ratio column in the
results table. The rc/bump ratio is the decisive number for
Decision 10's Boehm-retirement threshold (target 1.3x).