Commit Graph

898 Commits

Author SHA1 Message Date
Brummel c5eca7e91d bench: re-ratify compile_check baseline (closes #45)
Re-baseline bench/baseline_compile.json, last ratified 2026-05-15
(20add51, mut.4-tidy). check_ms shifted +35..47% uniformly across all
12 fixtures (~1.8-3.3ms -> ~2.4-4.7ms); build_O0_ms rose ~10%
(90->100ms) but stayed inside its 20% tolerance and so never tripped.

RATIFY, not a regression. The decisive signal is that the check_ms
drift is *uniform* across every fixture regardless of complexity
(hello +35%, nested_pat +35%). Fixture-independent drift means the
cost lives in shared per-invocation work, not in any fixture-specific
checker path. Two concrete shared-cost growths fully account for it:

  - examples/prelude.ail grew 116 -> 153 lines (+32%); every
    `ail check` parses and typechecks the full prelude.
  - the kernel_stub module is now injected alongside prelude on every
    check (landed in raw-buf prep.3, 9339279, #33) — a whole extra
    module parsed + typechecked per invocation.

Both are real modules added by wanted features, not a quadratic
blow-up. There is no fixable type-checker regression hiding here; the
+0.8ms is the parse+typecheck cost of more shared source. The throughput
baseline (bench/baseline.json) was already re-ratified 2026-05-28
(de4399d); this brings the compile baseline back in sync.

Corrects the record: the #44 cycle-close audit body (7321826) wrongly
attributed the compile_check exit-1 to transient machine load. main is
sacrosanct so 7321826 cannot be amended; #45 and this commit are the
forward correction.

Verified: compile_check.py -n5 -> 24 metrics, 0 regressed, 24 stable.
2026-05-30 15:31:04 +02:00
Brummel 7321826c66 audit: cycle-close tidy for #44$-lexer-reservation (refs #44)
Cycle-close audit for the #44 Form-A `$`-reservation cycle
(b151990..HEAD). One drift item fixed inline. Both regression scripts
exited 1; investigated and attributed to machine load this session, NOT
a code regression — baseline deliberately NOT updated.

Architect drift review:
- [fixed] design/models/0001-authoring-surface.md — the line "The only
  reserved tokens are `(`, `)`, and whitespace" was made stale by this
  cycle (the lexer now rejects any identifier token containing `$`).
  Corrected to distinguish token *delimiters* (`(`/`)`/whitespace) from
  the `$` *character reservation within a token*, scoped to the Form-A
  authoring surface, naming the enforcement site
  (`LexError::ReservedDollar`) and the intentional `.ail.json` non-guard.
- [carry-on] honesty sweep otherwise clean: the only other `$`-mentions
  in the ledger (0008-memory-model.md, 0003-pipeline.md) describe
  synthetic mint names (buf$1, <hint>$lr_N) and remain correct. No
  over-broad "no Module can contain `$`" prose exists. The corrected
  fresh_binder doc-comment (feat commit) is Form-A-scoped and honest.
- Decision: no new ledger *contract* added. 0015-language-constraints
  holds the four RC-soundness preconditions (strict eval, no recursive
  value bindings, no shared mutable refs, acyclic ADTs); a lexical
  character reservation is a different category and is documented in the
  authoring-surface model doc with its enforcement site, which is its
  natural home. Architect confirmed absence of `$`-contract prose is not
  itself drift.

Lockstep pairs: none apply (architect walked each against the diff):
- Pattern::Lit typecheck <-> pre_desugar_validation.rs — neither changed.
- lower_app <-> is_static_callee — codegen unchanged.
- INTERCEPTS <-> (intrinsic) markers — intercepts.rs + kernel sources
  unchanged.

Regression scripts:
- bench/cross_lang.py: EXIT 0. 25 metrics; 0 regressed, all stable.
- bench/compile_check.py: EXIT 1; "12 regressed" — ALL are check_ms.*
  (type-check wall-clock), baseline ~1.8-3.3ms vs actual ~2.5-4.4ms,
  ~+0.8ms absolute and roughly UNIFORM across every fixture regardless
  of size (hello +39%, bench_list_sum +51%). All 12 build_O0_ms.* (the
  ~93ms LLVM portion of the same `ail build`) are within tolerance but
  also uniformly shifted +12-15%. Attribution: NOT this cycle's code.
  The guard adds one `raw.contains('$')` byte-scan per token; a tiny
  module is ~50 tokens, so the added cost is nanoseconds — physically
  cannot account for +0.8ms on a 2ms check. The uniform ~12-15% shift
  across the WHOLE `ail` invocation (check AND build) is a global
  machine-slowdown fingerprint (heavy concurrent subagent/cargo load
  this session); it crosses the 25% tolerance only on the tiny-baseline
  check_ms metrics. Localised by reasoning, not by the bencher (the
  effect is environmental, nothing to localise in code).
- bench/check.py: EXIT 1; throughput metrics flipping run-to-run with
  identical code (rc_over_bump moved in/out of REGRESSION across four
  runs; bump_s swung -9.5/-11/-14/-15%). Same machine-load variance;
  rc_over_bump is a ratio of two independently-noisy wall-clock
  measurements, the noisiest metric in the set.

Baseline NOT updated on either script: ratifying a machine-load delta
would bake an under-load baseline in and mask a future real regression.
OPEN VERIFICATION: re-run bench/compile_check.py and bench/check.py on a
quiet machine to confirm green; tracked as the cycle's one open item.
Cycle #44 closes (code + docs clean; regression gate pending a
clean-machine confirmation, baseline untouched).
2026-05-30 15:18:49 +02:00
Brummel 11e4c4624d feat: reserve $ in the Form-A lexer (closes #44)
Enforces the `$`-in-authored-names reservation that fresh_binder
(ailang-core::desugar) silently relied on. `fresh_binder` mints
shadow-rename binders as <base>$<n>; its collision probe could not see
an authored binder literally named <base>$<n> that binds later under
the same (def, name) uniqueness key — the exact collapse class #43
closed. The `$`-for-synthetic convention (`$mp_N`, <hint>$lr_N,
<base>$<n>) was held by discipline only.

This makes it a lexer-enforced invariant: no Form-A identifier token
may contain `$`. A one-line guard in tokenize rejects any token run
containing `$` before int/float/ident classification, raising the new
LexError::ReservedDollar { token, start }. It surfaces through the
existing ParseError::Lex -> W::SurfaceParse -> surface-parse-error
channel with zero new wiring — no new CheckError, no AST walker, no
entry-point threading.

Placement rationale (the load-bearing call): the threat vector is
Form-A source only. The client LLM author is forbidden from emitting
canonical .ail.json directly — it writes Form A exclusively; only the
orchestrator hand-authors JSON in rare exceptions. So every authored
identifier a hallucinating client could produce flows through the
lexer. `$` is a reserved character (like parens) with no legitimate
authored use in any position — unlike `.`/`/`, which DO have
legitimate uses (std_list.map) and therefore live in the check layer
(InvalidDefName) with AST-aware positional logic. No positional split
for `$` means no walker; the lexer is the right boundary. An earlier
draft put the reject in pre_desugar_validation justified by "a client
could build .ail.json directly" — false under the authoring contract,
so the simpler lexer reservation replaced it.

Exemptions by scan order, not special-case: `$` inside string literals
and comments stays legal because both are consumed by earlier branches
of the scan loop, before a run is ever sliced. The six checked-in
example files mentioning loop$lr_0 in comments keep parsing.

Documented non-goals (honesty rule): the .ail.json deserialization
path stays unguarded (the orchestrator's self-responsible channel);
the fresh_binder probe-body simplification (issue Q4) is not bundled —
only its now-false doc-comment is corrected to state the enforced
invariant, scoped to Form-A authored identifiers.

Verification (all run, all green):
- 3 in-source lexer tests: reject on x$1, exemption for $ in string,
  exemption for $ in comment. RED confirmed pre-guard (tokenize("x$1")
  returned Ok([Ident]), expect_err panicked); GREEN after.
- 2 integration tests (reserved_dollar_pin.rs): the reject reaches the
  public parse entry as ParseError::Lex(ReservedDollar); string
  exemption survives at parse level.
- cargo test --workspace: green, 0 failed.
- CLI: check on the comment-$ example (exit 0) and on the #43 shadow
  idiom raw_buf_int.ail (exit 0, buf->buf$1 rename minted post-parse,
  never re-lexed); a fresh $-binder source now rejected with the
  surface-parse-error diagnostic naming the token and byte offset.

Spec: docs/specs/0057-reserved-dollar-in-names.md (grounding-check
PASS). Plan: docs/plans/0112-reserved-dollar-in-names.md.
2026-05-30 15:00:17 +02:00
Brummel 559531806d plan: reserved-dollar-in-names — dollar-lexer-reservation (refs #44)
Executable plan for spec 0057 (committed c760570). Single iteration,
four tasks: (1) LexError::ReservedDollar variant + inline tokenize
guard + 3 in-source lexer tests (RED-first on the must-fail
dollar_in_ident_is_reserved); (2) fresh_binder doc-comment correction;
(3) a surface integration test pinning the
parse -> ParseError::Lex(ReservedDollar) surfacing chain; (4)
no-regression sweep (workspace suite + CLI checks on a comment-dollar
example and the #43 shadow idiom).

plan-recon verified all line anchors against HEAD (lex.rs:183
run-slice, lex.rs:67-76 enum, 19 in-source tests, desugar.rs:528-536
doc-comment, parse.rs:103/126/149, main.rs:1339, ct1_check_cli.rs:310)
and confirmed none of the three CLAUDE.md lockstep pairs apply.
Self-review caught and fixed two defects before hand-off: the
integration test imported the private lex/parse module paths
(corrected to the crate-root re-exports
ailang_surface::{parse, LexError, ParseError}), and the compile-gate
check confirmed no external exhaustive match on LexError exists, so
adding a variant cannot break compilation and the Task 1 RED is a
genuine runtime expect_err panic.
2026-05-30 14:51:41 +02:00
Brummel c76057008e spec: reserve $ in the Form-A lexer (refs #44)
#44 asks to enforce-or-retract the `$`-in-authored-binder-names
reservation that `fresh_binder` (ailang-core::desugar) relies on for
collision-free shadow-rename mints. This spec chooses enforcement, and
places it in the Form-A lexer rather than the check layer.

The placement is the load-bearing design decision. An earlier draft put
the reject in `pre_desugar_validation` (check layer), justified by "a
client could construct `.ail.json` directly, bypassing the lexer". The
user corrected the threat model: the client LLM author is forbidden from
emitting canonical `.ail.json` — it writes Form A exclusively, and only
the orchestrator hand-authors JSON in rare exceptions. So the entire
hallucinating-client attack surface is Form-A source, which always flows
through `tokenize`. That collapses the design:

- `$` is a reserved character (like `(`/`)`) with no legitimate authored
  use in any position — verified: zero authored `$` idents exist in the
  checked-in `.ail` corpus; the six `$` occurrences are all in comments.
  No positional split, so no AST-aware walker is needed — unlike `.`/`/`,
  which DO have legitimate uses (`std_list.map`) and therefore live in
  the check layer (`InvalidDefName`). The issue's premise "lex.rs
  reserves only `.`" is false on two counts and is corrected in the spec.
- The reject is a single new `LexError::ReservedDollar`, raised inline in
  the `tokenize` run-classification arm, surfaced through the existing
  `ParseError::Lex` -> `W::SurfaceParse` -> `surface-parse-error` channel.
  No new `CheckError`, no AST walker, no multi-entry-point wiring (the
  check layer has four entry points, only two of which currently run the
  pre-desugar pass — a trap the lexer placement sidesteps entirely).

Documented non-goals (honesty rule): the `.ail.json` deserialization path
stays unguarded (the orchestrator's self-responsible channel, scoped out
by the user); the `fresh_binder` probe-body simplification (issue Q4) is
not bundled — only its now-false doc-comment is corrected.

grounding-check PASS on the final bytes: all six load-bearing assumptions
ratified by green tests; all three Form-A example blocks parse-gate clean
(exit 0 today, by design — the reject is new behaviour). String- and
comment-internal `$` stay legal by scan order; no must-fail `$` fixture
goes under examples/ (the round-trip test parses every fixture there).
2026-05-30 14:41:04 +02:00
Brummel b151990028 audit: raw-buf close-fixes — honesty + ledger for the binder-rename (refs #43)
Architect drift review at raw-buf milestone close (after 55d76ae closed
#43) surfaced three items; this commit clears them. All three lockstep-
invariant pairs were confirmed intact and the effective-name keying was
confirmed consistent across the desugar→lift boundary (lift_letrecs reads
the desugared tree's effective names too).

1. [high] Honesty fix. `fresh_binder`'s doc-comment (and 55d76ae's body)
   claimed "authored names cannot contain `$` — the lexer reserves it".
   False: the lexer (ailang-surface) reserves only `.`, not `$`. The
   `$`-for-synthetic convention is not enforced. So collision-freedom
   rests solely on the `used` + in-scope-`scope` probe, which does not
   see an authored `<base>$<n>` that is out of scope at mint time but
   later binds under the same `(def, name)` key — the very collision
   class this fix closes. Latent (no fixture uses a `$` binder), but the
   stated rationale was wrong. Doc-comment now describes the probe
   honestly and names the gap + its two possible closures (enforce the
   reservation, or seed `used` with every authored binder name in the
   def). The enforce-or-retract decision is the next-direction follow-up.

2. [medium] design/models/0003-pipeline.md — "desugar currently only
   flattens nested constructor patterns" no longer matches the code
   (it now also alpha-renames shadowing binders). Corrected to current
   state (honesty-rule).

3. [low] design/contracts/0008-memory-model.md — the three drop gates
   all key on `(def_name, binder_name)` consume_count, silently relying
   on per-fn binder-name injectivity, which the ledger never stated.
   Added that invariant as an explicit precondition of the gates, with
   the desugar guarantee and its ratifying tests.

No code-logic change; doc-comment + ledger only.
2026-05-30 12:50:55 +02:00
Brummel 55d76ae4a1 fix: alpha-rename shadowing binders at desugar — A2b leg (closes #43)
The last open leg of the RawBuf owned-heap drop-leak cluster #43: the
UniquenessTable shadow-name collapse. The side-table keys by
(def_name, binder_name); a fn that shadows a binder name — the shipped
`(let buf (new…) (let buf (set buf…) … (get buf)))` idiom — collapses
every shadow onto one key. The outermost binding records last (on pop)
and overwrites the inner ones, so codegen's scope-close drop gates read
the collapsed consume_count for the innermost binding, misjudge its
ownership, and suppress its drop. The owned slab leaked (live==1).

Fix: desugar now alpha-renames any binder whose authored name shadows
an enclosing binding to a fresh `<name>$<n>`, making the (def, name)
key injective per fn. The rename is on-shadow-only and uniform across
binder kinds (Let, flat-Match pattern-binders, Lam params, Loop
binders); non-shadowing binders keep their authored name, so every
existing fixture's desugared output is byte-identical (evidenced by the
full pre-existing desugar suite passing unchanged). IR-neutral: codegen
names heap by fresh SSA, never by the source binder name.

No consumer of the uniqueness table changes — they all key by name, and
the name is now a per-fn injective identity (acceptance criterion 5).
The fix is entirely internal to ailang-core::desugar; uniqueness.rs gets
only a doc-comment correction (its old text was wrong on two counts:
"last = innermost by pop-order" was backwards, and "every shipping
fixture has unique binder names" was false).

Mechanism (vs. the spec's sketch): the threaded lexical scope became a
`Scope` struct carrying `entries` keyed by each binder's EFFECTIVE
(post-rename) name and `rename` mapping authored→effective. Keying
entries by effective name is load-bearing, not cosmetic: the LetRec
capture-detection reads `free_vars_in_term` of the desugared body
(effective names) and filters by `scope.contains_key` — had entries
stayed keyed by authored name, renaming an enclosing let would have made
a captured shadowed binder invisible to capture detection (UnknownVar in
the lifted fn). `insert_fixed` records identity renames for never-renamed
binders (fn-params, LetRec name/params) so an inner renamable binder that
shadows them is still detected.

Alternatives rejected: (A) an AST id field and (B) a derived
binding-path — both solve the deeper, SEPARATE problem of persistent AST
provenance back to the authored form, which is certain to be needed
eventually but is not required here; conflating the internal naming
collision with provenance is a category error. C′ (this) reuses the
existing fresh-name machinery and touches one pass.

Verification: the three shipped Let-shadow tests
(raw_buf_{int,float,bool}_shadow_rebind_drop_balances_rc_stats) reach
live==0 (were RED at live==1); the flat-pattern differential
(flat_pat_shadow_binder_does_not_leak_more_than_alpha_renamed) reaches
its alpha-renamed control's live count (was 5 vs 3); a new desugar unit
test (shadowing_let_is_alpha_renamed) pins the rename + reference
resolution; full workspace suite green, no fixture output drift. Both
no-change consumers re-verified by hand: match_lower.rs keys by the
emitted (renamed) pattern binder, linearity builds table and lookups
from the same desugared tree.

Spec: docs/specs/0056-unique-binder-names.md.
Plan: docs/plans/0111-unique-binder-names.md.
2026-05-30 12:42:44 +02:00
Brummel 1990147467 plan: unique-binder-names — desugar alpha-rename for #43 A2b (refs #43)
Executable plan for spec 0056. Three tasks:
- Task 1 (atomic compile unit): introduce a `Scope` struct threading
  two maps — `entries` keyed by each binder's effective (post-rename)
  name, `rename` mapping authored→effective for shadow detection and
  Term::Var resolution; add `fresh_binder` (<name>$<n>) and
  `rename_pattern_binders`; rewrite every binder site (Let, flat-Match
  pattern, Lam params, Loop binders) with rename-on-shadow, plus the
  LetRec arm and both def-boundary constructions, to the new API.
- Task 2: correct the UniquenessTable doc-comment; verify the four RED
  tests go GREEN; full-suite regression gate.
- Task 3: regression guard — a desugar unit test pinning that a
  shadowing let is alpha-renamed and that a letrec capturing the
  shadow-renamed binder still resolves (the entries-keyed-by-effective
  choice is what keeps capture detection correct under rename).

Recon surfaced two facts the spec's sketch did not: the scope is a bare
BTreeMap (so resolved_name/bind live on a new Scope type, not the map),
and the LetRec capture-detection reads effective names from the
desugared body — hence entries are keyed by effective name. Both stay
internal to desugar.rs, preserving acceptance criterion 5 (no change to
uniqueness.rs logic / codegen gates / linearity.rs). Both no-change
consumers verified: match_lower.rs:795 keys by the emitted (renamed)
pattern binder; linearity builds table and lookups from the same
desugared tree.
2026-05-30 12:35:07 +02:00
Brummel 0015f3dad1 spec: unique binder names per fn — A2b leg of RawBuf drop-leak (refs #43)
The last open leg of the #43 owned-heap drop-leak cluster is the
UniquenessTable shadow-name collapse: the side-table keys by
(def_name, binder_name), so a fn that shadows a binder name collapses
every shadow onto one key. The outermost binding (records last, on pop)
overwrites the inner ones; codegen's scope-close drop gates then read
the collapsed consume_count for the innermost binding and suppress its
drop, leaking the owned slab.

Spec 0056 resolves this as a compiler-internal naming collision — NOT
the deeper, separate problem of persistent AST provenance back to the
authored form (certain to be needed eventually, deliberately deferred;
conflating the two is a category error). Fix: alpha-rename shadowing
binders during desugar (reusing the existing fresh-name machinery that
already mints $mp_N / $lr_N), making the (def, name) key injective
again. No consumer of the uniqueness table changes — they all key by
name, and the name becomes a per-fn injective identity. IR-neutral:
codegen names heap by fresh SSA, never by source binder name. The
pre-desugar hashed form is untouched, so module identity and hash-pin
tests are unaffected.

RED fixtures securing the binder-kind class (the user's gating
condition before proceeding to the uniform-across-kinds fix):
- Let-shadow: the three raw_buf_{int,float,bool}_shadow_rebind tests
  (already in tree, committed f7f4c3b) — assert live == 0.
- Flat-pattern-shadow: a differential test plus its two fixtures
  (flat_pat_shadow_leak.ail shadows the outer binder; _control.ail
  alpha-renames it to a distinct name). The shadow must reach the
  control's live count. Differential by design, to isolate the
  shadow-collapse drop from unrelated baseline drop gaps out of
  #43's scope.

Grounding-check PASS; ail-check parse-gate green on every fenced block.
2026-05-30 12:21:27 +02:00
Brummel 0f6108e428 fix: resolve cross-module borrow modes in the uniqueness pass (refs #43)
A2a leg of the owned-RawBuf drop-leak. Prerequisite: regression-free
and independently correct, but does NOT alone close the leak — the
three shadow_rebind RED tests (f7f4c3b) stay RED pending the A2b
shadow-collapse leg, which needs a UniquenessTable contract change and
its own spec.

The leg. The uniqueness pass infer_module runs strictly per module
(called per-Emitter from codegen lib.rs:993 with a single Module). The
module holding `main` registers only its own + builtin signatures in
`globals`; the monomorphised intrinsic signatures (RawBuf_get__Int with
param_modes=[Borrow,Implicit], etc.) live exclusively in the raw_buf
module's separate pass. So a qualified cross-module callee
`raw_buf.RawBuf_get__Int` missed `main`'s globals entirely, returned
vec![], and the walk defaulted every arg to Position::Consume —
mis-counting the borrowed `buf` arg as a consume and inflating the
binder's consume_count, which gates off the scope-close drop. This is a
latent over-conservative default affecting ANY cross-module borrow
callee, not just RawBuf.

The fix (additive, two files). infer_module is kept as a thin wrapper
over a new infer_module_with_cross(m, &cross_module_types) that threads
the workspace-flat module->def->Type table (the same module_def_ail_types
codegen already builds and the A1 emit_call rule already reads). On a
same-module globals miss, callee_arg_modes now falls back to
cross_module_callee_ty: split the callee on the last `.`, resolve
<mod>.<def> positionally against the cross-module table, and read its
Type::Fn.param_modes. Same-module + builtin lookup stays the first
resort; unqualified names and absent entries fall through to the
existing all-consume default unchanged. Modes are matched positionally,
so RawBuf.set's Own first param still counts as a consume (the
new/set-chain binders keep consume_count==1 and are not dropped —
correct, set is in-place, same slab).

Verified: full workspace green except the three intended-RED
shadow_rebind tests; ailang-check and ailang-codegen (incl.
intercepts_bijection_with_intrinsic_markers) green; A1's
raw_buf_owned_drop_balances_rc_stats still green. raw_buf_int.ail still
leaks (live=1) — that is the A2b leg, addressed next.
2026-05-30 11:19:49 +02:00
Brummel f7f4c3b237 test: RED shadow-rebind RawBuf drop-leak on shipped fixtures (refs #43)
raw_buf_{int,float,bool}_shadow_rebind_drop_balances_rc_stats guard
the three shipped milestone fixtures (the LLM-natural shadow-rebind
idiom `(let buf (new...) (let buf (set buf...) ...(get buf)))`) with a
live==0 RC-stats assertion alongside their existing stdout check.

This is a SECOND, distinct leak mechanism (A2), separate from the
anonymous-temp path fixed in 62cd4b4 (A1). The A1 call-site drop does
not cover it: here `get`'s arg is the BOUND `buf` (a Term::Var), not an
anonymous temp, so the A1 rule (which excludes Var args) correctly does
not fire, and the leak persists — allocs=2 frees=1 live=1.

Cause (A2). The uniqueness pass runs post-monomorphisation
(infer_module called from codegen lib.rs:993). It registers the
intrinsic monomorph signatures in `globals` under their bare local
name `RawBuf_get__Int` (uniqueness.rs:128), but callee_arg_modes
(uniqueness.rs:390-410) looks them up by the cross-module-qualified
callee-Var name `raw_buf.RawBuf_get__Int` -> MISS -> vec![] -> every arg
defaults to Position::Consume (walk arm uniqueness.rs:240-242). The
borrow-mode RawBuf param of `get`/`size` is thus mis-counted as a
consume, inflating the final `buf` binder's consume_count from 0 to 1,
which suppresses the Term::Let drop gate at codegen lib.rs:1795. The
param_modes in the registered Type::Fn are correct ([Borrow, Implicit]);
the only defect is the name-key mismatch that hides them from the
lookup.

NOT issue #42's "shadow-name collapse": #42's own evidence shows the
distinct-name (b0/b1/...) variant leaks identically, so the defect is
the borrow-counted-as-consume default, not name-keyed ownership.

RED until callee_arg_modes resolves the qualified intrinsic name to the
registered signature. The A1 test (raw_buf_owned_drop_balances_rc_stats)
and the four stdout _e2e tests stay green.
2026-05-30 10:58:07 +02:00
Brummel 62cd4b4ee7 fix: drop owned temp passed to a borrow slot at the call site (closes #43)
GREEN side of the owned-RawBuf drop-leak. RED test
raw_buf_owned_drop_balances_rc_stats (47efbdb) now passes:
allocs=2 frees=2 live=0, stdout still "10".

The leak. An owned temporary returned by an Own-ret call and then
passed only to a `borrow`-mode parameter is dead after that call but
was dropped by no one. In the repro, RawBuf.set returns (own RawBuf)
in-place (the intercept emits `ret ptr %b`, the same slab it received),
and that result is passed to RawBuf.get whose RawBuf param is `borrow`,
so get does not consume it and returns an i64. After get the owned
slab is dead. Codegen's two scope-close drop emitters cover only
Term::Let binders (the let gate) and Own fn-params; the general call
lowerer emit_call spliced args without inspecting per-arg modes, so an
owned arg landing in a borrow slot was never dec'd. The leak is
binder-independent — the fully inline form with no `let` at all leaks
byte-identically, which is why the fix lives at the call site, not at
the let gate.

The fix (emit_call, codegen-only, +50 lines, uniqueness.rs untouched).
After a non-tail call, for each argument that is itself an
is_rc_heap_allocated owned temporary (Own-ret call / fresh escaping
ctor / lambda — never a plain Var alias, whose owner is some other
binder dropped elsewhere) AND lands in a Borrow-mode param slot, emit
a drop of that argument's SSA through the same per-type drop symbol the
let gate uses (drop_symbol_for_binder). Per-param modes are read from
the Ail-level Type::Fn.param_modes in module_def_ail_types (which
survives monomorphisation: RawBuf_get__Int -> [Borrow, Implicit]);
note FnSig itself carries only LLVM type strings, not modes.

Why it cannot double-drop or drop a live value:
- set's buffer param is Own, not Borrow, so the rule does not fire for
  the new->set link; the single drop lands on the final temp after get.
  set is in-place (same pointer in and out), so that one drop frees the
  one slab exactly once.
- the rule excludes Var args, so it never races the let/Own-param
  emitters for a named binder.
- the dropped SSA is an input arg, always distinct from the fresh call
  result, and the rule is gated on !tail, so it can never dec a value
  that flows out as the surrounding fn's result.

Known limitation (advisory, no fixture exercises it, stated in a code
comment): a musttail call passing an owned temp into a borrow slot
still leaks — a tail call terminates the block, leaving no post-call
emission point.

Verified: full workspace green; ail e2e 95/95 incl. the 4 raw_buf
fixtures (60/4.0/42/reject) and the now-GREEN drop test; codegen
intercepts_bijection_with_intrinsic_markers green.
2026-05-30 10:49:49 +02:00
Brummel 47efbdb5c0 test: RED owned-RawBuf drop-leak repro (refs #43)
raw_buf_owned_drop_balances_rc_stats + examples/raw_buf_drop_min.ail:
a single owned 1-slot Int RawBuf, written once and read once. Prints
10 correctly but under AILANG_RC_STATS shows allocs=2 frees=1 live=1 —
the slab leaks. Asserts stdout=="10" (green) and live==0 (RED until
the owned-temp drop call is emitted).

Root cause (verified, supersedes #42's disproven diagnosis). The
leaked value is the anonymous owned temporary produced by RawBuf.set
(own-ret, in-place: intercepts.rs emit_rawbuf_set_int does `ret ptr
%b`, same slab as its input), then passed to RawBuf.get whose RawBuf
param is `borrow`. After get borrows and returns an i64 the temp is
dead, but no one drops it: codegen's only two scope-close drop
emitters cover Term::Let binders (lib.rs:1785-1822) and Own fn-params
(lib.rs:1426-1502); the general call lowerer emit_call (lib.rs:2552)
splices args without inspecting per-arg own/borrow modes and emits no
post-call drop for an owned arg landing in a borrow slot.

Binder-independent, contra #42: the fully inline form with no `let`
at all produces byte-identical leaking IR (no Term::Let exists, so the
1785 gate is never reached). The defect is the drop-CALL-site emission
raw-buf.4 deferred to raw-buf.5; uniqueness.rs is not implicated.
2026-05-30 10:41:52 +02:00
Brummel 8ac8756682 iter raw-buf.4 rawbuf-payload-termnew-desugar (DONE, drop-call deferred to .5): RawBuf works, prints 60 (refs #7)
Ships RawBuf end-to-end as a consumer of the raw-buf.3 scope-qualified
intrinsic mechanism, plus the general Term::New construction sugar.
Working subset committed; the drop-CALL ratification (no-leak) is
re-carved to raw-buf.5 (see b49f57d) — the flat drop FUNCTION ships here,
its call-insertion needs a codegen resolution mechanism.

What ships (all green):
- raw_buf kernel-tier submodule (crates/ailang-kernel/src/raw_buf/):
  RawBuf TypeDef (param-in {Int,Float,Bool}, ctor B a) + four
  (intrinsic) ops new/get/set/size. parse_raw_buf + workspace injection
  (kernel-tier auto-imported); workspace count 4 -> 5.
- 12 scope-qualified INTERCEPTS entries RawBuf_{new,get,set,size}__{Int,
  Float,Bool} + emit fns: new allocs an @ailang_rc_alloc slab
  (8-byte i64 size header + n*width element bytes), get/set
  getelementptr+load/store at offset 8 + i*width, size loads the header.
  Mechanical on the raw-buf.3 naming + bijection machinery; bijection
  green (4 markers -> 12 entries).
- Term::New desugar (crates/ailang-core/src/desugar.rs): (new T <types>
  <values>) -> (app T.new <values>), runs before check so the
  type-scoped callee flows through the raw-buf.3 scope threading to
  RawBuf_new__T; drops the NewArg::Type (element type inferred from
  use). Both codegen Term::New deferral arms removed (replaced with
  unreachable!). Ratified by new_stubt_builds_and_runs.
- Flat intrinsic-storage drop FUNCTION @drop_raw_buf_RawBuf (single
  @ailang_rc_dec on the slab), emitted for any TypeDef whose new op is
  (intrinsic)-bodied — distinguishes RawBuf (intrinsic new) from StubT
  (real-body new -> generic ADT drop).
- E2E: raw_buf_int (-> 60), raw_buf_float (-> 4.0), raw_buf_bool
  (-> 42), raw_buf_param_in_reject (param-not-in-restricted-set).

In-scope additions beyond the literal plan (both sound, ratified):
- qualify_workspace_term now normalises a monomorphic cross-module
  type-scoped callee (StubT.new) to <home>.f; without it
  new_stubt_builds_and_runs cannot build (StubT.new is monomorphic, so
  the raw-buf.3 poly-mono path mints no symbol). Same-module + polymorphic
  callees carved out.
- diagnostic-behaviour: (new T ..) missing-new-op now surfaces
  type-scoped-member-not-found (desugar runs before check, bypassing
  synth's Term::New arm); new-arg-kind-mismatch obsoleted. Two unit
  tests updated to the new behaviour. param-in reject unaffected.

The 5 .ll snapshots gained @drop_raw_buf_RawBuf (+ partial) — injecting
raw_buf emits its drop fn into every program; benign, regenerated to the
final IR. (The plan's "snapshots stay green" assumption was wrong.)

Verification (orchestrator, this session): cargo test --workspace 676
passed / 0 failed / 2 ignored. raw_buf_int_e2e prints 60; bijection,
round-trip, new_stubt, float/bool/reject all green. The raw_buf_no_leak
test is NOT in this commit — it moves to raw-buf.5 with the drop-call
resolution that makes it pass (the slab currently leaks at scope close;
tracked, fixed next iter; milestone not released until close).
2026-05-30 00:49:51 +02:00
Brummel b49f57d9c6 spec: raw-buf — re-carve drop ratification to raw-buf.5, retirement to .6 (refs #7)
raw-buf.4 (RawBuf payload + Term::New desugar) implemented and about to
land, but it surfaced that the drop ratification was mis-scoped: the
flat drop FUNCTION is emittable in .4, but the drop CALL needs a codegen
resolution mechanism the .4 plan did not scope. An owned RawBuf binder's
value is a cross-module type-scoped intrinsic call ((app RawBuf.set ..) :
own (RawBuf a)); codegen's is_rc_heap_allocated only marks an App binder
drop-trackable when synth_callee_ret_mode resolves the callee's Own mode,
but codegen synth_arg_type has no TypeDef-first / cross-module-mono
resolution ladder (unlike the checker's lib.rs:3465), so the binder is
non-trackable and no drop call is inserted. A second site
(uniqueness::infer_module's per-module globals) misses the cross-module
borrow mode too.

Re-carve (six iterations; .1-.4 done, .5/.6 remain):
  raw-buf.4 — RawBuf payload + Term::New desugar + flat drop FUNCTION
              (DONE). Worked program prints 60; Float/Bool/reject ship.
  raw-buf.5 — owned cross-module type-scoped drop-call resolution: a
              TypeDef-first + cross-module-mono arm in codegen
              synth_arg_type feeding is_rc_heap_allocated /
              synth_callee_ret_mode / drop_symbol_for_binder, plus
              cross-module op visibility in uniqueness::infer_module.
              Ratified by raw_buf_no_leak (live == 0).
  raw-buf.6 — kernel_stub retirement (was .5).

Also documents the raw-buf.4 diagnostic-behaviour change: because the
Term::New desugar now runs before check, (new T ..) with a missing
new-op surfaces type-scoped-member-not-found (more precise) rather than
the prep.2 new-type-not-constructible it supersedes; new-arg-kind-mismatch
is obsoleted (the desugar drops the type-arg). Same rejection conditions,
preserved. And corrects the @ailang_rc_release spec slip to the real
symbol @ailang_rc_dec.

The drop-call resolution is its own iteration for the same reason
raw-buf.3 became a mechanism prep: it is a codegen-resolution mechanism
(parity with the checker's type-scoped/cross-module ladder), separable
from the RawBuf payload, and isolating it keeps the resolution change
off the .4 payload diff.
2026-05-30 00:49:28 +02:00
Brummel 9ff1f22d42 plan: raw-buf.4 RawBuf payload + Term::New desugar (refs #7)
Four tasks. Task 1: raw_buf manifest (source.ail + mod + lib re-export)
+ ailang-surface wiring (parse_raw_buf + injection) + round-trip +
workspace count 4->5 — checkable, no codegen (raw_buf not yet in the
bijection collector list, so its markers are uncollected, bijection
stays green). Task 2: the general Term::New -> (app T.new <values>)
desugar (runs before check, so the type-scoped callee flows through the
raw-buf.3 scope threading to RawBuf_new__T), drops the NewArg::Type
(element type inferred from use — no type-ascription term exists),
removes both codegen deferral arms; ratified by a (new StubT 42)
build-and-run. Task 3: the 12 scope-qualified RawBuf_op__T entries +
emit fns (alloc/gep/load/store over an @ailang_rc_alloc slab, Int emits
in full + Float/Bool by an exact element-type/width substitution table)
+ a flat intrinsic-storage drop + raw_buf added to the bijection
collector module list (marker<->entry lockstep closes here). Task 4:
the E2E (int -> 60, float, bool, param-in reject, drop leak).

plan-recon resolved both make-or-break risks favourably: (A) the
alloc/load/store emit has a clean mirror (emit_eq_str does
locals-by-position -> fresh_ssa -> gep -> call -> ret; @ailang_rc_alloc
returns the payload ptr with rc-header auto-prepended); (D) desugar runs
BEFORE check/synth/mono (prepare_workspace_for_check), so Term::New
becomes (app RawBuf.new ..) before synth's type-scoped resolution sets
scope=Some("RawBuf") -> reaches the raw-buf.3 RawBuf_new__Int mint. No
checker tweak needed.

Three orchestrator design calls (spec underspecified the codegen
detail): (1) drop discriminator = derived signal "the TypeDef's `new`
op is (intrinsic)-bodied" -> flat @ailang_rc_dec drop; correctly
separates RawBuf (intrinsic new) from StubT (real-body new), where a
param_in heuristic would misclassify StubT (also param_in). No schema
change, no hash-pin. (2) @ailang_rc_release does not exist -> the real
symbol is @ailang_rc_dec (spec slip corrected). (3) Bool = 1 byte per
spec; the 12 emits are per-element-specialised so each hardcodes its
width (Int/Float 8, Bool 1) + store type (i64/double/i1); the i1
store/load syntax is verify-first, raw_buf_bool_e2e is the catch.

Baseline after raw-buf.3 is 670; gates step 670->671 (round-trip)
->672 (StubT desugar) ->677 (5 RawBuf E2E). kernel_stub stays
(retirement is raw-buf.5).
2026-05-29 19:53:36 +02:00
Brummel fd37fa6489 iter raw-buf.3 typescoped-intrinsic-mechanism (DONE 2/2): scope-qualified mono symbols + bijection expansion (refs #7)
New language mechanism: a type-scoped polymorphic top-level (intrinsic)
op now mints a scope-qualified mono symbol (StubT_peek__Int, not the
colliding bare peek__Int), and the intrinsic<->intercept bijection
expresses one such polymorphic marker as its N per-param-in-element
entries. Ratified standalone on the stub via a new StubT.peek op; no
RawBuf, no Term::New, no codegen E2E this iteration (RawBuf consumes the
mechanism in raw-buf.4).

Task 1 — scope threading. Added scope: Option<String> to FreeFnCall +
MonoTarget::FreeFn + the synth free_fn_owner tuple. Populated Some(prefix)
ONLY on the type-scoped T.f resolution branch (lib.rs:3538, gated on
type_home.is_some()); None on every local / same-module / implicit-import
branch, so existing bare-resolved symbols stay byte-identical. Consumed
via a scoped_base helper at both mono-symbol mint sites (synthesise +
rewrite, which read the same MonoTarget → automatically in lockstep) and
folded into mono_target_key so a scoped op and a bare op of the same name
never dedup-collide.

Task 2 — ratifier + bijection. Added StubT.peek to the stub source;
extended workspace_intrinsic_markers with a type-scoped-poly arm that
finds the unique same-module TypeDef referenced in the fn signature and
expands over its param-in set, building the T_f__<elem> strings via the
SAME mono_symbol_n on Type::Con elems so collector and mint agree
byte-for-byte; registered StubT_peek__Int/Float entries + emit fns;
extended kernel_stub_module_round_trips to cover peek; added
mono_scoped_symbol integration test (own-param calls, no Term::New, no
codegen — asserts both scoped symbols minted and bare peek__Int absent).

Latent bug fixed inline (regression caught at the full-suite gate, RED via
the existing escape_local_demo E2E). kernel_stub is implicitly imported,
so poly_free_fn_names_for_module unconditionally added peek's bare name to
every consumer; a consumer with its OWN monomorphic peek then had its
local call mistreated as a poly-free-fn site, over-advancing the mono slot
cursor and misaligning a print target. Fix: suppress the bare
implicit-import name when the current module declares a same-name global,
mirroring synth's resolution ladder (same-module global beats
implicit-import). Applied in lockstep to both poly_free_fn name + and
constraint-count maps; dot-qualified / type-scoped spellings stay
unconditional. This was a pre-existing inconsistency between mono's
name-set and synth's resolution that peek (first implicitly-imported poly
free fn colliding with a fixture-local name) exposed.

Plan deviations (all sound): collect_con_heads omits Borrow/Own recursion
(no such Type variant — borrow/own are ParamMode on Type::Fn, verified
ast.rs:752); scope bound via the existing synthesise_mono_fn_for_free_fn
MonoTarget destructure rather than a new param; the mono-pin fixture uses
own params (borrow + peek-returns-a trips consume-while-borrowed; peek is
never instantiated so the mode is immaterial to the scope-symbol
ratification).

Verification (orchestrator, this session): cargo build --workspace clean;
cargo test --workspace 670 passed / 0 failed / 2 ignored (669 baseline +
the one new mono test). Bijection green (1 marker -> 2 entries),
round-trip green, escape_local_demo green. .ll snapshots unchanged (peek
polymorphic -> never instantiated without a call site). Existing eq__*/
compare__*/float_* symbols byte-identical (mono_hash_stability green).
2026-05-29 19:39:35 +02:00
Brummel 8508182f84 plan: raw-buf.3 type-scoped polymorphic intrinsic mechanism (refs #7)
Two tasks. Task 1 threads a TypeDef scope through the free-fn mono path:
a scope: Option<String> field on FreeFnCall + MonoTarget::FreeFn,
populated Some(prefix) only on the type-scoped T.f resolution branch
(lib.rs:3535, gated on type_home.is_some()), None everywhere else;
consumed at the two mono-symbol mint sites via a scoped_base helper
(both read the same MonoTarget → lockstep) and folded into
mono_target_key. Result: StubT.peek @ Int mints StubT_peek__Int, not
the colliding bare peek__Int; existing bare-resolved symbols unchanged
(669-gate is the no-regression backstop).

Task 2 adds the StubT.peek ratifier op to the stub source, extends the
bijection collector to expand a polymorphic type-scoped intrinsic over
its TypeDef's param-in set (building the same T_f__<elem> strings via
mono_symbol_n on Type::Con elems, so collector and mint agree
byte-for-byte), registers StubT_peek__Int/Float entries + emit fns,
updates kernel_stub_module_round_trips, and adds a mono-symbol
integration test (borrow-param calls, no Term::New, no codegen).

plan-recon resolved the make-or-break unknown favourably: the TypeDef
scope is discarded at lib.rs:3535 but fully available there (prefix +
type_home live) — a clean thread-through, not a resolution
re-architecture. Three orchestrator design calls folded in: peek's emit
is a plausible signature-correct stub, unit-ratified only (never
instantiated in .3, no Term::New to build a StubT; RawBuf's emits in .4
are the E2E-verified ones; peek retires in .5); the exact
llvm_type(borrow StubT Int) string is an implementer verify-first step
(eq__Unit precedent); scope-trigger is the call path (not signature,
which would perturb bare-called fns like length), scope-value is the
prefix, collector infers from signature — they agree by construction
for single-TypeDef kernel modules, bijection + 669-gate backstop.

peek Form-A verified to parse+check this session under a probe module
(ok, 32 symbols / 3 modules). Final gate 670 (669 + 1 new mono test);
.ll snapshots stay green (peek polymorphic → no instantiation without a
call site).
2026-05-29 19:25:04 +02:00
Brummel d2885c7ae6 spec: raw-buf — re-carve .3/.4/.5, type-scoped polymorphic intrinsic prep (refs #7)
Second re-carve. Planning raw-buf.3 (via plan-recon) surfaced that
RawBuf's ops are a THIRD intrinsic shape the intrinsic-bodies mechanism
never handled: type-scoped polymorphic top-level intrinsics (forall a,
param-in {Int,Float,Bool}, called as RawBuf.get). The existing bijection
+ symbol story covers only monomorphic top-level intrinsics (answer,
float_*) and per-type class-method instances (eq__Int).

The gap (both confirmed in source by recon + grounding-check):
- mono_symbol_n mints <base>__<T> from the bare fn name, so RawBuf.get
  @ Int → get__Int — the RawBuf scope never enters the symbol;
  new/get/set/size @ Int would collide with any other poly free fn of
  those (very common) names.
- the bijection collector's Def::Fn arm records the bare name "get" as
  one marker, matching neither the per-type entries nor their symbols;
  one polymorphic marker must map to N per-element entries, which the
  1-marker-to-1-entry bijection cannot express.

So raw-buf.3 is no longer "add 12 table rows" — it is a new language
mechanism. Re-carve (3 remaining iterations):
  raw-buf.3 — type-scoped polymorphic intrinsic mechanism: scope-
              qualified mono symbols (RawBuf_get__Int) + bijection-
              collector expansion over param-in. Built and ratified
              standalone on the stub (a StubT.peek op + its 2 scoped
              entries + mono/bijection unit tests), the prep pattern
              prep.1/.2/.3 used. No RawBuf, no Term::New dependency.
  raw-buf.4 — RawBuf payload (module + 12 scoped entries + drop) +
              Term::New desugar ((new RawBuf ...) sugar, removes both
              deferral arms). Pure consumer of .3. Worked program → 60.
  raw-buf.5 — kernel_stub retirement (peek + answer + stub leave;
              RawBuf carries all roles).

Decided design (Option A): scope-qualified symbols, not bare. Rationale
is semantic — the type-scoped call convention RawBuf.get should carry
through to a collision-free, IR-legible symbol; bare get__Int is a
latent collision landmine on the most-reused op names. Removing that
collision class is itself feature-acceptance criterion-3 evidence.

The raw_buf module Form-A in § Concrete code shapes is gate-verified
(ail check → ok, 35 symbols / 3 modules). Grounding-check PASS: all 10
current-behaviour assumptions ratified by named green tests; the
Term::New codegen-deferral (lib.rs ~2096 + ~3298) is the same
about-to-be-deleted prep.2 transient, reported-not-blocked (consistent
with the override logged on 3ec406e).
2026-05-29 19:10:19 +02:00
Brummel fbdbe740e6 iter raw-buf.2-kernel-rename (DONE 2/2): ailang-kernel-stub → ailang-kernel family-crate (refs #7)
Pure crate rename, zero behavioural change. ailang-kernel-stub becomes
the ailang-kernel family-crate: a re-export hub (src/lib.rs) plus one
submodule per kernel-tier module (today only kernel_stub:
src/kernel_stub/{mod.rs, source.ail}). raw-buf.3 plugs raw_buf in as a
sibling submodule; this iter only builds the home.

Task 1 (atomic — the rename breaks the build until every site is
threaded):
- git mv crates/ailang-kernel-stub → crates/ailang-kernel.
- Carved the STUB_AIL Form-A body verbatim (byte-identical, verified
  via diff against HEAD's raw string) into src/kernel_stub/source.ail,
  loaded by mod.rs via include_str!. The answer (intrinsic) fn is
  byte-preserved — its INTERCEPTS bijection partner stays pinned.
- lib.rs is now the hub: `pub use kernel_stub::SOURCE as STUB_AIL;`.
  The public symbol STUB_AIL is preserved, so ailang-surface and the
  bijection test bind to it unchanged.
- 8 compile sites threaded across 6 files: Cargo package name,
  workspace member + dep-alias, ailang-surface dep, loader.rs
  use-paths (ailang_kernel_stub → ailang_kernel). Cargo.lock
  auto-regenerated.

Task 2 (doc/ledger honesty, no build impact):
- design/INDEX.md, CLAUDE.md code-layout row + lockstep-pair row,
  design/models/0007 ×2 — crate-path strings updated to the new
  ailang-kernel/src/kernel_stub path.
- Scope note: the spec's raw-buf.2 Components row named only
  design/INDEX.md + the CLAUDE.md code-layout row. plan-recon found two
  more present-tense crate-path refs (CLAUDE.md:312 lockstep row,
  model 0007:8/235). Folded them in per the honesty-rule — a rename
  that leaves stale present-state paths is an incomplete rename.
  Path-only; the retirement narrative stays for raw-buf.4.

The AILang module name kernel_stub (the Form-A string + parse_kernel_stub
fn) is deliberately unchanged — only the crate identifier moved.

Verification (orchestrator, this session): cargo build --workspace
clean; cargo test --workspace 669 passed / 0 failed / 2 ignored
(baseline held exactly — no test added/removed). Carve byte-identity
confirmed by diff; no stale ailang-kernel-stub / ailang_kernel_stub
refs remain in-tree (git grep, excl. history + lockfile). The four
rename-invisible ratifiers (kernel_stub_module_round_trips,
intercepts_bijection_with_intrinsic_markers, .ll snapshots,
workspace_pin) all green via the preserved re-export.
2026-05-29 18:39:36 +02:00
Brummel 395a40f3e7 plan: raw-buf.2-kernel-rename — atomic crate rename + family-crate reshape (refs #7)
Two tasks. Task 1 (atomic): git mv ailang-kernel-stub → ailang-kernel,
carve the STUB_AIL Form-A body verbatim into
src/kernel_stub/source.ail (incl. the answer (intrinsic) fn — a
perturbed byte shifts the .ll snapshots + kernel_stub_module_round_trips),
add mod.rs (include_str!) + lib.rs hub (pub use kernel_stub::SOURCE as
STUB_AIL), thread all 8 compile sites across 6 files (Cargo package +
workspace member + dep-alias + ailang-surface dep + loader.rs
use-paths), end with cargo build + cargo test green. Task 2: doc/ledger
crate-path honesty fixes (INDEX, CLAUDE.md code-layout + lockstep rows,
model 0007) — path-only, retirement narrative left for raw-buf.4.

Zero behavioural change; public symbol STUB_AIL preserved via the
re-export so ailang-surface + the bijection test bind unchanged. NO
raw_buf submodule (that is raw-buf.3).

plan-recon mapped 8 compile-breaking sites + 5 doc sites (two beyond
the spec's named row list — CLAUDE.md:312 lockstep + model 0007:8,235 —
folded in per honesty-rule). Baseline measured this session: 669
passed / 0 failed / 2 ignored (not the stale 667 from the pre-recarve
0105 plan; intrinsic-bodies added 2 tests).
2026-05-29 18:34:47 +02:00
Brummel 3ec406e687 spec: raw-buf — re-carve .2/.3/.4 post intrinsic-bodies (refs #7)
raw-buf.1 (intercept registry) shipped (140a0c0). The intrinsic-bodies
milestone (#9) then landed and pulled the foundation out from under the
original raw-buf.2/.3 split, so this revises the spec.

Two foundation changes from intrinsic-bodies:

1. The placeholder-body convention the original spec leaned on
   ((body x) round-trip stubs) is gone. prelude.ail now uses
   (intrinsic) markers, and check_fn (lib.rs:2173) typechecks every
   non-intrinsic body including kernel-tier ones. RawBuf.get with a
   placeholder body (declared (ret a), body constructs a RawBuf) is now
   a hard type error — get/new/set/size MUST be (intrinsic).

2. An (intrinsic) marker now requires a matching INTERCEPTS entry as a
   lockstep pair (intercepts_bijection_with_intrinsic_markers). The
   "manifest visible / codegen deferred" intermediate state the original
   raw-buf.2 was designed to ship is forbidden by that invariant — the
   marker and its codegen entry must land in the same iteration.

Re-carve (3 remaining iterations, retirement isolated):
  raw-buf.2 — kernel family-crate rename ailang-kernel-stub →
              ailang-kernel. Pure refactor, zero behavioural change.
  raw-buf.3 — RawBuf end-to-end: raw_buf module ((intrinsic) markers)
              + 12 INTERCEPTS entries + Term::New desugar + drop +
              ailang-surface wiring + E2E. Manifest+codegen land
              together. Workspace count 4 → 5.
  raw-buf.4 — kernel_stub retirement: RawBuf subsumes every
              ratification role (Term::New, param-in, kernel-tier
              auto-import, intrinsic); answer entry + stub removed,
              count 5 → 4.

Preserved unchanged: Goal, slab layout, feature-acceptance argument.

The raw_buf module Form-A source in § Concrete code shapes is
gate-verified: ail check → ok (35 symbols across 3 modules); the four
(intrinsic) markers are legal because the module is (kernel).

Grounding-check: 8/9 load-bearing current-behaviour assumptions
ratified by named green tests. The single override is the Term::New
codegen-deferral arm (lib.rs:2094 + sibling at lib.rs:3296, the latter
surfaced by the grounding agent) — a transient prep.2 marker that
raw-buf.3 deletes, not a relied-upon invariant; forward-ratified by
raw-buf.3's build-and-run E2E. Override logged, not a discard.
2026-05-29 18:26:59 +02:00
Brummel 6ccc756c0f audit + close: intrinsic-bodies — honest prelude docs + lockstep table + stale-model fix (refs #9)
Milestone close for intrinsic-bodies (.1 mechanism 52ff873 + .2
migration/lock caa3618). Architect drift review over c42034b^..caa3618
plus the regression scripts. Both gates assessed; four drift items
resolved (3 fixed inline here, 1 filed to backlog).

Regression: both scripts exit 0.
  bench/check.py        → 34 metrics, 0 regressed, 2 improved beyond
                          tolerance (bench_hof_pipeline.bump_s -10.76%,
                          bench_list_sum_explicit.bump_s -12.27%), 32 stable.
  bench/compile_check.py → 24 metrics, 0 regressed, 24 stable.
  The milestone is behaviour-preserving; the two throughput improvements
  are noise within the bench's run-to-run band, not a milestone effect.

Drift items (architect):

1. [FIXED] examples/prelude.ail — the 13 migrated defs' doc strings
   still said "Body is placeholder for round-trip stability" and named
   `try_emit_primitive_instance_body` (the pre-raw-buf.1 dispatch fn).
   Post-migration the body IS an honest (intrinsic) marker, not a
   placeholder, and dispatch is `intercepts::lookup`. The docs lied
   about the very artefact the milestone exists to de-lie — an active
   honesty-rule infraction on main. Rewritten to present-tense
   "compiler-supplied (intrinsic) body; codegen emits <IR> via the
   intercept registry" across all 13 (7 Eq/Ord instance methods +
   6 float_* fns).

2. [FIXED] design/models/0007-kernel-extensions.md — claimed the
   `try_emit_primitive_instance_body` hardcoded list is "not yet
   migrated into a plugin registry ... deferred to the Series
   milestone". Stale since raw-buf.1 (the registry shipped) and
   intrinsic-bodies (the (intrinsic) marker + bijection pin). Rewritten
   to the present state: the registry exists, (intrinsic) declares
   membership, the bijection pin locks marker<->entry, and the
   optimisation-only class is named.

3. [FIXED] CLAUDE.md "Lockstep-invariant pairs" — added the third pair:
   INTERCEPTS entries <-> (intrinsic) markers, guarded by
   intercepts_bijection_with_intrinsic_markers, with the
   optimisation-only allowlist carve-out. Same class as the two existing
   tabled pairs; ships silently broken if one side moves without the
   other.

4. [BACKLOG #41, label idea] INTERCEPTS conflates two concepts —
   intrinsic-backed (compiler-supplied body) vs optimisation-of-a-
   real-body (the 5 *__Int icmp family). The .2 bijection pin contains
   it with a hardcoded OPTIMISATION_ONLY allowlist + a stale-allowlist
   guard; a structural split (an Intercept kind field) is the
   forward direction but is its own focused change, out of scope here.

What holds (architect confirmed): data-model contract matches the
shipped Term::Intrinsic (design_schema_drift gates it); no INDEX row
needed (data-model addition, not a new contract); both existing
lockstep pairs untouched (answer lowers via the ordinary qualified-fn
path, no new lower_app arm; no Pattern::Lit reject); 0007-honesty-rule
correctly needed no edit (general rule, never named the dummies).

RATIFY — baseline move: the prelude module hash pin
(crates/ailang-surface/tests/prelude_module_hash_pin.rs) moves
2ea61ef21ebc1913 -> b1373a2c69e70a3f. Cause: this tidy's 13 doc-string
rewrites (item 1). doc is part of the canonical JSON, so the module
hash shifts; behaviour is unchanged. The 6 mono eq/compare def-hashes
do NOT move (synthesise_mono_fn sets doc: None, so instance-method docs
never reach the synthesised symbol). Full workspace test green
post-rebaseline.

Milestone intrinsic-bodies is closed. It unblocks the raw-buf.2 redo
(the polymorphic kernel-tier fns RawBuf needs now have an honest body
form). raw-buf (#7) remains parked; resuming it is a fresh decision.
2026-05-29 17:56:10 +02:00
Brummel caa3618c3e iter intrinsic-bodies.2-migration-lock (DONE 4/4): prelude → (intrinsic) + bijection pin (refs #9)
Second and final iteration of the intrinsic-bodies milestone. Migrates
the 13 authored prelude dummy bodies to (intrinsic), rebaselines the two
hash pins that move, and locks the registry to the source with a
bijection. Behaviour-preserving: the codegen intercepts emit identical
IR; the prelude placeholder bodies were already dead (intercepted by
name before lower_term, since raw-buf.1).

What landed (4 tasks):

  Task 1 — examples/prelude.ail: 13 authored dummy bodies → (intrinsic).
    7 Eq/Ord instance methods (eq Int/Bool/Str/Unit, compare
    Int/Bool/Str) keep their lambda shell (params/ret = the method's
    local signature, Design X); the inner (body false)/(body true)/
    (body (term-ctor Ordering EQ)) becomes (intrinsic). 6 float_* fns
    ((body false) → (intrinsic) in the fn body slot). The honesty-rule
    infraction the milestone exists to fix is now closed: no prelude
    body lies about what runs.

  Task 2 — hash rebaselines (the prelude bytes changed):
    prelude module hash  af372f28c726f29f → 2ea61ef21ebc1913
    eq__Int       86ed4988438924d3 → cc7b99b63d1e44ae
    eq__Bool      d62d4d8c51f433f8 → fd0412f127986512
    eq__Str       3d32f377c66b03e0 → fa269285754a52da
    compare__Int  6d6c20520766368b → 0a02bd9effc9746c
    compare__Bool 02b64e8fadc913eb → d0dc108dacf4e543
    compare__Str  9645929d53cd3cc9 → d1419595dc52a456
    The 4 show__* mono pins did NOT move (Show bodies are real).

  Task 3 — intercepts.rs: registry_contains_all_legacy_arms (one-
    directional, hardcoded 18-name list) replaced by
    intercepts_bijection_with_intrinsic_markers. The in-source test
    walks prelude + kernel_stub pre-mono for Term::Intrinsic markers,
    recovers each marker's codegen symbol (mono_symbol(method, type) for
    instance methods; the fn name for top-level float_*/answer), and
    asserts a bijection over the intrinsic-backed class: (A) every
    marker resolves to an INTERCEPTS entry, (B) every INTERCEPTS entry
    not on the optimisation-only allowlist has a marker, plus a guard
    that the allowlist names are themselves registered. The allowlist is
    the 5 *__Int icmp-family entries, which intercept the monomorphised
    specialisation of the real-bodied polymorphic lt/le/gt/ge/ne — an
    optimisation, not a compiler-supplied body, so no marker. The pin
    passing independently confirms Task 1 missed no prelude site.

  Task 4 — confirmed no codegen path lowers an intercepted body (already
    true since raw-buf.1: try_emit_primitive_instance_body matches by
    name before f.body is inspected). Refreshed the now-accurate comment
    at lib.rs:1310-1319. design/contracts/0007-honesty-rule.md needed NO
    edit — it is a general present-tense rule and does not name the
    prelude dummies (confirmed by grep), so editing it would have been
    invented work.

Verification:
  cargo test --workspace → 669 passed, 0 failed (post-.1 baseline held;
    one test replaced net-0).
  Behaviour-preservation ratifiers GREEN (the safety net):
    eq_primitives_smoke_compiles_and_runs,
    compare_primitives_smoke_prints_1_2_3_thrice (e2e.rs);
    float_compare_smoke_prints_true_true_false (float_compare_smoke_e2e.rs);
    eq_ord_polymorphic_runs_end_to_end (eq_ord_e2e.rs).
  Bijection pin GREEN. Both hash pins GREEN at the new baselines.
  bench/check.py + compile_check.py → 0 regressed.

Plan-text imprecision (no outcome impact, noted for the record): Task 1
Step 5 wrote `--test e2e` for all four ratifiers, but
float_compare_smoke_prints_true_true_false and
eq_ord_polymorphic_runs_end_to_end live in their own test targets
(float_compare_smoke_e2e.rs, eq_ord_e2e.rs). The verification-filter
self-review checked that the fn names exist, not that they sit in the
named target — a sharper form of the filter-zero discipline. The
implementer ran each in its real target; all four green.

Milestone intrinsic-bodies is now feature-complete (.1 mechanism +
.2 migration+lock). Audit + close follow.
2026-05-29 17:46:50 +02:00
Brummel 298fc2d36f plan: intrinsic-bodies.2-migration-lock — 4-task prelude migration + bijection pin (refs #9)
Decomposes intrinsic-bodies.2 (parent spec § Architecture points 6-8)
into 4 tasks + a final gate:

  Task 1 — migrate the 13 authored prelude dummy bodies to (intrinsic):
    7 Eq/Ord instance methods (inner (body false)/(body true)/
    (body (term-ctor Ordering EQ)) → (intrinsic), Design X lambda-body
    placement) + 6 float_* fns ((body false) → (intrinsic)). Gated by
    round-trip + the 4 eq/compare/float E2E ratifiers staying green
    (behaviour-preserving: the intercepts emit identical IR).
  Task 2 — rebaseline the two moving hash pins: prelude_module_hash_pin
    (prelude module hash) and mono_hash_stability's 6 eq/compare
    def-hashes (capture-from-failure, replace the constant). The 4
    show__* pins do NOT move.
  Task 3 — replace the one-directional registry_contains_all_legacy_arms
    pin with intercepts_bijection_with_intrinsic_markers: an in-source
    test that walks prelude + kernel_stub pre-mono for Term::Intrinsic
    markers, recovers mangled names (mono_symbol for instance methods,
    fn name for top-level), and asserts the bijection over the
    intrinsic-backed class (14) with the 5-name *__Int optimisation-only
    allowlist. Both directions + a stale-allowlist guard.
  Task 4 — confirm no codegen path lowers an intercepted body (already
    true post-.1: intercept-by-name precedes lower_term), delete the
    stale dummy-body comment at lib.rs:1310-1319, and conditionally edit
    0007-honesty-rule.md ONLY if it names the dummies (recon: it does
    not — a general rule; no forced edit).

Recon-driven plan decisions:

1. The bijection pin lives in intercepts.rs's in-source #[cfg(test)]
   mod tests, NOT a crates/ail/tests integration test. Visibility
   forces it: the pin needs INTERCEPTS (pub(crate) in codegen, in-source
   only) AND the parse hops (ailang-surface dev-dep). Verified
   ailang-surface does not dep ailang-codegen, so there is no
   dev-dep cycle blocking the in-source test from calling parse_prelude
   (the dev-dep-cycle hazard that bit pd.2.4/pd.3.1 does not apply
   here — that was the ailang-core <-> ailang-surface edge).
2. The pin walks PRE-mono (parse_prelude/parse_kernel_stub) and
   reconstructs mangled names via mono_symbol, rather than walking
   post-mono. Post-mono only synthesises USED mono symbols, so a
   post-mono walk would miss any instance method the pin's entry
   fixture does not exercise (e.g. eq__Unit). Pre-mono + mono_symbol
   sees all 14 markers unconditionally.
3. Task 2's hashes are capture-from-failure, not pre-computed — the new
   prelude bytes determine them. The old->new values get recorded in
   the iter commit body by the Boss.

All verification filter strings checked against the tree: the 4 E2E
ratifier fn names, the mono pin name, and the prelude pin target all
resolve to ≥1 real test. No ail/ail-json/ll fenced block in the plan
(the migrated-form snippets are scheme fragments — the (intrinsic) form
is already ratified by .1's kernel_intrinsic_smoke.ail), so the
parse-bytes gate is a documented no-op.

Handoff target: skills/implement on docs/plans/0107-intrinsic-bodies.2-migration-lock.md
2026-05-29 17:37:45 +02:00
Brummel 8301ca3ee8 spec: intrinsic-bodies — correct .2 count + bijection split (refs #9)
Forward-fix on 5b66de7, prompted by plan-recon for intrinsic-bodies.2.
Two spec-vs-reality gaps in the .2 (migration + lock) sections, both
caught before the .2 plan was written:

1. Count. The spec said "18 dummy bodies" migrate to (intrinsic). That
   conflated the INTERCEPTS entry count with authored prelude bodies.
   Reality: examples/prelude.ail carries 13 authored dummy bodies — the
   7 Eq/Ord instance methods (eq Int/Bool/Str/Unit, compare
   Int/Bool/Str) + the 6 float_* free fns. The registry has 19 entries
   (18 legacy + the .1 `answer`); the other 6 are not prelude dummies.

2. The strict bijection cannot hold. lt__Int/le__Int/gt__Int/ge__Int/
   ne__Int (5 INTERCEPTS entries) intercept the monomorphised __Int
   specialisations of the polymorphic free fns lt/le/gt/ge/ne, which
   carry REAL bodies in the prelude (ne = (app not (app eq x y)); the
   four ordering helpers are (match (app compare x y) ...)). Those
   bodies are honest and live — lowered for every non-Int instantiation;
   only the Int specialisation is intercepted for a faster direct icmp.
   They are an optimisation class, not a compiler-supplied-body class:
   no lie, no source body to replace, no (intrinsic) marker. A strict
   bijection over all INTERCEPTS entries would be red for these 5.

Corrections:
- § Architecture point 6: 13 authored prelude sites (named), with the
  5 icmp-family + `answer` explicitly listed as non-prelude / not
  migrated and why.
- § Architecture point 7: the pin splits the registry into
  intrinsic-backed (13 prelude markers + answer = 14) and
  optimisation-only (the 5 *__Int, an explicit documented allowlist).
  The bijection holds over the intrinsic-backed class only; the pin
  loads the workspace + monomorphises to recover mangled names for the
  marker direction.
- § Architecture point 8: reframed from "dead-path removal" to
  "dead-path confirmation" — .1's intercept-by-name already bypasses
  the dummy body before lower_term sees it (committed reality at
  52ff873), so .2 confirms no path lowers an intercepted body and
  deletes stale comments; the .1 Term::Intrinsic escape-guards stay.
- § Components + § Testing: the two moving hash pins named
  (prelude_module_hash_pin.rs; mono_hash_stability.rs's 6 eq/compare
  pins move, its 4 show pins do not); hash_pin.rs carries no
  prelude-derived hash. IR snapshots do not change.

Grounding-check PASS on all five corrected .2 claims against the
shipped .1 baseline (count, the 5 real-bodied helpers, the three hash
pins' behaviour, the already-dead path, the bijection-pin pipeline
reachability). No ail/ail-json/ll fenced block changed — parse-gate a
documented no-op for this revision.

The deeper observation this surfaced — that INTERCEPTS conflates two
concepts (compiler-supplied bodies vs. optimisation of a real body) —
is noted but NOT resolved here; splitting the registry is out of scope
for intrinsic-bodies and would be its own milestone.
2026-05-29 17:31:25 +02:00
Brummel 52ff8738b8 iter intrinsic-bodies.1-mechanism (DONE 8/8): Term::Intrinsic leaf + cross-crate wiring (refs #9)
First iteration of the intrinsic-bodies milestone. Introduces the
Form-A `(intrinsic)` body marker as a new leaf AST term and wires it
through surface, checker, codegen, and a kernel_stub ratifier. The
prelude migration + hard-lockstep pin + dead-path removal are .2.

What landed (8 tasks):

  Task 1 — Term::Intrinsic unit variant (ast.rs), tag "t":"intrinsic"
    via the enum's rename_all=lowercase. Additive: no existing fixture
    carries it, hashes bit-identical. In-core exhaustive-match arms
    (canonical/hash/visit/pretty/desugar/workspace) added as leaves.
  Task 2 — surface parse + print: (intrinsic) as a fn body-slot clause
    and a lambda positional body, mapped to/from Term::Intrinsic.
  Task 3 — cross-crate walker sweep (check/codegen/prose/ail-main):
    leaf no-op/identity arms at every no-wildcard Term match the
    compiler flagged.
  Task 4 — checker: new Env.current_module_kernel_tier flag (m.kernel
    || m.name=="prelude"), set alongside current_module. A def whose
    body is intrinsic (top-level fn OR instance-method lambda, via the
    shared is_intrinsic_body helper) is checked signature-only; an
    intrinsic body outside kernel-tier/prelude is rejected with
    intrinsic-outside-kernel-tier.
  Task 5 — codegen: an intrinsic-bodied fn routes through the existing
    try_emit_primitive_instance_body / intercepts::lookup path; if no
    intercept fired it is an internal error, never a lower_term
    fallthrough. lower_term and the synth walker get Term::Intrinsic
    internal-error arms (an intrinsic body reaching either is an
    escape bug).
  Task 6 — answer intercept (ret i64 42) registered in INTERCEPTS;
    the `answer : () -> Int` intrinsic added to STUB_AIL;
    examples/kernel_intrinsic_smoke.ail added so schema_coverage
    observes Term::Intrinsic in the examples/ corpus.
  Task 7 — E2E ratifier: examples/kernel_answer.ail calls
    kernel_stub.answer and prints 42; answer_intrinsic_builds_and_runs_printing_42
    asserts it end-to-end (source → native).
  Task 8 — design/contracts/0002-data-model.md gains the
    { "t": "intrinsic" } Term entry + fn/lam prose; form_a.md grammar
    note updated.

Verification:
  cargo test --workspace → 669 passed, 0 failed (baseline 667 +2:
    intrinsic_in_user_module_is_rejected, answer_intrinsic_builds_and_runs_printing_42).
  bench/check.py + bench/compile_check.py → 0 regressed.
  Reject E2E (subprocess ail check --json, exit 1, code
    intrinsic-outside-kernel-tier) GREEN.
  Round-trip + hash pins GREEN — Term::Intrinsic is additive, no
    existing fixture carries it, no hash moved.

Three implementation completions beyond the plan (all behaviour-
preserving, surfaced during execution):

1. The signature-only skip had to apply at the mono pass's two
   synth-on-body re-entry sites (collect_mono_targets,
   collect_residuals_ordered), not only check_fn — else an intrinsic
   body hits synth's Term::Intrinsic internal-error guard. Repaired by
   extracting the shared crate::is_intrinsic_body helper and applying
   it at all three synth-on-body paths. Not a representation surprise:
   the same signature-only treatment, more call sites.

2. The compiler-enumerated exhaustive-match set was broader than the
   plan's named grep set (the plan anticipated this and made the sweep
   compile-driven). Extra leaf arms in core desugar/workspace, check
   reuse-as + qualify_workspace_term, codegen synth_with_extras,
   ail/src/main.rs, and four test targets.

3. Fixture corrections: emit_answer needed the body-close
   (block terminator) the plan snippet omitted; kernel_answer.ail's
   main is (ret Unit)(effects IO) using (app print ...) since
   io/print_int does not exist (the plan flagged this for the
   implementer to resolve against real effect-op names).

IR snapshots (hello/sum/list/max3/ws_main.ll) refreshed: purely
additive @ail_kernel_stub_answer fn+adapter+closure, emitted into
every workspace exactly as the pre-existing @ail_kernel_stub_new
already was (kernel_stub is auto-injected; confirmed new was present
in the pre-iter hello.ll baseline). No user-fn IR changed.

The .2 iteration migrates the 18 prelude dummy bodies to (intrinsic),
upgrades registry_contains_all_legacy_arms to a source<->registry
bijection pin, and removes the dead body-lowering path.
2026-05-29 17:21:32 +02:00
Brummel ce0374ac0c plan: intrinsic-bodies.1-mechanism — 8-task Term::Intrinsic cross-crate landing (refs #9)
Decomposes intrinsic-bodies.1 (parent spec docs/specs/0055-intrinsic-bodies.md
§ Architecture points 1-5) into 8 tasks plus a final gate:

  Task 1 — Term::Intrinsic unit variant in ast.rs + the in-core
    exhaustive-match arms (canonical/hash/visit/pretty), enumerated by
    cargo build -p ailang-core (no-wildcard matches break until armed).
  Task 2 — surface parse + print: (intrinsic) as a fn body-slot clause
    and a lambda positional body, mapped to/from Term::Intrinsic;
    round-trip rides the examples/ corpus gate.
  Task 3 — cross-crate walker sweep (check + codegen + prose): leaf
    arms at the sites that match Term::New today, enumerated by
    cargo build --workspace. The two MEANINGFUL arms (codegen lower_term,
    checker body-check) are bridged with a temp unreachable! so the gate
    is green, then replaced by Tasks 4-5 — no deferred-caller across a
    0-error gate.
  Task 4 — checker: env.current_module_kernel_tier flag, signature-only
    body check for an intrinsic body, intrinsic-outside-kernel-tier
    reject (CheckError variant + code() arm). Reject test is subprocess
    `ail check --json` on a temp file, modelled exactly on the verified
    check_json_unbound_var pattern.
  Task 5 — codegen: a Term::Intrinsic body routes through
    intercepts::lookup; never reaches lower_term; lower_term's
    Term::Intrinsic arm is a codegen-internal error.
  Task 6 — `answer` intercept (ret i64 42) + the answer intrinsic in
    STUB_AIL + examples/kernel_intrinsic_smoke.ail so the
    schema_coverage corpus observes Term::Intrinsic (avoids the
    Term::New-in-match-but-not-in-corpus gap).
  Task 7 — E2E ratifier examples/kernel_answer.ail (calls
    kernel_stub.answer, prints 42); build_and_run("kernel_answer.ail")
    asserts stdout 42.
  Task 8 — design/contracts/0002-data-model.md gains the
    { "t": "intrinsic" } Term entry; design_schema_drift mirror stays
    green.

Three plan-time corrections after plan-recon + harness verification:

1. The reject test was first drafted against an invented ailang_check
   API (Workspace::single_with_prelude / check_workspace). Verified
   against e2e.rs:1236 that the established reject pattern is
   subprocess `ail check --json` + exit-1 + JSON code assertion;
   rewrote on a temp file.
2. build_and_run takes the fixture filename WITH .ail extension
   (verified e2e.rs:13-38, existing calls build_and_run("sum.ail")) —
   the ratifier call is build_and_run("kernel_answer.ail").
3. kernel_intrinsic_smoke.ail carries an intrinsic with no registered
   intercept; confirmed no gate builds it (compile_check.py uses a
   curated list, no test builds all examples/*.ail) — it rides only
   the parse-level round-trip + schema-coverage gates, never a build.

Scope guard: .1 does NOT migrate prelude dummies, does NOT upgrade the
registry pin to a bijection, does NOT remove the dead body-lowering
path — all .2. Hashes stay stable in .1 (Term::Intrinsic is additive;
no existing fixture carries it).

Test trajectory: 667 (post-raw-buf.1) → ~669 (+intrinsic_in_user_module_is_rejected,
+answer_intrinsic_builds_and_runs_printing_42; corpus/round-trip
fixtures ride existing dynamic gates).

Handoff target: skills/implement on docs/plans/0106-intrinsic-bodies.1-mechanism.md
2026-05-29 16:55:01 +02:00
Brummel 5b66de77ac spec: intrinsic-bodies — revise AST repr to Term::Intrinsic leaf (refs #9)
Forward-fix on c42034b. plan-recon for intrinsic-bodies.1 surfaced
that the original AST representation — FnDef.body / Term::Lam.body
made Option<Term> plus an `intrinsic: bool` flag — has a ~150-site
blast radius across six crates: every body read/construct site breaks
when a mandatory public field goes optional. That blast radius is the
signal (CLAUDE.md design-rationale rule) that the representation was
wrong, not merely expensive.

The Form-A surface (user's chosen Approach 1) and the Local-Reasoning
semantics (Design X marker placement) are UNCHANGED. Only the internal
AST representation changes, which is orchestrator authority over AST
design.

New representation: a single new leaf Term variant, Term::Intrinsic
({ "t": "intrinsic" }), is the body of a compiler-supplied definition.
FnDef.body (Term) and Term::Lam.body (Box<Term>) keep their existing
types. A def is intrinsic iff matches!(body, Term::Intrinsic).

Three structural reasons (not effort):

1. Established pattern. The project adds new constructs as additive
   Term variants — Term::New, Term::Loop, Term::Recur, Term::Clone,
   Term::ReuseAs all landed this way, documented "strictly additive,
   pre-existing fixtures hash bit-identically" in
   design/contracts/0002-data-model.md. Term::Intrinsic follows it.
   Option A introduced a brand-new pattern (mandatory field → optional)
   absent from the schema.

2. Meaning at the right locus. "This body is compiler-supplied" is a
   property of the body, not the container. Term::Intrinsic sits at the
   body position — fn body, or instance-method lambda body (Design X
   local-signature placement preserved exactly).

3. Illegal state unrepresentable. Option A admitted intrinsic:true with
   body:Some(...), forcing an intrinsic-with-body reject. Under the
   variant a body is either Term::Intrinsic or a real term, never both
   — the reject is deleted, the state cannot occur. This is the same
   make-illegal-states-unrepresentable discipline as the honesty theme
   the milestone exists to serve.

Blast radius collapses from ~150 body-read/construct sites to the
exhaustive match-on-Term arms (canonical/hash/visit + schema_coverage),
which the no-wildcard Term match turns into compile errors until each
gains a Term::Intrinsic case — the project's normal new-variant
discipline.

Sections revised: § Architecture points 1/3/4, § Concrete code shapes
(Implementation shape now shows the leaf variant, not the
Option+flag), § Components, § Data flow, § Error handling (the
intrinsic-with-body row removed), § Testing strategy (the
both-body-and-intrinsic reject test removed; schema_coverage Term::Intrinsic
observation added). The scheme/ail surface examples are byte-unchanged.

Re-ran the brainstorm gates on the revision: Step-7 parse gate green
(both ail blocks exit 0, unchanged); Step-7.5 grounding-check PASS on
the four new load-bearing claims (additive-variant precedent +
contract wording, exhaustive-Term-match mechanism, mono.rs
synthesise_mono_fn destructure unchanged under preserved body type,
Term::Recur as non-reducing-leaf precedent).
2026-05-29 16:45:47 +02:00
Brummel c42034b38d spec: intrinsic-bodies — (intrinsic) Form-A body marker (refs #9)
New milestone, triggered by the raw-buf.2 BLOCKED chain (the .2 work
was discarded; spec 4ad003d and plan 647121c stay on main per the
forward-only rule). The Form-A surface currently forces every fn /
instance-method to carry a (body ...) clause, which produces three
problems the marker resolves:

1. Prelude dummy-body lies. The eq/compare/ne/lt/le/gt/ge instance
   methods ship placeholder bodies — (body false), (body (term-ctor
   Ordering EQ)) — that parse and type-check but never run; codegen
   discards them and emits the intercept (registry shipped in
   raw-buf.1, intercepts.rs). A standing honesty-rule infraction
   (design/contracts/0007-honesty-rule.md): a reader who trusts the
   source is wrong about what runs.

2. Polymorphic kernel-tier fns are structurally impossible. RawBuf's
   get : RawBuf a -> Int -> a needs a placeholder body producing a
   value of type a, and AILang has no value of polymorphic type. The
   dummy-body requirement made the fn unauthorable — what BLOCKED
   raw-buf.2.

3. No surface affordance for "compiler supplies this body". Every
   systems language has one (LLVM declare, Rust extern
   "rust-intrinsic", Haskell foreign import prim, C builtins). The
   intercept registry IS AILang's compiler-supplied-body table;
   (intrinsic) is the surface declaration of membership.

Decomposition (2 iterations, full cut):

  intrinsic-bodies.1 — the mechanism. FnDef.body / Term::Lam.body
  become optional; an additive intrinsic: bool rides each
  (skip_serializing_if, hash-stable when omitted). Form-A parses +
  prints (intrinsic); round-trip gated. Checker checks signature-only,
  rejects body+intrinsic, rejects intrinsic outside kernel-tier /
  prelude (intrinsic-outside-kernel-tier). Codegen routes intrinsic
  defs through intercepts::lookup. Ratified by a throwaway `answer`
  smoke intrinsic in the kernel_stub fixture, end-to-end to native.

  intrinsic-bodies.2 — migration + lock. The dummy bodies swap for
  (intrinsic); a hard-lockstep pin asserts a bijection between
  INTERCEPTS entries and intrinsic markers reachable in the loaded
  workspace (extends raw-buf.1's registry_contains_all_legacy_arms
  from a one-way name check to a two-way source<->registry bijection).
  Dead body-lowering path for intercepted defs removed.

Design decisions locked during brainstorm:

- Marker placement (Design X). For a top-level fn, (intrinsic)
  replaces the (body ...) clause directly — the (type ...) signature
  stays beside it. For an instance method, the marker sits on the
  lambda BODY, not the method: the lambda's typed shell (params / ret)
  IS the method's local signature, and intrinsic drops the body, not
  the signature — exactly as LLVM declare / Rust extern-intrinsic keep
  the full signature. Hoisting the marker to (method eq (intrinsic))
  would erase the local signature (a reader would have to climb to the
  Eq class decl and substitute a := Int), violating local reasoning
  (design/INDEX.md § Goal). The mono pass
  (mono.rs::synthesise_mono_fn) reads params + inner body out of this
  lambda today, so the placement keeps that path unchanged. The Design
  Y alternative was considered and rejected on this signature-locality
  ground.

- Scope guard. (intrinsic) is legal only in (kernel)-tier modules and
  the prelude; user modules are rejected. This is the honesty-rule
  guard at the workspace boundary — user code cannot mark a body as
  compiler-supplied, so the lie cannot re-enter through user modules.

Process note: first spec under the hardened brainstorm pipeline
(Skills issue #1 fixes + the spec_validation parse gates retrofitted
in a2698a8). The Step-7 parse-every-block gate caught a real defect in
the spec's own ail examples on first run (an invalid module-level
(doc ...) head) — the defense line that was absent on raw-buf.2 and
let its unparseable spec bytes through to implement. Grounding-check
PASS on 12 load-bearing assumptions, each ratified by a named green
test.

Out of scope: a user-facing plugin API for custom intercepts (the
scope guard forbids user-module intrinsics); any change to the
raw-buf.1 dispatch mechanism; the raw-buf.2 redo itself (milestone #7,
parked behind this one).
2026-05-29 16:32:36 +02:00
Brummel a2698a80cf config: opt profile into spec_validation parse gates
Skills Issue #1 (compound-hallucination hardening) shipped a
new optional profile slot, `spec_validation.parsers`, that maps
markdown fence labels to validation commands. Three new gates
consume it: brainstorm Step 7 parse-every-block, planner Step 5
parse-the-bytes-you-inline, and the grounding-check code-block
parse pass. A profile written before the slot existed has all
three running as silent no-ops — exactly the failure mode the
raw-buf.2 spec slipped through.

Register three fence labels:
- `ail` → `ail check {file}` — Form-A surface modules. `check`
  is strictly broader than `parse` (catches the parser-novel
  head class AND the type-level rejects an MVP-only construct
  triggers, e.g. polymorphic fn-type without explicit forall),
  so it would have BLOCKED the storage-tag / RawBuf-shorthand /
  implicit-forall chain at Step 7 of brainstorm.
- `ail-json` → `ail check {file}` — JSON AST form; `check`
  auto-detects extension.
- `ll` → `llvm-as {file} -o /dev/null` — LLVM IR snippets in
  codegen specs.

Verified `ail check` works on a standalone Form-A file (no
workspace setup needed; the loader injects prelude + kernel).
2026-05-29 15:14:00 +02:00
Brummel 647121cb8f plan: raw-buf.2-kernel-manifest — 7-task crate-rename + RawBuf submodule + checker-side surface (refs #7)
Decomposes raw-buf.2 (parent spec docs/specs/0054-raw-buf.md
§ Architecture point 2, § Components row 2, § Testing strategy
"raw-buf.2") into 7 atomic tasks:

  Task 1 — crate rename + reshape: crates/ailang-kernel-stub/ →
    crates/ailang-kernel/, restructured as a family-crate with
    src/kernel_stub/{mod.rs,source.ail}; 13 steps covering 8
    compile-checked rename sites plus design/INDEX.md + CLAUDE.md
    path updates.
  Task 2 — add raw_buf submodule: src/raw_buf/{mod.rs,source.ail}
    with the spec's Form-A source (4 fns + 1 TypeDef with
    param-in (a Int Float Bool)); RAW_BUF_AIL re-export.
  Task 3 — wire raw_buf into ailang-surface: parse_raw_buf fn,
    re-export at lib.rs:39, workspace injection alongside the
    existing kernel_stub block, workspace_pin count bump 4→5.
  Task 4 — round-trip test raw_buf_module_round_trips, verbatim
    structural clone of kernel_stub_module_round_trips.
  Task 5 — E2E raw_buf_check_e2e: (con RawBuf (con Int)) consumer
    checks clean.
  Task 6 — E2E raw_buf_param_in_reject_e2e: (con RawBuf (con Str))
    consumer rejected at check with param-not-in-restricted-set.
  Task 7 — E2E raw_buf_build_intercept_missing_e2e: (new RawBuf …)
    consumer accepted at check, rejected at build with the
    existing Term::New deferral message.

Three substantive plan-time decisions resolving plan-recon's
open questions:

1. The intercept-not-registered diagnostic is NOT introduced.
   The spec hedged ("intercept-not-registered: new__RawBuf__Int
   (or the existing Term::New-deferral diagnostic)") between
   shipping a fresh diagnostic and reusing the existing deferral
   at crates/ailang-codegen/src/lib.rs:2075-2078. Plan picks
   reuse: the deferral already names "milestone raw-buf" and is
   self-describing; a new diagnostic would exist for ~1 iter
   (raw-buf.2 → raw-buf.3) before going away — disposable infra.
   Task 7's test asserts on the substring "Term::New requires
   the type's" which is stable in the existing message.

2. In-tree references to crates/ailang-kernel-stub/ AFTER the
   rename — split-scope between raw-buf.2 and raw-buf.3.
   The path changes in raw-buf.2 (the crate moves now), so
   path-references in design/INDEX.md:112 and the CLAUDE.md
   Code-layout table get updated in Task 1 Steps 10-11. The
   retire-language (stub as "ratifying fixture for kernel-
   extension-mechanics" → "retired by raw-buf") stays for
   raw-buf.3 close, per spec § Components row 3.

3. parse_raw_buf gets a mirror re-export at ailang-surface/
   src/lib.rs:39 alongside parse_kernel_stub. Recon's natural
   reading; load-bearing because the new round-trip test calls
   ailang_surface::parse_raw_buf() directly.

Plan honours the project's compile-gate discipline. Task 1's
13 steps thread all 8 rename sites inside the same task —
no deferred caller across the build gate. Task 3 bundles the
workspace_pin count bump with the loader injection that drives
it; the assertion goes 4→5 in the same task that makes it true.

Test-count trajectory: 667 (post-raw-buf.1 baseline) → 667
(Tasks 1, 2, 3 — refactor + wiring, no new test) → 668 (Task 4
round-trip) → 669 (Task 5 check E2E) → 670 (Task 6 reject E2E)
→ 671 (Task 7 deferral E2E).

Handoff target: skills/implement on docs/plans/0105-raw-buf.2-kernel-manifest.md
2026-05-29 11:17:30 +02:00
Brummel 140a0c0ef7 iter raw-buf.1-intercept-registry (DONE 5/5): try_emit_primitive_instance_body → registry table (refs #7)
Pure refactor — first iter of the raw-buf milestone. The
hardcoded 18-arm match in try_emit_primitive_instance_body
(crates/ailang-codegen/src/lib.rs L2841-3147 pre-iter, ~305
lines) becomes a 6-line shim that consults a single registry
table in the new sibling module crates/ailang-codegen/src/intercepts.rs.

Zero behavioural change. The wrapper name survives, all 14
in-source comment-refs to try_emit_primitive_instance_body stay
valid pointers to the canonical dispatch entry.

What landed (5 tasks):

  Task 1 — module skeleton: Intercept struct, INTERCEPTS static
  table (empty), lookup(name), check_sig(intercept, params, ret).
  mod intercepts; wired into lib.rs at L43.

  Task 2 — populate: 18 free emit fns lifted from the legacy
  match arms; INTERCEPTS populated with one entry per name. Three
  Emitter fields (body, locals, block_terminated) and three helper
  methods (emit_compare_ladder, emit_ordering_arm,
  emit_direct_int_icmp_intercept) bumped to pub(crate) so the
  sibling module can reach them. lower_ctor stayed at its existing
  visibility — not consumed by sibling-module fns.

  Task 3 — rewire dispatch: try_emit_primitive_instance_body body
  becomes `match intercepts::lookup(fn_name) { Some(i) => {
  check_sig(i, params, ret)?; (i.emit)(self)?; Ok(true) } None =>
  Ok(false) }`. The 305-line match collapses to 9 lines.

  Task 4 — fold wants_alwaysinline into the registry: every entry
  in INTERCEPTS carries wants_alwaysinline: bool. The predicate
  intercept_emit_wants_alwaysinline now consults the registry for
  branch 1 (the literal 13-name list the predicate hardcoded). One
  source of truth instead of two — closes the silent-drift class
  recon flagged.

  Task 5 — registry_contains_all_legacy_arms pin test: enumerates
  all 18 legacy names + asserts each resolves to a registered
  entry. A half-finished migration that drops any name becomes a
  hard test fail.

Two judgement calls the implementer made (both behaviour-
preserving, both worth knowing):

  (1) eq__Unit param shape: the plan named expected_params: &[]
  per the recon's "params ignored" reading. Reality: the call
  site delivers ["i8","i8"] (Unit lowers to i8 per codegen //!
  header). The registry entry uses ["i8","i8"]; the emit fn
  discards the operand SSAs and returns `ret i1 1` — semantically
  equivalent to the legacy arm that checked only ret_ty and
  ignored params. An inline rustdoc on the entry names the
  rationale.

  (2) intercept_emit_wants_alwaysinline has TWO branches, not one
  as the plan inferred. Branch 1 = the literal 13-name list (which
  the iter's drift-closure intent covers). Branch 2 = a stem-prefix
  carve-out matching {ne|lt|le|gt|ge}__* that covers polymorphic-
  body monomorphizations (lt__Bool, ne__Str, ne__MyADT, …) which
  are NOT registry entries and never were. A literal "replace with
  one-line registry lookup" would silently drop alwaysinline on
  those names, breaking the bench-perf parity established in iter
  operator-routing-eq-ord.1. Branch 2 stays as it was, with a
  fresh comment naming what the registry covers vs. what branch 2
  covers.

  Follow-up consideration (not in this iter): extend the Intercept
  variant to a predicate-only entry shape so branch 2 can fold
  into the registry as well, OR accept the carve-out as the final
  shape. Either is reasonable. Filed as a backlog idea (see Gitea
  issue created post-commit if labelled).

Verification:

  cargo test --workspace      → 667 passed, 0 failed, 2 ignored
                                (baseline was 666 + 2 ignored;
                                +1 from registry_contains_all_legacy_arms)

  Four E2E ratifiers GREEN (named in spec § Testing strategy
  raw-buf.1):
    - eq_primitives_smoke_compiles_and_runs (crates/ail/tests/e2e.rs)
    - compare_primitives_smoke_prints_1_2_3_thrice (crates/ail/tests/e2e.rs)
    - float_compare_smoke_prints_true_true_false
    - eq_ord_polymorphic_runs_end_to_end

  Alwaysinline pins GREEN (regression net for branch-2 carve-out):
    - crates/ailang-codegen/tests/eq_primitives_pin.rs
    - crates/ail/tests/prelude_eq_alwaysinline_ir_pin.rs

  New pin GREEN:
    - intercepts::tests::registry_contains_all_legacy_arms

Diff size: lib.rs −318 / +26 (net −292); intercepts.rs +488 (new
file). Net repository LOC delta: +196. The size reduction in
lib.rs is the wrong proxy — the value is the consolidation: one
table, one drift-closed predicate, one pin test enumerating the
migration surface.

The raw-buf.2 iter will rename crates/ailang-kernel-stub/ to
crates/ailang-kernel/ and reshape it as the family-crate that
hosts raw_buf alongside kernel_stub. The registry shipped here
is the substrate raw-buf.3 will plug RawBuf's 12 codegen
intercepts into.
2026-05-29 11:06:55 +02:00
Brummel d1612d5463 plan: raw-buf.1-intercept-registry — 5-task atomic codegen-registry refactor (refs #7)
Decomposes raw-buf.1 (parent spec docs/specs/0054-raw-buf.md
§ Architecture point 1, § Components row 1) into 5 tasks:

  Task 1 — intercepts module skeleton (3 steps)
  Task 2 — populate the table: 18 emit fns + visibility bumps (9 steps)
  Task 3 — rewire the dispatch site to a thin shim (3 steps)
  Task 4 — fold wants_alwaysinline into the registry (4 steps)
  Task 5 — registry_contains_all_legacy_arms pin test (3 steps)

Three substantive decisions made at plan time beyond what the spec
dictates, surfaced by the plan-recon report:

1. The match arm count is 18, not the ~8 the spec sentence hand-listed
   (eq__Str, compare__Int|Bool|Str, float_*). Five additional arms
   (eq__Int, eq__Bool, eq__Unit, plus the lt|le|gt|ge|ne__Int
   direct-icmp family) shipped after the spec sentence was written.
   All 18 are absorbed by the registry; the pin test enumerates all 18.

2. The `intercept_emit_wants_alwaysinline` predicate at lib.rs:1172 is
   a separate hardcoded name-list that mirrors the dispatch arms.
   This is a silent-drift class (regression seen earlier in the cycle
   on compare__Int perf at +29-47% when the lists diverged). The plan
   folds wants_alwaysinline into the Intercept entry as a per-row bool
   and rewrites the predicate to consult the registry — one source of
   truth instead of two.

3. The wrapper fn `try_emit_primitive_instance_body` survives as a
   thin shim (`match intercepts::lookup(name) { Some(i) => ..., None
   => Ok(false) }`) rather than being deleted. Trade-off: a 6-line
   wrapper vs. a 14-file comment-ref churn. The wrapper wins —
   landmark name preserved, all in-source doc comments stay valid.

Plan honours the project's compile-gate discipline: fn signatures
stay stable throughout (only bodies change), no caller-threading is
deferred across a build gate. Visibility bumps in Task 2
(pub(crate) on three Emitter fields and four helper methods) are
additive and do not change any caller's signature.

Verification commands in each Run step name actual test fns the
plan-recon confirmed exist at specific lines; no filter-zero-match
risk. Pre-flight workspace baseline: 668 tests; post-iter:
669 (the new pin).

Handoff target: skills/implement on docs/plans/0104-raw-buf.1-intercept-registry.md
2026-05-29 10:53:08 +02:00
Brummel 4ad003d21f spec: raw-buf — 3-iter base-extension milestone (refs #7)
First new milestone after kernel-extension-mechanics close.
RawBuf is the canonical kernel-tier *base* extension: mutable,
indexed, bounded-size flat buffer of primitive elements
({Int, Float, Bool}, restricted via prep.3's param-in). It
unblocks two downstream needs already in the backlog:

- series milestone #8 — library-tier ring buffer wrapping RawBuf.
- Embedding-ABI batch-FFI (subsumes closed #2) — the M5
  friction-harvest measured per-tick FFI at ~206 ns/tick on
  real EURUSD volume; RawBuf is the contiguous-slice primitive
  that amortises that per-tick cost.

The milestone also fulfils the whitepaper § "Plugin contract"
commitment: the migration of the hardcoded
try_emit_primitive_instance_body into a registry is triggered
by the first real base extension shipping.

Decomposition (R — registry-first refactor, 3 iters):

  raw-buf.1 — Intercept registry refactor. Lift the existing
  hard-coded match (eq__Str, compare__Int|Bool|Str, float_*) into
  a registry table. Zero behavioural change; ratified by the
  existing E2E suite (eq_primitives_smoke / compare_primitives_smoke
  in e2e.rs, float_compare_smoke, eq_ord_polymorphic). Pure
  refactor — the cleanest possible bisection target.

  raw-buf.2 — RawBuf kernel-tier manifest + checker side. Rename
  crates/ailang-kernel-stub/ → crates/ailang-kernel/ and reshape
  as a family-crate (src/{kernel_stub,raw_buf}/{mod.rs,source.ail});
  public surface preserved via re-exports. Consumer code with
  (con RawBuf (con Int)) passes ail check; ail build fails with
  intercept-not-registered — that diagnostic IS the ratification.

  raw-buf.3 — RawBuf codegen intercepts + Term::New desugar +
  stub retirement. Register 12 element-type-specialised entries
  (4 ops × {Int,Float,Bool}); lower via @ailang_rc_alloc +
  getelementptr + load/store. Desugar (new T args) →
  (app T.new args) so Term::New is eliminated before codegen
  (completes the prep.2 deferral). Retire the kernel_stub
  submodule + the round-trip test at design_schema_drift.rs:743;
  ailang-kernel crate stays as the family-crate for future
  series/matrix/… modules.

Ordering rationale: raw-buf.1 ships zero behavioural change so
its failure mode is "existing tests break" — cleanest bisection.
raw-buf.2 ships the checker-visible surface on an unchanged
codegen substrate, so its failure mode is checker-isolated.
raw-buf.3 lands codegen on a registry that has already absorbed
every legacy intercept, so the RawBuf entries do not co-mingle
with a registry move.

Kernel-tier crate organisation: rejected pro-modul-crate
(crates/ailang-series/, crates/ailang-matrix/, ...) for sublinear
scaling and onboarding locality; rejected sub-Cargo-crates under
crates/ailang-kernel/ (C1) for Cargo-boilerplate overhead with no
real consumer benefit at AILang's scale. C2 (one family-crate,
sub-folders per module, lib.rs re-exports) keeps Cargo dep
surface flat (ailang-surface needs one dep, not N) and stub
retirement is a submodule delete + re-export drop.

Out of scope: bounds checks (caller checks via RawBuf.size per
whitepaper — UB-on-overflow is the contract), RawBuf.fill /
.copy / .iter (deferred until series or Embedding-ABI concretely
asks), record element types (SoA — Forward Axis).

Grounding-check PASS on 10 load-bearing assumptions about
current code state (intercept dispatch site, existing E2E
ratifiers, ailang-kernel-stub crate layout, parse_kernel_stub
location).

Brainstorm → planner handoff: first iteration scope is raw-buf.1
(§ Architecture point 1, § Components row 1).
2026-05-29 10:44:28 +02:00
Brummel a163c8c34a GREEN: pre-pass/resolver asymmetry — same-module bare + kernel-tier qualifier acceptance (closes F1, F3)
Two minimal edits in ailang-check that bring the resolver's
acceptance set into line with what prep.1's workspace pre-pass
(`prepare_workspace_for_check`) actually produces. The pre-pass
itself is unchanged — its qualification rules are correct per
spec § "Realisation mechanism — workspace pre-pass". RED tests
landed in 4d39fdc; both turn GREEN, both fieldtest fixtures
that surfaced the bugs now `ail check` clean.

F1 — same-module type-scoped call (kem_2b_min_repro.ail).
The dot-qualified `Term::Var` arm in `synth_term`
(`crates/ailang-check/src/lib.rs` ~line 3425) was
unconditionally rewriting the resolved fn signature via
`qualify_local_types`, so a call like `(app Counter.value c)`
from inside Counter's home module produced a return type
`<this>.Counter` — but the matching `Term::Ctor` arm
(~line 3644) and the pre-pass itself both leave own-module
type-names bare. The two halves of the resolver disagreed.

Fix: branch on `target_module == env.current_module` — when
true, skip the qualification (the raw type is already in the
form the rest of the resolver speaks); when false, qualify
as before. `owner_types` lookup moved inside the else-branch
since it's only consulted when qualification runs. A short
comment names the pre-pass and the sibling Term::Ctor arm so
a future reader sees the three-way symmetry.

F3 — kernel-tier qualifier acceptance (kem_3_stub_consumer.ail).
The env-builder paths `build_check_env` (~line 1653) and
`check_in_workspace` (~line 1807) force-injected only
`"prelude"` into `env.imports` / `env.module_imports`. The
loader meanwhile derives the implicit-imports list from
`modules.values().filter(|m| m.kernel)` (since prep.3) — so
`kernel_stub` flows into runtime resolution but not into
the checker's qualifier-validity check (~line 1968). Bare
`StubT` got qualified by the pre-pass to `kernel_stub.StubT`,
then bounced off the validity check as "unknown module
prefix".

Fix at both sites: keep the unconditional `"prelude"` literal
injection AND additively insert every workspace module with
`kernel: true` into the per-module import map (skip
self-injection). Idempotent for production (where prelude
also carries `kernel: true`) and preserves the
`bare_name_resolves_through_implicit_import_to_free_fn`
unit-test contract — that test deliberately constructs a
prelude module with `kernel: false` to pin the legacy
"prelude is always implicit" guarantee per se, independent
of the kernel flag. Mirrors the loader's kernel-flag filter,
making the env-builder and the loader speak the same set.

Sibling pin `crates/ailang-check/tests/env_construction_pin.rs`
(the frozen-reproduction copy of `build_check_env`'s
`module_imports` loop) updated in lockstep so it remains a
meaningful drift detector after the env-builder change. The
pin is intentionally designed to track legitimate behavioural
changes.

Verification:
  - tests::type_scoped_call_in_same_module_resolves      GREEN
  - tests::kernel_tier_module_qualifier_resolves_…       GREEN
  - examples/fieldtest/kem_2b_min_repro.ail              ail check ok
  - examples/fieldtest/kem_3_stub_consumer.ail           ail check ok
  - cargo test --workspace                               666 / 0
  (664 prior baseline + 2 RED→GREEN this iter)

Implementer notes: one re-loop on the F3 site after the first
edit replaced the legacy "prelude" literal injection wholesale
with the kernel-flag filter — that broke the unit-test contract
above. Restored the literal AND added the filter on top per the
"don't adapt tests to bugs" memory. No spec-review or
quality-review loops.

prep.1's pre-pass design (spec § "Realisation mechanism") was
correct as written — the symmetry between owner-side
`qualify_local_types` and consumer-side `prepare_workspace_for_check`
holds. What was missing was uniform resolver-side acceptance of
the qualifiers the pre-pass produces. With these two edits, the
following equivalences hold throughout the checker:

  bare T  ≡  <this-module>.T            (F1, same-module rule)
  bare T  ≡  <kernel-module>.T          (F3, kernel-tier rule)

via the loader-driven implicit-imports list that now drives
both the checker's import map and the type-validity check.
2026-05-28 19:36:36 +02:00
Brummel 4d39fdc9c0 RED: same-module type-scoped call + kernel-tier qualifier — pre-pass/resolver asymmetry (refs F1, F3)
Two failing in-source tests added to ailang-check, both pinning
the pre-pass / resolver mismatch surfaced by the fieldtest:

  tests::type_scoped_call_in_same_module_resolves
      F1: `(app Counter.value c)` from inside Counter's own home
      module must check clean. Currently fails with
      `[type-mismatch] main: type mismatch: expected
      <this>.Counter, got Counter` because the dot-qualified
      Term::Var arm at lib.rs ~3430 unconditionally calls
      qualify_local_types on the resolved fn signature even when
      target_module == env.current_module, while the matching
      local Term::Ctor branch (~3644) returns bare `Counter`.
      The two halves of the resolver disagree on whether
      `<this>.T` and bare `T` denote the same type.

  tests::kernel_tier_module_qualifier_resolves_without_explicit_import
      F3: `(con StubT (con Int))` in a consumer type signature
      must resolve without `(import kernel_stub)`. Currently
      fails with `[unknown-module] … unknown module prefix
      `kernel_stub` …` because env.imports / env.module_imports
      (built at ~1797 and ~1667) force-inject only `prelude` from
      the kernel-tier set; the pre-pass freely produces
      `kernel_stub.StubT` qualifiers that then bounce off the
      qualifier-validity check at ~1968.

Shared root: in both bugs the workspace pre-pass synthesises a
qualifier (`<this>.T` for F1, `<kernel_mod>.T` for F3) that some
downstream consumer of the resolver does not accept. The fix
surface is the resolver-acceptance side in both cases — the
pre-pass's qualification rules are correct per the spec's
§ "Realisation mechanism — workspace pre-pass". Implementation
will be two minimal edits to distinct files-and-functions
(no sweeping refactor).

GREEN side handed to implement mini-mode in the next dispatch.
2026-05-28 19:29:02 +02:00
Brummel e094a71a3e ratify: design model — replace unit literal with canonical no-op idiom (refs F9)
Fieldtest F9 surfaced that `design/models/0007-kernel-extensions.md`
lines 80 + 89 use `unit` as a value literal of type Unit. The
checker has no such literal — it treats the token as a Term::Var
lookup and emits `[unbound-var] main: unknown identifier: unit`.
An LLM author copy-pasting the worked example from the whitepaper
hits a hard wall the second they `ail check` it.

Two coherent resolutions: (a) ratify `unit` as the canonical
Unit-value literal (a one-line resolver change + a contract
update naming the literal), or (b) tighten the model to use the
existing canonical idiom. Picking (b) because:

  - There is no existing language contract pinning `unit` as a
    literal — the model was speaking aspirationally.
  - The shipped surface already has a canonical no-op shape
    (`(do io/print_str "")`); promoting that to the model is
    cheaper than promoting a new keyword.
  - The kernel-extension-mechanics milestone is closed — adding
    a surface literal here would be unmotivated scope creep.

Both sites are if/match arms that produce Unit (i.e. they are
no-ops on the data side, used for control-flow exhaustiveness).
The replacement `(do io/print_str "")` produces Unit and is
already known-good per the kem_4_paramin_box_red.ail fieldtest
fixture. Future Series-milestone work may reach for a proper
unit-value literal; if it does, the brainstorm gate will weigh
adding it then.
2026-05-28 19:22:13 +02:00
Brummel 171a986f82 fix(stub): add missing (fn new ...) to STUB_AIL — restore prep.3 spec/ship coherence (refs F4)
Fieldtest F4 surfaced that the shipped STUB_AIL had only the
TypeDef + ctor; the spec's prep.3 § "Worked author example" wired
a `(fn new ...)` def into the stub and immediately exercised it
via the worked consumer's `(new StubT 42)`. The implementer
dropped the def during the Task 6 sweep (spec-review let it
through). Without the def, the stub's `Term::New` end-to-end
ratification story is broken — F4 demonstrated this with a
`new-type-not-constructible` diagnostic on a verbatim
worked-consumer paste.

Re-added the def per the spec's design:

  (fn new
    (doc "Construct StubT<Int> from an Int. Exercises Term::New end-to-end.")
    (type (fn-type (params (con Int)) (ret (con StubT (con Int)))))
    (params x)
    (body (term-ctor StubT Stub x)))

IR snapshots refreshed because every binary now carries the `new`
function's IR (in addition to the `drop_kernel_stub_StubT` from
the original prep.3 close). All 664 workspace tests green.

This restores coherence between the spec, the design model, and
the shipped stub. The `Term::New` invocation `(new StubT 42)` is
still codegen-deferred (per spec § Architecture, raw-buf
milestone); but at least `ail check` now passes on the spec's
worked-consumer paste.

A related and more substantive bug (F3 — kernel-tier auto-import
not registering the module as a qualifier prefix, making
`kernel_stub.StubT` consumer-unreachable) is handled separately
via the debug skill.
2026-05-28 19:21:57 +02:00
Brummel aa49a56d5a fieldtest: kernel-extension-mechanics — 6 examples, 9 findings (3 bugs, 1 friction, 1 spec_gap, 4 working)
Post-audit fieldtest for the kernel-extension-mechanics milestone.
Fieldtester wrote 6 .ail consumer programs against the public
interface only (design ledger + spec + ail CLI; no source crate
reads), one per axis with two for the param-in pair (accept +
reject). All artefacts in examples/fieldtest/ + the spec.

**Working (4):** F5 NewTypeNotConstructible diagnostic is precise
(names the home module + missing def in one sentence). F6 Term::New
codegen-deferral diagnostic explicitly names the raw-buf milestone
where support lands. F7 param-in accept-path is end-to-end (check +
build + run prints 17). F8 ParamNotInRestrictedSet names type-arg
+ var + TypeDef.

**Bugs (3, action: debug):**

- F1 (axis 1) — Same-module type-scoped call `(app Type.member …)`
  rejected with phantom-qualified `expected <this>.T, got T`.
  Bare `(app member …)` from inside Type's home module works.
  Spec's "Canonical form decision" promises type-scoped works
  uniformly; the resolver appears to disagree with the workspace
  pre-pass on whether `<this-module>.T` equals bare `T`. Repro:
  examples/fieldtest/kem_2b_min_repro.ail.

- F3 (axis 3) — Kernel-tier auto-import scopes the name but does
  not register the module as a resolvable qualifier prefix. Per
  the pre-pass, a bare `StubT` in a type slot is qualified to
  `kernel_stub.StubT`; the resolver then rejects `kernel_stub`
  as unknown. Asymmetric: prelude's free fns work (per
  prelude_free_fns.rs), but the stub's TypeDef is effectively
  unreachable from any consumer. Repro:
  examples/fieldtest/kem_3_stub_consumer.ail.

- F4 (axis 3, spec/ship coherence) — The spec's prep.3 worked-
  consumer relies on `(new StubT 42)`, but the shipped STUB_AIL
  has only a TypeDef + ctor — the `(fn new ...)` the spec
  designed is missing from the implementation. The diagnostic
  the resolver does emit on the consumer (F5) is precise; the
  bug is the absent def itself. Resolution: re-add the `new` to
  STUB_AIL (the spec's design), refresh the drift pin.

**Friction (1, action: plan-or-defer):**

- F2 — Loader's sibling-only module resolution forces symlinks
  for any cross-module fixture sitting outside the canonical
  `examples/` directory. The kem_1 example required local
  symlinks for std_list.ail + std_maybe.ail. Secondary: the
  diagnostic message names only the .ail.json path even though
  .ail also resolves — actively misleading. Recommended: small
  tidy iter for loader workspace-search-path + diagnostic clarity.

**Spec-gap (1, action: ratify):**

- F9 — `design/models/0007-kernel-extensions.md:80` uses `unit`
  as a value literal of type Unit; the checker treats it as a
  Term::Var lookup and emits [unbound-var]. Either ratify `unit`
  as the canonical Unit-value literal in the language, or tighten
  the design model to use the existing idiom. The current model/
  surface disagreement is a small but real LLM-author trap.

**Symlinks committed** as evidence of the F2 workaround:
examples/fieldtest/std_list.ail and std_maybe.ail are symlinks
into ../. They are the fingerprint of the friction — removing
them silently would erase the evidence.

Per Iron Law, fieldtester did not work around bugs — they were
all surfaced and recorded. The kem_2b_min_repro.ail fixture
exists specifically because F1 surfaced mid-drafting and got
minimised; kem_3_stub_consumer.ail also stays as the F3 RED-side
fixture.

Triage (next-step routing for /boss):

  F4 → small inline fix (add (fn new ...) to STUB_AIL + drift refresh)
  F9 → small inline ratify (whitepaper edit)
  F2 → backlog issue, deferred (single tidy iter someday)
  F1, F3 → debug skill dispatches, then implement mini-mode
  F5-F8 → carry-on, recorded as wins
2026-05-28 19:19:00 +02:00
Brummel 9d2e752f07 iter kernel-extension-mechanics.tidy: architect drift items — present-state + plugin-migration aftermath
Audit Step 3 fix path. Architect drift review at milestone close
surfaced 8 items (3 high, 4 medium, 1 low). Resolved 7 inline as
mechanical text rewrites + 2 source-rustdoc cleanups; the 1 low
(cycle-avoidance pattern documentation in design ledger) is
deferred — architect flagged it "Note, do not push" because the
pattern is implementation mechanism, not language semantics,
and is already documented in the load-bearing place
(`crates/ailang-kernel-stub/src/lib.rs //!`).

CLAUDE.md (2 sites):
  - Lead paragraph: "this file carries [...] in-tree skill system"
    → reframe to "this file carries [...] orchestrator discipline
    (the skill system itself lives in the ~/dev/skills/ plugin
    and is wired in via .claude/dev-cycle-profile.yml)".
  - Code-layout table: new row for `crates/ailang-kernel-stub/`
    documenting the zero-dep leaf design, the parse-hop location
    in `ailang-surface`, and the planned retirement at raw-buf
    landing.

design/INDEX.md (2 sites):
  - Project-ecosystem "Skills" bullet: in-tree `skills/` path +
    `skills/README.md` reference → `~/dev/skills/` plugin +
    in-tree per-project profile. Also added `docwriter` and
    `boss` to the enumerated skill list (8 in total) for
    completeness.
  - kernel-extensions row 111: added stub-retirement plan to the
    annotation so the spine names the future state, not just the
    in-tree reader of `lib.rs //!`.

design/models/0007-kernel-extensions.md (3 sites):
  - § "Migration policy" subsection: 4 bullets transitioned
    forward → present-state per honesty-rule. References to
    `loader.rs:98-108` / `workspace.rs:308-311, 467, 2655` as
    if hardcoded paths still existed → described as past state
    that has been replaced by the generic flag-filter. Codegen-
    intercept-migration bullet kept forward-looking because the
    `try_emit_primitive_instance_body` migration is *actually*
    still pending (deferred to Series milestone per spec
    § Out-of-scope).
  - § "Feature-acceptance argument": "the bounded push-only
    mutation surface — gated by the `Series` effect" — internal
    contradiction with three earlier statements that "Series
    carries no separate algebraic effect; mutation is mode-
    tracked, not effect-tracked". Re-framed to "gated by
    uniqueness mode-tracking on the owned `Series` value (not by
    an algebraic effect; see §"Coexistence" below)".
  - § "Coexistence" "Algebraic effects" bullet: "Reused
    unchanged. The `Series` effect is a new name" — same
    contradiction. Re-framed to "Not extended by Series.
    Mutation discipline lives in the uniqueness/mode system; the
    algebraic-effects set is unchanged".

Source rustdoc (2 sites):
  - `crates/ailang-core/tests/design_index_pin.rs:117` comment
    "skills/**/SKILL.md" → "any in-tree project-discipline
    document (e.g. CLAUDE.md)" — the allowlist comment now
    matches what the test actually does (no allowlist enforced
    in code; any existing path passes).
  - `crates/ailang-core/tests/design_schema_drift.rs:419`
    rustdoc: "RED until `skills/implement` mini-mode adds it"
    → "RED until the `implement` skill (mini-mode dispatch)
    adds it" — same skill, post-plugin-migration framing.

Architect items not addressed in this tidy:
  - 0007 § "The plugin contract (consolidated)" forward-intent
    reference to `try_emit_primitive_instance_body` migration —
    architect flagged as edge-case-acceptable per the explicit
    STATUS carve-out (the migration IS still pending). No
    change.
  - Cycle-avoidance pattern documentation (low): deferred per
    architect recommendation.

Tests green (664/0); workspace_pin module-count assertion picked
up from a844de3 carries through.
2026-05-28 19:04:43 +02:00
Brummel de4399df37 audit + close: kernel-extension-mechanics — latency-arm restoration + p99 jitter-metric removal (ratify)
Audit Step 2 (regression scripts) blocked at exit 2 on bench/check.py
— the latency arms produced lines=0 because their fixtures rely on
`(app print N)` to deliver one newline per sample, but the
io/print_str byte-faithful change (commit 26fb345, closes #29)
stopped trailing newlines from appearing in the polymorphic
print path. Pre-existing infrastructure failure, NOT iter-caused;
fix is one \n insertion per print site in both bench_latency_*.ail
fixtures, behaviour-preserving for any other consumer.

With infra unblocked, bench/check.py reported exit 1 on
latency.explicit_at_rc.p99_us (+42% then +65% across two
back-to-back runs) and the cognate p99_over_median. Median +1.3%,
implicit-arm-control all green. Dispatched bencher for
hypothesis-driven characterisation; the report (6 back-to-back
invocations, recorded per-invocation p99 + within-invocation
[lo, hi] spread) is unambiguous:

- explicit-arm p99_us cv = 18.6% across invocations
- explicit-arm median_us cv = 0.37% (steady-state allocator signal)
- implicit-arm p99_us cv = 2.9% (the control — no co-firing)
- 4 of 6 invocations would trip the 25% gate even though the
  underlying mean (~329 µs) is closer to baseline (259.9 µs) than
  to the worst observed (418 µs)

Structurally identical to the 2026-05-20 recalibration that
removed max_us and p99_9_us (iter bench-harness-recalibration.1,
Gitea #15 / #16) — see docs/specs/0047-bench-harness-recalibration.md.
The 2026-05-20 gamble was that p99 was still allocator-attributable
on a quiet developer machine; that gamble has now failed on the
explicit arm.

Ratify path: removed latency.explicit_at_rc.p99_us and
latency.explicit_at_rc.p99_over_median from baseline.json. The
explicit arm gates only on median_us going forward — the only
metric whose cv (0.37%) actually reflects allocator behaviour
rather than OS jitter. The implicit-arm p99 + p99_over_median
stay — they are stable (cv 2.9%) and provide the contrast that
lets future audits distinguish a real allocator regression from
machine-state jitter.

This is a ratify, not an intentional baseline movement caused by
prep.3: the kernel-extension-mechanics work touches schema +
checker + workspace-load — no codegen-runtime change that could
plausibly affect RC tail latency. The decision is forward-looking
metric removal (per honesty-rule: a regression gate that fires
4 of 6 times on byte-identical code is not a gate, it is a
random-event generator).

Bench results post-ratify: exit 0 across all three scripts
(bench/check.py 34 metrics 0 regressed; bench/compile_check.py 24
metrics 0 regressed; bench/cross_lang.py 25 metrics 0 regressed).

Architect drift items (8 enumerated) will be addressed in a
separate consolidated tidy iteration; this commit only closes the
bench gate.
2026-05-28 19:00:12 +02:00
Brummel a844de3f7a test: workspace_pin module count 3→4 (kernel_stub auto-injection)
Follow-up to 9339279 — the implement-orchestrator caught the
e2e.rs module-count assertion but missed this in-source pin in
workspace_pin.rs. The loader now injects two built-in kernel-
tier modules (`prelude` and `kernel_stub`); the user's ws_main
+ ws_lib bring the total to 4.

Also asserts both built-ins are present by name so a future
retirement of `kernel_stub` (planned for when the raw-buf
milestone lands a real second kernel-tier consumer) trips this
pin in lockstep, not as a silent off-by-one.
2026-05-28 18:45:38 +02:00
Brummel 138157767e design: forward-fix feature-acceptance contract anchor after plugin migration
The skill-system migration (c1441f3) moved skills/ out of the repo
to ~/dev/skills/, but the feature-acceptance contract's ratifying
anchor still pointed at the now-gone `skills/brainstorm/SKILL.md`.
`every_contract_names_a_resolvable_ratifying_test` in
design_index_pin.rs (the test the honesty-rule wires to every
contract row) caught it.

Re-anchor to `CLAUDE.md` § "Feature acceptance: LLM utility" —
that section in the in-tree project discipline file IS the
ratification of the criterion; the brainstorm skill is the
out-of-tree mechanism that applies it at spec time. Two edits in
lockstep: design/INDEX.md row 81 and the "Ratified by:" line at
the bottom of the contract body.

Not iter-scoped (orthogonal to prep.3 of kernel-extension-
mechanics, which landed in 9339279).
2026-05-28 18:44:09 +02:00
Brummel 9339279181 iter prep.3-kernel-tier-modules (DONE 9/9): kernel-tier modules + param-in + stub crate — closes #33
Terminal iteration of the kernel-extension-mechanics milestone. Ships
the four language-level mechanisms named in the spec's § Goal:
Module.kernel + TypeDef.param-in schema, their Form-A surface,
flag-driven kernel-tier auto-injection, and generic param-in checker
enforcement with a new diagnostic.

Schema (Tasks 1+2). Module gains a `kernel: bool` field
(skip_serializing_if = is_false), TypeDef gains a
`param_in: BTreeMap<String, BTreeSet<String>>` field
(skip-if-empty, kebab-renamed to "param-in"). Both fields are
strictly additive — every pre-existing fixture's canonical-JSON hash
is bit-stable except `prelude.ail`, which intentionally gains
`(kernel)`. The struct-literal sweep covered ~104 Module sites and
~35 TypeDef sites across the workspace; the additive serde-default
covers JSON deserialise paths, only Rust struct literals broke.

Form-A surface (Tasks 3+4). `(kernel)` is a bare module-header
attribute; `(param-in (a Int Float) (b Str))` is one outer
TypeDef-body clause carrying one or more inner var-lists (OQ1
decision — mirrors `(ctors …)`, one parser arm, deterministic
BTreeMap iteration). Both round-trip Form-A → JSON → Form-A
bit-identical.

Workspace-load migration (Task 5). The hardcoded `&["prelude"]`
literal at loader.rs:108 became a `modules.values().filter(|m|
m.kernel)` derivation; `parse_prelude()` injection stays because
the prelude has no on-disk manifest in user workspaces. Prelude
now carries `(kernel)` in its source, so the new filter picks it
up automatically. Code-path migration only — observable behaviour
is identical (prelude_free_fns.rs stays green). prelude hash
re-pinned (af372f28c726f29f) with Honesty-Rule provenance comment.
WorkspaceLoadError::ReservedModuleName diagnostic prose
repurposed: any built-in kernel module name is reserved
(currently prelude + kernel_stub), not specifically prelude. CLI
mapping at main.rs updated in lockstep.

Stub crate (Task 6). New `crates/ailang-kernel-stub/` is a
zero-dependency leaf crate carrying only `pub const STUB_AIL:
&str` with the Form-A source of the kernel_stub module (one
parametric TypeDef with param-in, one ctor). The parse hop —
`parse_kernel_stub()` — lives in ailang-surface next to
parse_prelude, keeping the crate-dependency graph acyclic
(`ailang-surface → ailang-kernel-stub → ailang-core`, no
back-edge). The stub is injected unconditionally in all builds as
the ratifying fixture for the kernel-extension mechanism; future
base extensions may add more or retire the stub. Drift-pinned by
`kernel_stub_module_round_trips`.

Checker (Task 7). New `CheckError::ParamNotInRestrictedSet`
variant + code() + ctx() arms + enforcement in
`check_type_well_formed`'s Type::Con arm — generic, data-driven
from the TypeDef, mentions no specific extension type. Two
in-source tests pin both the rejection (`Str` outside `{Int,
Float}`) and the acceptance (`Int` inside) paths.

Workspace-load integration tests (Task 8). New
`workspace_kernel.rs` integration-test crate with three tests:
auto-import without explicit `(import …)` declaration, two
kernel-tier modules co-load, explicit-import-overrides-auto-
import precedence preserved. Loader is import-tree-only so the
auto-import tests use a bridge module that brings the kernel
module into the workspace via the import graph — docstring
captures the reachability nuance for future readers.

Doc-state transitions (Task 9). INDEX.md kernel-extensions row
annotation transitions from "design accepted 2026-05-28; impl in
progress" to "mechanisms milestone closed 2026-05-28; raw-buf and
series milestones pending". Whitepaper STATUS + auto-import +
param-in sections transitioned forward→present for shipped
mechanisms; forward-tense survives only in sections describing
the still-pending raw-buf/series milestones (per Honesty-Rule).
data-model contract gains anchor blocks for both new schema
fields.

Side-effect: every binary's IR snapshot now contains ~52 lines
for `drop_kernel_stub_StubT` because the stub is auto-injected
into every workspace load. Snapshots refreshed; e2e expects 4
modules per workspace (prelude + kernel_stub + entry + zero or
more user modules) instead of the previous 3.

Plan defects scrubbed in the implementation (folded back into
the planner template via the planner's self-review checklist
next time): Task 4 sample test src used fictional
`(ctors (MkT a))` list form (project grammar is per-`(ctor MkT
a)`); Task 6 original wiring would have created a cycle
ailang-surface → ailang-kernel-stub → ailang-surface (inverted —
stub crate is zero-dep, parse hop lives in surface); Task 7 in-
source tests referenced a fictional `check_type_in_module`
helper (used the existing Workspace + check_workspace
convention); Task 8 first integration test expected loader to
auto-load kernel modules from disk (loader is import-tree-only;
tests use a bridge module).

Concern-5 fix folded in pre-commit: workspace.rs ReservedModuleName
doc-prose initially said "in test/dev builds" for kernel_stub —
but stub is unconditionally injected in all builds. Doc copy
tightened to present-state per Honesty-Rule.

Stats: 0 spec-review-loops, 0 quality-review-loops, 2 sweep-script
retries on Task 2 (brace-depth bug on nested vec![Ctor{…}],
recovered via per-file checkout + rewritten anchor-on-existing-
field sweep), 1 e2e-snapshot refresh on Task 6.
2026-05-28 18:43:42 +02:00
Brummel 3b4fb42771 plan: prep.3-kernel-tier-modules — 9-task schema + surface + workspace + diagnostic + stub crate (refs #33)
Terminal iteration of the kernel-extension-mechanics milestone.
Carves the spec's § Iteration prep.3 into nine bite-sized tasks:

  1. Module.kernel field + ~130-site struct-literal sweep + drift
     round-trip + data-model anchor.
  2. TypeDef.param_in field + ~30-site sweep + drift round-trip +
     anchor (BTreeMap<String, BTreeSet<String>>, kebab-cased
     `param-in`, skip-if-empty for hash stability).
  3. Form-A (kernel) module-header attribute — parse + print +
     round-trip.
  4. Form-A (param-in (a Int Float) (b Str)) TypeDef attribute —
     one outer clause, multiple inner var-lists (OQ1 decision).
  5. Prelude becomes kernel-tier + loader generalisation: the
     hardcoded `&["prelude"]` literal at loader.rs:108 migrates to
     a `modules.values().filter(|m| m.kernel)` derivation;
     ReservedModuleName diagnostic repurposed; CLI mapping +
     hash-pin refresh in lockstep.
  6. New crate `crates/ailang-kernel-stub/` — programmatic stub
     module via parse_kernel_stub() mirroring parse_prelude
     (OQ2 decision); wired into the loader; basic stub round-trip
     drift test ratifies the end-to-end mechanism.
  7. CheckError::ParamNotInRestrictedSet variant + code() + ctx() +
     enforcement in check_type_well_formed's Type::Con arm +
     in-source RED/GREEN tests.
  8. New workspace_kernel.rs integration tests — auto-import without
     explicit (import …), two kernel-tier modules co-load,
     explicit-import-overrides-auto-import precedence.
  9. design/INDEX.md + design/models/0007-kernel-extensions.md
     STATUS + forward→present transitions for the now-shipped
     mechanisms.

The four open questions raised by plan-recon are resolved up front
in the "Open-question decisions" block so no task carries a TBD.
ReservedModuleName variant shape stays `{ name: String }` (OQ3);
workspace.rs:2655 stays a test-site literal (OQ4).

The Module struct-literal sweep is the largest single mechanical
edit (~130 sites; the additive #[serde(default)] covers JSON
deserialise paths, only Rust struct literals break) — handled as
a compiler-driven sweep inside Task 1, with the workspace-build
gate validating no caller is deferred. TypeDef has ~30 sites.

Compile-gate-vs-deferred-caller and pin/replacement-substring
contiguity rules from planner self-review Step 5 are scrubbed:
no signature change defers a caller past its own compile gate,
no verbatim text edit pairs with a soft-wrappable presence pin.

Recon report received DONE_WITH_CONCERNS — concerns were the
four OQs, all decided in this plan.
2026-05-28 17:04:41 +02:00
Brummel c1441f3a87 switch to ~/dev/skills/ plugin: retire in-tree skills/
The skill system migrated to a standalone plugin
(http://192.168.178.103:3000/Brummel/Skills.git, local clone at
~/dev/skills/) during this branch's earlier commits. AILang now
consumes the plugin via:

- ~/.claude/skills/<name> and ~/.claude/agents/<name> symlinks
  created by ~/dev/skills/install.sh (user-level, installed
  once, visible from any project).
- A project profile at .claude/dev-cycle-profile.yml declaring
  paths, commands, vocabulary, naming policy, issue tracker,
  notification command, and pipeline customisations.

Changes:

- ADD .claude/dev-cycle-profile.yml — AILang's profile against
  the plugin's schema (paths spec_dir/plan_dir/design_ledger/
  design_contracts/design_models/code_roots/bench_dir/
  public_interface/fieldtest_examples; counter-prefix naming
  policy; cargo build/test/doc commands; bench/check.py +
  compile_check.py + cross_lang.py as regression; bench/
  architect_sweeps.sh as architect sweep; milestone/iteration/
  contract vocabulary; design-ledger walk + git-log standing
  reading; bencher gets concrete RC/bump reading paths;
  Gitea issue-tracker URL + tea list_cmd; ~/.claude/notify.sh
  as notification command; the full eight-skill pipeline).

- REMOVE skills/ in-tree (21 files: 7 SKILL.md + 13 agent files
  + skills/README.md). All eight skills + all twelve agents
  now live in ~/dev/skills/ as generic prose that reads slots
  from the profile.

- REMOVE .claude/skills/ + .claude/agents/ symlinks (15
  symlinks). User-level ~/.claude/skills/ + ~/.claude/agents/
  installed by the plugin take over discovery.

- UPDATE CLAUDE.md:
  * Skill-system section rewritten to point at ~/dev/skills/
    + the profile file.
  * "My role: orchestrator" agent names dropped the ailang-
    prefix (implementer, tester, debugger, architect).
  * "Authority over `skills/`" became "Authority over the
    skill plugin" — naming where plugin edits vs profile vs
    CLAUDE.md edits live.
  * Bug-fixes-TDD pointer updated to ~/dev/skills/debug.
  * Milestone-cycle section notes the cycle/iteration
    vocabulary mapping the profile carries.
  * design-ledger-roles paragraph dropped the `ailang-`
    prefix on the architect agent name.
  * Code-layout table's `skills/` row replaced with a row
    describing .claude/dev-cycle-profile.yml.
  * ADDED a new "Lockstep-invariant pairs" section
    enumerating AILang's two known cross-file pairings
    (Pattern::Lit::* / pre_desugar_validation;
    lower_app / is_static_callee) — the architect agent's
    drift walk and plan-recon's cross-reference column both
    consult this section per the plugin's
    templates/CLAUDE.md.fragment shape.

After this commit, AILang no longer ships any skill files in
its own tree. All discipline lives in ~/dev/skills/; all
project-specific calibration lives in
.claude/dev-cycle-profile.yml plus CLAUDE.md.
2026-05-28 16:32:38 +02:00
Brummel 078c39a76a iter prep.2-term-new-construct (DONE 4/4): Term::New AST + Form-A surface + checker + drift — closes #32
Second iteration of the kernel-extension-mechanics milestone. Ships
the `new` Form-A keyword + `Term::New { type_name, args: Vec<NewArg> }`
AST variant + `NewArg::Type|Value` enum end-to-end through schema /
surface / checker, with two new diagnostics
(`NewTypeNotConstructible`, `NewArgKindMismatch`), a new arm in
prep.1's workspace-wide normalisation pre-pass `qualify_workspace_term`
that rewrites bare `Term::New.type_name` to qualified form
(symmetric to the existing `Term::Ctor` arm), and the data-model.md
+ form_a.md anchor additions. Codegen out of scope per spec; codegen
sites get `CodegenError::Internal` arms naming the raw-buf milestone
as the carrier.

Verification:

- `cargo test --workspace`: 654 tests passing, 0 failed.
- 3 new in-source checker tests pin the elaboration paths:
  `new_resolves_via_type_scope` (the spec's worked Counter example
  checks cleanly), `new_type_not_constructible` (missing `new` def
  in home module fires the new diagnostic), `new_arg_kind_mismatch_value_where_type`
  (kind-mismatch detection).
- 2 new schema-drift pins (`term_new_round_trips`,
  `term_new_type_arg_round_trips`) ratify the JSON byte shape:
  `{"t": "new", "type": "<TypeName>", "args": [{"kind": "type"|"value",
  "value": ...}]}`.
- 1 new surface round-trip pin exercises mixed-kind args through
  Form-A → JSON → Form-A.
- 44+ exhaustive Term-match sites across 24 files extended with
  `Term::New` arms — workspace-wide cargo build clean.

Concerns documented inline:

- `crates/ailang-core/tests/schema_coverage.rs` deliberately does
  NOT register `VariantTag::TermNew` in the `EXPECTED_VARIANTS`
  set. The coverage test asserts every declared variant is
  observed in the .ail fixture corpus; no .ail fixture in the
  current tree emits `"t": "new"` (codegen-runnable Term::New
  programs require the raw-buf milestone's plugin registry).
  Registering would fail the coverage check. The `visit_term` arm
  IS extended (compile-forced); only the enum registration is
  deferred. Inline rationale in the source. Future milestone that
  ships a Term::New fixture extends both sides.
- `crates/ailang-surface/src/print.rs` `write_term` Term::New arm
  was added in Task 1 (not Task 2 as planned), because without it
  ailang-core's tests fail to compile (the surface crate is in
  the dependency graph of ailang-core's test binaries). Task 2's
  round-trip pin verified the arm's correctness.
- Task 3 (synth elaboration) needed one implementer re-loop: the
  first attempt over-qualified within-module type-references via
  `qualify_local_types` on `new`'s signature when the home module
  was the calling module itself. Fixed by skipping qualification
  when `home_module == env.current_module`.

Architectural composition with prep.1: `Term::New` joins
`Term::Ctor` as a `type_name`-bearing site that goes through
`qualify_workspace_term`'s bare→qualified rewrite before the
checker sees it. The checker's `synth` arm for `Term::New`
therefore receives an already-qualified `type_name` (or a local
bare name for same-module construction).

Milestone status: kernel-extension-mechanics (Gitea #6) advances
2/3 iters. Next: prep.3 (kernel-tier modules + param-in), issue #33.
2026-05-28 15:31:13 +02:00
Brummel c8932fa54c CLAUDE.md: scope down to AILang-specific decisions
With ~/dev/CLAUDE.md now carrying the cross-project dev rules
(English-in-repos, closes #N convention, label vocabulary,
milestone-as-container), the AILang CLAUDE.md no longer needs
to repeat them. Two changes:

- Add an inheritance note at the top spelling out which
  user-level CLAUDE.md files this file extends, so the reader
  knows what is intentionally absent (and where to find it).
- Remove the closes #N sentence from the git-log bullet in the
  "Roles of …" section — that convention lives in ~/dev/CLAUDE.md
  now, where it applies to every project under ~/dev/.

The orchestrator-discipline sections (only-Boss-commits, main
sacrosanct, when-not-to-delegate, design-rationale ≠ effort,
TDD-for-bugs) stay verbatim — they are project-calibrated
scaffolding, not literal duplicates of the templates fragment.
2026-05-28 15:10:44 +02:00
Brummel 70e6fcd5c0 plan: prep.2 Term::New construct — 4-task atomic AST + surface + checker + drift (refs #32)
Second iteration of the kernel-extension-mechanics milestone. The
plan ships the `new` Form-A keyword + Term::New AST variant + NewArg
enum + checker elaboration + two diagnostics, plus the schema-drift
pin and the data-model.md / form_a.md anchor additions.

Decomposition:

- Task 1 (large mechanical): AST variant declaration + workspace-wide
  compile-forced arms across 24 files (44+ exhaustive Term-match
  sites). Six arm patterns inlined; per-site table assigns one
  pattern per site. Includes the prep.1 pre-pass partner — the
  `qualify_workspace_term` arm that rewrites bare Term::New.type_name
  to qualified form, mirroring the existing Term::Ctor arm. Synth
  gets a Pattern-E stub here; Task 3 replaces with real logic.
- Task 2: Form-A lex/parse/print + round-trip fixture. New
  `parse_new` method with Type-vs-Term arg disambiguation by
  syntactic form (head keyword = Type-production heads
  `con`/`fn-type`/`borrow`/`own` → NewArg::Type; else NewArg::Value).
- Task 3: Checker synth real-logic + 2 new CheckError variants
  (NewTypeNotConstructible, NewArgKindMismatch) + 3 RED-first
  in-source tests covering the spec's worked example, the missing-
  `new`-def case, and the kind-mismatch case.
- Task 4: Schema-drift pin (term_new_round_trips +
  term_new_type_arg_round_trips) + spec_drift exemplar + form_a.md
  keyword cheatsheet + design/contracts/0002-data-model.md JSON-tag
  block.

Spec is already correct on prep.2 scope; the recon surfaced one
design decision the plan resolves inline: NewArg uses
`#[serde(tag = "kind", content = "value", rename_all = "lowercase")]`
to land the spec's exact JSON byte shape. Codegen remains explicitly
out of scope per the milestone spec; Task 1's codegen sites get
Pattern-D error-arms ("Term::New requires desugar first; codegen
support lands in the raw-buf milestone").

Forward-reference: Task 1's Pattern-C arm in `qualify_workspace_term`
extends prep.1's workspace-wide normalisation pre-pass — Term::New
joins Term::Ctor and Type::Con as a type_name-bearing site that
gets bare→qualified normalised before the checker sees it.
2026-05-28 15:00:27 +02:00