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.
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:
- 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.
- 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.
-
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-exportedbacktest_step_tick), plus frozen-value-layout box read/write helpers and safeState/Tickwrappers. Zero finance/data-serverknowledge. It is the Rust analogue of the already-audited C hostcrates/ail/tests/embed/tick_roundtrip.c— the same frozen-layoutmake_state/make_tick/read-back, the sameown-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. -
data-serverwiring (the adapter layer ofail-embed). The only place that knows both sides. It maps the richdata_server::records::TickParsed { time_ms, ask, bid }to the single scalar the M3Tickrecord carries —px = (ask + bid) / 2.0, the mid price — and drives the per-tick fold by unrolling eachArc<[TickParsed]>chunk host-side into per-tick(State, Tick) -> Statecalls. The adapter, not the kernel, owns chunk iteration (the M4-retirement decision: chunk boundaries are a host artefact, semantically invisible to the fold). -
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 howdata-serverchunked 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.
- Symbol-fan (headline). One distinct symbol per thread; each
thread's
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: payload8 + 2*8= 24 bytes —tag:i64 @0,acc:f64 @8,n:i64 @16.Tick: payload8 + 1*8= 16 bytes —tag:i64 @0,px:f64 @8.- 8-byte rc header at
p-8(set to 1 byailang_rc_alloc, payload zeroed).own⇒ kernel consumes both inputs; the return is host-owned and host-freed viaailang_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) |
TickParsed→Tick, 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) → SymbolChunkIter →
while let next_chunk() yields Arc<[TickParsed]> →
host unroll: each TickParsed → px=(ask+bid)/2 → make_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_free
→ AILANG_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*returnsNonefor 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-threadedStatemust not leak; on the error path the currentstisailang_rc_dec'd before unwinding).- The frozen-layout contract (
ailang_rc_alloc, never rawmalloc; fixed offsets) is enforced by construction:make_state/make_tickare the only box constructors and there is no public way to hand the kernel a raw pointer. owndiscipline: eachTickis freshlyailang_rc_alloc'd per call and consumed by the kernel;stis consumed and replaced by the return each step; only the final return is host-dec'd. This is thetick_roundtrip.ccontract, unchanged.- Real data absent (
/mnt/tickdata/Pepperstonemissing): the real-data swarm tests skip gracefully, mirroringdata-server's owntests/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).
- Hermetic smoke (always green, no
/mnt). A test-only writer emits the documented 24-byte packedRawTickRecordlayout ({time:f64 Delphi-day, ask:f64, bid:f64}) into a ZIP'dSYM_YYYY_MM.tickin atempfile::tempdir(), points the realDataServer::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. - 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 ownailang_ctx_t. Each thread's(acc, n)== single-thread host reference fold for that symbol/window. Globally leak-free, sanitiser-clean. - 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 diffafter M5 touches onlyail-embed/**,Cargo.toml(the[workspace] exclude/non-membership note if needed), docs, and the per-iter journal/stats. Zero diff tocrates/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-serverdata 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-serversymbol or dependency in any compiler crate orruntime/) 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.