feat(cli): chart resolves a --trace family by its chosen campaign name (#238)

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
This commit is contained in:
2026-07-11 18:04:09 +02:00
parent c70b4cc2d2
commit 18f8e72946
4 changed files with 185 additions and 10 deletions
+82 -8
View File
@@ -401,7 +401,19 @@ fn filter_to_tap(data: ChartData, tap: &str) -> Result<ChartData, String> {
/// (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 <NAME>` argument against the recorded
/// campaign runs (#238): `NAME` is not a trace-store handle, but it may be the
/// `--trace <NAME>` 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<String>),
/// 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
+98
View File
@@ -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 <NAME>` 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 <NAME>
/// 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 <name>");
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");
+4 -1
View File
@@ -588,4 +588,7 @@ is non-empty, the run also persists the named taps under
`runs/traces/<trace_name>/…`, 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
`<cell>/<member>`).
`<cell>/<member>`) — also by the `--trace <NAME>` you chose, when that name
uniquely names one recorded run (`aura chart <NAME>` resolves it against the
stored campaign documents; a name reused across runs refuses rather than
guessing which one you mean).
+1 -1
View File
@@ -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 <handle>`; its members are keyed `<cell>/<member>` 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 <handle>`; its members are keyed `<cell>/<member>` in the chart) — or, equivalently, by the `--trace <NAME>` 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:** —