Iteration-discipline milestone, 2 of 3. Strictly additive (nothing
tail-related removed; that is it.3). New whole-body pass
verify_structural_recursion sibling of verify_tail_positions
(DD-1): smaller-set algorithm with implicit candidate inference +
unconstrained accumulators (DD-2, foldl=structural), self/mutual
via inline ADT-family union-find (DD-3), it.2-only tail==false
grandfather. CheckError::NonStructuralRecursion. term_contains_loop
(stops at Term::Lam, DD-4) injects Diverge so existing
UndeclaredEffect enforces it, no new variant; lam-arrow + LetRec
sub-effect sites wired. DESIGN.md Decision 3 synced. Four it.1
loop fixtures gained !Diverge.
Two spec-premise boundary defects surfaced + resolved within the
additive invariant (corpus clean, check not weakened), recorded as
corrected it.3 corpus-migration scope: (1) the "21 tail-app
fixtures" grandfather premise under-counts the corpus —
no-ADT-candidate counter recursions have no structural position to
verify, deferred to it.3; (2) two RC-regression fixtures joined the
spec's transitional tail-app grandfather as the other 20 do (RC==GC
guards verified still green). cargo test --workspace 622/0; 9
acceptance pins non-vacuous. Spec fda9b78, plan bc9f512.
Iteration-discipline milestone, 1 of 3. Adds named loop + recur as
strictly-additive first-class AST nodes: parse/print/prose/serde/
round-trip/schema lockstep, typecheck (binder typing + recur
arity/type unification via loop_stack threaded as mut.2's
mut_scope_stack; recur-tail-position via verify_loop_body), codegen
(loop-header + one phi per binder; recur back-edge br with a NEW
parallel block_terminated setter; lambda-boundary loop_frames
save/restore). Four Recur* CheckError variants. Strictly additive:
zero deletions touch tail-app/tail-do or the seven existing
block_terminated sites — this is what makes the destructive it.3
safe. recur synth = fresh metavar (resolves the plan's flagged
Type::unit() risk). loop_counter->55, loop_in_lambda->49, four
negatives fire, tail-app byte-identical, cargo test --workspace
green. Spec fda9b78, plan 7381a42.
Sharpen the feature-acceptance criterion (DESIGN.md, mirrored in the
brainstorm gate) with a third conjunctive-necessary clause: a feature
reintroduces no bug class the core constraint exists to eliminate.
Criterion 1 ("an LLM reaches for it") does not discriminate — an LLM
reaches for every construct native to its imperative training
distribution, so utility alone would launder the imperative paradigm
back in one construct at a time. Clause 3 is the discriminator; a
documentation note is not a reshape (the gate is whether the wrong
code fails to typecheck).
Record the decided iteration story as a P1 milestone: structural
recursion permitted (total-by-construction under Decision 10's
acyclic-ADT invariant) + named loop/recur as the only other
repetition + tail-app retired. Discharges fieldtest finding F1 without
introducing an imperative construct; closes the residual "forgot to
mark tail" trap completely; unifies with Decision 10 (acyclic values
AND acyclic implicit call graph). Supersedes the "while-loops legal"
scope bullet of the Stateful-islands milestone, which is amended for
internal consistency. The escaping-state path (ref/MutArray/Stateful)
is unaffected and stays in Stateful-islands.
Tidy iter addressing the audit-mut-local drift. Plus paired baseline
update on bench/baseline_compile.json (audit-skill discipline).
Architect [high] items closed:
1. CheckError::MutVarCapturedByLambda rejects lambdas whose body
free vars include a mut-var of the enclosing mut_scope_stack.
Uses the existing ailang_core::desugar::free_vars_in_term walker
(which honours Term::Match pattern bindings). The scan runs only
when mut_scope_stack is non-empty.
2. crates/ailang-codegen/src/lambda.rs: the capture-not-in-locals
path that previously raised CodegenError::Internal blaming the
typechecker now uses unreachable!. The companion comment block
was rewritten to state the current reality.
Architect [medium] items closed (stale mut.1-stub history comments):
3. docs/DESIGN.md §'Term (expression)' mut/assign block: the
trailing paragraph describing the iter mut.1 stub state was
replaced with one describing the current mut.3-end-state. The
inline jsonc comment on {'t': 'assign'} was updated to drop the
'deferred to mut.2/mut.3' language.
4. crates/ailang-codegen/src/lib.rs: the stale comment block above
the real Term::Mut arm describing the mut.1 stub was removed
entirely.
Other:
- Short-circuit on empty mut_scope_stack in synth's Term::Var arm:
the iter mut.2 prepend now skips the iter-and-find walk when
the stack is empty, eliminating any per-Var-resolution overhead
for the common case (programs with no mut blocks). The
short-circuit did NOT close the check_ms regression — see ratify
below.
- Spec docs/specs/2026-05-15-mut-local.md §'Out of scope' amended
with the lambda-capture rejection bullet.
- Negative fixture examples/test_mut_var_captured_by_lambda.ail.json
+ driver test extension in crates/ailang-check/tests/
mut_typecheck_pin.rs (6th test) +
crates/ailang-core/tests/carve_out_inventory.rs EXPECTED bumped
12 → 13.
Bench-regression ratify:
bench/compile_check.py check_ms showed a uniform ~30-50% relative
shift across the entire 11-fixture suite (~0.5ms absolute on a
1.4-1.5ms baseline). The uniformity across fixtures of very
different Var counts argues for a fixed-cost-per-invocation tax,
not a Var-proportional hot path. The Term::Var short-circuit
falsified the hot-path hypothesis. The plausible remaining causes
(synth-parameter-passing through ~19 recursive sites, and binary-
size startup tax from mut-* adding ~1400 LOC to typecheck/codegen)
are both feature-cost, not pathological. Ratified by paired
journal entry; bench/baseline_compile.json updated to the post-
mut-local numbers via 'bench/compile_check.py --update-baseline'.
bench/check.py continues to show the established tail-latency-noise
envelope from audit-pd (2026-05-14) — no separate ratify needed.
bench/cross_lang.py clean.
Tests: 594 → 598 green (4 new lib.rs mod tests + 1 driver test +
1 fixture-corpus uptake).
Journal: docs/journals/2026-05-15-iter-mut.4-tidy.md.
mut-local milestone end-to-end status:
- mut.1 (7b92719): AST + Form A surface.
- mut.2 (b24718a): typecheck.
- mut.3 (03fb633): codegen + e2e.
- mut.4-tidy (this commit): audit-drift close + bench ratify.
mut-local milestone CLOSED.
Refs: docs/specs/2026-05-15-mut-local.md,
docs/plans/2026-05-15-iter-mut.4-tidy.md.
Replaces the iter mut.1 dispatch stubs in lower_term with real LLVM
lowering: mut-vars become alloca slots hoisted to the fn's entry
block, assign lowers to store, mut-var reads lower to load. Two
example fixtures run end-to-end printing 55 (Int via mut_counter,
Float via mut_sum_floats).
Concretely:
- Emitter struct gains three new fields: mut_var_allocas:
BTreeMap<String, (String, Type)> for per-fn name→(alloca SSA,
AIL type) tracking; pending_entry_allocas: String as a side
buffer accumulating alloca instructions during body lowering;
entry_block_end_marker: Option<usize> recording the byte position
in self.body immediately after the entry: label.
- start_block(label) captures the marker when label == 'entry'.
emit_fn resets all three new fields per fn body. At the end of
emit_fn, String::insert_str splices pending_entry_allocas into
self.body at the marker — alloca instructions land in the entry
block regardless of where in the source tree Term::Mut was
encountered (mem2reg eligibility preserved).
- Term::Mut arm: per var, fresh-name an alloca SSA, push the
alloca instruction into pending_entry_allocas, lower the init at
the current body position, emit a store to bind. The mut-var
binding is NOT visible during init (matches typecheck-side
ordering at lib.rs:3576). After all vars are bound, lower the
body in the extended scope. On block exit, restore any prior
bindings via a per-block save stack — supports nested
shadowing correctly.
- Term::Assign arm: look up the alloca + type via
mut_var_allocas.get(name); lower the value; emit store; yield
the canonical Unit SSA per the existing Term::Lit { lit:
Literal::Unit } convention.
- Term::Var arm: prepended with a mut-var lookup that emits a
load <ty>, ptr <alloca> and returns the load SSA. Innermost-wins
shadowing falls out of the BTreeMap's insert-overwrites
semantics combined with the per-block save/restore.
- examples/mut_counter.ail + examples/mut_sum_floats.ail:
recursive sum_helper accumulates 1..10 (Int) or 1.0..10.0 (Float)
inside a mut block whose single var is assigned the helper's
result. Both run end-to-end printing 55.
- crates/ail/tests/e2e.rs: mut_counter_prints_55 and
mut_sum_floats_prints_55 #[test] fns using the existing
build_and_run helper.
- DESIGN.md 'What is supported' subsection: 'Local mutable state'
bullet describes the new construct, points at the two example
fixtures, and reaffirms the seal-by-construction invariant under
Decision 10.
Beyond-plan adjustments absorbed in this iter:
- synth_with_extras's parallel Term::Var arm in
ailang-codegen/src/lib.rs needed the same mut-var lookup as
lower_term's. Plan only specified lower_term's arm.
- crates/ailang-codegen/src/lambda.rs needed save/restore of all
three new Emitter fields across the lambda-body boundary plus
in-thunk splice of pending_entry_allocas at the lambda's own
entry marker, so mut-blocks inside a closure body hoist into
the closure's entry block (not the outer fn's).
mut-local milestone end-to-end status:
- mut.1 (7b92719): AST + Form A surface.
- mut.2 (b24718a): typecheck.
- mut.3 (this commit): codegen + e2e.
Audit follows.
Tests: 592 → 594 green; cargo build green; both fixtures execute
and print 55.
Journal: docs/journals/2026-05-15-iter-mut.3.md.
Refs: docs/specs/2026-05-15-mut-local.md, docs/plans/2026-05-15-iter-mut.3.md.
First iteration of the mut-local milestone (foundation step on the
Stateful-islands roadmap path). Lands the schema + surface tier:
Term::Mut, Term::Assign, and the nested MutVar struct become
first-class AST nodes that round-trip cleanly through Form A.
Typecheck and codegen recognition are deferred to mut.2 and mut.3
per the spec's out-of-iteration boundary; reaching either dispatch
entry point with these variants produces CheckError::Internal /
CodegenError::Internal with a 'deferred to iter mut.{2,3}' message.
Concretely:
- crates/ailang-core/src/ast.rs: two new Term variants behind
#[serde(tag = 't')]; pub struct MutVar { name, ty, init } adjacent
to Arm. Two canonical-bytes pin tests for the explicit-empty-vars
serialisation and the assign round-trip.
- ~25 substantive Term-walker arms across ailang-core/desugar,
ailang-core/workspace, ailang-check (lib + lift + linearity + mono
+ pre_desugar_validation + reuse_shape + uniqueness),
ailang-codegen (escape + lambda + lib), ailang-prose, and
crates/ail/src/main.rs. Universal policy: substantive recurse-into-
children at every site; only the two dispatch entry points
(synth in ailang-check, lower_term in ailang-codegen) stub with
Internal-error. One test-side walker arm in
crates/ail/tests/codegen_import_map_fallback_pin.rs not
enumerated by the plan was added as well (defensive recursion).
- ailang-surface: parse_mut + parse_assign helpers; Term::Mut
body desugared from a flat statement sequence into a right-folded
Term::Seq chain inside the JSON-AST. Print arms in print.rs match
the parser convention. EBNF prologue + crates/ailang-core/specs/
form_a.md productions updated. Four new parser pin tests cover
the empty-mut, single-var, body-required, and vars-only-no-body
cases.
- Drift + coverage tests extended: design_schema_drift.rs adds two
exemplars + match arms; schema_coverage.rs adds two VariantTag
entries + EXPECTED_VARIANTS + visit_term arms; spec_drift.rs adds
two exemplars + match arms. DESIGN.md §'Term (expression)' gets
jsonc-blocked schemas for the two new variants.
- examples/mut.ail: six-fn round-trip fixture exercising empty mut,
single-var, two-var, nested-shadow, and the four supported scalar
return types (Int, Float, Bool, Unit). The round_trip auto-glob
and schema_coverage corpus walker both pick it up.
Plan deviation: the plan named lib.rs:2572 as the typecheck
dispatch stub site, but that line is actually verify_tail_positions
(substantive walker). The real dispatch is synth (3403-area, stub
at 3489); the orchestrator routed correctly.
Tests: 564 → 579 green; cargo build green; round-trip green for
the new fixture; all drift + coverage tests green.
Journal: docs/journals/2026-05-15-iter-mut.1.md.
Refs: docs/specs/2026-05-15-mut-local.md, docs/plans/2026-05-15-iter-mut.1.md.
Architect drift: 1 Important + 1 Minor fixed inline as audit-pd-tidy.
DESIGN.md §"Roundtrip Invariant" point 4 + §Enforcement asserted
"Eight `.ail.json`-only fixtures" with one being the prelude embed.
Post-pd.3 the count is seven and the embed clause is empty; rewrote
both paragraphs to reflect the post-milestone state with a forward-
pointer to the prelude-decouple resolution.
ail/src/main.rs's migrate-canonical-types subcommand carried a
`local_types.get("prelude")` fallback in `rewrite_type` that was
dead-by-construction after pd.3 retired the synthetic prelude load
in the same subcommand. Removed the or_else arm; replaced with a
multi-line comment naming the milestone and the repopulation path
for any future variant that needs prelude awareness.
Bench: check.py + compile_check.py exit 1 with the established
envelope-noise (15th consecutive observation since audit-cma);
cross_lang.py clean. pd.* iterations are workspace-loader / spec /
filesystem / test-code only — zero codegen / runtime / typecheck
path edited, so the regression cluster cannot be milestone-induced.
Baseline left pristine consistent with all 14 prior decisions.
Roadmap: P0 entry (prelude-decouple, [~]) struck and removed; P0 is
empty again. The pre-milestone P2 entry that this work superseded
("Prelude embed: Form-A as compile-time source") is gone too. Per-
iter journals stay in INDEX.md as chronological context.
Carried debt unchanged: 3 cargo doc warnings on dangling load_workspace
references in core's lib.rs (covered by the existing P2 "Rustdoc
warning sweep" todo).
Single iter shipping the post-milestone-24 follow-up named in
docs/specs/2026-05-14-retire-per-type-print-effects.md. After this
iter the only surviving direct-output effect-op is `io/print_str`;
all per-type print primitives are replaced by the polymorphic
`print` helper (prelude, iter 24.3).
Components:
- 92 examples/*.ail fixtures migrated (do io/print_<T> x) →
(app print x); 6 .prose.txt snapshots regenerated via `ail prose`.
- Four-site lockstep compiler deletion: crates/ailang-check/src/builtins.rs
(3 effect_ops.insert blocks + 3 list() rows + the
install_io_print_float_signature test + module + EffectOpSig
doc-comments); crates/ailang-codegen/src/lib.rs lower_app
(3 arms + lowers_io_print_float test); crates/ailang-codegen/src/synth.rs
builtin_effect_op_ret match-arm pattern. Dead `intern_string`
helper removed as a follow-up.
- Five incidental test-body migrations (ailang-check x2, ailang-core
spec_drift + design_schema_drift, ailang-surface/src/lex.rs,
ailang-prose/src/lib.rs round-trip test).
- Cat B test-harness patch: six IR-shape tests in
crates/ail/tests/e2e.rs gained a monomorphise_workspace call
before lower_workspace_with_alloc so they follow the same
pipeline as `ail build` (the home-rolled desugar+lift loop
stayed because mono's precondition is "already lifted"; mono
inserts after lift).
- Six doc-comment touch-ups (lex.rs module doc, parse.rs
diagnostic example, ail/src/main.rs x2, runtime/str.c %g anchor,
crates/ailang-core/specs/form_a.md surface-spec example).
- DESIGN.md seven-site sweep (Decision 11 example, Polymorphic
print past-tense, Heap-Str output sentence, effect-op
invocation comment, Float NaN paragraph re-anchored on
float_to_str, two "What is supported" lists).
- Three E2E test-comment polish + four IR-snapshot refresh + one
canonical-hash pin update (plan-unanticipated downstream
consequences of the corpus migration).
- bench/{check,compile_check,cross_lang}.py: all exit 0; no
ratification needed.
- Roadmap entry struck through; per-iter journal at
docs/journals/2026-05-14-iter-rpe.1.md.
Tests 564/0/3. cargo clippy and cargo doc: zero warnings.
Two upstream codegen bugs surfaced during the first BLOCKED
attempt and were fixed in separate iters before this retry:
- 1fb225e bugfix: mono cursor misalignment at poly-free-fn Var
with class-constrained Forall.
- feb9413 bugfix: print leak — propagate ret_mode through rigid
substitution + prelude Show.show ret_mode.
Known debt (carried forward for next /audit):
- Emitter.strings field is functionally dead post-iter (orphan
after intern_string removal); cycles over empty map harmlessly.
Closes fieldtest-form-a friction finding #4. `str_concat : (borrow Str,
borrow Str) -> Str` ships in the four-site-lockstep pattern established
by `str_clone` / `int_to_str` / `bool_to_str` (iter 24.1). The
LLM-natural Show-MyType body
`(app str_concat "label=" (app int_to_str x))` now parses, checks,
builds, and runs end-to-end.
Sites touched (lockstep):
- runtime/str.c — `ailang_str_concat(a, b)` slab-allocates and
memcpys both source payloads into a new heap-Str.
- ailang-check/src/builtins.rs — `env.globals.insert("str_concat",
Fn { 2x Str borrow, ret Str own, effects [] })` + `list()` row +
`install_str_concat_signature` unit test.
- ailang-codegen/src/lib.rs — `declare ptr @ailang_str_concat(ptr,
ptr)` extern + `lower_app` arm after str_clone + `is_builtin_callable`
extension + IR-pin unit test `str_concat_emits_call_to_ailang_str_concat`.
- examples/show_user_adt_with_label.ail (new) + crates/ail/tests/
str_concat_e2e.rs (new) — corpus fixture exercising the LLM-natural
Show body shape + E2E pin asserting check + build + run produce
`Item 42\n`.
Lockstep collision repaired: examples/bug_unbound_in_instance_method.ail
used `str_concat` as its UNBOUND name (because that was the literal
fieldtester repro). Renamed to `format_label` (LLM-author-realistic
helper name that will never become a builtin) and updated the pin test
`crates/ail/tests/unbound_in_instance_method_pin.rs` accordingly,
preserving the regression guard's intent (instance-method-body walked
through unbound-var check).
DESIGN.md amended: new §"Heap-Str primitives" subsection between the
milestone-24 Show-backer enumeration and the existing
`Primitive output goes through ...` paragraph, cataloguing all five
heap-Str primitives (`int_to_str`, `bool_to_str`, `float_to_str`,
`str_clone`, `str_concat`) with signatures, iter origins, and the
user-visible-vs-prelude-internal distinction. Show-backer block
unchanged.
IR snapshots regenerated (hello, list, max3, sum, ws_main) to absorb
the new `declare ptr @ailang_str_concat(ptr, ptr)` line in the
unconditional extern header — same upkeep pattern as hs.4 which
regenerated the same 5 snapshots for the same reason
(unconditional declares dead-stripped by clang -O2 when unused).
Tests: 559 + 3 = 562 green (E2E pin + builtin-signature test + IR-pin
test). Zero re-loops across all 7 tasks.
Documents the three iter-24.3 strengthenings as load-bearing
invariants and tightens two error-handling sites:
T1: DESIGN.md gains new subsection §Cross-module references in
synthesised bodies (between Resolution-and-monomorphisation and
Defaults-and-superclasses) documenting three invariants installed
in iter 24.3 — (1) MonoTarget::FreeFn::type_args carries canonical
types post-collection via normalize_type_for_lookup; (2) post-mono
synthesised body cross-module refs may bypass the source template's
import_map (codegen falls back to module_user_fns / module_def_ail_types);
(3) FreeFnCall synth pushes one ResidualConstraint per declared
forall-constraint with rigid vars substituted by fresh metavars.
T2: codegen_import_map_fallback_pin.rs (integration test) asserts
the synthesised prelude.print__<IntBox> body references
show_user_adt.show__<IntBox> AND prelude module's imports do not
contain show_user_adt — proving the cross-module ref bypasses
import_map at codegen.
T3: polyfn_dot_qualified_branch_pin.rs (integration test) asserts
bare-name print f (f : Int -> Int) fires exactly one no-instance
diagnostic at typecheck with zero unknown-variable diagnostics —
proving the bare-name resolution reaches the dot-qualified synth
branch where the constraint-residual push fires.
T4: check/lib.rs:2858 unwrap_or_default() replaced with .expect()
carrying the registry-coherence message — class_methods index
drift now surfaces explicitly rather than rendering NoInstance
with an empty method name.
T5: mono.rs gains apply_subst_and_normalize helper (Option<Type>
return) extracted from two byte-identical call sites at
collect_mono_targets and collect_residuals_ordered. Each call site
retains its own rigid-var / unit-default policy in the None arm
(site 1: rigid → has_rigid+break, unbound → Type::unit; site 2:
non-concrete → Type::unit). Byte-identity invariant on mono-symbol
hashes enforced by construction.
Tests: 558 passed (was 556 + 2 new pins). No production semantic
change — pure documentation + test pin + error-handling tightening
+ helper refactor. bench/cross_lang exit 0; bench/compile_check +
bench/check exit 0 this run (latency.implicit_at_rc / latency.explicit_at_rc /
bench_list_sum.bump_s noise envelope unobserved, lineage continues
at 10th consecutive observation without firing this run).
Ships fn print : forall a. Show a => (a borrow) -> () !IO in the
prelude with explicit-let body \\x -> let s = show x in do
io/print_str s. Three new E2E fixtures + tests verify the full
path:
- show_print_smoke: 4 primitives smoke (print 42 / true / 'hello' /
3.14) — compile, run, expected stdout
- show_user_adt: data IntBox + instance prelude.Show IntBox +
print (MkIntBox 7) — stdout '7'
- show_no_instance: let f : Int -> Int = \\x -> x in do print f —
fires Show-aware NoInstance with DESIGN.md §Prelude(built-in)
classes cross-reference
IR-shape pin in print_mono_body_shape.rs asserts post-mono
print__Int.body is structurally Term::Let → App(show__Int) →
Do(io/print_str). Protects the explicit let-binder for the heap-Str
RC discipline per eob.1 Str carve-out.
Three plan-defects-fixed-inline surfaced during user-ADT E2E,
necessary repairs to make the spec's stated user-ADT trajectory
work end-to-end:
(a) mono.rs (2 sites): MonoTarget::FreeFn::type_args were carrying
bare type-cons references; normalised to canonical
<owner>.<bare> form via workspace_registry.normalize_type_for_lookup
so synthesised cross-module bodies' post-mono walks reach the
registry-keyed instance entries. Symmetric to the existing
class-method-arm normalisation.
(b) codegen/lib.rs (3 sites: resolve_top_level_fn, lower_app
cross-module arm, synth_with_extras Var arm): post-mono
synthesised bodies may carry cross-module references to modules
their source template didn't import (prelude.print__<UserType>
references show_user_adt.show__<UserType> even though prelude
does not import user modules). Fall back to direct
module_user_fns lookup when prefix not in import_map. Both
ends were independently typechecked before mono ran; the
cross-module ref is created by mono not by source.
(c) lib.rs synth FreeFnCall arm: walked Type::Forall.constraints,
substituted rigid vars with fresh metavars, pushed one
ResidualConstraint per declared constraint. Without this,
print f at Int -> Int would silently typecheck and fail with
a confusing 'unknown variable: show' at codegen rather than
fire the right typecheck-time NoInstance diagnostic.
NoInstance Float-aware arm in check/lib.rs:770-779 extended with
a parallel class == 'prelude.Show' branch that cross-references
DESIGN.md §Prelude(built-in) classes verbatim. Negative-fixture
test asserts code 'no-instance' + Show substring + Prelude-(built-in)-
classes substring.
DESIGN.md §Prelude(built-in) classes milestone-24 paragraph flips
fn print from 'ships in 24.3' to 'shipped in iter 24.3' with body
shape + pin file reference. §Float semantics gains a Show-Float
NaN-spelling cross-reference paragraph linking show 1.5 / show nan /
show inf to instance Show Float via float_to_str.
Roadmap P1 'Post-22 Prelude — Show + print rewire' flipped to [x]
with closing summary naming all three shipped iters (24.1 / 24.2 /
24.3). New P2 entry 'Retire io/print_int|bool|float effect-ops +
migrate example corpus to print' inserted at top of P2.
Tests: 556 passed (was 552 + 4 new). bench/cross_lang exit 0;
bench/compile_check + bench/check exit 1 on documented noise-class
metrics per the audit-cma lineage envelope (8th consecutive
audit-grade observation, baseline pristine per conservative-call).
Milestone 24 closes structurally with this iter. Standard audit
pipeline next.
Ships class Show a where show : (a borrow) -> Str in the prelude
plus primitive Show Int / Bool / Str / Float instances. Each
instance body is a single-application lambda invoking the
corresponding runtime primitive (int_to_str, bool_to_str,
str_clone, float_to_str) — first prelude instance bodies to call
runtime primitives directly. Float is included in Show (unlike
Eq/Ord) because textual representation is well-defined modulo the
NaN-spelling caveat at DESIGN.md §Float semantics.
The 22b typeclass test corpus is migrated preemptively: 21 fixture
files and 6 consumer files (typeclass_22b{2,3}.rs +
hash.rs ct4 pin + workspace.rs iter22b1 tests + the
instance_present.prose.txt snapshot) rename class Show / show
to class TShow / tshow, analogous to the existing TEq/TOrd
convention. Migration runs before the prelude additions so the
workspace stays green throughout.
Three new tests pin the post-mono shape: show_mono_synthesis.rs
(existence of show__Int/Bool/Str/Float as Def::Fn entries in the
prelude post-mono module), show_dispatch_pin.rs (bare show and
tshow both Step-2 singletons workspace-globally),
mono_hash_stability.rs::primitive_show_mono_symbol_hashes_stay_bit_identical
(body hashes pinned for the 4 new mono symbols).
DESIGN.md §Prelude (built-in) classes drops Show from the
deferred-features list and appends a milestone-24 paragraph
naming the class signature + the 4 instances + the body shape.
print rewire stays deferred to iter 24.3.
Tests: 552 passed (was 548 + 4 new). bench/compile_check +
bench/cross_lang exit 0. bench/check exits 1 on the recurring
noise envelope per the conservative-call lineage.
T1 [high-1]: refine_multi_candidate_residual rigid-var filter now
requires class + type-unification via constraint_type_matches, so
forall a b. prelude.Show a, userlib.Show b => ... shapes discriminate
by which typevar the residual is on. Discharge-time only; synth-time
twin doesn't have a residual type to unify against (fresh metavar
constructed after dispatch).
T2 [high-2]: qualifier_is_class_shape extracted as pub(crate) helper
adjacent to parse_method_qualifier; broadened to accept PascalCase
single-segment qualifiers like Show.show alongside module-qualified
ones. Same-module bare-class call sites now reachable; symmetric to
mq.1 canonical-form rule.
T3 [medium-1]: any_candidate_class_has_instance pub(crate) helper
gates the class-method-shadowed-by-fn warning closure on registry
instance presence (any candidate class, any type). Conservative
tightening of the spec rule per Boss Q2 decision.
T4 [medium-2]: four DESIGN.md Data Model schema fragments
(SuperclassRef, InstanceDef.class, Type::Con.name, Constraint.class)
annotated with canonical-form cross-references.
Two trip-wires fixed inline: parse_method_qualifier docstring
coherence, mq3 in-crate test fixture-repair (registry instance
injection — analogous to mq.3 typeclass_22b3 pattern).
5/5 tasks. 548 tests green (was 545 + 3 new mq_tidy_* unit tests).
bench/compile_check.py + cross_lang.py exit 0; check.py exit 0
both runs with noise-class metric migration on
latency.implicit_at_rc.* max-tail (6th-consecutive observation
since audit-cma). Baseline pristine.
Closes the module-qualified-class-names milestone. Deletes the
workspace-load workaround; the two-libraries case now resolves via
type-driven dispatch at the call site with explicit qualifier as the
LLM-author's disambiguation tool. Resolves both mq.2 known-debt items
(active_declared_constraints plumbing, (class,method) re-key). New
synth warnings channel emits class-method-shadowed-by-fn at all three
fn-precedence branches per spec section Class-fn collisions. Three new
E2E fixtures + integration tests cover the ambiguous, qualified, and
class-fn-shadow trajectories. DESIGN.md class-names paragraph rewritten,
new section Method dispatch added. Roadmap P2 marked done, milestone-24
unblocked for re-brainstorm.
9/9 tasks. 545 tests green. bench/compile_check.py + cross_lang.py
exit 0; bench/check.py exit 1 (2 noise-class regressions, runtime
uncoupled to typecheck iter).
Architect drift review (range 750f97e..78e8338): drift_found with
three [high — spec acceptance] DESIGN.md items left over from the
heap-str-abi spec's acceptance criteria, plus one [medium] record-
keeping note. All three high items fixed inline as eob.tidy (Boss-
mechanical edits — small scope, content fully known from the just-
closed milestone, no plan / implement dispatch warranted):
1. DESIGN.md (Float-semantics block): replaced the stale 'float_to_str
is type-installed but codegen-deferred' caveat with a one-paragraph
statement that float_to_str and int_to_str are fully wired,
allocate a heap-Str slab, and carry ret_mode: Own.
2. DESIGN.md ('What is supported' inventory): dropped the codegen-
deferred parenthetical from float_to_str; added int_to_str to the
inventory with the same Str-ABI cross-reference.
3. DESIGN.md (new 'Str ABI' subsection after Float-semantics): documents
the dual realisation (static-Str packed-struct globals vs heap-Str
malloc slabs), the shared consumer ABI (Str pointer at len-field
offset 0, bytes at payload+8, inherited strcmp semantics), and the
codegen-level non-RC invariant for static-Str (move-tracking +
non-escape lowering + Type::Con{name:"Str"} carve-outs in
field_drop_call and drop_symbol_for_binder's App arm).
Medium-severity hs.4-journal forward-pointer item skipped: INDEX.md
already chains hs.4 → eob.1 chronologically, and eob.1's journal
explicitly cross-references hs.4's deferred fix.
Bench-regression check:
- compile_check.py: exit 0; 24 metrics all stable
- cross_lang.py: exit 0; 25 metrics all stable
- check.py: exit 1; 2 regressed (bench_list_sum.bump_s +12.6%,
bench_hof_pipeline.bump_s +11.65%, both marginal, the other four
bump_s metrics stable — most parsimonious explanation is per-
fixture system noise on ~50ms benches), 5 improved beyond
tolerance (latency.explicit_at_rc.p99_us / p99_9_us / p99_over_median
cluster — third consecutive audit showing the same shift
uncorrelated with milestone work, plus two gc_over_bump mirrors of
the bump_s regressions).
Baseline left pristine on every script for the third consecutive
audit. Latency cluster has persisted across audit-cma → audit-ms →
audit-eob without an identified cause; ratifying via --update-baseline
would obscure the next attributable signal. bump_s cluster is first-
sighting; first-sighting rule says observe in the next audit, do not
ratify on first sight.
Heap-str-abi milestone fully closed:
- Static-Str layout migrated (hs.1, hs.2 + spec amend).
- Heap-Str runtime wired (hs.3, hs.4).
- Effect-op-borrow rule + RC-discipline (eob.1).
- DESIGN anchors landed across eob.1 (arg-position policy under
Decision 10) and eob.tidy (Str-ABI subsection, signatures-inventory
cleanup).
Language rule: Term::Do.args[*] are walked in Position::Borrow by
the uniqueness + linearity passes, symmetric to Term::Ctor.args[*]
being walked in Position::Consume. The kind itself carries the
ownership default — no per-op field on EffectOpSig. Three
analyser sites flip in lockstep: uniqueness.rs:289, linearity.rs:506,
and the shared doc-comment at linearity.rs:42.
Heap-Str-specific consequences (closing hs.4's leak):
- int_to_str / float_to_str ret_mode: Implicit → Own at four lockstep
sites (builtins.rs:200,210 + synth.rs:172,179). The codegen
trackability gate (drop.rs:464-475, iter 18g.2) now fires on these
callees, so the let-binder receives a scope-close drop.
- drop_symbol_for_binder's App arm gets a Type::Con{name:"Str"} →
"ailang_rc_dec" carve-out, symmetric to field_drop_call's existing
Str arm. Without it, the new Own-trackable App binder would emit
drop_<m>_Str (undefined).
Tests: the two pre-existing RED tests at e2e.rs (commit 592d87b)
flip to GREEN unchanged with predicted numbers (allocs=1,frees=1,
live=0 and allocs=2,frees=2,live=0). Two new positive tests pin
the rule's coverage: primitive-Int arg through io/print_int produces
zero RC traffic; heap-Str through 2× io/print_str in sequence
typechecks (no use-after-consume) and balances allocs=1,frees=1,
live=0.
DESIGN.md §Decision 10 gets the arg-position-policy table for both
AST node kinds (Ctor=Consume, Do=Borrow) plus a linking paragraph
explaining how Do=Borrow cooperates with ret_mode==Own.
heap-str-abi milestone closes here: WhatsNew entry shipped (Strings
produced at runtime now release cleanly), roadmap P1 entry checked
off, Post-22-Prelude depends-on line removed.
Workspace cargo test + cross_lang.py + compile_check.py all green.
check.py noisy latency cluster + bench_list_sum.bump_s ±12% — same
precedent as the previous two audits, not coupled to this milestone.
Closes the loop opened by this morning's .ailx → .ail rename. Every
path-taking ail subcommand (check, build, run, manifest, render,
prose, merge-prose, describe, emit-ir, diff, workspace, deps) now
accepts a .ail (Form A) input as well as .ail.json (Form B). For
.ail paths the subcommand parses through ailang_surface::parse
in-line and proceeds with the same loaded Module value .ail.json
would have produced; binary semantics are extension-agnostic.
Architecture: a new ailang_surface::{load_module, load_workspace}
pair dispatches on file extension. A new injection point
ailang_core::workspace::load_workspace_with<F> lets the surface
crate plug in an extension-aware loader without core having to
import surface (which would close a crate cycle). Import resolution
in workspace::visit now prefers a .ail sibling over a .ail.json
sibling. The CLI binary's 18 callsites switched to the surface
loaders. Surface parse failures route through
workspace_error_to_diagnostic as a new SurfaceParse variant of
WorkspaceLoadError, so `ail check --json foo.ail` on a malformed
.ail returns a parseable JSON diagnostic with code
surface-parse-error instead of the misleading
`json: expected value at line 1 column 1` fall-through.
Boss pre-commit correction: the implementer followed the plan
verbatim and used snake_case `surface_parse_error`, but every
existing diagnostic code in the codebase is kebab-case (verified
via `git grep` over crates/ailang-check/src and crates/ail/src);
realigned to `surface-parse-error` at three sites (the diagnostic
arm + two ct1 test strings). Journal Concerns section records
the correction.
Tests: cargo test --workspace 487 green (+7 from this iter:
1 core unit, 4 surface integration, 1 ail E2E pair, 1 ct1 CLI
diagnostic-shape). bench/check.py, compile_check.py, cross_lang.py
clean on re-run; the latency.{explicit,implicit}_at_rc tail
cluster occasionally flagged on first runs is the same
nondeterministic cluster the previous two audits documented;
baseline left pristine for the third consecutive iter.
Roadmap follow-up "ail check/build/run accept .ail extension" (P2)
removed.
DESIGN.md §Decision 6 / "CLI" bullet gained the symmetric upward
sentence: both .ail and .ail.json are first-class CLI inputs.
The surface-form file extension changes from .ailx to .ail. AILang's
authoring surface now uses the same .ail stem as its canonical JSON
form (.ail.json), giving the language a single coherent extension
family: .ail is the LLM-authored Form A, .ail.json is the canonical
JSON-AST Form B.
Scope (touched):
- 61 example renames examples/**/*.ailx → .ail (git mv)
- 1 rename experiments/.../rendered/ailx.md → ail.md
- 35 content-edited live-toolchain files (crates/, docs/DESIGN.md,
docs/roadmap.md, docs/PROSE_ROUNDTRIP.md, skills/, bench/reference/*.c,
experiment crates under experiments/.../{render,harness,master})
- Experiment-crate cohort rename Cohort::Ailx → Cohort::Ail,
Form::Ailx → Form::Ail, per_cohort/ailx → per_cohort/ail,
{form-only: ailx} → {form-only: ail}, ```ailx → ```ail
Out of scope (deliberately untouched, to preserve honest history):
- docs/journal-archive.md (content-frozen per CLAUDE.md)
- docs/journals/, docs/specs/, docs/plans/, bench/orchestrator-stats/
- experiments/.../runs/ (frozen LLM-output artefacts; models actually
saw .ailx — renaming would falsify the experimental record)
Verification: cargo build/test --workspace green; experiment crate
cargo test green; bench/check.py + compile_check.py + cross_lang.py
all 0-regressed; negative grep for ailx|Ailx|AILX outside the
out-of-scope paths returns zero matches.
Opens immediate follow-up: roadmap.md P2 todo `ail check`/build/run
accept .ail extension — after this rename, .ail is canonical
authoring surface but the CLI still produces a misleading JSON-parse
error on `ail check foo.ail`. That's the next iter.
Milestone "Cross-model authoring-form test" closed. Live IONOS run
against Qwen/Qwen3-Coder-Next executed at temperature=0, max-turns=5,
token-budget=500000. Raw dataset under runs/2026-05-12-df7531/:
RUN_STATUS=ok, scores.csv with 8 rows (4 tasks × 2 cohorts),
summary.md, plus 156 per-turn raw artefacts (request/response/program/
stderr/stdout) preserved for any future analysis.
Headline numbers:
- AILX cohort: 2/4 green, 1/4 first-attempt, ~95k prompt + ~2.6k completion
- JSON cohort: 1/4 green, 0/4 first-attempt, ~192k prompt + ~15k completion
- Only first-attempt success was AILX on t3_main_prints (~5 lines of
tagged s-expr vs ~23 lines of structured JSON for the JSON cohort's
turn-1 attempt that needed correction).
- Per spec, single subject + single deterministic run = data point,
not verdict.
DESIGN.md §"Decision 6: authoring surface" gains an "Empirical
addendum (2026-05-12)" subsection: 6-row metric table, illustrative
t3 contrast, explicit single-subject scope note pointing at the
roadmap follow-up entry for multi-subject expansion.
Roadmap: P2 entry "Cross-model authoring-form test" removed (this
journal is the convention's one-line mirror); P3 entry
"Multi-subject expansion (cross-model authoring-form follow-up)"
added with the run dir named as baseline.
Milestone artefacts span three iters (cma.1 master mini-spec +
renderer; cma.2 harness + tasks + reference solutions; cma.3 this
live run + DESIGN.md addendum + close).
Milestone-close audit found three drift items (architect) plus
two carry-on items; bench all-green.
Fixes (rt.tidy, boss-direct):
- 5th test parse_then_print_then_parse_is_idempotent_on_every_ailx_fixture
enforces Direction 2 of the Roundtrip Invariant directly. The
DESIGN.md enforcement list grows from four to five tests.
- docs/roadmap.md P1 'Round-trip completeness invariant' entry
retired with one-line journal mirror per roadmap convention.
- DESIGN.md wording sync: <16-hex> → <16-lowercase-hex> for the
Float-bits-hex spelling in the new section (matches §Data model).
Milestone closed clean; five workspace-wide tests anchor the
.ail.json ↔ .ailx bijection plus AST-variant coverage.
New top-level section '## Roundtrip Invariant' between Decision 11
and '## Mangling scheme'. States both directions of the .ail.json
↔ .ailx bijection, names Float-bits-hex encoding as the in-
invariant mechanism (not an exception), lists the four enforcement
tests by path, and explains why the property is top-level
(load-bearing on language identity, not on Decision 6's surface-
design rationale).
Decision 6 Constraint 2 rewritten as upward cross-reference to the
new section; the substantive content now lives at the anchor.
Direct boss edit (no planner/implement dispatch): spec Architecture
#1 fully specified the content, no design judgement deferred to
execution. The iter is a deterministic projection of the spec
onto DESIGN.md prose.
Milestone-23 close audit. Architect clean on substance; one
pre-existing DESIGN.md grammar stub (line 1692, dangling 'The
original' from 2026-05-10 design-md-consolidation) caught and
fixed.
Bencher localised the compile_check.py regression to H2 (the 5 new
polymorphic prelude Def::Fns iter 23.5 added — each carries forall
+ class constraint + match/not body, all typechecked on every
workspace check). H1 (linearity workspace-visibility extension +
contains_rigid_var) is negligible at today's 2-module workspace.
The regression is the feature's measured cost. Ratified per audit
skill (--update-baseline + journal entry). 0/24 metrics regressed
post-update.
Milestone 23 (Eq/Ord prelude) is closed.
Five polymorphic prelude free fns (ne/lt/le/gt/ge) plus three E2E
fixtures: positive composition over Int/Bool/Str, user-ADT with
user-written Eq/Ord on IntBox, and Float-NoInstance with a
Float-aware diagnostic addendum cross-referencing DESIGN.md §"Float
semantics".
Two checker-side fixes were needed alongside the prelude additions,
both symmetric to the iter 23.4-prep visibility fix:
- linearity::check_module_with_visible — workspace-aware callee-mode
resolution so a Borrow-mode user fn forwarding into a prelude poly
fn (e.g. `at_most x y = not (gt x y)`) doesn't false-fire
consume-while-borrowed.
- mono::collect_mono_targets — distinguish rigid forall vars from
unbound metavars; rigid-bearing free-fn targets skip (they get
re-collected with concrete subst when the enclosing fn itself
monomorphises). Without this, a polymorphic body calling another
polymorphic free fn produced a spurious __Unit specialisation
whose body referenced compare at Unit.
Roadmap split: shipped Eq/Ord half checked, Show + print-rewire half
remains pending behind the heap-Str ABI prerequisite. DESIGN.md
§"Prelude classes" amended.
One pre-existing fixture (test_22b1_missing_method) renamed its
class method ne → tne to avoid collision with the new prelude `ne`.
Bench: check.py + cross_lang.py exit 0; compile_check.py exits 1
with one sub-ms check_ms.local_rec_capture at +26% (tolerance 25%) —
prelude-growth-related noise-band fluctuation, ratifiable per spec
§248-249 and journal Concerns.
Restructure ailang-check/src/mono.rs::monomorphise_workspace into a
single specialiser over every Type::Forall-quantified Def::Fn, covering
both class-method residuals AND polymorphic free-fn call sites in one
fixpoint pass. Remove the codegen-time specialiser (lower_polymorphic_call,
module_polymorphic_fns, mono_queue, mono_emitted, emit_specialised_fn,
plus the now-dead helpers apply_subst_to_term, descriptor_for_subst,
type_descriptor) entirely. Codegen sees only monomorphic Def::Fns after
this iter — no polymorphic call sites, no codegen-time queue.
Architecture changes:
- MonoTarget: struct → enum with ClassMethod / FreeFn variants; dedup
keys are guaranteed-disjoint via 'class'/'free' first component.
- collect_mono_targets: new FreeFnCall channel through synth (cleaner
than the plan's separate-walker proposal — one less data flow,
substitution tracking comes 'for free' via fresh metavars +
post-synth subst.apply). Plan Step 4.4 (polymorphic_free_fns env
field) consequently obsolete.
- synthesise_mono_fn_for_free_fn: new free-fn entry point; substitutes
rigid vars in BOTH the type AND the body (body substitution required
because free-fn bodies carry inner Lams whose param_tys reference
outer Forall vars — adapted from plan's 'body unchanged' sketch).
- mono_symbol_n: N-ary extension of mono_symbol; bit-stable for the
single-type-var case (hash-stability pin Task 1 fires zero times
through all eight refactor tasks).
- rewrite_class_method_calls → rewrite_mono_calls; interleaved-slot
collector preserves the positional-cursor invariant across both
channels.
- workspace_has_typeclasses → workspace_has_specialisable_targets
(principled correctness; old predicate happened to already be true
for every user workspace because the prelude is auto-injected).
Codegen cleanup:
- Two call sites of lower_polymorphic_call deleted (codegen/src/lib.rs
lines ~1939-1945, ~1965-1973).
- lower_polymorphic_call body, module_polymorphic_fns field +
population + Emitter wiring, mono_queue / mono_emitted, drain loop,
emit_specialised_fn — all removed.
- is_static_callee collapsed to module_user_fns-only.
- subst.rs apply_subst_to_term + descriptor_for_subst removed; synth.rs
type_descriptor removed (all dead post-removal).
- ail emit-ir command pipeline gains lift_letrecs + monomorphise_workspace
pre-passes (pre-iter-23.4 the codegen-time poly path was masking
this dependency — emit-ir is now consistent with build).
- Net codegen reduction: -285 lines lib.rs, -93 lines subst.rs.
Tests:
- mono_hash_stability.rs (new): regression pin for six primitive Eq/Ord
mono-symbol body hashes; stayed bit-stable through all eight refactor
tasks.
- mono_unification.rs (new): six tests covering variant-key disjointness,
free-fn target emission, free-fn synthesis with rigid substitution,
rewrite walker on free fns, identity for poly-free-fn-only workspaces,
end-to-end cmp_max_smoke runtime correctness.
- typeclass_22b3.rs: three test sites updated to MonoTarget::ClassMethod
enum form.
- 73 e2e + 18 typeclass + 81 codegen tests all green workspace-wide.
DESIGN.md §'Monomorphisation (post-typecheck, pre-codegen)' amended:
two-arm fixpoint described, disjoint-keyed dedup, cursor-aligned
walker, removed codegen-time path.
Bench check (all exit 0, 112 metrics stable): bench/check.py +
compile_check.py + cross_lang.py.
Plan parent: docs/plans/2026-05-11-iter-23.4.md (fab1685).
Spec parent: docs/specs/2026-05-11-23-eq-ord-prelude.md (841d65d).
Per-iter journal documents two adapted deviations from the plan
(synth-channel vs separate walker; body substitution; emit-ir fix);
no spec defects.
Anthropic now reserves /plan as a UI command, so the Skill tool refuses to
dispatch it. Rename the project's plan skill to planner, update the symlink
under .claude/skills/, and adjust references in CLAUDE.md, DESIGN.md,
skills/README.md, and the cross-references between brainstorm / implement /
audit / fieldtest / fieldtester. Plan files themselves (docs/plans/*.md)
keep their name — only the skill ID changes.