First iteration of the intrinsic-bodies milestone. Introduces the
Form-A `(intrinsic)` body marker as a new leaf AST term and wires it
through surface, checker, codegen, and a kernel_stub ratifier. The
prelude migration + hard-lockstep pin + dead-path removal are .2.
What landed (8 tasks):
Task 1 — Term::Intrinsic unit variant (ast.rs), tag "t":"intrinsic"
via the enum's rename_all=lowercase. Additive: no existing fixture
carries it, hashes bit-identical. In-core exhaustive-match arms
(canonical/hash/visit/pretty/desugar/workspace) added as leaves.
Task 2 — surface parse + print: (intrinsic) as a fn body-slot clause
and a lambda positional body, mapped to/from Term::Intrinsic.
Task 3 — cross-crate walker sweep (check/codegen/prose/ail-main):
leaf no-op/identity arms at every no-wildcard Term match the
compiler flagged.
Task 4 — checker: new Env.current_module_kernel_tier flag (m.kernel
|| m.name=="prelude"), set alongside current_module. A def whose
body is intrinsic (top-level fn OR instance-method lambda, via the
shared is_intrinsic_body helper) is checked signature-only; an
intrinsic body outside kernel-tier/prelude is rejected with
intrinsic-outside-kernel-tier.
Task 5 — codegen: an intrinsic-bodied fn routes through the existing
try_emit_primitive_instance_body / intercepts::lookup path; if no
intercept fired it is an internal error, never a lower_term
fallthrough. lower_term and the synth walker get Term::Intrinsic
internal-error arms (an intrinsic body reaching either is an
escape bug).
Task 6 — answer intercept (ret i64 42) registered in INTERCEPTS;
the `answer : () -> Int` intrinsic added to STUB_AIL;
examples/kernel_intrinsic_smoke.ail added so schema_coverage
observes Term::Intrinsic in the examples/ corpus.
Task 7 — E2E ratifier: examples/kernel_answer.ail calls
kernel_stub.answer and prints 42; answer_intrinsic_builds_and_runs_printing_42
asserts it end-to-end (source → native).
Task 8 — design/contracts/0002-data-model.md gains the
{ "t": "intrinsic" } Term entry + fn/lam prose; form_a.md grammar
note updated.
Verification:
cargo test --workspace → 669 passed, 0 failed (baseline 667 +2:
intrinsic_in_user_module_is_rejected, answer_intrinsic_builds_and_runs_printing_42).
bench/check.py + bench/compile_check.py → 0 regressed.
Reject E2E (subprocess ail check --json, exit 1, code
intrinsic-outside-kernel-tier) GREEN.
Round-trip + hash pins GREEN — Term::Intrinsic is additive, no
existing fixture carries it, no hash moved.
Three implementation completions beyond the plan (all behaviour-
preserving, surfaced during execution):
1. The signature-only skip had to apply at the mono pass's two
synth-on-body re-entry sites (collect_mono_targets,
collect_residuals_ordered), not only check_fn — else an intrinsic
body hits synth's Term::Intrinsic internal-error guard. Repaired by
extracting the shared crate::is_intrinsic_body helper and applying
it at all three synth-on-body paths. Not a representation surprise:
the same signature-only treatment, more call sites.
2. The compiler-enumerated exhaustive-match set was broader than the
plan's named grep set (the plan anticipated this and made the sweep
compile-driven). Extra leaf arms in core desugar/workspace, check
reuse-as + qualify_workspace_term, codegen synth_with_extras,
ail/src/main.rs, and four test targets.
3. Fixture corrections: emit_answer needed the body-close
(block terminator) the plan snippet omitted; kernel_answer.ail's
main is (ret Unit)(effects IO) using (app print ...) since
io/print_int does not exist (the plan flagged this for the
implementer to resolve against real effect-op names).
IR snapshots (hello/sum/list/max3/ws_main.ll) refreshed: purely
additive @ail_kernel_stub_answer fn+adapter+closure, emitted into
every workspace exactly as the pre-existing @ail_kernel_stub_new
already was (kernel_stub is auto-injected; confirmed new was present
in the pre-iter hello.ll baseline). No user-fn IR changed.
The .2 iteration migrates the 18 prelude dummy bodies to (intrinsic),
upgrades registry_contains_all_legacy_arms to a source<->registry
bijection pin, and removes the dead body-lowering path.
12 KiB
Data model
Data model
The on-disk JSON-AST is what the toolchain hashes, typechecks, and
lowers. This section is the canonical schema. The Rust types in
crates/ailang-core/src/ast.rs are the in-memory projection of it;
when the two disagree, this section wins, and the drift test
crates/ailang-core/tests/design_schema_drift.rs fires. Every
additive field is declared with skip_serializing_if so pre-existing
fixtures keep bit-identical canonical-JSON hashes — that gating
contract is what makes growing the schema cheap.
Module
{
"schema": "ailang/v0",
"name": "<id>",
"kernel": true, // optional; omitted when false (hash-stable when omitted). Kernel-tier modules are auto-imported by every consumer. See prep.3 of the kernel-extension-mechanics milestone.
"imports": [{ "module": "<id>", "as": "<id>" }],
"defs": [Def...]
}
Def
kind ∈ { "fn", "const", "type", "class", "instance" }. All five
are real surface forms. Class and type cross-module references
(canonical-form rule, qualified <module>.<Class> /
<module>.<TypeName>) follow the scoping rule in
memory model; the class/instance schema
narrative — defaults, superclasses, diagnostics — lives in
typeclasses. Exported fn defs interact with
embedding ABI.
// fn (the unit that gets a content hash)
{ "kind": "fn",
"name": "<id>",
"type": Type, // typically Type::Fn, optionally wrapped in Forall
"params": ["<id>"...], // names bound in body, in type.params order
"body": Term,
"doc": "<optional string>",
"export": "<optional C symbol>", // omitted when absent (hash-stable when omitted); embedding-ABI surface — see prose below
"suppress": [Suppress...] // omitted when empty
}
// const (top-level value; codegen emits as a global; body must be pure)
{ "kind": "const",
"name": "<id>",
"type": Type,
"value": Term,
"doc": "<optional string>"
}
// type (algebraic data type; parameterised)
{ "kind": "type",
"name": "<id>",
"vars": ["<id>"...], // type parameters; omitted when empty (hash-stable when omitted)
"ctors": [
{ "name": "<id>", "fields": [Type...] } // nullary ctor: fields = []
...
],
"doc": "<optional string>",
"drop-iterative": true, // opt-in; omitted when false (hash-stable when omitted)
"param-in": { "<var>": ["<TypeName>", ...] } // closed-set restriction per type variable; omitted when empty (hash-stable when omitted). See prep.3 of the kernel-extension-mechanics milestone.
}
// class (typeclass declaration; narrative in contracts/0013-typeclasses.md)
{ "kind": "class",
"name": "<id>", // class name (e.g. "Show")
"param": "<id>", // single class parameter, kind *
"superclass": null, // or { "class": "<id>", "type": "<param>" } — "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module)
"methods": [
{ "name": "<id>",
"type": Type, // FnSig over the class param
"default": Term // optional fallback body; null = abstract-required
}
...
],
"doc": "<optional string>"
}
// instance (typeclass instance; narrative in contracts/0013-typeclasses.md)
{ "kind": "instance",
"class": "<id>", // class being instantiated; canonical form (bare for same-module, "<module>.<Class>" for cross-module)
"type": Type, // concrete type expression (never the class param)
"methods": [
{ "name": "<id>", "body": Term }
...
],
"doc": "<optional string>"
}
Suppress (entry in FnDef.suppress):
{ "code": "<diagnostic-code>", // e.g. "over-strict-mode"
"because": "<author reason>" // must be non-empty;
// empty/whitespace fires `empty-suppress-reason` (Error)
}
Term (expression)
{ "t": "lit", "lit": Literal }
{ "t": "var", "name": "<id>" }
// fn application; tail flag triggers musttail under codegen.
// `tail` is omitted when false (hash-stable when omitted).
// `args` may be empty: a nullary call is the surface form
// `(app f)` (resolution of Gitea #12). Read-tolerant: a JSON
// document omitting the `args` key deserialises to `[]`.
{ "t": "app", "fn": Term, "args": [Term...], "tail": false }
{ "t": "let", "name": "<id>", "value": Term, "body": Term }
// Local recursive let. Always fn-shaped. The desugar pass
// lifts most `letrec` to a synthetic top-level fn; `lift_letrecs`
// finishes the job after typecheck for the residue that captures
// let-bound names. Post-codegen, no `letrec` survives.
{ "t": "letrec",
"name": "<id>", "type": Type, "params": ["<id>"...],
"body": Term, "in": Term }
{ "t": "if", "cond": Term, "then": Term, "else": Term }
// Effect-op invocation. `op` is "<eff>/<op>" (e.g. "io/print_str").
// `tail` triggers musttail (omitted when false).
{ "t": "do", "op": "<eff>/<op>", "args": [Term...], "tail": false }
// Ctor application. `args` is always emitted on write (including
// as `"args": []` for niladic ctors); reads tolerate the key being
// absent and treat it as `[]`. This mirrors the read/write
// asymmetry on `Term::App.args` (see above).
{ "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }
{ "t": "match", "scrutinee": Term, "arms": [Arm...] }
// Anonymous fn value; free vars captured from enclosing scope.
{ "t": "lam",
"params": ["<id>"...],
"param-types": [Type...],
"ret-type": Type,
"effects": ["<id>"...],
"body": Term }
// Sequencing. Semantically `let _ = lhs in rhs`; lhs must be Unit.
{ "t": "seq", "lhs": Term, "rhs": Term }
// Explicit RC clone. Codegen lowers as
// `call void @ailang_rc_inc(ptr %v)` before returning %v under `--alloc=rc`.
{ "t": "clone", "value": Term }
// Explicit reuse-as hint. `body` must be allocating
// (typically `ctor` or `lam`); `source` must be a bare `var`. Codegen
// lowers as in-place rewrite under `--alloc=rc`.
{ "t": "reuse-as", "source": Term, "body": Term }
// loop: strict iteration block. `binders` declares
// one or more loop parameters (name, type, init), evaluated in
// order on loop entry; `body` is in scope of all binders. The
// loop's value is `body`'s value on the iteration that exits via a
// non-`recur` branch. Strictly additive (no `skip_serializing_if`;
// pre-existing fixtures hash bit-identically — none carry the tag).
// No totality claim — an infinite loop is legal. See
// `docs/specs/0034-loop-recur.md`.
{ "t": "loop",
"binders": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
"body": Term }
// recur: re-enter the lexically innermost enclosing
// `loop`, rebinding its binders positionally to `args`. Transfers
// control (no fall-through); valid only in tail position of its
// enclosing loop (enforced at typecheck, `recur-not-in-tail-position`).
{ "t": "recur",
"args": [ Term, ... ] }
// new: functional construction. Resolves `type` via type-scoped
// lookup to its home module, then calls the home module's `new`
// def with the supplied args. Each arg is a `NewArg` (see below).
// Type-args (kind = "type") instantiate the `new` def's outer
// `Forall` vars in declaration order; Value-args (kind = "value")
// are checked against the (substituted) param types. Strictly
// additive (no `skip_serializing_if`; pre-existing fixtures hash
// bit-identically — none carry the tag). See prep.2 of the
// kernel-extension-mechanics milestone.
{ "t": "new",
"type": "<TypeName>",
"args": [ NewArg, ... ] }
// intrinsic: the body of a compiler-supplied definition. Legal only as
// a FnDef/Lam body, only in a (kernel)-tier module or the prelude
// (typecheck: intrinsic-outside-kernel-tier). Never reduces to a value;
// codegen consumes it via the intercept registry. A def is intrinsic
// iff its body is this term. Strictly additive (no skip_serializing_if;
// pre-existing fixtures hash bit-identically — none carry the tag).
{ "t": "intrinsic" }
NewArg (one positional arg to a (new T args...) call):
// Type-positional arg: instantiates one of `new`'s outer Forall
// vars. The inner `value` carries a full `Type` JSON object.
{ "kind": "type", "value": Type }
// Value-positional arg: a `Term` checked against the corresponding
// substituted param type of `new`. The inner `value` carries a
// full `Term` JSON object.
{ "kind": "value", "value": Term }
In the MVP, do is only a direct call to a built-in effect op (no
handler); the effect system is described in effects.
A lam term constructs an anonymous function value; free
variables of its body are captured from the enclosing scope. A lam
body may be { "t": "intrinsic" }, in which case the lambda is
compiler-supplied (the synthesised-instance-method form).
A fn def's body may be { "t": "intrinsic" }, in which case the
def is compiler-supplied: the typechecker validates only its signature
and codegen routes it through the intercept registry. Such a body is
legal only in a (kernel)-tier module or the prelude.
Loop binders are alloca-resident: typecheck binds them in the
ordinary local scope plus a positional loop_stack, and codegen
lowers them as entry-block allocas. Capturing a loop binder into a
lambda body is rejected at typecheck via
CheckError::LoopBinderCapturedByLambda. See
docs/specs/0034-loop-recur.md.
Literal:
{ "kind": "int", "value": <i64> }
{ "kind": "bool", "value": <bool> }
{ "kind": "str", "value": "<utf-8>" }
{ "kind": "unit" }
{ "kind": "float", "bits": "<16-lowercase-hex>" }
Pattern (the pat field of an Arm; discriminator p):
{ "p": "wild" } // _
{ "p": "var", "name": "<id>" } // x — binds the value
{ "p": "lit", "lit": Literal }
{ "p": "ctor", "ctor": "<id>", "fields": [Pattern...] } // fields omitted when empty
Patterns are linear: each pattern variable may appear at most once.
Type
The Type::Con.name canonical-form rule (bare for same-module /
primitives, qualified <module>.<TypeName> for cross-module) lives
in memory model; Type::Fn's parameter-mode
metadata is defined and gated there as well.
// Type-constructor application. `args` omitted when empty
// (hash-stable when omitted, for non-parameterised cases like Int, Bool, ...).
{ "k": "con", "name": "<id>", "args": [Type...] } // "name": canonical form (bare for same-module / primitives, "<module>.<TypeName>" for cross-module)
// Function type. paramModes/retMode are metadata on Type::Fn —
// they are NOT separate Type variants, so every existing match-arm
// in the typechecker (unify, occurs, apply) keeps working.
// `paramModes` omitted when every entry is "implicit"; `retMode`
// omitted when "implicit" (hash-stable when omitted). Full mode
// contract lives in contracts/0008-memory-model.md.
{ "k": "fn",
"params": [Type...],
"paramModes": [ParamMode...],
"ret": Type,
"retMode": ParamMode,
"effects": ["<id>"...] }
{ "k": "var", "name": "<id>" }
// Top-level polymorphism only. `constraints` carries class
// constraints (narrative in contracts/0013-typeclasses.md); omitted when
// empty (hash-stable when omitted).
{ "k": "forall",
"vars": ["<id>"...],
"constraints": [{ "class": "<id>", "type": "<id>" }, ...], // "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module)
"body": Type }
ParamMode (full contract in
memory model):
"implicit" — unannotated / back-compat. Treated as `own` by the typechecker.
"own" — (own T) — caller transfers ownership; callee consumes.
"borrow" — (borrow T) — caller retains ownership; callee may not consume.
implicit ≡ own semantically; the distinction exists so existing
unannotated fixtures continue to serialize without the mode wrapper and keep their
canonical-JSON hash. The full mode contract (codegen consequences,
the over-strict-mode lint, the Suppress mechanism) lives in
memory model; the four language-design
preconditions that make RC sound live in
language constraints.
Ratified by: crates/ailang-core/tests/design_schema_drift.rs.