diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 3c2e4f1..07744ae 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1069,6 +1069,15 @@ pub(crate) fn exit_on_campaign_result(r: Result) { } } +/// The dispatch_reproduce pattern, shared: print the runner refusal and +/// exit with its class (#297). Empty message = no stderr line. +fn exit_on_runner_error(e: aura_runner::RunnerError) -> ! { + if !e.message.is_empty() { + eprintln!("aura: {}", e.message); + } + std::process::exit(e.exit_code); +} + /// Build the run's tap plan from repeated `--tap TAP=FOLD` selections /// (#310). No selections → the record-all default (today's behaviour). /// Any selection → an explicit plan that REPLACES the default entirely: @@ -1334,13 +1343,19 @@ 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 = aura_campaign::catch_member_panic(|| { + let (report, skipped) = match aura_campaign::catch_member_panic(|| { run_signal_r(signal, ¶ms, RunData::Synthetic, 0, env, tap_plan, Some(&base_topo)) }) .unwrap_or_else(|msg| { eprintln!("aura: {}", panic_refusal_prose(&msg)); std::process::exit(1); - }); + }) { + Ok(p) => p, + Err(e) => exit_on_runner_error(e), + }; + for name in &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 @@ -1372,14 +1387,22 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au std::process::exit(2); } let params: Vec = Vec::new(); - let report = run_measurement(signal, ¶ms, RunData::Synthetic, 0, env, tap_plan); + let (report, skipped) = + 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 { + crate::diag::note!("declared tap \"{name}\" unbound this run"); + } println!("{}", report.to_json()); } else { eprintln!( "aura: exec needs either a `bias` output (a strategy) or ≥1 \ declared tap (a measurement); this blueprint exposes neither" ); - std::process::exit(1); + // C14 class 2: fault in argv-named content (#297) + std::process::exit(2); } } @@ -1445,15 +1468,12 @@ fn dispatch_runs(a: RunsCmd, env: &aura_runner::project::Env) { fn dispatch_reproduce(a: ReproduceCmd, env: &aura_runner::project::Env) { // `reproduce_family` (#295) returns a `RunnerError` instead of exiting - // the process itself; this is the one place that prints its message - // (empty for the "not every member reproduced" refusal, since no stderr - // line accompanies it — only the stdout summary `reproduce_family` - // already emitted) and exits with its code. + // the process itself; the shared helper prints its message (empty for + // the "not every member reproduced" refusal, since no stderr line + // accompanies it — only the stdout summary `reproduce_family` already + // emitted) and exits with its code. if let Err(e) = aura_runner::reproduce::reproduce_family(&a.id, env) { - if !e.message.is_empty() { - eprintln!("aura: {}", e.message); - } - std::process::exit(e.exit_code); + exit_on_runner_error(e); } } @@ -1830,7 +1850,7 @@ mod tests { aura_runner::binding::resolve_binding("persistnet", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; - let window = data.full_window(&env); + let window = data.full_window(&env).expect("synthetic full_window never refuses"); let pip = data.pip_size(); let cost = [aura_research::CostSpec::Constant { cost_per_trade: aura_research::CostValue::Scalar(0.0005), @@ -1842,7 +1862,7 @@ mod tests { reload(), &[], &space, - data.run_sources(&env, &binding.columns()), + data.run_sources(&env, &binding.columns()).expect("synthetic run_sources never refuses"), window, 0, pip, @@ -1852,7 +1872,8 @@ mod tests { &binding, &cost, "GER40", - ); + ) + .expect("GER40-keyed cost fixture resolves"); // The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg` // built through the SAME `aura_runner::translate::cost_nodes_for`, then @@ -1864,13 +1885,20 @@ mod tests { let (tx_req, _rx_req) = mpsc::channel(); let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); - let cost_leg = - Some(CostLeg { nodes: aura_runner::translate::cost_nodes_for(&cost, "GER40"), tx_cost, tx_net }); + let cost_leg = Some(CostLeg { + nodes: aura_runner::translate::cost_nodes_for(&cost, "GER40").unwrap_or_else(|f| { + eprintln!("aura: {}", f.message()); + // C14 class 2: fault in argv-named content (#297) + std::process::exit(2); + }), + tx_cost, + tx_net, + }); let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg) .compile_with_params(&[]) .expect("the persist-side wrap builds"); let mut h = Harness::bootstrap(flat).expect("the persist-side harness bootstraps"); - h.run(data.run_sources(&env, &binding.columns())); + h.run(data.run_sources(&env, &binding.columns()).expect("synthetic run_sources never refuses")); let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); @@ -2150,11 +2178,17 @@ mod tests { let env = aura_runner::project::Env::std(); let d = DataSource::Synthetic; assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE); - assert!(!d.run_sources(&env, &[aura_ingest::M1Field::Close]).is_empty()); + assert!(!d + .run_sources(&env, &[aura_ingest::M1Field::Close]) + .expect("synthetic run_sources never refuses") + .is_empty()); assert_eq!(d.wf_window_sizes(), (24, 12, 12)); // full_window equals window_of over the showcase stream (byte-unchanged source) let s: Vec> = vec![Box::new(VecSource::new(showcase_prices()))]; - assert_eq!(d.full_window(&env), window_of(&s).unwrap()); + assert_eq!( + d.full_window(&env).expect("synthetic full_window never refuses"), + window_of(&s).unwrap() + ); } #[test] @@ -2223,7 +2257,7 @@ mod tests { let topo = test_topology_hash(&reload()); let binding = aura_runner::binding::resolve_binding("everymember", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); - let window = data.full_window(&env); + let window = data.full_window(&env).expect("synthetic full_window never refuses"); let pip = data.pip_size(); // fast pinned at 2, slow varied over {4, 6}: two members, slow=6 the open-at-end one. let space = blueprint_axis_probe(&doc, &env).param_space(); @@ -2234,7 +2268,7 @@ mod tests { reload(), &[Cell::from_i64(2), Cell::from_i64(slow)], &space, - data.run_sources(&env, &binding.columns()), + data.run_sources(&env, &binding.columns()).expect("synthetic run_sources never refuses"), window, 0, pip, @@ -2245,6 +2279,7 @@ mod tests { &[], "", ) + .expect("no cost model: nothing to refuse") }) .collect(); @@ -2292,7 +2327,7 @@ mod tests { let topo = test_topology_hash(&reload()); let space = blueprint_axis_probe(&doc, &env).param_space(); let pip = data.pip_size(); - let window = data.full_window(&env); + let window = data.full_window(&env).expect("synthetic full_window never refuses"); // Mint one member under a NON-default vol-stop regime (default is length=3, k=2.0). // run_blueprint_member stamps stop_length=8/stop_k=4.0 into the manifest params. @@ -2303,7 +2338,7 @@ mod tests { reload(), &[], &space, - data.run_sources(&env, &binding.columns()), + data.run_sources(&env, &binding.columns()).expect("synthetic run_sources never refuses"), window, 0, pip, @@ -2313,7 +2348,8 @@ mod tests { &binding, &[], "GER40", - ); + ) + .expect("no cost model: nothing to refuse"); // persist exactly as the sweep/campaign paths do: store the blueprint, append the family. let canonical = blueprint_to_json(&reload()).unwrap(); @@ -2358,7 +2394,7 @@ mod tests { let topo = test_topology_hash(&reload()); let space = blueprint_axis_probe(&doc, &env).param_space(); let pip = data.pip_size(); - let window = data.full_window(&env); + let window = data.full_window(&env).expect("synthetic full_window never refuses"); let binding = aura_runner::binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let cost = vec![ @@ -2376,7 +2412,7 @@ mod tests { reload(), &[], &space, - data.run_sources(&env, &binding.columns()), + data.run_sources(&env, &binding.columns()).expect("synthetic run_sources never refuses"), window, 0, pip, @@ -2386,7 +2422,8 @@ mod tests { &binding, &cost, "GER40", - ); + ) + .expect("GER40-keyed cost fixture resolves"); // Non-vacuity: the cost model must actually bite, else a DIVERGED // verdict could never be observed and this pin proves nothing. let r = report.metrics.r.as_ref().expect("member carries R metrics"); @@ -2442,6 +2479,7 @@ mod tests { reload(), &[], &space, sources, window, seed, data.pip_size(), &topo, &env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], "", ) + .expect("no cost model: nothing to refuse") }) .collect(); @@ -2498,6 +2536,7 @@ mod tests { reload(), &[], &space, sources, window, seed, data.pip_size(), &topo, &env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], "", ) + .expect("no cost model: nothing to refuse") }) .collect(); diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index 3d52448..2c6cda0 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -793,7 +793,8 @@ fn exec_override_negative_value_wraps_the_raw_panic_in_domain_prose() { #[test] fn exec_override_kind_mismatch_refuses_with_prose_not_the_debug_struct() { let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "bias.scale=2"]); - assert_eq!(code, Some(1), "stdout/stderr: {out}"); + // C14 class 2: fault in argv-named content (#297) + assert_eq!(code, Some(2), "stdout/stderr: {out}"); assert!( !out.contains("ParamKindMismatch"), "the Debug struct must not leak: {out}" @@ -1399,7 +1400,8 @@ fn exec_rejects_an_unknown_node_blueprint() { #[test] fn exec_refuses_a_multi_column_blueprint() { let (out, code) = run_code(&["exec", "examples/r_channel.json"]); - assert_eq!(code, Some(1), "stdout/stderr: {out}"); + // C14 class 2: fault in argv-named content (#297) + assert_eq!(code, Some(2), "stdout/stderr: {out}"); assert!(out.contains("consumes columns beyond close"), "names the shape: {out}"); assert!(out.contains("--real"), "names the remedy: {out}"); } @@ -1429,7 +1431,8 @@ fn exec_refuses_a_multi_column_blueprint_before_data_access() { let bp_path = cwd.join("hl_range.json"); std::fs::write(&bp_path, HL_RANGE_CLOSED_BLUEPRINT).expect("write fixture"); let (out, code) = run_code_in(&cwd, &["exec", bp_path.to_str().expect("utf-8 path")]); - assert_eq!(code, Some(1), "stdout/stderr: {out}"); + // C14 class 2: fault in argv-named content (#297) + assert_eq!(code, Some(2), "stdout/stderr: {out}"); assert_eq!( out.trim_end(), "aura: strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \ diff --git a/crates/aura-cli/tests/graph_construct.rs b/crates/aura-cli/tests/graph_construct.rs index 8a5b52f..eb99fd9 100644 --- a/crates/aura-cli/tests/graph_construct.rs +++ b/crates/aura-cli/tests/graph_construct.rs @@ -2478,7 +2478,8 @@ fn running_an_open_blueprint_refuses_at_bootstrap() { let bp = dir.join("open.bp.json"); std::fs::write(&bp, &build_out).expect("write built blueprint"); let (stdout, stderr, code) = run_in(&dir, &["exec", bp.to_str().unwrap()]); - assert_eq!(code, Some(1), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}"); + // C14 class 2: fault in argv-named content (#297) + assert_eq!(code, Some(2), "an open blueprint refuses standalone at bootstrap, not finish: {stdout} {stderr}"); assert!(stdout.is_empty(), "no report emitted on a bootstrap refusal: {stdout}"); assert!( stderr.contains("root role \"price\" is unbound"), diff --git a/crates/aura-cli/tests/run_refuses_unrunnable_blueprint.rs b/crates/aura-cli/tests/run_refuses_unrunnable_blueprint.rs index 796db98..0ed0d88 100644 --- a/crates/aura-cli/tests/run_refuses_unrunnable_blueprint.rs +++ b/crates/aura-cli/tests/run_refuses_unrunnable_blueprint.rs @@ -1,8 +1,8 @@ //! `aura run ` must REFUSE a user-authorable blueprint it -//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the -//! established binding-refusal register — never a process panic. Both cases -//! are user mistakes on the hand-authored single-run path, so each deserves a -//! diagnosis, not a stack trace. +//! cannot run — a clean `aura:`-prefixed message on stderr and exit 2, the +//! argv-named blueprint-content class (C14) — never a process panic. Both +//! cases are user mistakes on the hand-authored single-run path, so each +//! deserves a diagnosis, not a stack trace. //! //! Two independent unrunnable shapes, each pinned as its own minimal, //! self-contained blueprint (inlined here — no shared fixture file): @@ -51,16 +51,18 @@ fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) { !stderr.to_lowercase().contains("panic"), "{what}: expected a clean refusal, got a PANIC:\n{stderr}" ); - // The clean-refusal register: `aura: ` on stderr, exit 1 — exactly - // what the binding refusals in `run_signal_r` already emit. + // The clean-refusal register: `aura: ` on stderr — the prefix every + // refusal class (C14) shares regardless of exit code. assert!( stderr.contains("aura:"), "{what}: refusal must carry the `aura:` prefix:\n{stderr}" ); + // C14 class 2: fault in argv-named content (#297) — both callers of this + // helper name a compile-time / exposed-output content fault. assert_eq!( out.status.code(), - Some(1), - "{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}" + Some(2), + "{what}: a refusal exits 2 (blueprint-content fault, not 101 panic, not 1 env/data fault):\n{stderr}" ); } diff --git a/crates/aura-cli/tests/tap_recording.rs b/crates/aura-cli/tests/tap_recording.rs index 22fbdd1..7183272 100644 --- a/crates/aura-cli/tests/tap_recording.rs +++ b/crates/aura-cli/tests/tap_recording.rs @@ -158,6 +158,8 @@ fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() { .expect("spawn aura run"); assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound"); + // C14 class 2: fault in argv-named content (#297) + assert_eq!(out.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("dup") && stderr.contains("more than once"), @@ -184,6 +186,8 @@ fn an_unwritable_store_root_exits_through_the_tap_trace_register() { .expect("spawn aura run"); assert!(!out.status.success(), "an unwritable store must refuse, not succeed"); + // C14 class 1: environment fault (#297) + assert_eq!(out.status.code(), Some(1)); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("writing tap traces failed"), @@ -220,6 +224,8 @@ fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() { .expect("restore run dir permissions"); assert!(!out.status.success(), "a failed writer must surface, not pass silently"); + // C14 class 1: environment fault (#297) + assert_eq!(out.status.code(), Some(1)); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("writing tap traces failed"), @@ -314,6 +320,8 @@ fn run_tap_selector_refuses_an_unknown_label_with_the_roster() { .output() .expect("spawn aura run"); assert!(!out.status.success(), "an unknown fold label must refuse"); + // C14 class 2: fault in argv-named content (#297) + assert_eq!(out.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&out.stderr); assert!( stderr.contains("medain") && stderr.contains("mean"), @@ -339,6 +347,8 @@ fn run_tap_selector_refuses_an_undeclared_tap() { .output() .expect("spawn aura run"); assert!(!out.status.success(), "an undeclared tap must refuse"); + // C14 class 2: fault in argv-named content (#297) + assert_eq!(out.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&out.stderr); assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}"); assert!( diff --git a/crates/aura-runner/src/family.rs b/crates/aura-runner/src/family.rs index 34fb584..fe361c6 100644 --- a/crates/aura-runner/src/family.rs +++ b/crates/aura-runner/src/family.rs @@ -18,6 +18,7 @@ use aura_backtest::{WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS}; use crate::member::{no_real_data, pip_or_refuse, probe_window, SYNTHETIC_PIP_SIZE}; use crate::project::Env; +use crate::RunnerError; /// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising /// again) used as `DataSource::Synthetic`'s full-window stream for the built-in @@ -90,19 +91,20 @@ pub enum DataSource { } impl DataSource { - /// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol - /// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G), - /// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses. - pub fn from_choice(choice: DataChoice, env: &Env) -> DataSource { + /// Build a provider from a parsed choice, or refuse (#297: returned, not + /// printed) on a symbol with no recorded geometry / absent data — both + /// BEFORE any member runs (Fork C/G), via the same `pip_or_refuse` / + /// `no_real_data` helpers `open_real_source` uses. + pub fn from_choice(choice: DataChoice, env: &Env) -> Result { match choice { - DataChoice::Synthetic => DataSource::Synthetic, + DataChoice::Synthetic => Ok(DataSource::Synthetic), DataChoice::Real { symbol, from_ms, to_ms } => { let server = Arc::new(data_server::DataServer::new(env.data_path())); - let pip = pip_or_refuse(&server, &symbol, env); + let pip = pip_or_refuse(&server, &symbol, env)?; if !server.has_symbol(&symbol) { - no_real_data(&symbol, env); + return Err(no_real_data(&symbol, env)); } - DataSource::Real { server, symbol, from_ms, to_ms, pip } + Ok(DataSource::Real { server, symbol, from_ms, to_ms, pip }) } } } @@ -117,11 +119,11 @@ impl DataSource { /// The full run window, probed once. Synthetic: the showcase span. Real: /// `probe_window` drains a separate single-pass probe source for first/last ts /// (the same helper `open_real_source` uses for its manifest window). - pub fn full_window(&self, env: &Env) -> (Timestamp, Timestamp) { + pub fn full_window(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> { match self { DataSource::Synthetic => { let s: Vec> = vec![Box::new(VecSource::new(showcase_prices()))]; - window_of(&s).expect("non-empty showcase stream") + Ok(window_of(&s).expect("non-empty showcase stream")) } DataSource::Real { server, symbol, from_ms, to_ms, .. } => { probe_window(server, symbol, *from_ms, *to_ms, env) @@ -135,11 +137,11 @@ impl DataSource { /// longer built-in stream (byte-unchanged from the retired pre-`DataSource` /// `walkforward_family`, which derived its span the same way). Real: the same /// probed `--from..--to` window as `full_window`. - pub fn wf_full_span(&self, env: &Env) -> (Timestamp, Timestamp) { + pub fn wf_full_span(&self, env: &Env) -> Result<(Timestamp, Timestamp), RunnerError> { match self { DataSource::Synthetic => { let s: Vec> = vec![Box::new(VecSource::new(walkforward_prices()))]; - window_of(&s).expect("non-empty walkforward stream") + Ok(window_of(&s).expect("non-empty walkforward stream")) } DataSource::Real { server, symbol, from_ms, to_ms, .. } => { probe_window(server, symbol, *from_ms, *to_ms, env) @@ -150,12 +152,14 @@ impl DataSource { /// A fresh full-window source set per member (single-pass): the synthetic /// showcase close stream, or one real source per resolved binding column /// in canonical order (callers guard the synthetic arm to `{close}`). - pub fn run_sources(&self, env: &Env, fields: &[aura_ingest::M1Field]) -> Vec> { + pub fn run_sources( + &self, env: &Env, fields: &[aura_ingest::M1Field], + ) -> Result>, RunnerError> { match self { - DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))], + DataSource::Synthetic => Ok(vec![Box::new(VecSource::new(showcase_prices()))]), DataSource::Real { server, symbol, from_ms, to_ms, .. } => { aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields) - .unwrap_or_else(|| no_real_data(symbol, env)) + .ok_or_else(|| no_real_data(symbol, env)) } } } @@ -165,12 +169,12 @@ impl DataSource { /// binding column over the ns-native window. pub fn windowed_sources( &self, from: Timestamp, to: Timestamp, env: &Env, fields: &[aura_ingest::M1Field], - ) -> Vec> { + ) -> Result>, RunnerError> { match self { - DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))], + DataSource::Synthetic => Ok(vec![Box::new(walkforward_window_source(from, to))]), DataSource::Real { server, symbol, .. } => { aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields) - .unwrap_or_else(|| no_real_data(symbol, env)) + .ok_or_else(|| no_real_data(symbol, env)) } } } diff --git a/crates/aura-runner/src/lib.rs b/crates/aura-runner/src/lib.rs index 8621760..43a550f 100644 --- a/crates/aura-runner/src/lib.rs +++ b/crates/aura-runner/src/lib.rs @@ -31,10 +31,10 @@ pub use tap_plan::{ pub use tap_recorder::TapRecorder; /// A refusal a library function reports instead of exiting the process -/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place -/// that maps it back to the identical stderr bytes + exit code, so the -/// binary's observable behaviour stays byte-unchanged (#295, spec §Error -/// handling). +/// 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 +/// follows the C14 partition — argv-named content 2, environment/data/IO 1 +/// (#295/#297). #[derive(Debug)] pub struct RunnerError { pub exit_code: i32, diff --git a/crates/aura-runner/src/measure.rs b/crates/aura-runner/src/measure.rs index f1ce7e8..6341664 100644 --- a/crates/aura-runner/src/measure.rs +++ b/crates/aura-runner/src/measure.rs @@ -15,6 +15,7 @@ use aura_engine::{CompileError, Composite, Harness, MeasurementReport, RunManife use crate::member::{key_supply, raw_bound_defaults, resolve_run_data, RunData}; use crate::project::Env; use crate::tap_plan::{bind_tap_plan, TapPlan}; +use crate::RunnerError; /// The single build-time commit provenance (`option_env!("AURA_COMMIT")`, /// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit` @@ -85,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, -) -> MeasurementReport { +) -> Result<(MeasurementReport, Vec), 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 @@ -94,14 +95,12 @@ pub fn run_measurement( &aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"), ); // before signal is consumed let run_name = signal.name().to_string(); + // C14 class 2: fault in argv-named content (#297) let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); + .map_err(|m| RunnerError { exit_code: 2, message: m })?; + // C14 class 2: fault in argv-named content (#297) if matches!(data, RunData::Synthetic) && !binding.close_only() { - eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding)); - std::process::exit(1); + return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) }); } let names: Vec = signal.param_space().iter().map(|p| p.name.clone()).collect(); // #339 item 3: `signal`'s own root-role names, captured before @@ -112,20 +111,18 @@ pub fn run_measurement( // engine boundary. `role_names` lets `compile_error_prose` resolve it. let role_names: Vec = signal.input_roles().iter().map(|r| r.name.clone()).collect(); let defaults = raw_bound_defaults(&signal); - let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding); + let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding)?; // Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks. - let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| { - eprintln!("aura: {}", compile_error_prose(&e, &role_names)); - std::process::exit(1); - }); + // C14 class 2: fault in argv-named content (#297) + let mut flat = signal.compile_with_params(params) + .map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &role_names) })?; // Bind each declared tap per the plan's subscription (mirrors // run_signal_r — the shared bind_tap_plan/BoundTaps pair IS the mirror). - let bound = bind_tap_plan(&mut flat, plan, env, &run_name).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); + // 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 mut h = Harness::bootstrap(flat).expect("valid measurement harness"); h.run_bound(key_supply(&binding, sources)) @@ -140,11 +137,10 @@ pub fn run_measurement( // Close the tap plan (mirrors run_signal_r; nothing buffered, #283). let tap_names: Vec = bound.declared_names().to_vec(); - bound.finish(&manifest).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - MeasurementReport { manifest, taps: tap_names } + 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)) } #[cfg(test)] @@ -197,7 +193,9 @@ mod tests { let (env, root) = temp_project_env("fold"); let mut plan = TapPlan::empty(); plan.subscribe("fast_tap", TapSubscription::named("mean")); - let report = run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan); + let (report, _skipped) = + run_measurement(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan) + .expect("measurement run succeeds"); assert_eq!(report.taps, vec!["fast_tap".to_string()]); let text = std::fs::read_to_string( @@ -224,8 +222,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 = - run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all()); + let (report, _skipped) = + run_measurement(signal, &[], RunData::Synthetic, 0, &env, TapPlan::record_all()) + .expect("measurement run succeeds"); 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 32eef47..018043c 100644 --- a/crates/aura-runner/src/member.rs +++ b/crates/aura-runner/src/member.rs @@ -24,10 +24,13 @@ use aura_backtest::{ use aura_std::{GatedRecorder, LinComb, Recorder, RollingMax, RollingMin, SeriesReducer, Sub}; use aura_strategy::{cost_port, GEOMETRY_WIDTH}; +use aura_campaign::MemberFault; + use crate::binding::ResolvedBinding; use crate::project::Env; use crate::tap_plan::{bind_tap_plan, TapPlan}; use crate::translate::{R_SMA_STOP_LENGTH, R_SMA_STOP_K}; +use crate::RunnerError; /// The single build-time commit provenance (`option_env!("AURA_COMMIT")`, /// falling back to `"unknown"`) — `sim_optimal_manifest`'s `RunManifest.commit` @@ -119,46 +122,51 @@ pub fn r_sma_prices() -> Vec<(Timestamp, Scalar)> { .collect() } -/// No-local-data refusal — stderr + exit(1). -pub fn no_real_data(symbol: &str, env: &Env) -> ! { - eprintln!("aura: no local data for symbol '{symbol}' at {}", env.data_path()); - std::process::exit(1) +/// No-local-data refusal (#297: returned, not printed — the CLI/library +/// boundary prints `aura: {message}` and exits with `exit_code`). +pub fn no_real_data(symbol: &str, env: &Env) -> RunnerError { + RunnerError { + exit_code: 1, + message: format!("no local data for symbol '{symbol}' at {}", env.data_path()), + } } -/// Empty-in-window refusal — stderr + exit(1). Distinct from `no_real_data`: -/// used only inside `probe_window`, whose callers have already proven the -/// symbol present via `has_symbol` — an empty probe result there is a fact -/// about the requested `--from`/`--to` window, not about symbol absence, so -/// it must not reuse the symbol-absence message (#242). -fn no_data_in_window(symbol: &str, from_ms: Option, to_ms: Option, env: &Env) -> ! { +/// Empty-in-window refusal (#297: returned, not printed). Distinct from +/// `no_real_data`: used only inside `probe_window`, whose callers have already +/// proven the symbol present via `has_symbol` — an empty probe result there is +/// a fact about the requested `--from`/`--to` window, not about symbol +/// absence, so it must not reuse the symbol-absence message (#242). +fn no_data_in_window(symbol: &str, from_ms: Option, to_ms: Option, env: &Env) -> RunnerError { let bound = |b: Option| b.map_or_else(|| "unbounded".to_string(), |v| v.to_string()); - eprintln!( - "aura: no data for symbol '{symbol}' in the requested window [{}, {}] at {}", - bound(from_ms), - bound(to_ms), - env.data_path() - ); - std::process::exit(1) + RunnerError { + exit_code: 1, + message: format!( + "no data for symbol '{symbol}' in the requested window [{}, {}] at {}", + bound(from_ms), + bound(to_ms), + env.data_path() + ), + } } /// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse -/// (stderr + exit 1) when the symbol has no recorded geometry — the single home of -/// the guessed-pip refusal, shared by `open_real_source` and the CLI shell's -/// `DataSource::from_choice`. Reads the symbol's geometry metadata (not bar data) -/// before the run source opens, so an instrument with no recorded geometry -/// refuses without a guessed pip; the pip is the provider's recorded value, -/// honest by construction. -pub fn pip_or_refuse(server: &Arc, symbol: &str, env: &Env) -> f64 { +/// (#297: returned, not printed) when the symbol has no recorded geometry — the +/// single home of the guessed-pip refusal, shared by `open_real_source` and the +/// CLI shell's `DataSource::from_choice`. Reads the symbol's geometry metadata +/// (not bar data) before the run source opens, so an instrument with no +/// recorded geometry refuses without a guessed pip; the pip is the provider's +/// recorded value, honest by construction. +pub fn pip_or_refuse(server: &Arc, symbol: &str, env: &Env) -> Result { match aura_ingest::instrument_geometry(server, symbol) { - Some(geo) => geo.pip_size, - None => { - eprintln!( - "aura: no recorded geometry for symbol '{symbol}' at {} — \ + Some(geo) => Ok(geo.pip_size), + None => Err(RunnerError { + exit_code: 1, + message: format!( + "no recorded geometry for symbol '{symbol}' at {} — \ refusing to run a real instrument with a guessed pip", env.data_path() - ); - std::process::exit(1); - } + ), + }), } } @@ -178,9 +186,9 @@ pub fn probe_window( from_ms: Option, to_ms: Option, env: &Env, -) -> (Timestamp, Timestamp) { +) -> Result<(Timestamp, Timestamp), RunnerError> { aura_ingest::archive_extent(server, std::path::Path::new(&env.data_path()), symbol, from_ms, to_ms) - .unwrap_or_else(|| no_data_in_window(symbol, from_ms, to_ms, env)) + .ok_or_else(|| no_data_in_window(symbol, from_ms, to_ms, env)) } /// Open the real M1 sources for a recorded symbol over an optional window — one @@ -188,27 +196,28 @@ pub fn probe_window( /// sources paired with the manifest `window` and the per-instrument `pip_size`. /// Single home of the real-source construction the single-run handlers share — the /// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and -/// the run-source open (each refusal an stderr + exit 1). Pre-data refusals keep the -/// pip honest by construction. Used by `resolve_run_data`. +/// the run-source open (each refusal returned as a `RunnerError`, #297). Pre-data +/// refusals keep the pip honest by construction. Used by `resolve_run_data`. +#[allow(clippy::type_complexity)] fn open_real_source( symbol: &str, from_ms: Option, to_ms: Option, env: &Env, fields: &[aura_ingest::M1Field], -) -> (Vec>, (Timestamp, Timestamp), f64) { +) -> Result<(Vec>, (Timestamp, Timestamp), f64), RunnerError> { // Per-instrument pip from the recorded sidecar; resolved BEFORE bar-data access // so an instrument with no geometry refuses without touching the archive. let server = Arc::new(data_server::DataServer::new(env.data_path())); - let pip = pip_or_refuse(&server, symbol, env); + let pip = pip_or_refuse(&server, symbol, env)?; if !server.has_symbol(symbol) { - no_real_data(symbol, env); + return Err(no_real_data(symbol, env)); } // Manifest window: drain a separate probe (single-pass Source) for first/last ts. - let window = probe_window(&server, symbol, from_ms, to_ms, env); + let window = probe_window(&server, symbol, from_ms, to_ms, env)?; let sources = aura_ingest::open_columns(&server, symbol, from_ms, to_ms, fields) - .unwrap_or_else(|| no_real_data(symbol, env)); - (sources, window, pip) + .ok_or_else(|| no_real_data(symbol, env))?; + Ok((sources, window, pip)) } /// Short-horizon realized-range window for vol-scaled slippage. Deliberately @@ -453,17 +462,20 @@ pub fn resolve_run_data( data: &RunData, env: &Env, binding: &ResolvedBinding, -) -> ( - Vec>, - (Timestamp, Timestamp), - f64, -) { +) -> Result< + ( + Vec>, + (Timestamp, Timestamp), + f64, + ), + RunnerError, +> { match data { RunData::Synthetic => { let sources: Vec> = vec![Box::new(VecSource::new(r_sma_prices()))]; let window = window_of(&sources).expect("non-empty synthetic stream"); - (sources, window, SYNTHETIC_PIP_SIZE) + Ok((sources, window, SYNTHETIC_PIP_SIZE)) } RunData::Real { symbol, from, to } => { open_real_source(symbol, *from, *to, env, &binding.columns()) @@ -533,7 +545,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>, -) -> RunReport { +) -> Result<(RunReport, Vec), RunnerError> { // topology_hash's own two-line body, inlined: `content_id_of` over the // canonical (#164) blueprint JSON — the CLI shell's `topology_hash` // helper is the same primitive, kept single-sourced at `aura_research`. @@ -548,15 +560,14 @@ pub fn run_signal_r( }; let run_name = signal.name().to_string(); // before signal is consumed by `wrap_r` // The default binding (name defaults; `aura run` carries no campaign - // overrides). Refusals are the established `aura: ` + exit-1 register. + // overrides). Refusals propagate as a `RunnerError` for the CLI to + // print and exit on, never a process exit inside the library (#297). + // C14 class 2: fault in argv-named content (#297) let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); + .map_err(|m| RunnerError { exit_code: 2, message: m })?; + // C14 class 2: fault in argv-named content (#297) if matches!(data, RunData::Synthetic) && !binding.close_only() { - eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding)); - std::process::exit(1); + return Err(RunnerError { exit_code: 2, message: crate::binding::synthetic_refusal(signal.name(), &binding) }); } let names: Vec = signal .param_space() @@ -570,20 +581,18 @@ pub fn run_signal_r( // The req tap (r_equity recorder) is wired but not persisted on this path; keep the // receiver alive so the sink's sends do not fail, but do not drain it. let (tx_req, _rx_req) = mpsc::channel(); - let (sources, window, pip_size) = resolve_run_data(&data, env, &binding); + let (sources, window, pip_size) = resolve_run_data(&data, env, &binding)?; let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size, &binding, None); - let mut flat = wrapped.compile_with_params(params).unwrap_or_else(|e| { - eprintln!("aura: {}", compile_error_prose(&e, &names, params)); - std::process::exit(1); - }); + // C14 class 2: fault in argv-named content (#297) + let mut flat = wrapped.compile_with_params(params) + .map_err(|e| RunnerError { exit_code: 2, message: compile_error_prose(&e, &names, params) })?; // Bind each declared tap per the plan's subscription, before bootstrap // (#283): typed refusals for bad plans, record consumers hold their // streaming writer in-graph, folds accumulate O(1), live closures run // inline. Dedup stays caller-owned per TapBindError::DuplicateBind's doc. - let bound = bind_tap_plan(&mut flat, plan, env, &run_name).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); + // 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 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 @@ -605,14 +614,13 @@ pub fn run_signal_r( manifest.project = env.provenance(); let mut metrics = summarize(&aura_engine::f64_field(&eq_rows, 0), &aura_engine::f64_field(&ex_rows, 0)); metrics.r = Some(summarize_r(&r_rows, &[])); + let skipped = bound.skipped().to_vec(); // Close the tap plan: drain the ≤1-message channels, write fold rows, // then `index.json` — nothing was buffered during the run (#283). A // tap-free (or nothing-persisting) plan wrote nothing at all. - bound.finish(&manifest).unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - RunReport { manifest, metrics } + bound.finish(&manifest) + .map_err(|e| RunnerError { exit_code: e.exit_class(), message: e.to_string() })?; + Ok((RunReport { manifest, metrics }, skipped)) } /// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path @@ -636,7 +644,7 @@ pub fn run_blueprint_member( binding: &ResolvedBinding, cost: &[aura_research::CostSpec], instrument: &str, -) -> RunReport { +) -> Result { let defaults = raw_bound_defaults(&signal); // read before `signal` is consumed below let (tx_eq, rx_eq) = mpsc::channel(); let (tx_ex, rx_ex) = mpsc::channel(); @@ -647,11 +655,13 @@ pub fn run_blueprint_member( // its sender is wired but unread here (the r_equity tap precedent above). let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); - let cost_leg = (!cost.is_empty()).then(|| CostLeg { - nodes: crate::translate::cost_nodes_for(cost, instrument), - tx_cost, - tx_net, - }); + let cost_leg = if cost.is_empty() { + None + } else { + let nodes = crate::translate::cost_nodes_for(cost, instrument) + .map_err(|f| MemberFault::Bind(f.message()))?; + Some(CostLeg { nodes, tx_cost, tx_net }) + }; let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding, cost_leg) .bootstrap_with_cells(point) .expect("member bootstraps (point kind-checked against param_space)"); @@ -693,7 +703,8 @@ pub fn run_blueprint_member( // reads this stamp back via `translate::cost_specs_from_params` (the #233 // stop-regime pattern), so a costed family reproduces net, not gross. for (k, spec) in cost.iter().enumerate() { - let (knob, v) = crate::translate::cost_knob(spec, instrument); + let (knob, v) = crate::translate::cost_knob(spec, instrument) + .map_err(|f| MemberFault::Bind(f.message()))?; named.push((format!("cost[{k}].{knob}"), Scalar::f64(v))); } let mut manifest = sim_optimal_manifest(named, window, seed, pip); @@ -714,7 +725,7 @@ pub fn run_blueprint_member( rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; m.r = Some(summarize_r(&r_rows, &cost_rows)); - RunReport { manifest, metrics: m } + Ok(RunReport { manifest, metrics: m }) } /// The exact wrapped probe the loaded-blueprint sweep resolves its axes @@ -1109,10 +1120,12 @@ mod tests { .expect("the price role resolves"); let report_a = run_blueprint_member( signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40", - ); + ) + .expect("no cost model: nothing to refuse"); let report_b = run_blueprint_member( r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40", - ); + ) + .expect("no cost model: nothing to refuse"); // Guard: the run must have actually traded, else the invariant is vacuous. assert!( report_a.metrics.total_pips.abs() > 0.0, @@ -1224,8 +1237,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); - let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env, TapPlan::record_all(), None); + 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"); assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); } @@ -1377,8 +1392,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); - 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"); + let (via_carve, _) = run_signal_r( r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)), &[], RunData::Synthetic, @@ -1386,7 +1402,8 @@ mod tests { &env, TapPlan::record_all(), None, - ); + ) + .expect("carved r_meanrev signal runs"); assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); } @@ -1576,8 +1593,9 @@ mod tests { // different but equally non-flat synthetic fixture. Every assertion // below is computed from the actual run (never a fixture-specific // literal), so the substitution preserves the property under test. - let sources = || resolve_run_data(&RunData::Synthetic, &env, &binding).0; - let (_, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding); + let sources = || resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves").0; + let (_, window, pip) = + resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves"); const CPT: f64 = 0.0005; // price units; the synthetic stream trades near 1.0 let run = |cost: &[aura_research::CostSpec]| { run_blueprint_member( @@ -1595,6 +1613,7 @@ mod tests { cost, "GER40", ) + .expect("GER40-keyed cost fixture resolves") }; let gross = run(&[]); let netted = run(&[aura_research::CostSpec::Constant { @@ -1663,6 +1682,39 @@ mod tests { ); } + #[test] + /// Review fix (#297 fork 3, item 1): the worker-channel seam a live sweep + /// observes — `run_blueprint_member` given a cost model whose per-instrument + /// map lacks the requested instrument refuses `Err(MemberFault::Bind(msg))` + /// with `msg` byte-identical to `CostKnobFault::message()` (the + /// `cost_knob_fault_message_is_byte_pinned` pin in `translate.rs`, one level + /// up the call chain) — never a panic that `catch_unwind` would instead + /// record as `MemberFault::Panic`. The fault fires before the harness is + /// even bootstrapped, so `sources`/`window` are minimal placeholders. + fn run_blueprint_member_propagates_the_cost_knob_fault_as_a_bind_message() { + let env = Env::std(); + let signal = r_meanrev_signal(Some(3), Some(0.0)); + let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let stop = StopRule::Vol { length: 3, k: 2.0 }; + let window = (Timestamp(0), Timestamp(0)); + let cost = [aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::PerInstrument(std::collections::BTreeMap::from( + [("EURUSD".to_string(), 2.0)], + )), + }]; + let err = match run_blueprint_member( + signal, &[], &[], Vec::new(), window, 0, 1.0, "topo", &env, stop, &binding, &cost, "GER40", + ) { + Err(e) => e, + Ok(_) => panic!("GER40 is not in the map — the run must refuse, not succeed"), + }; + let expected = + crate::translate::CostKnobFault { knob: "cost_per_trade", instrument: "GER40".to_string() } + .message(); + assert_eq!(err, MemberFault::Bind(expected)); + } + /// #262 write-side: `run_blueprint_member` given `StopRule::VolTf` actually /// bootstraps and runs the member (the `risk_executor`/`VolTfStop` arm, /// end-to-end over a real synthetic realization, not a hand-built value) @@ -1681,7 +1733,8 @@ mod tests { // See `run_blueprint_member_joins_a_constant_cost_model_into_net_metrics`'s // note: `RunData::Synthetic` + `resolve_run_data` stands in for the // CLI-only `DataSource::Synthetic` this test used before the #295 move. - let (sources, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding); + let (sources, window, pip) = + resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves"); let run = run_blueprint_member( reload(), &[], @@ -1696,7 +1749,8 @@ mod tests { &binding, &[], "GER40", - ); + ) + .expect("no cost model: nothing to refuse"); assert!( run.manifest.params.contains(&("stop_period_minutes".to_string(), Scalar::i64(60))), "manifest must stamp stop_period_minutes: {:?}", @@ -1729,7 +1783,8 @@ mod tests { let binding = crate::binding::resolve_binding("fixedstamp", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let stop = StopRule::Fixed(10.0); - let (sources, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding); + let (sources, window, pip) = + resolve_run_data(&RunData::Synthetic, &env, &binding).expect("test data resolves"); let run = run_blueprint_member( reload(), &[], @@ -1744,7 +1799,8 @@ mod tests { &binding, &[], "GER40", - ); + ) + .expect("no cost model: nothing to refuse"); assert!( run.manifest.params.contains(&("stop_distance".to_string(), Scalar::f64(10.0))), "manifest must stamp stop_distance: {:?}", @@ -1800,7 +1856,8 @@ mod tests { fn fold_and_live_plans_agree_with_the_recorded_series() { // Run A: record (the charting question). let (env_a, root_a) = temp_project_env("record"); - run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None); + run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None) + .expect("run A records"); let series = read_tap_json(&root_a); let ts: Vec = series["ts"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap()).collect(); @@ -1812,7 +1869,8 @@ mod tests { let (env_b, root_b) = temp_project_env("fold"); let mut plan_b = TapPlan::record_all(); plan_b.subscribe("fast_tap", TapSubscription::named("mean")); - run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b, None); + run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, plan_b, None) + .expect("run B folds"); let row = read_tap_json(&root_b); let mean = vals.iter().sum::() / vals.len() as f64; // sequential — FoldState's order assert_eq!(row["ts"], serde_json::json!([*ts.last().unwrap()])); @@ -1828,7 +1886,8 @@ mod tests { let _ = live_tx.send((ts.0, cell.f64())); }), ); - run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_c, plan_c, None); + run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_c, plan_c, None) + .expect("run C runs live-only"); let got: Vec<(i64, f64)> = live_rx.try_iter().collect(); let want: Vec<(i64, f64)> = ts.iter().copied().zip(vals.iter().copied()).collect(); assert_eq!(got, want, "the live closure saw exactly the recorded series (C1)"); @@ -1879,8 +1938,10 @@ mod tests { fn two_identical_record_runs_produce_byte_identical_tap_files() { let (env_a, root_a) = temp_project_env("det-a"); let (env_b, root_b) = temp_project_env("det-b"); - run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None); - run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all(), None); + run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_a, TapPlan::record_all(), None) + .expect("run a records"); + run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env_b, TapPlan::record_all(), None) + .expect("run b records"); let a = std::fs::read( root_a.join("runs").join("traces").join("sma_signal").join("fast_tap.json"), ) @@ -1891,4 +1952,18 @@ mod tests { .expect("run b trace"); assert_eq!(a, b, "same input, same bytes (the index carries env provenance; the tap file must not)"); } + + /// #297: a refusal returns through the Result seam — the embedding + /// process survives to read it (the #283 fieldtest scenario that used + /// to kill the host). + #[test] + fn run_signal_r_refuses_in_process_instead_of_killing_the_host() { + let (env, _root) = temp_project_env("in-process-refusal"); + let mut plan = TapPlan::record_all(); + plan.subscribe("fast_tap", TapSubscription::named("median")); + let err = run_signal_r(tapped_r_sma(), &[], RunData::Synthetic, 0, &env, plan, None) + .unwrap_err(); + assert_eq!(err.exit_code, 2); + assert!(err.message.contains("unknown fold"), "{}", err.message); + } } diff --git a/crates/aura-runner/src/reproduce.rs b/crates/aura-runner/src/reproduce.rs index 147f376..bcea473 100644 --- a/crates/aura-runner/src/reproduce.rs +++ b/crates/aura-runner/src/reproduce.rs @@ -134,6 +134,9 @@ pub fn reproduce_family_in( .map_err(|m| RunnerError { exit_code: 1, message: m })?; // The member's binding, re-derived from the stored blueprint's own // input roles (name defaults — family manifests carry no overrides). + // Deliberately class 1, unchanged by #297: prose identical to the + // now-class-2 `run_signal_r` twins, but a stored artifact being + // reproduced is not argv-named content (C14 partition). let binding = crate::binding::resolve_binding(&hash, reload()?.input_roles(), &BTreeMap::new()) .map_err(|m| RunnerError { exit_code: 1, message: m })?; if matches!(data, DataSource::Synthetic) && !binding.close_only() { @@ -199,7 +202,7 @@ pub fn reproduce_family_in( // stored window bounds; the winner params come from the shared // manifest->cells recovery below (as Sweep members do). let (from, to) = stored.manifest.window; - let s = data.windowed_sources(from, to, env, &binding.columns()); + let s = data.windowed_sources(from, to, env, &binding.columns())?; let w = window_of(&s).expect("non-empty OOS window"); (s, w) } @@ -212,9 +215,11 @@ pub fn reproduce_family_in( _ => match data { DataSource::Real { .. } => { let (from, to) = stored.manifest.window; - (data.windowed_sources(from, to, env, &binding.columns()), (from, to)) + (data.windowed_sources(from, to, env, &binding.columns())?, (from, to)) + } + DataSource::Synthetic => { + (data.run_sources(env, &binding.columns())?, data.full_window(env)?) } - DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)), }, }; let rerun = run_blueprint_member( @@ -234,7 +239,8 @@ pub fn reproduce_family_in( // construction (`cost_specs_from_params`), so the instrument is // inert; the fallback is never resolved against a map. stored.manifest.instrument.as_deref().unwrap_or(""), - ); + ) + .expect("re-run cost specs are stamp-derived scalars; the instrument-inert fallback never misses"); outcomes.push((label, rerun.metrics == stored.metrics)); } Ok(ReproduceReport { outcomes }) @@ -263,7 +269,7 @@ pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> { let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) { None => DataSource::Synthetic, Some(symbol) => { - DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env) + DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)? } }; let rep = reproduce_family_in(®, id, &data, env)?; diff --git a/crates/aura-runner/src/runner.rs b/crates/aura-runner/src/runner.rs index 7216a9e..f575922 100644 --- a/crates/aura-runner/src/runner.rs +++ b/crates/aura-runner/src/runner.rs @@ -199,7 +199,7 @@ impl MemberRunner for DefaultMemberRunner<'_> { &binding, &self.cost, &cell.instrument, - ); + )?; report.manifest.instrument = Some(cell.instrument.clone()); Ok(report) } @@ -579,11 +579,13 @@ pub fn persist_campaign_traces( // not just on a real divergence. let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, rx_net) = mpsc::channel(); - let cost_leg = (!campaign.cost.is_empty()).then(|| CostLeg { - nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument), - tx_cost, - tx_net, - }); + let cost_leg = if campaign.cost.is_empty() { + None + } else { + let nodes = cost_nodes_for(&campaign.cost, &cell_rec.instrument) + .map_err(|f| f.message())?; + Some(CostLeg { nodes, tx_cost, tx_net }) + }; let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg) .bootstrap_with_cells(&point) diff --git a/crates/aura-runner/src/tap_plan.rs b/crates/aura-runner/src/tap_plan.rs index 4646f16..ec21b3e 100644 --- a/crates/aura-runner/src/tap_plan.rs +++ b/crates/aura-runner/src/tap_plan.rs @@ -285,6 +285,25 @@ impl From for TapPlanError { } } +impl TapPlanError { + /// C14 partition, wired into `run_signal_r`/`run_measurement`'s + /// `bind_tap_plan` `.map_err` (#297 Fork 1/2): faults in the content of + /// what argv named are class 2; environment faults (store I/O) are + /// class 1. + pub fn exit_class(&self) -> i32 { + match self { + TapPlanError::Store(_) => 1, + TapPlanError::UnknownTap { .. } + | TapPlanError::UnknownLabel { .. } + | TapPlanError::KindMismatch { .. } + | TapPlanError::UnknownParam { .. } + | TapPlanError::MissingParam { .. } + | TapPlanError::ParamKind { .. } + | TapPlanError::Bind(_) => 2, + } + } +} + /// Validate one `Named` subscription's param bindings against the entry's /// schema: every binding names a schema param of the right kind; every /// schema param is bound. @@ -348,6 +367,10 @@ pub struct BoundTaps { persisted: Vec, rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec)>)>, outcomes: Vec<(String, Receiver>)>, + /// Declared taps that resolved to no subscription this run (#297): the + /// caller-printed "unbound" note migrates to the CLI, this is the data + /// it prints from. + skipped: Vec, } impl BoundTaps { @@ -356,6 +379,12 @@ impl BoundTaps { &self.declared } + /// Declared taps that resolved to no subscription this run (#297) — the + /// data the caller's "unbound" note prints from. + pub fn skipped(&self) -> &[String] { + &self.skipped + } + /// Drain the ≤1-message-per-tap channels and close the run: record /// outcomes first (declared order), then fold rows written through the /// same streamer, then `index.json` last. Any fault returns before the @@ -424,6 +453,7 @@ pub fn bind_tap_plan( Live(Box), } let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new(); + let mut skipped: Vec = Vec::new(); for tap in &declared_taps { let kind = flat.signatures[tap.node].output[tap.field].kind; let sub = match plan.by_name.remove(&tap.name) { @@ -438,11 +468,11 @@ pub fn bind_tap_plan( // (`default_named` = `Some(("record", …))`) always resolves the // Some arm above, so this arm never fires under record-all and // the note is exactly the C14 benign skipped-tap class (#334). - // Emitted here (aura-runner), beside the pre-existing - // runner-side `eprintln!` registers in this module/`member.rs`/ - // `measure.rs` — the runner→CLI print migration is #297. + // The name is recorded here and the note is CLI-printed from + // the returned `skipped` names (the runner→CLI print + // migration, #297) — this module no longer emits it. None => { - eprintln!("aura: note: declared tap \"{}\" unbound this run", tap.name); + skipped.push(tap.name.clone()); continue; } }, @@ -476,6 +506,7 @@ pub fn bind_tap_plan( persisted: Vec::new(), rows: Vec::new(), outcomes: Vec::new(), + skipped, }; for (name, kind, sub) in resolved { let node: Box = match sub { diff --git a/crates/aura-runner/src/translate.rs b/crates/aura-runner/src/translate.rs index dd937c7..13ac711 100644 --- a/crates/aura-runner/src/translate.rs +++ b/crates/aura-runner/src/translate.rs @@ -121,12 +121,15 @@ pub fn stop_rule_for_regime(regime: Option) -> aura_c /// cannot diverge from the run-side cost model (the #219 divergence class, /// cost edition). The bound knob names are the builders' own `ParamSpec` names /// — the `CostSpec` serde vocabulary conforms to them. -pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Vec { +pub fn cost_nodes_for( + specs: &[aura_research::CostSpec], + instrument: &str, +) -> Result, CostKnobFault> { specs .iter() .map(|s| { - let (knob, v) = cost_knob(s, instrument); - match s { + let (knob, v) = cost_knob(s, instrument)?; + Ok(match s { aura_research::CostSpec::Constant { .. } => { ConstantCost::builder().bind(knob, Scalar::f64(v)) } @@ -136,29 +139,47 @@ pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Ve aura_research::CostSpec::Carry { .. } => { CarryCost::builder().bind(knob, Scalar::f64(v)) } - } + }) }) .collect() } +/// The fault `cost_knob` refuses with: the document named an instrument the +/// value map has no entry for (unreachable after intrinsic validation — +/// kept as a loud refusal, never a silent 0-charge). +#[derive(Debug)] +pub struct CostKnobFault { + pub knob: &'static str, + pub instrument: String, +} + +impl CostKnobFault { + /// The exact prose the retired exit register printed (byte-identical). + pub fn message(&self) -> String { + format!("cost {}: no entry for instrument {}", self.knob, self.instrument) + } +} + /// The one CostSpec-variant -> (knob name, value) mapping, shared by /// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp: /// the stamp key must equal the bind key for reproduce to re-derive a /// `CostSpec` from a stored manifest, so both sites read the name off this /// single function rather than each carrying its own literal. -pub fn cost_knob(spec: &aura_research::CostSpec, instrument: &str) -> (&'static str, f64) { +pub fn cost_knob( + spec: &aura_research::CostSpec, + instrument: &str, +) -> Result<(&'static str, f64), CostKnobFault> { let (knob, value) = match spec { aura_research::CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade), aura_research::CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult), aura_research::CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle), }; - let v = value.resolve(instrument).unwrap_or_else(|| { + match value.resolve(instrument) { + Some(v) => Ok((knob, v)), // Unreachable after intrinsic validation (map keys ≡ instruments); // refuse loudly rather than charging 0 silently if it ever surfaces. - eprintln!("aura: cost {knob}: no entry for instrument {instrument}"); - std::process::exit(1); - }); - (knob, v) + None => Err(CostKnobFault { knob, instrument: instrument.to_string() }), + } } #[cfg(test)] @@ -240,14 +261,53 @@ mod tests { }, ], "GER40", - ); + ) + .expect("every knob resolves against the GER40-keyed fixture"); let labels: Vec = nodes.iter().map(|n| n.label()).collect(); assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]); assert!( nodes.iter().all(|n| n.params().is_empty()), "every component must be fully bound (no open param leaks into param_space)" ); - assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes"); + assert!( + cost_nodes_for(&[], "GER40").expect("empty model resolves").is_empty(), + "an empty model binds no nodes" + ); + } + + #[test] + /// Review fix (#297 fork 3): `CostKnobFault::message()` is the exact prose + /// the retired exit register printed byte-identically — pinned against a + /// concrete fixture so a future edit to the format string is caught here, + /// not only at the call sites that propagate it as `MemberFault::Bind`. + fn cost_knob_fault_message_is_byte_pinned() { + let fault = CostKnobFault { knob: "cost_per_trade", instrument: "XYZ".to_string() }; + assert_eq!(fault.message(), "cost cost_per_trade: no entry for instrument XYZ"); + } + + #[test] + /// Review fix (#297 fork 3): `cost_knob`/`cost_nodes_for` refuse (never + /// silently charge 0) when a `CostSpec`'s per-instrument map lacks the + /// requested instrument — the `PerInstrument` counterpart of + /// `cost_nodes_for_maps_each_component_to_its_bound_builder`'s all-resolve + /// case above, exercising the `None` arm of `CostValue::resolve`. + fn cost_knob_and_cost_nodes_for_refuse_an_uncovered_instrument() { + let spec = aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::PerInstrument(std::collections::BTreeMap::from([( + "GER40".to_string(), + 2.0, + )])), + }; + let err = cost_knob(&spec, "EURUSD").expect_err("EURUSD is not in the map"); + assert_eq!(err.knob, "cost_per_trade"); + assert_eq!(err.instrument, "EURUSD"); + + let err = match cost_nodes_for(std::slice::from_ref(&spec), "EURUSD") { + Err(f) => f, + Ok(_) => panic!("cost_nodes_for must propagate the same fault"), + }; + assert_eq!(err.knob, "cost_per_trade"); + assert_eq!(err.instrument, "EURUSD"); } #[test] diff --git a/docs/authoring-guide.md b/docs/authoring-guide.md index 7d55ea3..e373238 100644 --- a/docs/authoring-guide.md +++ b/docs/authoring-guide.md @@ -849,9 +849,31 @@ 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; a run that **completes with one or more failed cells** exits 3 — the run record and every healthy cell persist, and the failed cells are named on stderr -(#272). A **gate-emptied cell** — a `std::gate` stage that filters out every -member — is a *successful* cell, not a failed one: the gate legitimately -answered "no survivors", so the run still exits 0, the cell's realization is -recorded as truncated at that stage, and an informational `aura:`-prefixed -note names the cell on stderr. Exit 3 is reserved for cells that could not be -evaluated (faults), never for an empty-but-valid result. +(#272). A directly-run blueprint (`aura exec .json`, no campaign) +has no cells at all: its own refusals — a binding mismatch, a synthetic-data +mismatch, a compile error, a tap-bind content fault (unknown fold/tap, +duplicate) — are the content of the argv-named file and exit 2 like a usage +error; only its environment refusals (a tap-trace store write failure) +exit 1. The no-data family (no local data, no data in the requested window, +no recorded geometry) belongs to the real-data verbs — `aura run`, +`reproduce`, the campaign legs — where it likewise exits 1 (#297; the full +partition lives in [C14](design/contracts/c14-headless-two-faces.md)). + +The class boundary is FORM vs VALUE, not "environment/data/IO vs everything +else": an `--override` value that is well-formed but out of the node's own +domain (e.g. a negative length) is not caught at the compile/bind seam above +— it panics inside the node's own constructor at bootstrap. `aura exec` +catches that panic at the dispatch boundary (`catch_member_panic`) and +renders it as a runtime-class refusal, exit 1 — the same class an +environment/data fault gets, and the same class the campaign leg gives the +identical value as a per-cell fault (#272). A form fault the surface detects +before the node ever runs (kind mismatch, unknown param, compile error) +stays class 2 argv-content; a well-formed value the node itself refuses in +its own domain is class 1, regardless of which leg hit it. + +A **gate-emptied cell** — a `std::gate` stage that filters out every member — +is a *successful* cell, not a failed one: the gate legitimately answered "no +survivors", so the run still exits 0, the cell's realization is recorded as +truncated at that stage, and an informational `aura:`-prefixed note names the +cell on stderr. Exit 3 is reserved for cells that could not be evaluated +(faults), never for an empty-but-valid result. diff --git a/docs/design/contracts/c14-headless-two-faces.md b/docs/design/contracts/c14-headless-two-faces.md index bbfc445..eb1c8aa 100644 --- a/docs/design/contracts/c14-headless-two-faces.md +++ b/docs/design/contracts/c14-headless-two-faces.md @@ -59,6 +59,42 @@ caller branches on the failure class without parsing stderr. in `crates/aura-cli/src/main.rs`, threaded from the run registry — [C18](c18-registry.md)). +**`aura-runner`'s single-run refusals now honor this same partition (#297, +2026-07-26).** Before this cycle, every refusal inside `aura-runner`'s +single-run verb paths (`aura exec `, both the bias-output and +bare-measurement legs) exited 1 uniformly — a `std::process::exit(1)` baked +into the library itself, bypassing the partition above rather than +instantiating it. The #297 conversion turns every such site into a returned +`RunnerError { exit_code, message }` the CLI prints and exits on; the class +is now assigned by the same criterion as the rest of this partition — is the +fault in the content of what argv named, or in the environment the command +needs? The binding refusal, the synthetic/binding mismatch, the blueprint +compile error, and tap-bind content faults (unknown fold, unknown tap, +duplicate bind) are argv-named-content faults and exit 2; the no-local-data / +no-data-in-window / no-recorded-geometry refusals and a tap-trace store I/O +failure (`bound.finish`) are environment faults and stay at 1. `cost_knob` +(via `aura-runner::translate::cost_nodes_for`) has no single-run production +caller at all — its two production callers are both worker-side: reached +from inside a sweep worker (`run_blueprint_member`) it surfaces as a per-cell +`MemberFault::Bind` — never a process exit (the campaign contract +[C28](c28-stratification.md) already guaranteed); reached from +`persist_campaign_traces`'s C1 drift-alarm re-run it propagates as a `String` +through that function's existing channel, exiting 1 via the campaign run +summary (`campaign_run.rs`'s `?` after the run record is already persisted). + +**The class-1 set is not only environment/data/IO.** It also covers the exec +blueprint leg's bootstrap domain-refusal path: an `--override` value that is +well-formed but out of a node's own domain (e.g. a negative length) is not +caught at the compile/bind seam above — it panics inside the node's own +constructor at bootstrap. `aura exec` catches that panic at the dispatch +boundary (`catch_member_panic`, `crates/aura-cli/src/main.rs`) and renders it +as a runtime-class refusal, exit 1 — the same class the campaign leg gives +the identical value as a per-cell fault (#272). The boundary is FORM vs +VALUE: a form fault the surface detects before the node ever runs (kind +mismatch, unknown param, compile error) is class 2 argv-content; a +well-formed value the node itself refuses in its own domain is class 1, +regardless of which leg hit it. + **Single document grammar (#319, 2026-07-25).** The five dual-grammar subcommands (run/sweep/walkforward/mc/generalize) that used to keep two grammars under one token are retired; one verb, `exec `, dispatches diff --git a/docs/design/contracts/c27-declared-taps.md b/docs/design/contracts/c27-declared-taps.md index 853020a..93d8e7b 100644 --- a/docs/design/contracts/c27-declared-taps.md +++ b/docs/design/contracts/c27-declared-taps.md @@ -42,7 +42,10 @@ input role, which `check_root_roles_bound` rejects ([C26](c26-input-binding.md)) observation is optional, a fed input is mandatory. A declared-but-unbound tap compiles and runs, its producer evaluating and its output discarded (a no-out-edge producer is a valid runnable sink — the Kahn sort emits it, -`check_ports_connected` gates only inputs). +`check_ports_connected` gates only inputs). The CLI surfaces this to the human +as a note (`aura: note: declared tap "…" unbound this run`) printed from the +unbound names the entry points return beside their report — never emitted by +the library itself (#297). **Why.** Observability must be expressible in a hand-authored blueprint — the measurement-shaped study computes in the graph and surfaces via taps, no throwaway @@ -71,7 +74,10 @@ points, `run_signal_r` (`aura-runner::member`) and `run_measurement` (`aura-runner::measure`) — both arms of the single CLI verb `aura exec` (#319), whose repeatable `--tap TAP=FOLD` selector (#310) makes the `Named` selection data-reachable: no flag keeps the record-all default, any flag replaces the -plan entirely (unlisted taps stay unbound/inert). The boundary is thereby +plan entirely (unlisted taps stay unbound/inert). `BoundTaps` carries the +unbound tap names out (`skipped: Vec`), riding beside the returned +report rather than inside it, so the CLI — not the library — prints the +unbound-tap note (#297). The boundary is thereby fixed in place: *selecting* a subscription is run-mode authority, exercised by the run-mode owner — on the one-shot path the CLI invocation itself, a projection exercising this contract's authority, not a second home for diff --git a/docs/design/contracts/c28-stratification.md b/docs/design/contracts/c28-stratification.md index 6592ea1..8d9b282 100644 --- a/docs/design/contracts/c28-stratification.md +++ b/docs/design/contracts/c28-stratification.md @@ -167,12 +167,24 @@ demand-gated, no tracking issue): measurement runs as sweep-family citizens (report unification, campaign engine generic-over-`M`), until a concrete family/campaign demand exists. -**Deferred.** ~24 refusal sites inside `aura-runner`'s single-run verb paths still -terminate the process (`std::process::exit`; the #283 tap-plan refusals added four -— typed as `TapPlanError` before the exit, so the eventual conversion is a -mechanical rewrap); their conversion to `RunnerError` propagation is tracked as -**#297** (the campaign path already refuses via `MemberFault`, never a process -exit). +**#297 (closed, 2026-07-26).** `aura-runner` no longer terminates the host +process anywhere: the 14 recon-enumerated `std::process::exit` sites inside +the single-run verb paths (`member.rs` ×8, `measure.rs` ×5, `translate.rs` +×1 — the "~24" this paragraph used to cite predates the #319 dual-grammar +retirement and never updated in lockstep) are now `RunnerError { exit_code, +message }` constructors threaded through a fallible chain; the two entry +points, `run_signal_r` and `run_measurement`, return +`Result<(_, Vec), RunnerError>`, and the CLI's `exec_blueprint_leg` +remaps via one shared `exit_on_runner_error` helper (the `dispatch_reproduce` +pattern). The one hole in "a sweep worker never process-exits" — `cost_knob`, +reachable from `run_blueprint_member` — is closed the same cycle: the +campaign worker maps its fault into the cell's own `MemberFault` channel +(per-cell isolation, this contract's stated campaign guarantee unchanged), +while the campaign trace-persist path — the only other production caller — +reports it through its existing string-error channel into the campaign +summary's exit. Refusal prose is byte-identical everywhere; +only who prints it changed, plus — per the adjudicated C14 partition — the +exit class of refusals whose fault is in the content of what argv named. ## See also - [C1](c01-determinism.md) — determinism / bit-identity, the correctness invariant the layer cuts preserve