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:
@@ -248,6 +248,171 @@ fn run_real_vetted_index_pip_reaches_the_emitted_manifest() {
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
/// Property: a persisted `<tap>.json` is the **columnar SoA form** a downstream
|
||||
/// chart can consume, not an opaque blob — it carries the four
|
||||
/// `tap`/`kinds`/`ts`/`columns` keys, the equity tap is kind-tagged `["F64"]`
|
||||
/// (C7: the base type survives on disk), and the SoA invariant holds: `ts` has one
|
||||
/// entry per recorded cycle and EVERY column has exactly that many entries (parallel
|
||||
/// arrays, one timestamp per row). This is acceptance criterion 2 at the built-binary
|
||||
/// file boundary — the unit round-trip pins the in-memory transpose, but only an
|
||||
/// end-to-end run pins that the bytes actually written to disk hold that shape.
|
||||
/// Asserts on structure + array-length parity, never on the exact float values
|
||||
/// (pip equity's low bits vary build-to-build), so it stays deterministic.
|
||||
#[test]
|
||||
fn run_trace_persists_columnar_soa_shape_on_disk() {
|
||||
let cwd = temp_cwd("run-trace-shape");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--trace", "demo"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run --trace");
|
||||
assert!(out.status.success(), "traced run exit: {:?}", out.status);
|
||||
|
||||
let equity = std::fs::read_to_string(cwd.join("runs/traces/demo/equity.json"))
|
||||
.expect("read equity.json");
|
||||
// The four SoA keys are present, equity is f64-kind-tagged, and the parallel
|
||||
// arrays open as expected (the synthetic sample harness has a single column).
|
||||
assert!(equity.contains("\"tap\":\"equity\""), "tap name missing: {equity}");
|
||||
assert!(equity.contains("\"kinds\":[\"F64\"]"), "F64 kind tag missing: {equity}");
|
||||
assert!(equity.contains("\"ts\":["), "ts axis missing: {equity}");
|
||||
assert!(equity.contains("\"columns\":[["), "columns array missing: {equity}");
|
||||
|
||||
// SoA invariant: one timestamp per row, and every column has exactly that many
|
||||
// entries. Count the ts entries (the synthetic window is 7 cycles) and the
|
||||
// single column's entries by their comma-separated cardinality.
|
||||
let ts_body = json_array_body(&equity, "\"ts\":[");
|
||||
let col_body = json_array_body(&equity, "\"columns\":[[");
|
||||
let ts_len = ts_body.split(',').count();
|
||||
let col_len = col_body.split(',').count();
|
||||
assert_eq!(ts_len, 7, "synthetic run records 7 cycles; ts was: {ts_body:?}");
|
||||
assert_eq!(
|
||||
col_len, ts_len,
|
||||
"SoA parity broken: ts has {ts_len} entries but the column has {col_len}; file: {equity}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: `index.json` is the run's **table of contents** — it carries the run's
|
||||
/// `RunManifest` (so a chart page can show run context: commit, broker, window) and
|
||||
/// the tap names in a **deterministic order** (`["equity","exposure"]`). The serve
|
||||
/// half (`aura chart`, iteration 2) reads taps in this index order; a regression that
|
||||
/// dropped the manifest, or made the tap list order-unstable, would silently corrupt
|
||||
/// the chart header / the per-series mapping. Asserts on the manifest's structural
|
||||
/// presence (the commit value is the volatile git HEAD, pinned elsewhere) and the
|
||||
/// exact deterministic tap order.
|
||||
#[test]
|
||||
fn run_trace_index_carries_manifest_and_ordered_tap_list() {
|
||||
let cwd = temp_cwd("run-trace-index");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--trace", "demo"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run --trace");
|
||||
assert!(out.status.success(), "traced run exit: {:?}", out.status);
|
||||
|
||||
let index = std::fs::read_to_string(cwd.join("runs/traces/demo/index.json"))
|
||||
.expect("read index.json");
|
||||
// The manifest is embedded with its identifying context for the chart header.
|
||||
assert!(index.contains("\"manifest\":{\"commit\":\""), "manifest missing: {index}");
|
||||
assert!(index.contains("\"window\":[1,7]"), "window missing from manifest: {index}");
|
||||
assert!(
|
||||
index.contains("\"broker\":\"sim-optimal(pip_size=0.0001)\""),
|
||||
"broker label missing: {index}"
|
||||
);
|
||||
// The tap list is the deterministic read order the serve path depends on.
|
||||
assert!(
|
||||
index.contains("\"taps\":[\"equity\",\"exposure\"]"),
|
||||
"tap order must be deterministic [equity, exposure]: {index}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: `--trace` is an **orthogonal modifier** — it composes with `aura run
|
||||
/// --real <SYM>`, not only plain `run`, and is strictly **additive**: the looked-up
|
||||
/// instrument pip still reaches stdout byte-for-byte while the same two taps land on
|
||||
/// disk. The spec's headline is "one shared persist helper, every run form"; the
|
||||
/// existing E2E only exercised the synthetic `run` path. Gated on local GER40 data
|
||||
/// (skip cleanly when absent, the project convention) so it never fails on a
|
||||
/// data-less machine; the no-data path is the distinct "no local data" refusal.
|
||||
#[test]
|
||||
fn run_real_with_trace_persists_taps_and_keeps_stdout_unchanged() {
|
||||
const FROM_MS: &str = "1725148800000";
|
||||
const TO_MS: &str = "1727740799999";
|
||||
let cwd = temp_cwd("run-real-trace");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", "--real", "GER40", "--from", FROM_MS, "--to", TO_MS, "--trace", "ger"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run --real --trace");
|
||||
|
||||
// Skip on a data-less machine: vetted GER40 with no local data takes the
|
||||
// distinct no-data path (exit 2), never the pip-refusal, and writes no traces.
|
||||
if out.status.code() == Some(2) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("no local data for symbol 'GER40'"),
|
||||
"exit 2 for vetted GER40 --trace must be the no-data path, got: {stderr}"
|
||||
);
|
||||
eprintln!("skip: no local GER40 data");
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
return;
|
||||
}
|
||||
|
||||
assert_eq!(out.status.code(), Some(0), "real --trace exit: {:?}", out.status);
|
||||
// Additive: the index-pip report still reaches stdout unchanged.
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout");
|
||||
let line = stdout.trim_end();
|
||||
assert!(line.starts_with("{\"manifest\":{"), "report still printed: {line}");
|
||||
assert!(
|
||||
line.contains("\"broker\":\"sim-optimal(pip_size=1)\""),
|
||||
"real --trace must keep the GER40 index pip on stdout: {line}"
|
||||
);
|
||||
// And the same two taps landed under the named run dir.
|
||||
assert!(cwd.join("runs/traces/ger/index.json").exists(), "index.json written");
|
||||
assert!(cwd.join("runs/traces/ger/equity.json").exists(), "equity tap written");
|
||||
assert!(cwd.join("runs/traces/ger/exposure.json").exists(), "exposure tap written");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Extract the body of a JSON array opened by `marker` (e.g. `"ts":[`) up to its
|
||||
/// closing `]`, by substring — no serde dependency, mirroring the sibling tests'
|
||||
/// substring parsing. Used only to count comma-separated entries for the SoA
|
||||
/// length-parity check.
|
||||
fn json_array_body<'a>(json: &'a str, marker: &str) -> &'a str {
|
||||
let start = json.find(marker).expect("array marker present") + marker.len();
|
||||
let rest = &json[start..];
|
||||
let end = rest.find(']').expect("array is closed");
|
||||
&rest[..end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_args_prints_usage_and_exits_two() {
|
||||
let out = Command::new(BIN).output().expect("spawn aura");
|
||||
|
||||
Reference in New Issue
Block a user