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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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:** —
|
||||
|
||||
Reference in New Issue
Block a user