Commit Graph

95 Commits

Author SHA1 Message Date
Brummel 5b635760b9 JOURNAL: Iter 18e — (drop-iterative) annotation + worklist allocator
Records the schema additions (TypeDef.drop_iterative, serde-skip
when false), why heap-stretchy-buffer worklist was chosen over
slot-repurposing (not every box has a free ptr-slot to thread
through), the mono-typed worklist trade-off (simpler loop body,
small cross-type cascade penalty), the four new ABI symbols in
runtime/rc.c, and the validation against Decision 10's brief.

Validation note: hand-run confirmed the 1M-cell fixture with the
annotation exits 0; same fixture without the annotation SIGSEGVs
(exit 139). The annotation is load-bearing, not cosmetic.

Closes with the dispatch line for 18f (RC validation bench +
Boehm retirement decision) and reaffirms the post-18f tidy-iter
will be ailang-architect-driven drift cleanup over the whole
18-arc surface.
2026-05-08 12:54:23 +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 5e401a3520 JOURNAL: Iter 18d.4 — pattern-binder + Own-param dec at scope close
Records the two emission seams (pattern-binder at arm close,
Own-param at fn return), why they fit one iter (uniform
emission shape), the explicit dynamic-tag partial-drop fallback
to shallow ailang_rc_dec when moved_slots is non-empty for a
dynamic-runtime-ctor binder, the side effect on reuse_as_demo's
sum_list (correctness improvement, stdout unchanged, existing
assertions still hold), and the closing of 18d.3's regression.

Notes that 18d.3 + 18d.4 together close the move-aware-pattern
story for the static case; dynamic-tag partial-drop is the
remaining hygiene gap and is structurally orthogonal (would
exist even without moves). 18e (worklist) may be the natural
place to also close the dynamic-tag fallback.
2026-05-08 12:34:52 +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 8e415dd4b4 JOURNAL: Iter 18d.3 — move-aware pattern bindings (static tracking)
Records strategy (b) (codegen-side bookkeeping, no source
mutation), why strategy (a) was retired (borrow contracts +
desugar re-scrutinise), the moved_slots side table and its
consumers (Term::Let close + reuse arm), and the narrow
memory-hygiene regression on borrow_only_recursive_list_drop
that 18d.4 will close. Also flags 18d.3 as the first iter to
ship with an undetected (no valgrind in CI) memory-hygiene
regression — accepted with explicit follow-up iter, not
papered over.
2026-05-08 12:19:17 +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 509c9700bf JOURNAL: Iter 18d.2 — codegen reuse-as in-place rewrite
Records the runtime refcount-1 dispatch design (Lean 4 / Roc
lineage), why the dispatch lives at codegen rather than in
runtime/rc.c (per-site decision, no new ABI), the new
reuse_shape pre-codegen pass with its five reason sub-codes,
and — crucially — the explicit field-cleanup scope cut. The
reuse arm does not dec old pointer-typed fields because the
canonical fixture's pattern-bound subterms alias the new field
values (`(reuse-as xs (Cons _ (map_inc t)))` returns t's
in-place-rewritten box as the new tail). The root cause is
upstream: pattern-matching does not null out source slots. 18d.2
ships the perf-only half (alloc + outer cascade elided);
18d.3 / 18e closes the leak with move-aware patterns.
2026-05-08 11:22:21 +02:00
Brummel 73545ab086 Iter 18d.2: codegen reuse-as in-place rewrite under --alloc=rc
Lowers Term::ReuseAs { source, body=Term::Ctor } under
--alloc=rc to a runtime refcount-1 dispatch. At the reuse-as
site:

  %hdr_ptr = getelementptr i8, ptr %src, i64 -8
  %refcnt  = load i64, ptr %hdr_ptr
  %is_one  = icmp eq i64 %refcnt, 1
  br i1 %is_one, label %reuse, label %fresh

The reuse arm overwrites the source's box in place: stores the
new tag at offset 0 and the new field values at offsets 8, 16,
... — no malloc, no outer drop. The fresh arm allocates a brand-
new box via ailang_rc_alloc, stores tag and fields, then shallow-
dec's the source. Both arms phi-join to the same `ptr` result.

Other allocators (gc, bump) keep 18d.1's identity behaviour
unchanged.

Static shape compatibility is enforced by a new pre-codegen
pass crates/ailang-check/src/reuse_shape.rs. It tracks the
path-resolved ctor of every let-bound and pattern-matched
binder, then checks each Term::ReuseAs site against the body's
ctor. Mismatches emit reuse-as-shape-mismatch with stable
ctx.reason sub-codes (field-count-mismatch, field-type-mismatch,
indeterminate-source-ctor, cross-module-body-ctor, ctor-not-in-
module) and a drop-the-wrapper SuggestedRewrite. Build fails on
mismatch — the structured diagnostic tells the author whether
to fix the shapes or remove the hint.

Field-cleanup design — deliberate scope cut:

The reuse arm does NOT dec the old pointer-typed field values
before overwriting them. Doing so on the canonical fixture
`(reuse-as xs (Cons (+h 1) (map_inc t)))` causes use-after-free:
the recursive map_inc(t) returns t's in-place-rewritten box, so
the "new tail" being stored is the same pointer as the "old
tail". A dec-old-then-store-new schedule would dec the box (to
zero, free), then store the freed pointer.

The root cause is upstream: 18c.4 doesn't yet null-out pattern-
moved field slots, so codegen can't statically distinguish
"old field still owned" from "already moved out". The chosen
trade-off: skip the field dec entirely; pattern-wildcarded
fields (rare in shipping fixtures) leak; pattern-moved fields
are the body's responsibility (consistent with the 18c.4 leak
baseline — Own params still leak today). 18d.2 ships zero
regression vs 18c.4, plus the perf win on the alloc + outer
cascade. Closing the leak is the move-aware-pattern story
queued for 18d.3 / 18e.

Tests:
- e2e reuse_as_demo_under_rc_uses_inplace_rewrite — runs
  examples/reuse_as_demo.ail.json under --alloc=rc, asserts
  stdout 9 and locks in the IR shape (icmp eq i64 ..., 1; the
  reuse arm has no @ailang_rc_alloc call).
- workspace reuse_as_shape_mismatch_is_reported_on_cons_to_nil
  — happy negative for the new diagnostic.
- 3 unit tests in reuse_shape::tests covering same-ctor-clean,
  cons-to-nil-reject, and indeterminate-source-ctor-reject.

Test deltas: e2e 56, ailang-check unit 46 -> 49 (+3), workspace
9 -> 10 (+1). cargo test --workspace green. Hand-verified
--alloc=rc binary on reuse_as_demo: stdout 9, exit 0.
2026-05-08 11:21:02 +02:00
Brummel 0bf1a2fa1c JOURNAL: Iter 18d.1 — Term::ReuseAs schema + linearity check
Records the schema floor (Term::ReuseAs wrapper, identity
codegen), the three diagnostics that close the
schema-permits-meaningless-forms gap (reuse-as-non-allocating-
body, reuse-as-source-not-bare-var, use-after-consume), and the
"source-not-bare-var rule lives in linearity, not typecheck"
design point — that constraint is a use-rule about reuse
semantics, not a type-rule, so it sits in the linearity pass
where it's gated on the all-explicit-mode activation. Closes
with the dispatch note for 18d.2 (in-place rewrite under
--alloc=rc, plus reuse-as-shape-mismatch diagnostic).
2026-05-08 10:56:30 +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 8f40e8c31f DESIGN: substantive rationale for Term::ReuseAs wrapper form
The schema sketch in Decision 10 already committed to
Term::ReuseAs { source, body } as a wrapper, but did not record
WHY the wrapper form was chosen over a reuse_from: Option<String>
modifier on Term::Ctor. Adds the two substantive reasons:

- Compositional flexibility: wrapper generalises to record
  literals, opaque box wrappers, capability cells — anything
  future iters might add as an allocating construct. Modifier
  would replicate per Term variant.

- Source-locality: (reuse-as SRC NEW-CTOR) reads as a sentence
  with the source-binder at the head. Modifier scatters intent.

The trade-off accepted: schema permits Term::ReuseAs around a
non-allocating body. Caught at typecheck via
reuse-as-non-allocating-body diagnostic. Same shape as
Decision 7's Term::If composability accepts.

Recording before 18d.1 ships — per CLAUDE.md "design rationale
ne implementation effort", a schema choice has to name its
substantive reasons before code is written, not retroactively.
2026-05-08 10:40:35 +02:00
Brummel 6511838b58 JOURNAL: Iter 18c.4 — per-type drop fn + recursive dec cascade
Records the drop-symbol scheme (uniform @drop_<m>_<T> for every
ADT, even no-boxed-children ones; pair+env drop fns for escaping
closures), field_drop_call's per-type dispatch and the three
fall-back cases (Str / Fn / Type::Var), the unchanged Term::Let
emission gates, and the explicit deferral of stack-recursion to
18e's worklist allocator. Also lists the four known-debt items
(stack-recursive drop, closure-typed ADT fields, Type::Var
fields, fn parameters) so 18d's design surface is clear.
2026-05-08 10:37:34 +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
Brummel b4cddd830e JOURNAL: Iter 18c.3 — uniqueness inference + non-recursive RC inc/dec
Records the inference's max-over-paths semantics, the codegen
gates (consume_count == 0, body-tail-not-binder,
block-not-terminated, non-escape membership), the @-prefix elide
on closure-pair globals, the rc_box_drop fixture's choice of a
no-boxed-children ADT (shallow free sufficient), and the explicit
scope cut for per-type drop fn (18c.4). Closes with the dispatch
note for 18c.4 and the reminder that the new CLAUDE.md tidy-iter
rule fires after 18f when the 18-arc closes.
2026-05-08 10:25:30 +02:00
Brummel 307a3ea848 Iter 18c.3: uniqueness inference + non-recursive RC inc/dec
Ends the leak-everything era under --alloc=rc. Codegen now emits
ailang_rc_inc at every Term::Clone site and ailang_rc_dec at
end-of-scope of trackable RC-allocated let-binders. Default
--alloc=boehm path is byte-identical to before this iter.

Two parts:

(A) New module ailang-check::uniqueness — post-typecheck
    dataflow producing UniquenessTable: BTreeMap<(def, binder),
    UniquenessInfo>. consume_count is max-over-paths: at if/match
    the walker saves state, walks each arm against the snapshot,
    and merges by taking the per-binder max. Term::Clone is a
    Borrow on its inner term — the explicit clone is the user's
    signal that a fresh ref is being produced via inc.

(B) Codegen emission in ailang-codegen. Term::Clone emits inc
    under --alloc=rc, gated on val_ty == "ptr" and
    !val_ssa.starts_with('@') (top-level fn closure-pair globals
    live in the LLVM data segment, not in runtime/rc.c's heap).
    Term::Let emits dec at scope close iff the value is
    rc_alloc'd (Term::Ctor / Term::Lam in escape set under
    --alloc=rc), val_ty == "ptr", consume_count == 0 (no callee
    or outer site already took ownership), body's tail SSA is
    not the binder itself (caller-takes-ownership case), and the
    block isn't already terminated.

Header declares of @ailang_rc_inc / @ailang_rc_dec are gated on
--alloc=rc so Boehm/Bump IR shape is unchanged.

Fixture examples/rc_box_drop.ail.json: single-cell Box(Int) ADT
where shallow free is sufficient (no boxed children). E2E test
alloc_rc_emits_dec_for_unique_let_bound_box runs it under both
gc and rc, asserts byte-identical stdout, clean exit, expected
output (42).

Scope cuts deferred to 18c.4: per-type drop fn / recursive dec
cascades for ADTs with boxed children (List, Tree still leak
their tails). Fn params have no dec emission yet (no static
"caller handed off ownership" signal under Implicit mode);
under explicit (own T) the signal exists but wiring is 18d.

Tests: E2E 52 -> 53, ailang-check unit 38 -> 43 (+5 uniqueness),
all other buckets unchanged. cargo test --workspace green.
2026-05-08 10:25:22 +02:00
Brummel 7099af0a3a JOURNAL: Iter 18c.2 — linearity check + suggested_rewrites
Records what shipped in 18c.2 (activation gate, two diagnostic
codes, position model, the new parse_term / term_to_form_a
round-trip in ailang-surface, gate on clean-typecheck), the
three known false negatives that are deliberate scope cuts
(missing-consume-of-Own, lam captures conservative, pattern
bindings start fresh), and the dispatch for 18c.3 (uniqueness
inference + codegen inc/dec — the iter where RC actually starts
collecting memory).
2026-05-08 10:06:28 +02:00
Brummel 09fb5bb113 Iter 18c.2: linearity check + suggested_rewrites
Adds a per-fn linearity check that runs on every fn whose
param_modes are all explicit (Borrow or Own, no Implicit), tracks
each binder's consume/borrow state through the AST, and emits
two diagnostics with form-A `suggested_rewrites`:

- `use-after-consume` — a binder is referenced after it has
  already been consumed.
- `consume-while-borrowed` — a binder is consumed while a borrow
  of it is still live (sibling arg slot, or an enclosing-fn
  Borrow param).

Fns with any Implicit param skip the check entirely; that's the
back-compat lane that keeps every existing fixture green. Pure
diagnostic addition: no IR change, no codegen change, no runtime
change.

Each diagnostic carries a non-empty `suggested_rewrites` whose
`replacement` is form-A AILang text (typically `(clone <name>)`).
Adds two new public entrypoints to ailang-surface — `parse_term`
and `term_to_form_a` — so the round-trip
`parse_term(term_to_form_a(t)) ≡ t` is the contract every
emitted replacement must satisfy. Tests assert the contract.

Linearity pass runs only on modules that typechecked clean (any
upstream typecheck error suppresses it for that module — running
on partly-defined IR would produce noise).

Tests: 3 unit tests in `linearity::tests`, 3 integration tests
in `ailang-check/tests/workspace.rs`, 1 serialisation test in
`diagnostic.rs`. `cargo test --workspace` green.
2026-05-08 10:06:14 +02:00
Brummel 8d704cd9b5 CLAUDE: codify iter-cycle and JOURNAL/DESIGN roles
Adds an "Iter cycle" top-level section: work clusters into named
iter families (18a–f, 19a–c, …); after each family closes, the
next iter is a non-optional tidy-iter that runs ailang-architect
over the surface and resolves every drift item by fixing,
ratifying in DESIGN.md, or recording acceptable drift in
JOURNAL.md. Skipping requires an explicit JOURNAL entry naming
the reason.

Also writes down the previously-implicit roles of DESIGN.md
(canonical spec; what AILang IS) and JOURNAL.md (decisions log;
HOW we got here, what's queued, rationale not in DESIGN.md). The
rule was practice for a stretch, then quietly evaporated when no
written rule kept it alive across sessions; codifying it here so
the next compaction doesn't lose it again.
2026-05-08 10:05:59 +02:00
Brummel a91c3ffd94 CLAUDE/DESIGN/JOURNAL: design rationale ≠ implementation effort
User correction during the post-RC-overnight check-in: the 18a
"Type::Fn metadata vs. new Type variant" call was justified
across DESIGN.md, JOURNAL, and the commit message primarily by
"avoids ~250 match-arm sites". That is an observation about the
current state of the code, not a design rationale.

CLAUDE.md gains two binding rules:

- Design rationale ≠ implementation effort. Effort is at most a
  tiebreaker; a choice whose only stated reason is effort is
  suspect. The rule names the 18a misstep as the canonical
  anti-example so future sessions catch it earlier.
- Direction freedom + bounce-back conditions inlined (was
  previously a cross-reference to a private auto-memory file
  outside the repo, which the user couldn't see).

DESIGN.md Decision 10's Schema-additions block now leads with
the substantive reasons for per-position metadata: semantic
locality (modes belong to fn-parameter positions, not to types
in general — Decision 1 line); compositional clarity (type
identity vs. calling convention factor apart); future-proofing
(per-position metadata generalises; Type-variant approach
combinatoric blows up). The match-arm count remains parenthetical,
explicitly named a tiebreaker.

JOURNAL records the correction itself — the mistake stays as a
data point because how design discipline corrupts is informative.
2026-05-08 09:29:33 +02:00
Brummel 3d22dc13bf JOURNAL: Iter 18c.1 — Term::Clone schema
Records that 18c.1 ran zero-surprise (mechanical recursion at
~10 sites across 6 files), the future-work seam in codegen for
18c.3 is a single line (emit one rc_inc call before returning
the inner SSA reg), and 18c.2's linearity check should start
as 'green for borrow_own_demo' since that fixture's shape is
exactly what the check needs to accept.
2026-05-08 02:27:08 +02:00
Brummel 7dfc88a4f3 Iter 18c.1: Term::Clone schema (no inc emission yet)
Adds (clone X) as a new Term variant. Form-A:
  (let p (expensive_fn x)
    (do consume_a (clone p))    ; explicit RC inc — needs 18c.3
    (do consume_b p))

Schema floor for the LLM-aware RC design (Decision 10): explicit
clone is the author-visible alternative to implicit sharing. In
18c.1 the variant is purely additive — typechecker treats it as
identity (same type as inner), codegen lowers it to the inner
term's IR with no extra calls. Iter 18c.3 will replace the
codegen identity-arm with a single call:

  Term::Clone { value } =>
      let v = self.lower_term(value, ...)?;
      self.emit("call void @ailang_rc_inc(ptr {v})");
      Ok(v)

Match-arm count: ~10 sites across 6 files, all mechanical
structural recursion through `value`. The codegen `lower_term`
arm carries an explicit comment marking the 18c.3 emission seam.

Hash invariant: `(clone X)` uses a new serde tag "clone" that no
pre-18c.1 fixture contains. Every existing fixture's canonical
JSON is bit-identical (git diff examples/ empty).

New fixture examples/clone_demo.{ailx,ail.json}:
  (let x 42 (do io/print_int (clone x)))
Stdout 42 under both --alloc=gc and --alloc=rc.

Tail-position propagates through clone (verify_tail_positions
treats `(clone tail-call)` as itself a tail call), per the
identity-of-clone semantics in 18c.1.

Verified:
- cargo build --workspace clean
- cargo test --workspace green: 52 e2e (+1), 14 surface parse
  (+2 for clone tests), all other buckets unchanged
- ail render round-trip exact
- clone_demo --alloc=gc → 42; --alloc=rc → 42
2026-05-08 02:26:12 +02:00
Brummel 5cd0a3400a JOURNAL: Iter 18b — RC runtime + --alloc=rc routing
Records that the codegen change was a one-line extension because
the 18a bump path had centralised the allocator-symbol decision
on fn_name(). Documents the runtime ABI (8-byte refcount header,
ailang_rc_alloc/inc/dec) and the deliberate no-inc/dec posture:
programs leak intentionally under --alloc=rc; 18c lights up the
actual reference counting.

Next-step plan for 18c is split into three sub-iters
(clone schema / linearity check / inference + codegen) since
each wants its own verification cycle.
2026-05-08 02:14:40 +02:00
Brummel 1eed78c41e Iter 18b: RC runtime + --alloc=rc routing
Wires up reference-counting allocator end-to-end without any
inc/dec emission. Programs run under --alloc=rc and produce
correct stdout (validated against --alloc=gc); they leak every
allocation, exactly like the pre-Boehm era. The point of 18b is
to establish the runtime contract before 18c adds the
inc/dec-emission codegen pass.

runtime/rc.c — new file. 8-byte uint64 refcount header
prepended to every payload; ailang_rc_alloc(size) returns a
ptr to the payload (header at ptr-8). ailang_rc_inc / dec are
declared but never called by codegen yet; they exist so 18c
can wire codegen against a stable runtime ABI. dec frees on
zero refcount but does NOT recursively dec child references —
that's 18c's job once it has per-ctor type info.

crates/ailang-codegen/src/lib.rs — AllocStrategy::Rc variant
added; fn_name() returns "ailang_rc_alloc". Single-line
extension because Iter 18a's bump path had already centralised
the allocator-symbol decision on fn_name() for all four
allocation sites.

crates/ail/src/main.rs — --alloc=rc accepted by Build/Run;
parse_alloc_strategy extended; locate_rc_runtime() helper
mirrors locate_bump_runtime; new Rc arm in build_to compiles
runtime/rc.c with clang -O2 -c and links the resulting .o
into the final binary (no -lgc).

E2E coverage: alloc_rc_produces_same_stdout_as_gc on
list.ail.json (42), alloc_rc_matches_gc_on_std_list_demo for
broader allocation-site coverage. Total e2e bundle: 51 tests
(was 49).

Hand-verified on:
  sum.ail.json --alloc=rc → 55
  list.ail.json --alloc=rc → 42
  borrow_own_demo.ail.json --alloc=rc → 3 then 6
  std_list_demo.ail.json --alloc=rc → matches --alloc=gc

cargo build/test --workspace green; git diff examples/ empty.
2026-05-08 02:13:08 +02:00
Brummel 1e3697926b JOURNAL: Iter 18a — borrow/own annotations on fn signatures
Records the schema-choice rationale (per-position metadata on
Type::Fn vs new Type variants — the latter would be a 250-site
mechanical migration, the former is one iter), the
padding-on-read trick that kept construction sites unchanged,
and the forward-compat note on the new fixture's borrow+own
sharing pattern.
2026-05-08 02:08:27 +02:00
Brummel b9ca8894a5 Iter 18a: (borrow T) / (own T) mode annotations on fn signatures
Adds form-A surface (borrow T) / (own T) wrappers in fn-type
params and ret slots. Internally these are not new Type variants
but per-position metadata on Type::Fn:

  Type::Fn { params, param_modes, ret, ret_mode, effects }
  enum ParamMode { Implicit, Own, Borrow }

This keeps Type itself unchanged, so unification, occurs, apply,
and ~190 other Type match-arms in the typechecker need no new
branch. Fields use serde skip-if-default predicates so canonical
JSON hashes for every pre-18a fixture stay bit-identical (sum,
list, hof, closure, list_map, etc.: zero diff under git).

Iter 18a is purely additive: typechecker treats modes as
transparent (mode-compat check deferred to 18c), no codegen
change (--alloc=gc / Boehm path unchanged), no linearity
enforcement. The annotation info flows into the JSON side-table
that 18c will consume.

Equality of mode slices is length-tolerant: a vec![] (the form
written by mechanically-updated construction sites that elide
modes) compares equal to vec![Implicit; n]. This kept the patch
from being a 100-site mechanical migration through the
typechecker. 18c will choose: keep the slack, or normalise to
full-length on construction.

New fixture examples/borrow_own_demo.{ailx,ail.json} exercises
both modes. list_length declares (borrow (con List)); sum_list
declares (own (con List)). Stdout: 3 then 6.

Documentation: DESIGN.md schema-additions block clarified that
modes are Type::Fn metadata (not Type variants); migration plan
flag rename --memory=rc → --alloc=rc to match the existing CLI
flag.

Verification:
- cargo build --workspace green
- cargo test --workspace green (154 tests, +1 vs baseline 153)
- git diff examples/{sum,list,hof,closure,list_map,...}.ail.json: empty
- borrow_own_demo runtime stdout: "3\n6\n"
- canonical JSON contains "param_modes":["borrow"] and ["own"]
2026-05-08 02:07:07 +02:00
Brummel 52b129f558 DESIGN: clarify Iter 18a schema — modes are Type::Fn metadata, not Type variants
Avoids the ~190 Type match-arms in the typechecker that a new
Type variant would require. ParamMode enum (Implicit | Own |
Borrow) attaches per-param to Type::Fn, with serde defaults so
existing fixture hashes stay bit-identical.
2026-05-08 01:55:15 +02:00
Brummel e3ecf4f0cd DESIGN/JOURNAL: Decision 10 reframed — RC + LLM-author annotations
Replaces the earlier "RC + inference, no annotations" position
with a two-layer architecture: inference (intra-fn) + mandatory
LLM-author mode annotations on fn signatures (inter-fn). Adds
structured rationale for rejecting region inference (regions
assume stack-shaped lifetimes; real programs need
non-stack-shaped lifetimes for caches / memo tables) and linear
types as primary mechanism.

Schema additions enumerated for the 18-series:
  Type::Borrow / Type::Own (Iter 18a)
  Term::Clone (Iter 18c)
  Term::ReuseAs (Iter 18d)
  TypeDef.drop_iterative (Iter 18e)

Migration plan extended from 18a-d (4 iters) to 18a-f (6 iters).

JOURNAL records the regions excursion, the scope-shape reduction,
and the LLM-author lever as the structural advantage Decision 10
cashes in on.
2026-05-08 01:52:40 +02:00
Brummel 65e280bb70 Bench: GC overhead via bump-allocator comparison
Adds --alloc=<gc|bump> to ail build/run. Bump path links a 256MB
no-free arena C stub instead of libgc; IR is byte-identical except
for the @GC_malloc → @bump_malloc symbol swap. Bench harness times
two allocation-heavy workloads (list cons/sum and balanced tree
build/walk) under both modes.

Numbers (RUNS=5, median of 4):
  bench_list_sum   gc 0.141s  bump 0.048s  +194%
  bench_tree_walk  gc 0.103s  bump 0.041s  +151%

Bucket: large. ~60% of runtime is Boehm on these workloads —
upper bound for any realistic program. Both fixtures hold the
heap fully live, so the cost we're seeing is Boehm's allocate
path itself, not collection work; that fact narrows the design
space for the GC discussion.

- crates/ailang-codegen: AllocStrategy enum, three callsites and
  the IR header parameterised.
- crates/ail/src/main.rs: --alloc flag plumbed; bump runtime
  located + compiled on demand.
- runtime/bump.c: 256MB static arena, abort-on-overflow.
- examples/bench_list_sum, bench_tree_walk: accumulator-form
  fixtures (textbook recursive sum was constructor-blocked).
- bench/run.sh: harness with Python timing helper (Arch's
  /usr/bin/time isn't part of the base install).

No language-level changes; default --alloc=gc, all 141 workspace
tests green, all 5 IR snapshots unchanged, 11 prior fixtures
produce identical stdout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 00:06:20 +02:00
Brummel aee6e9d3bf Iter 17a: per-fn arena via alloca for non-escaping allocations
Adds a conservative escape analysis that flags Term::Ctor and
Term::Lam allocations as non-escaping when they are bound by a
let, never flow to a fn arg / ctor field / tail-return / lambda
capture, and remain inside the allocating fn's frame. Codegen
lowers flagged sites to LLVM `alloca` instead of `@GC_malloc`;
escaping sites continue to use Boehm.

- escape.rs (new): name-based taint propagation; let-only
  candidates; tail-position / app-arg / ctor-field / lam-capture
  all taint.
- codegen: three sites switched (lower_ctor, lam env, closure
  pair). Save/restore non_escape across lambda thunk emit.
- examples/escape_local_demo.{ailx,ail.json}: focused fixture
  exercising both branches (peek and recursive count).
- e2e + escape unit tests: 133 → 141 (+8).

Stop-gate: 17a closes the queue. Memory-management discussion
(GC scope) is the next user-driven step. Per-fn arena observations
appended to JOURNAL — including the headline finding that 0/270
shipped allocations are flagged non-escaping under this rule
(real AILang threads ctors directly between fns; build-locally /
consume-locally is not idiomatic).

All existing fixture stdouts and content hashes bit-identical;
IR snapshots unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:31:35 +02:00
Brummel 937782e36d Iter 16e: == polymorphic over Int / Bool / Str / Unit
Lifts the Int-only restriction on `==`. Declared type becomes
forall a. Fn(a, a) -> Bool; codegen monomorphises and dispatches:

  Int  → icmp eq i64
  Bool → icmp eq i1
  Str  → call @strcmp + icmp eq i32 0
  Unit → constant true (operands still emitted for side effects)
  ADT / Fn / other → CodegenError::Internal

This unblocks 16c's build_eq for non-Int lit patterns. == joins
__unreachable__ as the second polymorphic builtin (same Forall
machinery).

- check/builtins.rs: == registered as Forall(a, Fn(a, a) -> Bool).
- codegen: lower_eq dispatch table; @strcmp declared in IR header
  alongside @printf/@GC_malloc/@puts.
- examples/eq_demo.{ailx,ail.json}: covers all four supported
  scalars including a Str-lit-pattern match.
- IR snapshots refreshed: only +declare i32 @strcmp(ptr, ptr) in
  the header; every define body bit-identical.
- e2e + check + codegen tests: 124 → 133 (+9, of which +1 is the
  e2e fixture and the rest exercise the new dispatch / typecheck
  surface).

Other comparison ops (<, <=, >, >=, !=) remain Int-only — out of
scope for this iter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:12:24 +02:00
Brummel af35612c1d Iter 16d: chain-terminator via __unreachable__ builtin
Eliminates the Unit-typed chain default that 16c left in place.
Adds __unreachable__ : forall a. a — codegen lowers to LLVM
unreachable. The 16a/16c chain machinery now uses it as the
deepest fall-through, so exhaustive matches with non-Unit arms
no longer need a (case _ ...) workaround.

- check/builtins.rs: register __unreachable__ as Forall(a, a).
- codegen: Term::Var "__unreachable__" emits LLVM unreachable
  and sets block_terminated; If/Match/fn-body gate downstream
  work on that flag.
- desugar: chain default switched from Lit{Unit} to Var.
- examples/lit_pat: categorize_first's trailing _ arm removed.
  Hash changes from c4faec3abc2ed388 to 644de0c0ec15fc17.
- examples/unreachable_demo: positive fixture using safe_div
  with __unreachable__ in the impossible branch.
- e2e + desugar tests: 122 → 124 (+2).

Path-a from 16b.2 planning: real primitive over a desugar-time
exhaustiveness pre-check (which would need cross-module type
registry access from desugar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:00:23 +02:00
Brummel e5f9828a04 Iter 16b.7: nested LetRec mutual capture (params, not name)
Inner LetRec capturing outer LetRec's params now lifts cleanly via
the existing post-order traversal — outer's params are KnownType
in inner's outer-scope, so 16b.2's fast-path handles them. Inner
LetRec capturing outer's NAME remains rejected (closure-of-self
in body, deferred separately).

Plus hardening: lift.rs's deferred path tracks the outer LetRec
name in a parallel `enclosing_letrec_names` set so the symmetric
inner-captures-outer-name rejection fires there too — without it
a silent lift produced a runtime arity mismatch.

- examples/nested_let_rec.{ailx,ail.json}: outer counts down via
  inner that captures the outer's threshold param.
- desugar.rs: clarified panic message for outer-name capture.
- lift.rs: enclosing_letrec_names tracking.
- e2e + desugar tests: 120 → 122 (+2).

End-of-16b series: feature-complete except for closure-of-self
in body and closure-with-polymorphism, both queued separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:48:14 +02:00
Brummel 76d61aeced Iter 16b.6: LetRec inside polymorphic enclosing fn
Lifts the monomorphic-only restriction. Lifted top-level fn now
becomes Forall(α..., Fn(t..., T...) -> tr) when the enclosing fn
is polymorphic; capture types may mention outer type vars and
the existing mono pipeline (Iter 12b/14a) specializes them
correctly at call sites.

- desugar.rs / lift.rs: scope-building unified — Forall fn-params
  are now KnownType, not LetBound. Lift wraps augmented_ty in
  Forall mirroring the enclosing fn.
- Name-as-value-in-in-term in a polymorphic enclosing fn is
  rejected (eta-Lam wrap from 16b.5 has no Forall). Queued
  separately as closure-conversion-with-polymorphism.
- examples/poly_rec_capture.{ailx,ail.json}: apply_n_times :
  Forall(a). drives at Int and Bool to exercise two mono
  instantiations.
- e2e + check + desugar tests: 116 → 120 (+4).

Codegen pipeline untouched — apply_subst_to_type substitutes
through Fn.params uniformly (original params + appended captures).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:39:28 +02:00
Brummel ca507c9f52 Iter 16b.5: LetRec name as value (in_term only) via eta-Lam wrapper
Drops the direct-call-only restriction on the LetRec name when it
appears in `in_term`. No new ABI: the lift wraps the in-term in
`(let f (lam P (app f$lr_N P CAPTURES)) in_term')` so codegen's
existing Iter-8b closure-pair machinery handles it. Body-position
name-as-value still rejected (deferred — eta-of-self is harder).

- desugar.rs / lift.rs: split find_non_callee_use into body
  (panic) and in_term (wrap).
- examples/local_rec_as_value.{ailx,ail.json}: no-capture path
  (factorial passed to apply5 → 120).
- examples/local_rec_as_value_capture.{ailx,ail.json}: capture
  path; lifted fn has captures, eta-Lam picks them up via 8b.
- e2e + desugar tests: 113 → 116 (+3).

Codegen Lam machinery handled the wrapped form on the first try.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:27:21 +02:00
Brummel c0668178bb Iter 16b.4: LetRec captures of match-arm pattern bindings
Flips the desugar's MatchArm-capture rejection from panic to defer.
The 16b.3 lift_letrecs pass already handled match-arm bindings via
type_check_pattern_for_lift (Pattern::Var, Pattern::Ctor with sub-
patterns, ctor-field substitution against scrutinee args), so this
iter is a single-line classification change in desugar plus the
fixture and tests that exercise it.

- desugar.rs: MatchArm classification now defers (same arm as
  LetBound); EnclosingLetRec panic remains for 16b.7.
- examples/local_rec_match_capture.{ailx,ail.json}: enclosing fn
  pattern-matches on Pair<Int,Int>, inner LetRec captures both
  match-arm bindings simultaneously. Lifts to
  loop$lr_0(i: Int, threshold: Int, n: Int) -> Int.
- e2e + check + desugar tests: 110 → 113 (+3).

First fixture lifting more than one capture; subst_call_with_extras
already handled it generically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:16:12 +02:00
Brummel ca30606aec Iter 16b.3: post-typecheck lift for LetRec with Let-bound captures
Adds path-2 from the 16b.2 planning entry. Desugar now defers
LetRecs whose captures include Term::Let-bound names; a new
ailang-check::lift_letrecs pass runs after typecheck and uses the
elaborated env to resolve capture types. ailang-check learns a real
Term::LetRec typing rule in synth and verify_tail_positions.

- desugar: Term::LetRec arm gains a defer-arm; helpers promoted to
  pub for reuse by the lift pass; find_non_callee_use moved before
  classification.
- ailang-check::synth/verify_tail_positions: real LetRec rules
  (effect-subset, locals install, recursive name in body+in_term).
- ailang-check::lift.rs (new, 720 LOC): post-typecheck lift with
  post-order traversal, env-walk for capture-type resolution,
  fast-path skip when no LetRec is present.
- ail::main.rs: build path now does load → check → desugar →
  lift_letrecs → codegen.
- examples/local_rec_let_capture.{ailx,ail.json}: new fixture
  capturing a let-bound `threshold` in a recursive helper.
- e2e + check unit tests: 106 → 110 (+4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 22:07:05 +02:00
Brummel d5f63bc3e5 Iter 16b.2 — LetRec captures of fn-params (path-1 safe subset)
Lift 16b.1's no-capture restriction. The desugar pass's `scope`
becomes a `BTreeMap<String, ScopeEntry>` carrying `KnownType` for
fn/Lam-params, sentinels for Let-/Match-bindings. LetRec captures
of `KnownType` names are lifted: the synthetic top-level fn's
signature gets the capture types appended, and every call site of
the LetRec name is rewritten via `subst_call_with_extras` to pass
the captures positionally. Captures from let-bindings, match-arm
patterns, polymorphic enclosing fns, and name-as-value uses are
rejected at desugar with panics pointing at 16b.3-16b.7.

Demo: `sum_below(n)` recurses via `(let-rec loop (params i) ...
(body ... uses n ...) (in (app loop 1)))`. Lifts to
`loop$lr_0(i: Int, n: Int) -> Int` with `(app loop X)` rewritten
to `(app loop$lr_0 X n)`. Outputs 0, 10, 45.

Tests: 103 -> 106 (+1 e2e local_rec_capture_demo, +2 desugar unit;
the 16b.1 capture-panic test was repurposed into the positive
fn-param-capture test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 21:00:49 +02:00
Brummel 3bee7d4c44 JOURNAL — 16b.2 planning entry (LetRec capture)
Orchestrator-level planning artifact, not an iteration log.
Captures the design space for 16b.2 so the user can review
trade-offs before implementation. Three architectural paths
considered (desugar-only with restrictions, post-typecheck pass,
mini-inference inside desugar); recommends the first as the
incremental path. Lists restrictions for the safe subset
(fn/Lam-param captures only, direct-call only, monomorphic
enclosing fn, single-level LetRec) and queues the lifted
restrictions as 16b.3-16b.7. Adjacent open items (16d chain
terminator, 16e == extension) likewise scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:51:38 +02:00
Brummel 4683af6114 Iter 16c — literal patterns via desugar
Desugar `Pattern::Lit` (top-level and nested in Ctor) to
`Term::If { cond = (== sv lit), then = body, else_ = fall_k }`.
After 16c, no Pattern::Lit survives the desugar pass; codegen
and typechecker never see one. `is_flat` reclassifies Lit as
non-flat to force the chain machinery; new `build_eq` helper
constructs the equality test per literal kind.

Tests: 99 → 103 (+1 e2e lit_pat_demo, +3 desugar unit). Demo
fixture exercises top-level lit arms (classify) and nested
lit-in-Ctor (categorize_first) with a local IntList; output
100/200/999/-1/0/7.

Known limitations documented in DESIGN.md and JOURNAL: (a)
chain machinery's Unit terminator means non-Wild-terminated
exhaustive matches with lit arms still need a trailing `_`
catch-all to type-check; (b) `==` is currently Int-only, so
Bool/Str/Unit lit patterns desugar correctly but error at
typecheck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:47:21 +02:00
Brummel 8860600e37 Iter 16b.1 — local recursive let (no-capture)
Add `(let-rec NAME (params ...) (type ...) (body ...) (in ...))`
as a form-A surface and a `Term::LetRec` AST variant. The 16a
desugar pass lifts each LetRec whose body has no captures from
the enclosing scope to a synthetic top-level fn `<hint>$lr_N`
and substitutes the original name; typecheck and codegen never
see LetRec. Capture detection panics at desugar time, queued
for 16b.2.

Tests: 95 → 99 (+1 e2e local_rec_factorial_demo, +2 desugar
unit, +1 parse unit). The new fixture `examples/local_rec_demo`
runs `fact` at n=1, 3, 5 → prints 1, 6, 120.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 20:33:48 +02:00
Brummel ee5807d73c Iter 15h — std_list extension: take, drop
Adds the two canonical prefix-slicing combinators to std_list.
First fns in std_list to combine `if` + Int arithmetic + recursive
ADT pattern in one body.

  take : forall a. (Int, List<a>) -> List<a>
       — first n elements (or all of xs if shorter than n).
  drop : forall a. (Int, List<a>) -> List<a>
       — list with first n elements removed (Nil if n >= length).

Both use `(if (<= n 0) ...)` as the base-case guard since
literal sub-patterns inside Ctor patterns aren't yet supported
(queued as 16c — would let take/drop collapse the if into the
match).

examples/std_list_more_demo: builds [1, 2, 3, 4, 5] once and
exercises take/drop at n=0, n=mid, n=overflow on each. Expected
output 0, 3, 5, 5, 3, 0 (one per line).

Tests: 95/95 (e2e went from 35 to 36). std_list grew from 10 to
12 combinators; total stdlib 5 modules / 29 combinators. No new
compiler bug surfaced.

Pre-existing std_list defs are byte-identical (verified via diff
on .ailx and round-trip on .ail.json), so the five downstream
importers (std_list_demo, std_list_stress, std_either_list,
std_either_list_demo, list_map_poly) need no regeneration.
2026-05-07 20:05:45 +02:00
Brummel de674c4d0b Iter 15g-aux — symmetric $u early-return in unify_for_subst
Fix the codegen asymmetry that 15g surfaced. unify_for_subst had an
arg-side-only early-return for $u-prefixed synth wildcards. The
function's prev-binding recursion can swap a $u from arg into param
position when re-unifying a previously-bound type against a fresh
arg. Reduced repro: `length [Left 1, Right 10]` — `a` first binds
to `Either<Int, $u>`, then the recursive unification against
`Either<$u, Int>` lands $u in param-pos[1] and falls through to
the catch-all error.

Fix is three lines: add a symmetric $u early-return for param side.
$u is a synth-only wildcard regardless of which side it ends up on
after the prev-binding swap. Doc comment expanded to record the
origin and justification.

std_either_list_demo refactored: mkleft/mkright workaround helpers
removed; the list is now constructed inline by mixing
(term-ctor std_either.Either Left 1) and (... Right 10) directly.
Same expected output (2, 3, 2, 3); the demo doubles as the 15g-aux
regression fixture.

Tests: 94/94, unchanged. The fix expanded what compiles without
changing observable behaviour for any prior fixture.
2026-05-07 19:59:42 +02:00
Brummel e1587ebdae Iter 15g — std_either_list: first 3-way cross-module stdlib fn
First stdlib fn that imports three other stdlib modules (std_list,
std_either, std_pair) and returns a compound polymorphic ADT tree
(Pair<List<e>, List<a>>). Stresses monomorphisation across nested
parameterised ADTs.

What shipped:
- examples/std_either_list.{ailx,ail.json}: 3 combinators —
  - lefts : forall e a. (List<Either<e, a>>) -> List<e>
  - rights : forall e a. (List<Either<e, a>>) -> List<a>
  - partition_eithers : forall e a. (List<Either<e, a>>)
                        -> Pair<List<e>, List<a>>
  lefts/rights use depth-2 nested Ctor patterns (Cons (Left l) t)
  — exercises 16a's desugar pass at a depth not reached by any
  prior fixture.
- examples/std_either_list_demo.{ailx,ail.json}: drives all three
  combinators on a five-element List<Either<Int, Int>>; expected
  output one per line: 2, 3, 2, 3.
- crates/ail/tests/e2e.rs::std_either_list_demo: e2e count 34 → 35.
- docs/JOURNAL.md: Iter 15g entry.

Compiler bug surfaced (queued as 15g-aux, not fixed):
unify_for_subst in ailang-codegen/src/lib.rs accepts $u wildcards
only on the arg side. Mixing inline (Either Left n) and (Either
Right n) in a list literal lands $u on the param side via the
outer List<a>'s binding and errors. Reduced repro: `length [Left
1, Right 10]`. Demo works around with monomorphic mkleft/mkright
helpers; workaround documented inline. Fix is a one-line symmetric
extension of the early-return.

Tests: 94/94. Stdlib: 5 modules, 27 combinators.
2026-05-07 19:57:06 +02:00
Brummel 20b412342d Iter 16a-aux — DESIGN.md drift audit (post-15f)
After six feature iters since the last docs sweep, DESIGN.md had
visible drift. Patched in place rather than queueing the next
codegen-heavy iter against a stale spec.

DESIGN.md changes (+72 LOC):
- Decision 6: form (A) marked shipped (Iter 14c, sole projection
  since 15e); body kept as audit trail.
- Pipeline: desugar pass added between resolve+hash and typecheck;
  invariant noted that CheckedModule.symbols hashes from the
  original module, not the desugared one (so ail diff/manifest
  preserve on-disk identity).
- CLI: added deps, diff, workspace, builtins (shipped earlier but
  never doc'd).
- "What is not (yet) supported": re-anchored from "end of Iter 13"
  to "as of Iter 16a"; removed lifted gates (cross-module ADTs,
  no-GC, flat-pattern-only); added tighter follow-up gates
  (literal sub-patterns, local recursive let).
- "What IS supported": promoted nested Ctor patterns (16a),
  cross-module ADTs (14h), form-(A) text surface (14b/14c/15e),
  Boehm GC (Decision 9 / 14f) into the smoke-test list.
- Smoke tests: added std_list_demo, std_maybe_demo, std_either_demo,
  std_pair_demo, nested_pat fixtures.

JOURNAL: 16a-aux entry recording the drift sites and what was
explicitly *not* changed (Goal, Decisions 1-5, 7-9, Mangling,
Convention, Data model, Verification — spot-checked, all current).

Tests: 93/93 unchanged (doc-only). Build clean.
2026-05-07 19:39:44 +02:00
Brummel 689c445d25 Iter 15f: std_pair — 2-type-var product, no recursion
Fourth stdlib module. Pair<a, b> is the canonical product with two
type vars and a single constructor MkPair. Five combinators: fst,
snd, swap, map_first (Pair<a, b> -> Pair<c, b>), map_second
(Pair<a, b> -> Pair<a, c>).

Smallest dogfood for the parameterised-ADT path so far: no
recursion in the data def or any combinator, single-arm matches
throughout. Demo prints 7, 9, 9, 7, 8, 18 deterministically.

No compiler bug surfaced (fourth stdlib iter in a row to land
clean). The only fixable issue was a paren-balance typo in the
demo's seq chain.

Cumulative: 4 stdlib modules, 24 combinators. Type-system surface
exercised end-to-end now spans 1/2/3-type-var data, 1/2/3-type-var
fns, recursive ADTs, cross-module imports of all of the above,
flat and nested patterns, TCO via monomorphised musttail, GC.

Tests: 93/93 (e2e 33 → 34).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:12:23 +02:00
Brummel 0e90709a94 Iter 16a: nested constructor patterns via AST desugaring
Lifts the gate that rejected `(pat-ctor Cons a (pat-ctor Cons b _))`.
Approach: a pure AST → AST pass `ailang_core::desugar::desugar_module`
that flattens nested-Ctor sub-patterns into chains of single-level
matches with let-bound fresh vars and duplicate fall-through. Both
ailang-check and ailang-codegen call the pass at pipeline entry.
Hash-relevant canonical bytes are untouched: the pass runs after
load_module, in memory only. `check`'s returned CheckedModule.symbols
still carries hashes of the original defs so `ail diff` / `ail manifest`
see on-disk identities.

Lit sub-patterns inside a Ctor still rejected — that's the narrower
scope of `nested-ctor-pattern-not-allowed` post-16a; the variant doc
reflects the new meaning.

Fresh-name safety: `$` is a valid ident character in form (A), so
the Desugarer pre-walks every Term::Var / Pattern::Var name into a
BTreeSet and bumps its counter past any user-spelled `$mp_N`.

Already-flat matches go through `is_flat()` and emit identical AST
shapes; every existing fixture produces the same output as before.

New: examples/nested_pat.{ailx,ail.json} prints 30 from
first_two_sum on [10, 20, 30]. e2e test
`nested_ctor_pattern_first_two_sum` guards the path.

Tests: 92/92 (e2e 32→33, ailang-core unit 10→12).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 19:10:01 +02:00
Brummel 0b1b39f829 Iter 15e: render is the exact inverse of parse — CLI hygiene
The CLI help text claimed `render` was symmetric to `parse`, but
`render` was wired to ailang_core::pretty::module (an older
human-pretty form) while `parse` consumed form (A). Two different
"text projections" coexisted under one name.

Fix: Cmd::Render and Cmd::Describe (both branches) now call
ailang_surface::print, the actual inverse of `parse`. Round-trip
gate via `ail render | ail parse` is now an e2e test in
crates/ail/tests/e2e.rs in addition to the existing unit-level
round-trip across all 25 fixtures in ailang-surface/tests.

Cleanup: `ailang_core::pretty::module` and its three private
helpers (`def_block`, `term_block`, `term_inline`) deleted —
~260 LOC of duplicate-purpose code removed. pretty.rs goes
from 457 to 196 LOC. Surviving surface is intentionally narrow:
manifest, type_to_string, pattern_to_string — the diagnostic
stringification used in error messages, where one-line ML
notation reads better than form (A)'s nested S-expressions.
Module-level doc rewritten to nail down the single-purpose
framing.

Tests: 89/89 (e2e +1, ailang-core unit -1 since the deleted
test exercised the deleted fn).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:54:07 +02:00
Brummel df5b76bd65 Iter 15d: std_either ships — 2-type-var ADT + 3-type-var eliminator
Third stdlib module. Either<e, a> is the first 2-type-var data def,
and the eliminator `either : (e -> c) -> (a -> c) -> Either<e, a> -> c`
the first fn with three type vars on top of the data — the deepest
polymorphism shipped end-to-end so far.

Five combinators: from_right, is_left, is_right, map_right, either.
Demo exercises every one and runs to deterministic output. IR shows
six distinct monomorphisations including two `from_right` variants
(Left=Int vs Left=$u depending on call site) and `either__I_I_I`.

No new compiler bugs surfaced: 14a / 14h / 15b coverage was wide
enough to handle 2-type-var data plus 3-type-var fns out of the box.

Discovered (queued as 15e): `ail render` and `ail parse` are not
symmetric. Form-(A) printer in ailang_surface::print is the actual
inverse of `parse` (round-trip test covers it across all 25 fixtures
including std_either) but is not exposed via the CLI; `render` still
calls the older ailang_core::pretty::module printer.

Tests: 89/89 (was 88, +1 e2e for std_either_demo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:48:43 +02:00
Brummel bd19fee673 Iter 15c: empirical TCO + stack-budget validation at N=1000
Small empirical iter confirming the 14e tail-call story works
dogfood-practical. New fixture std_list_stress.ailx builds a
1000-element List<Int> via recursive build, folds it with both
fold_left (tail-marked in std_list) and fold_right (constructor-
blocked, unmarked), prints both sums (500500 each).

IR evidence at the monomorphised fold-recursion sites:
  fold_left__I_I:  musttail call i64 @ail_std_list_fold_left__I_I(...)
  fold_right__I_I: plain     call i64 @ail_std_list_fold_right__I_I(...)

The tail: true marker on std_list.fold_left's recursive call
survives monomorphisation through the __I_I specialisation.
fold_right correctly emits a plain call (recursive call is
second arg to f, not tail position).

Empirical findings at N=1000:
- fold_left runs in constant stack (musttail).
- fold_right runs in ~1000 frames. No segfault; default 8MB
  Linux stack absorbs it comfortably.
- build (recursive, unmarked) likewise fits.
- End-to-end ~40ms wall (build + clang link); program <1ms.
- Boehm GC handles 2 × 1000 Cons allocations without symptom.

Tests 87 -> 88. Cumulative 30 e2e tests. cargo doc 0 warnings.

Cumulative state, post-15c: 2 stdlib modules (std_maybe,
std_list), 14 combinators, cross-module recursive ADTs working,
TCO surviving monomorphisation, GC at 1000-element scale. Four
compiler bugs surfaced + fixed in dogfood since 14a.

Natural pause point. Queue for future iters: 15d (std_either),
15e (std_pair), 16a (nested patterns), 16b (local rec let),
17a (per-fn arena, Decision 9 future-iter). None block further
stdlib work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:39:16 +02:00
Brummel 92f4b4f8c7 Iter 15b: std_list ships, three more compiler gaps closed
Second stdlib module. Tester wrote std_list.ailx (10 combinators,
164 LOC) and a consumer demo. std_list typechecked standalone;
demo did not, surfacing three compiler bugs:

1. Check-side: Iter 14h's qualify_local_types was applied to
   Term::Var cross-module lookup but not to ctor-field types in
   Term::Ctor synth or Pattern::Ctor resolution. First recursive
   cross-module ADT (List has Cons a (List a) — recursive Con
   self-ref) triggers the bug. std_maybe slipped through because
   Maybe's ctors have no recursive Con field.
2. Codegen-side: same gap mirrored across 4 sites in codegen
   (Term::Ctor synth, lower_ctor, lower_match) plus a tweak to
   unify_for_subst (recurse on re-bind instead of strict equality
   so sibling-derived List<Int> accepts nullary-ctor's List<$u>
   wildcard).
3. Const codegen: emit_const rejected non-literal const bodies.
   The demo's xs : List<Int> = Cons 1 (...) requires it. Fix:
   per-module const table, Term::Var resolution loads literal
   consts from global, inlines non-literal bodies. Bare and
   qualified refs both supported.

All three fixes carry an "Iter 15b" code comment at their site.
~349/25 LOC across ailang-check, ailang-codegen, e2e.rs.

Tests 85 -> 87. New e2e std_list_demo asserts 11-line stdout:
length 5, is_empty false/true, head via from_maybe, tail length,
append length, reverse head, map double head, filter is_even
length, fold_left sum, fold_right sum. New ailang-check unit
test cross_module_recursive_adt_term_and_pat_ctor covers both
the original bug and the symmetric pat-ctor latent twin.

Hash invariance: all pre-15b fixtures + std_maybe defs
bit-identical. 14a / 14e / 14h regressions all green.

Cumulative state: 2 stdlib modules (std_maybe, std_list), 14
combinators, cross-module recursive ADT working end-to-end.
Three compiler bugs surfaced + fixed in dogfood since 14a (each
dogfood iter has surfaced ≥1).

Authoring observation: form (A) at 10 combinators is fine; main
friction is paren-counting in nested seq chains, not the form
itself. n-ary seq would help but is sugar.

Plan 15c: 1000-element list stress test for fold_left (tail-call-
marked) vs fold_right (constructor-blocked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 18:34:49 +02:00