Forward-fix on5b66de7, prompted by plan-recon for intrinsic-bodies.2. Two spec-vs-reality gaps in the .2 (migration + lock) sections, both caught before the .2 plan was written: 1. Count. The spec said "18 dummy bodies" migrate to (intrinsic). That conflated the INTERCEPTS entry count with authored prelude bodies. Reality: examples/prelude.ail carries 13 authored dummy bodies — the 7 Eq/Ord instance methods (eq Int/Bool/Str/Unit, compare Int/Bool/Str) + the 6 float_* free fns. The registry has 19 entries (18 legacy + the .1 `answer`); the other 6 are not prelude dummies. 2. The strict bijection cannot hold. lt__Int/le__Int/gt__Int/ge__Int/ ne__Int (5 INTERCEPTS entries) intercept the monomorphised __Int specialisations of the polymorphic free fns lt/le/gt/ge/ne, which carry REAL bodies in the prelude (ne = (app not (app eq x y)); the four ordering helpers are (match (app compare x y) ...)). Those bodies are honest and live — lowered for every non-Int instantiation; only the Int specialisation is intercepted for a faster direct icmp. They are an optimisation class, not a compiler-supplied-body class: no lie, no source body to replace, no (intrinsic) marker. A strict bijection over all INTERCEPTS entries would be red for these 5. Corrections: - § Architecture point 6: 13 authored prelude sites (named), with the 5 icmp-family + `answer` explicitly listed as non-prelude / not migrated and why. - § Architecture point 7: the pin splits the registry into intrinsic-backed (13 prelude markers + answer = 14) and optimisation-only (the 5 *__Int, an explicit documented allowlist). The bijection holds over the intrinsic-backed class only; the pin loads the workspace + monomorphises to recover mangled names for the marker direction. - § Architecture point 8: reframed from "dead-path removal" to "dead-path confirmation" — .1's intercept-by-name already bypasses the dummy body before lower_term sees it (committed reality at52ff873), so .2 confirms no path lowers an intercepted body and deletes stale comments; the .1 Term::Intrinsic escape-guards stay. - § Components + § Testing: the two moving hash pins named (prelude_module_hash_pin.rs; mono_hash_stability.rs's 6 eq/compare pins move, its 4 show pins do not); hash_pin.rs carries no prelude-derived hash. IR snapshots do not change. Grounding-check PASS on all five corrected .2 claims against the shipped .1 baseline (count, the 5 real-bodied helpers, the three hash pins' behaviour, the already-dead path, the bijection-pin pipeline reachability). No ail/ail-json/ll fenced block changed — parse-gate a documented no-op for this revision. The deeper observation this surfaced — that INTERCEPTS conflates two concepts (compiler-supplied bodies vs. optimisation of a real body) — is noted but NOT resolved here; splitting the registry is out of scope for intrinsic-bodies and would be its own milestone.
22 KiB
Intrinsic bodies — Design Spec
Date: 2026-05-29 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Give Form-A a way to say "this definition's body is supplied by
the compiler, not written in source" — and make that the single,
honest representation for every definition whose real implementation
lives in the codegen intercept registry (crates/ailang-codegen/src/intercepts.rs,
shipped in raw-buf.1).
Today every such definition carries a dummy body that never runs.
The prelude's eq Int instance method is
(module dummy_today
(fn eq_int_like
(doc "Real implementation is a codegen intercept; the body false never executes.")
(type (fn-type (params (con Int) (con Int)) (ret (con Bool))))
(params x y)
(body false)))
The (body false) (and the eighteen siblings like it across the
prelude) parses and type-checks, but codegen discards it and emits
the intercept instead. The body is a structural lie: a reader who
trusts the source is wrong about what runs. This is a standing
infraction of the honesty rule (design/contracts/0007-honesty-rule.md),
which the language has carried silently since the operator-routing
milestone.
The lie is not merely cosmetic. It blocks the raw-buf milestone:
a polymorphic kernel-tier function such as RawBuf's get : RawBuf a -> Int -> a
needs a placeholder body that produces a value of type a — and
AILang has no value of polymorphic type. There is no honest
expression to write. The dummy-body requirement makes the function
structurally impossible to author, which is what BLOCKED raw-buf.2
(see git log 4ad003d..647121c and the discarded .2 working tree).
The fix is a new Form-A surface affordance, (intrinsic), that
replaces the (body ...) clause for compiler-supplied definitions.
This is the same construct every systems language has: LLVM declare,
Rust extern "rust-intrinsic" / #[rustc_intrinsic], Haskell
foreign import prim, C compiler builtins. The intercept registry
is AILang's compiler-supplied-body table; (intrinsic) is the
surface declaration of membership in it.
Architecture
Three landing points, two iterations.
Iteration intrinsic-bodies.1 — the mechanism.
- AST. A new leaf term variant
Term::Intrinsicis the body of a compiler-supplied definition.FnDef.bodyandTerm::Lam.bodykeep their existing types (Term/Box<Term>) — they are not made optional. A definition is intrinsic iff its body isTerm::Intrinsic. The variant is strictly additive: a fixture that does not use it serialises bit-identically (the established additive-term-variant pattern —Term::New,Term::Loop,Term::Recur,Term::Clone,Term::ReuseAsall landed this way,design/contracts/0002-data-model.md). This is the load-bearing representation choice: it puts "this body is compiler-supplied" at the body position where it semantically belongs, keeps the ~150-sitebody-read/construct surface across six crates untouched (only exhaustivematch-on-Termarms gain a case), and makes the illegal "body AND intrinsic" state unrepresentable rather than rejected — a body is eitherTerm::Intrinsicor a real term, never both. (The rejected alternative,body: Option<Term>+ anintrinsic: boolflag, splits one fact across two fields, admits that illegal state, and breaks everybodyconsumer in the workspace.) - Form-A surface. For a top-level fn, the parser accepts
(intrinsic)as a sibling clause where(body ...)would go and maps it tobody = Term::Intrinsic; the printer emits(intrinsic)in that slot when the body isTerm::Intrinsic. For a lambda,(intrinsic)sits at the positional body slot (where the body term goes today) and parses toTerm::Intrinsic; the printer emits it there. Round-trip (parse ∘ print = id) holds by the standard fixture gate. The fn-vs-lam surface asymmetry mirrors the existing one (a fn carries a named(body X)clause; a lambda carries its body positionally) —(intrinsic)follows each form's existing body placement. - Checker. A definition whose body is
Term::Intrinsictype-checks against its signature only — there is no body term to infer. One new reject: an intrinsic definition outside a(kernel)-tier module or the prelude is rejected withintrinsic-outside-kernel-tier. (There is nointrinsic-with-bodyreject — the representation makes that state impossible, see point 1.) - Codegen. A definition whose body is
Term::Intrinsicroutes throughintercepts::lookup; if no intercept is registered for its mangled name, codegen emits the existing deferral diagnostic. The current "lower the body" path is not taken for these definitions —lower_termis never called on aTerm::Intrinsicbody (and aTerm::Intrinsicreachinglower_termthrough any other path is a codegen-internal error, since an intrinsic body must be consumed by the intercept route). - Ratifier. One throwaway intrinsic in the
kernel_stubfixture — a nullaryanswer : () -> Intwhose intercept emitsret i64 42— drives the mechanism end-to-end (parse → check → codegen → run).kernel_stubalready exists precisely to ratify kernel-tier mechanisms.
Iteration intrinsic-bodies.2 — the migration + the lock.
-
Prelude migration. The thirteen authored dummy bodies in
examples/prelude.ailswap their inner(body <dummy>)for(intrinsic): the seven Eq/Ord instance methods (eqfor Int/Bool/Str/Unit,comparefor Int/Bool/Str) and the sixfloat_*free fns (float_eq/ne/lt/le/gt/ge). These are exactly theINTERCEPTSentries that today carry a placeholder body a reader would mistake for the implementation.Not migrated, and why (the count reconciliation — the registry has nineteen entries, not eighteen-all-dummies):
lt__Int,le__Int,gt__Int,ge__Int,ne__Int(5) intercept the monomorphised__Intspecialisations of the polymorphic free fnslt/le/gt/ge/ne, which carry real bodies in the prelude ((match (app compare x y) …),(app not (app eq x y))). Those bodies are honest and live — they are lowered for every non-Intinstantiation; only theIntspecialisation is intercepted for a faster directicmp. These are an optimisation class, not a compiler-supplied-body class: there is no lie to fix and no source body to replace, so they get no(intrinsic)marker.answer(1) is the.1ratifier, already(intrinsic)inSTUB_AIL— not a prelude site.
So: 13 prelude migrations + 5 optimisation-class entries + 1 already migrated = 19
INTERCEPTSentries. -
Hard-lockstep pin. The registry splits into two classes: an entry is intrinsic-backed when a
(intrinsic)marker in the loaded workspace resolves to its mangled name (the 13 prelude migrations +answer= 14), or optimisation-only when it intercepts a real-bodied fn's specialisation (the 5*__Inticmp family). The pin asserts a bijection over the intrinsic-backed class: every workspace(intrinsic)marker has exactly oneINTERCEPTSentry, and everyINTERCEPTSentry not on the explicit optimisation-only allowlist has exactly one workspace marker. The optimisation-only set is an explicit, documented allowlist in the pin (the 5 names); adding a registry entry that is neither marker-backed nor allowlisted is a red test, as is an intrinsic marker with no entry. This extends theregistry_contains_all_legacy_armspin from raw-buf.1 from a one-directional name check to a two-directional source↔registry bijection. -
Dead-path confirmation.
.1already routes every intercepted def by name (try_emit_primitive_instance_bodyconsultsintercepts::lookupon the fn name before the body is inspected), so a prelude dummy body is bypassed before it could reachlower_term— the dummy is already dead, not lowered-then-discarded. After migration the dummy is gone entirely (replaced byTerm::Intrinsic). This point is therefore a verification, not a removal: confirm no codegen path lowers an intercepted body, and delete now-stale comments that reference the discarded dummy. TheTerm::Intrinsicinternal-error guards inlower_term/ the synth walker (added in.1) STAY — they guard against an intrinsic body escaping its definition slot, which is a different concern.
Concrete code shapes
North-star: what a kernel author writes (proposed surface)
The surface this milestone delivers. These blocks are labelled
scheme (not ail) on purpose — the (intrinsic) attribute does
not parse against the pre-feature tool, so they are proposals,
not ratified current behaviour. (Verified: see the must-fail
transcript below.)
The raw-buf north-star — a polymorphic kernel-tier function with
no honest body, the case that motivated the milestone:
(fn get
(doc "Indexed read. Codegen intercept get__RawBuf__<T> emits a getelementptr + load.")
(type (forall (vars a) (fn-type (params (borrow (con RawBuf a)) (con Int)) (ret (con a)))))
(params b i)
(intrinsic))
The prelude eq Int instance method after migration (the lie, fixed).
The marker sits on the lambda body; the lambda's typed shell
(params/ret) stays — it is the method's local signature, and
intrinsic drops the body, not the signature (see the rationale under
"Implementation shape" below):
(instance
(class Eq)
(type (con Int))
(doc "Eq Int. Codegen intercept eq__Int emits icmp eq i64 with alwaysinline.")
(method eq
(body (lam (params (typed x a) (typed y a)) (ret (con Bool)) (intrinsic)))))
The intrinsic-bodies.1 ratifier — the throwaway smoke intrinsic in
the kernel_stub fixture:
(fn answer
(doc "Ratifies the intrinsic mechanism end-to-end. Intercept answer emits ret i64 42.")
(type (fn-type (params) (ret (con Int))))
(params)
(intrinsic))
Current-state evidence (parses today)
The dummy-body lie exactly as the prelude carries it now — a body that parses, type-checks, and never runs:
(module dummy_today
(fn forty_two
(doc "Real implementation is a codegen intercept. The body 0 never executes.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body 0)))
Must-fail evidence (rejected today)
(intrinsic) is not yet a surface attribute, so the proposed forms
above are rejected by the pre-feature tool. This is the baseline the
milestone moves off — the parser reject is what intrinsic-bodies.1
turns into an accept (inside kernel-tier / prelude) and a different
reject (intrinsic-outside-kernel-tier, for user modules):
$ cat intr.ail
(module intr
(fn new (type (fn-type (params (con Int)) (ret (con Int)))) (params n) (intrinsic)))
$ ail check intr.ail
error: [surface-parse-error] parse error in fn-def: unknown fn attribute
`intrinsic`; expected `doc`, `export`, `suppress`, `type`, `params`,
or `body` at byte 86
Implementation shape (secondary — the AST delta)
The change is one new leaf Term variant. Exact bytes are the
planner's job; this fixes the shape only.
Term::Intrinsic (crates/ailang-core/src/ast.rs):
// new leaf term — the body of a compiler-supplied definition.
// Strictly additive (no skip_serializing_if needed; a fixture that
// does not carry it hashes bit-identically — the same additive-variant
// pattern as Term::New / Term::Loop / Term::Recur / Term::Clone).
// Never reduces to a value: codegen consumes it via intercepts::lookup,
// the typechecker treats a def with this body as signature-only.
{ "t": "intrinsic" }
FnDef.body (currently Term) and Term::Lam.body (currently
Box<Term>) are unchanged in type — they simply may now hold
Term::Intrinsic. A top-level intrinsic fn is
FnDef { body: Term::Intrinsic, .. }; an intrinsic instance-method
lambda is Term::Lam { body: Box::new(Term::Intrinsic), .. }. "Is
this definition intrinsic?" is matches!(body, Term::Intrinsic).
The marker sits at the body position — for a top-level fn that is the fn body; for an instance method that is the lambda body, not the method as a whole — and this placement is load-bearing, not incidental.
The reason is local reasoning (design/INDEX.md § Goal: "every
definition carries its full type and effect set, so a signature can
be trusted without reading the body"). An intrinsic is a signature
without a body, never nothing — exactly as LLVM declare i64 @llvm.foo(i64, i64), Rust extern "rust-intrinsic" { fn foo(x: i32) -> i32; }, and Haskell foreign import prim all keep the full
signature and drop only the body. For a top-level fn the signature is
the (type ...) clause, which stays beside the (intrinsic) marker.
For an instance method the signature-bearing structure is the lambda
itself: its (params (typed x a) (typed y a)) and (ret (con Bool))
are the parameter types and return type, readable at the definition
site. Hoisting the marker to the method ((method eq (intrinsic)))
would erase that local signature — a reader would have to climb to
the Eq class declaration and substitute a := Int mentally to
recover it. So the marker is the lambda's body (Term::Intrinsic),
leaving the lambda's typed shell — the local signature — in place. The
mono pass (crates/ailang-check/src/mono.rs::synthesise_mono_fn)
already reads the method's parameter names and inner body out of this
lambda (Term::Lam { params, body, .. } => (params, *body)); with the
inner body being Term::Intrinsic, the synthesised FnDef carries
body: Term::Intrinsic and is itself intrinsic. The destructure
itself is unchanged — only what the inner body is differs.
Hash-stability note: Term::Intrinsic is a new enum variant, not a
new field, so a fixture that does not use it serialises exactly as
before — the same mechanism that kept hashes stable when Term::New,
Term::Loop, and Term::Recur were added. The design_schema_drift.rs
schema mirror, the schema_coverage.rs variant corpus (a fixture must
now exercise Term::Intrinsic), and the 0002-data-model.md contract
all move in the same iteration as the variant.
Components
| Component | Iteration | Change |
|---|---|---|
crates/ailang-core/src/ast.rs |
.1 | New leaf variant Term::Intrinsic. FnDef.body / Lam.body types unchanged. |
crates/ailang-core canonical/hash/visit |
.1 | Exhaustive match-on-Term arms gain a Term::Intrinsic case (leaf, no sub-terms to walk). Schema-coverage corpus extended to exercise it. |
crates/ailang-surface (lex/parse/print) |
.1 | (intrinsic) parsed + printed: as a body-slot clause in fn-def, at the positional body slot in lam; maps to/from Term::Intrinsic; round-trip gated. |
crates/ailang-check/src/lib.rs |
.1 | A def whose body is Term::Intrinsic checks signature-only; rejects intrinsic outside kernel-tier/prelude (intrinsic-outside-kernel-tier). No intrinsic-with-body reject — the representation forbids that state. |
crates/ailang-check/src/mono.rs |
.1 | synthesise_mono_fn lambda-destructure unchanged; an inner Term::Intrinsic body flows through to an intrinsic synthesised FnDef. |
crates/ailang-codegen/src/lib.rs |
.1 | A def whose body is Term::Intrinsic routes through intercepts::lookup; lower_term is never called on it. |
crates/ailang-kernel-stub + ailang-surface parse hop |
.1 | answer smoke intrinsic added to the stub fixture; its intercept registered. |
examples/prelude.ail |
.2 | 13 authored dummy bodies → (intrinsic) (7 Eq/Ord instance methods + 6 float_* fns). The 5 *__Int icmp-family and answer are NOT prelude sites. |
crates/ailang-codegen/src/intercepts.rs (pin) |
.2 | registry_contains_all_legacy_arms upgraded to a bijection over the intrinsic-backed class + an explicit 5-name optimisation-only allowlist. Pin loads the workspace + monomorphises to recover mangled names for the marker direction. |
crates/ailang-surface/tests/prelude_module_hash_pin.rs |
.2 | Rebaseline the prelude module hash (13 bodies change). |
crates/ail/tests/mono_hash_stability.rs |
.2 | Rebaseline the 6 post-mono eq__*/compare__* def-hashes (body flips to Term::Intrinsic). The 4 show__* pins do NOT move. |
| codegen dummy-body path | .2 | Already dead post-.1 (intercept-by-name precedes body inspection); .2 confirms + deletes stale comments, no behavioural removal. |
design/contracts/0002-data-model.md |
.1 | New { "t": "intrinsic" } Term entry; note in fn/lam that the body may be Term::Intrinsic. |
design/contracts/0007-honesty-rule.md |
.2 | Edit only if it names the prelude dummies; recon found it is a general rule that may not reference them — flag honestly, no forced edit. |
Data flow
Authoring → parse → AST → check → codegen, unchanged in topology;
Term::Intrinsic is recognised at three of those stations:
- Parse.
(intrinsic)in a fn body slot or a lambda body slot produces aTerm::Intrinsicbody. There is no "both body and intrinsic" surface form to reject — the grammar offers one body slot, and(intrinsic)either fills it or it does not. - Check. A def whose body is
Term::Intrinsic: validate the signature, skip body inference. Enforce the kernel-tier/prelude scope. The module'skernel: trueflag (or prelude identity) is already available to the checker via the loaded workspace. - Codegen. A def whose body is
Term::Intrinsic:intercepts::lookup(mangled_name). Hit → emit the intercept. Miss → the existing deferral diagnostic (same one raw-buf.2 reuses for unregisteredRawBufops).
Error handling
| Condition | Diagnostic | Station |
|---|---|---|
(intrinsic) in a non-kernel, non-prelude module |
intrinsic-outside-kernel-tier |
check |
| Intrinsic def with no registered intercept | existing codegen deferral | codegen |
The first row 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. (There is deliberately no
"body-and-intrinsic" row — Term::Intrinsic is a body, so a def
either has it or has a real body, never both. The illegal state is
unrepresentable, not diagnosed.)
Testing strategy
intrinsic-bodies.1:
- Round-trip: a kernel-tier fixture carrying
(intrinsic)on both a top-level fn and a lambda parses → prints → re-parses to canonical-byte equality (rides the existinground_trip.rscorpus gate). - Check accept: the
kernel_stubanswerintrinsic checks clean. - Check reject (scope): a user module with an
(intrinsic)fn is rejected withintrinsic-outside-kernel-tier. - E2E:
answerbuilds and runs, exit/print observing42— the mechanism works from source to native. - Schema drift:
design_schema_drift.rsgreen against the new0002-data-model.md;schema_coverage.rsobservesTerm::Intrinsicin the fixture corpus.
intrinsic-bodies.2:
- Hard-lockstep pin: bijection over the intrinsic-backed class. Add an
intrinsic marker with no
INTERCEPTSentry → red; add a non-allowlistedINTERCEPTSentry with no marker → red; drop either → red. The 5-name optimisation-only allowlist is explicit in the pin; the pin loads the workspace, monomorphises, and walks post-monoFnDefbodies forTerm::Intrinsicto recover mangled names for the marker direction. - All pre-existing E2E ratifiers stay green (the eq/compare/float
smoke suite named in raw-buf.1's commit body:
eq_primitives_smoke_compiles_and_runs,compare_primitives_smoke_prints_1_2_3_thrice,float_compare_smoke_prints_true_true_false,eq_ord_polymorphic_runs_end_to_end) — the migration is behaviour-preserving (the intercepts emit the same IR; only the now-dead source body changes). - Hash rebaselines (both move; both recorded old→new in the commit
body):
prelude_module_hash_pin.rs(prelude module hash; 13 bodies change) andmono_hash_stability.rs(the 6 post-monoeq__*/compare__*def-hashes flip toTerm::Intrinsic; the 4show__*pins do NOT move). IR snapshots do NOT change (no prelude instance method is codegen'd into the user-program snapshots).
Acceptance criteria
Applied prospectively (design/contracts/0004-feature-acceptance.md):
- An LLM author naturally reaches for it. A kernel author writing
RawBuf.gethas no honest body to write — there is no value of typea. Faced with the dummy-body requirement, the natural move is to declare "the compiler supplies this", which is exactly(intrinsic). The north-star block above is that program; it is the empirical evidence, not a prose assertion. - Measurably improves correctness / removes redundancy. Closes a standing honesty-rule infraction (eighteen bodies that lie about what runs); removes the dead body-lowering path for intercepted defs; removes the structural impossibility blocking polymorphic kernel-tier functions.
- Reintroduces no eliminated failure class. Machine-readability,
local reasoning, provability, hallucination-robustness all hold or
improve: a reader of an
(intrinsic)def now knows from the source alone that the body is compiler-supplied, instead of having to read the codegen table to discover the source was lying.
Out of scope: a user-facing plugin API for registering custom
intercepts (the scope guard explicitly forbids (intrinsic) in user
modules); any change to the intercept dispatch mechanism shipped in
raw-buf.1; the raw-buf redo itself (a separate milestone that
consumes this one).