feat(trace-charts): persist recorder taps to disk (iter 1)

Iteration 1 (persist half) of the visual face — spec 0056 / plan 0055:
tap data from a graph and save it to disk so it can later be charted
(the serve/chart half is iteration 2).

- ColumnarTrace (aura-engine): a drained tap <-> columnar SoA form
  ({tap, kinds, ts, columns}); values coerced to f64 for plotting with
  the base type preserved in `kinds` (C7); to_rows rebuilds uniformly-f64
  rows for the serve-side join. Pure (C1), beside join_on_ts/summarize.
- TraceStore (aura-registry): a per-run directory under
  runs/traces/<name>/ — one <tap>.json per tap + an index.json (manifest
  + tap order), beside the families.jsonl sibling store. Missing run =
  NotFound (no panic); corrupt file = Parse{file}.
- CLI: a shared persist_traces helper threaded into run_sample /
  run_macd / run_sample_real via an orthogonal `trace: Option<&str>`;
  new arms `aura run [--macd] --trace <name>` and `--trace <name>` on
  `run --real`. The default (no --trace) run is byte-for-byte unchanged.

Verified by the orchestrator: `cargo test --workspace` green (incl. the
4 columnar_trace, 3 trace_store, 4 cli_run e2e, and the 4-tuple
parse_real_args tests); `cargo clippy --workspace --all-targets -D
warnings` exit 0. Implementer deviations folded in, all
compiler-prescribed: #[derive(Debug)] on RunTraces (the RED tests
panic-format a Result over it), #[allow(clippy::type_complexity)] on
the parse_real_args 4-tuple (mirrors the crate's sample_harness idiom).
Default-run byte-identity stays pinned by run_prints_json_and_exits_zero.

refs #101
This commit is contained in:
2026-06-19 00:14:18 +02:00
parent 99a18ff00c
commit 8bb5256041
6 changed files with 647 additions and 79 deletions
+106 -78
View File
@@ -15,14 +15,14 @@ mod render;
use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, Composite, Edge,
FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode, RunManifest, RunReport,
SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource, WalkForwardResult, WindowBounds,
WindowRoller, WindowRun,
f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, ColumnarTrace,
Composite, Edge, FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode,
RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource,
WalkForwardResult, WindowBounds, WindowRoller, WindowRun,
};
use aura_registry::{
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
walkforward_member_reports, FamilyKind, Registry,
walkforward_member_reports, FamilyKind, Registry, TraceStore,
};
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
@@ -152,10 +152,31 @@ fn sim_optimal_manifest(
}
}
/// Persist a run's drained taps to the on-disk trace store under `runs/traces/<name>/`
/// (beside the run registry's `runs/`). Shared by every run form — all drain the same
/// two f64 taps (equity off the broker, exposure off the strategy) and carry a
/// `RunManifest`. Pure wiring over `ColumnarTrace` + `TraceStore`; the engine is
/// untouched. An I/O failure is a usage-level error (stderr + exit 2), per the spec.
fn persist_traces(
name: &str,
manifest: &RunManifest,
eq_rows: &[(Timestamp, Vec<Scalar>)],
ex_rows: &[(Timestamp, Vec<Scalar>)],
) {
let taps = vec![
ColumnarTrace::from_rows("equity", &[ScalarKind::F64], eq_rows),
ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], ex_rows),
];
if let Err(e) = TraceStore::open("runs").write(name, manifest, &taps) {
eprintln!("aura: trace persist failed: {e}");
std::process::exit(2);
}
}
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
/// (C1): the same build yields the same report.
fn run_sample() -> RunReport {
fn run_sample(trace: Option<&str>) -> RunReport {
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
let sources: Vec<Box<dyn aura_engine::Source>> =
vec![Box::new(VecSource::new(synthetic_prices()))];
@@ -164,23 +185,21 @@ fn run_sample() -> RunReport {
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);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
SYNTHETIC_PIP_SIZE,
),
metrics,
let manifest = sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
SYNTHETIC_PIP_SIZE,
);
if let Some(name) = trace {
persist_traces(name, &manifest, &eq_rows, &ex_rows);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
/// `aura run --real <SYMBOL>`: run the built-in sample harness over real M1 close
@@ -190,7 +209,7 @@ fn run_sample() -> RunReport {
/// (a Source is single-pass), so the run source streams the window untouched.
/// A no-local-data condition (unknown symbol, or a window overlapping no file / no
/// bars) is a usage error: stderr + exit(2), not a panic.
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> RunReport {
fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>) -> RunReport {
// Per-instrument pip: look up BEFORE any data access, so an un-specced symbol
// refuses without touching local data. Honest by construction.
let spec = match aura_ingest::instrument_spec(symbol) {
@@ -237,23 +256,21 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> Ru
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);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
spec.pip_size,
),
metrics,
let manifest = sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
spec.pip_size,
);
if let Some(name) = trace {
persist_traces(name, &manifest, &eq_rows, &ex_rows);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
/// Parse the `run --real` tail: a mandatory `<SYMBOL>` then zero-or-more
@@ -261,25 +278,29 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> Ru
/// arg grammar is unit-testable; `main` does the side effects. An unknown token, a
/// flag without its value, a non-`i64` ms, or a repeated flag is an `Err` carrying
/// a short usage message.
fn parse_real_args(rest: &[&str]) -> Result<(String, Option<i64>, Option<i64>), String> {
let usage = || "run --real <SYMBOL> [--from <ms>] [--to <ms>]".to_string();
#[allow(clippy::type_complexity)]
fn parse_real_args(
rest: &[&str],
) -> Result<(String, Option<i64>, Option<i64>, Option<String>), String> {
let usage = || "run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>]".to_string();
let (symbol, mut tail) = match rest.split_first() {
Some((sym, t)) if !sym.is_empty() => (*sym, t),
_ => return Err(usage()),
};
let mut from: Option<i64> = None;
let mut to: Option<i64> = None;
let mut trace: Option<String> = None;
while let Some((flag, t)) = tail.split_first() {
let (value, t) = t.split_first().ok_or_else(usage)?;
let ms: i64 = value.parse().map_err(|_| usage())?;
match *flag {
"--from" if from.is_none() => from = Some(ms),
"--to" if to.is_none() => to = Some(ms),
"--from" if from.is_none() => from = Some(value.parse().map_err(|_| usage())?),
"--to" if to.is_none() => to = Some(value.parse().map_err(|_| usage())?),
"--trace" if trace.is_none() => trace = Some((*value).to_string()),
_ => return Err(usage()),
}
tail = t;
}
Ok((symbol.to_string(), from, to))
Ok((symbol.to_string(), from, to, trace))
}
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
@@ -820,7 +841,7 @@ fn macd_prices() -> Vec<(Timestamp, Scalar)> {
/// (the same bootstrap path the SMA sample's compiled view uses), drive it on the
/// synthetic stream, and fold both sinks into a `RunReport`. Pure and
/// deterministic (C1).
fn run_macd() -> RunReport {
fn run_macd(trace: Option<&str>) -> RunReport {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let flat = macd_strategy_blueprint(tx_eq, tx_ex)
@@ -835,28 +856,26 @@ fn run_macd() -> RunReport {
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);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: sim_optimal_manifest(
vec![
("ema_fast".to_string(), Scalar::i64(2)),
("ema_slow".to_string(), Scalar::i64(4)),
("ema_signal".to_string(), Scalar::i64(3)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
SYNTHETIC_PIP_SIZE,
),
metrics,
let manifest = sim_optimal_manifest(
vec![
("ema_fast".to_string(), Scalar::i64(2)),
("ema_slow".to_string(), Scalar::i64(4)),
("ema_signal".to_string(), Scalar::i64(3)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
SYNTHETIC_PIP_SIZE,
);
if let Some(name) = trace {
persist_traces(name, &manifest, &eq_rows, &ex_rows);
}
let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0));
RunReport { manifest, metrics }
}
const USAGE: &str =
"usage: aura run [--macd] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
fn main() {
// Collect argv and match the whole vector: every accepted form is exhaustive,
@@ -864,10 +883,14 @@ fn main() {
// than masquerading as a successful run (#16 strict reading).
let args: Vec<String> = std::env::args().skip(1).collect();
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["run"] => println!("{}", run_sample(None).to_json()),
["run", "--macd"] => println!("{}", run_macd(None).to_json()),
["run", "--trace", name] => println!("{}", run_sample(Some(name)).to_json()),
["run", "--macd", "--trace", name] => println!("{}", run_macd(Some(name)).to_json()),
["run", "--real", rest @ ..] => match parse_real_args(rest) {
Ok((sym, from, to)) => println!("{}", run_sample_real(&sym, from, to).to_json()),
Ok((sym, from, to, trace)) => {
println!("{}", run_sample_real(&sym, from, to, trace.as_deref()).to_json())
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
@@ -1096,8 +1119,8 @@ mod tests {
// the MACD strategy authors a nested EMA-of-EMA composite, compiles it to a
// flat runnable harness (the call not panicking proves the compile+bootstrap
// path), and runs it. C1 determinism: two runs are bit-identical.
let r1 = run_macd();
let r2 = run_macd();
let r1 = run_macd(None);
let r2 = run_macd(None);
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
@@ -1177,8 +1200,8 @@ mod tests {
// The headline: a real-data run over the bounded Sept-2024 window yields
// a finite, C1-deterministic RunReport.
let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS));
let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS));
let r1 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
let r2 = run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
assert!(
r1.metrics.total_pips.is_finite(),
@@ -1212,12 +1235,12 @@ mod tests {
// Bounded to the vetted Sept-2024 window so the pip-label + determinism
// checks run fast (not the full unbounded archive).
let report =
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS));
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
// The looked-up index pip (1.0) reaches the manifest — not the FX 0.0001.
assert_eq!(report.manifest.broker, "sim-optimal(pip_size=1)");
// Deterministic (C1): a second run yields the same report.
let again =
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS));
run_sample_real("GER40", Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None);
assert_eq!(report.manifest.broker, again.manifest.broker);
assert_eq!(report.metrics.total_pips, again.metrics.total_pips);
}
@@ -1227,15 +1250,20 @@ mod tests {
/// value — the pure arg grammar `main` relies on for `run --real`.
#[test]
fn parse_real_args_accepts_symbol_and_optional_window() {
assert_eq!(parse_real_args(&["EURUSD"]), Ok(("EURUSD".to_string(), None, None)));
assert_eq!(parse_real_args(&["EURUSD"]), Ok(("EURUSD".to_string(), None, None, None)));
assert_eq!(
parse_real_args(&["EURUSD", "--from", "100", "--to", "200"]),
Ok(("EURUSD".to_string(), Some(100), Some(200)))
Ok(("EURUSD".to_string(), Some(100), Some(200), None))
);
// flags in any order
assert_eq!(
parse_real_args(&["EURUSD", "--to", "200", "--from", "100"]),
Ok(("EURUSD".to_string(), Some(100), Some(200)))
Ok(("EURUSD".to_string(), Some(100), Some(200), None))
);
// the --trace tail flag carries a string name, parsed alongside the window
assert_eq!(
parse_real_args(&["EURUSD", "--from", "100", "--trace", "demo"]),
Ok(("EURUSD".to_string(), Some(100), None, Some("demo".to_string())))
);
// a flag without its value, an empty symbol, and a non-numeric ms all reject
assert!(parse_real_args(&["EURUSD", "--from"]).is_err());
@@ -1245,8 +1273,8 @@ mod tests {
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let r1 = run_sample();
let r2 = run_sample();
let r1 = run_sample(None);
let r2 = run_sample(None);
// C1 determinism: two runs are bit-identical (metrics + rendered JSON).
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());