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
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user