Files
AILang/docs/specs/0055-intrinsic-bodies.md
T
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

18 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.

  1. AST. A new leaf term variant Term::Intrinsic is the body of a compiler-supplied definition. FnDef.body and Term::Lam.body keep their existing types (Term / Box<Term>) — they are not made optional. A definition is intrinsic iff its body is Term::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::ReuseAs all 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-site body-read/construct surface across six crates untouched (only exhaustive match-on-Term arms gain a case), and makes the illegal "body AND intrinsic" state unrepresentable rather than rejected — a body is either Term::Intrinsic or a real term, never both. (The rejected alternative, body: Option<Term> + an intrinsic: bool flag, splits one fact across two fields, admits that illegal state, and breaks every body consumer in the workspace.)
  2. Form-A surface. For a top-level fn, the parser accepts (intrinsic) as a sibling clause where (body ...) would go and maps it to body = Term::Intrinsic; the printer emits (intrinsic) in that slot when the body is Term::Intrinsic. For a lambda, (intrinsic) sits at the positional body slot (where the body term goes today) and parses to Term::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.
  3. Checker. A definition whose body is Term::Intrinsic type-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 with intrinsic-outside-kernel-tier. (There is no intrinsic-with-body reject — the representation makes that state impossible, see point 1.)
  4. Codegen. A definition whose body is Term::Intrinsic routes through intercepts::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_term is never called on a Term::Intrinsic body (and a Term::Intrinsic reaching lower_term through any other path is a codegen-internal error, since an intrinsic body must be consumed by the intercept route).
  5. Ratifier. One throwaway intrinsic in the kernel_stub fixture — a nullary answer : () -> Int whose intercept emits ret i64 42 — drives the mechanism end-to-end (parse → check → codegen → run). kernel_stub already exists precisely to ratify kernel-tier mechanisms.

Iteration intrinsic-bodies.2 — the migration + the lock.

  1. Prelude migration. All eighteen dummy bodies (the eq__*, compare__*, and lt|le|gt|ge|ne__* instance methods enumerated by intercepts::INTERCEPTS) swap their (body <dummy>) for (intrinsic).
  2. Hard-lockstep pin. A test asserts a bijection between INTERCEPTS entries and (intrinsic) definitions reachable in the loaded workspace (prelude + kernel-tier modules): every registry entry has exactly one intrinsic marker and vice versa. Drift in either direction is a red test. This extends the registry_contains_all_legacy_arms pin from raw-buf.1 from a one-directional name check to a two-directional source↔registry bijection.
  3. Dead-path removal. The dummy-body-execution path for intercepted definitions is removed; nothing emits it once the migration lands.

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 18 dummy bodies → (intrinsic).
crates/ailang-codegen/src/intercepts.rs (pin) .2 registry_contains_all_legacy_arms upgraded to a source↔registry bijection pin.
codegen dummy-body path .2 Removed.
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 The prelude-dummy infraction is closed; note its resolution if the contract references it.

Data flow

Authoring → parse → AST → check → codegen, unchanged in topology; Term::Intrinsic is recognised at three of those stations:

  1. Parse. (intrinsic) in a fn body slot or a lambda body slot produces a Term::Intrinsic body. 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.
  2. Check. A def whose body is Term::Intrinsic: validate the signature, skip body inference. Enforce the kernel-tier/prelude scope. The module's kernel: true flag (or prelude identity) is already available to the checker via the loaded workspace.
  3. 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 unregistered RawBuf ops).

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 existing round_trip.rs corpus gate).
  • Check accept: the kernel_stub answer intrinsic checks clean.
  • Check reject (scope): a user module with an (intrinsic) fn is rejected with intrinsic-outside-kernel-tier.
  • E2E: answer builds and runs, exit/print observing 42 — the mechanism works from source to native.
  • Schema drift: design_schema_drift.rs green against the new 0002-data-model.md; schema_coverage.rs observes Term::Intrinsic in the fixture corpus.

intrinsic-bodies.2:

  • Hard-lockstep pin: bijection between INTERCEPTS and workspace intrinsic markers. Add a marker without an entry → red; add an entry without a marker → red; drop either → red.
  • All pre-existing E2E ratifiers stay green (the eq/compare/float smoke suite named in raw-buf.1's commit body) — the migration is behaviour-preserving by construction.
  • Prelude hash: the prelude module's hash changes in .2 (the dummy bodies are gone); the prelude_module_hash_pin.rs baseline is rebaselined in the same iteration, with the old→new hash recorded in the commit body.

Acceptance criteria

Applied prospectively (design/contracts/0004-feature-acceptance.md):

  • An LLM author naturally reaches for it. A kernel author writing RawBuf.get has no honest body to write — there is no value of type a. 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).