refactor(aura-ingest): fuse drain_trace taps via join_on_ts; doc the cadence rule

drain_trace now joins its four recording-sink taps through aura-engine's
join_on_ts instead of a hand-rolled per-tap HashMap, resolving the #93
zip-by-index panic at its source: the four taps fire at different
cardinalities (breakout one bar short via cold Delay(1); bars_since_open
filtered by Session), so a positional zip misaligns or panics. The BarTrace
mapping stays a thin layer with its caller-side defaults unchanged
(held->0.0, bars_since_open->-1, breakout->false) — the engine reports
presence, the consumer interprets absence. Behaviour is byte-preserved: the
gated GER40 tests (ger40_breakout_real, ger40_breakout_blueprint,
open_ohlc_seam) stay green against the real archive.

The Recorder doc gains a prose note on the multi-tap cadence rule (join on
ts, never by index), pointing at join_on_ts by name rather than rustdoc link
since aura-std does not depend on aura-engine.

Verified: cargo build/test/clippy --workspace --all-targets clean; the
_by_ts hand-rolled join is gone (acceptance grep empty).

closes #93
This commit is contained in:
2026-06-18 15:02:11 +02:00
parent 74324d178f
commit 35c5adc6f3
2 changed files with 17 additions and 21 deletions
@@ -40,8 +40,8 @@ use std::sync::mpsc;
use aura_core::{NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{
summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness, RunManifest, RunReport,
SourceSpec, Target,
join_on_ts, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness, RunManifest,
RunReport, SourceSpec, Target,
};
use aura_std::{And, Delay, EqConst, Gt, Latch, Recorder, Resample, Session, SimBroker};
use chrono::TimeZone;
@@ -352,30 +352,19 @@ pub struct BarTrace {
/// (the sinks fire on different cardinalities — see [`BarTrace`]). Reads only
/// recorded sink output (C8/C18) — never engine internals.
pub fn drain_trace(taps: &Taps) -> Vec<BarTrace> {
use std::collections::HashMap;
let equity: Vec<(Timestamp, Vec<Scalar>)> = taps.equity.try_iter().collect();
let held: Vec<(Timestamp, Vec<Scalar>)> = taps.held.try_iter().collect();
let bars: Vec<(Timestamp, Vec<Scalar>)> = taps.bars.try_iter().collect();
let breakout: Vec<(Timestamp, Vec<Scalar>)> = taps.breakout.try_iter().collect();
// held shares the equity spine (same node-firing cadence); align by index is
// safe between those two, but we still fuse the side taps by ts.
let held_by_ts: HashMap<i64, f64> =
held.iter().map(|(t, r)| (t.0, as_f64(&r[0]))).collect();
let bars_by_ts: HashMap<i64, i64> =
bars.iter().map(|(t, r)| (t.0, as_i64(&r[0]))).collect();
let breakout_by_ts: HashMap<i64, bool> =
breakout.iter().map(|(t, r)| (t.0, as_bool(&r[0]))).collect();
equity
.iter()
.map(|(ts, row)| BarTrace {
ts: *ts,
equity: as_f64(&row[0]),
held: held_by_ts.get(&ts.0).copied().unwrap_or(0.0),
bars_since_open: bars_by_ts.get(&ts.0).copied().unwrap_or(-1),
breakout: breakout_by_ts.get(&ts.0).copied().unwrap_or(false),
join_on_ts(&equity, &[&held, &bars, &breakout])
.into_iter()
.map(|j| BarTrace {
ts: j.ts,
equity: as_f64(&j.spine[0]),
held: j.sides[0].as_ref().map_or(0.0, |r| as_f64(&r[0])),
bars_since_open: j.sides[1].as_ref().map_or(-1, |r| as_i64(&r[0])),
breakout: j.sides[2].as_ref().is_some_and(|r| as_bool(&r[0])),
})
.collect()
}