Commit Graph

145 Commits

Author SHA1 Message Date
Brummel e580f75adf test: red for mono pass mis-resolving cross-module ctor patterns
`monomorphise_workspace` re-runs `synth` on every fn body to recover
residual class constraints; the env it uses is built by
`mono::build_workspace_env`, which delegates to `build_check_env` and
produces a workspace-flat `ctor_index` (every Def::Type ctor across
every module, keyed by bare ctor name → bare type name).

`check_in_workspace` (lib.rs:1247-1258) explicitly clears that flat
index after `build_check_env` and rebuilds it per-module so that
`Pattern::Ctor`'s local-first / imports-fallback resolution at
lib.rs:2486-2521 keeps the qualified-type-name comparison at
lib.rs:2526 intact: imports-fallback yields a qualified
`resolved_type_name` (`Mod.Type`), local-hit yields a bare one.

The mono pass never does the per-module overlay, so when a body in
module B pattern-matches a ctor `C` whose Def::Type lives in imported
module A, the workspace-flat index resolves `C` locally and yields the
bare type name. The scrutinee, however, was typed against the
qualified name, and the comparison fails with
`PatternTypeMismatch { ctor: "Cons", ty: "A.Type<...>" }`.

Surfaced by iter 23.2 Task 3, which adds `class Eq a` + Eq Int/Bool/Str
instances to the prelude. Pre-Task-3 the prelude is class-free, so
`workspace_has_typeclasses(ws) == false` and the mono pass early-outs
at `mono.rs:73`. Task 3 flips the gate; every workspace now traverses
bodies, which brings the latent bug to the surface for the
pre-existing `nested_ctor_pattern_first_two_sum` and
`std_either_list_demo` E2E tests.

Sibling regressions in the same family: `mono_xmod_qualified_ref.rs`
(env.imports not seeded), commit 13b36cc (env.globals not seeded for
self-recursive fns), commit 5c5180f (env.types / env.ctor_index not
seeded for user ADTs — the original "flat ctor_index" decision that
this bug now exposes as wrong-by-construction for cross-module ctor
pattern resolution).

The minimal fixture is two modules with five defs total:
test_mono_ctor_listmod (data List a = Nil | Cons a (List a)) and
test_mono_ctor_main (class Trivial a + instance Trivial Int + a fn
that pattern-matches Cons against the imported List<Int> + a main).
The test pins the inner cause: matches against the specific
`CheckError::PatternTypeMismatch` variant with the qualified
scrutinee type and bare ctor name, so it stays RED regardless of the
prelude's typeclass content.
2026-05-10 22:16:51 +02:00
Brummel aace5e3ce2 iter 23.1.4: E2E fixture — bare LT match via implicit prelude import 2026-05-10 21:32:05 +02:00
Brummel cce3d9738e iter 23.1.1: examples/prelude.ail.json — Ordering ADT skeleton 2026-05-10 21:11:06 +02:00
Brummel 2052f4dfcc fieldtest: floats — 4 examples, 6 findings (1 bug, 1 friction, 1 spec_gap, 3 working) 2026-05-10 16:57:38 +02:00
Brummel 9764b616ce floats iter 4.6: codegen io/print_float + examples/floats.ail.json E2E fixture 2026-05-10 16:25:25 +02:00
Brummel 51011511b4 iter 22-tidy.6.2: write_fn_def renders forall class constraints 2026-05-10 04:58:01 +02:00
Brummel 788c9808dd iter 22-tidy.6.1 fixup: cover abstract-method + instance-override branches; doc + fallback comments 2026-05-10 04:54:22 +02:00
Brummel d1c992d49a iter 22-tidy.6.1: write_class_def + write_instance_def — full Form-B projection 2026-05-10 04:47:50 +02:00
Brummel 2bf827f600 test: red for mono-pass unknown module prefix on qualified xmod reference in class workspace 2026-05-10 01:19:45 +02:00
Brummel 3b0bcf3f65 test: red for mono-pass unknown identifier on recursive fn in class workspace 2026-05-10 01:06:18 +02:00
Brummel 4cacfcbdac bench: mono-vs-vdisp micro-benchmark + revised Decision 11 rationale
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).
2026-05-10 01:03:21 +02:00
Brummel 59e86b3805 test: red for monomorphise_workspace unknown type on user ADT 2026-05-09 22:41:18 +02:00
Brummel 090f082215 iter 22b.3.tester: e2e for coherence, default-keyword, cross-module mono
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.
2026-05-09 21:36:15 +02:00
Brummel b59820b737 iter 22b.3.7: synthetic fixture — class+instance compiles, runs, prints 5 2026-05-09 21:28:41 +02:00
Brummel 7f260b82ad iter 22b.3.6: fix — quality-review (shadow-aware walker, cross-mod test, Option-typed targets, const helper) 2026-05-09 21:21:33 +02:00
Brummel d1b590ceab iter 22b.3.6: call-site rewrite — same/cross module qualified names 2026-05-09 21:07:58 +02:00
Brummel e8018d48b6 iter 22b.3.5: fix — quality-review (chained-call test, hoist class_index, drop noise comments) 2026-05-09 20:59:43 +02:00
Brummel dd87b36e00 iter 22b.3.5: workspace fixpoint loop — dedup + synth-append 2026-05-09 20:52:07 +02:00
Brummel 73965a6cbd iter 22b.2.e2e: cross-module class resolution + multi-fn aggregation
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_*).
2026-05-09 19:19:44 +02:00
Brummel 47bec74b26 iter 22b.2.10: fix — positive green-path test, drop dead let _ = ws
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.
2026-05-09 19:13:32 +02:00
Brummel 3600e82ec2 iter 22b.2.10: no-instance per-fn diagnostic 2026-05-09 19:07:46 +02:00
Brummel b741a8ba05 iter 22b.2.9: fix — superclass-walk tests, simplify class_superclasses map shape 2026-05-09 18:59:15 +02:00
Brummel 7cff741632 iter 22b.2.9: missing-constraint per-fn diagnostic 2026-05-09 18:51:39 +02:00
Brummel 17605a3327 iter 22b.2.8: register class methods in module globals 2026-05-09 18:33:01 +02:00
Brummel a546c5851c iter 22b.2.7: missing-superclass-instance diagnostic 2026-05-09 18:18:12 +02:00
Brummel a9aa248910 iter 22b.2.6: method-name-collision diagnostic 2026-05-09 18:07:52 +02:00
Brummel 634bd5eca3 iter 22b.2.5: overriding-non-existent-method diagnostic 2026-05-09 17:59:10 +02:00
Brummel 257b47625d iter 22b.2.4: constraint-references-unbound-type-var diagnostic 2026-05-09 17:54:20 +02:00
Brummel 4b84e781d4 iter 22b.2.3: invalid-superclass-param class-schema diagnostic 2026-05-09 17:50:29 +02:00
Brummel 928deb9d74 iter 22b.2.2: kind-mismatch class-schema diagnostic 2026-05-09 17:45:27 +02:00
Brummel 057784934a iter 22b.1.3: registry coherence-check fixtures + tests
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).
2026-05-09 12:40:06 +02:00
Brummel 75f7fda788 bench: 21'f — explicit-mode pair, full alloc+dec vs malloc+free
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.
2026-05-09 01:21:15 +02:00
Brummel 5a4a6de031 bench: 21'd — pure-compute fixtures + harness hardening
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.
2026-05-09 01:11:26 +02:00
Brummel 07bff24527 bench: 21'b — closure-pair + HOF/poly fixtures, 47-metric baseline
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.
2026-05-09 00:55:57 +02:00
Brummel 8375eb81ed prose: split reuse-as keyword + inline single-use lets (iter 20b polish)
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.
2026-05-08 23:03:10 +02:00
Brummel 50b68267fe check: mode-strict-because suppression (iter 19b)
Closes the 19a/19a.1/19b arc. Corpus signal from 19a.1 (5/65
fixtures fire over-strict-mode, all deliberate RC codegen-test
fixtures) justified shipping the suppress mechanism end-to-end.

Schema: Suppress { code, because } on FnDef. Pre-19b hashes
bit-identical via skip_serializing_if. Typechecker drops matching
diagnostics; empty 'because' is Error severity; wrong codes are
silent no-ops (open-set registry).

Form-A: (suppress (code "...") (because "...")) clause, parser
+ printer round-trip clean. Form-B: '// @suppress <code>: <reason>'
above the doc string, lossless contract metadata.

5 RC fixtures migrated (.ail.json + .ailx + .prose.txt). Corpus
signal: 5/65 -> 0/65. Lint still fires on any future fn that's
accidentally over-strict without an authored reason.

Test counts: ailang-check 55->61, ailang-core 26->28, ailang-surface
21->26, ailang-prose 49->52, e2e 70 unchanged.

Known debt: .ailx comment headers lost on regen (ail render's
contract excludes comments); parse_suppress_attr accepts
duplicate code/because attrs without diagnose (bounded by canonical
print order).
2026-05-08 19:36:04 +02:00
Brummel 9cf0e3e81c prose: infix + paren elision + doc-wrap + nested-match (iter 20b)
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.
2026-05-08 18:13:43 +02:00
Brummel a9d57c5c81 prose: human-readable projection renderer (iter 20a)
New crate ailang-prose with one public fn module_to_prose(&Module)
-> String. Rust-flavour with braces, =>-match-arms, mode keywords
(own/borrow), effects as trailing 'with IO', Cons(1, Nil) ctor
form, /// doc strings.

Lossy projection where the LLM can re-derive: (con T) wrap,
(fn-type ...) wrap, (term-ctor ...) wrap. Load-bearing semantic
detail stays visible (modes, effects, clone, reuse-as, doc strings,
type annotations, tail flag).

CLI: 'ail prose <file.ail.json>' prints the projection.

3 snapshot fixtures + 28 unit tests. 215-line .ail.json reduces
to ~18 lines of legible source.

20b queued: infix arithmetic, paren elision, let-inlining, do
prettify.
2026-05-08 18:06:16 +02:00
Brummel caefcf996a codegen rc: tag-conditional partial-drop helper closes 3 carve-outs
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.
2026-05-08 16:25:40 +02:00
Brummel 2e0006029b fix: let-alias-aware mode propagation through Term::Let
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.
2026-05-08 15:21:40 +02:00
Brummel 3113258680 tidy: 18g sub-arc — DESIGN ratification, latency bench, negative coverage
Resolves the architect's drift report on the 18g sub-arc:

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

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

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

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

The 18-arc is formally closed with this tidy. Next iter is
the orchestrator's Boehm-retirement decision (Path A vs Path B
from JOURNAL 2026-05-08 18f entry, joined by the post-18g.2
re-bench numbers).
2026-05-08 14:37:36 +02:00
Brummel 88045a485b fix: 18g.2 — let-binder drop for Own-returning App
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.
2026-05-08 14:25:07 +02:00
Brummel ae2eb2efac fix: 18g.1 — pre-tail-call shallow-dec for moved-from scrutinee outer cell
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.
2026-05-08 14:18:30 +02:00
Brummel ac70011352 bench: latency harness + paired latency fixtures
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.
2026-05-08 14:05:03 +02:00
Brummel e8c6e99a87 fix: 18d.4 Iter A — gate arm-close pattern-binder dec on scrutinee mode
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").
2026-05-08 13:56:17 +02:00
Brummel ce6ab8ee44 Iter 18e: (drop-iterative) annotation + worklist allocator
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.
2026-05-08 12:53:09 +02:00
Brummel ba63a16366 Iter 18d.4: pattern-binder + Own-param dec at scope close
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.
2026-05-08 12:33:48 +02:00
Brummel c96940ea96 Iter 18d.3: move-aware pattern bindings via codegen-side bookkeeping
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).
2026-05-08 12:18:12 +02:00
Brummel 74379b29e5 Iter 18d.1: Term::ReuseAs schema + linearity check
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.
2026-05-08 10:55:31 +02:00
Brummel 298dcaf0f7 Iter 18c.4: per-type drop fn + recursive dec cascade
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.
2026-05-08 10:37:25 +02:00