Files
AILang/docs/specs/0044-embedding-abi-m5.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

18 KiB

Embedding ABI — M5: ail-embed adapter + data-server wiring + thread-swarm backtest — Design Spec

Date: 2026-05-19 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude

Goal

Prove the whole embedding arc end-to-end against the real target host: stream real Pepperstone tick data through the real external data-server crate into the already-shipped, M3-frozen AILang kernel, across a thread swarm of independent per-thread embedding contexts, and harvest the host-per-tick-FFI friction that feeds the later P2 batch-crossing perf decision.

M5 is the terminal embedding milestone. M4 (cons-list crossing) was retired; nothing follows M5 in this arc. Its value is exactly two things, both stated in the roadmap:

  1. Existence proof of the whole goal — a real backtest runs over a real data-server stream on a real thread swarm via the frozen M3 ABI, leak-free and sanitiser-clean.
  2. Friction harvest — honest, at real data scale, feeding the host-per-tick-FFI-vs-batch-crossing perf decision (the P2 flat-array roadmap item).

M5 ships zero language/compiler/runtime change. No schema, checker, codegen, runtime/, or examples/*.ail edit. The M3 ABI is frozen and already sufficient: the kernel (State, Tick) -> State two-record-param per-tick crossing is gate-accepted, forwarder- supported, and E2E-pinned both own and borrow (commit 170464f). The only AILang artefact M5 consumes is the already-shipped examples/embed_backtest_step_tick.ail. M5 is host-side code + wiring + the proof.

Architecture

Three things, in dependency order. None of ailang-core, ailang-check, ailang-codegen, crates/ail, or runtime/ is touched — Invariant 1 (the compiler crates gain no data-server/finance knowledge or dependency) is preserved not only in letter but in the dependency graph, and is audited at close.

  1. ail-embed — a lean, reusable AILang-kernel embedding module. extern "C" declarations against the M3-frozen ABI (ailang_ctx_new/ailang_ctx_free, ailang_rc_alloc, ailang_rc_dec, the author-exported backtest_step_tick), plus frozen-value-layout box read/write helpers and safe State/Tick wrappers. Zero finance/data-server knowledge. It is the Rust analogue of the already-audited C host crates/ail/tests/embed/tick_roundtrip.c — the same frozen-layout make_state/make_tick/read-back, the same own-mode contract — exposed as a small safe API instead of an inline test loop. It is not a published crate with a stability contract and implies no successor milestone; its correctness is proven by the (b) E2E below, not by an API-versioning promise.

  2. data-server wiring (the adapter layer of ail-embed). The only place that knows both sides. It maps the rich data_server::records::TickParsed { time_ms, ask, bid } to the single scalar the M3 Tick record carries — px = (ask + bid) / 2.0, the mid price — and drives the per-tick fold by unrolling each Arc<[TickParsed]> chunk host-side into per-tick (State, Tick) -> State calls. The adapter, not the kernel, owns chunk iteration (the M4-retirement decision: chunk boundaries are a host artefact, semantically invisible to the fold).

  3. The thread-swarm backtest (the E2E proof + friction harvest). N worker threads, each with its own ailang_ctx_t, each folding the shipped kernel over a data-server stream, in the two shapes the user selected ("volle Möhre"):

    • Symbol-fan (headline). One distinct symbol per thread; each thread's (acc, n) checked against a single-thread host reference fold for that symbol. This is data-server's own stated concurrency design ("designed to back hundreds of single-threaded VMs in parallel" — one symbol per VM).
    • Time-shard (second assertion). One symbol, N threads each a disjoint month-window. Boundary-invisibility is proven per shard, bit-exact: each shard's (acc, n) equals a single-thread host fold of that exact window in stream order — the kernel's result for a window is independent of how data-server chunked it. The host then sums the partials; the same summation over the reference partials matches bit-exact by construction. The whole-window single-thread total is a secondary cross-check with an f64-reassociation tolerance (cross-shard re-association is expected host arithmetic, not a kernel property — asserting it bit-exact would be wrong). The per-shard bit-exact equality is the first actual proof of the chunk/window-boundary-invisibility claim the entire M4 retirement rests on.

Topology / Invariant 1 (orchestrator decision, not a user fork)

ail-embed/ lives in the AILang repo but excluded from the [workspace] — its own Cargo.toml, built and tested explicitly. Substantive reason (not effort): the compiler workspace (cargo build/test --workspace) then owes nothing to ../libs/data-server or /mnt/tickdata — Invariant 1 holds in the dependency graph, not just on paper — while the sole consumer of the frozen ABI stays co-versioned and co-audited with the ABI it binds, which is precisely the protection the M3 freeze exists to provide. (Rejected: workspace member — couples the compiler's own CI to an external sibling dir + a 6 GB data mount; out-of-repo crate — sacrifices the co-audit the freeze is for.)

ail-embed depends on data-server via a path dependency (../libs/data-server); the README also documents a git source. The path dep is the in-repo-dev choice and is the meeting point Invariant 1 names.

Concrete code shapes

The AILang program M5 delivers (headline)

M5 delivers no new .ail. The delivered AILang program is the already-shipped, M3-frozen kernel it consumes — examples/embed_backtest_step_tick.ail, verbatim:

(module embed_backtest_step_tick
  (data State (ctor State (con Float) (con Int)))
  (data Tick  (ctor Tick  (con Float)))
  (fn step
    (export "backtest_step_tick")
    (type (fn-type
            (params (own (con State)) (own (con Tick)))
            (ret (con State))))
    (params st tick)
    (body
      (match st
        (case (pat-ctor State acc n)
          (match tick
            (case (pat-ctor Tick px)
              (term-ctor State State (app + acc px) (app + n 1)))))))))

acc += px; n += 1 over the per-tick mid price ⇒ the run's result is (Σ mid, tick-count), i.e. the mean mid price over the window — a real, exactly-verifiable backtest aggregate.

No new must-fail fixture. The feature-acceptance clause-3 discriminator ("wrong code fails to typecheck") is already owned by the M3 export gate (export-non-scalar-signature, export-has-effects); M5 adds no checker/codegen change, so there is no new wrong-code obligation. The three-clause feature-acceptance criterion gates language features; M5 ships none, so it is N/A in form. The discipline that was applied is the same M4-lesson skepticism turned on the deliverable shape: "does a host author need a named reusable embedding crate, or is that speculative infra?" — resolved to a lean module whose correctness the real E2E proves, not a speculative published API (see Decisions).

The frozen box layout the kernel crosses (DESIGN.md §"Frozen value layout", M3 one-way commitment):

  • State: payload 8 + 2*8 = 24 bytes — tag:i64 @0, acc:f64 @8, n:i64 @16.
  • Tick: payload 8 + 1*8 = 16 bytes — tag:i64 @0, px:f64 @8.
  • 8-byte rc header at p-8 (set to 1 by ailang_rc_alloc, payload zeroed). own ⇒ kernel consumes both inputs; the return is host-owned and host-freed via ailang_rc_dec.

Implementation shape (secondary — supporting, not the point)

The lean embedding module — Rust port of the audited tick_roundtrip.c helpers, exposed as a safe API:

// ail-embed/src/lib.rs  (no finance knowledge)
#[repr(transparent)] pub struct Ctx(*mut c_void);   // !Send by default
unsafe extern "C" {
    fn ailang_ctx_new() -> *mut c_void;
    fn ailang_ctx_free(c: *mut c_void);
    fn ailang_rc_alloc(n: usize) -> *mut c_void;     // header=1, payload zeroed
    fn ailang_rc_dec(p: *mut c_void);
    fn backtest_step_tick(c: *mut c_void, st: *mut c_void,
                          tick: *mut c_void) -> *mut c_void;
}

impl Ctx {
    pub fn new() -> Self { Self(unsafe { ailang_ctx_new() }) }
    /// State box: tag@0=0, acc:f64@8, n:i64@16  (frozen M3 layout).
    fn make_state(acc: f64, n: i64) -> *mut c_void { /* ailang_rc_alloc(24) + writes */ }
    /// Tick box: tag@0=0, px:f64@8  (frozen M3 layout).
    fn make_tick(px: f64) -> *mut c_void { /* ailang_rc_alloc(16) + write */ }
    /// One fold step. `own`: kernel consumes st+tick; returns new owned State.
    pub fn step(&self, st: *mut c_void, px: f64) -> *mut c_void {
        unsafe { backtest_step_tick(self.0, st, Self::make_tick(px)) }
    }
}
impl Drop for Ctx { fn drop(&mut self) { unsafe { ailang_ctx_free(self.0) } } }

The adapter + per-thread fold (the only code that knows data-server):

// the worker body, one per thread, own ctx:
let ctx = Ctx::new();
let mut st = Ctx::make_state(0.0, 0);
let mut it = server.stream_tick_windowed(symbol, from_ms, to_ms).unwrap();
while let Some(chunk) = it.next_chunk() {            // data-server's loop
    for r in chunk.iter() {                          // host-side unroll
        let px = (r.ask + r.bid) / 2.0;              // adapter's job
        st = ctx.step(st, px);                       // M3 ABI, per tick
    }
}
let (acc, n) = read_state(st); ailang_rc_dec(st);    // host-owned return

ail-embed/build.rs invokes the ail CLI to emit the staticlib and links it (mirrors embed_tick_e2e.rs:61-78 verbatim, but from a workspace-excluded crate): resolve the ail binary via an AIL_BIN env override else cargo build -p ail against the parent AILang workspace; run ail build examples/embed_backtest_step_tick.ail --emit=staticlib -o <OUT_DIR>; emit cargo:rustc-link-search + -l static=embed_backtest_step_tick + -l static=ailang_rt.

Components

Component Role Knows data-server? Knows ABI?
ail-embed/build.rs emit + link the M3 staticlib no symbol names only
ail-embed core (Ctx, box helpers) frozen-layout FFI, safe wrappers no yes (the only crate that does)
ail-embed adapter (tick→px, fold driver) TickParsedTick, chunk unroll yes (sole site) via core
swarm E2E (symbol-fan + time-shard + hermetic smoke) the proof + friction harvest yes via core
examples/embed_backtest_step_tick.ail the delivered kernel (unchanged) no it is the ABI source

Data flow

DataServer::new(path)Arc → per worker thread: stream_tick_windowed(symbol, win)SymbolChunkIterwhile let next_chunk() yields Arc<[TickParsed]> → host unroll: each TickParsedpx=(ask+bid)/2make_tick box → backtest_step_tick(ctx, st, tick) (kernel consumes st+tick, returns new owned State) → thread st forward → end-of-stream: read (acc, n), ailang_rc_dec(st), ctx dropped (ailang_ctx_freeAILANG_RC_STATS ctx readback). Symbol-fan: per-thread (acc,n) vs per-symbol host reference (bit-exact, same stream order). Time-shard: each shard's (acc,n) vs a single-thread host fold of that exact window (bit-exact); host-summed (Σacc,Σn) vs the identically-summed reference partials (bit-exact by construction); whole-window single-thread total as a tolerance cross-check only.

Error handling

  • stream_tick* returns None for an unknown symbol or an empty window → the adapter surfaces a typed error to the harness, never a panic inside a kernel call (a half-threaded State must not leak; on the error path the current st is ailang_rc_dec'd before unwinding).
  • The frozen-layout contract (ailang_rc_alloc, never raw malloc; fixed offsets) is enforced by construction: make_state / make_tick are the only box constructors and there is no public way to hand the kernel a raw pointer.
  • own discipline: each Tick is freshly ailang_rc_alloc'd per call and consumed by the kernel; st is consumed and replaced by the return each step; only the final return is host-dec'd. This is the tick_roundtrip.c contract, unchanged.
  • Real data absent (/mnt/tickdata/Pepperstone missing): the real-data swarm tests skip gracefully, mirroring data-server's own tests/data_server.rs::skip_if_no_data() precedent — consistency with the wired crate's own convention, not an ad-hoc gate. The hermetic smoke test still runs and still proves the wiring/ABI handshake.

Testing strategy

Three layers; the regression/leak posture mirrors the M1/M2/M3 global-leak-freedom proof (AILANG_RC_STATS, Σallocs == Σfrees across all stat lines; sanitiser-clean).

  1. Hermetic smoke (always green, no /mnt). A test-only writer emits the documented 24-byte packed RawTickRecord layout ({time:f64 Delphi-day, ask:f64, bid:f64}) into a ZIP'd SYM_YYYY_MM.tick in a tempfile::tempdir(), points the real DataServer::new(tempdir) at it, runs a single-thread fold, and asserts (acc, n) equals a host reference fold over the same synthetic ticks. Proves "adapter compiles, links, ABI handshake holds" independent of real data — a genuinely different failure mode from "real data present". This is also data-server's own tempdir-fixture unit discipline.
  2. Symbol-fan swarm (headline existence proof; skip-if-absent). N threads, one liquid symbol each (default spine EURUSD; planner enumerates from what is present, e.g. EURUSD/XAUUSD), bounded window (default order-of one month per thread — enough real volume to surface per-tick-FFI friction, ~seconds runtime), each own ailang_ctx_t. Each thread's (acc, n) == single-thread host reference fold for that symbol/window. Globally leak-free, sanitiser-clean.
  3. Time-shard swarm (boundary-invisibility proof; skip-if-absent). One symbol, N threads each a disjoint month-window. Assert, in order of strength: (a) each shard's (acc,n) == a single-thread host fold of that exact window in stream order, bit-exact — this is the boundary-invisibility proof (the kernel result for a window is independent of data-server's chunking); (b) the host-summed (Σacc,Σn) == the identically-summed reference partials, bit-exact by construction; (c) the whole-window single-thread total within an f64-reassociation tolerance only (cross-shard re-association is host arithmetic, not a kernel property). The M4-retirement claim's first actual proof is (a).

Friction harvest: the swarm runs are timed; the per-tick-FFI cost at real tick volume is recorded in the close-out journal as the input to the P2 batch-crossing decision (observation, not a perf gate — no bench baseline is moved by M5).

Acceptance criteria

  • git diff after M5 touches only ail-embed/**, Cargo.toml (the [workspace] exclude/non-membership note if needed), docs, and the per-iter journal/stats. Zero diff to crates/ailang-*, crates/ail/, runtime/, examples/*.ail. Mechanically checkable; this is the no-language-change + Invariant-1 guarantee.
  • The hermetic smoke test passes everywhere (no /mnt, no ../libs/data-server data files needed beyond the crate itself).
  • On this machine (real data present): the symbol-fan swarm and the time-shard swarm both pass — symbol-fan per-thread (acc,n) bit-exact vs the same-order single-thread reference; time-shard per-shard (acc,n) bit-exact vs a single-thread fold of that exact window and the host-summed partials bit-exact by construction (whole-window total only within f64-reassociation tolerance) — globally leak-free (AILANG_RC_STATS Σallocs == Σfrees), sanitiser-clean (TSan: no data race across the per-thread ctxs).
  • Where real data is absent, the two swarm tests skip (not fail), mirroring data-server's own precedent.
  • The architect audit at close confirms Invariant 1 (no finance/data-server symbol or dependency in any compiler crate or runtime/) and that the M3 frozen-layout SSOT is unmoved.
  • The close-out journal records the measured per-tick-FFI cost at real volume as the P2 perf-decision input.

Decisions (forks resolved during this brainstorm)

  • What is data-server → the real external crate at /home/brummel/dev/libs/data-server (not a synthetic mock); real data at /mnt/tickdata/Pepperstone (6.3 GB).
  • Test-data strategy → real-data swarm is the headline proof + friction harvest (skip-if-absent per data-server's own skip_if_no_data()); tiny hermetic synthetic-format fixture is the always-green floor. Real-as-opt-in was rejected: it would gut the harvest, which is only honest at real scale.
  • What is ail-embed → a lean reusable embedding module + the real E2E on top (Option 2). Not "real E2E only" (the frozen ABI was designed to be bound generically — the module is the artefact the freeze is for); not two published crates (speculative infra, implies successor milestones the arc does not have).
  • Topology → in-repo, [workspace]-excluded (orchestrator decision; Invariant-1/build-hygiene reason above).
  • Swarm shape → both symbol-fan (headline) and time-shard (boundary-invisibility second assertion) — user choice "3, volle Möhre".

Out of scope / known pre-existing drift (not M5's job)

DESIGN.md §"Frozen value layout" still calls a recursive typed-free for boxed-field records "an additive M4 concern" (docs/DESIGN.md ~:2358-2360). M4 is retired; this is stale doc-honesty drift already flagged by the M4-retirement journal's forward note as reconcile-when-touched. M5 does not touch it and must not be charged with it at audit; noted here so the architect sees it is pre-existing, not M5-introduced.