Files
AILang/docs/specs/0040-embedding-abi-m1.md
T
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

429 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:554561`).
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 M1M5 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 M1M5 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:
shipping `ailang_runtime_init/shutdown` stubs now would be replaced
by `ctx_new/free` at 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-embed` adapter binds against that frozen ABI as the
sole `data-server`↔AILang meeting point. The exported **symbol
name** must therefore be author-controlled and decoupled from
AILang's internal `ail_<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).
1. **Schema (`ailang-core`).** One additive field
`FnDef.export: Option<String>`, serialised
`#[serde(default, skip_serializing_if = "Option::is_none")]`
byte-for-byte the established `FnDef.doc` precedent
(`crates/ailang-core/src/ast.rs:206207`). `None` for 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.rs` moves in
lockstep; a new DESIGN.md §"Embedding ABI (M1)" subsection
documents the scalar C ABI and labels the M1 signature
*provisional until M3*.
2. **Surface (`ailang-surface`).** The Form-A `fn` head 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.
3. **Check (`ailang-check`).** A new diagnostic family gates every
`(export …)` fn: its parameter types and return type must each be
`Int` or `Float` (M1 scalar scope — `Bool`, `Str`, every ADT
rejected), and its effect set must be empty (a pure
`(State,Chunk)->State` fold 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.
4. **Codegen (`ailang-codegen`).** When lowering for the staticlib
target: (a) suppress the `@main` trampoline emission
(`lib.rs:554561`) and the `MissingEntryMain` workspace-shape
check (`lib.rs:419431`) — a kernel module is a library and has no
`main`; (b) for each `(export "sym")` fn, additionally emit an
externally-visible C entrypoint `@sym` with the scalar signature,
forwarding to the existing `@ail_<module>_<fn>`. The
`_adapter`/`_clos` closure pair is orthogonal and left untouched.
5. **CLI (`crates/ail`).** `ail build` gains an `--emit=staticlib`
mode. Instead of `clang … -o <exe>` it: compiles the program `.ll`
to `<entry>.o` (`clang -c`), archives it to `lib<entry>.a`
(`ar rcs`), and **separately** emits `libailang_rt.a` from 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 only `libailang_rt.a`).
6. **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-embed` is 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:
```c
#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**:
```rust
// 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`:
```rust
// 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):
```text
before (lib.rs:554561, 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:
```text
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): new `ailang-check` Error diagnostic naming the
offending position and that M1's embedding ABI is scalar-only
(`Int`/`Float`).
- **Effectful `(export)` fn** (non-empty effect set): new
`ailang-check` Error diagnostic — an embedding kernel must be
pure.
- **`--emit=staticlib` with zero `(export)` fns**: CLI error
("staticlib target needs at least one `(export …)` fn"), the
staticlib-mode analogue of `MissingEntryMain`. Default executable
mode still requires `main` exactly 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 existing
`main`-executable path; no diagnostic.
- **Existing `MissingEntryMain`**: unchanged for the default
executable target.
## Testing strategy
RED-first throughout (`skills/debug` / TDD discipline).
0. **`MissingEntryMain` build-path baseline pin (prerequisite —
first task, RED-first).** The staticlib design is defined as
*suppressing* `MissingEntryMain` for the staticlib target. That
suppression is only meaningful if the check demonstrably *fires*
on a `main`-free workspace in the default executable target. The
*codegen-unit* layer of this is already pinned green —
`crates/ailang-codegen/src/lib.rs:33143342`
`tests::missing_entry_main_is_error` builds a `main`-free
`noentry` module and asserts `emit_ir` returns
`CodegenError::MissingEntryMain("noentry")`; reverting/weakening
the `:429` guard 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*: that `ail build` (default executable mode) on a
`main`-free module surfaces the rejection with
`entry 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.
1. **Round-trip pin.** `(export "sym")` Form-A ↔ JSON byte-isomorphic
both directions; a fixture with `export` and one without, asserting
canonical-JSON hash of the without-fixture is unchanged from a
pre-M1 golden (additive-field hash stability).
2. **Schema drift.** `design_schema_drift.rs` green with the new
field present in DESIGN.md §Data model.
3. **Check RED (clause-3).** Three must-fail `ail check` fixtures:
(a) `Str` param export, (b) ADT ret export, (c) `!IO` export —
each fails with the new diagnostic. Plus a positive fixture
(scalar, pure) that passes.
4. **Codegen.** `--emit`-staticlib lowering emits **no** `@main`,
emits `define i64 @backtest_step(...)` with external linkage
forwarding to `@ail_backtest_step`; default mode still emits the
`@main` trampoline (regression pin).
5. **E2E host harness.** Build `libbacktest.a` + `libailang_rt.a`,
compile + link the C harness, run it, assert exit 0 (the
`s == 25` assertion holds). This is the coherent-stop proof.
6. **Clean-core invariant.** No new dependency in `ailang-core` /
`ailang-codegen` / `runtime/` `Cargo.toml`/build; architect
audits at close (Invariant 1).
7. **Bench regression trio** unaffected (no runtime/codegen change
on the default executable path) — `bench/check.py`,
`compile_check.py`, `cross_lang.py` carry-on.
## Acceptance criteria
Feature-acceptance criterion (DESIGN.md §"Feature-acceptance
criterion"), applied honestly to a strategic infra milestone:
1. **LLM author naturally produces it.** Asked "make this AILang fold
callable from a Rust host", an LLM author writes exactly
`examples/embed_backtest_step.ail` above — the `(export "sym")`
marker on the fold fn. The worked `.ail` *is* this evidence (not a
prose assertion).
2. **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.
3. **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.ail` builds with
`ail build --emit=staticlib`, producing `libbacktest.a` +
`libailang_rt.a`.
- The C host harness links both archives, calls `backtest_step`, and
the `s == 25` assertion holds (E2E test green).
- All three must-fail fixtures fail `ail check` with 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-server`
knowledge or dependency entered `ailang-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), `Str` in/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-embed` adapter crate + `data-server` wiring + thread
swarm — M5.
- Array/slice primitive — P2, post-M5, measurement-driven.
- Touching the `_adapter`/`_clos` closure pair.