0bbeceea46477fc0fb6d93163d2a90b40169736f
90 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
057784934a |
iter 22b.1.3: registry coherence-check fixtures + tests
Adds seven fixtures under examples/test_22b1_* exercising the
workspace-load coherence checks plus four positive/negative tests in
ailang-core/src/workspace.rs:
- iter22b1_instance_in_class_module_loads_clean: positive case;
test_22b1_orphan_class.ail.json declares Show + instance Show Int
in the same module (the class's). Registry has one entry.
- iter22b1_orphan_instance_fires_diagnostic: declares Show in
test_22b1_orphan_third_classmod, declares instance Show Int in
test_22b1_orphan_third (a third module). Fires OrphanInstance.
- iter22b1_duplicate_instance_fires_diagnostic: per the JOURNAL hint,
module A defines class Show + instance Show MyInt (legal, A is
class's module), module B defines type MyInt + instance Show MyInt
(legal, B is type's module). Entry imports both. Fires
DuplicateInstance with first/second_module distinct.
- iter22b1_missing_method_fires_diagnostic: class Eq with two
non-default methods (eq, ne), instance Eq Int specifies only ne.
Fires MissingMethod { method: "eq" }.
The surface round-trip gate (ailang-surface/tests/round_trip.rs)
filters out test_22b1_* fixtures because the Form-B parser arms for
ClassDef/InstanceDef are deferred to 22b.4. Without the filter, every
fixture's printed text would re-parse-fail on the unknown `class` /
`instance` head.
Test count: 291 → 295 (4 new workspace tests, all green).
|
||
|
|
eb4db9dafc |
iter 22b.1.2: workspace registry skeleton + coherence checks
Adds the workspace-global typeclass instance registry per Decision 11
"Resolution and monomorphisation". Built at the end of load_workspace
after the DFS over imports completes; keyed by (class-name, type-hash)
where type-hash uses the new canonical::type_hash (16-hex prefix,
parallel to def_hash and module_hash).
Three coherence checks fire from build_registry, each surfacing as a
distinct WorkspaceLoadError variant:
- OrphanInstance: `instance C T` not in C's or T's defining module.
- DuplicateInstance: two entries share the same (class, type-hash) key.
- MissingMethod: instance omits a required (non-default) method.
The CLI's workspace_error_to_diagnostic is extended with the three new
codes (orphan-instance / duplicate-instance / missing-method).
All existing Workspace { ... } construction sites get the new
`registry` field, defaulting to Registry::default() at synthetic /
test-only paths and threading through ws.registry.clone() at codepath
sites that already hold a real workspace.
Includes hash-stability regression tests (iter22b1_schema_extension_*
and iter22b1_classdef_empty_optionals_hash_stable) and the empty-
registry positive test against examples/sum.ail.json. Test count:
288 → 291 (3 new tests, all pass).
|
||
|
|
f25d7b6cd6 |
iter 22b.1.1: ClassDef/InstanceDef AST + downstream match arms
Adds Def::Class(ClassDef) and Def::Instance(InstanceDef) variants per Decision 11 §"Form-A schema". All optional fields (superclass, doc, default body) carry skip_serializing_if so canonical-JSON bytes of pre-22b fixtures stay bit-identical. Downstream match-on-Def sites are extended with explicit Class/Instance arms. Behaviour for 22b.1 is placeholder (skip in typecheck/codegen, one-line summary in pretty/manifest, placeholder marker in prose/print) with TODO comments naming the deferred sub-iters (22b.2 typecheck, 22b.3 codegen, 22b.4 prose-projection arms). |
||
|
|
7e5c95f6c6 |
runtime: half-retirement of Boehm — flip CLI default to --alloc=rc
The dual-allocator policy from 2026-05-08 made Boehm the CLI default
"for general workloads", with rc selected explicitly for real-time-
sensitive code. That asymmetry contradicted Decision 10: rc + uniqueness
inference is the canonical memory model AILang's typechecker enforces,
and the CLI was teaching the opposite mental model to users and prompt-
fed LLMs.
This iter flips the asymmetry without removing Boehm:
- main.rs: default_value gc -> rc for build/run; help text rewritten
so rc is the canonical path and gc is the parity oracle.
- DESIGN.md Decision 9 retitled "RC canonical, Boehm parity oracle"
and rewritten end-to-end. Migration plan step 6 updated to a
two-step retirement (default-flip done, full removal gated).
- DESIGN.md pipeline diagram: rc-first, gc-as-oracle.
- JOURNAL: 2026-05-09 entry recording the call, what shipped,
the bug the flip surfaced, the gating condition for full
Boehm removal.
Bug surfaced by the flip and fixed in the same iter:
codegen/drop.rs build_pair_drop_fn emitted `getelementptr inbounds
{{ ptr, ptr }}, ...` via push_str (not format!), so the doubled
braces leaked verbatim into the IR. LLVM parsed it as a struct-of-
struct, the GEP referenced a non-existent field, and clang failed.
Invisible under the old gc default (no per-type drop fns) and
invisible to the 8 explicit _with_alloc("rc") tests (none of them
escaped a closure-with-captures). Caught immediately by two corpus
tests once rc became the baseline: closure_captures_let_n,
local_rec_as_value_capture_demo. Fix: single braces. Tests stay
as the regression guard.
The episode is the canonical justification for keeping the GC arm
as a parity oracle for now — a months-old codegen bug found by
flipping the baseline column.
288 passed / 0 failed / 3 ignored, unchanged.
|
||
|
|
72626fa94f |
ail: embed Form-A spec in merge-prose prompt (iter 20f)
Closes a design hole shipped in 20d: the merge-prose prompt instructed
the LLM to emit JSON-AST and gave a 12-line schema-essentials reminder.
JSON-AST is the canonical hashable artefact, not a writing surface; the
reminder was not a language spec. Foreign LLMs had no realistic shot.
20f makes two coupled changes:
1) The LLM now emits Form-A (the canonical authoring surface fixed by
Decision 6). merge-prose loads the original via ailang_core::load_module
and re-renders via ailang_surface::print before embedding (round-trip
is a gating contract on the surface crate, so this is lossless). The
user runs `ail parse foo.new.ailx` to recover JSON, then `ail check`.
2) crates/ailang-core/specs/form_a.md is the complete LLM-targeted
Form-A specification — grammar, every term/pattern/type/def keyword,
schema invariants, pitfall catalogue, four few-shot modules from
examples/*.ailx. Exported as ailang_core::FORM_A_SPEC via include_str!
and embedded verbatim in every merge-prose prompt.
Drift detection in crates/ailang-core/tests/spec_drift.rs: every variant
of Term, Pattern, Type, Def, Literal is reached via exhaustive `match`.
The arms are not the assertion — adding a new variant without updating
the match is a compile error before the test runs. Once matched, an
anchor string is asserted to appear in FORM_A_SPEC. 8 tests, all green.
The hand-written + mechanical-drift-test combo addresses the user's
"distance to code is too big" concern about a docs-only spec. Generator
overkill rejected: structural drift is mechanically caught, but prose
explanation, schema-invariant catalogue, pitfall list, and few-shot
corpus cannot be emitted from AST shape alone.
Tests:
- ailang-core: +8 spec_drift tests; existing 12 unchanged
- ail unit (3): rewritten in lockstep — assert (own T)/(effects IO)
landmarks, FORM-A SPECIFICATION header, FORM_A_SPEC body verbatim
- ail e2e merge_prose_prints_framed_prompt: rewritten — assert
`(module foo` + `FORM-A SPECIFICATION` instead of `ailang/v0`
- Workspace: all green
Richer integration paths (LLM tool-use, MCP server, LSP) were named
in the design discussion and deferred per "kiss". All three layer
additively on the static-prompt path; static prompt remains the
lowest-common-denominator fallback.
|
||
|
|
8375eb81ed |
prose: split reuse-as keyword + inline single-use lets (iter 20b polish)
reuse-as: render `(reuse-as src body)` as `reuse src as body` with adaptive braces — inline for single-line bodies (the 99% Ctor case), braced for multi-line (Let, Seq, nested Match). Reads as English subject-verb-object instead of compound keyword + always-block. let-inlining: `let x = rhs; <body using x once>` collapses to `<body[x := rhs]>` when (a) rhs is not a `Term::Do` (keep effect sequencing visible), (b) x is used exactly once (no work duplication), (c) rhs renders single-line AND ≤ 40 chars (no line smearing). Lossy projection — round-trip mediator sees the original .ail.json and can re-introduce a let when the mode model demands it. Helpers `count_free_var` and `subst_var_with_term` are render-local; both respect inner shadowing (let, lam, match arm pattern binders, let rec). Pre-render value at current level + check for newline + len budget; on inline, write_term_prec gets passed parent_prec so the substituted term still wraps correctly inside binops. Snapshot drift: rc_app_let_partial_drop_leak.prose.txt — boilerplate `let p = build_pair(1)` collapsed into the match scrutinee. Improves readability. Cons-tower in rc_own_param_drop kept its let thanks to the length budget (52 chars > 40). Tests: 59 unit (+ 9 new) + 4 snapshot, all green. Coverage: inline-single-use, two-uses-keeps, zero-uses-keeps, Do-rhs-keeps, multiline-rhs-keeps, length-budget-keeps, shadowing-not-counted, reuse-as inline + braces. |
||
|
|
50b68267fe |
check: mode-strict-because suppression (iter 19b)
Closes the 19a/19a.1/19b arc. Corpus signal from 19a.1 (5/65
fixtures fire over-strict-mode, all deliberate RC codegen-test
fixtures) justified shipping the suppress mechanism end-to-end.
Schema: Suppress { code, because } on FnDef. Pre-19b hashes
bit-identical via skip_serializing_if. Typechecker drops matching
diagnostics; empty 'because' is Error severity; wrong codes are
silent no-ops (open-set registry).
Form-A: (suppress (code "...") (because "...")) clause, parser
+ printer round-trip clean. Form-B: '// @suppress <code>: <reason>'
above the doc string, lossless contract metadata.
5 RC fixtures migrated (.ail.json + .ailx + .prose.txt). Corpus
signal: 5/65 -> 0/65. Lint still fires on any future fn that's
accidentally over-strict without an authored reason.
Test counts: ailang-check 55->61, ailang-core 26->28, ailang-surface
21->26, ailang-prose 49->52, e2e 70 unchanged.
Known debt: .ailx comment headers lost on regen (ail render's
contract excludes comments); parse_suppress_attr accepts
duplicate code/because attrs without diagnose (bounded by canonical
print order).
|
||
|
|
9f3f10ce6e |
check: precise sub-binder analysis for over-strict-mode (iter 19a.1)
Corpus-signal upgrade: orchestrator ran 19a's lint across 65 fixtures, zero warnings fired. The conservative match-scrutinee carve-out filtered every (own T) fixture because every such body destructures the param via match. Real-corpus signal warranted the precise variant. Precise rule: for an Own param with consume_count == 0, walk each match arm whose scrutinee is the param; if any HEAP-TYPED pattern-binder has consume_count > 0, stay silent (sub-consume forces ownership). Otherwise fire. Heap-type filter is the implementer's design call (not in the brief): reading a primitive (h: Int) is load-by-value, no RC, no heap-data move; reading heap-typed t: IntList IS a pointer move requiring outer-cell ownership. Documented in module-doc. Corpus signal after upgrade: 5/65 fixtures fire — all RC codegen-test fixtures with deliberate over-strictness for the codegen path. Justifies 19b (mode-strict-because suppression). ailang-check test count: 53 → 55. e2e + everything else green. Known debt: deeply-nested-match-on-sub-binder (no fixture hits). |
||
|
|
b9e942b99d |
prose: doc-wrap widow control for 1-word orphans
When greedy word-wrap leaves a 1-word last line, pull the penultimate line's tail word down so the orphan has at least 2 words. Skipped if the combined length would exceed budget (invariant takes priority over cosmetics). Visible on nested_let_rec.prose.txt: before: '...captures outer's param\n/// i.' after: '...captures outer's\n/// param i.' Two new unit tests; existing 4 snapshots unaffected (none had a 1-word orphan). |
||
|
|
9c09dfbc8d |
ail: merge-prose subcommand + PROSE_ROUNDTRIP.md (iter 20d)
Closes family 20: prose-edit cycle is end-to-end. ail merge-prose <original.ail.json> <edited.prose.txt> composes a shaped LLM prompt to stdout. User pipes to their LLM (Claude/etc.), gets new .ail.json, runs ail check, saves. No built-in API client — AILang stays a compiler + tooling, the LLM mediator is supplied externally. PROSE_ROUNDTRIP.md documents the 6-step cycle, the prompt contract (preserve modes/effects/tail flags from original), failure modes, and the design choice to omit an API client. Family 20 now closes: 20a renderer + 20b polish + 20d mediator shipped; 20c rolled into 20a. Three deferred polishes (let-inlining, print sugar, doc-wrap widow control) wait for corpus signal. |
||
|
|
9cf0e3e81c |
prose: infix + paren elision + doc-wrap + nested-match (iter 20b)
Eleven binary builtins (+ - * / % == != < <= > >=) render as lhs op rhs when called with two args (tail-flagged keeps prefix). Standard Rust-aligned 4-level precedence table elides parens. not(x) renders as !x. Long /// doc strings wrap at 80 cols on word boundaries. bench_list_sum: 'if ==(n, 0)' becomes 'if n == 0', '+(acc, h)' becomes 'acc + h', tail keyword still visible on tail calls. 19 new unit tests (47 total). 4 snapshots (3 re-rendered + 1 new bench fixture). Public API unchanged. |
||
|
|
a9d57c5c81 |
prose: human-readable projection renderer (iter 20a)
New crate ailang-prose with one public fn module_to_prose(&Module) -> String. Rust-flavour with braces, =>-match-arms, mode keywords (own/borrow), effects as trailing 'with IO', Cons(1, Nil) ctor form, /// doc strings. Lossy projection where the LLM can re-derive: (con T) wrap, (fn-type ...) wrap, (term-ctor ...) wrap. Load-bearing semantic detail stays visible (modes, effects, clone, reuse-as, doc strings, type annotations, tail flag). CLI: 'ail prose <file.ail.json>' prints the projection. 3 snapshot fixtures + 28 unit tests. 215-line .ail.json reduces to ~18 lines of legible source. 20b queued: infix arithmetic, paren elision, let-inlining, do prettify. |
||
|
|
41804a39fb |
check: over-strict-mode lint (iter 19a)
When a fn-param is annotated (own T) but the body never consumes it (consume_count == 0) and never destructures it via match, the linearity pass now emits a Severity::Warning diagnostic with a form-A-rendered (borrow T) rewrite as suggested_rewrite. Pure advisory, Decision 10 unchanged. First Warning-severity diagnostic the typechecker emits; three CLI exit paths gated on Error-only. JOURNAL: design entry + iter 19a shipping entry. |
||
|
|
caefcf996a |
codegen rc: tag-conditional partial-drop helper closes 3 carve-outs
Three sibling sites previously fell back to shallow `ailang_rc_dec` when a binder had non-empty `moved_slots` and a dynamic runtime ctor tag: Iter B Own-param dec at fn-return (lib.rs), Iter A arm-close pattern-binder dec (match_lower.rs), and emit_inlined_partial_drop's non-Ctor branch (drop.rs). Shallow freed the outer cell but did not walk the unmoved ptr fields — silent leak. Adds `emit_partial_drop_fn_for_type`: a per-ADT helper structurally parallel to `emit_drop_fn_for_type` but taking an i64 moved-slots bitmask. Each ptr-field's dec is gated on the corresponding mask bit; the outer box is dec'd at join. Emitted alongside the per-type drop fn for every ADT under --alloc=rc. Three call sites rewritten to resolve the partial_drop symbol from the binder's static type and build the mask from `moved_slots`. Three RED-then-GREEN fixtures (rc_own_param_/rc_match_arm_/ rc_app_let_partial_drop_leak) pin the carve-outs in regression coverage; live cell counts go from 3/1/3 (pre-fix) to 0/0/0. e2e count: 66 → 69. The pre-existing `alloc_rc_own_param_dec_at_fn_return` IR-shape assertion is widened to accept `partial_drop_<m>_<T>(%arg_xs, i64 mask)` as a third valid drop shape (the canonical fu2 case). Closes the JOURNAL queue's only remaining iter; the "wait for organic fixture" stance from the 18g tidy is retracted — known leaks are bugs, CLAUDE.md's TDD rule covers them autonomously. |
||
|
|
bea5c92591 |
codegen tidy: extract lambda lowering into lambda.rs
Fourth slice of the codegen split. Pulls the closure-emission
cluster into a dedicated module. No behaviour change.
Methods moved:
- lower_lambda (~290 lines, pub(crate)): capture analysis +
thunk fn lifting + heap-env construction + closure-pair
packing + per-pair drop-fn emission under --alloc=rc.
- collect_captures (~108 lines, private): recursive free-var
walk used only as lower_lambda's prelude.
- pattern_bound_names (~17 lines, private): pattern-binder
enumeration used by collect_captures's match arm.
Imports: super::escape (for analyze_fn_body), super::synth
(for fn_sig_from_type, llvm_type), the usual Emitter/Result
re-exports.
lib.rs drops from 3237 → 2825 lines. lambda.rs is 447 lines.
Codegen split summary (5295 → 2825 lib.rs, ~47% reduction):
lib.rs 2825 — Emitter struct, lifecycle (new,
start_block, emit_module, emit_*),
lower_term mass dispatcher, app/eq/effect
lowering, lookup helpers, synth helpers.
match_lower.rs 935 — Term::Ctor / ReuseAs / Match.
escape.rs 722 — escape analysis (predates the split).
drop.rs 696 — per-type drop fns + per-let-close drop.
lambda.rs 447 — Term::Lam + closure machinery.
subst.rs 319 — monomorphisation substitution.
synth.rs 216 — IR shaping helpers + builtin types.
cargo test --workspace clean across all four phases.
|
||
|
|
86406aca72 |
codegen tidy: extract match/ctor/reuse-as lowering into match_lower.rs
Third slice of the codegen split. Pulls the three big lowering
methods that share the ADT runtime layout into a dedicated
module. No behaviour change.
Methods moved (all pub(crate)):
- lower_ctor (135 lines): allocation + tag store + field
stores. Iter 17a alloca-vs-heap routing through escape
analysis lives here.
- lower_reuse_as_rc (261 lines): Iter 18d.2 in-place rewrite
optimisation under --alloc=rc. Reuse arm + fresh arm,
Lean-4-shaped runtime refcount-1 dispatch.
- lower_match (497 lines): switch + per-arm pattern
destructure + per-arm phi + 18d.4 Iter A pattern-binder
drops + 18g.1 outer-cell shallow dec at tail-call sites.
lookup_ctor_in_pattern stays in lib.rs (sits next to its
lookup-family siblings).
Visibility: four parent-module helpers that match_lower.rs
calls back into were upgraded to pub(crate): start_block,
lower_term, collect_owner_local_types, fresh_id.
lookup_ctor_in_pattern likewise pub(crate).
lib.rs drops from 4139 → 3237 lines. match_lower.rs is 935
lines (the lower_match body alone is ~500 of those — the
single-largest method in the codebase, but moving it gives
the biggest navigability win). cargo test --workspace clean.
|
||
|
|
86989a7cb5 |
codegen tidy: extract drop emission into drop.rs
Second slice of the codegen split. Pulls every method that emits
or dispatches drop fns into a dedicated module. No behaviour
change.
Cluster A (per-type drop bodies):
- emit_drop_fn_for_type — recursive cascade, ADT default.
- emit_iterative_drop_fn_for_type — Iter 18e worklist variant.
- field_is_same_type — same-ADT predicate (private to drop.rs).
- field_drop_call — per-field drop-symbol resolver.
Cluster B (per-let-close dispatch):
- is_rc_heap_allocated — Iter 18c.3 trackability gate, widened
in 18g.2 to include Own-returning App.
- synth_callee_ret_mode — ret_mode lookup helper (private).
- drop_symbol_for_binder — picks the symbol Term::Let calls
at scope close.
- emit_inlined_partial_drop — partial-drop emission for
pattern-moved slots.
Cluster C (closure drops):
- build_env_drop_fn — env's drop fn body builder.
- build_pair_drop_fn — pair's drop fn body builder.
Visibility: methods called from lib.rs are pub(crate);
helpers used only inside drop.rs (field_is_same_type,
synth_callee_ret_mode) stay private. Three Emitter helpers
in lib.rs that drop.rs calls back into were upgraded to
pub(crate): synth_arg_type, lookup_ctor_by_type, fresh_ssa.
Field access from drop.rs into the parent's private Emitter
fields uses the standard descendant-module privacy lane.
lib.rs drops from 4800 → 4139 lines. drop.rs is 580 lines.
cargo test --workspace clean: 66/66 e2e + 26/26 codegen +
all sibling crates pass.
|
||
|
|
84ba83dda3 |
codegen tidy: extract pure helpers into synth.rs + subst.rs
First slice of the codegen module split. Pulls the run of free functions at the bottom of lib.rs into two purpose-named submodules, with no behaviour change: - synth.rs (216 lines): LLVM-IR shaping helpers — llvm_type, fn_sig_from_type, builtin_ail_type, builtin_effect_op_ret, type_descriptor, builtin_binop, c_byte_len, default_triple, ll_string_literal. - subst.rs (319 lines): monomorphisation pipeline — the derive_substitution + unify_for_subst + apply_subst_to_* family, plus qualify_local_types_codegen and descriptor_for_subst. lib.rs drops from 5295 → 4800 lines. Visibility is unchanged (submodules see lib.rs's private Result/CodegenError/FnSig through normal Rust scoping); call sites are unchanged in behaviour, only re-imported via 'use' at the lib.rs head. Motivation is the codebase-control conversation: lib.rs was the one navigability outlier flagged in the size sanity- check, and the 18g family closing left a clean window before the next iter. The remaining bulk (drop emission, match lowering, lambda lowering) will come out in follow-up commits, each an independent move-only refactor with full cargo test --workspace between. |
||
|
|
2e0006029b |
fix: let-alias-aware mode propagation through Term::Let
Closes the carve-out shared by 18d.4 Iter A and 18g.1's pre-tail-
call seam: a let-binder whose value is Term::Var aliasing a non-
Own fn-param now inherits the param's mode in current_param_modes
for the duration of the let body. Without this, (let a t (match
a ...)) where t is Implicit / Borrow defeated scrutinee_is_owned
(default for non-fn-param scrutinees was 'owned'), and the arm-
close drop fired on pattern-binders whose underlying memory
belongs to the caller — refcount underflow / SIGSEGV on the next
recursion.
Implementation: Term::Let lowering checks if value is Term::Var
referencing a name in current_param_modes; if so, inserts that
mode under the let-binder's name for the body and restores on
let-close (push/pop pattern, symmetric with locals).
Red:
alloc_rc_let_alias_of_implicit_param_does_not_dec_borrowed_children
on examples/rc_let_alias_implicit_param. Pre-fix: SIGSEGV / RC
underflow on the second iteration of loop. Post-fix: matches
alloc=gc ('0').
The Borrow-ret-mode case is already covered by the language-design
rule (typechecker rejects 'consume-while-borrowed' on the
borrow-aliasing shape); the Implicit-mode case is what the carve-
out actually surfaces. Both are now closed at codegen-time.
Two of the three known debts from the 18g tidy now resolved
(stat-of-N earlier in the session, let-alias here). The remaining
debt — tag-conditional partial-drop helper for the dynamic-tag
carve-outs in lower_match and emit_inlined_partial_drop — is the
substantively larger piece and stays queued.
|
||
|
|
3113258680 |
tidy: 18g sub-arc — DESIGN ratification, latency bench, negative coverage
Resolves the architect's drift report on the 18g sub-arc: - DESIGN.md: ratify mode-metadata's codegen role. param_modes / ret_mode were already on Type::Fn since 18a; with 18d.4 / 18g they became load-bearing for drop-emission decisions in codegen. The new 'Mode metadata is load-bearing for codegen' subsection records the four seams (Iter A + Iter B + 18g.1 + 18g.2) and names the let-alias-of-borrow carve-out the gates do not propagate through. - bench/run.sh: post-throughput, invoke bench/latency_harness.py on the three canonical arms (Implicit @ gc, explicit @ rc, Implicit @ rc control). The Boehm-retirement bench numbers are now reproducible by anyone running the harness, not just by hand on a specific host. - Negative coverage: examples/rc_let_implicit_returning_app + alloc_rc_let_binder_for_implicit_returning_app_does_not_drop pin the asymmetry to the (own)-ret-mode test (live=0 there, live=1 here). The Borrow-ret-mode case is covered by the language design itself — typechecker rejects 'borrow- passthrough' shapes with consume-while-borrowed. JOURNAL entry records four items as deferred known debt: emit_inlined_partial_drop dynamic-tag fallback, carve-out diagnostics surface, bench-number stat-of-N, and the cross-family ordering observation about CLAUDE.md's tidy-iter rule. The 18-arc is formally closed with this tidy. Next iter is the orchestrator's Boehm-retirement decision (Path A vs Path B from JOURNAL 2026-05-08 18f entry, joined by the post-18g.2 re-bench numbers). |
||
|
|
88045a485b |
fix: 18g.2 — let-binder drop for Own-returning App
18c.3's is_rc_heap_allocated returned false for any Term::App shape; the doc-comment explicitly deferred the owned-returning-call case to 'later iters tied to (own) ret-mode contracts'. We have those contracts (Iter 18a). With this iter the predicate widens to recognise Term::App whose callee carries ret_mode=Own; the let- scope close emits a drop call, routing through the per-type drop fn derived from the call's return type so the cascade walks ptr children correctly. Borrow- and Implicit-returning calls are still not trackable — they don't carry the static caller-owns-the-return signal. drop_symbol_for_binder gains an App arm that synthesises the return type and resolves drop_<m>_<T> from it (same shape as the existing Ctor arm), with cross-module qualification through import_map. emit_inlined_partial_drop now defaults to shallow ailang_rc_dec when value is not Term::Ctor — that path is the dynamic-tag partial-drop debt 18d.4 already documents (the runtime tag of a let-binder whose value is Term::App is not statically known, so per-field partial-drop emission is not usable; closing this remaining leak path requires a tag- conditional helper, queued as future work). Red: alloc_rc_let_binder_for_owned_returning_app_drops_at_scope_close on examples/rc_let_owned_app_leak — pre-fix live=3 (TNode + 2 TLeaf), post-fix live=0. End-to-end: bench_latency_explicit under --alloc=rc now reports allocs=11068575 frees=11068575 live=0. The depth-19 Tree cache + all 10M per-op IntList cells deallocate cleanly. Decision 10's prompt-deallocation property now holds end-to-end on the canonical bench fixture. |
||
|
|
ae2eb2efac |
fix: 18g.1 — pre-tail-call shallow-dec for moved-from scrutinee outer cell
18d.4 Iter A (arm-close pattern-binder dec) and Iter B (Own-param
dec at fn return) both fire AFTER the arm body lowers; for a tail-
call arm body, lower_term emits 'musttail call ... ret' and sets
block_terminated=true, so neither seam fires. The result: the
scrutinee's outer cell (whose ptr fields were all moved into the
tail-call's args via 18d.3 moved_slots) leaks one cell per
recursion step.
Surfaced by 18f.2's tail-latency bench: explicit-mode RC peaked
at the same 511 MB RSS as implicit-mode RC despite full mode
annotations — the per-op IntList chains were never being freed.
Bencher diagnosed the tail-call drop-elision; this iter implements
the fix.
Fix shape: in lower_match, BEFORE lower_term(arm.body), emit
'call void @ailang_rc_dec(ptr <scrutinee_ssa>)' iff:
- alloc=Rc,
- arm.body is structurally Term::App{tail:true} or
Term::Do{tail:true},
- scrutinee_is_owned (existing 18d.4 Iter A gate, hoisted),
- every ptr-typed slot in this ctor's pattern is in
moved_slots[scrutinee] (i.e. all ptr children are moved into
binders that own them downstream — a Wild-bound ptr would
still hold a live ref that the shallow free would strand).
Shallow rc_dec, not field_drop_call: the per-type drop fn would
re-walk ptr fields, dec'ing values now owned by the downstream
frame — exactly the bug 18d.3's moved_slots was set up to prevent.
Hoists scrutinee_is_owned to a single declaration shared by both
the new 18g.1 emission and 18d.4 Iter A.
Red: alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells
on examples/rc_tail_sum_explicit_leak — 100 LCons cells leaked
pre-fix, 0 post-fix. Verified end-to-end on
bench_latency_explicit: pre-fix live=11068575,
post-fix live=1048575 (= the persistent tree cache, separate
issue at main-scope-close).
Test infrastructure: build_and_run_with_rc_stats helper +
AILANG_RC_STATS=1 env-var for the runtime atexit summary.
|
||
|
|
e8c6e99a87 |
fix: 18d.4 Iter A — gate arm-close pattern-binder dec on scrutinee mode
Iter B (Own-param dec at fn return) gates on param_modes[i] ==
ParamMode::Own and skips Implicit/Borrow because those modes carry
no caller-handed-off-ownership signal. Iter A (arm-close pattern-
binder dec) is the same shape — pattern-binders loaded out of a
scrutinee owe their drop-validity to the scrutinee's ownership —
but Iter A had no such gate and fired on every ptr-typed binder
with consume_count == 0.
Concrete failure (regression test): pin (params t) is Implicit,
caller loop holds t and re-passes it to itself. pin's (TNode v l r)
arm dec'd l and r, fragmenting the tree the caller still references.
Next recursion tripped ailang_rc_dec underflow.
Fix: thread current_param_modes onto the emitter, set it in
emit_fn from the fn type's param_modes (and reset/save it across
lambda thunk emission). In lower_match, derive scrutinee_is_owned
from the scrutinee's mode (Own only; let-binders and temps are
treated as owned) and skip Iter A when not owned.
Carve-out: a let-alias of a non-Own param is not yet detected;
the regression doesn't trigger it and a propagation pass belongs
in its own iter. Recorded in JOURNAL.
Red: alloc_rc_pattern_bind_in_implicit_fn_does_not_dec_borrowed_children.
Pre-fix the rc binary aborted with refcount underflow; post-fix
matches alloc=gc ("0").
|
||
|
|
6b3ff3bbed |
tidy: rewrite stale 18b/18c.x doc-headers to match shipped state
First half of the post-18-arc tidy-iter (per the new CLAUDE.md iter-cycle rule). Architect's drift review flagged module-doc headers describing 18b's leak-everything snapshot or 18c.x's "deferred" debt that has since shipped. Doc-only changes; cargo build clean, cargo test --workspace green at e2e=61, no behavioural change. - runtime/rc.c top-of-file header: rewrote from "Iter 18b deliberately stops at the layout and the alloc... programs leak every allocation" (false post-18c.3) to a stage summary spanning 18b–18e. Fixed `--memory=rc` reference (renamed to `--alloc=rc` in 18b's CLI work). Updated ailang_rc_inc / ailang_rc_dec block comments to point at `drop_<m>_<T>` and the worklist as the cascade owners, not at "18c will wire this up". - ailang-check uniqueness.rs module-doc: replaced the "deferred to later iters" block (which named 18c.4 + 18d as future work, both shipped) with a current "what this pass does NOT do" block. Cross-fn reasoning is still genuinely deferred; per-type drop fns and recursive cascades are NOT this pass's job by design (codegen does them, not the inference). - ailang-codegen emit_drop_fn_for_type doc + in-body comment: rewrote "Iter 18e replaces the recursive call with an iterative worklist free" to describe the actual shipped behaviour — the 18e (drop-iterative) annotation routes annotated types through emit_iterative_drop_fn_for_type; unannotated types stay recursive by orchestrator design (cheaper IR, no worklist alloc). Held back for the second half of the tidy-iter (pending the ailang-bencher determinism result): - DESIGN.md Decision 10 line 700 says modes are "mandatory" but lines 940–952 admit they're opt-in with deferred mandatoriness. The bench result either supports tightening the mandatoriness claim or backs down to "opt-in with performance benefit" — orchestrator-level decision blocked on the bench data. - Dynamic-tag partial-drop debt is captured in JOURNAL but should be surfaced in DESIGN as a known precision gap. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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). |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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"]
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|