diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 774b970..413b4fa 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -433,12 +433,42 @@ fn render_chart_by_kind( 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 — \ - a trace is produced by `aura exec --tap =` on a blueprint \ - or a campaign's `presentation.persist_taps` section, not by naming a handle here)" - ); + match family_id_campaign_prefix(name) { + Some(prefix) => { + // Say what was found, never that nothing exists: an + // unreadable store also yields an empty list, so + // "none" would be a claim this path cannot support. + let handles = recorded_handles_with_prefix(prefix, store); + if handles.is_empty() { + eprintln!( + "aura: no recorded run or family '{name}' under runs/traces — that \ + looks like a family id, not a trace handle. The handle is the \ + `trace_name` the campaign run printed; this id's second segment \ + counts strategies while the handle's counts runs, so cutting the \ + id down is not a way to derive it." + ); + } else { + eprintln!( + "aura: no recorded run or family '{name}' under runs/traces — that \ + looks like a family id, not a trace handle. Recorded trace handles \ + for campaign '{prefix}': {}.", + handles.join(", ") + ); + if handles.len() == 1 { + eprintln!("Did you mean: aura chart {}", handles[0]); + } + } + } + None => { + eprintln!( + "aura: no recorded run or family '{name}' under runs/traces \ + (both a single run and a campaign run print their handle as `trace_name` \ + on stdout — one per run — so check that value for a typo. A trace is \ + produced by `aura exec --tap =` on a blueprint \ + or a campaign's `presentation.persist_taps` section, not by naming a handle here)" + ); + } + } std::process::exit(1); } }, @@ -463,6 +493,55 @@ enum NameResolution { None, } +/// A family id (`{campaign8}-{strategy_ordinal}-{instrument}-w-r-s-{run}`, +/// minted in `aura_campaign::exec` and printed under the `family_id` key) is +/// NOT a trace handle (`{campaign8}-{run}`): the second segment differs in +/// meaning, and one campaign run mints family ids at several strategy +/// ordinals whose traces all live under that one run's handle. Recognition is +/// purely syntactic: the trailing `-w-r-s-` suffix AND a leading +/// segment shaped like the campaign prefix `derive_trace_name` mints — the +/// 8-char head of a 64-hex campaign id. Requiring the hex head keeps an +/// ordinary run name that merely ends in those segments (root names may +/// contain hyphens) from being misread as a family id. +fn family_id_campaign_prefix(name: &str) -> Option<&str> { + let mut segs = name.rsplitn(5, '-'); + let member = segs.next()?; + let s = segs.next()?; + let r = segs.next()?; + let w = segs.next()?; + let head = segs.next()?; + let numeric_after = |seg: &str, tag: char| { + seg.strip_prefix(tag).is_some_and(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit())) + }; + if member.is_empty() + || !member.bytes().all(|b| b.is_ascii_digit()) + || !numeric_after(s, 's') + || !numeric_after(r, 'r') + || !numeric_after(w, 'w') + { + return None; + } + head.split('-') + .next() + .filter(|p| p.len() == 8 && p.bytes().all(|b| b.is_ascii_hexdigit())) +} + +/// The recorded handles whose name starts with `prefix-`, sorted — filtered to +/// the ones `chart` would actually accept. Reads the store rather than the +/// filesystem: file I/O for traces belongs to `aura-registry::TraceStore` +/// (C22), not to this shell. The `name_kind` filter is what keeps a suggestion +/// honest: an interrupted run leaves a directory with no `index.json`, which +/// classifies as `NotFound`, and suggesting it would send the caller into the +/// refusal they just came from. +fn recorded_handles_with_prefix(prefix: &str, store: &aura_registry::TraceStore) -> Vec { + store + .names() + .into_iter() + .filter(|n| n.strip_prefix(prefix).is_some_and(|rest| rest.starts_with('-'))) + .filter(|n| !matches!(store.name_kind(n), aura_registry::NameKind::NotFound)) + .collect() +} + /// 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`) @@ -1198,6 +1277,26 @@ fn panic_refusal_prose(msg: &str) -> String { } } +/// One stdout line: the run record's own bytes plus the trace handle, when the +/// run recorded one. `flatten` is load-bearing — composing this through +/// `serde_json::Value` would re-sort every key (its map is key-ordered), so +/// the record's byte shape would change. With `flatten` the report serializes +/// exactly as `to_json` renders it and the handle is appended as one optional +/// trailing key. +#[derive(serde::Serialize)] +struct RunLine<'a, R: serde::Serialize> { + #[serde(flatten)] + report: &'a R, + #[serde(skip_serializing_if = "Option::is_none")] + trace_name: Option<&'a str>, +} + +impl RunLine<'_, R> { + fn to_json(&self) -> String { + serde_json::to_string(self).expect("a finite run line always serializes") + } +} + /// The `exec` blueprint leg (#319): a synthetic single run of a fully-bound /// blueprint envelope — the sole surviving heir of the retired `dispatch_run`'s /// `.json`-branch (and of the retired `run` verb it served, both gone with the @@ -1344,7 +1443,7 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au // `SilencedPanic` + `catch_unwind`) and render it as a runtime-class // refusal (exit 1, C14 partition) instead of an uncaught exit 101 — // the input was well-formed argv; the value is what the node refuses. - let (report, skipped) = match aura_campaign::catch_member_panic(|| { + let outcome = match aura_campaign::catch_member_panic(|| { run_signal_r(signal, ¶ms, RunData::Synthetic, 0, env, tap_plan, Some(&base_topo)) }) .unwrap_or_else(|msg| { @@ -1354,17 +1453,20 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au Ok(p) => p, Err(e) => exit_on_runner_error(e), }; - for name in &skipped { + for name in &outcome.skipped { crate::diag::note!("declared tap \"{name}\" unbound this run"); } // #324 (comment 4501): this leg always runs `RunData::Synthetic` (no // direct-blueprint exec ever binds real data), so a validating // caller reading zero trades here as "broken strategy" needs telling // it never touched real data. - if report.metrics.r.as_ref().map_or(0, |m| m.n_trades) == 0 { + if outcome.report.metrics.r.as_ref().map_or(0, |m| m.n_trades) == 0 { crate::diag::note_synthetic_smoke_zero_trades(r_sma_prices().len()); } - println!("{}", report.to_json()); + println!( + "{}", + RunLine { report: &outcome.report, trace_name: outcome.trace_name.as_deref() }.to_json() + ); } else if has_tap { if !overrides.is_empty() { eprintln!( @@ -1388,15 +1490,18 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au std::process::exit(2); } let params: Vec = Vec::new(); - let (report, skipped) = + let outcome = match run_measurement(signal, ¶ms, RunData::Synthetic, 0, env, tap_plan) { Ok(p) => p, Err(e) => exit_on_runner_error(e), }; - for name in &skipped { + for name in &outcome.skipped { crate::diag::note!("declared tap \"{name}\" unbound this run"); } - println!("{}", report.to_json()); + println!( + "{}", + RunLine { report: &outcome.report, trace_name: outcome.trace_name.as_deref() }.to_json() + ); } else { eprintln!( "aura: exec needs either a `bias` output (a strategy) or ≥1 \ diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 48d17c1..dd8c31d 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -4519,3 +4519,145 @@ fn show_resolves_a_unique_prefix_to_the_full_id_bytes() { ); } } + +/// #309: the not-found remedy pointed at "the handle a sweep/walk-forward/ +/// campaign run printed" — two of those verbs were retired with #319, and a +/// single run printed no handle at all. It must name the field a run actually +/// emits instead. +#[test] +fn chart_unknown_name_refusal_names_trace_name_not_retired_verbs() { + let cwd = temp_cwd("chart-unknown-no-retired-verbs"); + + let out = Command::new(BIN) + .args(["chart", "ghost"]) + .current_dir(&cwd) + .output() + .expect("spawn chart ghost"); + assert_eq!(out.status.code(), Some(1), "unknown name must still exit 1: {:?}", out.status); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("trace_name"), + "the refusal must name the field a run emits its handle under: {stderr}" + ); + assert!( + !stderr.contains("sweep") && !stderr.contains("walk-forward"), + "the refusal must not cite verbs retired by #319: {stderr}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// #309: a caller who copied a family id out of a families listing must not +/// hit a dead end. The handle is NOT the id's leading pair — the id's second +/// segment is the strategy ordinal while the handle's is the campaign-run +/// counter — so the refusal lists the handles actually on disk. +#[test] +fn chart_family_id_lists_the_recorded_handles_of_that_campaign() { + let cwd = temp_cwd("chart-family-id"); + // Two recorded handles of one campaign prefix, and deliberately NO + // `deadbeef-1`: a truncation rule would suggest exactly that. + for handle in ["deadbeef-0", "deadbeef-2"] { + let dir = cwd.join("runs/traces").join(handle).join("cell/member"); + std::fs::create_dir_all(&dir).expect("fabricate a family fan-out"); + std::fs::write(dir.join("index.json"), "{}").expect("write member index"); + } + + let out = Command::new(BIN) + .args(["chart", "deadbeef-1-GER40-w0-r0-s0-0"]) + .current_dir(&cwd) + .output() + .expect("spawn chart on a family id"); + + assert_eq!(out.status.code(), Some(1), "must keep the intake's existing exit code: {:?}", out.status); + assert!(out.stdout.is_empty(), "nothing may be rendered: {:?}", String::from_utf8_lossy(&out.stdout)); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("family id"), "the refusal must identify the shape: {stderr}"); + assert!( + stderr.contains("deadbeef-0") && stderr.contains("deadbeef-2"), + "the recorded handles of that campaign must be listed: {stderr}" + ); + assert!( + !stderr.contains("Did you mean"), + "with two candidates it must not suggest one: {stderr}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// #309: with exactly one recorded handle under the prefix, the refusal +/// suggests it — the one-step fix the field test asked for. +#[test] +fn chart_family_id_suggests_the_single_recorded_handle() { + let cwd = temp_cwd("chart-family-id-single"); + let dir = cwd.join("runs/traces/deadbeef-0/cell/member"); + std::fs::create_dir_all(&dir).expect("fabricate a family fan-out"); + std::fs::write(dir.join("index.json"), "{}").expect("write member index"); + + let out = Command::new(BIN) + .args(["chart", "deadbeef-0-GER40-w0-r0-s0-0"]) + .current_dir(&cwd) + .output() + .expect("spawn chart on a family id"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("Did you mean: aura chart deadbeef-0"), + "a single candidate must be suggested verbatim: {stderr}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// #309: an index-less directory is NOT a chartable handle — an interrupted +/// run leaves one behind, and suggesting it would send the caller straight +/// back into the refusal they came from. The store lists it; the refusal must +/// filter it out. Without the `name_kind` filter this test fails by naming +/// `deadbeef-3` as the single candidate. +#[test] +fn chart_family_id_omits_an_index_less_directory_from_the_candidates() { + let cwd = temp_cwd("chart-family-id-indexless"); + // A real, chartable family. + let good = cwd.join("runs/traces/deadbeef-0/cell/member"); + std::fs::create_dir_all(&good).expect("fabricate a family fan-out"); + std::fs::write(good.join("index.json"), "{}").expect("write member index"); + // An interrupted run under the same campaign prefix: directory, no index. + std::fs::create_dir_all(cwd.join("runs/traces/deadbeef-3")).expect("fabricate a stub dir"); + + let out = Command::new(BIN) + .args(["chart", "deadbeef-1-GER40-w0-r0-s0-0"]) + .current_dir(&cwd) + .output() + .expect("spawn chart on a family id"); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("deadbeef-0"), + "the chartable handle must be listed: {stderr}" + ); + assert!( + !stderr.contains("deadbeef-3"), + "an index-less directory must not be offered as a handle: {stderr}" + ); + assert!( + stderr.contains("Did you mean: aura chart deadbeef-0"), + "with the stub filtered out exactly one candidate remains, so it is suggested: {stderr}" + ); + + let _ = std::fs::remove_dir_all(&cwd); +} + +/// #309: an unmatched prefix invents nothing. +#[test] +fn chart_family_id_with_no_recorded_campaign_suggests_nothing() { + let cwd = temp_cwd("chart-family-id-none"); + + let out = Command::new(BIN) + .args(["chart", "cafebabe-0-GER40-w0-r0-s0-0"]) + .current_dir(&cwd) + .output() + .expect("spawn chart on a family id"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.contains("family id"), "the shape is still named: {stderr}"); + assert!(!stderr.contains("Did you mean"), "nothing may be suggested: {stderr}"); + + let _ = std::fs::remove_dir_all(&cwd); +} diff --git a/crates/aura-cli/tests/tap_recording.rs b/crates/aura-cli/tests/tap_recording.rs index 7183272..a852fed 100644 --- a/crates/aura-cli/tests/tap_recording.rs +++ b/crates/aura-cli/tests/tap_recording.rs @@ -430,3 +430,75 @@ fn run_tap_selector_refuses_malformed_and_duplicate_pairs() { assert!(!cwd.join("runs").exists()); } } + +/// #309: a run that records traces names the handle they landed under, on the +/// data plane — so a caller can chain `exec` into `chart` without knowing the +/// blueprint's render name. The tapped fixture's root composite is named +/// `sma_signal`, which is exactly the directory the existing +/// `single_run_persists_a_declared_tap_series` reads its trace back from. +#[test] +fn recording_run_reports_its_trace_handle_on_stdout() { + let cwd = temp_cwd("trace-handle-echo"); + let bp_path = cwd.join("tapped_r_sma.json"); + std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint"); + + let out = Command::new(BIN) + .args(["exec", bp_path.to_str().expect("utf-8 path")]) + .current_dir(&cwd) + .output() + .expect("spawn exec"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + + let line = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(line.trim()).expect("stdout is one JSON object"); + let handle = v["trace_name"].as_str().expect("trace_name is present and a string"); + assert_eq!(handle, "sma_signal", "the handle is the run's own name"); + assert!( + cwd.join("runs/traces").join(handle).join("fast_tap.json").exists(), + "the reported handle must name the directory the traces landed in; got {handle}" + ); +} + +/// #309: the handle is appended, never interleaved — the report's own key +/// order and bytes survive the flattening wrapper untouched. This is the first +/// pin of `serde(flatten)` byte behaviour in this workspace; assert bytes. +#[test] +fn trace_handle_is_appended_as_the_last_key() { + let cwd = temp_cwd("trace-handle-bytes"); + let bp_path = cwd.join("tapped_r_sma.json"); + std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint"); + + let out = Command::new(BIN) + .args(["exec", bp_path.to_str().expect("utf-8 path")]) + .current_dir(&cwd) + .output() + .expect("spawn exec"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); + + assert!( + line.ends_with(",\"trace_name\":\"sma_signal\"}"), + "the handle must be the last key, appended before the closing brace: {line}" + ); +} + +/// #309: a tap-free run records nothing, so it reports no handle — the +/// wrapper must not emit an empty key. (The line's key order and grouping are +/// pinned separately, in `exec.rs`; this asserts only the absent key.) +#[test] +fn tap_free_run_line_carries_no_trace_handle() { + let cwd = temp_cwd("trace-handle-absent"); + let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR")); + + let out = Command::new(BIN) + .args(["exec", &bp]) + .current_dir(&cwd) + .output() + .expect("spawn exec"); + assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr)); + let line = String::from_utf8_lossy(&out.stdout).trim().to_string(); + assert!( + !line.contains("trace_name"), + "a run that recorded nothing must emit no trace_name key at all: {line}" + ); +} diff --git a/crates/aura-registry/src/trace_store.rs b/crates/aura-registry/src/trace_store.rs index 54046cc..ebc9254 100644 --- a/crates/aura-registry/src/trace_store.rs +++ b/crates/aura-registry/src/trace_store.rs @@ -158,6 +158,25 @@ impl TraceStore { NameKind::NotFound } + /// Every top-level directory entry in the store, sorted. This enumerates, + /// it does not classify: an interrupted run leaves a directory with no + /// `index.json`, which appears here but is `NotFound` to + /// [`TraceStore::name_kind`] — a caller that needs chartable names must + /// filter. A store directory that does not exist or cannot be read yields + /// none rather than an error: enumeration is a diagnostic aid, never a + /// run-blocking step, so callers must not read emptiness as "nothing + /// recorded". + pub fn names(&self) -> Vec { + let Ok(entries) = fs::read_dir(&self.dir) else { return Vec::new() }; + let mut out: Vec = entries + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .collect(); + out.sort(); + out + } + /// Read every member of a family. Two on-disk shapes are resolved: the /// depth-1 campaign layout (`//index.json`, `key` = the immediate /// subdir name) and the depth-2 #224 sweep/walk-forward fan-out @@ -575,6 +594,37 @@ mod tests { let _ = fs::remove_dir_all(&root); } + /// `names()` enumerates top-level directories, sorted, and nothing else: + /// a loose file in the store root is not a handle, and an index-less + /// directory (an interrupted run) IS listed even though `name_kind` calls + /// it absent — the split this method's callers must filter on. + #[test] + fn names_lists_sorted_directories_only() { + let root = temp_traces_root("names"); + let store = TraceStore::open(&root); + store.write("zeta", &sample_manifest(), &sample_taps()).expect("write zeta"); + store.write("alpha", &sample_manifest(), &sample_taps()).expect("write alpha"); + // An interrupted run: directory created, no index written. + store.begin_run("halfway").expect("begin halfway"); + // A loose file beside the handles must not be listed. + fs::write(root.join("traces").join("stray.json"), "{}").expect("write stray file"); + + assert_eq!(store.names(), vec!["alpha", "halfway", "zeta"]); + assert_eq!(store.name_kind("halfway"), NameKind::NotFound, "listed, but not chartable"); + + let _ = fs::remove_dir_all(&root); + } + + /// An absent store root is not an error for enumeration — callers use it + /// on a diagnostic path where a refusal is already being printed. + #[test] + fn names_on_an_absent_store_is_empty() { + let root = temp_traces_root("names-absent"); + let store = TraceStore::open(&root); + assert!(store.names().is_empty()); + let _ = fs::remove_dir_all(&root); + } + #[test] fn name_kind_classifies_run_family_and_absent() { let root = temp_traces_root("namekind"); diff --git a/crates/aura-runner/src/lib.rs b/crates/aura-runner/src/lib.rs index 43a550f..6903bd6 100644 --- a/crates/aura-runner/src/lib.rs +++ b/crates/aura-runner/src/lib.rs @@ -30,6 +30,25 @@ pub use tap_plan::{ }; pub use tap_recorder::TapRecorder; +/// What a declared-tap entry point returns: the run's record plus the two +/// values that ride *beside* it rather than inside it. The report is the +/// durable C18 record; these are per-invocation facts the shell renders +/// (C27/#297 — the library never prints). +/// +/// `Debug` is load-bearing, not decoration: a caller asserting a refusal with +/// `Result::unwrap_err` needs the `Ok` side to be `Debug`, which the +/// `(report, skipped)` tuple this type replaced satisfied implicitly. +#[derive(Debug)] +pub struct RunOutcome { + /// The run record itself. + pub report: R, + /// Declared taps the plan left unbound this run; the CLI prints the note. + pub skipped: Vec, + /// The trace-store handle the run's taps landed under, when it recorded + /// any; `None` when the plan persisted nothing. + pub trace_name: Option, +} + /// A refusal a library function reports instead of exiting the process /// itself. The shell (`aura-cli`'s `exit_on_runner_error`, shared by the /// dispatch arms) maps it back to the stderr bytes + exit code; the class diff --git a/crates/aura-runner/src/measure.rs b/crates/aura-runner/src/measure.rs index 6341664..22a9ff0 100644 --- a/crates/aura-runner/src/measure.rs +++ b/crates/aura-runner/src/measure.rs @@ -86,7 +86,7 @@ fn compile_error_prose(e: &CompileError, role_names: &[String]) -> String { pub fn run_measurement( signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env, plan: TapPlan, -) -> Result<(MeasurementReport, Vec), RunnerError> { +) -> Result, RunnerError> { // topology_hash's own two-line body, inlined (mirrors member::run_signal_r): // `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's // `topology_hash` helper is the same primitive, kept single-sourced at @@ -123,6 +123,7 @@ pub fn run_measurement( // C14 class 2: fault in argv-named content (#297) let bound = bind_tap_plan(&mut flat, plan, env, &run_name) .map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?; + let trace_name = bound.trace_name().map(str::to_string); let mut h = Harness::bootstrap(flat).expect("valid measurement harness"); h.run_bound(key_supply(&binding, sources)) @@ -140,7 +141,11 @@ pub fn run_measurement( let skipped = bound.skipped().to_vec(); bound.finish(&manifest) .map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?; - Ok((MeasurementReport { manifest, taps: tap_names }, skipped)) + Ok(crate::RunOutcome { + report: MeasurementReport { manifest, taps: tap_names }, + skipped, + trace_name, + }) } #[cfg(test)] @@ -193,9 +198,9 @@ mod tests { let (env, root) = temp_project_env("fold"); let mut plan = TapPlan::empty(); plan.subscribe("fast_tap", TapSubscription::named("mean")); - let (report, _skipped) = + let report = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan) - .expect("measurement run succeeds"); + .expect("measurement run succeeds").report; assert_eq!(report.taps, vec!["fast_tap".to_string()]); let text = std::fs::read_to_string( @@ -222,9 +227,9 @@ mod tests { let (env, root) = temp_project_env("tapfree"); let doc = include_str!("../../aura-cli/examples/r_sma.json"); let signal = blueprint_from_json(doc, &|t| std_vocabulary(t)).expect("r_sma loads"); - let (report, _skipped) = + let report = run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all()) - .expect("measurement run succeeds"); + .expect("measurement run succeeds").report; assert!(report.taps.is_empty(), "no declared taps"); assert!(!root.join("runs").exists(), "a tap-free run writes no runs/ entry"); } diff --git a/crates/aura-runner/src/member.rs b/crates/aura-runner/src/member.rs index 54c3c6f..af1d53c 100644 --- a/crates/aura-runner/src/member.rs +++ b/crates/aura-runner/src/member.rs @@ -553,7 +553,7 @@ fn compile_error_prose(e: &CompileError, names: &[String], params: &[Scalar]) -> pub fn run_signal_r( signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env, plan: TapPlan, topo: Option<&str>, -) -> Result<(RunReport, Vec), RunnerError> { +) -> Result, RunnerError> { // #297 fieldtest finding B1 (milestone-safe-to-embed): mirror the CLI's // own `exec_blueprint_leg` exposes-neither guard (`aura-cli/src/main.rs`, // `has_bias`/`has_tap`) IN the library, before `wrap_r` below wires a @@ -618,6 +618,7 @@ pub fn run_signal_r( // C14 class 2: fault in argv-named content (#297) let bound = bind_tap_plan(&mut flat, plan, env, &run_name) .map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?; + let trace_name = bound.trace_name().map(str::to_string); let mut h = Harness::bootstrap(flat).expect("valid r-sma harness"); // `sources` were opened via `resolve_run_data(&data, env, &binding)` against // this SAME `binding`, and `key_supply` keys them by that binding's own role @@ -645,7 +646,11 @@ pub fn run_signal_r( // tap-free (or nothing-persisting) plan wrote nothing at all. bound.finish(&manifest) .map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?; - Ok((RunReport { manifest, metrics }, skipped)) + Ok(crate::RunOutcome { + report: RunReport { manifest, metrics }, + skipped, + trace_name, + }) } /// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path @@ -1262,10 +1267,10 @@ mod tests { let env = Env::std(); let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_breakout.json"), &|t| std_vocabulary(t)) .expect("shipped r_breakout example loads"); - let (via_file, _) = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None) - .expect("r_breakout example runs"); - let (via_carve, _) = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None) - .expect("carved r_breakout signal runs"); + let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None) + .expect("r_breakout example runs").report; + let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None) + .expect("carved r_breakout signal runs").report; assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); } @@ -1417,9 +1422,9 @@ mod tests { let env = Env::std(); let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_meanrev.json"), &|t| std_vocabulary(t)) .expect("shipped r_meanrev example loads"); - let (via_file, _) = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None) - .expect("r_meanrev example runs"); - let (via_carve, _) = run_signal_r( + let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None) + .expect("r_meanrev example runs").report; + let via_carve = run_signal_r( r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)), &[], RunData::Synthetic, @@ -1428,7 +1433,7 @@ mod tests { TapPlan::record_all(), None, ) - .expect("carved r_meanrev signal runs"); + .expect("carved r_meanrev signal runs").report; assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); } diff --git a/crates/aura-runner/src/tap_plan.rs b/crates/aura-runner/src/tap_plan.rs index 77ccbb9..7b6e22e 100644 --- a/crates/aura-runner/src/tap_plan.rs +++ b/crates/aura-runner/src/tap_plan.rs @@ -372,6 +372,11 @@ pub struct BoundTaps { /// caller-printed "unbound" note migrates to the CLI, this is the data /// it prints from. skipped: Vec, + /// The trace-store handle this run's taps landed under — `Some` exactly + /// when the plan persisted something and `begin_run` was called. Rides + /// beside the report like `skipped` (#297): the CLI prints it, the + /// library hands it back as a value. + trace_name: Option, } impl BoundTaps { @@ -380,6 +385,11 @@ impl BoundTaps { &self.declared } + /// The recorded trace handle, or `None` when this run persisted nothing. + pub fn trace_name(&self) -> Option<&str> { + self.trace_name.as_deref() + } + /// Declared taps that resolved to no subscription this run (#297) — the /// data the caller's "unbound" note prints from. pub fn skipped(&self) -> &[String] { @@ -508,6 +518,7 @@ pub fn bind_tap_plan( rows: Vec::new(), outcomes: Vec::new(), skipped, + trace_name: if persists { Some(run_name.to_string()) } else { None }, }; for (name, kind, sub) in resolved { let node: Box = match sub { diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index ea316e0..f9680f5 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -826,7 +826,7 @@ $ aura exec 42edebd2159de708009ba21e1ed4aea2cffabc373bf761c5765f79c190b677bd With two stop regimes every (instrument, window) cell runs twice — the four cells above are the "4 cell(s)" the validate summary counted. The regime ordinal is an INFIX segment, `-r{k}`, right after the window segment -(`{id8}-{ordinal}-{instrument}-w{k}-r{k}-s{seed}-{member}`) — the +(`{id8}-{ordinal}-{instrument}-w{k}-r{k}-s{stage}-{run}`) — the default/first regime carries `-r0` explicitly, never left unmarked — each cell record names its regime, and generalization is keyed per regime — regimes are compared, never pooled. @@ -843,7 +843,8 @@ name — chartable with `aura chart ` (members keyed `/`); `aura chart` also resolves a bare campaign id/name against the stored campaign documents when no literal trace directory matches, refusing rather than guessing when the name is ambiguous across -recorded runs. +recorded runs. A single run reports its handle the same way: the `trace_name` value on +its own stdout line, absent when the run recorded nothing. Exit codes: a clean run exits 0; a usage error exits 2; a refusal before any cell runs (invalid document, missing project, unresolvable strategy) exits 1; diff --git a/docs/design/contracts/c22-playground-traces.md b/docs/design/contracts/c22-playground-traces.md index 8ee4d65..4def728 100644 --- a/docs/design/contracts/c22-playground-traces.md +++ b/docs/design/contracts/c22-playground-traces.md @@ -71,6 +71,13 @@ path's job, not a per-run flag (the repeatable `--tap TAP=FOLD` selector, #310, only chooses which of the blueprint's own declared taps to subscribe, never names the trace). +A run that persists anything reports the handle it wrote under as +`trace_name` on its stdout line (#309) — appended to the record's own bytes, +absent entirely when the run recorded nothing — so the handle is read, never +guessed. The library hands it to an embedding caller as a value beside the +report, like the unbound-tap names (C27/#297); it is not part of the stored +record. + **Families.** A campaign document's `presentation.persist_taps` (the closed tap vocabulary, C18/C27) requests traces for each cell's nominee, run through `aura exec ` (#319 — a document's `axes` replace the