feat(family-traces): persist per-member streams for sweep/MC/walk-forward
Family runs (sweep, Monte-Carlo, walk-forward) ran a full harness per
member and drained its equity+exposure recorders, but immediately folded
the streams to metrics and discarded them — so the C21 family-comparison
view had no per-member data to chart. A new opt-in `--trace <name>` on
`aura sweep|mc|walkforward` now persists each member's streams to disk,
mirroring the single-run `aura run --trace` path. Any single member is
chartable today via `aura chart <name>/<member_key>`; the comparison view
itself is a later cycle.
Design:
- Persist INSIDE each per-member closure (aura-cli), reusing persist_traces
verbatim — the engine and the family types are untouched. The closures
already held the drained rows; they now bind them once, build the
manifest, persist when --trace is set, then fold as before.
- Content-derived, deterministic member keys (MC seed{N}, sweep f{fast}s{slow},
walk-forward oos{ns}). The engine HOFs are Fn + Sync — members run in
parallel, so a runtime ordinal counter would be non-deterministic (a C1
break); keys derive from what each closure receives deterministically.
sweep_member_key panics (naming the axis) if the grid lacks a key axis,
rather than silently collapsing two points to a shared dir.
- Each member is a standalone run-dir at runs/traces/<name>/<member_key>/
(persist_traces with a slash-name; TraceStore nests via create_dir_all),
so the existing single-run viewer charts a member with no view-side code.
- Opt-in: without --trace, stdout and the run registry are byte-unchanged;
the cardinality cost of N members x full resolution stays user-chosen.
Out of scope (deferred): the family-comparison view (overlay/small-multiples),
decimation/LOD, a binary trace container.
Verified locally: cargo build --workspace, cargo test --workspace (all green,
incl. 3 new family persistence tests + a sweep determinism test + 2
sweep_member_key unit tests), cargo clippy --workspace --all-targets
-D warnings (clean). The pre-existing family determinism and single-run
trace tests pass unchanged (no-trace path is byte-identical).
closes #104
This commit is contained in:
+125
-60
@@ -174,6 +174,27 @@ fn persist_traces(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The content-derived member key for a swept grid point: the two varying axes
|
||||||
|
/// of the built-in grid (`signals.trend.fast.length`, `signals.trend.slow.length`).
|
||||||
|
/// Deterministic + collision-free over that grid (distinct points differ in ≥1
|
||||||
|
/// of these). `named` is the `zip_params(&space, point)` the manifest already builds.
|
||||||
|
///
|
||||||
|
/// A key-driving axis that is absent from `named` is a grid/key drift, not data:
|
||||||
|
/// the collision-free promise above would silently break (two points collapsing
|
||||||
|
/// to a shared `f…s…` dir that overwrite each other's traces). So a missing axis
|
||||||
|
/// panics, naming the axis — the same caller-bug-is-a-panic idiom as `Scalar::as_i64`.
|
||||||
|
fn sweep_member_key(named: &[(String, Scalar)]) -> String {
|
||||||
|
let val = |name: &str| {
|
||||||
|
named
|
||||||
|
.iter()
|
||||||
|
.find(|(n, _)| n.as_str() == name)
|
||||||
|
.unwrap_or_else(|| panic!("sweep_member_key: grid is missing the key axis '{name}'"))
|
||||||
|
.1
|
||||||
|
.as_i64()
|
||||||
|
};
|
||||||
|
format!("f{}s{}", val("signals.trend.fast.length"), val("signals.trend.slow.length"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6
|
/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6
|
||||||
/// 3-step union-spine alignment — no tap privileged, no point dropped:
|
/// 3-step union-spine alignment — no tap privileged, no point dropped:
|
||||||
/// (1) xs = the sorted, deduped union of every tap's timestamps;
|
/// (1) xs = the sorted, deduped union of every tap's timestamps;
|
||||||
@@ -445,7 +466,7 @@ fn sample_blueprint() -> Composite {
|
|||||||
/// the same report. Each point builds a fresh blueprint (fresh sink channels),
|
/// the same report. Each point builds a fresh blueprint (fresh sink channels),
|
||||||
/// bootstraps it under the point vector, runs it, and folds the drained sinks to
|
/// bootstraps it under the point vector, runs it, and folds the drained sinks to
|
||||||
/// metrics — the per-point closure the engine `sweep` drives disjointly.
|
/// metrics — the per-point closure the engine `sweep` drives disjointly.
|
||||||
fn sweep_family() -> SweepFamily {
|
fn sweep_family(trace: Option<&str>) -> SweepFamily {
|
||||||
let bp = sample_blueprint_with_sinks().0;
|
let bp = sample_blueprint_with_sinks().0;
|
||||||
let space = bp.param_space();
|
let space = bp.param_space();
|
||||||
bp.axis("signals.trend.fast.length", [2, 3])
|
bp.axis("signals.trend.fast.length", [2, 3])
|
||||||
@@ -465,12 +486,17 @@ fn sweep_family() -> SweepFamily {
|
|||||||
vec![Box::new(VecSource::new(showcase_prices()))];
|
vec![Box::new(VecSource::new(showcase_prices()))];
|
||||||
let window = window_of(&sources).expect("non-empty showcase stream");
|
let window = window_of(&sources).expect("non-empty showcase stream");
|
||||||
h.run(sources);
|
h.run(sources);
|
||||||
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
|
||||||
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
|
||||||
RunReport {
|
let named = zip_params(&space, point);
|
||||||
manifest: sim_optimal_manifest(zip_params(&space, point), window, 0, SYNTHETIC_PIP_SIZE),
|
let key = sweep_member_key(&named);
|
||||||
metrics: summarize(&equity, &exposure),
|
let manifest = sim_optimal_manifest(named, window, 0, SYNTHETIC_PIP_SIZE);
|
||||||
|
if let Some(name) = trace {
|
||||||
|
persist_traces(&format!("{name}/{key}"), &manifest, &eq_rows, &ex_rows);
|
||||||
}
|
}
|
||||||
|
let equity = f64_field(&eq_rows, 0);
|
||||||
|
let exposure = f64_field(&ex_rows, 0);
|
||||||
|
RunReport { manifest, metrics: summarize(&equity, &exposure) }
|
||||||
})
|
})
|
||||||
.expect("the built-in named grid matches the sample param-space")
|
.expect("the built-in named grid matches the sample param-space")
|
||||||
}
|
}
|
||||||
@@ -480,7 +506,7 @@ fn sweep_family() -> SweepFamily {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn sweep_report() -> String {
|
fn sweep_report() -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for pt in &sweep_family().points {
|
for pt in &sweep_family(None).points {
|
||||||
out.push_str(&pt.report.to_json());
|
out.push_str(&pt.report.to_json());
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
@@ -494,12 +520,13 @@ fn default_registry() -> Registry {
|
|||||||
Registry::open("runs/runs.jsonl")
|
Registry::open("runs/runs.jsonl")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `aura sweep [--name <n>]`: run the built-in sweep, persist it as a *family*
|
/// `aura sweep [--name <n>|--trace <n>]`: run the built-in sweep, persist it as a
|
||||||
/// (related records sharing one `family_id`, C18/C21) via `append_family`, and
|
/// *family* (related records sharing one `family_id`, C18/C21) via `append_family`,
|
||||||
/// print each point's record line carrying the assigned id.
|
/// and print each point's record line carrying the assigned id. With `--trace`,
|
||||||
fn run_sweep(name: &str) {
|
/// also persist each member's streams under `runs/traces/<n>/<member_key>/` (opt-in).
|
||||||
|
fn run_sweep(name: &str, persist: bool) {
|
||||||
let reg = default_registry();
|
let reg = default_registry();
|
||||||
let family = sweep_family();
|
let family = sweep_family(persist.then_some(name));
|
||||||
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
let id = match reg.append_family(name, FamilyKind::Sweep, &sweep_member_reports(&family)) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -512,16 +539,17 @@ fn run_sweep(name: &str) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `aura walkforward [--name <n>]`: run a built-in rolling walk-forward over the
|
/// `aura walkforward [--name <n>|--trace <n>]`: run a built-in rolling walk-forward
|
||||||
/// sample blueprint + a synthetic windowed source. Per window: sweep the built-in
|
/// over the sample blueprint + a synthetic windowed source. Per window: sweep the
|
||||||
/// grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3,
|
/// built-in grid on the in-sample slice, optimize by total_pips (axis 2 inside axis 3,
|
||||||
/// where aura-cli bridges engine + registry), run the chosen params out-of-sample.
|
/// where aura-cli bridges engine + registry), run the chosen params out-of-sample.
|
||||||
/// Persist the per-window OOS reports as a *family* (C18/C21) via `append_family`,
|
/// Persist the per-window OOS reports as a *family* (C18/C21) via `append_family`,
|
||||||
/// print each carrying the assigned id, then the stitched summary line.
|
/// print each carrying the assigned id, then the stitched summary line. With
|
||||||
/// Deterministic (C1).
|
/// `--trace`, also persist each OOS member's streams under
|
||||||
fn run_walkforward(name: &str) {
|
/// `runs/traces/<n>/oos<ns>/` (opt-in). Deterministic (C1).
|
||||||
|
fn run_walkforward(name: &str, persist: bool) {
|
||||||
let reg = default_registry();
|
let reg = default_registry();
|
||||||
let result = walkforward_family();
|
let result = walkforward_family(persist.then_some(name));
|
||||||
let id =
|
let id =
|
||||||
match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result))
|
match reg.append_family(name, FamilyKind::WalkForward, &walkforward_member_reports(&result))
|
||||||
{
|
{
|
||||||
@@ -541,7 +569,7 @@ fn run_walkforward(name: &str) {
|
|||||||
/// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3
|
/// stepping 12 (contiguous OOS tiling), over the 60-bar synthetic span -> 3
|
||||||
/// windows. Each window sweeps the built-in grid in-sample, optimizes by
|
/// windows. Each window sweeps the built-in grid in-sample, optimizes by
|
||||||
/// total_pips (axis 2), and runs the chosen params out-of-sample.
|
/// total_pips (axis 2), and runs the chosen params out-of-sample.
|
||||||
fn walkforward_family() -> WalkForwardResult {
|
fn walkforward_family(trace: Option<&str>) -> WalkForwardResult {
|
||||||
let sources: Vec<Box<dyn aura_engine::Source>> =
|
let sources: Vec<Box<dyn aura_engine::Source>> =
|
||||||
vec![Box::new(VecSource::new(walkforward_prices()))];
|
vec![Box::new(VecSource::new(walkforward_prices()))];
|
||||||
let span = window_of(&sources).expect("non-empty synthetic stream");
|
let span = window_of(&sources).expect("non-empty synthetic stream");
|
||||||
@@ -551,7 +579,7 @@ fn walkforward_family() -> WalkForwardResult {
|
|||||||
walk_forward(roller, space, |w: WindowBounds| {
|
walk_forward(roller, space, |w: WindowBounds| {
|
||||||
let is_family = sweep_over(w.is.0, w.is.1);
|
let is_family = sweep_over(w.is.0, w.is.1);
|
||||||
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
|
let best = optimize(&is_family, "total_pips").expect("total_pips is a known metric");
|
||||||
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1);
|
let (oos_equity, oos_report) = run_oos(&best.params, w.oos.0, w.oos.1, trace);
|
||||||
WindowRun {
|
WindowRun {
|
||||||
// The tag-free sweep winner is the chosen point; its kinds live on
|
// The tag-free sweep winner is the chosen point; its kinds live on
|
||||||
// WalkForwardResult.space (computed once above from the same blueprint).
|
// WalkForwardResult.space (computed once above from the same blueprint).
|
||||||
@@ -596,7 +624,12 @@ fn sweep_over(from: Timestamp, to: Timestamp) -> SweepFamily {
|
|||||||
|
|
||||||
/// Run the chosen params over an out-of-sample window; return the recorded
|
/// Run the chosen params over an out-of-sample window; return the recorded
|
||||||
/// pip-equity segment (for stitching) and the OOS RunReport (the C18 record).
|
/// pip-equity segment (for stitching) and the OOS RunReport (the C18 record).
|
||||||
fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp, f64)>, RunReport) {
|
fn run_oos(
|
||||||
|
params: &[Cell],
|
||||||
|
from: Timestamp,
|
||||||
|
to: Timestamp,
|
||||||
|
trace: Option<&str>,
|
||||||
|
) -> (Vec<(Timestamp, f64)>, RunReport) {
|
||||||
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
|
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
|
||||||
let space = bp.param_space();
|
let space = bp.param_space();
|
||||||
let mut h = bp
|
let mut h = bp
|
||||||
@@ -606,12 +639,15 @@ fn run_oos(params: &[Cell], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp,
|
|||||||
vec![Box::new(walkforward_window_source(from, to))];
|
vec![Box::new(walkforward_window_source(from, to))];
|
||||||
let window = window_of(&sources).expect("non-empty out-of-sample window");
|
let window = window_of(&sources).expect("non-empty out-of-sample window");
|
||||||
h.run(sources);
|
h.run(sources);
|
||||||
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
|
||||||
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
|
||||||
let report = RunReport {
|
let manifest = sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE);
|
||||||
manifest: sim_optimal_manifest(zip_params(&space, params), window, 0, SYNTHETIC_PIP_SIZE),
|
if let Some(name) = trace {
|
||||||
metrics: summarize(&equity, &exposure),
|
persist_traces(&format!("{name}/oos{}", from.0), &manifest, &eq_rows, &ex_rows);
|
||||||
};
|
}
|
||||||
|
let equity = f64_field(&eq_rows, 0);
|
||||||
|
let exposure = f64_field(&ex_rows, 0);
|
||||||
|
let report = RunReport { manifest, metrics: summarize(&equity, &exposure) };
|
||||||
(equity, report)
|
(equity, report)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +695,7 @@ fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource {
|
|||||||
/// helper (mirrors `sweep_report`).
|
/// helper (mirrors `sweep_report`).
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn walkforward_report() -> String {
|
fn walkforward_report() -> String {
|
||||||
let result = walkforward_family();
|
let result = walkforward_family(None);
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for w in &result.windows {
|
for w in &result.windows {
|
||||||
out.push_str(&w.run.oos_report.to_json());
|
out.push_str(&w.run.oos_report.to_json());
|
||||||
@@ -675,7 +711,7 @@ fn walkforward_report() -> String {
|
|||||||
/// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`,
|
/// realization of a synthetic price walk (C12 axis 4). Mirrors `sweep_family`,
|
||||||
/// varying the *seed* rather than a tuning param. The seed -> `Source`
|
/// varying the *seed* rather than a tuning param. The seed -> `Source`
|
||||||
/// construction lives inside the per-draw closure (eager-agnostic, #71).
|
/// construction lives inside the per-draw closure (eager-agnostic, #71).
|
||||||
fn mc_family() -> McFamily {
|
fn mc_family(trace: Option<&str>) -> McFamily {
|
||||||
let base_point: Vec<Scalar> = Vec::new();
|
let base_point: Vec<Scalar> = Vec::new();
|
||||||
monte_carlo(&base_point, &[1, 2, 3], |seed, _base| {
|
monte_carlo(&base_point, &[1, 2, 3], |seed, _base| {
|
||||||
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
|
let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE);
|
||||||
@@ -683,21 +719,24 @@ fn mc_family() -> McFamily {
|
|||||||
let sources: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(spec.source(seed))];
|
let sources: Vec<Box<dyn aura_engine::Source>> = vec![Box::new(spec.source(seed))];
|
||||||
let window = window_of(&sources).expect("non-empty synthetic stream");
|
let window = window_of(&sources).expect("non-empty synthetic stream");
|
||||||
h.run(sources);
|
h.run(sources);
|
||||||
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
let eq_rows = rx_eq.try_iter().collect::<Vec<_>>();
|
||||||
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
let ex_rows = rx_ex.try_iter().collect::<Vec<_>>();
|
||||||
RunReport {
|
let manifest = sim_optimal_manifest(
|
||||||
manifest: sim_optimal_manifest(
|
vec![
|
||||||
vec![
|
("sma_fast".to_string(), Scalar::i64(2)),
|
||||||
("sma_fast".to_string(), Scalar::i64(2)),
|
("sma_slow".to_string(), Scalar::i64(4)),
|
||||||
("sma_slow".to_string(), Scalar::i64(4)),
|
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
||||||
("exposure_scale".to_string(), Scalar::f64(0.5)),
|
],
|
||||||
],
|
window,
|
||||||
window,
|
seed,
|
||||||
seed,
|
SYNTHETIC_PIP_SIZE,
|
||||||
SYNTHETIC_PIP_SIZE,
|
);
|
||||||
),
|
if let Some(name) = trace {
|
||||||
metrics: summarize(&equity, &exposure),
|
persist_traces(&format!("{name}/seed{seed}"), &manifest, &eq_rows, &ex_rows);
|
||||||
}
|
}
|
||||||
|
let equity = f64_field(&eq_rows, 0);
|
||||||
|
let exposure = f64_field(&ex_rows, 0);
|
||||||
|
RunReport { manifest, metrics: summarize(&equity, &exposure) }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,12 +754,13 @@ fn mc_aggregate_json(agg: &McAggregate) -> String {
|
|||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `aura mc [--name <n>]`: run the built-in Monte-Carlo family, persist it to the
|
/// `aura mc [--name <n>|--trace <n>]`: run the built-in Monte-Carlo family, persist
|
||||||
/// family store via `append_family` (C18/C21), print each draw's record line
|
/// it to the family store via `append_family` (C18/C21), print each draw's record
|
||||||
/// (carrying the assigned `family_id`) plus the aggregate line.
|
/// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`,
|
||||||
fn run_mc(name: &str) {
|
/// also persist each draw's streams under `runs/traces/<n>/seed<seed>/` (opt-in).
|
||||||
|
fn run_mc(name: &str, persist: bool) {
|
||||||
let reg = default_registry();
|
let reg = default_registry();
|
||||||
let family = mc_family();
|
let family = mc_family(persist.then_some(name));
|
||||||
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
|
let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -744,7 +784,7 @@ fn run_mc(name: &str) {
|
|||||||
/// computation, separate from the store-dependent id.
|
/// computation, separate from the store-dependent id.
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn mc_report() -> String {
|
fn mc_report() -> String {
|
||||||
let family = mc_family();
|
let family = mc_family(None);
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for draw in &family.draws {
|
for draw in &family.draws {
|
||||||
out.push_str(&draw.report.to_json());
|
out.push_str(&draw.report.to_json());
|
||||||
@@ -926,7 +966,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const USAGE: &str =
|
const USAGE: &str =
|
||||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | 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 chart <name> [--panels] | aura graph | aura sweep [--name <n>|--trace <n>] | aura mc [--name <n>|--trace <n>] | aura walkforward [--name <n>|--trace <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||||
@@ -950,12 +990,15 @@ fn main() {
|
|||||||
["chart", name] => emit_chart(name, ChartMode::Overlay),
|
["chart", name] => emit_chart(name, ChartMode::Overlay),
|
||||||
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
||||||
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||||
["sweep"] => run_sweep("sweep"),
|
["sweep"] => run_sweep("sweep", false),
|
||||||
["sweep", "--name", n] => run_sweep(n),
|
["sweep", "--name", n] => run_sweep(n, false),
|
||||||
["walkforward"] => run_walkforward("walkforward"),
|
["sweep", "--trace", n] => run_sweep(n, true),
|
||||||
["walkforward", "--name", n] => run_walkforward(n),
|
["walkforward"] => run_walkforward("walkforward", false),
|
||||||
["mc"] => run_mc("mc"),
|
["walkforward", "--name", n] => run_walkforward(n, false),
|
||||||
["mc", "--name", n] => run_mc(n),
|
["walkforward", "--trace", n] => run_walkforward(n, true),
|
||||||
|
["mc"] => run_mc("mc", false),
|
||||||
|
["mc", "--name", n] => run_mc(n, false),
|
||||||
|
["mc", "--trace", n] => run_mc(n, true),
|
||||||
["runs", "families"] => runs_families(),
|
["runs", "families"] => runs_families(),
|
||||||
["runs", "family", id] => runs_family(id, None),
|
["runs", "family", id] => runs_family(id, None),
|
||||||
["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)),
|
["runs", "family", id, "rank", metric] => runs_family(id, Some(metric)),
|
||||||
@@ -1142,16 +1185,16 @@ mod tests {
|
|||||||
// a fresh temp store (the run_* fns themselves bind `default_registry()`):
|
// a fresh temp store (the run_* fns themselves bind `default_registry()`):
|
||||||
// engine family -> per-kind extractor -> append_family.
|
// engine family -> per-kind extractor -> append_family.
|
||||||
let sid = reg
|
let sid = reg
|
||||||
.append_family("sweep", FamilyKind::Sweep, &sweep_member_reports(&sweep_family()))
|
.append_family("sweep", FamilyKind::Sweep, &sweep_member_reports(&sweep_family(None)))
|
||||||
.expect("sweep family");
|
.expect("sweep family");
|
||||||
let mid = reg
|
let mid = reg
|
||||||
.append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family()))
|
.append_family("mc", FamilyKind::MonteCarlo, &mc_member_reports(&mc_family(None)))
|
||||||
.expect("mc family");
|
.expect("mc family");
|
||||||
let wid = reg
|
let wid = reg
|
||||||
.append_family(
|
.append_family(
|
||||||
"walkforward",
|
"walkforward",
|
||||||
FamilyKind::WalkForward,
|
FamilyKind::WalkForward,
|
||||||
&walkforward_member_reports(&walkforward_family()),
|
&walkforward_member_reports(&walkforward_family(None)),
|
||||||
)
|
)
|
||||||
.expect("walkforward family");
|
.expect("walkforward family");
|
||||||
assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0"));
|
assert_eq!((sid.as_str(), mid.as_str(), wid.as_str()), ("sweep-0", "mc-0", "walkforward-0"));
|
||||||
@@ -1360,4 +1403,26 @@ mod tests {
|
|||||||
assert!(!r1.manifest.commit.is_empty());
|
assert!(!r1.manifest.commit.is_empty());
|
||||||
assert_eq!(r1.manifest.commit, r2.manifest.commit);
|
assert_eq!(r1.manifest.commit, r2.manifest.commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sweep_member_key_maps_the_two_named_axes() {
|
||||||
|
// The happy path the doc comment promises: the two varying axes drive
|
||||||
|
// the `fNsM` key, so distinct grid points yield distinct keys.
|
||||||
|
let named = vec![
|
||||||
|
("signals.trend.fast.length".to_string(), Scalar::i64(2)),
|
||||||
|
("signals.trend.slow.length".to_string(), Scalar::i64(5)),
|
||||||
|
];
|
||||||
|
assert_eq!(sweep_member_key(&named), "f2s5");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "signals.trend.slow.length")]
|
||||||
|
fn sweep_member_key_fails_loudly_on_a_missing_axis() {
|
||||||
|
// Property: the key is collision-free over the grid, so a key-driving
|
||||||
|
// axis that is absent (a future grid/key drift) must fail loudly, not
|
||||||
|
// silently collapse to a shared `f…s0` dir that overwrites a sibling's
|
||||||
|
// trace. The panic names the offending axis.
|
||||||
|
let drifted = vec![("signals.trend.fast.length".to_string(), Scalar::i64(2))];
|
||||||
|
let _ = sweep_member_key(&drifted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -678,3 +678,157 @@ fn runs_bare_subcommand_is_strict_and_exits_two() {
|
|||||||
let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
|
let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs");
|
||||||
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Property: `aura mc --trace <name>` persists one standalone, round-tripping
|
||||||
|
/// run-dir per draw seed (the built-in MC family draws seeds 1,2,3), and a plain
|
||||||
|
/// `aura mc` (no --trace) writes no trace tree (opt-in; byte-unchanged path).
|
||||||
|
#[test]
|
||||||
|
fn mc_trace_persists_a_member_dir_per_seed() {
|
||||||
|
let cwd = temp_cwd("mc-trace");
|
||||||
|
|
||||||
|
// opt-in OFF: plain `aura mc` persists no per-member trace dirs.
|
||||||
|
let plain = Command::new(BIN).arg("mc").current_dir(&cwd).output().expect("spawn aura mc");
|
||||||
|
assert!(plain.status.success(), "plain mc exit: {:?}", plain.status);
|
||||||
|
assert!(!cwd.join("runs/traces").exists(), "plain mc must write no trace files");
|
||||||
|
|
||||||
|
// opt-in ON: one standalone run-dir per draw seed.
|
||||||
|
let traced = Command::new(BIN)
|
||||||
|
.args(["mc", "--trace", "mc1"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura mc --trace");
|
||||||
|
assert!(traced.status.success(), "traced mc exit: {:?}", traced.status);
|
||||||
|
for seed in [1, 2, 3] {
|
||||||
|
let dir = cwd.join(format!("runs/traces/mc1/seed{seed}"));
|
||||||
|
assert!(dir.join("index.json").exists(), "seed{seed} index.json missing");
|
||||||
|
assert!(dir.join("equity.json").exists(), "seed{seed} equity tap missing");
|
||||||
|
assert!(dir.join("exposure.json").exists(), "seed{seed} exposure tap missing");
|
||||||
|
// the persisted equity tap is columnar SoA (C7): kind tag + ts/column parity.
|
||||||
|
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||||||
|
assert!(equity.contains("\"kinds\":[\"F64\"]"), "seed{seed} F64 tag: {equity}");
|
||||||
|
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||||||
|
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||||||
|
assert_eq!(col_len, ts_len, "seed{seed} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: `aura sweep --trace <name>` persists one standalone, round-tripping
|
||||||
|
/// run-dir per grid point. The built-in grid is fast∈{2,3} × slow∈{4,5} = 4
|
||||||
|
/// points, content-keyed f2s4/f2s5/f3s4/f3s5; a plain `aura sweep` writes nothing.
|
||||||
|
#[test]
|
||||||
|
fn sweep_trace_persists_a_member_dir_per_grid_point() {
|
||||||
|
let cwd = temp_cwd("sweep-trace");
|
||||||
|
|
||||||
|
let plain = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep");
|
||||||
|
assert!(plain.status.success(), "plain sweep exit: {:?}", plain.status);
|
||||||
|
assert!(!cwd.join("runs/traces").exists(), "plain sweep must write no trace files");
|
||||||
|
|
||||||
|
let traced = Command::new(BIN)
|
||||||
|
.args(["sweep", "--trace", "swp"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura sweep --trace");
|
||||||
|
assert!(traced.status.success(), "traced sweep exit: {:?}", traced.status);
|
||||||
|
for key in ["f2s4", "f2s5", "f3s4", "f3s5"] {
|
||||||
|
let dir = cwd.join(format!("runs/traces/swp/{key}"));
|
||||||
|
assert!(dir.join("index.json").exists(), "{key} index.json missing");
|
||||||
|
assert!(dir.join("equity.json").exists(), "{key} equity tap missing");
|
||||||
|
assert!(dir.join("exposure.json").exists(), "{key} exposure tap missing");
|
||||||
|
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||||||
|
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
|
||||||
|
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||||||
|
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||||||
|
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property: `aura walkforward --trace <name>` persists one standalone,
|
||||||
|
/// round-tripping run-dir per OOS window (the built-in roll = 3 windows), each
|
||||||
|
/// keyed `oos<ns>`; a plain `aura walkforward` writes nothing.
|
||||||
|
#[test]
|
||||||
|
fn walkforward_trace_persists_a_member_dir_per_oos_window() {
|
||||||
|
let cwd = temp_cwd("wf-trace");
|
||||||
|
|
||||||
|
let plain = Command::new(BIN).arg("walkforward").current_dir(&cwd).output().expect("spawn aura walkforward");
|
||||||
|
assert!(plain.status.success(), "plain walkforward exit: {:?}", plain.status);
|
||||||
|
assert!(!cwd.join("runs/traces").exists(), "plain walkforward must write no trace files");
|
||||||
|
|
||||||
|
let traced = Command::new(BIN)
|
||||||
|
.args(["walkforward", "--trace", "wf1"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura walkforward --trace");
|
||||||
|
assert!(traced.status.success(), "traced walkforward exit: {:?}", traced.status);
|
||||||
|
|
||||||
|
let base = cwd.join("runs/traces/wf1");
|
||||||
|
let mut members: Vec<String> = std::fs::read_dir(&base)
|
||||||
|
.expect("read wf1 trace dir")
|
||||||
|
.map(|e| e.expect("dir entry").file_name().to_string_lossy().into_owned())
|
||||||
|
.collect();
|
||||||
|
members.sort();
|
||||||
|
assert_eq!(members.len(), 3, "built-in roll = 3 OOS windows -> 3 member dirs; got {members:?}");
|
||||||
|
for key in &members {
|
||||||
|
assert!(key.starts_with("oos"), "member key must be oos<ns>: {key}");
|
||||||
|
let dir = base.join(key);
|
||||||
|
assert!(dir.join("index.json").exists(), "{key} index.json missing");
|
||||||
|
let equity = std::fs::read_to_string(dir.join("equity.json")).expect("read equity.json");
|
||||||
|
assert!(equity.contains("\"kinds\":[\"F64\"]"), "{key} F64 tag: {equity}");
|
||||||
|
let ts_len = json_array_body(&equity, "\"ts\":[").split(',').count();
|
||||||
|
let col_len = json_array_body(&equity, "\"columns\":[[").split(',').count();
|
||||||
|
assert_eq!(col_len, ts_len, "{key} SoA parity: ts={ts_len} col={col_len}; {equity}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Property (C1 / Fork-C content-derived keys): `aura sweep --trace` is
|
||||||
|
/// reproducible — re-running it produces the SAME member-dir set and
|
||||||
|
/// BYTE-IDENTICAL tap files. The whole reason member keys are derived from the
|
||||||
|
/// grid point's content (not a runtime ordinal counter) is that members run in
|
||||||
|
/// parallel across threads (C1: parallelism *across* sims); a thread-race in the
|
||||||
|
/// keying or the persisted bytes would silently regress determinism. A runtime
|
||||||
|
/// counter would shuffle which member lands in `f2s4` vs `f3s5` run-to-run; this
|
||||||
|
/// test fails loudly if that drift is ever reintroduced.
|
||||||
|
#[test]
|
||||||
|
fn sweep_trace_is_byte_deterministic_across_runs() {
|
||||||
|
let collect = |tag: &str| -> std::collections::BTreeMap<String, String> {
|
||||||
|
let cwd = temp_cwd(tag);
|
||||||
|
let out = Command::new(BIN)
|
||||||
|
.args(["sweep", "--trace", "det"])
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output()
|
||||||
|
.expect("spawn aura sweep --trace");
|
||||||
|
assert!(out.status.success(), "{tag} sweep exit: {:?}", out.status);
|
||||||
|
|
||||||
|
// Map each member dir's tap files to their bytes (member_key/file -> content).
|
||||||
|
let base = cwd.join("runs/traces/det");
|
||||||
|
let mut taps = std::collections::BTreeMap::new();
|
||||||
|
for member in std::fs::read_dir(&base).expect("read det trace dir") {
|
||||||
|
let member = member.expect("dir entry").file_name().to_string_lossy().into_owned();
|
||||||
|
for file in ["index.json", "equity.json", "exposure.json"] {
|
||||||
|
let body = std::fs::read_to_string(base.join(&member).join(file))
|
||||||
|
.unwrap_or_else(|e| panic!("{tag} read {member}/{file}: {e}"));
|
||||||
|
taps.insert(format!("{member}/{file}"), body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_dir_all(&cwd);
|
||||||
|
taps
|
||||||
|
};
|
||||||
|
|
||||||
|
let first = collect("sweep-det-1");
|
||||||
|
let second = collect("sweep-det-2");
|
||||||
|
|
||||||
|
// Same member-dir set, run-to-run (content-keyed, not order-keyed).
|
||||||
|
let first_keys: Vec<&String> = first.keys().collect();
|
||||||
|
let second_keys: Vec<&String> = second.keys().collect();
|
||||||
|
assert_eq!(first_keys, second_keys, "member-dir set drifted across runs");
|
||||||
|
|
||||||
|
// Every tap byte-identical across the two runs (the index.json git-commit
|
||||||
|
// field is build-identity, constant within one build, so it compares equal
|
||||||
|
// here too — this pins run-to-run determinism, C1).
|
||||||
|
assert_eq!(first, second, "a traced member's bytes drifted across two runs (C1 violation)");
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user