From 4c64feb9ed0690485dceca5fb0ac22dcfdf5cd83 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 21 Jun 2026 18:54:17 +0200 Subject: [PATCH] feat(chart): aura chart overlays a family's members; --tap + write-guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aura chart ` now branches on the name's on-disk kind: a single run charts its taps as before; a FAMILY overlays one tap (default equity) across all its members in one self-contained uPlot page, consistent across sweep / MC / walk-forward. Members share ONE y-scale (they measure one quantity — that is what makes the overlay comparable, unlike the single-run overlay of different taps), and align on the union-ts spine via the existing join_on_ts: sweep/MC members share the window (true overlay), walk-forward members are disjoint OOS windows (null-complementary -> the stitched curve). One mechanism, three correct readings. - build_comparison_chart_data (one labelled Series per member, shared y_scale_id); filter_to_tap (single-run --tap restricts to one tap; no --tap = all taps, unchanged); parse_chart_args (` [--tap ] [--panels]`, any order); emit_chart name-kind branch; USAGE updated. - Write-guard: ensure_name_free is now called once per tracing command (run / run --macd / run --real -> Run; sweep / mc / walkforward -> Family) so a name cannot be reused across kinds — name resolution stays a total function. Refuse-don't-guess throughout (exit 2, never panic). Engine untouched (C9/C14). Tasks 2-3 of plan 0061; Task 1 (registry) landed in 9b2adcb. Verified: cargo test --workspace green (no failures; new unit + integration tests incl. family-overlay, single-run --tap filter, cross-kind collision RED-then-green), cargo clippy --workspace --all-targets -D warnings clean, cargo build --workspace clean. Benign clippy-forced deltas vs the literal plan (no behaviour change): a local MemberRows type alias (type_complexity), let-chains in the six guards (collapsible_if), and dropping the now-dead TraceStoreError import. closes #107 --- crates/aura-cli/src/main.rs | 295 +++++++++++++++++++++++++++++-- crates/aura-cli/tests/cli_run.rs | 178 +++++++++++++++++++ 2 files changed, 455 insertions(+), 18 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 053f3bd..28cb27b 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -23,7 +23,8 @@ use aura_engine::{ }; use aura_registry::{ group_families, mc_member_reports, optimize, rank_by, sweep_member_reports, - walkforward_member_reports, FamilyKind, Registry, RunTraces, TraceStore, TraceStoreError, + walkforward_member_reports, FamilyKind, FamilyMember, NameKind, Registry, RunTraces, TraceStore, + WriteKind, }; use aura_std::{Ema, Exposure, LinComb, LongOnly, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; @@ -271,28 +272,130 @@ fn build_chart_data(traces: RunTraces) -> ChartData { ChartData { xs, series } } -/// `aura chart [--panels]`: read the persisted run's traces, align them, and -/// print the self-contained uPlot page. A missing run is a usage error (stderr + -/// exit 2), per the CLI's convention — never a panic. -fn emit_chart(name: &str, mode: ChartMode) { - let traces = match TraceStore::open("runs").read(name) { - Ok(t) => t, - Err(TraceStoreError::NotFound(n)) => { - eprintln!("aura: no recorded run '{n}' under runs/traces (run `aura run --trace {n}` first)"); +/// One member's contribution to the comparison build: its key (the future series +/// name) paired with the chosen tap's drained `(ts, row)` pairs. +type MemberRows = (String, Vec<(Timestamp, Vec)>); + +/// Build the comparison `ChartData` for a family: one `Series` per member (the +/// chosen `tap`'s column), labelled by `member.key`, ALL sharing ONE `y_scale_id` +/// (the members measure one identical quantity, so a shared scale is what makes +/// them comparable — unlike the single-run overlay, whose series are different +/// taps). Aligned on the union-ts spine via the same `join_on_ts` build_chart_data +/// uses. `Err` if NO member carries `tap` (refuse-don't-guess). +fn build_comparison_chart_data( + members: &[FamilyMember], + tap: &str, +) -> Result { + let mut member_rows: Vec = Vec::new(); + for m in members { + if let Some(t) = m.traces.taps.iter().find(|t| t.tap == tap) { + member_rows.push((m.key.clone(), t.to_rows())); + } + } + if member_rows.is_empty() { + return Err(format!("no family member has a tap named '{tap}'")); + } + + let mut xs: Vec = + member_rows.iter().flat_map(|(_, r)| r.iter().map(|(t, _)| t.0)).collect(); + xs.sort_unstable(); + xs.dedup(); + + let spine: Vec<(Timestamp, Vec)> = + xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect(); + let sides: Vec<&[(Timestamp, Vec)]> = + member_rows.iter().map(|(_, r)| r.as_slice()).collect(); + let joined: Vec = join_on_ts(&spine, &sides); + + // One shared y-scale across all member series (same quantity). + let y_scale_id = format!("y_cmp_{tap}"); + let mut series: Vec = Vec::new(); + for (i, (key, _)) in member_rows.iter().enumerate() { + // Project column 0 — the doc's "chosen tap's column" (singular). The + // comparison taps in scope (equity / exposure) are single-column `f64`. + // A future multi-column tap selection would need a column index here. + let points: Vec> = + joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[0].as_f64())).collect(); + series.push(Series { name: key.clone(), y_scale_id: y_scale_id.clone(), points }); + } + Ok(ChartData { xs, series }) +} + +/// Restrict a single-run `ChartData` to the one series named `tap`. `Err` if the +/// run has no such tap (refuse-don't-guess). Used by the `--tap` flag on the +/// single-run chart path; without `--tap` the single-run page is unchanged. +fn filter_to_tap(data: ChartData, tap: &str) -> Result { + let series: Vec = data.series.into_iter().filter(|s| s.name == tap).collect(); + if series.is_empty() { + return Err(format!("run has no tap named '{tap}'")); + } + Ok(ChartData { xs: data.xs, series }) +} + +/// `aura chart [--tap ] [--panels]`: classify the name and render. A +/// single run charts all its taps (or the one `--tap` selects); a family overlays +/// one tap (default `equity`) across its members; an unknown name is a usage error +/// (stderr + exit 2), never a panic. +fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode) { + let store = TraceStore::open("runs"); + match store.name_kind(name) { + NameKind::Run => { + let traces = match store.read(name) { + Ok(t) => t, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + let mut data = build_chart_data(traces); + if let Some(t) = tap { + data = match filter_to_tap(data, t) { + Ok(d) => d, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + } + print!("{}", render::render_chart_html(&data, mode)); + } + NameKind::Family => { + let members = match store.read_family(name) { + Ok(m) => m, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + let data = match build_comparison_chart_data(&members, tap.unwrap_or("equity")) { + Ok(d) => d, + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + }; + print!("{}", render::render_chart_html(&data, mode)); + } + NameKind::NotFound => { + eprintln!( + "aura: no recorded run or family '{name}' under runs/traces \ + (run `aura run --trace {name}` or `aura sweep --trace {name}` first)" + ); std::process::exit(2); } - Err(e) => { - eprintln!("aura: {e}"); - std::process::exit(2); - } - }; - print!("{}", render::render_chart_html(&build_chart_data(traces), mode)); + } } /// 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(trace: Option<&str>) -> RunReport { + if let Some(n) = trace + && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) + { + eprintln!("aura: {e}"); + std::process::exit(2); + } let (mut h, rx_eq, rx_ex) = sample_harness(SYNTHETIC_PIP_SIZE); let sources: Vec> = vec![Box::new(VecSource::new(synthetic_prices()))]; @@ -326,6 +429,12 @@ fn run_sample(trace: Option<&str>) -> RunReport { /// 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, to_ms: Option, trace: Option<&str>) -> RunReport { + if let Some(n) = trace + && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) + { + eprintln!("aura: {e}"); + std::process::exit(2); + } // Per-instrument pip: look up BEFORE any data access, so an un-specced symbol // refuses without touching local data. Honest by construction. let spec = instrument_spec_or_refuse(symbol); @@ -394,6 +503,34 @@ fn parse_real_args( Ok((symbol.to_string(), from, to, trace)) } +/// Parse `aura chart` args: ` [--tap ] [--panels]` in any order. +fn parse_chart_args(rest: &[&str]) -> Result<(String, Option, ChartMode), String> { + let mut name: Option = None; + let mut tap: Option = None; + let mut mode = ChartMode::Overlay; + let mut i = 0; + while i < rest.len() { + match rest[i] { + "--panels" => { + mode = ChartMode::Panels; + i += 1; + } + "--tap" => { + let t = rest.get(i + 1).ok_or("--tap needs a value")?; + tap = Some((*t).to_string()); + i += 2; + } + other if !other.starts_with("--") && name.is_none() => { + name = Some(other.to_string()); + i += 1; + } + other => return Err(format!("unexpected chart argument '{other}'")), + } + } + let name = name.ok_or("chart needs a ")?; + Ok((name, tap, mode)) +} + /// The shared `--real [--from ] [--to ]` accumulator for the /// family parsers (`parse_sweep_args` / `parse_walkforward_args`). It holds the /// one home of the real/window grammar — flag-repeat strictness, the empty-symbol @@ -947,6 +1084,12 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { /// carrying the assigned id. With `--trace`, also persist each member's streams /// under `runs/traces///` (opt-in). fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) { + if persist + && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) + { + eprintln!("aura: {e}"); + std::process::exit(2); + } let reg = default_registry(); let family = match strategy { Strategy::SmaCross => sweep_family(persist.then_some(name), &data), @@ -973,6 +1116,12 @@ fn run_sweep(strategy: Strategy, name: &str, persist: bool, data: DataSource) { /// `--trace`, also persist each OOS member's streams under /// `runs/traces//oos/` (opt-in). Deterministic (C1). fn run_walkforward(name: &str, persist: bool, data: DataSource) { + if persist + && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) + { + eprintln!("aura: {e}"); + std::process::exit(2); + } let reg = default_registry(); let result = walkforward_family(persist.then_some(name), &data); let id = @@ -1190,6 +1339,12 @@ fn mc_aggregate_json(agg: &McAggregate) -> String { /// line (carrying the assigned `family_id`) plus the aggregate line. With `--trace`, /// also persist each draw's streams under `runs/traces//seed/` (opt-in). fn run_mc(name: &str, persist: bool) { + if persist + && let Err(e) = TraceStore::open("runs").ensure_name_free(name, WriteKind::Family) + { + eprintln!("aura: {e}"); + std::process::exit(2); + } let reg = default_registry(); let family = mc_family(persist.then_some(name)); let id = match reg.append_family(name, FamilyKind::MonteCarlo, &mc_member_reports(&family)) { @@ -1361,6 +1516,12 @@ fn macd_prices() -> Vec<(Timestamp, Scalar)> { /// synthetic stream, and fold both sinks into a `RunReport`. Pure and /// deterministic (C1). fn run_macd(trace: Option<&str>) -> RunReport { + if let Some(n) = trace + && let Err(e) = TraceStore::open("runs").ensure_name_free(n, WriteKind::Run) + { + eprintln!("aura: {e}"); + std::process::exit(2); + } let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); let flat = macd_strategy_blueprint(tx_eq, tx_ex) @@ -1394,7 +1555,7 @@ fn run_macd(trace: Option<&str>) -> RunReport { } const USAGE: &str = - "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; + "usage: aura run [--macd] [--trace ] | aura run --real [--from ] [--to ] [--trace ] | aura chart [--tap ] [--panels] | aura graph | aura sweep [--strategy ] [--real [--from ] [--to ]] [--name |--trace ] | aura mc [--name |--trace ] | aura walkforward [--real [--from ] [--to ]] [--name |--trace ] | aura runs families | aura runs family [rank ]"; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -1415,8 +1576,13 @@ fn main() { std::process::exit(2); } }, - ["chart", name] => emit_chart(name, ChartMode::Overlay), - ["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels), + ["chart", rest @ ..] => match parse_chart_args(rest) { + Ok((name, tap, mode)) => emit_chart(&name, tap.as_deref(), mode), + Err(msg) => { + eprintln!("aura: {msg}"); + std::process::exit(2); + } + }, ["graph"] => print!("{}", render::render_html(&sample_blueprint())), ["sweep", rest @ ..] => match parse_sweep_args(rest) { Ok((strategy, name, persist, choice)) => { @@ -1454,6 +1620,54 @@ fn main() { mod tests { use super::*; + fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember { + let rows: Vec<(Timestamp, Vec)> = + ts.iter().zip(vals).map(|(&t, &v)| (Timestamp(t), vec![Scalar::f64(v)])).collect(); + let tap = ColumnarTrace::from_rows("equity", &[ScalarKind::F64], &rows); + FamilyMember { + key: key.to_string(), + traces: RunTraces { + manifest: sim_optimal_manifest(vec![], (Timestamp(0), Timestamp(0)), 0, 1.0), + taps: vec![tap], + }, + } + } + + #[test] + fn comparison_overlays_one_shared_scale_series_per_member() { + let members = vec![ + cmp_member("a", &[1, 2, 3], &[10.0, 11.0, 12.0]), + cmp_member("b", &[1, 2, 3], &[20.0, 21.0, 22.0]), + ]; + let data = build_comparison_chart_data(&members, "equity").expect("builds"); + assert_eq!(data.xs, vec![1, 2, 3]); + assert_eq!(data.series.len(), 2); + assert_eq!(data.series[0].name, "a"); + assert_eq!(data.series[1].name, "b"); + // ONE shared y-scale across members (same quantity). + assert_eq!(data.series[0].y_scale_id, data.series[1].y_scale_id); + // shared ts -> dense, no nulls. + assert!(data.series[0].points.iter().all(Option::is_some)); + } + + #[test] + fn comparison_disjoint_members_are_null_complementary() { + let members = vec![ + cmp_member("oos1", &[1, 2], &[10.0, 11.0]), + cmp_member("oos2", &[3, 4], &[20.0, 21.0]), + ]; + let data = build_comparison_chart_data(&members, "equity").expect("builds"); + assert_eq!(data.xs, vec![1, 2, 3, 4]); + assert_eq!(data.series[0].points, vec![Some(10.0), Some(11.0), None, None]); + assert_eq!(data.series[1].points, vec![None, None, Some(20.0), Some(21.0)]); + } + + #[test] + fn comparison_errors_when_no_member_has_the_tap() { + let members = vec![cmp_member("a", &[1], &[1.0])]; + assert!(build_comparison_chart_data(&members, "nosuch").is_err()); + } + /// #99: a sweep/walk-forward family-member stdout line embeds the `RunReport` in /// its own declaration key order (manifest leads with `commit`), byte-matching the /// stored `families.jsonl` — never `serde_json::Value`'s alphabetical order (which @@ -1896,6 +2110,51 @@ mod tests { assert!(parse_real_args(&["EURUSD", "--from", "notanumber"]).is_err()); } + /// `parse_chart_args` parses ``, `--tap `, and `--panels` in any order + /// (the loop grammar's reason for being — the name need not lead), and surfaces + /// each of its three distinct errors: `--tap` missing its value, an unexpected + /// extra argument, and a missing ``. The any-order contract is the only + /// reason `main` runs a loop here instead of an exhaustive argv match, so it is + /// asserted as a property, not just exercised. + #[test] + fn parse_chart_args_accepts_any_order_and_rejects_three_ways() { + // bare name -> default tap (None) + Overlay mode + let (name, tap, mode) = parse_chart_args(&["fam"]).expect("bare name parses"); + assert_eq!(name, "fam"); + assert_eq!(tap, None); + assert!(matches!(mode, ChartMode::Overlay)); + + // name + --tap + --panels in canonical order + let (name, tap, mode) = + parse_chart_args(&["fam", "--tap", "equity", "--panels"]).expect("full parses"); + assert_eq!(name, "fam"); + assert_eq!(tap.as_deref(), Some("equity")); + assert!(matches!(mode, ChartMode::Panels)); + + // any order: flags before the name, and --panels between name and --tap + let (name, tap, mode) = + parse_chart_args(&["--tap", "equity", "fam"]).expect("flag-first parses"); + assert_eq!(name, "fam"); + assert_eq!(tap.as_deref(), Some("equity")); + assert!(matches!(mode, ChartMode::Overlay)); + + let (name, tap, mode) = + parse_chart_args(&["--panels", "fam", "--tap", "exposure"]).expect("interleaved parses"); + assert_eq!(name, "fam"); + assert_eq!(tap.as_deref(), Some("exposure")); + assert!(matches!(mode, ChartMode::Panels)); + + // three distinct error branches, by message (the Ok tuple holds a + // non-Debug ChartMode, so match the Err arm instead of unwrapping). + let err = |args: &[&str]| match parse_chart_args(args) { + Err(e) => e, + Ok(_) => panic!("expected an error for {args:?}"), + }; + assert_eq!(err(&["fam", "--tap"]), "--tap needs a value"); + assert_eq!(err(&["fam", "extra"]), "unexpected chart argument 'extra'"); + assert_eq!(err(&["--panels"]), "chart needs a "); + } + #[test] fn run_sample_is_deterministic_and_non_trivial() { let r1 = run_sample(None); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 03e1151..d03843d 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -467,6 +467,55 @@ fn chart_panels_flag_selects_panels_mode() { let _ = std::fs::remove_dir_all(&cwd); } +/// Property: `--tap ` on a single-run chart is a CLEAN refusal at the +/// CLI seam — refuse-don't-guess (C10). The builder's no-tap `Err` is unit-tested, +/// but this pins the user-facing wiring: `filter_to_tap` returning `Err` must reach +/// `eprintln!` + `exit(2)`, never a panic and never a chart of zero series. A +/// regression that swallowed the `Err` (e.g. charting an empty `ChartData`) would +/// exit 0 with an empty page; this fails it. Archive-local (persists its own run). +#[test] +fn chart_tap_nonexistent_on_run_refuses_with_exit_2() { + let cwd = temp_cwd("chart-tap-missing"); + + // persist a single run; its taps are `equity` / `exposure`, never `nope`. + let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace"); + assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); + + let out = Command::new(BIN).args(["chart", "demo", "--tap", "nope"]).current_dir(&cwd).output().expect("spawn chart --tap"); + assert_eq!(out.status.code(), Some(2), "nonexistent tap must exit 2: {:?}", out.status); + assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("run has no tap named 'nope'"), + "expected the no-such-tap refusal message, got: {stderr}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: an unknown `chart ` is a usage error pinned at BOTH the exit code +/// and the user-facing message — exit 2 (never a panic) AND the NotFound branch's +/// "no recorded run or family ''" wording, which now covers families too. A +/// regression that drifted the message (e.g. back to the run-only phrasing) or +/// downgraded the exit would slip past the bundled exit-only check; this pins the +/// message/exit contract. Archive-local: charts into an empty cwd so the name is +/// genuinely absent. +#[test] +fn chart_unknown_name_refuses_with_exit_2_and_message() { + let cwd = temp_cwd("chart-unknown"); + + let out = Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost"); + assert_eq!(out.status.code(), Some(2), "unknown name must exit 2: {:?}", out.status); + assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no recorded run or family 'ghost'"), + "expected the unknown-name (run-or-family) refusal message, got: {stderr}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + #[test] fn no_args_prints_usage_and_exits_two() { let out = Command::new(BIN).output().expect("spawn aura"); @@ -1199,3 +1248,132 @@ fn family_window_flag_without_real_refuses_with_usage_exit_2() { let _ = std::fs::remove_dir_all(&dir); } } + +/// Property: `aura chart ` overlays one series per family member, labelled +/// by member key. The built-in sweep grid is fast∈{2,3} × slow∈{4,5} = 4 members; +/// each member key appears as a series name in the injected AURA_TRACES. +#[test] +fn chart_of_a_family_overlays_one_series_per_member() { + let cwd = temp_cwd("chart-family"); + let swept = Command::new(BIN) + .args(["sweep", "--trace", "fam"]) + .current_dir(&cwd) + .output() + .expect("spawn sweep --trace"); + assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status); + + let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam"); + assert!(chart.status.success(), "chart family exit: {:?}", chart.status); + let html = String::from_utf8(chart.stdout).expect("utf-8 html"); + assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected"); + for key in [ + "signals.trend.fast.length-2_signals.trend.slow.length-4", + "signals.trend.fast.length-2_signals.trend.slow.length-5", + "signals.trend.fast.length-3_signals.trend.slow.length-4", + "signals.trend.fast.length-3_signals.trend.slow.length-5", + ] { + assert!(html.contains(key), "member series '{key}' missing from the family chart"); + } + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: the single-run chart path is unchanged — no `--tap` shows ALL taps +/// (equity + exposure); `--tap equity` filters to one. A regression net for the +/// "single-run byte-unchanged when no --tap" acceptance bullet. +#[test] +fn chart_single_run_tap_filter_keeps_only_the_named_tap() { + let cwd = temp_cwd("chart-tap"); + let traced = Command::new(BIN).args(["run", "--trace", "solo"]).current_dir(&cwd).output().expect("spawn run --trace"); + assert!(traced.status.success(), "run --trace exit: {:?}", traced.status); + + // no --tap: both taps present (unchanged behaviour). + let all = Command::new(BIN).args(["chart", "solo"]).current_dir(&cwd).output().expect("spawn chart"); + assert!(all.status.success()); + let all_html = String::from_utf8(all.stdout).expect("utf-8"); + assert!(all_html.contains("\"equity\""), "equity series missing"); + assert!(all_html.contains("\"exposure\""), "exposure series missing"); + + // --tap equity: only equity remains. + let one = Command::new(BIN).args(["chart", "solo", "--tap", "equity"]).current_dir(&cwd).output().expect("spawn chart --tap"); + assert!(one.status.success(), "chart --tap exit: {:?}", one.status); + let one_html = String::from_utf8(one.stdout).expect("utf-8"); + assert!(one_html.contains("\"equity\""), "equity series missing under --tap"); + assert!(!one_html.contains("\"exposure\""), "--tap equity must drop exposure"); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: a trace name cannot be reused across kinds — the write-guard refuses +/// it (exit 2), keeping `aura chart ` resolution a total function. +#[test] +fn trace_name_collision_across_kinds_is_refused() { + let cwd = temp_cwd("collision"); + + // run then sweep on the same name -> the sweep is refused. + let run = Command::new(BIN).args(["run", "--trace", "t"]).current_dir(&cwd).output().expect("spawn run --trace"); + assert!(run.status.success(), "run --trace exit: {:?}", run.status); + let sweep = Command::new(BIN).args(["sweep", "--trace", "t"]).current_dir(&cwd).output().expect("spawn sweep --trace"); + assert_eq!(sweep.status.code(), Some(2), "sweep onto a run name must exit 2"); + + // reverse: sweep then run on a fresh name -> the run is refused. + let sweep2 = Command::new(BIN).args(["sweep", "--trace", "u"]).current_dir(&cwd).output().expect("spawn sweep --trace u"); + assert!(sweep2.status.success(), "sweep --trace u exit: {:?}", sweep2.status); + let run2 = Command::new(BIN).args(["run", "--trace", "u"]).current_dir(&cwd).output().expect("spawn run --trace u"); + assert_eq!(run2.status.code(), Some(2), "run onto a family name must exit 2"); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: the write-guard refuses ONLY cross-kind name reuse — a SAME-kind +/// overwrite stays Ok. Re-running `aura sweep --trace ` onto a name already +/// held by a family succeeds (exit 0), so the guard does not over-broadly forbid +/// all reuse. Without this the collision test alone would pass even if the guard +/// degenerated into "refuse every existing name", silently breaking re-tracing. +#[test] +fn same_kind_overwrite_is_allowed_by_the_write_guard() { + let cwd = temp_cwd("same-kind-overwrite"); + + let first = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam"); + assert!(first.status.success(), "first sweep --trace exit: {:?}", first.status); + + // Same name, same kind (family -> family): the guard must NOT refuse it. + let again = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam again"); + assert_eq!(again.status.code(), Some(0), "same-kind family overwrite must stay Ok, got: {:?}; stderr: {}", again.status, String::from_utf8_lossy(&again.stderr)); + + // And the re-traced family is still chartable (name resolution unbroken). + let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam"); + assert!(chart.status.success(), "chart after overwrite exit: {:?}", chart.status); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// Property: the family overlay honours a non-default `--tap` — `chart +/// --tap exposure` overlays the exposure tap of every member, not the default +/// equity. Pins the family branch's `tap.unwrap_or("equity")` selection: a +/// regression that ignored `--tap` on families (always equity) would still pass +/// the default-equity family test, so the explicit non-default tap is its own net. +#[test] +fn chart_family_with_tap_overlays_the_named_tap_per_member() { + let cwd = temp_cwd("chart-family-tap"); + + let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace"); + assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status); + + let chart = Command::new(BIN).args(["chart", "fam", "--tap", "exposure"]).current_dir(&cwd).output().expect("spawn chart fam --tap exposure"); + assert!(chart.status.success(), "chart family --tap exposure exit: {:?}", chart.status); + let html = String::from_utf8(chart.stdout).expect("utf-8 html"); + assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected"); + // One series per member, keyed by member key — the exposure overlay still + // spans all four grid points (the tap selection changes the column, not the + // member set). + for key in [ + "signals.trend.fast.length-2_signals.trend.slow.length-4", + "signals.trend.fast.length-2_signals.trend.slow.length-5", + "signals.trend.fast.length-3_signals.trend.slow.length-4", + "signals.trend.fast.length-3_signals.trend.slow.length-5", + ] { + assert!(html.contains(key), "member series '{key}' missing from the --tap exposure family chart"); + } + + let _ = std::fs::remove_dir_all(&cwd); +}