From 18f8e72946b139db0b68b88b60ea10fb744194c8 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 11 Jul 2026 18:04:09 +0200 Subject: [PATCH] feat(cli): chart resolves a --trace family by its chosen campaign name (#238) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NotFound arm of emit_chart now queries the recorded campaign runs: records with a persisted trace_name whose stored campaign document carries name == ARG. A unique match re-dispatches the existing charting path onto the resolved handle; several matches refuse listing the candidate handles in append order (refuse-don't-guess — re-running a named invocation legitimately yields {campaign8}-0, -1, ...); zero matches keep the not-found message verbatim. The exact trace-store handle always wins; the name never becomes a store key (C18 untouched). Guide §3 and the glossary tap entry document the name alternative. closes #238 --- crates/aura-cli/src/main.rs | 90 ++++++++++++++++++++++++++--- crates/aura-cli/tests/cli_run.rs | 98 ++++++++++++++++++++++++++++++++ docs/authoring-guide.md | 5 +- docs/glossary.md | 2 +- 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index ff5903c..db0e771 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -401,7 +401,19 @@ fn filter_to_tap(data: ChartData, tap: &str) -> Result { /// (stderr + exit 1), never a panic. fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env) { let store = env.trace_store(); - match store.name_kind(name) { + render_chart_by_kind(name, store.name_kind(name), tap, mode, env, &store); +} + +/// Render (or refuse) a chart for a name whose [`NameKind`] is already known — +/// the shared tail `emit_chart` calls directly for the original name, and +/// [`resolve_campaign_name`]'s `NotFound` arm re-enters with the resolved +/// trace-store handle's own kind (never the un-resolved name), so the +/// handle-charted path (Run/Family) is exercised exactly once either way. +fn render_chart_by_kind( + name: &str, kind: NameKind, tap: Option<&str>, mode: ChartMode, env: &project::Env, + store: &aura_registry::TraceStore, +) { + match kind { NameKind::Run => { let traces = match store.read(name) { Ok(t) => t, @@ -441,15 +453,77 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env let data = decimate(data, CHART_DECIMATE_BUCKETS); print!("{}", render::render_chart_html(&data, mode)); } - NameKind::NotFound => { - eprintln!( - "aura: no recorded run or family '{name}' under runs/traces \ - (check the handle a sweep/walk-forward/campaign run printed for a typo — \ - re-running with this handle as `--trace` will not create it)" - ); - std::process::exit(1); + NameKind::NotFound => match resolve_campaign_name(name, env) { + NameResolution::Unique(handle) => { + let resolved_kind = store.name_kind(&handle); + render_chart_by_kind(&handle, resolved_kind, tap, mode, env, store); + } + NameResolution::Ambiguous(handles) => { + eprintln!( + "aura: the campaign name '{name}' names {} recorded runs ({}) — \ + chart one of these handles directly", + handles.len(), + handles.join(", "), + ); + std::process::exit(1); + } + NameResolution::None => { + eprintln!( + "aura: no recorded run or family '{name}' under runs/traces \ + (check the handle a sweep/walk-forward/campaign run printed for a typo — \ + re-running with this handle as `--trace` will not create it)" + ); + std::process::exit(1); + } + }, + } +} + +/// The outcome of resolving a `chart ` argument against the recorded +/// campaign runs (#238): `NAME` is not a trace-store handle, but it may be the +/// `--trace ` the user chose when the family was produced, which lands +/// only in the campaign document's `name` field (`put_campaign`), never in the +/// trace-store layout itself. +enum NameResolution { + /// Exactly one recorded campaign run's stored document carries `name == + /// NAME` and a persisted `trace_name` — the trace-store handle to chart. + Unique(String), + /// More than one recorded run's document carries `name == NAME` — refuse + /// rather than silently pick one; the candidate handles, in the campaign + /// store's file (append) order, so the refusal is deterministic. + Ambiguous(Vec), + /// No recorded run's document carries `name == NAME` — NAME is genuinely + /// unknown, not a chosen campaign name either. + None, +} + +/// Resolve a `chart` argument against `env.registry()`'s campaign-run records: +/// keep every record whose `trace_name` is `Some` (a family was actually +/// persisted for it) AND whose stored campaign document (`get_campaign`) +/// parses with `name == name`. Malformed/missing campaign documents are +/// skipped rather than propagated — a resolution failure downgrades to "not a +/// campaign name", not a hard error, since the trace-store NotFound message is +/// still an honest fallback. +fn resolve_campaign_name(name: &str, env: &project::Env) -> NameResolution { + let registry = env.registry(); + let records = match registry.load_campaign_runs() { + Ok(r) => r, + Err(_) => return NameResolution::None, + }; + let mut candidates = Vec::new(); + for record in &records { + let Some(trace_name) = &record.trace_name else { continue }; + let Ok(Some(doc_json)) = registry.get_campaign(&record.campaign) else { continue }; + let Ok(doc) = aura_research::parse_campaign(&doc_json) else { continue }; + if doc.name == name { + candidates.push(trace_name.clone()); } } + match candidates.len() { + 0 => NameResolution::None, + 1 => NameResolution::Unique(candidates.remove(0)), + _ => NameResolution::Ambiguous(candidates), + } } /// What `--real` parsing yields: the synthetic default, or a real symbol + an diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 3e20dec..1394da9 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -769,6 +769,104 @@ fn chart_resolves_a_trace_family_by_its_chosen_campaign_name() { let _ = std::fs::remove_dir_all(&cwd); } +/// Property (#238 ambiguity): when TWO recorded campaign runs' stored documents +/// both carry the SAME chosen `name`, `chart ` must refuse (exit 1) rather +/// than silently pick one — the resolver names both candidate trace-store +/// handles in the refusal (deterministic file/append order), so the user can +/// re-run `chart` against the specific handle they mean. Mirrors the fixture of +/// [`chart_resolves_a_trace_family_by_its_chosen_campaign_name`], duplicated +/// once with a second `put_campaign` + `append_campaign_run` under the same +/// `name` (a distinct campaign document — the `data.windows` bound differs — +/// still resolves to the same `name`, exactly the "two separate --trace +/// invocations reusing a name" scenario this guards against). +#[test] +fn chart_by_campaign_name_refuses_when_the_name_is_ambiguous() { + use aura_core::{Scalar, ScalarKind, Timestamp}; + use aura_engine::{ColumnarTrace, RunManifest}; + use aura_registry::{derive_trace_name, CampaignRunRecord, Registry, TraceStore}; + + let cwd = temp_cwd("chart-by-name-ambiguous"); + const CAMPAIGN_NAME: &str = "reused-name"; + + let reg = Registry::open(cwd.join("runs").join("runs.jsonl")); + let campaign_json_of = |to_ms: i64| { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "{CAMPAIGN_NAME}", + "data": {{ "instruments": ["GER40"], + "windows": [ {{ "from_ms": 1, "to_ms": {to_ms} }} ] }}, + "strategies": [ + {{ "ref": {{ "content_id": "9f3a" }}, + "axes": {{ "fast": {{ "kind": "I64", "values": [8, 12] }} }} }} + ], + "process": {{ "ref": {{ "content_id": "4e2d" }} }}, + "seed": 1, + "presentation": {{ "persist_taps": ["equity"], "emit": ["family_table"] }} + }}"# + ) + }; + + let manifest = || RunManifest { + commit: "c0ffee".to_string(), + params: vec![("fast".to_string(), Scalar::f64(2.0))], + window: (Timestamp(1), Timestamp(2)), + seed: 0, + broker: "sim-optimal(pip_size=1)".to_string(), + selection: None, + instrument: Some("GER40".to_string()), + topology_hash: None, + project: None, + }; + let taps = |a: f64, b: f64| { + vec![ColumnarTrace::from_rows( + "equity", + &[ScalarKind::F64], + &[(Timestamp(1), vec![Scalar::f64(a)]), (Timestamp(2), vec![Scalar::f64(b)])], + )] + }; + let store = TraceStore::open(cwd.join("runs")); + + let mut handles = Vec::new(); + for to_ms in [2, 3] { + let campaign_id = reg.put_campaign(&campaign_json_of(to_ms)).expect("store campaign document"); + let record = CampaignRunRecord { + campaign: campaign_id.clone(), + process: "4e2d".to_string(), + run: 0, + seed: 1, + cells: vec![], + generalizations: vec![], + trace_name: Some("claim".to_string()), + }; + let run = reg.append_campaign_run(&record).expect("append campaign run"); + let handle = derive_trace_name(&campaign_id, run); + store.write(&format!("{handle}/cell0/m0"), &manifest(), &taps(0.0, 0.4)).expect("write member m0"); + store.write(&format!("{handle}/cell0/m1"), &manifest(), &taps(0.0, 0.7)).expect("write member m1"); + handles.push(handle); + } + + let out = Command::new(BIN).args(["chart", CAMPAIGN_NAME]).current_dir(&cwd).output().expect("spawn chart "); + assert_eq!( + out.status.code(), + Some(1), + "an ambiguous campaign name must refuse (exit 1), not silently pick one; got {:?}, stdout: {}", + out.status.code(), + String::from_utf8_lossy(&out.stdout), + ); + assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout"); + let stderr = String::from_utf8_lossy(&out.stderr); + for handle in &handles { + assert!( + stderr.contains(handle.as_str()), + "the refusal must list every candidate handle deterministically; got: {stderr}, want handle: {handle}" + ); + } + + 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"); diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index d6bf976..5652711 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -588,4 +588,7 @@ is non-empty, the run also persists the named taps under `runs/traces//…`, chartable with `aura chart`. The `sweep`/`walkforward --trace` analog persists every member's taps as a family charted the same way, by the handle the run prints (members keyed -`/`). +`/`) — also by the `--trace ` you chose, when that name +uniquely names one recorded run (`aura chart ` resolves it against the +stored campaign documents; a name reused across runs refuses rather than +guessing which one you mean). diff --git a/docs/glossary.md b/docs/glossary.md index c0519eb..b4fe71c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -307,7 +307,7 @@ An orchestration axis varying tuning params (grid or random) within a fixed stru ### tap **Avoid:** — -A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's via the record's `trace_name`, a `sweep`/`walkforward --trace` family's via the family handle the run prints (`aura chart `; its members are keyed `/` in the chart). +A named recorded stream produced by a recording `sink` — the addressable label (e.g. `equity`, `net_r_equity`) under which one sink's per-cycle output is persisted as a columnar (SoA) `ColumnarTrace` and selected for charting via `--tap`. Distinct from the `sink` node that emits it (a tap is the stream, the sink is the role) and from a whole recorded run (a bundle of taps); taps fire at their own cadences and are fused only by joining on the recorded timestamp, never by positional index. In a `campaign document`, `persist_taps` names taps from the CLOSED vocabulary `equity | exposure | r_equity | net_r_equity` (`tap_vocabulary`, 0109/#201) — a new observable is a new vocabulary entry or an authored blueprint sink, never an open node-path namespace. Persisted taps are charted by the printed handle: a campaign run's via the record's `trace_name`, a `sweep`/`walkforward --trace` family's via the family handle the run prints (`aura chart `; its members are keyed `/` in the chart) — or, equivalently, by the `--trace ` the user chose, when `NAME` uniquely resolves against the recorded campaign documents (#238; a name reused across runs refuses rather than guessing). ### topology hash **Avoid:** —