plan: trace charts iter 1 (persist) — ColumnarTrace, TraceStore, --trace wiring

Iteration-1 (persist half) plan for spec 0056: ColumnarTrace (aura-engine), TraceStore (aura-registry), and the shared persist_traces helper threaded into all three run forms. RED-first; Task 3 folds all nine signature-change callers in before the build gate (per plan-recon's compile-gate warning). Serve/chart half deferred to iteration 2.

refs #101
This commit is contained in:
2026-06-18 23:41:42 +02:00
parent bcf48f3f86
commit 99a18ff00c
+769
View File
@@ -0,0 +1,769 @@
# Trace charts — iteration 1 (persist) — Implementation Plan
> **Parent spec:** `docs/specs/0056-trace-charts-persist-and-serve.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Persist a run's drained recorder taps to disk as columnar (SoA) JSON
under `runs/traces/<name>/`, reachable via `aura run [--macd] [--trace <name>]` and
`aura run --real <SYM> ... [--trace <name>]`, leaving the default (no-`--trace`) run
byte-for-byte unchanged. (The serve/chart half is iteration 2.)
**Architecture:** Three layers, each landing beside its existing siblings.
`ColumnarTrace` (a pure transpose of `&[(Timestamp, Vec<Scalar>)]` ↔ columnar f64,
kind-tagged) goes in `aura-engine/report.rs` next to `join_on_ts`/`summarize`. A
`TraceStore` (write/read a per-run directory of `<tap>.json` + `index.json`) goes in
a new `aura-registry/trace_store.rs`, mirroring the `families.jsonl` sibling-store
discipline. A CLI-local `persist_traces` helper threads into all three run forms via
a new `trace: Option<&str>` parameter. The engine gains no I/O (it only encodes); the
registry crate does the file I/O; the CLI wires it.
**Tech Stack:** Rust; `serde`/`serde_json` (already deps of both crates); the
existing `Scalar`/`ScalarKind`/`Timestamp` types; `aura-engine`'s `RunManifest`; the
`std::process::Command`-driven `cli_run.rs` e2e harness.
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/report.rs:187` — add `ColumnarTrace` (struct +
`from_rows`/`to_rows` + two private coercion helpers) after `join_on_ts`, before
`#[cfg(test)]` at :189; add three unit tests inside the existing test module.
- Modify: `crates/aura-engine/src/lib.rs:64` — add `ColumnarTrace` to the
`pub use report::{…}` re-export.
- Create: `crates/aura-registry/src/trace_store.rs``TraceStore` + `RunTraces` +
`TraceStoreError` + three temp-dir unit tests.
- Modify: `crates/aura-registry/src/lib.rs:24` — add `mod trace_store;` +
`pub use trace_store::{RunTraces, TraceStore, TraceStoreError};`.
- Modify: `crates/aura-cli/src/main.rs` — imports (:1726), `persist_traces` helper
(new, before `run_sample` at :158), `run_sample` (:158), `run_sample_real` (:193),
`parse_real_args` (:264), `run_macd` (:823), `USAGE` (:858), the argv arms
(:867870), and the nine in-file test callers (:10991100, :11801181, :1215,
:1220, :12301239, :12481249).
- Test: `crates/aura-cli/tests/cli_run.rs` — one new e2e: `aura run --trace demo`
writes `index.json` + per-tap files; plain `aura run` writes none.
---
### Task 1: `ColumnarTrace` (aura-engine)
**Files:**
- Modify: `crates/aura-engine/src/report.rs` (add type + helpers before :189; tests in the existing `mod tests`)
- Modify: `crates/aura-engine/src/lib.rs:64`
- [ ] **Step 1: Write the failing tests**
Append these three tests inside the existing `#[cfg(test)] mod tests { … }` in
`crates/aura-engine/src/report.rs` (e.g. directly after the `join_on_ts_*` tests,
before the closing `}` of the module). They use `Scalar`/`ScalarKind`/`Timestamp`,
already imported by the test module via `use super::*;`:
```rust
#[test]
fn columnar_trace_round_trips_f64_rows() {
let rows = vec![
(Timestamp(2), vec![Scalar::f64(10.0)]),
(Timestamp(3), vec![Scalar::f64(20.0)]),
(Timestamp(4), vec![Scalar::f64(30.0)]),
];
let ct = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows);
assert_eq!(ct.tap, "equity");
assert_eq!(ct.kinds, vec!["F64".to_string()]);
assert_eq!(ct.ts, vec![2, 3, 4]);
assert_eq!(ct.columns, vec![vec![10.0, 20.0, 30.0]]);
// to_rows is the inverse for f64 taps
assert_eq!(ct.to_rows(), rows);
}
#[test]
fn columnar_trace_empty_rows_yields_empty_columns_sized_to_kinds() {
let ct = ColumnarTrace::from_rows("exposure", &[ScalarKind::F64], &[]);
assert_eq!(ct.ts, Vec::<i64>::new());
assert_eq!(ct.columns, vec![Vec::<f64>::new()]);
assert!(ct.to_rows().is_empty());
}
#[test]
fn columnar_trace_coerces_non_f64_kinds_to_f64() {
let rows = vec![
(Timestamp(1), vec![Scalar::i64(7), Scalar::bool(true)]),
(Timestamp(2), vec![Scalar::i64(9), Scalar::bool(false)]),
];
let ct = ColumnarTrace::from_rows("mix", &[ScalarKind::I64, ScalarKind::Bool], &rows);
assert_eq!(ct.kinds, vec!["I64".to_string(), "Bool".to_string()]);
assert_eq!(ct.columns, vec![vec![7.0, 9.0], vec![1.0, 0.0]]);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `cargo test -p aura-engine columnar_trace`
Expected: FAIL — compile error `cannot find type/function ColumnarTrace in this scope` (the type does not exist yet).
- [ ] **Step 3: Write the implementation**
Insert this immediately after `join_on_ts` ends (`report.rs:187`) and before
`#[cfg(test)]` (`report.rs:189`). `Scalar`/`ScalarKind`/`Timestamp` are already
imported at `report.rs:11`:
```rust
/// One drained recorder tap in columnar (SoA) form: parallel arrays, one per
/// recorded column, plus the shared recorded-timestamp axis. Chart-ready (a column
/// is a series of numbers) and kind-tagged (C7) — values are coerced to f64 for
/// plotting; the base type survives in `kinds`. Pure: identical rows always encode
/// to the same value (C1).
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ColumnarTrace {
pub tap: String,
pub kinds: Vec<String>,
pub ts: Vec<i64>,
pub columns: Vec<Vec<f64>>,
}
impl ColumnarTrace {
/// Transpose a drained tap's `(ts, row)` pairs into columns. An empty `rows`
/// yields empty `ts` and one empty column per kind. Each cell is coerced to f64:
/// f64 as-is, i64 as f64, bool 1.0/0.0, timestamp epoch as f64.
pub fn from_rows(tap: &str, kinds: &[ScalarKind], rows: &[(Timestamp, Vec<Scalar>)]) -> Self {
let ts: Vec<i64> = rows.iter().map(|(t, _)| t.0).collect();
let mut columns: Vec<Vec<f64>> = vec![Vec::with_capacity(rows.len()); kinds.len()];
for (_, row) in rows {
for (c, scalar) in row.iter().enumerate() {
columns[c].push(scalar_to_f64(*scalar));
}
}
ColumnarTrace {
tap: tap.to_string(),
kinds: kinds.iter().map(kind_tag).collect(),
ts,
columns,
}
}
/// Inverse for the serve/align path: rebuild `(ts, row)` pairs with each cell as
/// `Scalar::f64(columns[c][r])` — uniformly f64 (the on-disk store is f64
/// columns; the base type survives only in `kinds`). A true value round-trip for
/// f64 taps; keeps the serve-side `as_f64` projection total.
pub fn to_rows(&self) -> Vec<(Timestamp, Vec<Scalar>)> {
self.ts
.iter()
.enumerate()
.map(|(r, &t)| {
let row = self.columns.iter().map(|col| Scalar::f64(col[r])).collect();
(Timestamp(t), row)
})
.collect()
}
}
/// The four-kind tag string for a column (the on-disk `kinds` form; `ScalarKind`
/// derives no serde, so tags are plain strings).
fn kind_tag(kind: &ScalarKind) -> String {
match kind {
ScalarKind::F64 => "F64",
ScalarKind::I64 => "I64",
ScalarKind::Bool => "Bool",
ScalarKind::Timestamp => "Timestamp",
}
.to_string()
}
/// Coerce a recorded scalar to the f64 a chart plots: f64 as-is, i64 as f64,
/// bool 1.0/0.0, timestamp epoch as f64.
fn scalar_to_f64(s: Scalar) -> f64 {
match s.kind() {
ScalarKind::F64 => s.as_f64(),
ScalarKind::I64 => s.as_i64() as f64,
ScalarKind::Bool => {
if s.as_bool() {
1.0
} else {
0.0
}
}
ScalarKind::Timestamp => s.as_ts().0 as f64,
}
}
```
- [ ] **Step 4: Re-export `ColumnarTrace`**
In `crates/aura-engine/src/lib.rs:64`, change:
```rust
pub use report::{f64_field, join_on_ts, summarize, JoinedRow, RunManifest, RunMetrics, RunReport};
```
to:
```rust
pub use report::{
f64_field, join_on_ts, summarize, ColumnarTrace, JoinedRow, RunManifest, RunMetrics, RunReport,
};
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `cargo test -p aura-engine columnar_trace`
Expected: PASS — `columnar_trace_round_trips_f64_rows`, `columnar_trace_empty_rows_yields_empty_columns_sized_to_kinds`, `columnar_trace_coerces_non_f64_kinds_to_f64` all green (3 passed).
---
### Task 2: `TraceStore` (aura-registry)
**Files:**
- Create: `crates/aura-registry/src/trace_store.rs`
- Modify: `crates/aura-registry/src/lib.rs:24`
- [ ] **Step 1: Register the new module**
In `crates/aura-registry/src/lib.rs`, directly after `mod lineage;` (`lib.rs:24`) and
its `pub use lineage::{…}` block (`lib.rs:2528`), add:
```rust
mod trace_store;
pub use trace_store::{RunTraces, TraceStore, TraceStoreError};
```
- [ ] **Step 2: Write the new file with its failing tests first**
Create `crates/aura-registry/src/trace_store.rs` with ONLY the test module + the
`use` header below (the impl is added in Step 4, so the tests fail to compile = RED):
```rust
//! The per-run trace store: a directory of columnar tap files under
//! `<runs_dir>/traces/<name>/`, beside the run registry's `runs.jsonl`. One
//! subdirectory per run name; one `<tap>.json` (a `ColumnarTrace`) per tap; plus
//! `index.json` carrying the run's `RunManifest` and the tap order. Mirrors the
//! registry's directory-co-located sibling-store discipline; a missing run reads as
//! `NotFound` (treat-as-absent), never a panic.
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use aura_engine::{ColumnarTrace, RunManifest};
use serde::{Deserialize, Serialize};
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{Scalar, ScalarKind, Timestamp};
fn temp_traces_root(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("aura-tracestore-{}-{}", std::process::id(), name));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp traces root");
dir
}
fn sample_manifest() -> RunManifest {
RunManifest {
commit: "c".to_string(),
params: vec![("p".to_string(), Scalar::f64(1.0))],
window: (Timestamp(1), Timestamp(5)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
}
}
fn sample_taps() -> Vec<ColumnarTrace> {
vec![
ColumnarTrace::from_rows(
"equity",
&[ScalarKind::F64],
&[(Timestamp(1), vec![Scalar::f64(0.0)]), (Timestamp(2), vec![Scalar::f64(0.5)])],
),
ColumnarTrace::from_rows(
"exposure",
&[ScalarKind::F64],
&[(Timestamp(1), vec![Scalar::f64(1.0)]), (Timestamp(2), vec![Scalar::f64(-1.0)])],
),
]
}
#[test]
fn write_then_read_round_trips() {
let root = temp_traces_root("roundtrip");
let store = TraceStore::open(&root);
let manifest = sample_manifest();
let taps = sample_taps();
store.write("demo", &manifest, &taps).expect("write");
let back = store.read("demo").expect("read");
assert_eq!(back.manifest, manifest);
assert_eq!(back.taps, taps);
assert!(root.join("traces/demo/index.json").exists());
assert!(root.join("traces/demo/equity.json").exists());
assert!(root.join("traces/demo/exposure.json").exists());
let _ = fs::remove_dir_all(&root);
}
#[test]
fn read_missing_run_is_not_found() {
let root = temp_traces_root("missing");
let store = TraceStore::open(&root);
match store.read("nope") {
Err(TraceStoreError::NotFound(name)) => assert_eq!(name, "nope"),
other => panic!("expected NotFound, got {other:?}"),
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn corrupt_tap_file_is_a_parse_error_naming_the_file() {
let root = temp_traces_root("corrupt");
let store = TraceStore::open(&root);
store.write("demo", &sample_manifest(), &sample_taps()).expect("write");
fs::write(root.join("traces/demo/equity.json"), "not json").expect("clobber");
match store.read("demo") {
Err(TraceStoreError::Parse { file, .. }) => assert!(file.ends_with("equity.json")),
other => panic!("expected Parse, got {other:?}"),
}
let _ = fs::remove_dir_all(&root);
}
}
```
- [ ] **Step 3: Run the tests to verify they fail**
Run: `cargo test -p aura-registry trace_store`
Expected: FAIL — compile error `cannot find type TraceStore / TraceStoreError / RunTraces` (impl not written yet).
- [ ] **Step 4: Write the implementation**
In `crates/aura-registry/src/trace_store.rs`, insert this BETWEEN the `use` header
and the `#[cfg(test)] mod tests` block:
```rust
/// A per-run trace directory rooted at `<runs_dir>/traces`. One subdir per run name.
pub struct TraceStore {
dir: PathBuf,
}
/// A run's read-back traces: its manifest plus its taps in index (write) order.
pub struct RunTraces {
pub manifest: RunManifest,
pub taps: Vec<ColumnarTrace>,
}
/// `index.json`: the run manifest + the tap names in write order (= read order).
#[derive(Serialize, Deserialize)]
struct Index {
manifest: RunManifest,
taps: Vec<String>,
}
impl TraceStore {
/// Bind to `<runs_dir>/traces`. No I/O — directories are created on write.
pub fn open(runs_dir: impl AsRef<Path>) -> TraceStore {
TraceStore { dir: runs_dir.as_ref().join("traces") }
}
/// Write one run's taps + index under `traces/<name>/`, creating directories as
/// needed. Each tap lands in `<tap>.json`; the manifest + tap order in
/// `index.json`.
pub fn write(
&self,
name: &str,
manifest: &RunManifest,
taps: &[ColumnarTrace],
) -> Result<(), TraceStoreError> {
let run_dir = self.dir.join(name);
fs::create_dir_all(&run_dir)?;
for tap in taps {
let path = run_dir.join(format!("{}.json", tap.tap));
let body = serde_json::to_string(tap).map_err(|source| TraceStoreError::Parse {
file: path.display().to_string(),
source,
})?;
fs::write(&path, body)?;
}
let index = Index {
manifest: manifest.clone(),
taps: taps.iter().map(|t| t.tap.clone()).collect(),
};
let index_path = run_dir.join("index.json");
let body = serde_json::to_string(&index).map_err(|source| TraceStoreError::Parse {
file: index_path.display().to_string(),
source,
})?;
fs::write(&index_path, body)?;
Ok(())
}
/// Read a run's index + taps back, in index order. A missing run directory (no
/// `index.json`) is `NotFound`, not a panic; a malformed file is `Parse{file,..}`.
pub fn read(&self, name: &str) -> Result<RunTraces, TraceStoreError> {
let run_dir = self.dir.join(name);
let index_path = run_dir.join("index.json");
let index_text = match fs::read_to_string(&index_path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(TraceStoreError::NotFound(name.to_string()))
}
Err(e) => return Err(TraceStoreError::Io(e)),
};
let index: Index = serde_json::from_str(&index_text).map_err(|source| {
TraceStoreError::Parse { file: index_path.display().to_string(), source }
})?;
let mut taps = Vec::with_capacity(index.taps.len());
for tap_name in &index.taps {
let path = run_dir.join(format!("{tap_name}.json"));
let text = fs::read_to_string(&path)?;
let tap: ColumnarTrace = serde_json::from_str(&text).map_err(|source| {
TraceStoreError::Parse { file: path.display().to_string(), source }
})?;
taps.push(tap);
}
Ok(RunTraces { manifest: index.manifest, taps })
}
}
/// What can go wrong reading or writing the trace store.
#[derive(Debug)]
pub enum TraceStoreError {
/// An I/O error reading or writing a trace file.
Io(std::io::Error),
/// A trace/index file did not parse (the offending file named).
Parse { file: String, source: serde_json::Error },
/// No recorded run of this name (no `index.json` under `traces/<name>/`).
NotFound(String),
}
impl fmt::Display for TraceStoreError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TraceStoreError::Io(e) => write!(f, "trace store i/o: {e}"),
TraceStoreError::Parse { file, source } => {
write!(f, "trace store parse error in {file}: {source}")
}
TraceStoreError::NotFound(name) => {
write!(f, "no recorded run '{name}' under runs/traces")
}
}
}
}
impl std::error::Error for TraceStoreError {}
impl From<std::io::Error> for TraceStoreError {
fn from(e: std::io::Error) -> Self {
TraceStoreError::Io(e)
}
}
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `cargo test -p aura-registry trace_store`
Expected: PASS — `write_then_read_round_trips`, `read_missing_run_is_not_found`,
`corrupt_tap_file_is_a_parse_error_naming_the_file` all green (3 passed).
---
### Task 3: CLI persist wiring (aura-cli)
This task is **one compile unit**: the `trace: Option<&str>` parameter on three run
fns and the `parse_real_args` 3→4-tuple widen break every caller (including nine
in-file test callers), so all caller-threading lands here before the build gate. The
RED test (Step 1) is an e2e that compiles against the *binary*, not the changed
signatures, so it fails cleanly before any code change.
**Files:**
- Test: `crates/aura-cli/tests/cli_run.rs` (new e2e)
- Modify: `crates/aura-cli/src/main.rs` (imports :1726, helper, :158, :193, :264, :823, USAGE :858, argv :867870, test callers :10991100/:11801181/:1215/:1220/:12301239/:12481249)
- [ ] **Step 1: Write the failing e2e test**
Append to `crates/aura-cli/tests/cli_run.rs` (it already has `BIN` + `temp_cwd`):
```rust
#[test]
fn run_trace_persists_taps_and_plain_run_writes_no_traces() {
let cwd = temp_cwd("run-trace");
// plain `aura run` (no --trace) persists nothing to disk.
let plain = Command::new(BIN).arg("run").current_dir(&cwd).output().expect("spawn aura run");
assert!(plain.status.success(), "plain run exit: {:?}", plain.status);
assert!(!cwd.join("runs/traces").exists(), "plain run must write no trace files");
// `aura run --trace demo` persists the two taps + index, and still prints the report.
let traced = Command::new(BIN)
.args(["run", "--trace", "demo"])
.current_dir(&cwd)
.output()
.expect("spawn aura run --trace");
assert!(traced.status.success(), "traced run exit: {:?}", traced.status);
let stdout = String::from_utf8(traced.stdout).expect("utf-8 stdout");
assert!(stdout.trim_end().starts_with("{\"manifest\":{"), "report still printed: {stdout:?}");
assert!(cwd.join("runs/traces/demo/index.json").exists(), "index.json written");
assert!(cwd.join("runs/traces/demo/equity.json").exists(), "equity tap written");
assert!(cwd.join("runs/traces/demo/exposure.json").exists(), "exposure tap written");
let _ = std::fs::remove_dir_all(&cwd);
}
```
- [ ] **Step 2: Run the e2e to verify it fails**
Run: `cargo test -p aura-cli --test cli_run run_trace_persists_taps_and_plain_run_writes_no_traces`
Expected: FAIL — `traced run exit` assertion fails: `["run","--trace","demo"]` has no argv arm yet, so it falls to the usage-error path (exit 2), and no trace files are written. (The tree still compiles — no source change yet.)
- [ ] **Step 3: Extend the imports**
In `crates/aura-cli/src/main.rs`, add `ColumnarTrace` to the `aura_engine::{…}`
import (`main.rs:18`) and `TraceStore` to the `aura_registry::{…}` import
(`main.rs:25`):
```rust
use aura_engine::{
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, TraceStore,
};
```
(Only `TraceStore` is needed this iteration; `persist_traces` Display-formats the
error inline, so `TraceStoreError` is not named here — importing it would be an
unused import under `-D warnings`. Iteration 2's `emit_chart` adds it.)
- [ ] **Step 4: Add the `persist_traces` helper**
Insert directly before `fn run_sample` (`main.rs:158`):
```rust
/// 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);
}
}
```
- [ ] **Step 5: Thread `trace` through `run_sample`**
Replace `fn run_sample` (`main.rs:158184`) with (signature gains `trace`, manifest
hoisted before the persist call, fold inlined after):
```rust
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()))];
let window = window_of(&sources).expect("non-empty synthetic stream");
h.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 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 }
}
```
- [ ] **Step 6: Thread `trace` through `run_sample_real`**
In `fn run_sample_real` (`main.rs:193257`): change the signature to
`fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>, trace: Option<&str>) -> RunReport`,
then replace the drain-to-RunReport tail (`main.rs:238256`) with:
```rust
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 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 }
}
```
- [ ] **Step 7: Widen `parse_real_args` to a 4-tuple with a `--trace` tail flag**
Replace `fn parse_real_args` (`main.rs:264283`) with:
```rust
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)?;
match *flag {
"--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, trace))
}
```
- [ ] **Step 8: Thread `trace` through `run_macd`**
In `fn run_macd` (`main.rs:823856`): change the signature to
`fn run_macd(trace: Option<&str>) -> RunReport`, then replace the drain-to-RunReport
tail (`main.rs:836855`) with:
```rust
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 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 }
}
```
- [ ] **Step 9: Update the argv arms**
Replace the three run arms (`main.rs:867875``["run"]`, `["run","--macd"]`, the
`["run","--real", rest @ ..]` block) with:
```rust
["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, trace)) => {
println!("{}", run_sample_real(&sym, from, to, trace.as_deref()).to_json())
}
Err(msg) => {
eprintln!("aura: {msg}");
std::process::exit(2);
}
},
```
- [ ] **Step 10: Update `USAGE`**
Replace the `USAGE` const (`main.rs:858859`) with (one line; adds `[--trace <name>]`
on both run forms — the `chart` verb is added in iteration 2):
```rust
const USAGE: &str =
"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>]";
```
- [ ] **Step 11: Migrate the nine in-file test callers**
These break on the signature/tuple changes; update each in place:
- `main.rs:10991100`: `run_macd()``run_macd(None)` (both lines).
- `main.rs:12481249`: `run_sample()``run_sample(None)` (both lines).
- `main.rs:1180`, `1181`, `1215`, `1220`: append `, None` as the 4th arg to each
`run_sample_real(SYMBOL_OR_"GER40", Some(..), Some(..))` call, e.g.
`run_sample_real(SYMBOL, Some(GER40_SEP2024_FROM_MS), Some(GER40_SEP2024_TO_MS), None)`.
- `parse_real_args` test (`main.rs:12291244`): replace the body with the 4-tuple
shape + a new `--trace` case:
```rust
fn parse_real_args_accepts_symbol_and_optional_window() {
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), None))
);
// flags in any order
assert_eq!(
parse_real_args(&["EURUSD", "--to", "200", "--from", "100"]),
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());
assert!(parse_real_args(&[]).is_err());
assert!(parse_real_args(&["EURUSD", "--from", "notanumber"]).is_err());
}
```
- [ ] **Step 12: Build gate — workspace compiles, full aura-cli suite green**
Run: `cargo build --workspace`
Expected: PASS — 0 errors (all callers threaded; no unresolved signature/tuple breaks).
Run: `cargo test -p aura-cli`
Expected: PASS — the new e2e `run_trace_persists_taps_and_plain_run_writes_no_traces`
passes, the updated `parse_real_args_accepts_symbol_and_optional_window` passes, and
the byte-for-byte default-run pins (`run_prints_json_and_exits_zero`,
`run_with_trailing_token_is_strict_and_exits_two`) stay green (default run unchanged).
- [ ] **Step 13: Workspace regression gate**
Run: `cargo test --workspace`
Expected: PASS — Task 1 (`columnar_trace_*`) + Task 2 (`trace_store::tests::*`) + the
CLI suite all green; no regression elsewhere.
Run: `cargo clippy --workspace --all-targets -- -D warnings`
Expected: PASS — no warnings. (Watch for: an unused import if `TraceStore`/`ColumnarTrace`
is imported but a step is skipped; a `clippy::needless_range_loop` on `from_rows`'s
index loop — if flagged, the `.enumerate()` form already shown avoids it.)