Files
Aura/docs/plans/0041-source-ingestion-seam.md
T
Brummel 9337a8585d plan: 0041 source ingestion seam
Decomposes the boss-signed spec into four tasks: (1) the `Source` trait +
`VecSource` adapter in aura-engine; (2) the atomic `Harness::run` re-type to
`Vec<Box<dyn Source>>` with all 53 call sites threaded behaviour-preserving;
(3) the streaming `M1FieldSource` over a data-server window; (4) a gated
streaming e2e + the measured residency predicate.

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

872 lines
34 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 — Implementation Plan
> **Parent spec:** `docs/specs/0041-source-ingestion-seam.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to
> run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Re-type 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 prove it against a real, lazily-streamed
`data-server` window — closing the cycle-0011 eager-materialization gap.
**Architecture:** `aura-engine` gains a `Source` trait + a `VecSource` adapter;
`Harness::run` is re-typed to `Vec<Box<dyn Source>>` with the merge loop driven
by `peek`/`next`, and every existing call site is wrapped in `VecSource`
(behaviour-preserving, byte-identical). `aura-ingest` gains a streaming
`M1FieldSource` over a `data-server` M1 window (per-pull decode from a borrowed
`Arc` chunk, lazy `next_chunk` refill, `ms→epoch-ns` at the seam), proven by a
gated end-to-end backtest plus a measured residency predicate.
**Tech Stack:** `aura-engine` (`harness.rs`, `lib.rs`), `aura-ingest`
(`lib.rs`, `tests/`), `data-server` (`stream_m1_windowed`, `SymbolChunkIter`,
`next_chunk`, `loader::CHUNK_SIZE`, `M1Parsed`).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/harness.rs` — add `Source` trait + `VecSource`
(after `SourceSpec`, ~line 52); re-type `Harness::run` (line 220) + merge loop
(220-258); re-type test helper `ohlcv_streams()` (line 900); wrap 31 call sites.
- Modify: `crates/aura-engine/src/lib.rs:25-30,47` — refresh module doc; add
`Source`, `VecSource` to the crate-root re-export.
- Modify: `crates/aura-engine/src/blueprint.rs` — wrap 10 call sites.
- Modify: `crates/aura-engine/src/sweep.rs:263` — wrap 1 call site.
- Modify: `crates/aura-engine/src/report.rs:223` — wrap 1 call site.
- Modify: `crates/aura-cli/src/main.rs` — wrap 4 call sites.
- Modify: `crates/aura-ingest/src/lib.rs` — add `M1Field` + `decode` + `M1FieldSource`
(near `load_m1_window`, ~line 110); add `data_server::SymbolChunkIter` import.
- Modify: `crates/aura-ingest/tests/real_bars.rs:72` — wrap 1 call site.
- Create: `crates/aura-ingest/tests/streaming_seam.rs` — gated streaming e2e +
residency-drive + sharing tests.
- Test: `harness.rs` `#[cfg(test)] mod tests``VecSource` unit tests.
- Test: `aura-ingest/src/lib.rs` `#[cfg(test)] mod tests``decode` unit tests.
---
## Task 1: `Source` trait + `VecSource` adapter (aura-engine)
**Files:**
- Modify: `crates/aura-engine/src/harness.rs` (after `SourceSpec`, ~line 52)
- Modify: `crates/aura-engine/src/lib.rs:25-30,47`
- Test: `crates/aura-engine/src/harness.rs` (`#[cfg(test)] mod tests`)
This task adds the new types only; `Harness::run` is NOT re-typed here (that is
Task 2). `Source`/`VecSource` compile and their tests pass standalone.
- [ ] **Step 1: Add the `Source` trait + `VecSource` to `harness.rs`**
Insert immediately after the `SourceSpec` struct (after its closing `}` at
`harness.rs:52`, before the `FlatGraph` doc comment at line 54):
```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 advancing 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)>;
}
/// 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()?;
self.cursor += 1;
Some(item)
}
}
```
- [ ] **Step 2: Add `VecSource` unit tests**
Append inside the existing `#[cfg(test)] mod tests { ... }` block in
`harness.rs` (the module already has `use super::*;`):
```rust
#[test]
fn vec_source_peek_is_non_consuming_and_idempotent() {
let mut s = VecSource::new(vec![(Timestamp(1), Scalar::F64(10.0)), (Timestamp(2), Scalar::F64(20.0))]);
// peek twice: same head, no advance.
assert_eq!(s.peek(), Some(Timestamp(1)));
assert_eq!(s.peek(), Some(Timestamp(1)));
// next pops it; peek now reflects the new head.
assert_eq!(Source::next(&mut s), Some((Timestamp(1), Scalar::F64(10.0))));
assert_eq!(s.peek(), Some(Timestamp(2)));
}
#[test]
fn vec_source_exhaustion_yields_none() {
let mut s = VecSource::new(vec![(Timestamp(5), Scalar::I64(7))]);
assert_eq!(Source::next(&mut s), Some((Timestamp(5), Scalar::I64(7))));
assert_eq!(s.peek(), None);
assert_eq!(Source::next(&mut s), None);
}
#[test]
fn vec_source_empty_peeks_none() {
let mut s = VecSource::new(vec![]);
assert_eq!(s.peek(), None);
assert_eq!(Source::next(&mut s), None);
}
```
(`Source::next(&mut s)` is spelled fully-qualified so it never collides with
`Iterator::next` in scope.)
- [ ] **Step 3: Re-export the new types and refresh the module doc**
In `crates/aura-engine/src/lib.rs`, change the re-export at line 47 from:
```rust
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
```
to:
```rust
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, Target, VecSource};
```
And in the module doc, change the "Still to come" line at lines 25-26 from:
```rust
//! Still to come (subsequent cycles): the `Source` trait + data-server ingestion
//! and source-native time normalization (C3/C11), the broker-independent
```
to:
```rust
//! Still to come (subsequent cycles): the broker-independent
```
- [ ] **Step 4: Build and run the `VecSource` tests**
Run: `cargo test -p aura-engine vec_source`
Expected: PASS (3 tests: `vec_source_peek_is_non_consuming_and_idempotent`,
`vec_source_exhaustion_yields_none`, `vec_source_empty_peeks_none`).
- [ ] **Step 5: Lint gate**
Run: `cargo clippy -p aura-engine --all-targets -- -D warnings`
Expected: clean (no warnings; the pub re-exported `Source`/`VecSource` are not
dead code).
---
## Task 2: Re-type `Harness::run` + thread all callers (atomic compile unit)
**Files:**
- Modify: `crates/aura-engine/src/harness.rs:220-258` (signature + merge loop),
`:900` (`ohlcv_streams` helper), 31 call sites
- Modify: `crates/aura-engine/src/blueprint.rs` (10 sites)
- Modify: `crates/aura-engine/src/sweep.rs:263` (1 site)
- Modify: `crates/aura-engine/src/report.rs:223` (1 site)
- Modify: `crates/aura-cli/src/main.rs` (4 sites)
- Modify: `crates/aura-ingest/tests/real_bars.rs:72` (1 site)
**Why one task:** re-typing `run`'s signature makes the whole workspace fail to
compile until every one of the 53 callers is threaded. The signature change and
all callers must land together; the build gate at the end is unsatisfiable
otherwise (self-review item 7). The 4 `h.run(ohlcv_streams())` sites are threaded
by re-typing the helper, not the call site.
**The mechanical wrap rule (applies to every `vec![...]` call-site literal):**
each top-level element `X` of the `vec![...]` passed to `.run(` is replaced by
`Box::new(VecSource::new(X))`. The expected parameter type `Vec<Box<dyn Source>>`
drives the unsizing coercion, so no `as Box<dyn Source>` is needed.
Example: `h.run(vec![prices])``h.run(vec![Box::new(VecSource::new(prices))])`;
`h.run(vec![s0, s1])``h.run(vec![Box::new(VecSource::new(s0)), Box::new(VecSource::new(s1))])`.
- [ ] **Step 1: Re-type the `run` signature + merge loop in `harness.rs`**
Replace the current signature + pick scan (`harness.rs:220-257`). Current:
```rust
pub fn run(&mut self, streams: Vec<Vec<(Timestamp, Scalar)>>) {
assert_eq!(
streams.len(),
self.sources.len(),
"run: one stream per source required (got {} streams for {} sources)",
streams.len(),
self.sources.len()
);
// disjoint field borrows so the topo walk can read topo/out_edges/sources
// while mutating nodes
let Harness { nodes, topo, out_edges, sources } = self;
let mut cursor: Vec<usize> = vec![0; streams.len()];
let mut cycle_id: u64 = 0;
let mut scratch: Vec<Scalar> = Vec::new();
loop {
// pick the live source head with the smallest (timestamp, source index)
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, // all streams exhausted
};
let (ts, value) = streams[s][cursor[s]];
cursor[s] += 1;
cycle_id += 1;
```
Replace with (sources arg is `mut`; the forward+eval body below line 257 is
UNCHANGED):
```rust
pub fn run(&mut self, mut sources_in: Vec<Box<dyn Source>>) {
assert_eq!(
sources_in.len(),
self.sources.len(),
"run: one source per SourceSpec required (got {} sources for {} specs)",
sources_in.len(),
self.sources.len()
);
// disjoint field borrows so the topo walk can read topo/out_edges/sources
// while mutating nodes
let Harness { nodes, topo, out_edges, sources } = self;
let mut cycle_id: u64 = 0;
let mut scratch: Vec<Scalar> = Vec::new();
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_in.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, // all sources exhausted
};
// `&mut *sources_in[s]`: deref the Box to `dyn Source` then re-borrow,
// so UFCS resolves Self = dyn Source (Box<dyn Source> does not itself
// impl Source). Fully-qualified to never collide with Iterator::next.
let (ts, value) =
Source::next(&mut *sources_in[s]).expect("peeked Some ⇒ next is Some");
cycle_id += 1;
```
(The local binding is named `sources_in` to avoid shadowing the `sources`
destructured from `self` — the `SourceSpec` wiring used in the forward step at
the unchanged line `for t in sources[s].targets.iter()`.)
- [ ] **Step 2: Verify the forward/eval body still references `sources` (the wiring)**
Confirm the unchanged forwarding line (was `harness.rs:261`) still reads:
```rust
for t in sources[s].targets.iter() {
```
`sources` here is the `Vec<SourceSpec>` destructured from `self` — unchanged. No
edit; this step is a read-check that the rename in Step 1 did not collide.
- [ ] **Step 3: Re-type the `ohlcv_streams()` test helper in `harness.rs:900`**
Replace (lines 899-908):
```rust
/// Build five timestamp-aligned f64 sources feeding Ohlcv's five barrier slots.
fn ohlcv_streams() -> Vec<Vec<(Timestamp, Scalar)>> {
vec![
f64_stream(&[(1, 10.0), (2, 20.0)]), // open
f64_stream(&[(1, 15.0), (2, 25.0)]), // high
f64_stream(&[(1, 8.0), (2, 19.0)]), // low
f64_stream(&[(1, 12.0), (2, 22.0)]), // close
f64_stream(&[(1, 100.0), (2, 200.0)]), // volume
]
}
```
with (returns boxed sources, so the four `h.run(ohlcv_streams())` callers need
no change):
```rust
/// Build five timestamp-aligned f64 sources feeding Ohlcv's five barrier slots.
fn ohlcv_streams() -> Vec<Box<dyn Source>> {
vec![
Box::new(VecSource::new(f64_stream(&[(1, 10.0), (2, 20.0)]))), // open
Box::new(VecSource::new(f64_stream(&[(1, 15.0), (2, 25.0)]))), // high
Box::new(VecSource::new(f64_stream(&[(1, 8.0), (2, 19.0)]))), // low
Box::new(VecSource::new(f64_stream(&[(1, 12.0), (2, 22.0)]))), // close
Box::new(VecSource::new(f64_stream(&[(1, 100.0), (2, 200.0)]))), // volume
]
}
```
- [ ] **Step 4: Wrap the 31 remaining `harness.rs` call sites**
Apply the wrap rule to each site below (the 4 `h.run(ohlcv_streams())` at
`:948/:1000/:1013/:1043` are NOT in this list — they are handled by Step 3).
Each `vec![...]` element becomes `Box::new(VecSource::new(<element>))`:
```text
:594 h.run(vec![f64_stream(&[(1,1.0),(2,2.0),(3,3.0),(4,4.0),(5,5.0)])]);
:642 h.run(vec![prices.clone()]); :657 h2.run(vec![prices]);
:689 h.run(vec![s0.clone(), s1.clone()]); :704 h2.run(vec![s0, s1]);
:735 h.run(vec![s0.clone(), s1.clone()]); :748 h2.run(vec![s0, s1]);
:789 h.run(vec![prices.clone()]); :804 h2.run(vec![prices]);
:834 h.run(vec![s0, s1, s2]);
:1122 h.run(vec![f64_stream(&[(1,10.0),(2,12.0),(3,14.0),(4,16.0),(5,18.0)])]);
:1172 h.run(vec![ f64_stream(..)×5 ]); (wrap each of the 5 elements)
:1219 h.run(vec![ <4 raw inner-vec literals> ]); (wrap each of the 4 — see Step 5)
:1257 h.run(vec![f64_stream(&[(1,10.0),(2,20.0),(3,30.0)])]);
:1292 a.run(vec![prices.clone()]); :1297 b.run(vec![prices]);
:1327 h.run(vec![s0, s1]); :1362 h.run(vec![s0, s1]);
:1447 h.run(vec![prices.clone()]); :1466 h2.run(vec![prices]);
:1512 h.run(vec![prices.clone()]); :1535 h2.run(vec![prices]);
:1579 h.run(vec![a.clone(), b.clone()]); :1600 h2.run(vec![a, b]);
:1640 h.run(vec![prices.clone()]); :1651 h2.run(vec![prices]);
:1701 h.run(vec![prices.clone()]); :1728 h2.run(vec![prices]);
:1788 h.run(vec![prices.clone(), counts.clone()]); :1817 h2.run(vec![prices, counts]);
:1861 h.run(vec![f64_stream(&[...])]); (multi-line; wrap the single element)
:1926 h.run(vec![f64_stream(&[...])]); (multi-line; wrap the single element)
```
(Line numbers drift as edits are applied; match on the call expression, not the
absolute line. After this step, `git grep -n 'run(vec!' crates/aura-engine/src/harness.rs`
should return zero un-wrapped `vec![` — every element is now `Box::new(VecSource::new(...))`.)
- [ ] **Step 5: Wrap the raw inner-vec literal at `harness.rs:1219`**
Replace:
```rust
h.run(vec![
vec![(Timestamp(1), Scalar::I64(7))],
vec![(Timestamp(2), Scalar::F64(1.5))],
vec![(Timestamp(3), Scalar::Bool(true))],
vec![(Timestamp(4), Scalar::Ts(Timestamp(99)))],
]);
```
with:
```rust
h.run(vec![
Box::new(VecSource::new(vec![(Timestamp(1), Scalar::I64(7))])),
Box::new(VecSource::new(vec![(Timestamp(2), Scalar::F64(1.5))])),
Box::new(VecSource::new(vec![(Timestamp(3), Scalar::Bool(true))])),
Box::new(VecSource::new(vec![(Timestamp(4), Scalar::Ts(Timestamp(99)))])),
]);
```
- [ ] **Step 6: Wrap the 10 `blueprint.rs` call sites**
Apply the wrap rule to each (match on the expression):
```text
:1023 named.run(vec![synthetic_prices()]); :1031 positional.run(vec![synthetic_prices()]);
:1742 flat.run(vec![prices.clone()]); :1749 composed.run(vec![prices]);
:1812 h.run(vec![prices]); :1829 a.run(vec![prices.clone()]);
:1835 b.run(vec![prices]); :1873 a.run(vec![prices.clone()]);
:1877 b.run(vec![prices]); :2395 h.run(vec![prices]);
```
Each → `<recv>.run(vec![Box::new(VecSource::new(<element>))]);`.
- [ ] **Step 7: Wrap the `sweep.rs` and `report.rs` call sites**
`crates/aura-engine/src/sweep.rs:263`:
`h.run(vec![synthetic_prices()]);``h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);`
`crates/aura-engine/src/report.rs:223` (multi-line `f64_stream` element): wrap the
single element → `h.run(vec![Box::new(VecSource::new(f64_stream(&[...])))]);`
- [ ] **Step 8: Wrap the 4 `aura-cli/src/main.rs` call sites**
```text
:140 h.run(vec![prices]); :283 h.run(vec![prices]);
:462 h.run(vec![prices]); :527 h.run(vec![showcase_prices()]);
```
Each → `h.run(vec![Box::new(VecSource::new(<element>))]);`. `main.rs` already
imports from `aura_engine`; ensure `VecSource` is in the import list (the file
imports `Harness` etc. from `aura_engine::{...}` — add `VecSource`).
- [ ] **Step 9: Wrap the `real_bars.rs` call site**
`crates/aura-ingest/tests/real_bars.rs:72`:
`h.run(vec![prices]);``h.run(vec![Box::new(VecSource::new(prices))]);`
Add `VecSource` to the `use aura_engine::{...}` import list at `real_bars.rs:11`.
- [ ] **Step 10: Workspace build gate**
Run: `cargo build --workspace --tests`
Expected: 0 errors (every one of the 53 callers threaded; the only `fn run` in
the workspace is `Harness::run`, so there is no method-resolution collision).
- [ ] **Step 11: Behaviour-preserving test gate**
Run: `cargo test --workspace`
Expected: all green — byte-identical to pre-seam. The two-run C1 determinism
pairs (`real_bars.rs` `r1`/`r2` `to_json`, `blueprint.rs` sweep-equality,
`harness.rs` `h`/`h2` pairs) stay green; no test output changes.
- [ ] **Step 12: Lint gate**
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean.
---
## Task 3: `M1Field` + `M1FieldSource` streaming source (aura-ingest)
**Files:**
- Modify: `crates/aura-ingest/src/lib.rs` (imports at 15-18; new items near
`load_m1_window` at ~110)
- Test: `crates/aura-ingest/src/lib.rs` (`#[cfg(test)] mod tests`)
`decode` is a **free function** of `(field, bar)` (the spec calls it "a pure
function of `(bar, field)`"), so the hermetic decode test needs no iterator.
- [ ] **Step 0: Move `aura-engine` to `[dependencies]`**
`M1FieldSource` lives in `src/lib.rs` and implements `aura_engine::Source`, so
`aura-engine` must be a regular dependency — it is currently `[dev-dependencies]`
only (`Cargo.toml:17`). In `crates/aura-ingest/Cargo.toml`, add under
`[dependencies]` (after the `data-server` line):
```toml
aura-engine = { path = "../aura-engine" }
```
and REMOVE the now-duplicate `aura-engine = { path = "../aura-engine" }` line
from `[dev-dependencies]` (cargo warns on a dependency present in both). Leave
`aura-std` in `[dev-dependencies]` — only the tests use it. Regular deps are
visible to tests, so `real_bars.rs` still resolves `aura_engine`.
Run: `cargo tree -p aura-ingest -e normal -i aura-engine 2>&1 | head -2`
Expected: `aura-engine` now appears as a normal (non-dev) dependency.
- [ ] **Step 1: Add the `SymbolChunkIter` import**
In `crates/aura-ingest/src/lib.rs`, change line 17:
```rust
use data_server::DataServer;
```
to:
```rust
use data_server::{DataServer, SymbolChunkIter};
```
- [ ] **Step 2: Add `M1Field`, `decode`, and `M1FieldSource`**
Insert after `load_m1_window` (after its closing `}` at `lib.rs:123`):
```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).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum M1Field {
Open,
High,
Low,
Close,
Spread,
Volume,
}
/// Project one M1 bar into `(normalized ts, scalar)` for `field`. Pure: a
/// function of `(field, bar)` only — no iterator, no clock — so it is testable
/// in isolation. Time is normalized ms→epoch-ns at this one seam (C3).
fn decode(field: M1Field, bar: &M1Parsed) -> (Timestamp, Scalar) {
let value = match 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)
}
/// A streaming [`Source`](aura_engine::Source) over a data-server M1 window. Holds
/// at most one `Arc` chunk (a 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>,
chunk: Option<Arc<[M1Parsed]>>,
pos: usize,
field: M1Field,
head: Option<(Timestamp, Scalar)>,
}
impl M1FieldSource {
/// Open over `[from_ms, to_ms]` (inclusive Unix-ms, data-server's contract).
/// `None` when no archived file overlaps the window (unknown symbol /
/// 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();
Some(s)
}
/// 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();
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(decode(self.field, &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 source holds at most one `Arc` chunk and no accumulating
/// field), so it can never grow with window length.
pub fn resident_records(&self) -> usize {
self.chunk.as_ref().map_or(0, |c| c.len())
}
}
impl aura_engine::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();
Some(item)
}
}
```
(`aura-engine` is made a regular dependency in Step 0, so `aura_engine::Source`
resolves from `src/lib.rs`.)
- [ ] **Step 4: Add hermetic `decode` unit tests**
Append inside the existing `#[cfg(test)] mod tests` block in
`aura-ingest/src/lib.rs` (it already has `use super::*;` and a `full_bar`
helper at `lib.rs:142`):
```rust
#[test]
fn decode_projects_each_field_to_its_scalar_kind() {
// full_bar: open 1.0, high 2.0, low 0.5, close 1.5, spread 0.1, volume 100.
let bar = full_bar(1_000);
assert_eq!(decode(M1Field::Open, &bar), (Timestamp(1_000_000_000), Scalar::F64(1.0)));
assert_eq!(decode(M1Field::High, &bar), (Timestamp(1_000_000_000), Scalar::F64(2.0)));
assert_eq!(decode(M1Field::Low, &bar), (Timestamp(1_000_000_000), Scalar::F64(0.5)));
assert_eq!(decode(M1Field::Close, &bar), (Timestamp(1_000_000_000), Scalar::F64(1.5)));
assert_eq!(decode(M1Field::Spread, &bar), (Timestamp(1_000_000_000), Scalar::F64(0.1)));
// volume is the one i64 column.
assert_eq!(decode(M1Field::Volume, &bar), (Timestamp(1_000_000_000), Scalar::I64(100)));
}
```
- [ ] **Step 5: Build + run the decode tests + lint**
Run: `cargo test -p aura-ingest decode_projects`
Expected: PASS (`decode_projects_each_field_to_its_scalar_kind`).
Run: `cargo clippy -p aura-ingest --all-targets -- -D warnings`
Expected: clean.
---
## Task 4: Gated streaming e2e + residency proof (aura-ingest)
**Files:**
- Create: `crates/aura-ingest/tests/streaming_seam.rs`
Mirrors the gated pattern of `real_bars.rs` (skip where local data is absent).
Three tests: the streaming backtest e2e, the residency-drive predicate, and the
N-field sharing property.
- [ ] **Step 1: Write the gated streaming test file**
Create `crates/aura-ingest/tests/streaming_seam.rs`:
```rust
//! Gated integration test for the Source ingestion seam: a real data-server M1
//! close stream driven LAZILY through the signal-quality sample harness via
//! `M1FieldSource`, plus the residency predicate (peak resident records ≤ one
//! chunk, independent of window length) and the N-field sharing property. Skips
//! where the local Pepperstone data directory is absent.
use std::sync::mpsc;
use std::sync::Arc;
use aura_core::{Firing, NodeSchema, PortSpec, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Edge, FlatGraph, Harness, RunManifest, RunReport, Source, SourceSpec,
Target, VecSource,
};
use aura_ingest::{M1Field, M1FieldSource};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use data_server::loader::CHUNK_SIZE;
use data_server::{DataServer, DEFAULT_DATA_PATH};
const SYMBOL: &str = "AAPL.US";
// 2006-08 in inclusive Unix-ms (a full month of M1 ⇒ many > CHUNK_SIZE bars).
const FROM_MS: i64 = 1_154_390_400_000;
const TO_MS: i64 = 1_157_068_799_999;
/// Bootstrap the cycle-0007 two-sink signal-quality harness and run it on a
/// single boxed source, folding the recorded equity + exposure into a RunReport.
fn run_sample(window: (Timestamp, Timestamp), source: Box<dyn Source>) -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let f64_recorder_sig = || NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
};
let mut h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)),
Box::new(Sma::new(4)),
Box::new(Sub::new()),
Box::new(Exposure::new(0.5)),
Box::new(SimBroker::new(0.0001)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)),
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)),
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Exposure::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 },
],
}],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
Edge { from: 3, to: 6, slot: 0, from_field: 0 },
],
})
.expect("valid signal-quality DAG");
h.run(vec![source]);
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
RunReport {
manifest: RunManifest {
commit: "streaming-seam-test".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
],
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
}
fn skip_if_no_data(server: &Arc<DataServer>) -> bool {
if !server.has_symbol(SYMBOL) {
eprintln!("skip: no local data at {DEFAULT_DATA_PATH} (symbol {SYMBOL} absent)");
return true;
}
false
}
#[test]
fn streaming_close_source_backtests_end_to_end_deterministically() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if skip_if_no_data(&server) {
return;
}
let open_close = || {
M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Close)
.expect("AAPL.US has data in the 2006-08 window")
};
// window bounds for the manifest: drain a source once to read first/last ts.
let mut probe = open_close();
let first = probe.peek().expect("non-empty window");
let mut last = first;
while let Some((t, _)) = Source::next(&mut probe) {
last = t;
}
let window = (first, last);
let r1 = run_sample(window, Box::new(open_close()));
assert!(r1.metrics.total_pips.is_finite(), "a backtest ran over the streamed window");
// same window streamed again ⇒ bit-identical report (C1).
let r2 = run_sample(window, Box::new(open_close()));
assert_eq!(r1.to_json(), r2.to_json());
}
#[test]
fn residency_is_bounded_by_one_chunk_independent_of_window_length() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if skip_if_no_data(&server) {
return;
}
let mut src = M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Close)
.expect("AAPL.US has data in the 2006-08 window");
// Drive the source the way `run` does (peek to pick, next to pop), sampling
// resident records at every pull.
let mut peak = 0usize;
let mut total = 0usize;
while src.peek().is_some() {
peak = peak.max(src.resident_records());
Source::next(&mut src);
total += 1;
}
// multi-chunk window: more records than a single chunk holds, so the bound
// below is non-vacuous (a O(window) source would have peaked at `total`).
assert!(total > CHUNK_SIZE, "window must span multiple chunks (got {total} records)");
// O(one chunk), NOT O(window): the per-pull ceiling never exceeds one chunk,
// regardless of how many chunks the window spans.
assert!(peak <= CHUNK_SIZE, "resident records {peak} exceeded one chunk ({CHUNK_SIZE})");
assert!(peak > 0, "a non-empty window resided at least one record");
}
#[test]
fn two_field_sources_share_one_window() {
let server = Arc::new(DataServer::new(DEFAULT_DATA_PATH));
if skip_if_no_data(&server) {
return;
}
// close + volume over the same window: each is an independent Source pulling
// its field; both stream to exhaustion (the N-sources-share-the-ts-axis shape).
let mut close = M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Close)
.expect("close source");
let mut volume = M1FieldSource::open(&server, SYMBOL, Some(FROM_MS), Some(TO_MS), M1Field::Volume)
.expect("volume source");
let mut n_close = 0usize;
while let Some((_, v)) = Source::next(&mut close) {
assert!(matches!(v, Scalar::F64(_)), "close is an f64 column");
n_close += 1;
}
let mut n_volume = 0usize;
while let Some((_, v)) = Source::next(&mut volume) {
assert!(matches!(v, Scalar::I64(_)), "volume is an i64 column");
n_volume += 1;
}
assert_eq!(n_close, n_volume, "both fields share the same ts axis ⇒ same count");
assert!(n_close > 0);
}
```
(The `VecSource` import is retained for parity even though this file uses only
`M1FieldSource`; drop it if clippy flags it unused — see Step 3.)
- [ ] **Step 2: Run the gated streaming tests**
Run: `cargo test -p aura-ingest --test streaming_seam`
Expected (where local data is present): PASS, 3 tests
(`streaming_close_source_backtests_end_to_end_deterministically`,
`residency_is_bounded_by_one_chunk_independent_of_window_length`,
`two_field_sources_share_one_window`).
Expected (where local data is absent): all 3 print `skip:` and pass (hermetic).
- [ ] **Step 3: Lint + unused-import scrub**
Run: `cargo clippy -p aura-ingest --all-targets -- -D warnings`
Expected: clean. If `VecSource` (or any import) is flagged unused in
`streaming_seam.rs`, remove it from the `use aura_engine::{...}` line.
- [ ] **Step 4: Full workspace gate**
Run: `cargo test --workspace`
Expected: all green.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: clean.
Run: `cargo doc --workspace --no-deps 2>&1`
Expected: clean (the new `pub` items — `Source`, `VecSource`, `M1Field`,
`M1FieldSource` — carry doc comments; no missing-doc or broken-intra-doc-link).