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.
Four new E2E tests in typeclass_22b2.rs covering invariants the
per-task fixtures (all single-module, single-fn) did not exercise:
- cross_module_class_method_resolves_and_fires_no_instance:
module B's call to `show` (declared in module A) reaches the
no-instance arm, proving Env.class_methods is merged workspace-
wide and not restricted to the local module.
- cross_module_polymorphic_fn_without_constraint_fires_missing_constraint:
same merge guarantees the missing-constraint arm sees the residual
produced by a call to A's class method from inside B.
- cross_module_instance_satisfies_concrete_call: the workspace
registry built at load time over all modules must populate keys
visible from B; a concrete call resolves clean cross-module.
- two_fns_each_missing_constraint_produce_two_diagnostics: pins
check_in_workspace's per-def aggregation — two ill-formed fns
produce two diagnostics (one per fn), not one.
Six new fixtures (xmod_* trio + two_fns_*).
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.
Address quality review of a9aa248:
- Replace string-prefix discriminator (`prior.starts_with("class ")`)
with a structural `Origin { Class | Fn }` enum; `kind` is now a
`match` on variants, formatting is reserved for error construction.
- Fix fn-fn collision misreport: in the `Def::Fn` arm, only fire
`MethodNameCollision` when the prior origin is a class. Two
`Def::Fn` with the same name are `CheckError::DuplicateDef`'s job
in `ailang-check`, not this pre-pass.
- Tests use `examples_dir()` like the rest of the module; path
construction is no longer ad-hoc.
- Tests assert `first_origin` / `second_origin` so an origin-format
regression cannot silently break the `kind` discriminator.
- Drop forward-looking 22b.4 prelude reservation note from the
variant doc — keep only what's load-bearing for 22b.2.
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).
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).
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.
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.
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.
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 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).
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.
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.
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.
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.
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.
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.
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.
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.
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).