Commit Graph

870 Commits

Author SHA1 Message Date
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
Brummel b586999e81 iter prep.1-type-scoped-namespacing (DONE 5/5): TypeDef-first resolution + workspace pre-pass — closes #31
First iteration of the kernel-extension-mechanics milestone. Ships
the type-scoped `<TypeName>.<member>` resolution path as the
canonical form for type-associated operations, narrows the
`BareCrossModuleTypeRef` / `BadCrossModuleTypeRef` diagnostics from
"bare = strictly local" to "bare = in-scope by any path", migrates
12 std-library example fixtures, and introduces a workspace-wide
normalisation pre-pass `prepare_workspace_for_check` shared between
`check_workspace` and `monomorphise_workspace`.

Architectural discovery during implementation: the plan covered the
`Term::Var` dot-qualified resolver layer plus the workspace
validator's bare-name acceptance, but the migration of bare-form
fixtures exposed five sites where bare vs. qualified type-names
needed symmetric treatment — `Term::Ctor` resolution, `Type::Con`
well-formedness, mono's poly-free-fn name/constraint-count
enumeration, codegen's `lookup_ctor_by_type` bare-name path, and
the upstream desugar-then-qualify composition. Rather than
scattering TypeDef-first ladders across each site, the implementer
centralised the work into one pre-pass that walks every consumer
module's `Type::Con.name` and `Term::Ctor.type_name`, rewriting
bare cross-module references to their qualified `<home>.<Type>`
form. This is symmetric to the pre-existing `qualify_local_types`
(owner-side); the new pre-pass is the consumer-side mirror.
Downstream passes see qualified Types regardless of authoring form.
The TypeDef-first ladder still lives in `synth`'s `Term::Var` arm
because `<TypeName>.<member>` is term-position-only — `Maybe.from_maybe`
is a Var, not a Type expression, and the pre-pass does not rewrite
Var names.

Alternatives considered:

(a) Add TypeDef-first ladder at every resolution site separately
    (the plan's implicit assumption). Rejected: O(N) extension
    sites, each carrying the same workspace-walking logic; the
    pre-pass version is O(1) — one pass, every downstream consumer
    benefits.
(b) BLOCKED + spec re-brainstorm. Rejected: the architecture
    extension is consistent with prep.1's thesis (bare type-name
    resolves to the workspace-wide TypeDef) and forward-compatible
    with prep.2 (Term::New.type_name falls under the same rewrite)
    and prep.3 (kernel-tier TypeDefs enter the workspace map
    automatically). No design regression to bounce back over.

Spec updated to document the realisation mechanism honestly: the
"Realisation mechanism — workspace pre-pass" subsection clarifies
that the resolver-level semantics described in "Implementation
shape" are the user-facing contract, and the actual code path is
the pre-pass.

Verification:

- `cargo test --workspace`: ALL GREEN. 87 e2e + every crate's unit
  + integration tests pass with no regressions.
- Three NEW in-source tests pin Task 1's resolver paths:
  `type_scoped_member_resolves`, `type_scoped_member_not_found`,
  `type_scoped_receiver_not_a_type`.
- One NEW workspace test pins the narrowed validator:
  `ct1_validator_accepts_bare_with_explicit_import`.
- One renamed-and-flipped existing test:
  `ct1_validator_rejects_bare_xmod_with_import_candidate` →
  `ct1_validator_accepts_bare_xmod_with_import_candidate` (the
  bare-with-import path is now ACCEPTED).
- One NEW companion test for the workspace-wide ctor lookup:
  `ct2_term_ctor_bare_cross_module_via_workspace_resolves`.
- Two pre-existing tests' assertions updated for the new error
  wording: `ct1_check_cli::check_human_mode_emits_actionable_message_to_stderr`
  and `crates/ailang-check/tests/workspace.rs::unknown_module_prefix_is_reported`.
- 12 migrated `.ail` fixtures verified via the existing e2e
  suite (each fixture is the test runner's target for an existing
  `build_and_run` assertion).
- Negative fixture `ct_2_bare_cross_module.ail` semantically
  preserved: dropped its `(import std_maybe)` so bare `Maybe` is
  out-of-scope under the narrowed rule and still fires
  `BareCrossModuleTypeRef`.

Concerns:

- The pre-pass introduces a new architectural layer (consumer-side
  qualification) that the spec did not originally anticipate. Spec
  amendment in this commit documents the layer. Future iterations
  reference `prepare_workspace_for_check` as established
  infrastructure.
- `examples/test_ct1_bare_xmod_rejected.ail.json` switched its
  offending name from bare `Ordering` (which under the prep.1
  semantics may now resolve via implicit prelude) to a still-
  unresolvable `Mystery_Type`. The CLI test's intent (assert that
  a human-mode `ail check` exits non-zero on a still-RED case) is
  preserved.

Milestone status: kernel-extension-mechanics (Gitea #6) advances
1/3 iters. Next: prep.2 (`Term::New` construct) issue #32.
2026-05-28 14:43:03 +02:00
Brummel 46c9aabf00 plan: prep.1 type-scoped namespacing — 5-task atomic resolver + 12-fixture migration (refs #31)
The plan covers the first iteration of the kernel-extension-mechanics
milestone: type-scoped `<TypeName>.<member>` resolution in
`ailang-check`, narrowed `BareCrossModuleTypeRef` /
`BadCrossModuleTypeRef` diagnostics in `ailang-core::workspace`,
two new `CheckError` variants, CLI diagnostic-message rewording,
and atomic rewrite of 12 `.ail` example fixtures from `std_X.Y` to
the type-scoped form. Five tasks, each unit-of-review.

Includes a spec correction (same commit because plan recon
surfaced it): the prep.1 Blast Radius previously claimed
`hash_pin.rs` + `prelude_module_hash_pin.rs` refreshes and a
`design_schema_drift.rs` pin addition. Plan-recon walked every
test crate (per the schema-camelcase-fix hash-pin-blast-radius
lesson) and found:
  * neither hash-pin file pins any fixture in prep.1's migration
    set — refresh is empty;
  * type-scoped resolution is a checker-only change (no new JSON
    tags, no AST shape change) — the drift pin belongs to prep.2
    (`Term::New`) and prep.3 (`kernel: true` + `param-in`), not
    prep.1.

Spec section 'Blast radius' and the 'Iteration scope' summary now
reflect the recon-cleared reality.
2026-05-28 14:04:01 +02:00
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00
Brummel 7b8596cef0 spec: kernel-extension-mechanics — three-iter prep milestone
docs/specs/2026-05-28-kernel-extension-mechanics.md: per-milestone
implementation container for the four language-level mechanisms
described in design/models/kernel-extensions.md.

Three iterations, each independently shippable:

- prep.1 — Type-scoped namespacing. Resolver change so
  `<TypeName>.<member>` resolves to the type's home module.
  Migrates ~14 std-library example fixtures from `std_X.Y`-style
  cross-module references to type-scoped form. Hash pins
  refreshed in lockstep (ailang-core + ailang-surface — full
  blast-radius walk, per the hash-pin-audit lesson from
  schema-camelcase-fix).

- prep.2 — Term::New. New AST variant and Form-A keyword
  `(new T args...)`, calls the `new` def in T's home module.
  Uses prep.1's resolution.

- prep.3 — Kernel-tier modules + `param-in`. Schema flag
  `Module.kernel` + TypeDef `param-in` field. Workspace-load
  generalises the existing hardcoded prelude auto-injection
  (loader.rs:98-108 + workspace.rs:308-311, 467, 2655) into a
  flag-driven mechanism; prelude itself gains `kernel: true` as
  a code-path migration (consumer-observable behaviour
  unchanged — prelude_free_fns.rs regression test stays green).
  A new minimal ailang-kernel-stub crate ratifies the mechanism
  without domain content.

Five new diagnostics across the three iters:
TypeScopedMemberNotFound, TypeScopedReceiverNotAType,
NewTypeNotConstructible, NewArgKindMismatch,
ParamNotInRestrictedSet. BareCrossModuleTypeRef and
BadCrossModuleTypeRef diagnostics repositioned (still "type
name not resolvable" but with revised remediation pointers).

Each iter's spec section carries explicit `## Canonical form
decision`, `## Blast radius`, and `## Integration with existing
mechanisms` subsections — the migration-policy discipline made
visible per spec section.

Grounding-check (two dispatches): first BLOCK on prelude
auto-injection mis-framing (claimed it was a new mechanism;
in fact prelude is hardcoded-auto-injected today), corrected
inline; second PASS after the revised two-tier-architecture
framing also landed. All load-bearing assumptions ratified by
named tests in the working tree.

Out of scope, named explicitly: the raw-buf milestone (#7), the
series milestone (#8), record-element SoA support, LSP/MCP
integration for `ail describe`, higher-kinded `param-in`, and
primitive-instance intercept migration (deferred to #7 when
there is a real second consumer for the registry).

Refs Gitea milestone #6.
2026-05-28 13:14:35 +02:00
Brummel d745399a1f design: kernel-extensions whitepaper + INDEX entry
Adds design/models/kernel-extensions.md as the architectural anchor
for the kernel-extensions arc — a plugin-style mechanism for
domain-specific types that live outside ailang-core and present
themselves to user code as if they were primitives.

The whitepaper articulates a two-tier extension architecture:

- Base extensions ship as a kernel-tier module + Rust codegen
  intercepts emitting LLVM IR. They provide primitives that are
  not expressible in AILang itself (mutable indexed storage,
  hardware/OS interaction, library wrappers). The first base
  extension will be RawBuf.

- Library extensions ship as pure AILang code in a kernel-tier
  .ail module, wrapping a base extension to provide domain-specific
  API. The first library extension will be Series.

Four language-level mechanisms enable this:

1. Type-scoped namespacing — `<TypeName>.<member>` resolves to
   the type's home module.
2. The `new` term construct — `(new T args...)` calls the `new`
   def in T's home module.
3. Kernel-tier modules — `Module.kernel: bool` flag with
   auto-import (generalises the existing hardcoded prelude
   auto-injection at loader.rs:98-108 + workspace.rs:308-311,
   467, 2655 into a flag-driven mechanism).
4. `param-in` — closed-set type-parameter restriction on TypeDefs.

The full Series-via-SMA worked example serves as the clause-1
feature-acceptance evidence: the .ail program an LLM author
naturally writes when asked to compute a moving average over
streaming float data. Series.push uses ownership-mode threading
(`(own (Series a)) -> (own (Series a))`), not a separate
`Series` effect — mutation discipline is mode-tracked, not
effect-tracked, consistent with AILang's existing memory model.

Coexistence with existing mechanisms (class dispatch, algebraic
effects, RC + uniqueness, heap-Str ABI, term-ctor) is named
explicitly. Migration policy stated: pre-production stage, so
the right design is chosen even where it requires rewriting
existing test fixtures.

STATUS header marks the document as design-accepted, impl in
progress across milestones #6#7#8. As each closes, the
relevant sections transition from forward-looking to present
state per the honesty-rule.

design/INDEX.md gains a corresponding Models-table entry.

Refs Gitea milestones #6 (kernel-extension-mechanics), #7
(raw-buf), #8 (series). Closes #2 (Flat array/slice primitive)
as duplicate of #7. Closes #4 (Stateful islands) as superseded
by this arc — the streaming-analytics workload class the
stateful-islands design targeted is delivered by kernel-extensions
without re-introducing `mut`/`var`/`assign` (atomically removed
in `remove-mut-var-assign.1`) or adding a `!Mut` effect.
2026-05-28 13:14:10 +02:00
Brummel 4fc65ccb99 iter schema-camelcase-fix.1 (DONE 4/4): paramTypes/retType → param-types/ret-type — closes #30
`Term::Lam`'s two camelCase JSON tags become kebab-case, matching
the convention every other compound-key tag in the AST schema
already follows. After this iter the canonical JSON has zero
camelCase outliers.

## Schema swap (atomic, Task 2)

- `crates/ailang-core/src/ast.rs:492,494` — two
  `#[serde(rename)]` strings: `"paramTypes" → "param-types"`,
  `"retType" → "ret-type"`. Rust field names unchanged.
- `crates/ailang-core/src/workspace.rs:1925-1926` — in-source
  JSON literal in the `ct1_validator_walks_lam_embedded_types`
  test.
- `design/contracts/data-model.md:142-147` — fenced JSON block
  in the canonical data-model contract. Honesty-Rule touch-point:
  the contract document must describe the actual present-state
  schema.
- `examples/test_loop_binder_captured_by_lambda.ail.json` and
  `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json`
  — the two `.ail.json` fixtures whose canonical-JSON embeds
  the renamed tags.

The five files moved together within one task because `Term::Lam`
has no `#[serde(default)]` on `param_tys` / `ret_ty` — a
renamed-away key is a hard deserialise error. Splitting the swap
would have left the workspace untestable mid-step.

## RED → GREEN pin (Tasks 1, 2)

`crates/ailang-core/tests/design_schema_drift.rs` gains
`lam_serialises_with_kebab_keys`: pins that `serde_json::to_value(&Term::Lam{...})`
emits `"param-types"` / `"ret-type"` AND does not emit the old
camelCase keys. Companion: the existing
`design_md_anchors_every_term_variant` test now also asserts the
kebab anchors appear inside `data-model.md`'s `lam` fenced block.
Both confirmed RED on entry, GREEN after Task 2.

## Rustdoc honesty pass (Task 3)

Three pure-prose edits keep production rustdoc consistent with
the new schema vocabulary: `crates/ailang-core/src/ast.rs:8` (the
module-level rename enumeration), `crates/ailang-surface/src/parse.rs:81`
(prose mention of `lam`'s carry), `crates/ailang-check/src/lib.rs:1707`
(InstanceMethod routing helper rustdoc). No compile or test
consequence — Honesty-Rule maintenance.

## Plan-pseudo-vs-reality finding: under-audited hash blast radius

The brainstorm spec audited `crates/ailang-core/tests/hash_pin.rs`
exhaustively (5 pinned modules, zero `(lam ...)` occurrences,
"no hash refresh required"). It missed
`crates/ailang-surface/tests/prelude_module_hash_pin.rs` — a
separate hash-pin file in a different crate that pins `prelude.ail`,
which contains 11 `(lam ...)` forms. The prelude module hash
drifted (`6d0577ff0d4e50ac` → `562a03fc57e7e017`); the implementer
refreshed it with a Honesty-Rule provenance comment matching the
precedent set in commit `26fb345`. Form-A is unchanged — only the
canonical-JSON byte stream differs, which is exactly what the
rename targets. The spec-side learning is: schema-rename
brainstorms must walk *every* test crate for hash-pin files, not
just the home crate of the AST.

## Why this shape, and not the alternatives

- *`params-ty` / `ret-ty` (Rust-field-name vibe)* — rejected. The
  AST JSON schema follows its own kebab/single-word convention,
  not Rust field names. The other multi-word tag (`reuse-as`) is
  kebab; abbreviating `params-ty` mixes singular+plural and has
  no precedent.
- *Inline typed params as a sub-tag* (e.g. `params: [{name, type}, ...]`)
  — rejected. Stronger semantic locality, but it changes schema
  topology rather than tag spelling. Out of scope for a camelCase
  correction; would have re-pinned far more than this milestone.
- *Bundle with #27 (arith-rename)* — rejected. #27 carries four
  open brainstorm questions (mod vs rem, neg vs sub 0, Num class,
  deprecation window) and is structurally a separate milestone.
  Each gets one re-pin wave with its own rationale; no churn
  saving from bundling.

## Honest call on the feature-acceptance gate

Clause 1 (LLM author naturally uses the new form) is the weakest
of the three. Direct empirical evidence is absent — the 2026-05-21
naming-A/B run measured a different axis. The argument is
indirect: a future LLM author generalises from the rest of the
schema ("compound keys are kebab"), and the two camelCase
outliers are precisely the sites where that generalisation
diverged from reality. Clause 2 (redundancy reduction) and
Clause 3 (no semantic surface touched) are direct.

## Forbidden touches verified untouched

`docs/plans/*.md` historic plans (which embed old tag names in
example JSON), `experiments/2026-05-12-cross-model-authoring/master/spec.md`,
`experiments/.../rendered/*.md`, and `experiments/.../runs/**` —
all left as frozen historical artefacts per Honesty-Rule
analogue (describe state at time of writing, not present state).
Verified via `git diff --name-only HEAD` (Task 4 Step 4).

## Verification

- `cargo test --workspace`: all test groups OK, 0 failed,
  2 ignored (pre-existing).
- `cargo test -p ailang-surface --test round_trip`: GREEN
  (Form-A unchanged invariant).
- New schema-shape pin and the extended data-model anchor walk:
  GREEN after Task 2.
- Negative grep across `crates/`, `examples/`, `design/`,
  `runtime/`: the only remaining `paramTypes` / `retType`
  occurrences in checked-in code are the load-bearing
  negative-assertion strings inside the new pin (intentional —
  they are what makes the pin RED-able on regression).
- Stats: `bench/orchestrator-stats/2026-05-21-iter-schema-camelcase-fix.json`
  — 4/4 tasks DONE, no re-loops, no review-loops.

closes #30
2026-05-21 12:57:06 +02:00
Brummel 90977663ff plan: schema-camelcase-fix — 4-task atomic schema-tag migration (refs #30)
Iteration plan derived from `docs/specs/2026-05-21-schema-camelcase-fix.md`
(commit 55ce6d0, amended). Single-iteration milestone, four tasks:

- **Task 1 (RED)**: add `lam_serialises_with_kebab_keys` pin to
  `design_schema_drift.rs` + extend `anchor_in_jsonc_block` walk
  to require the new tag spellings inside `data-model.md`'s `lam`
  fenced block. Verifies pin fires RED on current state.

- **Task 2 (GREEN atomic schema swap)**: 10-step coupled
  migration of `ast.rs:492,494` + `workspace.rs:1925-1926` +
  `examples/test_loop_binder_captured_by_lambda.ail.json` +
  `experiments/.../master/examples/fn_with_lambda.ail.json` +
  `design/contracts/data-model.md:142-147`. All five files MUST
  move together — changing only `ast.rs` makes the JSON literals
  undeserialisable (no `#[serde(default)]` on `param_tys`/`ret_ty`,
  so the renamed-away key is a hard error).

- **Task 3 (GREEN-2 rustdoc honesty)**: three pure-prose edits in
  `ast.rs:8`, `parse.rs:81`, `check/lib.rs:1707`. Decoupled from
  the schema swap — no compile or test consequence.

- **Task 4 (Verify)**: exhaustive negative grep, full workspace
  test suite, round-trip invariance, forbidden-files check.

Plan pre-recon by `ailang-plan-recon` (DONE_WITH_CONCERNS) caught
the data-model.md + rustdoc layer the brainstorm initially missed
— spec was amended in commit 55ce6d0 + re-grounding-check PASS
(9 claims ratified, 2 of them via exhaustive negative grep as
"no-test-pins-this" Boss-overrideable shape).

Self-review: 8/8 (spec coverage, no placeholders, name consistency,
step granularity 2-5 min, no commit steps, pin/replacement
contiguity, compile-gate threading complete within Task 2,
verification filters all resolve to named real tests).
2026-05-21 12:48:59 +02:00
Brummel 55ce6d0d70 spec: schema-camelcase-fix — expand scope to data-model.md + rustdoc layer (refs #30)
Spec amendment after plan-recon flagged four missed touch-points
in the brainstorm grounding-check loop:

1. `design/contracts/data-model.md:142-147` — fenced JSON-block in
   the canonical data-model contract. The data-model contract IS
   the canonical-schema doc (INDEX.md row, ratifying test
   `tests/design_schema_drift.rs`); leaving it on camelCase after
   `ast.rs` ships kebab is a direct Honesty-Rule violation.

2. Three rustdoc strings in production source that describe the
   present-state schema vocabulary: `ast.rs:8`, `parse.rs:81`,
   `check/lib.rs:1707`. Each enumerates the rename targets in
   prose; left unchanged they would describe a state that no
   longer exists.

3. Better home for the new schema-shape pin: `design_schema_drift.rs`
   (not `schema_coverage.rs`). That file already operates as the
   data-model-contract ratifying test, already builds Term::Lam
   exemplars at L121-129, and uses `anchor_in_jsonc_block` to walk
   data-model.md fenced blocks — the proposed extension slots
   directly into the existing pin family.

4. Experiment-tree files (`experiments/2026-05-12-.../master/spec.md`,
   `rendered/*.md`, `runs/**`) carry old tags. Per Honesty-Rule
   analogy with docs/plans/* — these are frozen historical
   artefacts of the 2026-05-12 cross-model-authoring experiment
   and are NOT migrated. The experiment's `master/examples/*.ail.json`
   fixture IS migrated (live JSON the workspace loader can
   deserialise); surrounding prose is not.

Plus an editorial fix: the fixture-occurrence count parenthetical
corrected from "3" to "2" — each fixture has one `paramTypes` +
one `retType`, one per line.

Spec re-dispatched through `ailang-grounding-check` (Step 7.5
re-PASS). All 9 load-bearing claims ratified, with 2 negative-grep
"no test pins this" ratifications openly flagged in the agent
report as a Boss-override-eligible shape. Acceptance criteria
renumbered to 7 (was 6).

Touch-point count now: 2 serde-renames + 1 workspace.rs literal +
2 .ail.json fixtures + 1 data-model.md fenced block + 3 rustdoc
strings = 9 files, all small edits. No hash-pin refresh required.
2026-05-21 12:45:51 +02:00
Brummel 7d086e69ce spec: schema-camelcase-fix — paramTypes/retType → param-types/ret-type (closes #30)
Brainstorm output. Single-iteration milestone: rename the two
camelCase JSON tags on `Term::Lam` — the only camelCase outliers
in the AST schema — to kebab-case, matching the convention every
other compound-key tag (`reuse-as`, etc.) already follows.

Scope is verified-tiny: ast.rs (2 serde-rename strings) +
workspace.rs in-source test JSON-literal + 2 `.ail.json` fixtures.
None of the five hash-pinned `.ail` modules contains a lambda,
so the milestone refreshes zero hash pins. Form-A is untouched —
the surface uses `(params (typed ...))` / `(ret ...)`, never the
camelCase tags.

Feature-acceptance gate: passes weakly-but-honestly. Clause 1 is
indirect (schema-internal consistency reduces the LLM author's
"compound-tag-is-kebab" generalisation failure rate); there is no
empirical preference measurement for this axis. Clause 2 holds
(one fewer special case to memorise). Clause 3 vacuous (no
semantic surface touched).

Grounding-check (Step 7.5) PASS: all four load-bearing claims
ratified by currently-green tests — round_trip.rs (Form-A
invariance), hash_pin.rs (no lambda in pinned modules),
workspace.rs in-source ct1_validator test (exhaustive call-site
list), design_schema_drift.rs (kebab convention pin).

Not bundled with #27 (arith-rename) per the spec preamble: #27
carries its own four open brainstorm questions and is a separate
milestone; each gets one re-pin wave with its own rationale, no
churn savings from bundling.
2026-05-21 12:37:45 +02:00
Brummel 26fb3459d8 GREEN: io/print_str byte-faithful via @fputs(@stdout) — closes #29
The runtime print path now writes exactly the bytes of its
argument with no implicit trailing newline. `io/print_str` is
byte-faithful; authors who want a newline emit `(do io/print_str
"\n")` themselves.

## Codegen

`crates/ailang-codegen/src/lib.rs`:
- Module preamble: `@puts(ptr)` → `@fputs(ptr, ptr)` plus
  `@stdout = external global ptr` (libc's `FILE *stdout`).
- Effect-op lowering for `io/print_str`: emit
  `getelementptr +8` then `load ptr, ptr @stdout` then
  `call/tail call i32 @fputs(ptr bytes, ptr fp)`. Identical
  bytes-pointer GEP, distinct sink.
- The pinned IR-shape test renames from
  `print_str_calls_puts_with_bytes_pointer` to
  `print_str_calls_fputs_with_bytes_pointer_and_stdout` and now
  asserts: bytes-GEP present, stdout-load present, both module-
  preamble declarations present, and no `@puts(` call anywhere
  in the emitted IR.

## Why this shape, and not the alternatives

- *Rename `io/print_str` to `io/println_str` (issue #29 option 2)*
  — kept the auto-newline, just relabelled it. AILang's design
  bias is explicit-over-implicit (CLAUDE.md: implicit conversions
  cut). Auto-newline is a hidden runtime augmentation; the rename
  would have preserved it. Rejected.
- *Append `\n` inside the polymorphic `print` (Show-mediated)
  function in `examples/prelude.ail`* — would have been a one-line
  fix. Rejected: `print` is the Show-mediated formatter, not a
  newline emitter; baking a newline into it would have re-imposed
  the same implicit-augmentation problem one layer up, breaking
  callers that legitimately want pure-bytes output.

## Fixture / test sweep

30 `.ail` fixtures whose owning tests asserted line-separated
stdout now emit explicit `(do io/print_str "\n")` after each
print. Tests that asserted on multi-line stdout (`show_print_e2e`,
`floats_e2e`, `str_concat_e2e`, `eq_ord_e2e`, several `print_*`
smoke tests) had their fixtures sweetened the same way; assertions
themselves remain the canonical observable output. Fixtures that
never relied on the newline (no test ever read the absence of one)
were left untouched.

## Migrated metadata

- `crates/ailang-core/tests/hash_pin.rs`: the `ordering_match::main`
  canonical hash is refreshed (`b65a7f834703ffb4` →
  `8ed47b4062ce00f5`). The comment now names both successive
  corpus migrations honestly: the per-type-print-retirement (which
  moved `(do io/print_int x)` to `(app print x)`) AND this
  fputs swap (which wrapped that with `(seq ... (do io/print_str
  "\n"))`).
- `design/contracts/str-abi.md`: the consumer-ABI table now lists
  `@fputs` as the print sink. A prose paragraph documents the
  byte-faithful semantics and references this issue.
- `examples/ordering_match.prose.txt`: regenerated from the
  updated `ordering_match.ail`.
- `crates/ail/tests/snapshots/{hello,sum,max3,list,ws_main}.ll`:
  IR snapshots regenerated via `UPDATE_SNAPSHOTS=1`.
- Stale `@puts` comments in `runtime/str.c`,
  `crates/ail/tests/{e2e,show_print_e2e,print_no_leak_pin}.rs`
  replaced with `@fputs`.

## Verification

- `cargo test -p ail --test print_str_no_auto_newline_e2e` — both
  RED tests from commit c8ecfa3 now pass.
- `cargo test --workspace` — 90 test groups GREEN, 0 failures.
- `cargo build --workspace` — GREEN.
- No new clippy lints (24 warnings pre-existing in
  `crates/ailang-core/src/lib.rs:129`).
- Stats: `bench/orchestrator-stats/2026-05-21-iter-bugfix-print-
  str-fputs.json` — 1/1 tasks, 0 re-loops, 0 review loops.

## Empirical evidence cited

Caught in the 2026-05-21 Qwen3-Coder naming-A/B run
(`experiments/2026-05-21-naming-ab/runs/r1/`): every cohort wrote
`(do io/print_str "...\n")` with explicit `\n` and got doubled
newlines, failing the `t3_main_prints` stdout match across all
three cohorts. The empirical LLM-natural form already assumes the
new (post-this-commit) semantics — confirming the
feature-acceptance test in CLAUDE.md.

closes #29
2026-05-21 12:22:45 +02:00
Brummel c8ecfa39b9 RED: io/print_str must print exactly the bytes of its argument — refs #29
Two RED E2E tests that pin the byte-faithful-print property of
io/print_str. Both fail today because the codegen at
crates/ailang-codegen/src/lib.rs:2754 lowers the op to a C `@puts`
call, which writes the string plus a trailing newline.

- io_print_str_does_not_append_auto_newline:
  (seq (do io/print_str "a") (do io/print_str "b")) → stdout "ab"
  current: "a\nb\n"
- io_print_str_does_not_double_embedded_newline:
  (do io/print_str "x\n") → stdout "x\n"
  current: "x\n\n"

Assertions compare RAW stdout (no .trim()) — the byte-level
property IS the assertion target.

Empirically caught in the cross-model-authoring naming-A/B run
on 2026-05-21 (experiments/2026-05-21-naming-ab/runs/r1/): Qwen3-
Coder repeatedly wrote `(do io/print_str "text\n")` with explicit
\n, expecting byte-faithful output, and got doubled newlines —
failing every t3_main_prints stdout match across all three cohorts.

GREEN side (codegen swap @puts → fputs(s, @stdout) + sweep of
existing fixtures that implicitly relied on the auto-newline) is
the next commit, via implement mini-mode.

refs #29
2026-05-21 11:59:38 +02:00
Brummel b85d498d03 doc: fix Form-A ctor drift in cross-model-authoring spec — closes #28
The master spec at experiments/2026-05-12-cross-model-authoring/
master/spec.md taught two wrong Form-A keywords:

- Term position: `(ctor TYPE-NAME CTOR-NAME ARG*)` — parser
  rejects this; the canonical keyword is `term-ctor`. The bare
  `(ctor ...)` is reserved for the inside of `(data ...)`
  definitions only.
- Pattern position: `(ctor CTOR-NAME SUBPAT*)` — the canonical
  pattern keyword is `pat-ctor`.

The JSON section was already correct (canonical schema tag IS
"t": "ctor", per ast.rs:471). Only the AIL section had drifted.

Empirically caught by the Qwen3-Coder naming-A/B run on 2026-05-21
(experiments/2026-05-21-naming-ab/runs/r1/), where every
t4_count_zeros output produced `(ctor List Nil)` in term position
and failed `ail check` 100 % across all three cohorts — a
constant tax independent of the naming variable under test.

Re-rendered rendered/ail.md from the patched master via
xmodel-render. rendered/json.md unaffected.

closes #28
2026-05-21 11:54:48 +02:00
Brummel 5bd148a607 fieldtest: naming A/B against Qwen3-Coder — refactor not justified
Three-cohort harness against IONOS-hosted Qwen3-Coder-Next, testing
whether application-head tag (`app` vs `apply` vs `call`) affects
LLM authoring success. Cohorts share lambda/constructor/typecon tag
renamings (the non-discriminator changes); only the head differs.

Result: 100 % cohort-treue — Qwen writes whatever the spec teaches,
no measurable natural preference. Pipeline pass-rate (classic 1/8,
apply 0/8, call 1/8) is cohort-independent. The four task failures
are general Form-A friction, not naming-related. Refactor not
justified per the feature-acceptance gate.

Tracked harness:
- 2026-05-12-cross-model-authoring/rename-spec.py — regex tag
  rewriter for the two non-classic spec variants
- 2026-05-21-naming-ab/run.py, reprocess.py — three-cohort runner
  + post-processing with disjoint-discriminator counting and
  module-name-matched temp dirs

Generated artefacts (runs/, rendered/ail-{renamed,call}.md) are
gitignored — regeneratable from rename-spec.py + run.py.

Side-effects filed against current spec/schema bugs surfaced during
the run:
- refs #28  spec teaches (ctor X) for term position; parser requires
            (term-ctor X) — 100 % t4 failure across cohorts
- refs #29  io/print_str appends newline (de facto println);
            spec now documents the behavior, code question still open
- refs #30  schema camelCase outlier: paramTypes/retType in Term::Lam

Spec patch in rendered/ail.md:
- replaces stale io/print_int references with io/print_str (bitrot
  from the print-builtin consolidation)
- documents io/print_str's trailing-newline behavior
- corrects the prelude claim about int_to_str (IS in prelude)
2026-05-21 11:49:04 +02:00
Brummel 8d61599b8d doc: fix bare (class Eq) → (class prelude.Eq) in operator-routing-eq-ord spec north-star
Fieldtest (505eb84) finding spec_gap → tighten-the-design-ledger:
the spec's §"Concrete code shapes" north-star example at line 113
wrote `(class Eq)` (bare). An LLM-author who copies the spec
verbatim hits `bare-cross-module-class-ref` — the language
requires `(class prelude.Eq)` (qualified) at instance-declaration
sites. The shipped fixture `examples/eq_user_adt_smoke.ail`
already uses the qualified form (line 5 — implementer caught the
drift during Task 1 fixture authoring), so the corpus was
consistent; only the spec text drifted. Fix: rewrite line 113
to match the qualified form the language and the existing
corpus require.

One-line doc fix; no code surface change. Spec stays as
`docs/specs/2026-05-20-operator-routing-eq-ord.md` (Status: Draft
unchanged — content correction, not a re-issue).
2026-05-21 01:34:54 +02:00
Brummel 505eb8484e fieldtest: operator-routing-eq-ord — 5 examples, 6 findings (4 working, 1 friction, 1 spec_gap)
Post-audit field test of the operator-routing-eq-ord milestone
(closed via 5170b6a + 4d45bc6). Five `.ail` Surface-form fixtures
under `examples/fieldtest/` written by an LLM-author working
strictly from the design/ ledger + public examples (no
`crates/` / `runtime/` / `bench/` reads — Iron Law honoured):

  eqord_1_fizzbuzz.ail        — FizzBuzz [1..15] via (app eq …)
                                 on Int+Str and (app gt …) on Int
  eqord_2_rational_eq.ail     — data Rational + user-instance
                                 (class prelude.Eq) by cross-mult;
                                 inner Int-eq nested in instance body
  eqord_3_newton_sqrt.ail     — Newton's method via float_lt
                                 (convergence + fabs) and float_eq
                                 (zero-guard)
  eqord_4_float_ord_must_fail.ail — (app lt 1.5 2.5) must reject
                                     at typecheck with float_lt hint
  eqord_5_float_eq_must_fail.ail  — (app eq 1.5 1.5) must reject
                                     at typecheck with float_eq hint

Findings:

  [working] x4 — primitive Eq/Ord at Int/Bool/Str/Unit;
    user-ADT Eq with nested primitive eq calls; Float named-fn
    surface (float_eq/float_lt/etc.); NoInstance Eq/Ord Float
    diagnostic with float_eq / float_lt addendum. The milestone's
    central thesis (class-dispatch is the only comparison
    surface; Float opts out via named fns) is LLM-natural —
    every (app eq …) / (app gt …) / (app float_lt …) call I
    wrote compiled on first try with no diagnostic friction.

  [spec_gap] x1 — `docs/specs/2026-05-20-operator-routing-eq-ord.md:113`
    north-star example writes bare `(class Eq)` where the
    language requires `(class prelude.Eq)`. An LLM-author who
    copies the spec verbatim hits `bare-cross-module-class-ref`.
    The milestone spec is the highest-information reference an
    LLM-author consults; the bare-vs-qualified drift mis-primes
    the pattern. Resolution: tighten-the-design-ledger — fix
    the spec example inline (separate follow-up commit). The
    existing `examples/eq_ord_user_adt.ail` already uses the
    qualified form, so the corpus is consistent; only the spec
    drifted.

  [friction] x1 — `(app compare 1.5 2.5)` at Float fires the
    same diagnostic as `(app lt …)` at Float, naming
    `float_eq / float_lt (and siblings)` as the alternative.
    But the alternatives don't return Ordering; an LLM-author
    who wanted three-way LT/EQ/GT can't satisfy that with
    float_lt + float_eq alone. The spec (lines 433-436)
    acknowledged this case in commentary — "no float_compare
    ships; build it from float_lt + float_eq if you need
    three-way" — but the diagnostic doesn't say so. Resolution:
    file as Gitea backlog issue for a follow-up tidy iteration
    (codegen-side: branch the Float-aware NoInstance addendum
    on the called method-name; the `compare` arm gets a
    one-sentence "no float_compare; build it from float_lt +
    float_eq if you need three-way" addendum).

Status: clean — no bugs, no blockers, the milestone surface is
solidly LLM-usable. Both non-working findings are tractable
forward-fixes that don't disturb the milestone's core
contracts.

Spec: docs/specs/2026-05-21-fieldtest-operator-routing-eq-ord.md
2026-05-21 01:34:19 +02:00
Brummel 4d45bc6c56 audit + close: operator-routing-eq-ord — drift_found → post-audit tidy applied inline; CLEAN
Single-iter milestone closing Gitea #1. Architect surfaced 4
honesty-rule drift items (2 high, 2 medium) — all post-Task-13
documentation drift, no codegen behaviour shift. Bench all three
scripts exit 0. Inline tidy applied under the CLAUDE.md trivial-
mechanical carve-out (4 documentation/comment edits on architect-
pre-named sites; no code surface change). No separate tidy iter.

Architect: drift_found → CLEAN-after-tidy.

The architect walked design/INDEX.md, the contracts touched by
the milestone (float-semantics, prelude-classes, str-abi,
scope-boundaries, authoring-surface, typeclasses, method-dispatch),
`git log 86dd7d7..HEAD --format=full` for the three milestone
commits (spec a68d7b6, plan 400ad7c, iter 5170b6a), and the
diff. The 4 drift items + per-item resolution:

  - [high] `design/contracts/prelude-classes.md` — Task-13
    direct-icmp intercept for `lt__Int`/`le__Int`/`gt__Int`/
    `ge__Int`/`ne__Int` was invisible. The Ord-class free-helper
    paragraph described them as routing via `compare` + `match`
    over Ordering, with no statement that at Int they short-
    circuit to direct `icmp`. The IR shape is actively pinned by
    `ord_int_intercept_ir_pin.rs` and load-bearing for
    `bench_closure_chain` perf. Tidy: new paragraph added naming
    the Int-direct family explicitly, the bench_closure_chain
    rationale, and the deliberate Int-only asymmetry (Bool/Str
    still go through compare→match because no current fixture
    exercises Ord-helpers there at hot-loop frequency).

  - [high] `crates/ailang-codegen/src/lib.rs:2777-2789` —
    rustdoc on `try_emit_primitive_instance_body` was three
    iterations stale: claimed "currently inhabited arms: `eq__Str`
    and `compare__Int/Bool/Str`" (= 4 arms, was correct as of
    iter 23.3), and claimed `eq__Int`/`eq__Bool` "ride the natural
    `lower_eq` dispatch via their lambda body `(== x y)`". Both
    claims are factually false post-milestone (the arm set is now
    14: 4 Eq + 3 compare + 5 lt/le/gt/ge/ne + 6 float_*; lower_eq
    is deleted; `==` is no longer a surface name). Tidy: rustdoc
    rewritten to enumerate the actual 14 arms, explain the
    alwaysinline-pairing, name the Int-direct optimization
    asymmetry, and cross-reference prelude-classes.md.

  - [medium] `crates/ail/tests/prelude_eq_alwaysinline_ir_pin.rs:19-21`
    — comment asserted the test could not use `eq_demo.ail`
    because "`eq_demo.ail` calls `(app == …)` directly which today
    uses the `lower_eq` direct-emit short-circuit and never
    references the prelude eq__Int symbol". Both factual claims
    became false the moment Task 4's fixture migration landed
    (eq_demo.ail now calls `(app eq …)`) and Task 7 deleted
    lower_eq. The test functions correctly but the rationale-
    comment lied about why the fixture choice mattered. Tidy:
    comment rewritten to describe the actual test path —
    `eq_ord_user_adt.ail` invokes `(app eq …)` on IntBox whose
    user-instance body internally calls `(app eq ai bi)` on Int,
    monomorphising `prelude.eq__Int` into the emitted IR where
    the alwaysinline attribute on the `define` line is the
    assertion target.

  - [medium] `design/contracts/typeclasses.md` — implicit-prelude-
    import was load-bearing at codegen for the new bare-prelude-
    fn surface (`(app float_eq …)` etc. without explicit
    `prelude.float_eq` qualifier) but no contract named it. The
    codegen extension (`lower_app::resolve_callee`,
    `is_static_callee`, `resolve_top_level_fn` each gained a
    parallel prelude-fallback during iter Task 2/3) was a
    silent dependency on a workspace invariant that lived only
    in typechecker-side comments. Tidy: invariant (4) added as a
    sibling to the existing three invariants on cross-module
    typeclass references, naming the source-level codegen
    fallback as the symmetric partner to invariant (2)'s post-
    mono fallback; the summary paragraph extended from "three
    invariants are lockstep partners" to four, with
    `float_compare_smoke_e2e.rs` named as the invariant-(4)
    ratifier.

The four fixes are pure-docs/comment work (no `.rs` code path
changed, no IR shape changed); the workspace passing-test count
is unchanged (640 still GREEN, 0 FAILED). No code regression
risk; therefore inline-apply rather than dispatching a separate
planner+implement cycle was the right cost-benefit call.

Bench: 0/0/0.

  - bench/check.py exit 0 — 36 metrics, 0 regressed (full
    bench_closure_chain healed by Task 13's direct-icmp
    intercept arms — bump_s -6.05% rc_s -2.67% bump_rss_kb
    -0.77% rc_rss_kb +0.46%, all four previously-regressed
    metrics back inside tolerance).
  - bench/compile_check.py exit 0 — 24 metrics, 0 regressed.
    Largest delta build_O0_ms.bench_list_sum_explicit +11.04%
    (within 20% tolerance — operator-routing adds two extra
    intercept-arm matches on the compile hot-path, marginal).
  - bench/cross_lang.py exit 0 — 25 metrics, 0 regressed.

No baseline updates needed; no `--update-baseline` invoked. The
intercept-arm extension in Task 13 explicitly aimed at restoring
the pre-milestone baseline; bench data ratifies that the aim was
met.

Pipeline this milestone followed:

  brainstorm (one-question-at-a-time Q&A, three design forks
              resolved: operator-names die from surface; Float
              keeps named fns; codegen via always-call +
              alwaysinline pre-emptive mitigation; grounding-
              check PASS over 11 load-bearing assumptions, after
              one BLOCK → spec defect about desugar.rs:2414
              mischaracterisation fixed)
  -> planner (recon DONE_WITH_CONCERNS, 4 substantive spec gaps
              absorbed inline: 60 fixtures not ≈10, 8 in-source
              AST-literal sites not 3, 5 contract files not 3,
              alwaysinline mechanism new to crate; plus 2 self-
              review items absorbed inline: north-star RED-first
              via Unit-eq line; lockstep edits at codegen/lib.rs:2529)
  -> implement (DONE 12/12 from orchestrator; Boss reclassified
                as PARTIAL via acceptance-#8-fail after independent
                bench re-run; iter scope extended to Task 13 via
                SendMessage to same orchestrator-context; final
                DONE 13/13)
  -> audit (drift_found, 4 items inline-tidy applied: prelude-
            classes.md paragraph + codegen rustdoc refresh + test
            comment rewrite + typeclasses.md invariant-(4)
            addition; bench 0/0/0; CLEAN-after-tidy)

Closing Gitea #1. The `closes #1` trailer lived on the iter
commit `5170b6a` and fired on push.

Spec: docs/specs/2026-05-20-operator-routing-eq-ord.md
Plan: docs/plans/operator-routing-eq-ord.1.md
2026-05-21 01:26:13 +02:00
Brummel 5170b6abd1 iter operator-routing-eq-ord.1 (DONE 13/13): drop comparator builtins, route through Eq/Ord
Closes Gitea #1. Realises the "P2 follow-up" called out in
examples/prelude.ail:9 — removes the surface comparator names
`==` / `!=` / `<` / `<=` / `>` / `>=` from the language entirely,
routes the LLM-author's `(app eq …)` / `(app compare …)` /
`(app ne|lt|le|gt|ge …)` through prelude.Eq / prelude.Ord class-
dispatch, ships six named Float-comparison fns
(`float_eq`/`float_ne`/`float_lt`/`float_le`/`float_gt`/`float_ge`)
so Float keeps comparability without an Eq/Ord instance, and emits
primitive instance bodies with `alwaysinline` so the -O0 IR shape
stays at one instruction per comparison.

Plan-task journal (13 tasks, single atomic iter per Approach A):

  Task 1 — Bootstrap: 4 new fixtures (eq_user_adt_smoke.ail,
    eq_float_must_fail.ail, float_compare_smoke.ail,
    operator_unbound_check.ail) + 5 new E2E/pin tests as RED
    starting state. All 5 confirmed RED at start: north-star
    fixed `NoInstance Eq Unit` (preserved by Unit-eq opening line
    intentionally added in plan-self-review); float_compare unknown
    `float_eq`; operator-name typechecked (== still polymorphic);
    must-fail diagnostic lacked `float_eq`; alwaysinline absent
    from IR.

  Task 2 — Codegen alwaysinline + intercept arms: introduced
    `intercept_emit_wants_alwaysinline` allowlist + appended
    ` alwaysinline` between `)` and `{` of the `define` line in
    `emit_fn`; added 9 new intercept arms to
    `try_emit_primitive_instance_body` — `eq__Int`/`eq__Bool`/
    `eq__Unit` + the six `float_*` arms.

  Task 3 — Prelude reshape: `instance Eq Unit` added; Eq Int/Bool
    bodies become placeholder-`false` (intercept overrides); six
    `float_*` free fns added; line-9 P2-follow-up comment removed.
    Lockstep hash re-pins: prelude_module_hash_pin (3abe0d3fa3c11c99
    → new), mono_hash_stability six body-hash literals
    (eq__Int/Bool/Str + compare__Int/Bool/Str all shifted because
    placeholder body changes the canonical hash). IR-snapshot
    regen (hello/list/max3/sum/ws_main) rolled forward into this
    task at orchestrator's pragmatic call — prelude shift forces
    the snapshots immediately.

  Task 4 — Fixture migration: 58 .ail fixtures rewritten
    (`(app == …)` → `(app eq …)`, etc.; Float-typed operand sites
    to `(app float_eq …)` / `(app float_lt …)`). eq_ord_user_adt.ail:21
    inner `==` → `eq` migration with lockstep eq_ord_e2e.rs:134
    body-hash re-pin (3c4cf040cb4e8bb2 → new). Two prose snapshots
    accepted; deps test + 5 hash_pin literals updated as cascading
    consequences.

  Task 5 — Test-scaffold migration: all 8 in-source
    `#[cfg(test)] mod tests` AST-literal sites threaded
    (desugar.rs:2414, check/lib.rs:5656/6092/6220, codegen/lib.rs
    sites). 7 obsolete in-source tests deleted (5 eq_typechecks +
    2 lower_eq ADT/Fn rejection — coverage moves to E2E
    eq_user_adt_smoke + eq_float_must_fail). 2 additional letrec
    tests deleted (single-module check env can't resolve eq/ge
    without prelude auto-import; covered by workspace E2E).

  Task 6 — Lit-pattern desugar: build_eq emits
    `Term::Var { name: "eq" }` instead of `"=="` at desugar.rs:1109;
    doc-comment rewritten to describe class-dispatch.

  Task 7 — Dead-machinery deletion sweep (compile-gated): typchecker
    builtins (install + list comparator entries deleted); codegen
    builtin_binop_typed (10 comparator arms deleted from synth.rs,
    table reduced to arithmetic core); lower_eq fn entirely
    deleted; `==` short-circuit in lower_app deleted;
    `is_static_callee` `==` clause deleted;
    `is_arithmetic_or_comparison_op` renamed to `is_arithmetic_op`
    + caller-update at codegen/lib.rs:2152 + :2529. Dead
    `poly_a_a_to_bool` helpers also removed. Workspace build green
    after compile gate — every caller of the deleted surface was
    migrated by Tasks 4-6.

  Task 8 — Float-aware NoInstance diagnostic: check/lib.rs:856-880
    addendum extended to name `float_eq` / `float_lt` as the
    explicit alternative for Eq/Ord at Float;
    eq_float_noinstance.rs:32-44 assertion extended.

  Task 9 — IR snapshot regen: subsumed by Task 3 (prelude shift
    forced immediate snapshot regen; deferring to Task 9 would
    have left the workspace red between tasks).

  Task 10 — Prose-projection cleanup: 6 comparator arms deleted
    from binop_info; 3 in-source mod-tests updated/deleted
    (comparator-infix rendering would be dishonest now that the
    operators are no longer language identifiers).

  Task 11 — Contract updates: 5 design files rewritten to the
    class-dispatch present —
      float-semantics.md (arithmetic guarantees retained on
        +/-/*/; comparison guarantees transferred from ==/!=/<
        to float_eq/float_ne/float_lt/etc.);
      prelude-classes.md (Eq Unit added to instance list;
        new paragraph on six float_* fns; Float-no-Eq/Ord clause
        gains `→ use float_eq` cross-reference);
      str-abi.md (clause "REMAIN primitive operators" rewritten
        to describe class-method dispatch);
      scope-boundaries.md (multiple operator-name and
        Pattern::Lit-desugar clauses rewritten);
      authoring-surface.md (==, <= dropped from operator-example
        list).

  Task 12 — Initial acceptance gate: workspace 638/0 GREEN;
    bench/compile_check + cross_lang 0 regressed; bench/check.py
    flagged 4 regressions on bench_closure_chain (+29% bump_s,
    +47% rc_s, +29% bump_rss_kb, +48% rc_rss_kb). Orchestrator
    initially classified as DONE-with-concerns; Boss reclassified
    as PARTIAL-via-acceptance-#8-fail after independent re-run,
    extended iter scope to Task 13.

  Task 13 — Direct icmp intercept arms (Boss-extension):
    try_emit_primitive_instance_body gains direct-icmp arms for
    lt__Int / le__Int / gt__Int / ge__Int / ne__Int, bypassing
    the compare__Int → Ordering → match indirection that allocated
    one Ordering ctor per call in tight loops. Each new arm
    inherits `alwaysinline` via the existing allowlist (extended
    accordingly). New IR pin test `ord_int_intercept_ir_pin.rs`
    + smoke fixture `ord_int_intercept_smoke.ail` ratify the
    optimization — opt -O2 -S confirms zero `call
    @ail_prelude_lt__Int` in optimized IR; the icmp folds directly
    at every use site. Bool variants (lt__Bool/etc.) deliberately
    NOT added — no bench/example calls Ord at Bool; spec-extension
    permits skipping if unreachable at bench level. Family can be
    extended symmetrically when first Bool-ordered perf workload
    appears.

Bench-gate post-Task-13: bench_closure_chain bump_s -6.05%,
rc_s -2.67%, bump_rss_kb -0.77%, rc_rss_kb +0.46% — all four
previously-regressed metrics back inside tolerance. Full bench
corpus: 36 metrics, 0 regressed, 0 improved beyond tolerance,
36 stable. bench/check.py exit 0 ✓; bench/compile_check.py exit
0 ✓; bench/cross_lang.py exit 0 ✓.

Workspace: 640 passed, 0 failed across all binaries (delta vs.
milestone start: +5 new E2E/pin tests added in Task 1 + 1 IR pin
added in Task 13 − 9 in-source mod tests deleted as obsolete net
≈ −3). The eq_user_adt_smoke fixture's Unit-opening line was
the deliberate RED-first device added in planner self-review so
the north-star wouldn't accidentally GREEN at start (user-ADT-Eq
on Point with hand-written instance was already operable today;
the milestone's actual delivery is operator-name death + Eq Unit
+ Float-named-fns + cleanup of the two-pathy primitive
comparator machinery).

Net delta: 96 files changed, 1760 insertions, 1101 deletions;
12 net-new files (6 new fixtures, 5 new E2E/pin tests, 1 stats
file). main passes-test-count: 640 (was 633 pre-iter, accounting
for the deletions).

Spec-vs-acceptance addendum: spec §Testing strategy anticipated
the bench-gate-regression case with two recovery paths
(`alwaysinline` investigation or "spec needs revisiting toward α").
Task 13 is the third path — direct intercept arms for `lt`/etc.
that bypass the compare→match path entirely. The spec's contingency
clause was thus generous enough to absorb Task 13 without spec
revision, but a future iter that hits a class-method primitive
where the body shape introduces a similar codegen cost (Ordering
allocation, RC tax, deferred-init) should expect a parallel
extension. The pattern is "primitive instance whose canonical body
indirects through other class methods that allocate" → add a
direct-emit intercept arm + alwaysinline + IR-shape pin.

Concerns absorbed:
  - Codegen lower_app / resolve_top_level_fn gained a
    prelude-fallback lookup so bare monomorphic prelude fns
    (`float_eq` etc.) resolve from non-prelude modules without
    explicit `prelude.float_eq` qualifier. Mirrors the
    typechecker's implicit prelude import — not in plan but
    necessary infra for the named-fn surface to be callable.
  - Recon's "≈10 fixtures" estimate was 6× too low — 58 actual.
    Mechanical migration; no design impact.
  - Recon caught 4 in-source AST-literal sites the spec missed
    (lib.rs:5656 `>=` site + three codegen/lib.rs sites);
    Task 5 covered all.
  - Recon caught 2 contract files outside the spec's update set
    that directly contradicted the milestone (str-abi.md +
    scope-boundaries.md "REMAIN primitive operators" /
    "Pattern::Lit desugar to ==" clauses); Task 11 covered all 5.

Stats file:
`bench/orchestrator-stats/2026-05-21-iter-operator-routing-eq-ord.1.json`.

closes #1
2026-05-21 01:16:21 +02:00
Brummel 400ad7c067 plan: operator-routing-eq-ord.1 — atomic single-iter execution layout
Decomposes the operator-routing-eq-ord spec (a68d7b6) into 12
bite-sized tasks. Task 1 bootstraps all 5 RED tests + 4 new
fixtures (TDD). Tasks 2-3 ship the load-bearing additions
(codegen alwaysinline mechanism + new intercept arms; prelude
reshape with Eq Unit + six float_* fns + body-hash re-pins).
Tasks 4-6 migrate everything that references the dying surface
(60 fixtures, 8 in-source test scaffolds, lit-pattern desugar).
Task 7 deletes the now-dead operator machinery (typchecker
builtin entries + codegen lower_eq + comparator arms + `==`-
short-circuits) in one cohesive sweep behind a compile-gate.
Task 8 extends the Float-aware NoInstance diagnostic. Tasks
9-11 are mechanical follow (IR snapshot regen, prose-projection
cleanup, 5-contract-file honesty-rule updates). Task 12 is the
acceptance gate.

Plan-recon (DONE_WITH_CONCERNS) surfaced 4 substantive spec
gaps; the plan absorbs all 4:

  - Fixture-migration scope is 60 files / 76 occurrences, not
    the spec's "~10". Task 4 is correspondingly large but the
    work is mechanical.
  - The spec named 3 in-source `#[cfg(test)] mod tests` AST-
    literal sites that need migration; recon enumerated 8
    (incl. `>=` at lib.rs:5656, three `==` sites in codegen/
    lib.rs at 3371/3419/4354, two `cmp("==", …)` Float-arith
    tests at codegen/lib.rs:3729-3734). Task 5 covers the
    full 8.
  - The spec named 3 contract files for honesty-rule update;
    recon flagged 2 additional load-bearing files where the
    dying surface is described present-tense — `str-abi.md:40-42`
    ("REMAIN primitive operators" — directly contradicts the
    milestone) and `scope-boundaries.md:42-96` (multiple
    operator + Pattern::Lit-desugar clauses) — plus a model
    file `authoring-surface.md:58` (operator-example list).
    Task 11 covers all 5.
  - `alwaysinline` codegen attribute machinery does not exist
    in the codebase today (no `attributes #N = { … }` group
    emission, no inline-attribute on any `define` line — recon
    verified). Task 2 introduces it as inline-attribute-on-
    define (LLVM-13+ permits attribute-name directly between
    `)` and `{`), keyed by a new `intercept_emit_wants_alwaysinline`
    allowlist that mirrors the intercept-arm symbol set in
    `try_emit_primitive_instance_body`.

Plan self-review caught two plan-grade items that recon did not
flag, both absorbed:

  - North-star test (`eq_user_adt_smoke_e2e`) would have been
    structurally GREEN at start-of-iter because today's
    `(app eq …)` on user-ADT-with-instance already works (the
    `eq_ord_user_adt.ail` fixture has shipped since 23.5). To
    hold the TDD-RED-first discipline, the north-star fixture
    adds an opening `(app print (app eq () ()))` line which
    fires `NoInstance Eq Unit` today; the new `instance Eq Unit`
    in Task 3 turns this from RED to GREEN. The expected
    stdout becomes `"true\ntrue\nfalse\n"` (Unit-eq, then
    Point-eq, then Point-neq).
  - `is_arithmetic_or_comparison_op` rename + the `name == "=="`
    clause deletion at codegen/lib.rs:2529 both touch the same
    line; the plan explicitly notes both edits land at this
    site in lockstep (Task 7 step 3 + step 5).

Per-task expected-state map names which RED-test flips GREEN at
which task; the workspace test-suite goes through known partial-
pass states between tasks but Task 12 is the only "all green"
gate. The four code-path-deletion tasks (Tasks 4-7) each end
with `cargo test --workspace --quiet` returning `0 failed`
because all callers of the about-to-delete surface are migrated
in earlier tasks — the compile-gate at Task 7 step 7 is
satisfiable by construction (recurring "compile-gate-vs-deferred-
caller" defect family, explicitly scrubbed in self-review #7).

All `cargo test --…<filter>` Run steps use filter substrings
verified to resolve to a real named test or test binary in the
current tree (recurring "verification-filter doesn't resolve"
defect family, scrubbed in self-review #8).

Net delta on landing: 9 new files (4 fixtures + 5 tests), ~30
.rs files modified across `ailang-check` / `ailang-codegen` /
`ailang-core` / `ailang-prose`, 60 .ail fixture migrations,
2 IR snapshots regenerated, 5 design contract / model files
updated, 8 hash-pin literals re-pinned across 3 pin-files.
Workspace pass-count delta: +5 (new tests) − 3-to-5 (test-
scaffold deletions in Tasks 5+10) ≈ +0 to +2 net.

refs #1
2026-05-21 00:05:15 +02:00
Brummel a68d7b6353 spec: operator-routing-eq-ord — drop comparator builtins, route through Eq/Ord
Resolves Gitea #1. Realises the "P2 follow-up" called out in
examples/prelude.ail line 9. Approach A (single-iter atomic
milestone): one cohesive cut across seven layers in one iter,
plus the regenerated prelude module hash pin.

Three design forks resolved with the user via brainstorm Q&A:

  (1) Operator-name surface: `==` `!=` `<` `<=` `>` `>=` die from
      the language. The LLM-author writes only the class-method
      names `eq` `ne` `lt` `le` `gt` `ge` `compare`. One mental
      model: AILang has class-dispatch, operators are not a
      separate concept. Option 2 (keep names as surface aliases)
      was rejected because it adds a second spelling for an
      identical operation — the redundancy the milestone is
      supposed to remove gets reintroduced syntactically. Option 3
      (two-track: primitive Built-in for Int/Bool/Str/Unit, class
      for user-types) was rejected because the two-pathy state is
      precisely what this milestone exists to dismantle.

  (2) Float comparison surface: Float keeps comparison capability
      via six named prelude fns (`float_eq` `float_ne` `float_lt`
      `float_le` `float_gt` `float_ge`), no `Eq Float` /
      `Ord Float` instance. Motivation: all three feature-acceptance
      clauses simultaneously satisfied — clause 1 (LLM-natural via
      Library-Convention-Pattern like Python's `math.isclose` or
      Rust's `approx`-crates), clause 2 (within the polymorphic
      surface there is one path; Float is honestly stamped
      "non-polymorphic"), clause 3 (the NaN-comparison anti-pattern
      stays visible in code as `float_eq` rather than hiding
      behind `eq`). Option A (Float loses all comparison) was
      rejected as overstretch — workarounds via `is_nan` +
      arithmetic are more bug-prone than a named fn that emits the
      right fcmp directly. Option C (Eq Float with IEEE semantics)
      was rejected as clause-3 violation — it would reintroduce
      the silent NaN-comparison bug class that the existing
      Float-no-Eq/Ord design exists to prevent.

  (3) Codegen mechanism for primitive Eq/Ord instances: option β
      (always-call through dispatch, with `alwaysinline` attribute
      on intercept-emitted bodies as pre-emptive `-O0` mitigation,
      and option α — call-site intercept — held as the bench-gate
      fallback if measurements show real regression). Motivation:
      semantic honesty (class methods ARE calls, optimiser folds
      primitives uniformly at Int and User-Point alike), parity
      with `Show` (which is also full-call today with no
      intercept), smaller IR-shape contract for the existing pins.
      Under `-O2` the inliner deterministically collapses 2-
      instruction bodies; bench corpus runs `-O2`. Under `-O0`
      `alwaysinline` overrides the no-inline default, giving the
      same per-call IR shape as today. α was the initial-framed
      Recommendation but was scrutinised in user pushback ("spricht
      irgendwas FÜR β?") and after substantive re-balancing β
      came out coherent.

Grounding-check (ailang-grounding-check) PASS on re-dispatch — 11
load-bearing assumptions ratified by:
`crates/ail/tests/e2e.rs::eq_demo` + `::lit_pat_demo`,
`crates/ail/tests/eq_ord_e2e.rs::eq_ord_polymorphic_runs_end_to_end`
+ `::eq_ord_user_adt_runs_end_to_end` + `::eq_ord_user_adt_eq_intbox_hash_stable`,
`crates/ail/tests/eq_float_noinstance.rs::eq_at_float_fires_float_aware_noinstance`,
`crates/ail/tests/prelude_free_fns.rs::ne_at_int_produces_mono_symbol` (+4 siblings),
`crates/ailang-surface/tests/prelude_module_hash_pin.rs::prelude_parse_yields_canonical_hash`,
plus in-source `#[cfg(test)] mod tests` ratifiers in
`crates/ailang-check/src/lib.rs` (`eq_typechecks_at_int/bool/str/unit`,
`mq2_env_method_to_candidate_classes_built`) and
`crates/ailang-codegen/src/lib.rs` (`lower_eq_str_calls_strcmp_with_bytes_pointer`).
The `alwaysinline` LLVM attribute is correctly classified as new
feature-work commitment (no live occurrences today), not as a
load-bearing assumption about present state.

First grounding-check pass BLOCKED on one spec defect — the spec
mischaracterised `crates/ailang-core/src/desugar.rs:2414` as "a
separate desugar pass" when it is in fact a `#[cfg(test)] mod tests`
AST-literal scaffold. Fixed inline: §Architecture and
§Components/4 now enumerate only `build_eq` (desugar.rs:1099) as
the sole production desugar site; `desugar.rs:2414`, `lib.rs:6092`,
`lib.rs:6220` are framed as test-scaffold migrations alongside the
production change, not as desugar-pass work. Re-dispatch PASS.

Out of scope (tracked separately):
  - Deriving for Eq/Ord — `typeclasses.md:177` "No deriving"
    stands; instance bodies remain hand-written.
  - Parameterised-ADT instances (`instance Eq (List a)` etc.) —
    requires constraint-propagation infrastructure, belongs to
    Gitea #2 ("22c typeclass corpus expansion").
  - Eq/Ord for the `Ordering` ADT itself — no use case; consumed
    via match, not compared.
  - `and` / `or` as Builtins — north-star fixture uses
    `(if … … false)` for short-circuit conjunction; separate
    concern.

refs #1
2026-05-20 23:43:13 +02:00
Brummel 86dd7d7b60 audit + close: boehm-retirement CLEAN milestone close (no drift, bench 0/0/0, no tidy)
Single-iter milestone closing Gitea #4. Architect (drift review)
and the three bench-regression scripts all green.

Architect: CLEAN.

The architect walked design/INDEX.md and the relevant contracts
(memory-model, language-constraints, embedding-abi, honesty-rule,
frozen-value-layout, str-abi), read git log 7a42989..HEAD
--format=full for the three milestone commits, and inspected the
full diff. Specific scrutiny items reported clean:

  - All four by-design Boehm-grep exceptions are doing distinct
    load-bearing work, none is residual drift hiding behind a
    justification:
      (a) `crates/ailang-core/tests/docs_honesty_pin.rs` lines
          73-80 — the four absence-pins name the Boehm-zombie
          strings as scan targets; the pin cannot exist without
          naming what it forbids;
      (b) `crates/ail/tests/boehm_retirement_pin.rs` — the
          milestone-pin must literally invoke `--alloc=gc` to
          assert its CLI rejection;
      (c) `crates/ail/tests/embed_staticlib_alloc_guard.rs:3-5`
          — file-level doc-comment historical anchor explaining
          why the test's gc-arm went away (the rejection moved
          upstream to CLI parsing);
      (d) `design/contracts/embedding-abi.md:44-45` — contract
          historical clause naming the Boehm-retirement iter,
          which is the honesty-rule-compliant form for "this
          used to be a staticlib-guard rejection, see the
          milestone".

  - The 1.3× reframe in `design/models/rc-uniqueness.md` (the
    sentence in `## Memory model` plus the closure-chain ±15%
    block) is internally consistent: the regression gate is a
    *quantitative band*, scoped to the linear/tree/poly-ADT
    corpus with closure-chain on its own ±15% band. The
    RC-as-canonical commitment in `design/contracts/memory-model.md`
    is unchanged; no contract surface was softened.

  - No `ratifying-test` link in `design/INDEX.md` was orphaned
    by the test churn. All 21 referenced files exist; `e2e.rs`
    still exercises the Str-path, Eq/Ord, and the
    staticlib-bump-rejection substring pin that the
    `embedding-abi` and `str-abi` rows depend on.

  - Architect sweeps 1-5 all exit 0; the four-string absence-pin
    (`transitional Boehm`, `parity oracle`, `GC_malloc`, `libgc`)
    is the durable invariant that catches any future
    Boehm-narrative regrowth in the ledger.

Architect recommendation: carry on as planned.

Bench: 0/0/0.

  - bench/check.py exit 0 — 36 metrics, 0 regressed (throughput
    + latency). RC-over-bump ratios stable: bench_list_sum 2.74,
    bench_tree_walk 2.52, bench_closure_chain 4.23 (within the
    ±15% closure-chain band), bench_hof_pipeline 2.74,
    bench_compute_collatz 1.00, bench_list_sum_explicit 2.96.
    Latency: explicit @ rc median 217 µs / p99 245 µs;
    implicit @ rc median 306 µs / p99 477 µs.
  - bench/compile_check.py exit 0 — 24 metrics, 0 regressed.
    All `check_ms.*` (~2 ms) and `build_O0_ms.*` (~100 ms)
    workloads within tolerance vs. the baseline frozen by the
    prior milestone close.
  - bench/cross_lang.py exit 0 — 25 metrics, 0 regressed.
    RC-over-C ratios: bench_list_sum 1.48×, bench_tree_walk
    2.62×, bench_compute_collatz 0.98×, bench_list_sum_explicit
    1.30× — all within tolerance against the C reference
    corpus.

No baseline updates needed; no `--update-baseline` invoked. The
baseline regenerated *during* the iter (via the post-retirement
bench/run.sh column-shape change) was the source-of-truth here,
and the freshly-captured run matches it within self-comparison
range. The closing-audit bench is the steady-state confirmation.

Pipeline this milestone followed:

  brainstorm (one-question-at-a-time Q&A, three design forks
              resolved: bump survives, differential tests deleted,
              1.3× reframed; grounding-check PASS over the
              spec's seven load-bearing assumptions)
  -> planner (recon DONE_WITH_CONCERNS; nine open questions
              absorbed inline; self-review caught a
              bench/check.py header-sentinel + col-count
              planner-defect that was paired into Task 5)
  -> implement (DONE 10/10; orchestrator end-report flagged five
                concerns all absorbed into iter commit body:
                clap-allowlist corollary, iter17a witness update,
                baseline write_new_baseline gc-fallback,
                spec §6 grep wording-vs-reality drift, bench
                per-run variance ±1-5%)
  -> audit (CLEAN, no tidy, no [low]-priority forward-fix)

Closing Gitea #4. The `closes #4` trailer lives on the iter
commit `14a91f0` and fires on push.

Spec: docs/specs/2026-05-20-boehm-retirement.md
Plan: docs/plans/boehm-retirement.1.md
2026-05-20 20:57:01 +02:00
Brummel 14a91f0ae5 iter boehm-retirement.1 (DONE 10/10): retire the transitional Boehm GC backend
Closes Gitea #4. Removes the Boehm-Demers-Weiser conservative GC
backend wholesale across six layers in one atomic iteration. After
this iter, `AllocStrategy` has two variants (`Rc`, `Bump`),
`--alloc=gc` is rejected at CLI parse with `unknown --alloc value`,
the libgc link arm is gone, and the design ledger describes RC
(canonical) + bump (raw-alloc bench-floor) as the only allocators.

Layer-by-layer summary:

  CLI surface — `crates/ail/src/main.rs`:
    `parse_alloc_strategy` arm `"gc" => Ok(AllocStrategy::Gc)`
    removed; error wording updated to `(expected `rc` or `bump`)`;
    clap-derive `value_parser = ["gc","bump","rc"]` allowlist on
    BOTH `Build` and `Run` subcommands DROPPED so that
    `parse_alloc_strategy` remains the sole gatekeeper for the
    unknown-value diagnostic (otherwise clap shadows the runtime
    diagnostic with `invalid value 'gc' for '--alloc'`, which would
    miss the milestone-pin's stderr substring check). The
    `default_value = "rc"` stays.

  Codegen — `crates/ailang-codegen/src/lib.rs`:
    `AllocStrategy::Gc` variant + `Default` derive removed (no
    caller of `AllocStrategy::default()` existed in the workspace,
    so the trait derivation was dead). `fn_name` (spec called it
    `runtime_alloc_fn` loosely; actual identifier is `fn_name`)
    drops the `Gc => "GC_malloc"` arm. `lower_workspace` and
    `lower_workspace_staticlib` defaults flip from `Gc` to `Rc`.
    In-source negative-complement codegen test (mod tests, lib.rs:3571ff)
    retargets from `AllocStrategy::Gc` to `AllocStrategy::Bump`
    (bump also doesn't emit per-type drop fns; the test's semantic
    "no drop fns under non-RC" is preserved).

  Link branch — `crates/ail/src/main.rs:2389ff`:
    The `match strategy { AllocStrategy::Gc => { ... cmd.arg("-lgc"); ... } }`
    arm and its libgc-link block are entirely gone. The surviving
    match exhausts on `Bump` and `Rc` (Rust's exhaustiveness check
    confirms; no `error[E0004]`). Staticlib-guard diagnostic
    rewritten to drop the "shared Boehm collector" phrasing while
    preserving the prefix `staticlib (swarm) artefact is RC-only`
    verbatim (the surviving `staticlib_bump_is_rejected` test
    depends on that substring).

  Test suite — 3 pure-differential e2e tests deleted
    (`gc_handles_recursive_list_construction`,
    `alloc_rc_produces_same_stdout_as_gc`,
    `alloc_rc_matches_gc_on_std_list_demo`); 9 RC-feature tests
    stripped of their `stdout_gc` build call and differential
    `assert_eq!(stdout_gc, stdout_rc, ...)` (absolute
    `assert_eq!(stdout_rc.trim(), "<n>")` pin retained as
    correctness oracle); `staticlib_gc_is_rejected` deleted; new
    milestone-pin `crates/ail/tests/boehm_retirement_pin.rs`
    asserts `ail build --alloc=gc` exits ≠ 0 with stderr containing
    `unknown --alloc value` and `\`gc\``; `examples/gc_stress.ail`
    fixture deleted (no remaining references).

    Implementer expansion (not in plan): `iter17a_local_box_alloca`
    (in `e2e.rs`) carried an IR-shape assertion against
    `@GC_malloc`-absence as the witness for non-escaping
    allocation. After the Task-2 codegen default flip, the witness
    shifts to `@ailang_rc_alloc`-absence in escape-targeted
    positions; assertion + doc-comment updated. Property
    protected ("no heap allocation in non-escaping contexts") is
    unchanged; only the named allocator shifts.

  Bench harness — `bench/run.sh` 9→6 column compaction
    (workload + bump(s) + rc(s) + rc/bump + bump RSS + rc RSS);
    gc-arm `bench_latency_implicit_gc` build call + harness
    invocation dropped from latency block; header comment reframed
    from "GC-overhead bench harness" to "RC-overhead bench
    harness"; "Decision 10's Boehm-retirement target (1.3x)"
    rewording to "RC-overhead-vs-bump bench-health regression gate".

    `bench/check.py:62` header-sentinel changes from
    `"gc(s)" in line` to `"bump(s)" in line`; column-count check
    at `:72` flips from `!= 9` to `!= 6`; per-workload field set
    drops `gc_s`/`gc_over_bump`/`gc_rss_kb`; `ARM_LABEL_TO_KEY`
    drops the `"implicit @ gc": "implicit_at_gc"` entry.
    `bench/baseline.json` regenerated via `--update-baseline`.

    Implementer note (planner-defect): `write_new_baseline`
    iterated over the *existing* baseline's metric list when
    emitting the regenerated file, so even after parser-level
    `gc_*` removal, the fallback emitted them back into the JSON.
    Scrubbed post-update; the cleaner fix (have
    `write_new_baseline` emit only keys present in
    `parsed_throughput[workload]`) is a follow-up if the script
    becomes load-bearing for further allocator changes.

  Design ledger — `design/models/rc-uniqueness.md` excises the
    `## Dual allocator — RC canonical, Boehm parity oracle`
    section and the `Boehm-Demers-Weiser conservative GC` choice
    block + rationale + trade-offs; the per-fn-alloca section
    generalises Boehm-specific language to allocator-agnostic;
    the memory-model section's `## Choice.` paragraph reframes the
    1.3× target from "Boehm-retirement gate" to "bench-health
    regression gate".

    `design/models/pipeline.md` drops the `--alloc=gc → links libgc`
    arm of the pipeline diagram and replaces it with
    `--alloc=bump → links bump-floor`; the accompanying prose
    rewrites accordingly.

    `design/contracts/scope-boundaries.md` rewrites the
    "Memory management via Boehm conservative GC" bullet to
    describe RC + per-fn-arena present-tense; the dead reference
    to `examples/gc_stress.ail.json` (file never existed; the
    fixture only ever had a `.ail` form, deleted by this iter) is
    dropped along with the `examples/std_list_stress.ail.json`
    reference whose purpose was Boehm-only soak testing.
    `:67`'s `@printf` / `@GC_malloc` parenthetical updated.

    `design/contracts/memory-model.md:232` drops the
    "leaks like the pre-Boehm era" phrase; the RC inc/dec
    instrumentation is wired up, so the "until then" conditional
    that referenced pre-Boehm is closed.

    `design/contracts/embedding-abi.md:42-44` rewrites the
    staticlib-guard prose to drop the `--alloc=gc` clause (gc is
    now a CLI-parser-level unknown-value, not a staticlib-guard
    rejection) and reframe the swarm-safety justification around
    `--alloc=bump` (leak-only bench instrument) rather than the
    historical Boehm collector.

  Honesty pin — `crates/ailang-core/tests/docs_honesty_pin.rs`
    inverts the polarity: the present-tense Boehm-anchor assertion
    on `pipeline.md` (`:116-117`) is deleted, and four
    absence-pins are added to `design_md_has_no_wunschdenken`
    against the Boehm-zombie strings `transitional Boehm`,
    `parity oracle`, `GC_malloc`, `libgc`. The
    `design_corpus()` already includes `rc-uniqueness.md` so no
    path-list change was needed for the new pins to scan.

    `crates/ailang-core/tests/design_index_pin.rs:166` drops the
    `"pre-Boehm"` token from the protected-exception comment list
    (the phrase no longer appears in `memory-model.md` after this
    iter, so the exception is dead).

  Runtime docs — `runtime/bump.c`, `runtime/rc.c`, `runtime/str.c`
    header comments scrubbed of Boehm/`GC_malloc`/`libgc`
    references. `bump.c`'s function signature description still
    documents `void *bump_malloc(size_t)` as the bench-floor
    allocator interface, but no longer cross-references libgc.

  Example fixtures — `examples/bench_latency_implicit.ail`,
    `bench_latency_explicit.ail`, `escape_local_demo.ail`,
    `reuse_as_demo.ail`, `rc_pin_recurse_implicit.ail` doc-comment
    headers scrubbed of `--alloc=gc` / Boehm references. The
    `.ail` surface (AST) is untouched in every case; round-trip
    invariant holds (`cargo test -p ailang-surface --test round_trip`
    green).

  Skill / agent prompts — `skills/audit/agents/ailang-bencher.md`
    rewritten to use an RC-vs-bump worked example pattern for the
    hypothesis-driven bench tutorial, replacing the recurring
    "RC vs Boehm under heap pressure" example.
    `skills/implement/agents/ailang-implementer.md` Decision-10 /
    Boehm references replaced with present-tense RC-commitment
    framing.

  IR snapshots — the 5 checked-in snapshots
    (`crates/ail/tests/snapshots/{hello,list,max3,sum,ws_main}.ll`)
    regenerated via `UPDATE_SNAPSHOTS=1 cargo test -p ail --test
    ir_snapshot`. Each previously contained
    `declare ptr @GC_malloc(i64)` and (for `list.ll`) a `call ptr
    @GC_malloc(...)` invocation; post-flip the snapshots contain
    `declare ptr @ailang_rc_alloc(i64)` plus the rc inc/dec runtime
    declarations.

Spec-vs-acceptance addendum (caught at orchestrator end-report,
absorbed here rather than in a follow-up spec edit): spec §6
acceptance criteria said "Boehm-grep returns matches ONLY in
docs_honesty_pin.rs". The plan itself prescribed historical Boehm
references in 3 additional files: (a) the new milestone-pin
`boehm_retirement_pin.rs` (must literally invoke `--alloc=gc` to
assert its rejection), (b) `embed_staticlib_alloc_guard.rs` file
doc-comment historical note ("`--alloc=gc` no longer exists as a
CLI value"), (c) `embedding-abi.md:44-45` contract historical
clause ("see the Boehm-retirement iter"). All three are
prescribed; the spec's grep wording was too narrow. The four
absence-pins in `docs_honesty_pin.rs` catch the actual zombies
(Boehm-narrative re-emerging in the design ledger), which is the
substantive intent the spec was aiming at — the four extra
documented-by-design exceptions are the cost of having an
explicit milestone-pin and contract-level historical anchors.

Net delta:
  - 32 files modified, 2 new (boehm_retirement_pin.rs + stats),
    1 deleted (gc_stress.ail);
  - workspace tests: every binary `0 failed`. Pass-count delta:
    -3 net (4 e2e tests deleted, 1 new milestone-pin test added);
  - boehm-grep state: hits only in the four by-design exceptions
    documented above;
  - `bench/check.py` exit 0 against regenerated baseline;
  - CLI must-fail fixture: `ail build --alloc=gc examples/hello.ail`
    exits non-zero with stderr containing `unknown --alloc value`
    and `\`gc\``;
  - design ledger present-tense honest (Boehm-narrative gone from
    `rc-uniqueness.md` + `pipeline.md`; the few historical
    references in `embedding-abi.md` / `boehm_retirement_pin.rs` /
    `embed_staticlib_alloc_guard.rs` are explicit milestone-pins
    or contract anchors, not silent ledger residue).

Bench measurement variance noted: closure-chain and hof-pipeline
are ±1-5% jittery between runs; one regeneration flagged 2
metrics as `regressed` before a second run returned 0. The
captured baseline is within self-comparison range. Existing
per-metric tolerances absorb the jitter.

Stats file:
`bench/orchestrator-stats/2026-05-20-iter-boehm-retirement.1.json`.

closes #4
2026-05-20 20:51:53 +02:00
Brummel ad0a8d8786 plan: boehm-retirement.1 — atomic single-iter execution layout
Decomposes the Boehm-retirement spec (50dc478) into 10 bite-sized
tasks. Task 1 ships the RED milestone-pin first (TDD); Task 2 is
the compile-gated atomic core (drops `AllocStrategy::Gc` variant +
the `Default` derive + threads every caller in codegen + main.rs in
one cohesive task, so the workspace-build gate at the end is
satisfiable). Tasks 3-9 cover the scrub work that cascades off the
core (e2e suite, doc-comments, bench harness, design ledger,
honesty-pin inversion, example fixtures + agent prompts, IR
snapshots). Task 10 is the acceptance gate.

Plan-recon (DONE_WITH_CONCERNS) surfaced 9 spec gaps; the plan
absorbs all 9:
  - `runtime_alloc_fn` → actual identifier `fn_name` (no rename)
  - 5 checked-in IR snapshots (`hello.ll`, `list.ll`, `max3.ll`,
    `sum.ll`, `ws_main.ll`) regenerate via `UPDATE_SNAPSHOTS=1`
  - `design/contracts/scope-boundaries.md:67,114-127` Boehm block
    + `examples/gc_stress.ail.json` dead-reference
  - `design/contracts/memory-model.md:232` "pre-Boehm era" phrase
  - `bench/check.py:91,94` `implicit @ gc` arm label map
  - `crates/ailang-codegen/src/match_lower.rs:40,113`
    `@GC_malloc` doc-comments
  - `skills/implement/agents/ailang-implementer.md:95,185` Decision
    10/Boehm references
  - `AllocStrategy`'s `Default` derive (no callers — strip)
  - e2e.rs spec line numbers refer to gc-call lines, not test-fn
    header lines

Plan self-review caught a planner-grade defect that recon had
not flagged: `bench/check.py:62` hardcodes `"gc(s)" in line` as
the throughput-table header sentinel and `:72` hardcodes the
9-column count — both must shift in lockstep with the
`bench/run.sh` 9→6 column compaction; without the pair-edit,
`parse_throughput_table` silently returns an empty dict and the
"green" check pins nothing. Task 5 now carries Steps 4a-4c (header
+ count + field-set) as a coherent unit, plus the explicit pin in
the rationale.

Net delta on landing: 1 new test, ~30 modified files, 5
regenerated IR snapshots, 1 deleted fixture. Workspace passing-
test count shifts by `-3` (4 deletions − 1 new). The iter commit
will close Gitea #4 via the trailer.

refs #4
2026-05-20 20:24:22 +02:00
Brummel 50dc478ca5 spec: boehm-retirement — drop the transitional Boehm GC path
Resolves Gitea #4. Approach A (atomic single iteration). Six layers
in one cohesive cut: CLI `--alloc=gc` arm removed; codegen
`AllocStrategy::Gc` variant deleted with the default flipped to
`Rc`; libgc link branch removed; ~3 pure-differential e2e tests
plus the `gc_stress.ail` fixture deleted; ~9 RC-feature tests that
used GC-stdout as backstop lose only the differential assertion
(absolute fixed-stdout pin retained); M2 staticlib alloc-guard
drops its gc-arm (bump-arm preserved); design/models/rc-uniqueness
excises the Boehm-parity-oracle narrative; pipeline.md drops the
libgc pipeline-diagram arm; docs_honesty_pin flips from
Boehm-present-tense anchor to four absence-pins against
Boehm-zombie strings.

Three design forks were resolved with the user via brainstorm Q&A:

  (1) bump survives as bench-floor — `AllocStrategy::Bump`, the
      `--alloc=bump` CLI flag, and `runtime/bump.c` all stay; the
      enum keeps two variants (Rc, Bump); the codegen
      negative-complement test retargets from `AllocStrategy::Gc`
      to `AllocStrategy::Bump`. Bump's standing role is the
      raw-alloc bench-floor for RC-overhead measurement, not a
      production target.
  (2) the ~12 RC-vs-GC differential e2e tests are NOT all deleted
      wholesale — pure-differential ones (test name literally
      `*_matches_gc_*` or `*_same_stdout_as_gc`) are deleted; the
      RC-feature tests with GC as backstop keep their absolute
      stdout pin (`assert_eq!(stdout_rc.trim(), "<n>")`) and only
      lose the differential assertion. This nuance was added at
      spec time on top of the user's "delete the differential
      pattern" answer, because the differential was incidental to
      tests like `rc_box_drop` / `rc_list_drop_borrow` that pin
      drop-fn correctness under RC and would lose unrelated
      coverage if deleted entirely.
  (3) the 1.3× RC-over-bump number is retained in
      design/models/rc-uniqueness.md but reframed as a
      bench-health regression gate (not a Boehm-retirement gate);
      the closure-chain ±15% wider band is preserved analogously.

Grounding-check (ailang-grounding-check) PASS — 7 load-bearing
assumptions ratified, all spec-named paths and line numbers
verified (±2 lines), all proposed-for-removal strings present at
the spec-named locations. Assumption #7 (codegen default
currently Gc) is structurally self-evident: removing the variant
forces the default onto a surviving one by construction.

Out of scope, tracked separately:
  - Gitea #3 "Closure-pair slab / pool" — would tighten the
    closure-chain ±15% band; not blocked by retirement.
  - Any further `AllocStrategy::Bump` rework — still a
    single-variant bench instrument.

refs #4
2026-05-20 20:02:32 +02:00
Brummel 7a42989b34 iter nullary-app.1 (DONE 5/5): accept (app f) as canonical zero-arg call
Resolves Gitea #12 — the design fork from the 2026-05-20 /boss
session, surfaced independently by two prior fieldtests
(mut-local F3 + loop-recur `run_forever`).

Mechanics, layer by layer:

  Parser — `crates/ailang-surface/src/parse.rs:1322-1331` drops
    the 4-line `expected at least one argument` guard in
    `parse_app_body`. Grammar comments at file top change `term+`
    to `term*` on both `app-term` and `tail-app-term`; doc comment
    on `parse_app_body` changes `1+ args` to `0+ args`. Inline
    comment cites #12 and notes which layers below already accept
    empty args.

  Serde — `crates/ailang-core/src/ast.rs:419-425` annotates
    `Term::App.args` with `#[serde(default)]`, mirroring
    `Term::Ctor.args`'s *actual* attrs verbatim (no
    `skip_serializing_if`). Write behaviour unchanged — canonical
    JSON still emits `"args":[]` for nullary calls; only read
    behaviour gains tolerance for the `args` key being absent.
    Verified hash-impact-free at plan time:
    `grep -rn '"args":\[\]' examples/*.ail.json` returns zero
    matches, so no existing fixture's canonical bytes shift.

  Doc-honesty — `design/contracts/data-model.md`:
    (a) the `app` jsonc shape gains an explicit "args may be
        empty / read-tolerant" note;
    (b) the existing `ctor` comment claiming "args omitted when
        empty" was factually false (Ctor.args has no
        `skip_serializing_if`; a serde probe at plan time
        confirmed writes always emit `"args":[]`). Rewritten to
        describe the actual write/read asymmetry, with a
        cross-reference to the new `app` note.

  E2E pin — `crates/ail/tests/nullary_app_e2e.rs` +
    `examples/nullary_app_smoke.ail`. Defines `greet : fn()
    -> Unit !IO` and calls it as `(app greet)`; asserts stdout
    `"hello\n"`. RED→GREEN pin for the parser change AND the
    milestone-protecting E2E for nullary call surface going
    forward. Fixture literal is `"hello"` (no `\n`) because
    `io/print_str` lowers via `puts` which appends a newline —
    the plan body had a `"hello\n"` literal which would have
    yielded `"hello\n\n"`; the implementer caught and aligned to
    the canonical pattern in `examples/hello.ail` while writing
    the fixture, fix scoped to that single file.

Layers below parser untouched: typechecker's arity check at
`crates/ailang-check/src/lib.rs:3300` is `args.len() !=
params.len()` which is `0 != 0 → false` for nullary; codegen's
`args.iter().zip(sig.params.iter())` at
`crates/ailang-codegen/src/lib.rs:2410` is an empty loop;
LLVM `call @ail_<mod>_<name>()` with empty arglist is valid;
surface printer's arg-emit loop at
`crates/ailang-surface/src/print.rs:430-434` writes nothing for
empty args, producing exactly `(app f)`.

Verification: full workspace `cargo test --workspace --quiet`
green (0 failed across all crates); drift pins
`design_index_pin 5/5` + `design_schema_drift 8/8`; round-trip
`2/2`; new `nullary_app_e2e 1/1`. Stats file:
`bench/orchestrator-stats/2026-05-20-iter-nullary-app.1.json`.

closes #12
2026-05-20 18:57:14 +02:00
Brummel 7d8b3a3c10 plan: nullary-app.1 — accept (app f) as canonical zero-arg call form
Resolves the design fork in Gitea #12 (the bounce-back from the
2026-05-20 /boss session). User accepted both half-decisions:

  (a) the surface form `(app f)` becomes the canonical nullary
      call shape, dropping the "expected at least one argument"
      parser guard in `parse_app_body`;
  (b) `Term::App.args` mirrors the *actual* serde attrs of
      `Term::Ctor.args` — `#[serde(default)]` for read-tolerance,
      no `skip_serializing_if`, so writes always emit
      `"args":[]`.

The plan covers five small tasks: a RED→GREEN E2E (nullary user fn
called as `(app greet)`), the four-line parser-guard removal, the
one-attribute serde edit, and a doc-honesty fix on
`design/contracts/data-model.md` — the current "args omitted when
empty" comment on `Term::Ctor` is factually false relative to the
code (verified by a serde probe at plan time) and gets rewritten to
describe actual write/read behaviour while we are in the same jsonc
block. `design/contracts/honesty-rule.md` enforcement folded into
the same iter because leaving a stale comment behind in the block
we're modifying would be the exact failure mode the rule names.

Hash-impact verified at plan time: `grep -rn '"args":\[\]'
examples/*.ail.json` returns zero matches, so the new
`#[serde(default)]` on `App.args` is read-only behaviour change —
write side is unchanged, no existing fixture hash mutates. The
plan documents this explicitly under Task 3.2.

Two prior fieldtest specs already supplied the LLM-natural shape
evidence — `docs/specs/2026-05-15-fieldtest-mut-local.md` F3
(mut-local) and `docs/specs/2026-05-18-fieldtest-loop-recur.md`
spec_gap (`run_forever`) — so the feature-acceptance gate in
`design/contracts/feature-acceptance.md` is already passed; no
brainstorm phase needed for this iter.

refs #12
2026-05-20 18:52:22 +02:00
Brummel 70aad32e86 workflow(plan-recon): require compile-driven enumeration + existence table
Tightens `skills/planner/agents/ailang-plan-recon.md` against two
recon-contract failure modes documented in Gitea #9:

(a) For signature-change / enum-variant-add-remove / public-symbol-
    removal scopes, hand-listing of call sites or match arms has
    under-counted the blast radius four times across two milestones,
    each through a different site type (loop-recur.1 walker arms,
    loop-recur.2 cross-module synth callers, loop-recur.tidy implicit,
    remove-mut-var-assign.1 in-source `mod tests` fns + drift pin +
    `.ail.json` carve-outs + a non-enumerated E0599). The recon now
    MUST report a compile-driven enumeration (cargo check after a
    stub / exhaustive `git grep -nE` / `cargo expand`) as the
    authoritative code-site set; hand-listing is explicitly labelled
    "advisory". The compile-driven channel only covers compile-checked
    sites, so a separate sweep for drift pins, `.ail`/`.ail.json`
    fixtures, and design-doc references is required and reported
    under a "Non-compile-checked sites" section.

(b) Every path the spec names is now `ls`-verified before propagation
    into the brief. An existence claim feeding a recon is itself
    load-bearing — one prior recon brief inherited an unverified
    "X does not exist" claim (form_a.md, remove-mut-var-assign.1)
    and was caught only at milestone-close audit. The new "Spec-named
    path existence table" is mandatory for every dispatch (regardless
    of scope class); entries marked "not exists" must carry the exact
    `ls` / `git ls-files` command run, and only verified-not-exists
    entries may feed "Anchors not yet present".

Three new Common Rationalisations and three new Red Flags
calibrate against the named failure modes. The "Compile-driven
site set" / "Non-compile-checked sites" output sections may be
omitted for iterations that do not involve a signature / variant /
removal scope; the existence table is always required.

No language change; no code change. Agent-definition discipline
fix only.

closes #9
2026-05-20 18:26:54 +02:00
Brummel 13946219f5 fix(runtime): mirror surface .0-fallback in ailang_float_to_str
`runtime/str.c::ailang_float_to_str` now applies the `.0`-fallback
already enforced by the surface printer's `write_float_lit`
(crates/ailang-surface/src/print.rs lines 624-637): after the
existing `%g` snprintf, if `x` is finite and the rendered buffer
contains neither `.` nor `e`/`E`, append `.0` so a whole-valued
Float renders with Float-shaped text. The `isfinite(x)` fence
keeps `nan`/`inf`/`-inf` from matching the predicate and turning
into `nan.0`/`inf.0`. The 64-byte stack buffer's truncation guard
is extended by 2 bytes for the fallback path so the defensive
`abort()` discipline holds.

The alternative (document `%g`-without-fallback in the design/
ledger as a deliberate human-friendly contract) was rejected: the
language's stated ethos is machine-readability and round-trip
identity; the surface printer is the reference, runtime drift
from it is a defect not a feature.

Golden updates flow from the contract change. Three fixtures
encoded the old Int-shaped output for Float values:
- `crates/ail/tests/floats_e2e.rs`: `"4\n42\n-1.5\n"` →
  `"4.0\n42.0\n-1.5\n"` (1.5+2.5=4.0, int_to_float(42)=42.0).
- `crates/ail/tests/e2e.rs::mut_sum_floats_prints_55`: `"55"` →
  `"55.0"`. The accompanying doc comment used to canonicalise the
  bug ("`%g` strips the trailing `.0` — so the canonical stdout
  for Float 55.0 is `55`"); rewritten to reflect the new contract.
- `examples/mut_sum_floats.ail` docstring: same correction.

Two doc comments are updated where the post-fix contract differs
from the old text but the fixture golden does not change:
- `examples/float_to_str_smoke.ail` docstring (3.5 still renders
  as `3.5` because `.` is already present — the fallback does not
  fire).
- `crates/ail/tests/e2e.rs::float_to_str_smoke` doc comment (same
  observation, made explicit so the trip-wire's intent is clear).

Verified: `cargo test -p ail --test print_float_whole_e2e` green
(was RED at f19b0dd); `cargo test --workspace` 0 failed across
all crates and integration suites.

closes #7
2026-05-20 18:24:57 +02:00
Brummel f19b0dd860 test(runtime): RED — whole-valued Float must render with .0 suffix
Adds `examples/print_float_whole_smoke.ail` (single `(app print
2.0)`) and `crates/ail/tests/print_float_whole_e2e.rs`. The test
builds and runs the fixture through the public `ail build` CLI
and asserts stdout is `"2.0\n"`.

Currently FAILS with `left: "2\n"`, `right: "2.0\n"` — runtime
`ailang_float_to_str` uses libc `snprintf("%g", x)` which strips
trailing zeros, so a whole-valued Float prints as Int-shaped
text. The surface printer at
`crates/ailang-surface/src/print.rs::write_float_lit` (lines
624-637) already enforces the `.0`-fallback property; the runtime
drifts. This RED pins the desired symmetry.

The GREEN side will mirror the surface fallback in
`runtime/str.c::ailang_float_to_str` and update the
`floats_e2e.rs` golden (`"4\n42\n-1.5\n"` → `"4.0\n42.0\n-1.5\n"`)
plus the doc comments at `crates/ail/tests/e2e.rs` and in the
runtime/example files that documented the previous `%g`-without-
fallback contract.

The alternative (document `%g`-behaviour in design/ ledger §Float
semantics as a deliberate human-friendly contract) was rejected:
the language's stated ethos is machine-readability + round-trip
identity (CLAUDE.md §"AILang — a language for LLM authors"); the
surface printer is the reference, runtime drift from it is a
defect not a feature.

refs #7
2026-05-20 18:19:46 +02:00