Quality-review follow-up to 89311ee:
- Revert `substitute_rigids` to private. Task 3 does not call it; the
visibility bump was forward-looking for Task 4 (synthesise_mono_fn).
Task 4 will re-bump it in the same diff that uses it.
- Promote `build_module_types` to pub(crate); drop the inline
reimplementation in `mono::build_workspace_env`. Removes drift risk
between the two paths.
- Replace defensive `unwrap_or_default()` on `build_module_globals`
with `expect(...)` naming the pre-condition. Silent default violated
CLAUDE.md "no defensive validation in internal code paths".
- Add a one-line drift-risk reminder at the top of `build_workspace_env`
flagging that it and `check_in_workspace` both populate `Env`.
Build clean; all 5 typeclass_22b3 tests green; no workspace regressions.
Float is referenced nowhere else in the codebase; the plan-text
listed it aspirationally. Restored parity with the four other
primitive enumerations across the crate. When Float lands as a
real type (post-22b.4 Prelude work, if at all), the arm gets
re-added in the same iter that introduces Type::float().
Deferred (not part of this fix):
- Shared-constant extraction across the four primitive enumeration
sites (linearity.rs:143, lib.rs:1232/2214, mono.rs) — workspace-
level cleanup belonging to the milestone-22 audit phase.
- pub mod mono vs sibling pub use lift::lift_letrecs style — plan
prescribed pub mod for 22b.3 because Tasks 3-7 add multiple pub
items reached by integration tests; per-item pub use would
proliferate.
Adds the missing positive counterpart to the no-instance test:
`show 42` with `instance Show Int` PRESENT must typecheck green.
This guards against registry-keying or threading bugs that would
otherwise sneak past the negative-only test (an empty registry at
typecheck time, or a key shape mismatch, would silently fire
no-instance even when the instance exists).
Also drops the dead `let _ = ws;` and its stale comment in
`check_in_workspace` — the line above already consumes
`ws.registry`, so the explicit drop is redundant. The remaining
comment is folded into the registry-threading comment so the
"registry is the only thing threaded" rationale is preserved.
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).
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).
Corpus-signal upgrade: orchestrator ran 19a's lint across 65
fixtures, zero warnings fired. The conservative match-scrutinee
carve-out filtered every (own T) fixture because every such body
destructures the param via match. Real-corpus signal warranted
the precise variant.
Precise rule: for an Own param with consume_count == 0, walk each
match arm whose scrutinee is the param; if any HEAP-TYPED
pattern-binder has consume_count > 0, stay silent (sub-consume
forces ownership). Otherwise fire.
Heap-type filter is the implementer's design call (not in the
brief): reading a primitive (h: Int) is load-by-value, no RC, no
heap-data move; reading heap-typed t: IntList IS a pointer move
requiring outer-cell ownership. Documented in module-doc.
Corpus signal after upgrade: 5/65 fixtures fire — all RC
codegen-test fixtures with deliberate over-strictness for the
codegen path. Justifies 19b (mode-strict-because suppression).
ailang-check test count: 53 → 55. e2e + everything else green.
Known debt: deeply-nested-match-on-sub-binder (no fixture hits).
When 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.
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.
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.
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.
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.
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.
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.