c747cdf932bdeaf7289dac1263fcf55af08622ae
986 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
bd62f2b9d0 |
docs(design): name the fn-value resolution carve-out in 0018
Audit follow-up (typed-MIR milestone-close drift review, clean). The boundary contract said codegen does "no callee resolution"; that is true for App callees — the only thing `Callee` totality claims over — but `resolve_top_level_fn` still resolves a dotted fn used as a *value* via `import_map` (the in-corpus-unreachable type-home residue the spec blessed at mir.2), and ctor/const resolution stay codegen-side. Tighten the prose to "no App-callee resolution" and name the value-position carve-out, so the contract is precise and a future drift review does not read the blanket claim as violated. Prose-only; no code, no test change. |
||
|
|
a378dad0aa |
docs(design): ratify the check→codegen boundary (mir.5, typed-MIR close)
mir.5 is the typed-MIR milestone's closing iteration. Its CODE half had
already converged before the iteration began, so mir.5 ships no code —
it ratifies into the design/ ledger the boundary the code already holds.
Verified at iteration entry (empirically, not from the spec sketch):
- all four named re-derivers grep-clean in codegen: synth_with_extras,
synth_arg_type, type_home_module, the second infer_module_with_cross;
- lower_workspace takes &MirWorkspace (codegen consumes MIR);
- MTerm::New is unreachable!() — raw-buf.4 desugars Term::New to
(app T.new …) before codegen, so there is no element-type
re-derivation left to relocate;
- #51 / #53 (the element-type / new-T codegen crashes) are closed;
their residue was fixed by
|
||
|
|
c5fd16a4eb |
feat(codegen): StrRep loop-carried Str ⇒ Heap, close the #49 recur leak
mir.4 closes bug #49 (a heap-Str loop binder replaced across `recur`
leaks every superseded slab; the loop result leaks too) structurally,
by making every Str a loop binder holds OR a loop returns an owned heap
slab — then deleting the `!is_str` carve-out from BOTH codegen gates
that carried it. The leg-by-leg `!is_str` patches are gone.
Mechanism (the milestone thesis: representation in MIR, codegen reads
it). lower_to_mir sets rep = StrRep::Heap on every Str literal in a
loop-carried position; codegen's MTerm::Str arm promotes a Heap literal
via ailang_str_clone (a fresh ailang_rc_alloc slab, rc_header refcount
1). Three promotion positions, all in the Loop/Recur lowering:
1. binder seed inits (is_str_ty on the binder type);
2. recur args at Str-binder positions (read off the innermost
loop_stack frame);
3. exit-arm tail result literals (promote_tail_str_literals walks the
loop body's tail positions when the loop returns Str).
With every loop-carried Str now owned-heap, both gates lose !is_str:
the recur superseded-value dec (lib.rs) frees each intermediate slab;
the loop-result trackability gate (drop.rs:493) lets the caller's
scope-close free the final slab.
Why both gates, and the planning-time error this corrects. The original
plan/spec scoped the deletion to the recur dec gate only, reasoning the
#49 loop result is "consumed by print". The implement-orchestrator
landed that scope (Tasks 1-2) clean and correctly BLOCKED: it took #49
from live=3 to live=1, the residual being the loop RESULT. I verified
the diagnosis empirically:
- print BORROWS its arg (prelude: `(let s (show x) (do io/print_str
s))`, the eob.1 heap-Str discipline), so `(print s)` does not free
the loop result; the caller's let-binder does, via drop.rs:493.
"consumed by print" != "freed by print" — that was the error.
- Deleting drop.rs:493's !is_str takes #49 and the recur-literal
fixture to live=0.
- Deleting drop.rs:493 WITHOUT exit-arm promotion SIGSEGVs (exit 139)
on a loop returning a static Str literal — hence the third
promotion position and the static-exit witness.
The agent surfaced a real spec defect via an empirical BLOCK rather
than guessing; I corrected the spec refinement note (both gates, three
positions) and completed the loop-result leg inline, the context
already loaded from verifying the BLOCK (CLAUDE.md "already loaded
context" carve-out). drop.rs:493's Str carve-out is now sound to delete.
Boundary (asserted ill-typed, not constructible): a borrowed static-Str
Var seeded/recur'd into a consumed Str loop binder — rejected upstream
by uniqueness (a consumed binder position is owned).
Verification (orchestrator-run): all four leak fixtures reach live=0
under AILANG_RC_STATS — #49 (xyyy), recur-literal (reset), static-exit
(result), and the
|
||
|
|
2536b5f535 |
plan(mir.4): StrRep loop-carried Str ⇒ Heap, delete the recur !is_str gate
mir.4 closes bug #49 (a heap-Str loop binder replaced across `recur` leaks every superseded str_concat slab, live=3) structurally rather than with a fourth leg-by-leg patch. Design calls made in planning (folded into the spec as the mir.4 refinement note): - Mechanism: producer-side representation, codegen reads it — the milestone thesis. lower_to_mir sets rep = StrRep::Heap on every MTerm::Str literal that flows into a Str loop-binder alloca (seed inits AND recur args at Str-binder positions); codegen's MTerm::Str arm gains a Heap leg that promotes the static literal via ailang_str_clone (fresh ailang_rc_alloc slab, rc_header refcount 1). No special-casing in the codegen Loop/Recur store arms — the clone is emitted automatically when the Str{Heap} node is lowered. - Recur args are promoted too, not just seeds: `(recur "reset" …)` is well-typed (verified: `ail check` accepts it), and a seed-only promotion would leave a static literal under the now-unconditional dec — a constructible UB hole. Soundness over minimalism on the highest-risk RC/drop surface. - Only the recur dec gate (lib.rs:2231) loses !is_str. The drop.rs loop-RESULT trackability gate (drop.rs:493) STAYS conservative: deleting it needs a broader "every Str a loop can return is owned-heap" guarantee (a static exit-arm literal breaks it) and is not required by the #49 acceptance (the #49 loop result is consumed by print). drop.rs is untouched. - Boundary (out of scope, asserted ill-typed): a borrowed static-Str Var seeded/recur'd into a consumed Str loop binder is rejected upstream by uniqueness (a consumed binder position is owned), so it is not constructible. Plan 0119 carries exact code per step: producer rep promotion + a loop-frame-driven Str-binder mask for recur args (reusing the existing ctx.loop_stack, no new ctx field), the codegen Heap leg, the gate deletion, the #[ignore] lift on the #49 pin, a recur-literal witness fixture + runtime pin proving the recur-arg leg, and the full-suite + ir_snapshot verification. The existing lower_to_mir_ty Static-seed pin flips to assert Heap. |
||
|
|
eba1be8d9d |
feat(codegen): fill MArg.mode for App args, read the anon-temp drop gate off it, delete module_def_ail_types (mir.3b)
Second of two iterations delivering spec 0060's mir.3 row (plan docs/plans/0118-mir.3b-marg-mode-and-table-delete.md). lower_to_mir's Term::App arm fills each MArg.mode from the resolved callee's param_modes (the sig = synth_pure(callee) it already holds); codegen's emit_call anon-temp borrow-slot drop gate reads arg.mode instead of re-looking-up the callee's param_modes from the module_def_ail_types table; and — since that gate was the table's only reader — the module_def_ail_types field plus all its construction and threading are deleted. Two refinements to the spec's mir.3b sketch, settled in planning from a focused recon and recorded in spec 0060: 1. module_def_ail_types is deleted in mir.3b, not mir.5. A grep proved self.module_def_ail_types had exactly ONE reader (the anon-temp gate); the own-param drop reads param_modes from the def's own f.ty, not this table. Once the gate moves onto MArg.mode the table is dead, so it is removed now rather than carried as dead code to mir.5. The mir.5 row's "last re-derivation residue" shrinks to the element-type / Term::New (New.elem) work. 2. MArg.mode is filled for App args only; MTerm::Let.mode is not filled. Only App args have a real per-arg mode source (the callee Type::Fn.param_modes). Do args (EffectOpSig has no param modes), Ctor args (no per-field mode), and Recur args (loop binders carry no mode) stay Mode::Owned. Let.mode has no source (ast::Term::Let has no mode field) and no consumer (the let-drop gate reads consume, not Let.mode), so it stays Owned — filling it would invent a value nobody reads. Safety property held: MArg.mode is filled from the same callee fn-type the gate read from the table, so the drop fires identically. For (app RawBuf.get (app RawBuf.set …) 0), RawBuf.get's param 0 is borrow, so args[0].mode = Borrow — exactly the value module_def_ail_types yielded. Confirmed by the anon-temp witness staying green. ParamMode -> Mode conversion: Borrow -> Mode::Borrow; Own and Implicit (the Implicit ≡ Own contract) -> Mode::Owned. The own-param drop keeps reading ParamMode off f.ty (Type/ParamMode imports retained, live readers at lib.rs:1356/1507). Also retires three now-stale doc comments that named the deleted module_def_ail_types field (ailang-check/src/lib.rs, uniqueness.rs, and the codegen_import_map_fallback_pin test doc) — a direct consequence of the deletion, reworded to the current architecture. Verification (orchestrator, post-implement inspect): diff matches the plan (App-arg m_args built before m_callee consumes sig — the benign borrow-order deviation the plan's note flagged); module_def_ail_types grep-clean across all of crates/; cargo build --workspace clean (no unused/dead_code); cargo test --workspace 702 passed / 0 failed / 3 ignored (+1 = the new producer pin app_arg_carries_callee_borrow_mode; no #[ignore] added). The anon-temp drop-correctness witness raw_buf_owned_drop_balances_rc_stats stays live=0, now driven by MArg.mode; the other RC-stats leak pins green; #49 stays #[ignore] (mir.4); #51/#53 build guards green. mir.3 is now complete (3a relocated consume_count + deleted the second uniqueness run; 3b filled the mode annotation + deleted module_def_ail_types). Remaining: mir.4 (#49 StrRep RED->GREEN), mir.5 (element-type/Term::New + ledger). |
||
|
|
0bd7f47fad |
plan: mir.3b fill MArg.mode, switch the anon-temp gate onto it, delete module_def_ail_types
Executable plan for the second of two iterations delivering spec 0060's mir.3 row. A focused recon (the anon-temp gate + the per-arg mode sources) established two refinements to the spec sketch, both locked in the plan: 1. module_def_ail_types is deleted in mir.3b, not mir.5. A grep proved self.module_def_ail_types has exactly ONE reader — the emit_call anon-temp borrow-slot drop gate. The own-param drop reads param_modes from the def's own f.ty, not this table. So switching that gate onto MArg.mode leaves the table dead; deleting it now is the clean move. mir.5's "last re-derivation residue" shrinks to element-type/Term::New. 2. MArg.mode is filled for App args only; MTerm::Let.mode is not filled. Only App args have a real per-arg mode source (the callee Type::Fn.param_modes). Do/Ctor/Recur args have no per-arg mode (EffectOpSig/Ctor.fields/loop-binders carry none) -> stay Owned default. Let.mode has no source (ast::Term::Let has no mode field) and no consumer (the let-drop gate reads consume, not Let.mode) -> stays Owned. Safety property (recon-confirmed on the #43 anon-temp witness): MArg.mode is filled from the same callee fn-type the gate read from the table, so the drop fires identically. For (app RawBuf.get (app RawBuf.set …) 0), RawBuf.get's param 0 is borrow -> args[0].mode = Borrow, exactly the value module_def_ail_types yielded. Four tasks: (1) producer fills App-arg MArg.mode via a param_mode_at helper; (2) codegen gate reads arg.mode + delete module_def_ail_types (field + construction + threading, compiler-completeness-checked); (3) producer pin (App arg mode == Borrow) + the live=0 anon-temp acceptance (raw_buf_owned_drop_balances_rc_stats); (4) record the refinements in spec 0060. No new .ail fixture (reuses raw_buf_drop_min.ail). |
||
|
|
6bc0b501cb |
feat(codegen): relocate per-binder consume_count into MIR; delete codegen's second uniqueness run (mir.3a)
First of two iterations delivering spec 0060's mir.3 row (plan docs/plans/0117-mir.3a-consume-count-relocation.md). The per-binder consume_count — the only thing codegen read from its second infer_module_with_cross run — now lives on a new binder-keyed MirDef.consume: BTreeMap<String,u32>, filled once by lower_to_mir on the post-mono body. Codegen builds its existing (def,binder)->consume_count lookup from those maps and the three scope-close drop sites (own-param, let-binder, match-arm) read it; the codegen uniqueness run, its UniquenessTable field, and the import are deleted. mir.3 is split into two iterations (recorded in spec 0060): the named deletion depends ONLY on consume_count. All three drop sites read consume_count from self.uniqueness, while the modes they also gate on (param_modes for the own-param dec, scrutinee_is_owned for the match dec) come from the type, not the uniqueness pass (UniquenessInfo carries no mode). So mir.3a relocates consume_count and ships the deletion; mir.3b (next) fills MArg.mode/MTerm::Let.mode and switches emit_call's anon-temp gate onto MArg.mode. The split halves the change at the codebase's highest-risk surface (RC/drop correctness, the raw-buf leak saga) and makes each half independently verifiable against the live=0 leak pins. Design: consume_count lands on MirDef (binder-keyed), NOT on MArg. The drop sites key by (def, binder_name) — a per-binder aggregate — while the spec-sketched MArg.consume_count is per-arg-position and never reaches them; the per-def map matches the UniquenessTable's own keying and all three binder kinds (param/let/pattern) uniformly. MArg.consume_count stays a mir.1 default. Source = run check's infer_module_with_cross inside lower_to_mir post-mono (the synth_pure single-engine-re-walked pattern); retaining a check-time result is blocked (check's uniqueness is pre-mono and runs on-demand-from-codegen, never in check_workspace, so it would miss the mono specialisations). cross_module_types (= codegen's module_def_ail_types) is rebuilt in elaborate_workspace from the post-mono workspace. Safety property held: this is a PURE RELOCATION of an identical computation — infer_module_with_cross ran on the post-mono module in codegen and now runs on the same post-mono module in lower_to_mir, so drop placement is byte-identical. cross_module_types matches module_def_ail_types exactly (both insert f.name->f.ty over post-mono Def::Fn), confirmed by the leak pins staying green. Verification (orchestrator, post-implement inspect): diff matches the plan; codegen grep-clean for infer_module_with_cross + UniquenessTable (now ailang-check-internal); cargo build --workspace clean (no errors, no unused warnings — module_def_ail_types survives for emit_call's anon-temp param_modes gate); cargo test --workspace 701 passed / 0 failed / 3 ignored (+1 = the new producer pin mirdef_consume_is_populated_for_let_binder; no #[ignore] added). The live=0 drop-correctness witnesses all green: alloc_rc_loop_valued_rawbuf_binder_drops_at_scope_close (#43/#47), alloc_rc_heap_loop_binder_replaced_by_recur_does_not_leak, alloc_rc_print_int_does_not_leak_show_result_str. #49 stays #[ignore] (mir.4); #51/#53 build guards green. |
||
|
|
69749fc3e1 |
plan: mir.3a relocate consume_count into MIR, delete codegen's second uniqueness run
Executable plan for the first of two iterations delivering spec 0060's mir.3 row. Two plan-recon passes (mode/consume/drop-placement surface + the uniqueness-pass signature) established that the named deletion (the second infer_module_with_cross run) depends ONLY on consume_count: all three codegen scope-close drop sites read consume_count from self.uniqueness, while the modes they also gate on (param_modes for the own-param dec, scrutinee_is_owned for the match-arm dec) come from the type, not the uniqueness pass (UniquenessInfo carries no mode). So mir.3 splits: - mir.3a (this plan): relocate consume_count; delete the second run. - mir.3b (next): fill MArg.mode / MTerm::Let.mode from the type modes and switch emit_call's anon-temp borrow-slot gate onto MArg.mode. The split halves the change at the codebase's highest-risk surface (RC/drop correctness, where the raw-buf leak saga lived) and makes each half independently verifiable against the live=0 leak pins. Design decisions locked in the plan: - consume_count lands on a new binder-keyed MirDef.consume: BTreeMap<String,u32>, NOT on MArg. The recon surfaced the central structural tension: the drop sites key consume_count by (def, binder_name) — a per-binder aggregate — while the spec-sketched MArg.consume_count is per-arg-position and never reaches them. The per-def binder map matches both the UniquenessTable's own keying and all three binder kinds (param/let/pattern) uniformly. MArg.consume_count stays a mir.1 default (no consumer). Spec 0060 is updated to record this (Task 5). - Source = run check's infer_module_with_cross inside lower_to_mir on the post-mono module (the synth_pure single-engine-re-walked pattern). The alternative of retaining a check-time result is blocked: check's uniqueness runs pre-mono and on-demand-from-codegen, never during check_workspace, so a retained result would miss the mono specialisations. cross_module_types (= codegen's module_def_ail_types, module->def->Type) is rebuilt in elaborate_workspace from the post-mono workspace. Key safety property carried in the plan: this is a PURE RELOCATION of an identical computation. infer_module_with_cross ran on the post-mono module in codegen and now runs on the same post-mono module in lower_to_mir — same function, same input, same output — so drop placement is byte-identical. The live=0 leak pins (raw_buf_loop_no_leak, loop_recur_heap_binder, print_no_leak) are the value-correctness gate. Five tasks: (1) add MirDef.consume; (2) elaborate_workspace builds cross_module_types + lower_module runs the pass and fills consume; (3) codegen builds its (def,binder)->consume lookup from MIR, the three drop sites read it, delete the run + field + import; (4) producer pin + leak-pin acceptance + grep-clean; (5) record the split in spec 0060. No new .ail fixture (producer pin reuses new_counter_user_adt.ail). |
||
|
|
a6fd93adba |
feat(codegen): pre-resolve the App callee in MIR; delete is_static_callee + type_home_module (mir.2)
Second re-deriver removal of the typed-MIR milestone (spec
docs/specs/0060-typed-mir.md, plan docs/plans/0116-mir.2-callee-static.md).
Static-callee resolution moves out of codegen and into lower_to_mir:
every Term::App callee is classified once, mirroring check's own synth
Term::Var ladder (lib.rs:3409-3582), and emitted as a resolved Callee.
Codegen reads the callee identity off MIR and routes each kind to the
right lowering. The codegen re-derivers is_static_callee and
type_home_module are deleted, and the lower_app <-> is_static_callee
lockstep pair is struck from CLAUDE.md.
Callee reshape (beyond the spec's original {module, fn_name} sketch —
spec 0060's Concrete-code-shapes block is updated in this iteration):
pub enum Callee {
Static { module, fn_name, sig: Type }, // user/prelude fn -> emit_call, module pre-resolved
Builtin { name, sig: Type }, // operator/intrinsic -> inline opcode lowering
Indirect(Box<MTerm>), // dynamic: fn-pointer / closure / shadowed name
}
Two forced refinements, both settled in planning:
- sig:Type on each resolved variant. drop::synth_callee_ret_mode reads
the callee ret_mode, today off Callee::Indirect(inner).ty(). A
resolved callee has no sub-term, so the sig (= synth_pure(callee),
mode-preserving) is the lossless replacement. This is NOT a mir.3
pull-forward: the MArg param-mode / consume_count fields stay at their
mir.1 defaults. Without it, every statically-resolved call would lose
its drop signal and the RawBuf / int_to_str RC-stats pins mir.1b fixed
would regress.
- Builtin variant. Operators and the str-num builtins (+, not,
str_concat, ...) are lowered inline by name and have no module/fn_name;
a separate variant is cleaner than a sentinel module. The classifier
decides Builtin vs Static from CHECK'S OWN resolution (a name in
env.globals with no owning module is a builtin), never a copy of
codegen's old is_static_callee allowlist. The recon's divergence audit
confirmed check's builtin set and codegen's inline-arm set match
exactly, so the single-engine rule is mechanically satisfiable with no
env threading gap.
type_home_module is fully deletable: a workspace-wide grep confirmed no
program references a type-scoped T.fn as a value, so its only other
consumer (resolve_top_level_fn) collapses to the module-name fallback
with no behaviour change. (The spec's "x3 mirrors" wording over-counted;
the live surface was the definition plus two call sites.)
Alternatives rejected during planning: sentinel module in Static
(ugly); builtins left as Indirect (breaks — no fn-pointer for an
operator); name-rewrite Static{name,sig} keeping lower_app's by-name
dispatch (blurs builtin/user and still needs sig). The mir.1b
re-synth-strictness trap (post-mono synth_pure re-unify surfacing
mode-strip / bare-vs-qualified / metavar leaks) did NOT fire under the
new producer path — qualify_local_types mode-preservation and the $u
wildcard normalisation from mir.1b held.
Verification (orchestrator, post-implement inspect): diff matches the
plan; grep-clean for is_static_callee + type_home_module across
ailang-codegen and ail; cargo build --workspace green; cargo test
--workspace 700 passed / 0 failed / 3 ignored (no #[ignore] added this
iteration — the 3 are the pre-existing #49 Str-leg pin + two doctests).
Acceptance witnesses green: #53 (mono_new_over_user_adt_builds_and_runs),
#51 (rawbuf_new_int_size_only_builds_and_runs still builds), show_print
cross-module (print_user_adt_runs_end_to_end), the new mir.2 pins
(mir2_resolved_callee_kinds_run_end_to_end, callee_classification_*,
strengthened new_over_user_adt_carries_node_types asserting Callee::Static).
#49 heap-Str loop binder stays #[ignore] (lifts at mir.4).
Two new examples/ fixtures back the new pins (classify_pin.ail for the
producer classification pin, mir2_callee_kinds.ail for the E2E),
ail-parse / round-trip clean.
Forward note (cycle-close architect): crates/ail/tests/codegen_import_map_fallback_pin.rs:14-15
doc prose names lower_app's cross-module arm and synth_with_extras as
failure-mode targets — both now deleted (mir.2 / mir.1b). The pin's
protected invariant is still valid and green; the prose is stale and
wants a doc-honesty reword, left out of this iteration's footprint.
|
||
|
|
d81ea93de6 |
plan: mir.2 Callee::Static pre-resolved, codegen stops re-deriving the callee
Executable plan for spec 0060's mir.2 iteration: relocate static-callee
resolution from codegen into lower_to_mir. Two plan-recon passes (codegen
consumer surface + check-side producer surface) mapped the resolution
frontend; this plan locks three design decisions made during planning,
ahead of the tasks:
1. The Callee bridge enum is reshaped beyond the spec sketch: a third
variant Builtin{name,sig} (operators / str-num intrinsics have no
module/fn_name and are lowered inline by name), and a sig:Type on each
resolved variant. The sig is the lossless replacement for the pre-mir.2
Callee::Indirect(inner).ty() read that drop's synth_callee_ret_mode
depends on; it is synth_pure(callee), mode-preserving. NOT a mir.3
pull-forward — the MArg param-modes stay at mir.1 defaults. Spec
0060's Concrete-code-shapes Callee block is updated as part of the
iteration (Task 5).
2. Classification mirrors check's own synth Term::Var ladder
(lib.rs:3409-3582), never copies codegen's is_static_callee allowlist.
The recon's divergence audit confirmed check's builtin set and
codegen's inline-arm set match exactly, so the single-engine rule is
mechanically satisfiable with no env threading gap.
3. type_home_module is fully deletable: a workspace-wide grep confirmed no
program references a type-scoped T.fn as a value, so its second
consumer (resolve_top_level_fn) collapses to the module-name fallback
with no behaviour change. The spec's "grep-clean" acceptance holds; the
spec's "x3 mirrors" wording over-counts (def + 2 live sites).
Five tasks: (1) reshape Callee; (2) lower_to_mir classify_callee +
App arm; (3) the atomic codegen consumer switch (App arm/drop/lower_app
split/delete is_static_callee+type_home_module/resolve_top_level_fn);
(4) pins (strengthen #53 to assert Callee::Static, add a three-way
classification pin) + workspace suite; (5) strike the lower_app <->
is_static_callee lockstep row from CLAUDE.md, update spec 0060, clean
stale comment references. The classify_pin fixture is ail-parse-gated
(exit 0).
|
||
|
|
895ba846e8 |
feat(codegen): switch the lowering walk from &Term to typed &MTerm (mir.1b)
Atomic completion of spec iteration mir.1 (docs/specs/0060-typed-mir.md): codegen now consumes the typed MIR produced by `lower_to_mir` instead of re-deriving types from the bare `ast::Term`. Every codegen helper that took `&Term` (lower_term, lower_app, drop.rs, match_lower.rs) takes `&MTerm` and reads each node's checker-proved type off `MTerm::ty()`. The build path is `Workspace -> elaborate_workspace -> MirWorkspace -> lower_workspace`; the public `lower_workspace*` entry points and their 18 call sites thread `&MirWorkspace`. The codegen-side type re-derivers `synth_with_extras` + `synth_arg_type` (and the `builtin_ail_type` / `builtin_effect_op_ret` mirror tables) are deleted — grep-clean. The three re-derivers the spec keeps until mir.2/mir.3 (`type_home_module`, `is_static_callee`, the second `infer_module_with_cross`) stay. The mechanical Term->MTerm match-arm conversion was straightforward and compiler-enforced. The substance was a set of producer-side correctness gaps that only surface once codegen reads `MTerm::ty()` and once the build path re-synthesises the post-mono AST through the canonical `synth` (which, unlike the old codegen, fully re-unifies). Each was root-caused against a failing e2e fixture: 1. `qualify_local_types` stripped fn-type modes (rebuilt `Type::Fn` with empty `param_modes` / `Implicit` `ret_mode`), so a monomorphised polymorphic intrinsic (`RawBuf.set`) lost its `Own` ret-mode and the owned temporary leaked at the call site. Made mode-preserving, like its sister `qualify_workspace_types` and `Subst::apply` ( |
||
|
|
449df13c9c |
fix(check): preserve fn-type modes through Subst::apply
`Subst::apply` rebuilt every `Type::Fn` with `param_modes: vec![]` and `ret_mode: Implicit`, discarding the modes the surrounding type carried. That was harmless while nothing downstream of inference read fn-type modes — but it is a latent under-specification: substitution is meant to be type-preserving, and a fn type's modes are part of its identity (an `(own T) -> (own U)` contract is not the same contract as its borrow-returning sibling). Modes are positional over `params`, and substitution remaps each param type element-wise without changing the arity, so the mode lists stay aligned — preserving them is a clone, not a re-derivation. The typed-MIR boundary (milestone 0060) makes this load-bearing: a node type synthesised by `lower_to_mir::synth_pure` ends with `subst.apply`, and the codegen drop path reads `ret_mode == Own` off a call's callee node to decide RC trackability. With the modes stripped here, an Own-returning call read back `Implicit`, nothing was trackable, and a heap-allocated binder leaked at scope close. Preserving the modes restores that signal at the source rather than re-deriving it in codegen (which is exactly the re-derivation the milestone exists to delete). Safe by construction: `unify`'s Fn arm destructures modes with `..` and compares only params/ret/effects, so carrying modes through `apply` cannot change unification; and `Type`'s custom `PartialEq` already treats `Implicit` and `Own` as interchangeable (only `Borrow` is distinct), so equality outcomes are unaffected except where preserving a `Borrow` is the more correct answer anyway. Whole `ailang-check` suite stays green. RED pin: crates/ailang-check/tests/subst_preserves_modes.rs. |
||
|
|
600565d356 |
fix(check): localize own-ADT type-args in free-fn mono; complete the lower_to_mir producer
Additive producer-side work for the typed-MIR milestone (spec
docs/specs/0060-typed-mir.md), surfaced while building the mir.1b
codegen-consumption switch. Nothing here consumes MIR — codegen is
untouched; the whole existing suite stays green and one new RED pin
goes green. The mir.1b codegen switch itself stays in the working tree.
Headline fix — own-ADT type-arg localization in free-fn mono:
monomorphise_workspace's free-fn arm normalises every observed
type-arg through Registry::normalize_type_for_lookup. That
normalisation is load-bearing for cross-module dedup and a stable
specialisation symbol name, but it qualifies a *defining-module-own*
ADT to `<module>.<T>`. synthesise_mono_fn_for_free_fn then substitutes
those qualified type-args into the polymorphic source signature and
body, which are in the module's own *bare* convention (the qualify
pre-pass deliberately leaves own-module type-cons bare — lib.rs:4358,
"the current module sees its own types bare"). The result is a
self-inconsistent specialisation: e.g. fold_left specialised with
accumulator b := List<Int> minted
((std_list.List<Int>, Int) -> std_list.List<Int>,
std_list.List<Int>, List<Int>) -> std_list.List<Int>
— a qualified accumulator parameter against a bare list argument and a
bare Lam body. check_workspace never hits this (it synths the pre-mono
bodies, bare throughout); the old codegen tolerated it via the
now-deleted synth_arg_type's loose type-tracking. The post-mono synth
re-entry in lower_to_mir does full unification and correctly rejects
it: `expected std_list.List<Int>, got List<Int>`.
Fix: localize_own_types (mono.rs) strips the `<defining_module>.`
prefix back to bare for the module's own ADTs, applied to the type-args
before they are substituted, so the synthesised signature and body stay
uniformly bare-own. The *stored* type_args remain normalised, so the
symbol name and the Phase-3 rewrite-cursor key on the same canonical
form — the byte-identity invariant between synthesis and rewrite is
untouched. The ClassMethod arm needs no analogue: it already
substitutes the un-normalised target.type_ (mono.rs:841).
Alternatives rejected: (a) drop the normalisation in the shared
apply_subst_and_normalize helper — breaks cross-module dedup, mints
duplicate specialisations under divergent names. (b) Make
lower_to_mir's synth treat `<owner>.T` and bare `T` as equal during
unification — papers over the producer inconsistency in the consumer
and contradicts the established own-types-stay-bare invariant the rest
of check relies on. (c) Qualify the whole synthesised def own->qualified
— fights lib.rs:4358 and would require the lower path to also flip,
cascading the convention change through synth.
Producer completeness (additive, mirrors existing guards):
- lower_module skips Type::Forall defs (lower_to_mir.rs), mirroring
emit_module's guard. Polymorphic defs are stale source kept for
round-trip; their bodies carry mono-rewritten call names for
specialisations that were never synthesised, so a synth re-entry
would hit UnknownIdentifier.
- lower_module lowers non-literal const bodies to typed MTerm, carried
in MirModule.consts (+ MirConst in ailang-mir). A non-literal const
(e.g. a List ctor expression) needs a typed body once the consumer
walk takes &MTerm; literal consts emit a global and need no body.
- MirModule gains `ast: Module`, populated by lower_module — forward
scaffolding the mir.1b codegen consumption reads for module
structure. Populated here, consumed by the in-tree switch.
Verification: full `cargo test --workspace` green (697 passed, 0 failed)
with the codegen-consumption switch stashed out, including the new pin
crates/ailang-check/tests/lower_to_mir_ty.rs::poly_free_fn_accumulating_into_own_adt_elaborates
(RED before the localize fix with the exact mismatch above, GREEN after)
and the full e2e corpus (the own-ADT-poly demos elaborate clean and run
through the old codegen). This proves the producer fix is
regression-free and the remaining 22 e2e reds in the working tree are
codegen-consumption defects, not producer defects.
|
||
|
|
5b4eb766c3 |
glossary: bootstrap AILang nomenclature ledger
Add design/glossary.md (25 entries) and wire it in via the paths.glossary profile slot, making it standing reading for every skill and agent role. Built by the glossary skill's bootstrap procedure: five read-only glossary-extractor agents swept the prose surface (design/contracts, design/models, design/INDEX.md, docs/PROSE_ROUNDTRIP.md, and the 60 docs/specs in three blocks), reporting recurring domain-concept terms and their competing synonyms with frequencies. docs/plans was excluded as a derived execution artefact that mirrors spec/contract vocabulary rather than originating it. Three apparently-contested clusters were resolved by record-reality against the authoritative current docs (CLAUDE.md + design/): the retired .ailx extension yields to .ail; Form-A wins over "Form A" (13:2); round-trip wins over roundtrip (15:10). The one genuine nomenclature choice -- the canonical term for the structured hashable form -- was decided by the user: JSON-AST is canonical, .ail.json is its on-disk file. The local conformance check caught and removed a Form-B / JSON-AST Avoid-list collision before write. |
||
|
|
1a1a68df8b |
plan: mir.1b — codegen consumes MIR (the atomic switch)
Second half of spec iteration mir.1, planned against the landed mir.1a
infrastructure (
|
||
|
|
135f4ceed7 |
feat(check): mir.1a — typed-MIR types + post-mono lower_to_mir + elaborate_workspace (additive)
First half of spec iteration mir.1 (docs/specs/0060-typed-mir.md, plan docs/plans/0114-mir.1a-mir-infrastructure.md). Purely additive: a new leaf crate plus a post-mono lowering walk plus the front-end entry. Nothing consumes MIR yet — codegen and the CLI are untouched — so the whole existing suite stays green (102 test-result-ok lines, 0 failed; the one ignored is the #49 pin, which lifts at mir.4). mir.1b flips codegen to consume MIR and deletes the synth_with_extras mirror. What lands: - `ailang-mir` (new leaf crate, depends only on ailang-core): MTerm / MArg / MArm / MLoopBinder / MNewArg / Callee / Mode / StrRep / MirDef / MirModule / MirWorkspace, structurally complete over all 17 Term variants plus the dedicated Str split (MTerm::Str{lit,rep}). Every later-iteration annotation field exists now at a mir.1 default (App.callee = Indirect, MArg.mode/consume_count = Owned/1, Str.rep = Static, New.elem = None); the struct shape will not change across mir.2–mir.5, only the values. MTerm::ty() reads the carried type. - `ailang-check::lower_to_mir`: the post-mono walk. Carries no second type engine — it calls the canonical `synth` per node with fresh throwaway inference scaffolding (subst/counter/residuals/…), applies the substitution, and threads only the lexical locals/loop_stack, maintained by the same push/pop rules synth uses (Let, Loop, Lam, LetRec, and Match via synth's own type_check_pattern). This is the "one engine, not two" property the milestone buys, on the producer side. - `ailang-check::elaborate_workspace`: the single front-end entry — check (diagnostics gate) → desugar+lift → monomorphise → lower_to_mir — transcribed from the CLI build path. check_workspace stays for the diagnostics-only callers. - Three ty-fill pins (crates/ailang-check/tests/lower_to_mir_ty.rs) load the #51/#53/#49 witnesses as fixtures and elaborate the whole workspace, so lower_to_mir is exercised over the full prelude+kernel, not just the tiny witness. Two new witness fixtures (examples/new_{rawbuf_size_only,counter_user_adt}.ail); #49 reuses the existing loop_recur_str_binder_no_leak_pin.ail. Deviations from the plan, all forced by ground truth and verified against the live code before commit: - FnDef.export is Option<String>, not bool — dropped from MirDef (the plan's note allowed this fallback). Codegen reads FnDef.export directly, unaffected. - fn_param_types did not exist — lifted the param-type extraction into a shared pub(crate) fn and rewired both mono.rs sites (collect_mono_targets, collect_rewrite_targets) to it, so the param-type source cannot drift between mono and lower_to_mir. The helper unwraps a top-level Forall then reads Type::Fn.params — identical to the inner_ty extraction it replaces; the early-return non-fn guard is preserved at both sites. Behaviour-preserving (whole suite green; this is the only edit to existing mono behaviour). - lower_module mirrors two more mono.rs scaffolding rules the plan under-transcribed: skip intrinsic-bodied fns (they are signature-only; lowering them would hit synth's Term::Intrinsic guard) and seed env.current_module + env.globals + env.imports per module so synth's Var lookup resolves post-mono specialisations (e.g. compare__Int) in the prelude. - The #53 pin asserts the lowered (new Counter 42) init carries ty Counter, not that it is an MTerm::New: a monomorphic `new` over a user ADT is desugared to a dotted call `(app Counter.new 42)` before lower_to_mir runs (desugar.rs:1078-1098), so it lowers to MTerm::App. MTerm::New survives only for a polymorphic `new` carrying a written NewArg::Type (the #51 RawBuf path, covered by the rawbuf pin). The pin's intent — ty-fill correctness — is preserved; the structural variant is mir.2/mir.5 territory. - ailang-mir uses workspace-inherited Cargo fields for consistency with the existing crates (added it to [workspace.dependencies]). refs #49 |
||
|
|
438a009b83 |
plan: mir.1a — typed-MIR infrastructure (additive half of spec mir.1)
First of two plans for spec iteration mir.1 (docs/specs/0060-typed-mir.md).
mir.1 is the project's largest iteration — a full codegen frontend
switch from walking &Term to walking &MTerm — and it does not fit one
bite-sized plan, because the switch is atomic: there is no compiling
intermediate between "all Term" and "all MTerm" (dual-path is
spec-forbidden). The MIR *producer* side, by contrast, compiles and
tests in isolation. So mir.1 is sequenced across two plans:
- mir.1a (this plan): the additive producer — new leaf crate
ailang-mir (MTerm/MArg/Callee/Mode/StrRep, structurally complete over
all 17 Term variants plus the Str split), ailang-check::lower_to_mir
(post-mono walk that calls canonical synth per node and maintains
locals/loop_stack exactly as synth does — no second type engine), and
ailang-check::elaborate_workspace (check → desugar+lift → mono →
lower_to_mir). Nothing consumes MIR yet; codegen untouched; whole
suite stays green; three ty-fill pins load the #51/#53/#49 witnesses
as fixtures and elaborate the full workspace (prelude included).
- mir.1b (next plan, against the landed types): flip codegen's walk to
&MTerm, delete synth_with_extras + synth_arg_type (9 call sites across
lib.rs/drop.rs/match_lower.rs), thread the 18 lower_workspace* callers,
switch the CLI build path to elaborate_workspace.
Both together satisfy the spec's mir.1 row. The split is plan
granularity, not a spec change — the spec's mir.1 deliverable is
unchanged.
Orchestrator design calls baked in (planner judgement, no user fork):
dedicated MTerm::Str{lit,rep} variant (Str-split installs the mir.4
hook at the #49 loop seed); emit_ir keeps &Module (builds MIR
internally — minimal blast radius); lower_to_mir scaffolding mirrors
mono.rs:771-816; Match-arm binding reuses synth's own type_check_pattern.
Recon (plan-recon) mapped the full deletion blast radius for mir.1b
(9 synth_arg_type sites, not the 3 the focus-hint assumed; 18
lower_workspace* callers). The two new witness fixtures
(new_rawbuf_size_only, new_counter_user_adt) were verified ail
check-clean during planning.
|
||
|
|
8bc2972594 |
spec: typed-mir — place lower_to_mir post-mono, correct one-engine framing
plan-recon flagged that monomorphise_workspace runs between check and codegen, and codegen lowers the post-mono workspace. The first draft of this spec placed lower_to_mir as the final phase of check_workspace (pre-mono) and claimed "nothing re-walks the AST". That is wrong on both counts: - Built pre-mono, MIR would not carry the monomorphic specialisations monomorphise_workspace appends — exactly the post-mono bodies codegen emits today — reintroducing the check↔codegen gap this milestone exists to close. - synth is a pure `&Term → Type` function and the authoring AST carries no node-ids, so MIR cannot be a side-table read off a check pass; it is built inline by a walk. The honest property is therefore not "no second walk" but "no second *engine*": codegen's synth_with_extras is a hand-copied mirror of synth that drifts (every raw-buf bug is a drift point). The milestone replaces the mirror with a single post-mono call to canonical synth, materialised as MIR. Corrections: architecture diagram and data flow now run check → lift → monomorphise → lower_to_mir; lower_to_mir is wrapped by a new front-end entry elaborate_workspace that subsumes the lift+mono orchestration the CLI does today; check_workspace stays for the diagnostics-only path (`ail check`). Node count fixed 27 → 17 Term variants (ast.rs:436). Re-dispatched grounding-check after the edit (post-PASS edit invalidates the prior report) — PASS, all load-bearing post-mono assumptions ratified against live code (main.rs build order, mono.rs synth use, 17-variant Term, no node-ids). |
||
|
|
207c63649f |
spec: typed-mir check→codegen boundary contract
Open a new milestone introducing a typed mid-level IR (`ailang-mir`) as the single artefact crossing the check → codegen boundary, so codegen re-derives nothing. Root cause (architect drift review): the boundary today carries no typed artefact. `crates/ail/src/main.rs:2293` calls `check_workspace`, discards the result, and hands `lower_workspace` the raw `Workspace`. `CheckedModule` (ailang-check/src/lib.rs:1283) carries only top-level (Type, hash). Codegen therefore re-runs four independent derivations of what check already proved — type synthesis (`synth_with_extras`), callee resolution (`type_home_module` ×3 + `is_static_callee`), uniqueness/mode (`infer_module_with_cross`, second run), and Str representation (`!is_str` recur gate). Every raw-buf bug (#43/#46/#47/#49/#51/#53, all "check-clean, build-divergent") is a point where one of those re-derivations disagreed with check and got patched one leg at a time. Honest framing: this is removal of a re-derivation architecture, not a RED→GREEN of crashing programs. #51 and #53 build today (patched by |
||
|
|
02775b58ca |
test(codegen): mark the #49 Str-leg RED pin #[ignore] pending the representation spec
The Str-leg pin (committed |
||
|
|
04f75c8b71 |
test(codegen): RED-pin #49 Str leg — heap-Str loop binder must not leak across recur
A heap-Str accumulator threaded through a `(loop …)`/`recur` as a loop binder leaks: each iteration's superseded `str_concat` slab is never RC-dec'd, even when the loop's final result is consumed. The fixture reports `allocs=4 frees=1 live=3` under AILANG_RC_STATS (stdout `xyyy` is correct). Root (data-flow traced): the `Term::Recur` arm in crates/ailang-codegen/src/lib.rs (~2131) gates its superseded-value dec behind `!is_str`. That exclusion exists because a `Str` loop seed may be a static-literal Str — a constexpr GEP into a packed-struct global with no rc_header (lib.rs:1612) — and `ailang_rc_dec` on a static pointer reads garbage at `payload-8` and aborts on underflow (rc.c:221, no runtime guard, per design/contracts/0011-str-abi.md). So the Str binder's prior heap slab is overwritten by a plain `store` with no dec. Distinct from the loop-RESULT scope-close drop ( |
||
|
|
420703d321 |
fix(codegen): resolve the dotted T.new user-ADT callee, symmetric to check
A monomorphic `(new Counter 42)` over a user-defined ADT that declares a `new` fn passed `ail check` but crashed `ail build --alloc=rc` with `unknown variable: Counter.new` (RED-pinned in |
||
|
|
1eff055a0e |
test(codegen): RED-pin #53 — monomorphic (new T args) over a user ADT must build
A monomorphic `(new Counter 42)` over a user-defined ADT that declares a
`new` fn passes `ail check` but crashes `ail build --alloc=rc` with
`unknown variable: Counter.new`. Distinct root from #51 (the polymorphic
type-arg drop, fixed by
|
||
|
|
ee4107c721 |
fix(check): honour the written element type-arg on polymorphic (new T …)
A `(new RawBuf (con Int) 3)` whose buffer is never observed by a later
get/set passed `ail check` but crashed `ail build` with
`RawBuf_new__Unit has no registered intercept`, and a forbidden
`(new RawBuf (con Unit) 3)` was wrongly accepted at check. Both legs
share one root: the `Term::New` desugar discarded the author's written
`NewArg::Type` before anything could use it.
This was never an inference problem. The element type is EXPLICITLY
known — the author wrote `(con Int)`. The defect was that the desugar
threw that known type away and relied on re-discovering it from
downstream use; with no use (size-only), `RawBuf.new`'s result-only
forall var stayed unpinned, mono Unit-defaulted it, and codegen aborted
on the unregistered `RawBuf_new__Unit` intercept. The fix USES the
written type instead of inferring it.
Mechanism:
- desugar.rs: a polymorphic `(new T <elem> …)` (carrying a written
`NewArg::Type`) now SURVIVES into check instead of being lowered to
`(app T.new …)`. A monomorphic `(new Counter 42)` (no type-arg) keeps
the raw-buf.4 app-lowering unchanged.
- lib.rs synth `Term::New` arm: (a) validates the written element type
against the constructed type's `param_in` set via
`check_type_well_formed`, firing `ParamNotInRestrictedSet` naming the
forbidden element (leg 2) — synth is the correct site because it has
Env/registry access, unlike `pre_desugar_validation`; (b) binds the
result-only forall var DIRECTLY to the written type (`?a := Int`, a
trivial binding, no inference channel) by pushing a `FreeFnCall` whose
metas are the written concrete types, so mono mints
`RawBuf_new__<elem>` and never `__Unit`.
- mono.rs: `rewrite_mono_calls` and `interleave_slots` gain matching
`Term::New` arms that each consume exactly one free-fn slot at a
type-arg-bearing `Term::New` (mirroring synth's single observation,
pushed before the value-args), and `rewrite_mono_calls` reduces the
node to `(app <mangled> <value-args>)` so codegen never sees
`Term::New` and the lib.rs:2200 `unreachable!` stays valid. The two
walkers stay in lockstep.
The Unit-default policy in mono stays correct for genuinely-unobservable
vars (`is_empty(Nil)` etc.); only the case with a written `NewArg::Type`
is changed.
Alternatives rejected: an additive `type_args` field on `Term::App`
(~100 construction sites across 7 crates — a detour that builds a new
transport slot only to copy the already-known type into it); a runtime
type-witness parameter on `RawBuf.new` (invents a runtime value for a
pure type property); an identity-lambda wrapper carrying the element in
its param-type (hides a semantic property in a runtime construct). The
written type belongs where the author put it, carried to the one point
that consumes it.
Verification: both RED legs (committed
|
||
|
|
61b9d3f513 |
test(raw-buf): RED-pin #51 — (new T <elem> …) must honour the element type-arg
Two failing E2E tests pinning the two legs of the #51 codegen crash, both rooted in the `Term::New` desugar dropping `NewArg::Type` (crates/ailang-core/src/desugar.rs:1053): - Leg 1 (legitimate): `(new RawBuf (con Int) 3)` used only via `RawBuf.size` — never observed by get/set, so the dropped `(con Int)` is the sole element-type carrier. The unpinned forall var defaults to Unit in mono and `ail build --alloc=rc` aborts on the unregistered `RawBuf_new__Unit` intercept. Pins build+run printing `3`. - Leg 2 (forbidden): `(new RawBuf (con Unit) 3)` — Unit is outside the param-in set {Int, Float, Bool}, but the dropped type-arg never reaches param-in enforcement, so `ail check` wrongly passes. Pins a `param-not-in-restricted-set` rejection naming Unit at check time. Both RED at HEAD. GREEN side carries the explicit element type from Term::New through to monomorphisation (must not mint __Unit) and adds the leg-2 reject pre-desugar per the Term::New/desugar lockstep. refs #51 |
||
|
|
1b2d23ec42 |
fieldtest: raw-buf comprehensive — 4 examples, 8 findings (refs #7)
Comprehensive usability re-test of the RawBuf kernel extension in natural LLM-author decompositions the prior field test (0058) did not exercise: a Float RawBuf in a user ADT read through two borrow helpers, a Bool flag buffer filled with a computed value, an Int fib buffer whose fill reads its own writes plus two composed borrow summaries, and a forbidden core-primitive element. All three working fixtures build, run to expected values, and report live=0 across Float/Bool/Int widths — the core RawBuf surface (borrow-helper composition, fill loops, RawBuf-in-ADT, element widths) genuinely holds together. Orchestrator triage corrected the fieldtester's run, which executed a stale target/release/ail (built before the #50 fix). Every outcome was re-verified against a fresh build: - F2 (bare RawBuf ADT field unresolved) — RETRACTED: a stale-binary false positive. On a fresh build the bare name resolves in every configuration (ctor name == or != type name; builds/runs live=0). The #50 fix is correct and general. - F7 (NEW, issue #51) — a `check`-clean program crashes `build`: a buffer whose element type is never observed by a later get/set defaults its element to Unit in monomorphisation and hits an unregistered `RawBuf_new__Unit` intercept. Affects a legitimate `RawBuf<Int>` used only for its size AND a forbidden locally- constructed element. Root: the `(new T ...)` desugar drops the explicit element annotation; honouring it reverses the milestone's § Term::New desugar decision, so it routes through brainstorm. - F8 (process) — the field test ran stale; the fieldtester agent now builds from the current tree before running (plugin fix). F1 (spec_gap) resolved here: the design ledger's §Series substrate ctor wrote the storage field `(own (RawBuf a))`, which does not parse (`own` is fn-param/ret only). Rewritten to the verified-authorable `(con RawBuf a)`. The rest of the §Series listing is illustrative and its element- less `(new RawBuf lookback)` construction is entangled with #51; a full compile-verification of that listing belongs with the Series-substrate work. F5 (param-in rejects a forbidden core primitive, message names the set) and F4/F3 (borrow composition; substrate composite) carry on. F6 (param-in set rendered as a Rust-debug list) filed as #52. refs #7 |
||
|
|
f488d314e8 |
fix(codegen): dec superseded heap loop-binder values across recur (ADT leg)
A heap value threaded through a `(loop ...)`/`recur` as a loop binder
leaked: the `Term::Recur` arm stored each new arg into the binder's
loop-carried alloca with a plain `store` and never RC-dec'd the heap
value already in that slot, so every iteration's superseded value was
lost. The scope-close drop path (drop.rs, commit
|
||
|
|
a92bcaf969 |
fix(check): qualify cross-module kernel type-cons in user ADT fields
A consumer module's ADT field could reference a kernel-tier auto-imported type by its bare name in op/value positions (`(new RawBuf ...)`, `RawBuf.get`) but NOT in the type-constructor position of the field declaration: `(con RawBuf (con Int))` stayed the unqualified `RawBuf<Int>` and failed to unify with the constructor argument's `raw_buf.RawBuf<Int>`, so a RawBuf could not be stored in a user ADT field without spelling the qualified `raw_buf.RawBuf`. `qualify_workspace_module` skipped `Def::Type` entirely, and the `Term::Ctor` arm only qualified field types of cross-module *owning* types — a *local* type carrying a cross-module field was the uncovered case. The fix qualifies each ctor field type in the `Def::Type` arm via the existing `qualify_workspace_types`, which upgrades only genuinely cross-module type-cons (skips `own_local_types` and primitives), so a purely-local field is never spuriously rewritten. This is a check-side workspace transform, not the on-disk canonical form, so no module hash drifts (both hash-pin tests stay green). RED test `rawbuf_in_user_adt_field_resolves_bare_name` in crates/ailang-check/tests/workspace.rs, with the bare-name fixture (now checks/builds/runs -> 42, live=0, the ADT drop cascade frees the buffer slab) and the qualified control twin. This is the Series-substrate shape (a RawBuf wrapped in a user ADT), so the inconsistency would have bitten the pending series milestone. closes #50 |
||
|
|
43e3b21080 |
audit: raw-buf usability repair — cycle tidy (refs #7)
Close-out audit for the raw-buf usability repair (range 8e9f0f0..HEAD: B1 #46, B2 #47, B3 #48, B5 loop-result drop, docs). Architect drift review (against design/INDEX.md + the kernel-extensions model): all three lockstep-invariant pairs undisturbed (INTERCEPTS ↔ intrinsic markers, lower_app ↔ is_static_callee, Pattern::Lit ↔ pre_desugar_validation — none touched; the edits sit in linearity, synth_with_extras, and drop.rs). Every fix shipped a property-protecting RED test. B3 brings the diagnostic into conformance with the model's pre-existing §4 promise rather than opening a gap. One drift item raised and fixed here: the B4 ratification paragraph in design/models/0007 §RawBuf overstated the set non-enforcement as unconditional. Verified: a double-consume of an own-param RawBuf inside a fn with explicit modes fires [use-after-consume]; it slips through only where the linearity activation gate skips the fn (paramless main, or implicit-mode params). The paragraph now states the enforcement is gated, names the gate, and gives both the caught and the uncaught case. Regression (commands.regression, verbatim): - bench/check.py EXIT 0 — 34 metrics; 0 regressed, 34 stable - bench/compile_check.py EXIT 0 — 24 metrics; 0 regressed, 24 stable - bench/cross_lang.py EXIT 0 — 25 metrics; 0 regressed, 25 stable No baseline moved; carry-on. Two forward-queue items filed during the repair, both out of scope here: - #49 per-iteration leak of superseded heap loop-binder values across recur (a Str accumulator leaks; RawBuf does not — set is in-place). - #50 bare RawBuf not auto-imported in type-constructor positions (a RawBuf in a user ADT field needs the qualified raw_buf.RawBuf); the RawBuf-in-ADT substrate itself works (builds, runs, drop cascades leak-clean) with the qualified name. cycle raw-buf-usability tidy (clean). |
||
|
|
d5cc6e96b6 |
docs(raw-buf): refresh fixture outcomes; ratify set non-enforcement (refs #7)
- rawbuf_1_score_table / rawbuf_2_running_max: the header OUTCOME blocks described the pre-fix breakage (does-not-check / unknown-variable). Those defects are fixed (B1 #46, B2 #47, B5); update the comments to the current state — both check, build, run to their expected values and are leak-clean under AILANG_RC_STATS. The files now serve as positive regression examples, not bug repros. - design/models/0007 §RawBuf: ratify the fieldtest's B4 spec_gap. The `own -> own` signature of RawBuf.set is a threading discipline, not runtime-enforced single-use; a double-consume of the same owned buffer aliases one slab (consistent with every owned value in the language). State that single-use is the author's responsibility and full linear enforcement is Issue #22 territory. Surfaced by the raw-buf fieldtest (docs/specs/0058). |
||
|
|
8bcdae1b53 |
fix(codegen): drop a let-bound owned loop result at scope close
An owned heap value whose `let`-binding value is a `(loop ...)` result, afterwards only borrow-read (or unused), was never dropped at scope close — a memory leak (AILANG_RC_STATS live=1). Newly observable once #47 made fill-loops build: rawbuf_2_running_max leaked one buffer per run. Root cause: is_rc_heap_allocated — the predicate the let-lowering uses to decide a scope-close `dec` — matched only Term::Ctor, Term::Lam and Own-returning Term::App. A Term::Loop-valued binder fell through to false, so it was never registered for drop. (Sibling of the #47 synth gap: the Term::Loop arm under-handled in yet another pass.) Fix: add a Term::Loop arm that tracks the binder iff the loop's static result type lowers to llvm `ptr` (boxed/heap) AND is not Str — and widen drop_symbol_for_binder's App arm to cover Term::Loop so it resolves the per-type drop symbol. Two load-bearing safety gates: - the `ptr` gate excludes unboxed primitives (Int/Bool/Float/Unit), so an Int-returning loop is not handed to a drop fn; - Str is excluded because a loop can return a *static* Str (a seed literal threaded unchanged) and ailang_rc_dec on a static-Str pointer is UB; an Own-App can never return a static Str, which is why the App arm may track Str but the Loop arm must not. The loop's seed binders are consumed (moved in), so nothing else tracks the result; linearity guarantees no alias, so the new drop is single. Verified leak-clean and double-free-free across the fieldtest RawBuf set, the consumed case, the Int-gate case, and the escape case (a fn returning a loop-built owned RawBuf to its caller). The separate per-iteration leak of superseded heap loop-binder values (e.g. a Str accumulator threaded through recur) is a distinct, broader pre-existing bug, filed separately — not addressed here. RED-first: raw_buf_loop_no_leak_pin. |
||
|
|
db710b1a73 |
fix(codegen): replay loop binders in synth_with_extras (closes #47)
A `let`-bound `loop` whose body references one of its own loop binders on the non-recur exit passed `ail check` but failed `ail build` with `unknown variable`. The fieldtest surfaced this with an owned RawBuf loop binder, but the trigger is element-type-independent. Root cause: the Term::Loop arm of synth_with_extras (the type-replay walk the let-lowering runs via synth_arg_type on every let value) descended into the loop body WITHOUT adding the loop binders to `extras`, unlike the Term::Let arm which pushes its binder. A loop binder referenced on the non-recur exit then resolved to UnknownVar during the type replay. Fix: clone `extras`, push each loop binder's (name, ty), and thread the augmented extras into the recursive synth of the body — mirroring the Term::Let arm exactly. General fix; a plain Int let-bound loop that references its binder also builds now. This restores check<->codegen agreement for the natural fill-loop (thread an owned buffer of runtime length through a loop/recur, one RawBuf.set per iteration) — the way to populate a buffer whose length is not a fixed set of literal indices. Surfaced by the raw-buf fieldtest (finding B2, docs/specs/0058). RED-first: loop_let_bound_binder_reference_builds. |
||
|
|
744ad41e47 |
fix(check): resolve type-scoped borrow-receiver ops in linearity (closes #46)
A function whose parameter is `borrow (RawBuf a)` and which calls RawBuf.get or RawBuf.size on that parameter even once was rejected at `ail check` with [consume-while-borrowed] — the exact borrow-receiver use the ledger advertises for get/size. The diagnostic also misnamed the cause: the receiver is read, not consumed. Root cause: the linearity walk's callee_arg_modes looked up the callee under its spelled name. A type-scoped polymorphic op is spelled `RawBuf.get` at the call site, but check_module_with_visible registers the kernel raw_buf ops under their bare def names (get/size). The lookup missed, the receiver arg defaulted to Consume, and consuming a binder whose borrow_count==1 (the Borrow param) fired the false positive. The kernel signatures themselves are correct (receiver slot is `borrow`); the only defect was the name-resolution gap in the linearity globals lookup. Fix: on a direct-lookup miss, fall back to the bare method name via the existing parse_method_qualifier + qualifier_is_class_shape resolvers, gated on a class/type-shaped qualifier so lowercase cross-module-fn qualifiers (std_list.length) are excluded. This is how the rest of the checker already treats type-scoped polymorphic ops; the fix is general, not RawBuf-specific. This unblocks the natural borrow-helper decomposition (a shared read-only buffer behind a helper fn) — the shape the downstream series milestone's Series.at / Series.total_count need. Surfaced by the raw-buf fieldtest (finding B1, docs/specs/0058). RED-first: rawbuf_borrow_receiver_read_is_linearity_clean. |
||
|
|
75109f171d |
fix(check): param-not-in-restricted-set names the allowed set (closes #48)
The ParamNotInRestrictedSet diagnostic named the offending type, the
type-variable and the home type, but not the allowed set {Int, Float,
Bool} that design/models/0007 §4 promises it names. A downstream author
was told what is wrong but not what is right, and the kernel source that
carries the set is not available to a downstream consumer.
The allowed set already travels on the error struct (it flows into the
JSON `details.allowed`); only the human-readable `#[error]` render
dropped it. Append it to the Display string. JSON details unchanged.
Surfaced by the raw-buf fieldtest (finding B3, docs/specs/0058).
RED-first: param_in_reject_message_names_allowed_set pins the message
content before the one-line render fix.
|
||
|
|
8e9f0f06a6 |
fieldtest: raw-buf — 4 examples, 6 findings (refs #7)
Downstream field test of the raw-buf surface as a consumer with only the public interface (design ledger + `ail` CLI; no crate source). Four fixtures under examples/fieldtest/, spec at docs/specs/0058-fieldtest-raw-buf.md. Every recorded outcome reproduced independently before commit. Findings (3 bug, 1 spec_gap, 2 working): - B1 [bug] `borrow (RawBuf a)` receiver is unusable. A fn taking `borrow (RawBuf Int)` that calls RawBuf.get/.size on the parameter even once is rejected `consume-while-borrowed` — the diagnostic names the wrong cause (the receiver is not consumed). The ledger advertises get/size as borrow-receiver ops, so the documented use is unreachable from any helper-function decomposition. This blocks Series (#8) — the milestone's own downstream raison d'être — whose at/total_count read through a `borrow (Series a)`. - B2 [bug] An owned RawBuf threaded through a `loop` binder passes check but panics codegen `unknown variable: b` at build. A plain Int loop binder builds fine, so the fault is specific to an owned RawBuf loop binder. Breaks the check↔codegen agreement. - B3 [bug] `param-not-in-restricted-set` omits the allowed set. design/models/0007 §4 promises the diagnostic "names the offending type and the allowed set"; the shipped message names the type, the type-var, and the home type but not `{Int, Float, Bool}`. A downstream author is told what is wrong but not what is right. - B4 [spec_gap] RawBuf.set receiver double-consume is not enforced; two set calls on the same owned buffer silently alias the slab. Not raw-buf-specific (owned Str/ADT behave identically) — a pre-existing linearity-non-enforcement property. Recorded because RawBuf's own→own mutation is the first place a silent alias yields a mutated-in-place surprise. Ratify in the ledger or open a backlog item for full linear enforcement. - W1 [working] `(new …)` sugar + inference-from-use across Int/Float/Bool builds and runs first try (sensor_pair prints 5.0); the Bool i1/i8 packing edge round-trips. The milestone's cleanest win. - W2 [working] param-in reject fires for both Str and a user TypeDef. Net: B1+B2 mean the only RawBuf programs that build today are straight-line owned let-threading with literal indices — the shipped-fixture shape. Helper decomposition (B1) and runtime-length loop fill (B2) both fail. fieldtest does not self-resolve; routing is the orchestrator's call. |
||
|
|
f37bd959d4 |
audit: raw-buf milestone close — tidy (clean) (refs #7)
Cycle-close drift review for the raw-buf milestone (raw-buf.1-.6 +
the #42/#43 drop-leak bug-fixes), range a163c8c..HEAD. raw-buf.6
(
|
||
|
|
13e590cb46 |
iter raw-buf.6 kernel-stub-retirement (DONE 5/5): retire the stub, raw_buf is the sole base extension (refs #7)
Closes the raw-buf milestone. raw_buf (shipped raw-buf.1-.5; the
owned-drop resolution landed under bug #42/#43) has subsumed every
ratification role kernel_stub held -- Term::New end-to-end, the
param-in reject, kernel-tier auto-import, the monomorphic intrinsic
path, and the type-scoped polymorphic intrinsic mechanism (RawBuf's
four ops). The stub is now redundant; this iteration removes it.
Pure removal (-636/+49 across 22 files, 7 deleted):
- Rust surface: drop `mod kernel_stub` + `STUB_AIL` re-export
(ailang-kernel), `parse_kernel_stub` + its workspace injection
(ailang-surface), the `answer` + two `StubT_peek__{Int,Float}`
INTERCEPTS entries + their three emit fns (ailang-codegen). The
(intrinsic) markers leave with the deleted source, so the
marker<->entry bijection holds in lockstep.
- Source + tests: delete crates/ailang-kernel/src/kernel_stub/,
kernel_stub_module_round_trips, the two stub-consumer e2e tests,
and mono_scoped_symbol.rs (its scope-qualified-mono mechanism is
now pinned by RawBuf's ops + the intercepts bijection).
- Fixtures: delete kernel_answer.ail, new_stubt_smoke.ail,
peek_mono_pin_smoke.ail, and fieldtest kem_3_stub_consumer.ail
(referenced the deleted StubT; documented a since-fixed bug).
- Pins: re-baseline both workspace-count content-pins 5 -> 4
(workspace_pin.rs + the e2e.rs twin) and regenerate all five IR
snapshots (400 stub-IR lines removed; no user IR changed -- the
stub auto-injected into every build).
- Ledger: design/INDEX.md + design/models/0007 to present-state
(raw_buf is the live ratifier; raw-buf milestone shipped, series
pending), plus architecture-comment sweep across workspace.rs,
mono.rs, check/codegen lib.rs, workspace_kernel.rs.
Scope decisions (orchestrator, pre-plan): the self-contained inline
serde_json check-layer fixtures in ailang-check/src/lib.rs keep their
incidental StubT/kernel_stub sample names (they construct what they
reference; not stub-ratification) -- the one permitted residual.
kem_4 fieldtest kept (uses a user NumBox ADT, comment-only edit).
hash.rs `name: "answer"` left (generic serde doc example).
Plan-gap caught at implement: the planner's verbatim edit list
under-enumerated four honesty sites; one (mono.rs:562) carried a
literal `StubT_peek__Int` -- a hard-gate symbol the verbatim list
missed, which would have failed Task 4's removal gate. Filled
(comment-only, no behaviour). Confirms the planner self-review
filter-completeness risk; the implement gate caught it.
Verification: full workspace suite green (0 failed); hard symbol gate
(STUB_AIL|parse_kernel_stub|emit_answer|emit_stubt_peek|StubT_peek__)
zero matches across crates/ examples/ design/; soft gate residual only
the kept inline fixtures; kernel crate is lib.rs + raw_buf/ only.
Regression: compile_check 24/24 stable (exit 0; uniform downward
check_ms within tol -- stub no longer parsed per compile), cross_lang
25/25, check 34/34 stable (exit 0; an earlier exit-1 on
rc_over_bump was machine-load ratio noise -- denominator bump_s got
faster, rc_s also improved -- confirmed green on re-run, not
baselined).
|
||
|
|
fb0dd92a6c | plan: raw-buf.6 kernel-stub-retirement (refs #7) | ||
|
|
c5eca7e91d |
bench: re-ratify compile_check baseline (closes #45)
Re-baseline bench/baseline_compile.json, last ratified 2026-05-15 ( |
||
|
|
7321826c66 |
audit: cycle-close tidy for #44 — $-lexer-reservation (refs #44)
Cycle-close audit for the #44 Form-A `$`-reservation cycle (b151990..HEAD). One drift item fixed inline. Both regression scripts exited 1; investigated and attributed to machine load this session, NOT a code regression — baseline deliberately NOT updated. Architect drift review: - [fixed] design/models/0001-authoring-surface.md — the line "The only reserved tokens are `(`, `)`, and whitespace" was made stale by this cycle (the lexer now rejects any identifier token containing `$`). Corrected to distinguish token *delimiters* (`(`/`)`/whitespace) from the `$` *character reservation within a token*, scoped to the Form-A authoring surface, naming the enforcement site (`LexError::ReservedDollar`) and the intentional `.ail.json` non-guard. - [carry-on] honesty sweep otherwise clean: the only other `$`-mentions in the ledger (0008-memory-model.md, 0003-pipeline.md) describe synthetic mint names (buf$1, <hint>$lr_N) and remain correct. No over-broad "no Module can contain `$`" prose exists. The corrected fresh_binder doc-comment (feat commit) is Form-A-scoped and honest. - Decision: no new ledger *contract* added. 0015-language-constraints holds the four RC-soundness preconditions (strict eval, no recursive value bindings, no shared mutable refs, acyclic ADTs); a lexical character reservation is a different category and is documented in the authoring-surface model doc with its enforcement site, which is its natural home. Architect confirmed absence of `$`-contract prose is not itself drift. Lockstep pairs: none apply (architect walked each against the diff): - Pattern::Lit typecheck <-> pre_desugar_validation.rs — neither changed. - lower_app <-> is_static_callee — codegen unchanged. - INTERCEPTS <-> (intrinsic) markers — intercepts.rs + kernel sources unchanged. Regression scripts: - bench/cross_lang.py: EXIT 0. 25 metrics; 0 regressed, all stable. - bench/compile_check.py: EXIT 1; "12 regressed" — ALL are check_ms.* (type-check wall-clock), baseline ~1.8-3.3ms vs actual ~2.5-4.4ms, ~+0.8ms absolute and roughly UNIFORM across every fixture regardless of size (hello +39%, bench_list_sum +51%). All 12 build_O0_ms.* (the ~93ms LLVM portion of the same `ail build`) are within tolerance but also uniformly shifted +12-15%. Attribution: NOT this cycle's code. The guard adds one `raw.contains('$')` byte-scan per token; a tiny module is ~50 tokens, so the added cost is nanoseconds — physically cannot account for +0.8ms on a 2ms check. The uniform ~12-15% shift across the WHOLE `ail` invocation (check AND build) is a global machine-slowdown fingerprint (heavy concurrent subagent/cargo load this session); it crosses the 25% tolerance only on the tiny-baseline check_ms metrics. Localised by reasoning, not by the bencher (the effect is environmental, nothing to localise in code). - bench/check.py: EXIT 1; throughput metrics flipping run-to-run with identical code (rc_over_bump moved in/out of REGRESSION across four runs; bump_s swung -9.5/-11/-14/-15%). Same machine-load variance; rc_over_bump is a ratio of two independently-noisy wall-clock measurements, the noisiest metric in the set. Baseline NOT updated on either script: ratifying a machine-load delta would bake an under-load baseline in and mask a future real regression. OPEN VERIFICATION: re-run bench/compile_check.py and bench/check.py on a quiet machine to confirm green; tracked as the cycle's one open item. Cycle #44 closes (code + docs clean; regression gate pending a clean-machine confirmation, baseline untouched). |
||
|
|
11e4c4624d |
feat: reserve $ in the Form-A lexer (closes #44)
Enforces the `$`-in-authored-names reservation that fresh_binder (ailang-core::desugar) silently relied on. `fresh_binder` mints shadow-rename binders as <base>$<n>; its collision probe could not see an authored binder literally named <base>$<n> that binds later under the same (def, name) uniqueness key — the exact collapse class #43 closed. The `$`-for-synthetic convention (`$mp_N`, <hint>$lr_N, <base>$<n>) was held by discipline only. This makes it a lexer-enforced invariant: no Form-A identifier token may contain `$`. A one-line guard in tokenize rejects any token run containing `$` before int/float/ident classification, raising the new LexError::ReservedDollar { token, start }. It surfaces through the existing ParseError::Lex -> W::SurfaceParse -> surface-parse-error channel with zero new wiring — no new CheckError, no AST walker, no entry-point threading. Placement rationale (the load-bearing call): the threat vector is Form-A source only. The client LLM author is forbidden from emitting canonical .ail.json directly — it writes Form A exclusively; only the orchestrator hand-authors JSON in rare exceptions. So every authored identifier a hallucinating client could produce flows through the lexer. `$` is a reserved character (like parens) with no legitimate authored use in any position — unlike `.`/`/`, which DO have legitimate uses (std_list.map) and therefore live in the check layer (InvalidDefName) with AST-aware positional logic. No positional split for `$` means no walker; the lexer is the right boundary. An earlier draft put the reject in pre_desugar_validation justified by "a client could build .ail.json directly" — false under the authoring contract, so the simpler lexer reservation replaced it. Exemptions by scan order, not special-case: `$` inside string literals and comments stays legal because both are consumed by earlier branches of the scan loop, before a run is ever sliced. The six checked-in example files mentioning loop$lr_0 in comments keep parsing. Documented non-goals (honesty rule): the .ail.json deserialization path stays unguarded (the orchestrator's self-responsible channel); the fresh_binder probe-body simplification (issue Q4) is not bundled — only its now-false doc-comment is corrected to state the enforced invariant, scoped to Form-A authored identifiers. Verification (all run, all green): - 3 in-source lexer tests: reject on x$1, exemption for $ in string, exemption for $ in comment. RED confirmed pre-guard (tokenize("x$1") returned Ok([Ident]), expect_err panicked); GREEN after. - 2 integration tests (reserved_dollar_pin.rs): the reject reaches the public parse entry as ParseError::Lex(ReservedDollar); string exemption survives at parse level. - cargo test --workspace: green, 0 failed. - CLI: check on the comment-$ example (exit 0) and on the #43 shadow idiom raw_buf_int.ail (exit 0, buf->buf$1 rename minted post-parse, never re-lexed); a fresh $-binder source now rejected with the surface-parse-error diagnostic naming the token and byte offset. Spec: docs/specs/0057-reserved-dollar-in-names.md (grounding-check PASS). Plan: docs/plans/0112-reserved-dollar-in-names.md. |
||
|
|
559531806d |
plan: reserved-dollar-in-names — dollar-lexer-reservation (refs #44)
Executable plan for spec 0057 (committed
|
||
|
|
c76057008e |
spec: reserve $ in the Form-A lexer (refs #44)
#44 asks to enforce-or-retract the `$`-in-authored-binder-names reservation that `fresh_binder` (ailang-core::desugar) relies on for collision-free shadow-rename mints. This spec chooses enforcement, and places it in the Form-A lexer rather than the check layer. The placement is the load-bearing design decision. An earlier draft put the reject in `pre_desugar_validation` (check layer), justified by "a client could construct `.ail.json` directly, bypassing the lexer". The user corrected the threat model: the client LLM author is forbidden from emitting canonical `.ail.json` — it writes Form A exclusively, and only the orchestrator hand-authors JSON in rare exceptions. So the entire hallucinating-client attack surface is Form-A source, which always flows through `tokenize`. That collapses the design: - `$` is a reserved character (like `(`/`)`) with no legitimate authored use in any position — verified: zero authored `$` idents exist in the checked-in `.ail` corpus; the six `$` occurrences are all in comments. No positional split, so no AST-aware walker is needed — unlike `.`/`/`, which DO have legitimate uses (`std_list.map`) and therefore live in the check layer (`InvalidDefName`). The issue's premise "lex.rs reserves only `.`" is false on two counts and is corrected in the spec. - The reject is a single new `LexError::ReservedDollar`, raised inline in the `tokenize` run-classification arm, surfaced through the existing `ParseError::Lex` -> `W::SurfaceParse` -> `surface-parse-error` channel. No new `CheckError`, no AST walker, no multi-entry-point wiring (the check layer has four entry points, only two of which currently run the pre-desugar pass — a trap the lexer placement sidesteps entirely). Documented non-goals (honesty rule): the `.ail.json` deserialization path stays unguarded (the orchestrator's self-responsible channel, scoped out by the user); the `fresh_binder` probe-body simplification (issue Q4) is not bundled — only its now-false doc-comment is corrected. grounding-check PASS on the final bytes: all six load-bearing assumptions ratified by green tests; all three Form-A example blocks parse-gate clean (exit 0 today, by design — the reject is new behaviour). String- and comment-internal `$` stay legal by scan order; no must-fail `$` fixture goes under examples/ (the round-trip test parses every fixture there). |
||
|
|
b151990028 |
audit: raw-buf close-fixes — honesty + ledger for the binder-rename (refs #43)
Architect drift review at raw-buf milestone close (after
|
||
|
|
55d76ae4a1 |
fix: alpha-rename shadowing binders at desugar — A2b leg (closes #43)
The last open leg of the RawBuf owned-heap drop-leak cluster #43: the UniquenessTable shadow-name collapse. The side-table keys by (def_name, binder_name); a fn that shadows a binder name — the shipped `(let buf (new…) (let buf (set buf…) … (get buf)))` idiom — collapses every shadow onto one key. The outermost binding records last (on pop) and overwrites the inner ones, so codegen's scope-close drop gates read the collapsed consume_count for the innermost binding, misjudge its ownership, and suppress its drop. The owned slab leaked (live==1). Fix: desugar now alpha-renames any binder whose authored name shadows an enclosing binding to a fresh `<name>$<n>`, making the (def, name) key injective per fn. The rename is on-shadow-only and uniform across binder kinds (Let, flat-Match pattern-binders, Lam params, Loop binders); non-shadowing binders keep their authored name, so every existing fixture's desugared output is byte-identical (evidenced by the full pre-existing desugar suite passing unchanged). IR-neutral: codegen names heap by fresh SSA, never by the source binder name. No consumer of the uniqueness table changes — they all key by name, and the name is now a per-fn injective identity (acceptance criterion 5). The fix is entirely internal to ailang-core::desugar; uniqueness.rs gets only a doc-comment correction (its old text was wrong on two counts: "last = innermost by pop-order" was backwards, and "every shipping fixture has unique binder names" was false). Mechanism (vs. the spec's sketch): the threaded lexical scope became a `Scope` struct carrying `entries` keyed by each binder's EFFECTIVE (post-rename) name and `rename` mapping authored→effective. Keying entries by effective name is load-bearing, not cosmetic: the LetRec capture-detection reads `free_vars_in_term` of the desugared body (effective names) and filters by `scope.contains_key` — had entries stayed keyed by authored name, renaming an enclosing let would have made a captured shadowed binder invisible to capture detection (UnknownVar in the lifted fn). `insert_fixed` records identity renames for never-renamed binders (fn-params, LetRec name/params) so an inner renamable binder that shadows them is still detected. Alternatives rejected: (A) an AST id field and (B) a derived binding-path — both solve the deeper, SEPARATE problem of persistent AST provenance back to the authored form, which is certain to be needed eventually but is not required here; conflating the internal naming collision with provenance is a category error. C′ (this) reuses the existing fresh-name machinery and touches one pass. Verification: the three shipped Let-shadow tests (raw_buf_{int,float,bool}_shadow_rebind_drop_balances_rc_stats) reach live==0 (were RED at live==1); the flat-pattern differential (flat_pat_shadow_binder_does_not_leak_more_than_alpha_renamed) reaches its alpha-renamed control's live count (was 5 vs 3); a new desugar unit test (shadowing_let_is_alpha_renamed) pins the rename + reference resolution; full workspace suite green, no fixture output drift. Both no-change consumers re-verified by hand: match_lower.rs keys by the emitted (renamed) pattern binder, linearity builds table and lookups from the same desugared tree. Spec: docs/specs/0056-unique-binder-names.md. Plan: docs/plans/0111-unique-binder-names.md. |
||
|
|
1990147467 |
plan: unique-binder-names — desugar alpha-rename for #43 A2b (refs #43)
Executable plan for spec 0056. Three tasks: - Task 1 (atomic compile unit): introduce a `Scope` struct threading two maps — `entries` keyed by each binder's effective (post-rename) name, `rename` mapping authored→effective for shadow detection and Term::Var resolution; add `fresh_binder` (<name>$<n>) and `rename_pattern_binders`; rewrite every binder site (Let, flat-Match pattern, Lam params, Loop binders) with rename-on-shadow, plus the LetRec arm and both def-boundary constructions, to the new API. - Task 2: correct the UniquenessTable doc-comment; verify the four RED tests go GREEN; full-suite regression gate. - Task 3: regression guard — a desugar unit test pinning that a shadowing let is alpha-renamed and that a letrec capturing the shadow-renamed binder still resolves (the entries-keyed-by-effective choice is what keeps capture detection correct under rename). Recon surfaced two facts the spec's sketch did not: the scope is a bare BTreeMap (so resolved_name/bind live on a new Scope type, not the map), and the LetRec capture-detection reads effective names from the desugared body — hence entries are keyed by effective name. Both stay internal to desugar.rs, preserving acceptance criterion 5 (no change to uniqueness.rs logic / codegen gates / linearity.rs). Both no-change consumers verified: match_lower.rs:795 keys by the emitted (renamed) pattern binder; linearity builds table and lookups from the same desugared tree. |
||
|
|
0015f3dad1 |
spec: unique binder names per fn — A2b leg of RawBuf drop-leak (refs #43)
The last open leg of the #43 owned-heap drop-leak cluster is the
UniquenessTable shadow-name collapse: the side-table keys by
(def_name, binder_name), so a fn that shadows a binder name collapses
every shadow onto one key. The outermost binding (records last, on pop)
overwrites the inner ones; codegen's scope-close drop gates then read
the collapsed consume_count for the innermost binding and suppress its
drop, leaking the owned slab.
Spec 0056 resolves this as a compiler-internal naming collision — NOT
the deeper, separate problem of persistent AST provenance back to the
authored form (certain to be needed eventually, deliberately deferred;
conflating the two is a category error). Fix: alpha-rename shadowing
binders during desugar (reusing the existing fresh-name machinery that
already mints $mp_N / $lr_N), making the (def, name) key injective
again. No consumer of the uniqueness table changes — they all key by
name, and the name becomes a per-fn injective identity. IR-neutral:
codegen names heap by fresh SSA, never by source binder name. The
pre-desugar hashed form is untouched, so module identity and hash-pin
tests are unaffected.
RED fixtures securing the binder-kind class (the user's gating
condition before proceeding to the uniform-across-kinds fix):
- Let-shadow: the three raw_buf_{int,float,bool}_shadow_rebind tests
(already in tree, committed
|
||
|
|
0f6108e428 |
fix: resolve cross-module borrow modes in the uniqueness pass (refs #43)
A2a leg of the owned-RawBuf drop-leak. Prerequisite: regression-free
and independently correct, but does NOT alone close the leak — the
three shadow_rebind RED tests (
|
||
|
|
f7f4c3b237 |
test: RED shadow-rebind RawBuf drop-leak on shipped fixtures (refs #43)
raw_buf_{int,float,bool}_shadow_rebind_drop_balances_rc_stats guard
the three shipped milestone fixtures (the LLM-natural shadow-rebind
idiom `(let buf (new...) (let buf (set buf...) ...(get buf)))`) with a
live==0 RC-stats assertion alongside their existing stdout check.
This is a SECOND, distinct leak mechanism (A2), separate from the
anonymous-temp path fixed in
|
||
|
|
62cd4b4ee7 |
fix: drop owned temp passed to a borrow slot at the call site (closes #43)
GREEN side of the owned-RawBuf drop-leak. RED test
raw_buf_owned_drop_balances_rc_stats (
|