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.
19 KiB
Embedding ABI — M1: linkable artifact + scalar C entrypoint + main-free lifecycle — Design Spec
Date: 2026-05-18 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude
Goal
Make a compiled AILang function callable in-process from a Rust/C host.
Today AILang is a whole-program compiler whose only output is an
executable wrapping main (hard-coded define i32 @main() { call i8 @ail_<entry>_main() }, crates/ailang-codegen/src/lib.rs:554–561).
There is no way to invoke a compiled AILang function from a host with
structured arguments and a typed return — that missing call surface,
not the math, is the entire blocker for the financial-backtest swarm
this language is meant to run (roadmap M1–M5 motivation).
M1 delivers the first, narrowest slice: an author marks one fn
as the embedding boundary, ail build --emit=staticlib produces a
relocatable archive instead of an executable, and a C/Rust host links
it and calls the marked function with scalar arguments. Scalar
signatures only ((Int)->Int, (Float,Float)->Float). No ADT, no
record, no list, no IO crosses the boundary yet — those are M3/M4.
This is a backend/packaging capability. The language's evaluation
semantics do not change: no new term, no new effect, no RC behaviour
change. The one schema addition (FnDef.export) is additive and
hash-stable, gates only codegen-symbol selection plus a static
signature check, and is justified strategically (this is where the
language is meant to run) per the user-made direction call recorded in
the roadmap — not the standard authoring-utility gate. The
feature-acceptance criterion is still applied (see Acceptance
criteria); its clause-3 discriminator (wrong code fails to
typecheck) is the load-bearing part here.
Coherent stop: AILang scalar arithmetic callable from C/Rust,
proven by a host harness that links the .a, calls the symbol, and
asserts the return value — with every M1-arc invariant audited.
Position in the M1–M5 arc (load-bearing for two M1 decisions)
The arc is a one-way stability progression. Two M1 design decisions are derived from downstream milestones, not chosen freely:
- M2 threads
ailang_ctx_new()/_free"through every call" — the C signature of an exported kernel changes at M2. M1's signature is therefore provisional by construction and is labelled as such in DESIGN.md (honesty rule). M1 ships no runtime lifecycle API: shippingailang_runtime_init/shutdownstubs now would be replaced byctx_new/freeat M2 — exactly the API churn the M3 freeze exists to forbid. - M3 freezes the value/ABI layout as a one-way commitment, and
M5's
ail-embedadapter binds against that frozen ABI as the soledata-server↔AILang meeting point. The exported symbol name must therefore be author-controlled and decoupled from AILang's internalail_<module>_<def>mangling, so a module/fn refactor in M4/M5 cannot break the M3-frozen ABI. This is why the export marker carries an explicit C symbol string, not a bare boolean (Approach A, chosen 2026-05-18).
Architecture
Five layers change, in pipeline order. None of ailang-core,
ailang-codegen, or runtime/ gains any data-server/finance
knowledge or dependency (Invariant 1; audited at close).
-
Schema (
ailang-core). One additive fieldFnDef.export: Option<String>, serialised#[serde(default, skip_serializing_if = "Option::is_none")]— byte-for-byte the establishedFnDef.docprecedent (crates/ailang-core/src/ast.rs:206–207).Nonefor every pre-M1 fixture ⇒ all existing canonical-JSON hashes stay bit-identical. DESIGN.md §"Data model" / §Def gains the field;crates/ailang-core/tests/design_schema_drift.rsmoves in lockstep; a new DESIGN.md §"Embedding ABI (M1)" subsection documents the scalar C ABI and labels the M1 signature provisional until M3. -
Surface (
ailang-surface). The Form-Afnhead accepts an optional(export "<c-symbol>")modifier, positioned like the existing optional fn modifiers. Lex + parse + print, gated by the round-trip invariant:(export "sym")↔ the JSON"export":"sym"field is byte-isomorphic both directions.crates/ailang-core/specs/form_a.md(verified present) gains the grammar production. -
Check (
ailang-check). A new diagnostic family gates every(export …)fn: its parameter types and return type must each beIntorFloat(M1 scalar scope —Bool,Str, every ADT rejected), and its effect set must be empty (a pure(State,Chunk)->Statefold has no effects; an effectful export is the clause-3 wrong-code that must fail to typecheck). This is the feature-acceptance clause-3 discriminator, in code, not prose. -
Codegen (
ailang-codegen). When lowering for the staticlib target: (a) suppress the@maintrampoline emission (lib.rs:554–561) and theMissingEntryMainworkspace-shape check (lib.rs:419–431) — a kernel module is a library and has nomain; (b) for each(export "sym")fn, additionally emit an externally-visible C entrypoint@symwith the scalar signature, forwarding to the existing@ail_<module>_<fn>. The_adapter/_closclosure pair is orthogonal and left untouched. -
CLI (
crates/ail).ail buildgains an--emit=staticlibmode. Instead ofclang … -o <exe>it: compiles the program.llto<entry>.o(clang -c), archives it tolib<entry>.a(ar rcs), and separately emitslibailang_rt.afrom the RC runtime glue (rc.c/str.c→.o→ar rcs). Two artefacts, cleanly layered: the program archive carries no runtime objects (chosen 2026-05-18 — Invariant 1, and so M2's runtime rebuild touches onlylibailang_rt.a). -
Host harness (test only). A small C harness in the test tree links
lib<entry>.a+libailang_rt.a, calls the exported symbol, asserts the return. This is the E2E proof of the coherent stop. It is not a new crate —ail-embedis M5; M1's harness is test scaffolding.
Concrete code shapes
The AILang program M1 delivers (headline)
examples/embed_backtest_step.ail — the (State, Sample) -> State
fold the embedding boundary exists for, in the scalar M1 form an LLM
author writes when asked "make this AILang fold callable from a Rust
host":
(module backtest
(fn step
(export "backtest_step")
(type
(fn-type
(params (con Int) (con Int))
(ret (con Int))))
(params state sample)
(body
(app + state (app * sample sample))))
(fn helper
(type
(fn-type
(params (con Int))
(ret (con Int))))
(params x)
(body
(app * x x))))
step is the boundary; helper is internal — no (export), no C
symbol, not in the (M3-frozen) ABI. The host:
#include <stdint.h>
#include <assert.h>
extern int64_t backtest_step(int64_t state, int64_t sample);
int main(void) {
int64_t s = 0;
s = backtest_step(s, 3); /* 0 + 9 */
s = backtest_step(s, 4); /* 9 + 16 */
assert(s == 25);
return 0;
}
/* cc host.c libbacktest.a libailang_rt.a -o t && ./t */
backtest_step is decoupled from the ail_backtest_step mangling: a
later rename of module backtest or fn step does not move the
C symbol, so the M3-frozen ABI and the M5 adapter survive refactors.
The must-fail fixture (clause-3 discriminator — mandatory)
examples/embed_export_effectful_rejected.ail — parses fine, must
fail ail check. An effectful (and non-scalar) export is exactly
the wrong code the gate exists to reject at typecheck, not by
documentation:
(module bad
(fn log_step
(export "log_step")
(type
(fn-type
(params (con Int) (con Str))
(ret (con Int))
(effects IO)))
(params state label)
(body
(seq (do io/print_str label) state))))
The program is otherwise well-formed (body returns state : Int after
the effectful do), so the only reason it fails is the export
gate: the Str parameter is not C-scalar and the !IO effect
set is non-empty. (Narrower must-fail fixtures isolate each clause
separately; see Testing strategy.)
North-star slice (provisional surface, explicitly flagged)
The M5 swarm shape this M1 boundary is the first brick of — provisional, not M1 surface, shown only to anchor the arc:
// M5 — ail-embed adapter, NOT delivered in M1, shape provisional:
let ctx = ailang_ctx_new(); // M2
for chunk in data_server.chunks() { // M5 wiring
state = backtest_step(ctx, state, chunk); // M2 ctx + M4 chunk
}
ailang_ctx_free(ctx);
M1 delivers only the state = backtest_step(state, sample) scalar
call with no ctx and no chunk — everything else above is later
milestones, drawn here solely so the M1 signature's
provisional-until-M3 labelling is concrete.
Implementation shape (secondary — supporting, not the point)
Schema field, modelled byte-for-byte on FnDef.doc:
// crates/ailang-core/src/ast.rs — pub struct FnDef, after `doc`:
/// M1 embedding ABI: when `Some(sym)`, this fn is the embedding
/// boundary and codegen additionally emits an externally-visible
/// C entrypoint `@<sym>` (scalar signature, provisional until M3).
/// Author-chosen symbol, decoupled from `ail_<module>_<def>`
/// mangling so the M3-frozen ABI survives module/fn refactors.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub export: Option<String>,
Codegen, staticlib path (schematic — exact bytes are planner's job):
before (lib.rs:554–561, executable target — UNCHANGED for default mode):
define i32 @main() { call i8 @ail_<entry>_main() ret i32 0 }
after (staticlib target, per (export "backtest_step") fn):
; no @main trampoline, no MissingEntryMain check
define i64 @backtest_step(i64 %s, i64 %x) {
%r = call i64 @ail_backtest_step(i64 %s, i64 %x)
ret i64 %r
}
; @ail_backtest_step + its _adapter/_clos pair: unchanged
CLI, schematic:
before: clang <opt> -o <entry> <entry>.ll (+ link rc.o/str.o)
after : clang <opt> -c -o <entry>.o <entry>.ll
ar rcs lib<entry>.a <entry>.o
clang -O2 -c -o rc.o rc.c ; clang -O2 -c -o str.o str.c
ar rcs libailang_rt.a rc.o str.o
Components
| Component | Crate | Change |
|---|---|---|
FnDef.export field |
ailang-core |
additive Option<String>, skip_serializing_if |
| DESIGN.md §Data model / §Def / §Mangling / new §"Embedding ABI (M1)" | docs | field + scalar ABI + provisional-until-M3 label |
design_schema_drift.rs |
ailang-core |
lockstep with the new field |
Form-A (export "<sym>") modifier |
ailang-surface |
lex/parse/print + round-trip |
form_a.md grammar production |
ailang-core/specs |
new production |
| export-signature gate diagnostic | ailang-check |
scalar-only + effect-free, new CheckError |
| staticlib lowering | ailang-codegen |
suppress @main/MissingEntryMain; emit @<sym> forwarders |
ail build --emit=staticlib |
crates/ail |
.o + ar two-archive emit, no exe link |
| host harness | test tree | C harness, link + call + assert |
Data flow
backtest.ail
→ ail parse → JSON-AST (FnDef.export = Some("backtest_step"))
→ ail check → export-signature gate (scalar-only + effect-free)
→ desugar / lift / monomorphise (unchanged)
→ lower_workspace [staticlib] (no @main; emit @backtest_step → @ail_backtest_step)
→ backtest.ll
→ clang -c → backtest.o
→ ar rcs → libbacktest.a
( rc.c/str.c → clang -c → rc.o/str.o → ar rcs → libailang_rt.a ) [parallel, independent]
host: cc host.c libbacktest.a libailang_rt.a -o t → ./t → exit 0 (assert holds)
Error handling
- Non-scalar
(export)signature (Bool/Str/ADT in any param or ret): newailang-checkError diagnostic naming the offending position and that M1's embedding ABI is scalar-only (Int/Float). - Effectful
(export)fn (non-empty effect set): newailang-checkError diagnostic — an embedding kernel must be pure. --emit=staticlibwith zero(export)fns: CLI error ("staticlib target needs at least one(export …)fn"), the staticlib-mode analogue ofMissingEntryMain. Default executable mode still requiresmainexactly as today.(export)present in default executable mode: accepted and ignored (the field is inert unless the staticlib target is selected) — keeps the field orthogonal to the existingmain-executable path; no diagnostic.- Existing
MissingEntryMain: unchanged for the default executable target.
Testing strategy
RED-first throughout (skills/debug / TDD discipline).
MissingEntryMainbuild-path baseline pin (prerequisite — first task, RED-first). The staticlib design is defined as suppressingMissingEntryMainfor the staticlib target. That suppression is only meaningful if the check demonstrably fires on amain-free workspace in the default executable target. The codegen-unit layer of this is already pinned green —crates/ailang-codegen/src/lib.rs:3314–3342tests::missing_entry_main_is_errorbuilds amain-freenoentrymodule and assertsemit_irreturnsCodegenError::MissingEntryMain("noentry"); reverting/weakening the:429guard already turns that test red (grounding-check re-dispatch, 2026-05-18). What is not yet pinned is the same behaviour at the CLI build-path layer the staticlib mode actually branches on: thatail build(default executable mode) on amain-free module surfaces the rejection withentry module \{0}` has no `main` def`. This task adds that build-path-level regression fixture, layered on the existing codegen-unit pin (defence in depth at the layer the suppression is introduced). It ships before any staticlib code, so the suppression is built atop a baseline pinned at its own layer — the 2026-05-11 failure class pre-empted in its proper order.- Round-trip pin.
(export "sym")Form-A ↔ JSON byte-isomorphic both directions; a fixture withexportand one without, asserting canonical-JSON hash of the without-fixture is unchanged from a pre-M1 golden (additive-field hash stability). - Schema drift.
design_schema_drift.rsgreen with the new field present in DESIGN.md §Data model. - Check RED (clause-3). Three must-fail
ail checkfixtures: (a)Strparam export, (b) ADT ret export, (c)!IOexport — each fails with the new diagnostic. Plus a positive fixture (scalar, pure) that passes. - Codegen.
--emit-staticlib lowering emits no@main, emitsdefine i64 @backtest_step(...)with external linkage forwarding to@ail_backtest_step; default mode still emits the@maintrampoline (regression pin). - E2E host harness. Build
libbacktest.a+libailang_rt.a, compile + link the C harness, run it, assert exit 0 (thes == 25assertion holds). This is the coherent-stop proof. - Clean-core invariant. No new dependency in
ailang-core/ailang-codegen/runtime/Cargo.toml/build; architect audits at close (Invariant 1). - Bench regression trio unaffected (no runtime/codegen change
on the default executable path) —
bench/check.py,compile_check.py,cross_lang.pycarry-on.
Acceptance criteria
Feature-acceptance criterion (DESIGN.md §"Feature-acceptance criterion"), applied honestly to a strategic infra milestone:
- LLM author naturally produces it. Asked "make this AILang fold
callable from a Rust host", an LLM author writes exactly
examples/embed_backtest_step.ailabove — the(export "sym")marker on the fold fn. The worked.ailis this evidence (not a prose assertion). - Measurably improves correctness / removes redundancy. This is a capability gate, not a correctness delta — the embedding boundary does not exist at all today. Recorded honestly: the justification is strategic (the language's target deployment), the explicit user-made direction call in the roadmap, not the standard authoring-utility metric. No pretence otherwise.
- Reintroduces no bug class the core eliminates. The export-signature gate makes effectful / non-scalar exports fail to typecheck (clause-3 in code, not documentation). Invariant 1 (clean core — no finance knowledge/dep in core/codegen/runtime), Invariant 2 (single-threaded — no value crosses a thread boundary; nothing here introduces sharing), and Invariant 3 (native-AOT only — no interpreter) all hold and are audited.
Milestone-close ("coherent stop") is met when all hold:
examples/embed_backtest_step.ailbuilds withail build --emit=staticlib, producinglibbacktest.a+libailang_rt.a.- The C host harness links both archives, calls
backtest_step, and thes == 25assertion holds (E2E test green). - All three must-fail fixtures fail
ail checkwith the new diagnostic; the positive fixture passes. - Round-trip + schema-drift + every pre-M1 canonical hash unchanged.
- Architect audit confirms Invariant 1 (no finance/
data-serverknowledge or dependency enteredailang-core/ailang-codegen/runtime/); bench trio carry-on. - DESIGN.md §"Embedding ABI (M1)" documents the scalar C ABI and labels the M1 signature provisional until M3.
Iteration sequencing (prerequisite-first)
The planner decomposes M1 into bite-sized tasks, but task 1 is
fixed: the MissingEntryMain build-path baseline pin (Testing
strategy item 0). Rationale: the staticlib path is defined as
suppressing MissingEntryMain at the CLI build-path layer. The
codegen-unit layer of that check is already pinned green
(lib.rs:3314 missing_entry_main_is_error), but the CLI
build-path layer the staticlib mode actually branches on is not;
introducing the suppression before its own layer's baseline is
pinned would rest the milestone's load-bearing branch on an
un-pinned layer — the 2026-05-11 failure-class shape. (This spec's
first grounding-check, 2026-05-18, BLOCKed here on the mistaken
premise that no layer was pinned; the re-dispatch found the
codegen-unit pin, so task 1 is retained as the build-path-layer
hardening — defence in depth where the suppression is introduced —
not as a missing-coverage backfill.) Only after task 1 is green
does the schema/surface/check/codegen/CLI work proceed. No other
inter-task ordering is mandated by this spec; the planner owns the
rest.
Out of scope (explicit)
- Any non-scalar crossing — record/ADT (M3), list (M4),
Strin/out. - Any runtime lifecycle API (
ailang_runtime_init/shutdown,ailang_ctx_new/free) — M2; M1 ships the documented no-call contract only. - Per-thread context / concurrency safety /
atexit-hook neutralisation /g_rc_*counter de-globalisation — M2. - The
ail-embedadapter crate +data-serverwiring + thread swarm — M5. - Array/slice primitive — P2, post-M5, measurement-driven.
- Touching the
_adapter/_closclosure pair.