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
+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");