Files
Aura/docs/specs/0041-source-ingestion-seam.md
T
Brummel ae240140c4 spec: 0041 source ingestion seam (boss-signed)
Re-types the engine's ingestion input from a materialized
Vec<Vec<(Timestamp, Scalar)>> into a `Source` producer trait the k-way
merge drives by peek/next, and proves it against a real, lazily-streamed
data-server window in the same cycle — closing the cycle-0011 deliberate
eager-materialization gap (C12 Arc<[T]> cross-sim sharing). A VecSource
adapter keeps every existing run call site byte-identical
(behaviour-preserving); an M1FieldSource streams data-server chunks with
O(one-chunk) residency, independent of window length.

Foundational cycle of the World-II milestone; #66/#68/#69 inherit the
seam. Signed under the boss auto-sign gate: precondition clean, grounding
PASS on the final bytes, unanimous five-lens spec-skeptic panel (two
editorial ambiguity rounds on the residency predicate repaired to a
measured per-pull CHUNK_SIZE ceiling).

refs #71
2026-06-15 10:00:44 +02:00

460 lines
23 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.
# Source ingestion seam — Design Spec
**Date:** 2026-06-15
**Status:** Draft — awaiting user spec review
**Authors:** orchestrator + Claude
## Goal
Replace the engine's single materialized-`Vec` ingestion input with a **producer
seam** — a `Source` trait the k-way merge drives by `peek`/`next` — and prove it
against a real, lazily-streamed `data-server` window in the same cycle, so the
seam is designed against the real data source from the first line.
`Harness::run` today takes `Vec<Vec<(Timestamp, Scalar)>>`: one fully
materialized stream per source. That is the **only** engine type encoding an
eager-dataflow assumption. At realistic research scale (20 years, 3 tick streams,
~7.9e9 ticks) the eager layout is ~190 GB resident (24 B/tick) — non-residable.
The merge loop itself never needs the whole stream: it only ever peeks the head
timestamp of each live source and pops one record (`harness.rs:237``257`). So
the eager `Vec` is gratuitous; a `peek`/`next` producer is the faithful seam, and
a `data-server`-backed producer streams `Arc<[T]>` chunks lazily — resident
footprint O(Σ node lookbacks + one chunk), flat across the horizon.
This is the foundational cycle of the World-II milestone: the orchestration
families (#66 / #68 / #69) all bootstrap harnesses over data windows, so they
inherit this seam. Ledger cycle 0011 (`INDEX.md:530`) already names today's eager
materialization "a known, deliberate gap" whose zero-copy `Arc<[T]>` cross-sim
sharing is "the target for the orchestration cycle that introduces [the axes]" —
this milestone. This cycle closes that gap by making a streaming path exist; it
does not delete the eager convenience path (still valid for bounded loads).
**Contracts touched:** C2 (bounded read-only windows), C3 (one merge at
ingestion, ms→epoch-ns normalized there), C4 (cycle granularity / tie-by-source-
index), C7 (SoA base columns), C12 (`Arc<[T]>` cross-sim sharing).
## Architecture
Three layers, one new boundary type:
1. **`aura-engine`** gains a public `Source` trait and a `VecSource` adapter.
`Harness::run` is re-typed from `Vec<Vec<(Timestamp, Scalar)>>` to
`Vec<Box<dyn Source>>`. The merge loop's index arithmetic
(`streams[s][cursor[s]]`) becomes `sources[s].peek()` / `sources[s].next()`.
Every existing call site wraps its `Vec<(Timestamp, Scalar)>` in
`VecSource::new(..)` — behaviour-preserving, byte-identical run output, no
residency change. `VecSource` *is* the old behaviour, named.
2. **`aura-ingest`** gains `M1FieldSource`: a streaming `Source` that holds a
`data-server` `SymbolChunkIter<M1Parsed>`, the current `Arc<[M1Parsed]>` chunk
(an `Arc` clone of the cache's chunk — zero-copy), a cursor into it, a field
selector, and a **pre-decoded head** record. It constructs each `Scalar`
per-pull from the borrowed chunk (no owned-`Vec` materialization of the
window) and refills via `next_chunk()` when the current chunk drains.
3. **End-to-end**: a gated integration test bootstraps the signal-quality sample
harness, opens an `M1FieldSource` over a real `[from, to]` window, runs it
through the seam to a `RunReport`, and asserts determinism (C1).
The `peek(&self)`/`next(&mut self)` split is what forces the pre-decoded head in
the streaming source: answering `peek` without `&mut` is impossible over an
underlying iterator whose refill (`next_chunk`) is `&mut`, so the head is decoded
ahead of demand at construction and after every `next`. This is the standard
peekable shape, determined by the trait signature (settled in the issue), not an
open design choice.
`SourceSpec` (`harness.rs:49`) is unchanged: it stays the *wiring* metadata
(scalar `kind` + target slots), declared on the `FlatGraph`. A `Box<dyn Source>`
is the *runtime* producer bound to source index `s` at `run` time. The two were
already distinct (the old `Vec` was the runtime half); this cycle only re-types
the runtime half from an owned `Vec` to a producer.
### Scope boundary
In scope: the trait, `VecSource`, the `run` re-type with every call site adapted,
`M1FieldSource` (the streaming source), and one streaming end-to-end test.
Out of scope (deliberately, to keep the seam focused):
- Deleting the eager `load_m1_window` / `close_stream` path — it stays a valid
convenience for bounded loads; the CLI keeps using it (wrapped in `VecSource`).
The deliberate-eager gap is closed by a streaming path *existing*, not by
removing the eager one.
- Migrating the CLI's real-data commands to streaming — follow-on.
- Tick sources (`stream_tick_windowed`) — the same shape applies (`M1FieldSource`
generalizes), but the e2e and the SMA-cross sample consume M1 close; tick is
not built here.
- Seed-as-input (#66) — the seed lives at the recording edge, outside this seam.
## Concrete code shapes
### User-facing program — a streaming real-window backtest
What a project author (or a World program) writes to backtest a strategy over a
real data window. This is the acceptance criterion's empirical evidence: the
audience's natural reach, shown.
```rust
// Bootstrap the harness (unchanged — topology is FlatGraph as today).
let mut h = Harness::bootstrap(sma_cross_signal_quality_graph())?;
// Open a streaming source over a real window. None = no archived file overlaps
// the window (data-server's file-level Option contract, propagated).
let close = M1FieldSource::open(&server, "AAPL.US", Some(from_ms), Some(to_ms), M1Field::Close)
.expect("window overlaps real data");
// Drive the seam. The source streams Arc<[M1Parsed]> chunks lazily: resident
// footprint is one chunk + the engine's per-node lookback windows, NOT the
// whole window. A 20-year window streams in the same memory as a one-day one.
h.run(vec![Box::new(close)]);
let report = fold_recorded_into_report(/* sinks */);
```
The eager path still works unchanged for bounded loads (every existing call
site, now wrapped):
```rust
let prices: Vec<(Timestamp, Scalar)> =
load_m1_window(&server, "AAPL.US", Some(from_ms), Some(to_ms))?.close_stream();
h.run(vec![Box::new(VecSource::new(prices))]); // was: h.run(vec![prices]);
```
### Delivered code — the `Source` trait (aura-engine)
```rust
/// The ingestion-boundary producer the k-way merge drives (C3). A source yields
/// timestamped scalars in ascending timestamp order (the C3 ingestion
/// precondition). Object-safe: the merge holds `Vec<Box<dyn Source>>`.
pub trait Source {
/// The timestamp of the head record, without consuming it. `None` = the
/// source is exhausted. Cheap and side-effect-free: it reads a pre-decoded
/// head, never advances the underlying stream.
fn peek(&self) -> Option<Timestamp>;
/// Pop the head `(timestamp, scalar)` and advance. `None` = exhausted.
/// After `next` returns `Some`, `peek` reflects the new head.
fn next(&mut self) -> Option<(Timestamp, Scalar)>;
}
```
### Delivered code — `VecSource` (aura-engine), behaviour-preserving
```rust
/// A `Source` over a fully materialized stream — the cycle-0011 eager shape,
/// named. Every pre-seam call site wraps its `Vec<(Timestamp, Scalar)>` in this,
/// so run output is byte-identical (C1) and residency is unchanged.
pub struct VecSource {
stream: Vec<(Timestamp, Scalar)>,
cursor: usize,
}
impl VecSource {
pub fn new(stream: Vec<(Timestamp, Scalar)>) -> Self {
Self { stream, cursor: 0 }
}
}
impl Source for VecSource {
fn peek(&self) -> Option<Timestamp> {
self.stream.get(self.cursor).map(|&(t, _)| t)
}
fn next(&mut self) -> Option<(Timestamp, Scalar)> {
let item = self.stream.get(self.cursor).copied()?; // (Timestamp, Scalar): Copy
self.cursor += 1;
Some(item)
}
}
```
### `Harness::run` — the merge loop re-type (before → after)
```rust
// BEFORE (harness.rs:220) — eager Vec input, index arithmetic.
pub fn run(&mut self, streams: Vec<Vec<(Timestamp, Scalar)>>) {
assert_eq!(streams.len(), self.sources.len(), /* ... */);
let mut cursor: Vec<usize> = vec![0; streams.len()];
loop {
let mut pick: Option<usize> = None;
for (s, stream) in streams.iter().enumerate() {
if cursor[s] < stream.len() {
match pick {
None => pick = Some(s),
Some(p) => if stream[cursor[s]].0 < streams[p][cursor[p]].0 { pick = Some(s); }
}
}
}
let s = match pick { Some(s) => s, None => break };
let (ts, value) = streams[s][cursor[s]];
cursor[s] += 1;
/* ... forward into target slots, eval in topo order ... unchanged ... */
}
}
// AFTER — producer seam, peek/next. The pick scan, tie-by-source-index, and the
// entire forward+eval body are preserved; only the head access changes.
pub fn run(&mut self, mut sources: Vec<Box<dyn Source>>) {
assert_eq!(sources.len(), self.sources.len(), /* ... */);
loop {
// pick the live source head with the smallest (timestamp, source index).
// strictly-`<` replace + source-order scan preserves C4 tie-breaking.
let mut pick: Option<(usize, Timestamp)> = None;
for (s, src) in sources.iter().enumerate() {
if let Some(ts) = src.peek() {
match pick {
None => pick = Some((s, ts)),
Some((_, best)) if ts < best => pick = Some((s, ts)),
_ => {}
}
}
}
let s = match pick { Some((s, _)) => s, None => break };
let (ts, value) = sources[s].next().expect("peeked Some ⇒ next is Some");
/* ... forward into target slots, eval in topo order ... byte-identical ... */
}
}
```
Note the borrow shape: `self.sources` (the `SourceSpec` wiring) is read inside the
forward step while `sources` (the producers) is the separate `run` argument, so
the existing destructuring of `self` into disjoint field borrows is unaffected.
### Delivered code — `M1FieldSource` (aura-ingest), the streaming source
```rust
/// Which base column of an M1 bar a source streams (C7: a composite window is a
/// bundle of base columns; one `Source` per consumed field).
pub enum M1Field { Open, High, Low, Close, Spread, Volume }
/// A streaming `Source` over a data-server M1 window. Holds one Arc chunk
/// (zero-copy clone of the cache's chunk) and a cursor; constructs each `Scalar`
/// per-pull; refills via `next_chunk()` when the chunk drains. Resident footprint
/// is O(one chunk), independent of window length.
pub struct M1FieldSource {
iter: SymbolChunkIter<M1Parsed>, // data-server's lazy chunk producer
chunk: Option<Arc<[M1Parsed]>>, // current chunk, borrowed (Arc refcount, not a data copy)
pos: usize, // cursor within `chunk`
field: M1Field, // which column this source projects
head: Option<(Timestamp, Scalar)>, // pre-decoded head ⇒ peek(&self) is cheap
}
impl M1FieldSource {
/// Open over `[from_ms, to_ms]` (inclusive Unix-ms, data-server's contract).
/// `None` when no archived file overlaps the window (an unknown symbol or a
/// far-future window) — propagating `stream_m1_windowed`'s file-level Option.
/// A window that overlaps a file but holds zero matching bars yields a source
/// whose first `peek` is `None` (immediately exhausted), not `None` here.
pub fn open(
server: &Arc<DataServer>, symbol: &str,
from_ms: Option<i64>, to_ms: Option<i64>, field: M1Field,
) -> Option<Self> {
let iter = server.stream_m1_windowed(symbol, from_ms, to_ms)?;
let mut s = Self { iter, chunk: None, pos: 0, field, head: None };
s.advance(); // pre-decode the first head
Some(s)
}
/// Project the bar at the cursor into (normalized ts, scalar) for `field`.
fn decode(&self, bar: &M1Parsed) -> (Timestamp, Scalar) {
let value = match self.field {
M1Field::Open => Scalar::F64(bar.open),
M1Field::High => Scalar::F64(bar.high),
M1Field::Low => Scalar::F64(bar.low),
M1Field::Close => Scalar::F64(bar.close),
M1Field::Spread => Scalar::F64(bar.spread),
M1Field::Volume => Scalar::I64(bar.volume),
};
(unix_ms_to_epoch_ns(bar.time_ms), value) // ms → epoch-ns at the seam (C3)
}
/// Decode the head at the cursor, refilling chunks as needed. Sets
/// `self.head = None` at exhaustion.
fn advance(&mut self) {
loop {
if self.chunk.is_none() {
self.chunk = self.iter.next_chunk(); // &mut refill; None = window exhausted
self.pos = 0;
if self.chunk.is_none() { self.head = None; return; }
}
let chunk = self.chunk.as_ref().unwrap();
if self.pos < chunk.len() {
self.head = Some(self.decode(&chunk[self.pos]));
return;
}
self.chunk = None; // current chunk drained — loop to refill
}
}
/// Records resident in this source right now: the current chunk's length (or
/// 0 at exhaustion). The residency probe — bounded by one chunk length by
/// construction (the struct holds at most one `Arc` chunk and no accumulating
/// field), so it can never grow with window length. Sampled by the residency
/// test to make the O(one chunk) claim a measured quantity, not prose.
pub fn resident_records(&self) -> usize {
self.chunk.as_ref().map_or(0, |c| c.len())
}
}
impl Source for M1FieldSource {
fn peek(&self) -> Option<Timestamp> {
self.head.map(|(t, _)| t)
}
fn next(&mut self) -> Option<(Timestamp, Scalar)> {
let item = self.head?;
self.pos += 1;
self.advance(); // pre-decode the next head (may refill the chunk)
Some(item)
}
}
```
### N-field generalization (shape the trait permits; not all-tested here)
Two `M1FieldSource`s over the same `(symbol, window)` get the **same** `Arc`
chunks from the data-server cache (the `SymbolGuard` keeps the symbol loaded; the
cache shares `Arc<[T]>`), so they stream different fields sharing the chunk's ts
axis zero-copy — C12's cross-source `Arc<[T]>` sharing:
```rust
let close = M1FieldSource::open(&server, sym, f, t, M1Field::Close)?;
let volume = M1FieldSource::open(&server, sym, f, t, M1Field::Volume)?;
h.run(vec![Box::new(close), Box::new(volume)]); // co-scheduled by timestamp (C4)
```
The e2e headline uses the close source alone; a focused test asserts two sources
over one window both stream (the sharing property), but the full OHLCV-as-N-
sources harness is not built in this cycle.
## Components
| Component | Crate | Change |
|-----------|-------|--------|
| `Source` trait | aura-engine | **new**`peek(&self)`, `next(&mut)`; object-safe; re-exported from the crate root |
| `VecSource` | aura-engine | **new** — behaviour-preserving adapter over `Vec<(Timestamp, Scalar)>` |
| `Harness::run` | aura-engine | **re-typed**`Vec<Box<dyn Source>>`; merge loop drives `peek`/`next`; forward+eval body byte-identical |
| ~53 `run` call sites | aura-engine, aura-cli, aura-ingest | **adapted** — wrap streams in `VecSource::new(..)`; mostly tests |
| `M1Field`, `M1FieldSource` | aura-ingest | **new** — streaming M1 source, per-pull decode, lazy chunk refill, ms→ns normalization |
| streaming e2e test | aura-ingest | **new** — real-window backtest through the seam to a `RunReport` |
| `load_m1_window`, `close_stream`, `transpose_m1` | aura-ingest | **kept** — eager convenience for bounded loads; unchanged |
## Data flow
```
data-server files ──stream_m1_windowed(sym, from_ms, to_ms)──▶ SymbolChunkIter<M1Parsed>
│ next_chunk() : Arc<[M1Parsed]> (lazy, N+2 prefetch)
M1FieldSource { chunk: Arc<[M1Parsed]>, pos, field, head } ──decode(bar)──▶ (epoch-ns ts, Scalar)
│ peek() / next()
Harness::run(Vec<Box<dyn Source>>) ──k-way merge by (peek ts, source idx)──▶ one chronological cycle stream
│ (forward into target slots → eval in topo order — unchanged)
recorded sinks → RunReport
```
The only resident data at any instant: each source's current `Arc` chunk
(≤1 chunk) + one pre-decoded head, plus the engine's per-node lookback ring
buffers (already O(Σ lookbacks), unchanged). Nothing scales with window length.
## Error handling
- **Source open failure** is an `Option`, not a `Result`: `M1FieldSource::open`
returns `None` exactly when `stream_m1_windowed` does — no archived file
overlaps the window (unknown symbol / far-future window). This mirrors the
existing `load_m1_window` file-level Option contract (`lib.rs:101``109`).
- **Empty-but-overlapping window** (a file overlaps but holds zero matching
bars): `open` returns `Some`, and the source's first `peek` is `None`
immediately exhausted. `run` over it drives zero cycles. This preserves the
existing distinction "data source present" vs "nothing to read from".
- **Mid-stream**: `next`/`peek` never error — exhaustion is `None`. `next_chunk`
yields `None` at window end; `advance` maps that to `head = None`.
- **`run`'s length assertion** (one source per `SourceSpec`) is unchanged.
- The ascending-timestamp ingestion precondition (C3) is unchanged: data-server
files are chronological, and `decode` preserves order within and across chunks.
## Testing strategy
1. **Behaviour-preserving (the load-bearing guarantee).** The full
`cargo test --workspace` suite stays green and byte-identical after the
re-type — every call site wrapped in `VecSource`. The existing C1-determinism
assertions (e.g. `real_bars.rs:118``120`, `blueprint.rs` sweep equality) are
the evidence; no test output changes.
2. **`VecSource` unit tests** (aura-engine): `peek` is non-consuming and idempotent;
`peek` then `next` agree; exhaustion yields `None`; an empty stream peeks `None`.
3. **`decode` projection unit tests** (aura-ingest, hermetic — pure `decode` on
hand-made `M1Parsed`, no data-server files): each `M1Field` projects to the
right `Scalar` kind/value with ts normalized ms→ns. `decode` is a pure
function of `(bar, field)`, so it is fully testable without an iterator or
files. (The `peek`/`next`/refill walk over a real iterator is exercised by the
gated e2e below, which is where a real `SymbolChunkIter` exists.)
4. **Streaming e2e + residency proof** (aura-ingest, gated — skips where the
local Pepperstone directory is absent, like `real_bars.rs`): bootstrap the
signal-quality sample harness, `M1FieldSource::open` over a real window
spanning **many** data-server chunks, `h.run` through the seam, fold to a
`RunReport`; assert `total_pips.is_finite()` and C1 (two runs bit-identical
JSON). **Residency predicate — the measurable O(one chunk) claim:** sampling
`resident_records()` at every pull across the whole run, its maximum is
**≤ one data-server chunk** — the `CHUNK_SIZE` bound (1024 records;
`data_server`'s documented chunk size, asserted against the constant, not a
magic literal). This per-pull bound is **independent of window length**: it
holds at every pull whether the window spans a handful of chunks or millions,
which is the O(one chunk)-not-O(window) content. The bound is correct on real
chunks regardless of boundary-chunk lengths (`filter_chunk` only ever yields a
chunk *≤* `CHUNK_SIZE`). A source that accumulated chunks would blow past the
one-chunk bound on a long window; a source that accumulated decoded state is
excluded structurally by §5. (Deliberately *not* an "equal peak across two
windows" assertion — boundary/file-final chunks are short, so per-window
maxima legitimately differ; the load-bearing invariant is the per-pull
one-chunk *ceiling*, which is what bounds residency.)
5. **Structural no-accumulation guarantee** (always-on, not gated): `M1FieldSource`
holds exactly `{ iter, one Option<Arc<[M1Parsed]>>, cursor, field, one head }`
— no `Vec<Arc<..>>` or other field that grows with the stream — so
`resident_records()` is bounded by one chunk length *by construction*. This is
the always-on half of the residency claim (verifiable from the struct
definition + clippy); the gated item 4 is the dynamic-invariance half.
6. **Sharing property** (gated): two `M1FieldSource`s (close + volume) over one
window both stream to exhaustion in a single `run` — the N-sources-share-the-
window shape.
## Acceptance criteria
- [ ] `Source` (`peek` + `next`, object-safe) and `VecSource` exist in
aura-engine; `Harness::run` takes `Vec<Box<dyn Source>>`.
- [ ] Every `run` call site is adapted; `cargo test --workspace` is green and
run output is byte-identical to pre-seam (behaviour-preserving).
- [ ] `M1FieldSource` streams a data-server M1 window through the seam: per-pull
`Scalar` decode from a borrowed `Arc` chunk, lazy `next_chunk` refill,
ms→epoch-ns normalized at the seam (C3).
- [ ] A real short-window backtest runs end-to-end through the seam to a
`RunReport`, deterministically (C1).
- [ ] **Residency (measured predicate):** across a full run over a window
spanning many chunks, peak `M1FieldSource::resident_records()` is
**≤ one data-server chunk** (`CHUNK_SIZE` = 1024) — a per-pull ceiling
**independent of window length** — so the source holds O(one chunk), NOT
O(window length), measured on real multi-chunk data (Testing strategy §4).
The struct carries no accumulating field, so this bound holds by
construction (§5). The engine's per-node lookback ring buffers are
unchanged — already O(Σ lookbacks), pinned by the existing suite — so total
resident memory is flat across the horizon.
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean;
`cargo doc --workspace --no-deps` clean.
## Surfaced boundary frictions (recorded, per the issue's friction-first intent)
- **Transpose granularity.** The streaming source does **per-pull** field decode
from the borrowed chunk, not `transpose_m1`'s per-window owned-SoA transpose.
The eager `M1Columns`/`transpose_m1` path is a different shape, kept for bounded
loads; the two now coexist (a follow-on may unify them).
- **`Arc` borrow lifetime.** Each source holds an `Arc<[M1Parsed]>` clone; across
disjoint sims (C1/C12) the data-server cache shares the same chunks zero-copy.
The `SymbolGuard` inside `SymbolChunkIter` keeps the symbol loaded for the
source's lifetime.
- **Data-server cache file retention.** The aura-side source is O(one chunk); the
data-server *cache* retains loaded files (with N+2 prefetch). Whether files
behind the cursor are evicted is the data-server's contract, **owned by that
lib, not this cycle** — the residency claim here is scoped to aura's source
footprint. Flagged for the milestone, not resolved here.
- **Error channel asymmetry.** `Option` at open (file-level), `None`-on-
exhaustion mid-stream — no `Result` anywhere, matching `stream_m1_windowed`.
- **`peek(&self)` vs `&mut` refill.** Resolved by the pre-decoded head; the head
decode happens in `advance`, called from `open` and `next`.