feat(0070): wire folding sink reductions into the stage1-r sweep (closes #138)

Completes the M3 fix for BLOCKER #138. The no-trace stage1-r sweep now folds its
reductions online: `stage1_r_graph(reduce=true)` wires `SeriesReducer` (equity,
exposure -> one summary row each) and `GatedRecorder` (gated on
`closed_this_cycle` -> only the trade rows + the final row), and the folded
consumer rebuilds `RunMetrics` from those compact outputs. Per-member memory drops
from O(cycles) (~2 GiB over ~5.5M one-minute bars) to O(trades), so full-history
multi-member sweeps no longer exhaust RAM+swap. The `r_equity` tap is omitted when
reducing (it is consumed only by the `--trace` persistence path).

`reduce = trace.is_none()`: the `--trace` path and the single `run_stage1_r` keep
the raw recorders + `summarize`/`summarize_r` over full rows — the byte-for-byte
reference. A new CLI-seam test
(`sweep_strategy_stage1_r_folded_no_trace_metrics_equal_raw_trace_metrics`) proves
the folded no-trace sweep reports byte-identical per-member metrics to the raw
`--trace` path.

Verified: cargo test --workspace green (42 suites); cargo clippy --workspace
--all-targets -- -D warnings clean; stage1-r goldens byte-identical.

closes #138
This commit is contained in:
2026-06-25 11:24:27 +02:00
parent cfe7bad0ff
commit eb8653488f
2 changed files with 169 additions and 30 deletions
+70 -30
View File
@@ -19,7 +19,7 @@ use aura_composites::{risk_executor, risk_executor_vol_open, StopRule};
use aura_engine::{
f64_field, join_on_ts, monte_carlo, param_stability, summarize, summarize_r, walk_forward,
window_of, ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, JoinedRow,
McAggregate, McFamily, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily,
McAggregate, McFamily, RollMode, RunManifest, RunMetrics, RunReport, SourceSpec, SweepFamily,
SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds, WindowRoller, WindowRun,
};
use aura_registry::{
@@ -28,7 +28,8 @@ use aura_registry::{
WriteKind,
};
use aura_std::{
Bias, Ema, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub, PM_FIELD_NAMES, PM_RECORD_KINDS,
Bias, Ema, GatedRecorder, LinComb, LongOnly, Recorder, SeriesReducer, SimBroker, Sma, Sub,
PM_FIELD_NAMES, PM_RECORD_KINDS,
};
use std::sync::mpsc::{self, Receiver};
use std::sync::LazyLock;
@@ -1176,7 +1177,7 @@ fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RG
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true);
let bp = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, true);
let space = bp.param_space();
// resolve the open stop slots' exact path-qualified names from the live param-space
// (the composite prefix is never hand-synced — match by the stable suffix).
@@ -1209,14 +1210,11 @@ fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RG
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true)
let reduce = trace.is_none();
let mut h = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, None, None, true, reduce)
.bootstrap_with_cells(point)
.expect("stage1-r grid points are kind-checked against param_space");
h.run(data.run_sources());
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 r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
// record the swept knobs PLUS the fixed R-defining bias scale, matching the
// single run: a family member must be reproducible from its own manifest
// (C18). The stop knobs are now real swept axes (they appear in `point`), so
@@ -1231,11 +1229,33 @@ fn stage1_r_sweep_family(trace: Option<&str>, data: &DataSource, grid: &Stage1RG
let key = member_key(&named, &varying);
let mut manifest = sim_optimal_manifest(named, window, 0, pip);
manifest.broker = stage1_r_broker_label(pip);
if let Some(name) = trace {
persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows);
}
let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
metrics.r = Some(summarize_r(&r_rows, 0.0));
let metrics = if reduce {
// folded: GatedRecorder emits O(trades) R rows; each SeriesReducer
// emits one [last, max_drawdown, sign_flips] summary row.
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let (total_pips, max_drawdown) = rx_eq
.try_iter()
.next()
.map(|(_, row)| (row[0].as_f64(), row[1].as_f64()))
.unwrap_or((0.0, 0.0));
let bias_sign_flips =
rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0);
let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None };
m.r = Some(summarize_r(&r_rows, 0.0));
m
} else {
// trace path (--trace set): raw recorders, persist, summarize — today's code.
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 r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
if let Some(name) = trace {
persist_traces_r(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows, &req_rows);
}
let mut m = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
m.r = Some(summarize_r(&r_rows, 0.0));
m
};
RunReport { manifest, metrics }
})
.expect("the stage1-r named grid matches the stage1-r param-space")
@@ -1929,6 +1949,7 @@ fn stage1_r_graph(
fast_len: Option<i64>,
slow_len: Option<i64>,
stop_open: bool,
reduce: bool,
) -> Composite {
let mut g = GraphBuilder::new("stage1_r");
// SMA-cross signal → Bias (the same signal the pip sample uses).
@@ -1946,23 +1967,36 @@ fn stage1_r_graph(
let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5)));
// pip branch (verbatim from sample_blueprint_with_sinks).
let broker = g.add(SimBroker::builder(SYNTHETIC_PIP_SIZE));
let eq = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq));
let ex = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex));
// R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop knobs
// are bound to the pinned constants (default) or left open as sweep axes (`stop_open`).
// In `reduce` mode the per-cycle taps fold online: SeriesReducer folds the eq/ex
// f64 series to one summary row, GatedRecorder retains only the gated R rows — the
// O(cycles)→O(trades) memory win. The raw `Recorder`s (and the r_equity tap) are the
// `--trace` path, where the full per-cycle series is persisted.
let gate_col = PM_FIELD_NAMES
.iter()
.position(|&n| n == "closed_this_cycle")
.expect("PM record has a closed_this_cycle column");
let eq = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_eq))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq))
};
let ex = if reduce {
g.add(SeriesReducer::builder(Firing::Any, tx_ex))
} else {
g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex))
};
let exec = g.add(if stop_open {
risk_executor_vol_open(1.0)
} else {
risk_executor(StopRule::Vol { length: STAGE1_R_STOP_LENGTH, k: STAGE1_R_STOP_K }, 1.0)
});
let rrec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r));
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
let r_equity = g.add(
LinComb::builder(2)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0)),
);
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
let rrec = if reduce {
g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r))
} else {
g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r))
};
let price = g.source_role("price", ScalarKind::F64);
g.feed(
price,
@@ -1971,19 +2005,25 @@ fn stage1_r_graph(
g.connect(fast.output("value"), spread.input("lhs"));
g.connect(slow.output("value"), spread.input("rhs"));
g.connect(spread.output("value"), exposure.input("signal"));
// bias fans to: broker (pip), exposure sink, and the RiskExecutor (R).
g.connect(exposure.output("bias"), broker.input("exposure"));
g.connect(exposure.output("bias"), ex.input("col[0]"));
g.connect(exposure.output("bias"), exec.input("bias"));
g.connect(broker.output("equity"), eq.input("col[0]"));
// tap the dense R-record (all PM fields) for summarize_r.
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str()));
}
// r_equity sum + tap.
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
g.connect(r_equity.output("value"), req.input("col[0]"));
if !reduce {
// r_equity = cum_realized_r + unrealized_r — one tapped series for charting.
let r_equity = g.add(
LinComb::builder(2)
.bind("weights[0]", Scalar::f64(1.0))
.bind("weights[1]", Scalar::f64(1.0)),
);
let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req));
g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]"));
g.connect(exec.output("unrealized_r"), r_equity.input("term[1]"));
g.connect(r_equity.output("value"), req.input("col[0]"));
}
g.build().expect("stage1_r wiring resolves")
}
@@ -2003,7 +2043,7 @@ fn run_stage1_r(data: RunData, trace: Option<&str>) -> RunReport {
let (tx_ex, rx_ex) = mpsc::channel();
let (tx_r, rx_r) = mpsc::channel();
let (tx_req, rx_req) = mpsc::channel();
let flat = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4), false)
let flat = stage1_r_graph(tx_eq, tx_ex, tx_r, tx_req, Some(2), Some(4), false, false)
.compile_with_params(&[])
.expect("valid stage1-r blueprint");
let mut h = Harness::bootstrap(flat).expect("valid stage1-r harness");