feat(aura-runner, aura-cli): RunnerError propagation — the assembly crate stops killing the host
All 14 process::exit sites in aura-runner (member x8, measure x5,
translate x1) convert to RunnerError { exit_code, message } propagation;
run_signal_r / run_measurement return Result tuples carrying the
skipped-tap names beside the C18 reports (record shapes untouched), and
the CLI owns printing via the shared exit_on_runner_error arm (the
dispatch_reproduce pattern — reproduce now uses the same helper).
cost_knob becomes fallible: the sweep worker maps it into the cell's
MemberFault (closing the one hole in 'a worker never process-exits'),
the persist path keeps its string channel. The skipped-tap note is
CLI-printed from returned data, byte-identical.
The one deliberate behaviour change is the minuted C14 adjudication:
refusals in the content of what argv named (binding, synthetic mismatch,
compile, tap-bind content, exposes-neither) move exit 1 -> 2; the
boundary is FORM vs VALUE — an out-of-domain override value keeps the
runtime class via the panic containment, matching the campaign leg.
Environment/data/IO refusals stay 1 (TapPlanError::exit_class is the
single source). Named re-pins in exec.rs, run_refuses_unrunnable_
blueprint.rs, tap_recording.rs, graph_construct.rs; new library-level
test pins the milestone promise (a refusal returns in-process, the
embedding host survives). Prose is byte-identical everywhere.
Docs in lockstep: C28 deferred block closed (14 sites, hole closed),
C14 partition + form-vs-value boundary, C27 note emission, guide exit
classes. Fork minutes: issues/297#issuecomment-4868 and -4873.
closes #297
This commit is contained in:
+67
-28
@@ -1069,6 +1069,15 @@ pub(crate) fn exit_on_campaign_result(r: Result<usize, String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Scalar> = 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<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = 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<Box<dyn aura_engine::Source>> = 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();
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! `aura run <blueprint.json>` 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: <msg>` on stderr, exit 1 — exactly
|
||||
// what the binding refusals in `run_signal_r` already emit.
|
||||
// The clean-refusal register: `aura: <msg>` 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}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<DataSource, RunnerError> {
|
||||
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<Box<dyn aura_engine::Source>> = 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<Box<dyn aura_engine::Source>> = 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<Box<dyn aura_engine::Source>> {
|
||||
pub fn run_sources(
|
||||
&self, env: &Env, fields: &[aura_ingest::M1Field],
|
||||
) -> Result<Vec<Box<dyn aura_engine::Source>>, 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<Box<dyn aura_engine::Source>> {
|
||||
) -> Result<Vec<Box<dyn aura_engine::Source>>, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>), 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<String> = 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<String> = 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<String> = 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");
|
||||
}
|
||||
|
||||
@@ -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<i64>, to_ms: Option<i64>, 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<i64>, to_ms: Option<i64>, env: &Env) -> RunnerError {
|
||||
let bound = |b: Option<i64>| 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<data_server::DataServer>, 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<data_server::DataServer>, symbol: &str, env: &Env) -> Result<f64, RunnerError> {
|
||||
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<i64>,
|
||||
to_ms: Option<i64>,
|
||||
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<i64>,
|
||||
to_ms: Option<i64>,
|
||||
env: &Env,
|
||||
fields: &[aura_ingest::M1Field],
|
||||
) -> (Vec<Box<dyn aura_engine::Source>>, (Timestamp, Timestamp), f64) {
|
||||
) -> Result<(Vec<Box<dyn aura_engine::Source>>, (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<Box<dyn aura_engine::Source>>,
|
||||
(Timestamp, Timestamp),
|
||||
f64,
|
||||
) {
|
||||
) -> Result<
|
||||
(
|
||||
Vec<Box<dyn aura_engine::Source>>,
|
||||
(Timestamp, Timestamp),
|
||||
f64,
|
||||
),
|
||||
RunnerError,
|
||||
> {
|
||||
match data {
|
||||
RunData::Synthetic => {
|
||||
let sources: Vec<Box<dyn aura_engine::Source>> =
|
||||
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<String>), 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<String> = 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<RunReport, MemberFault> {
|
||||
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<i64> =
|
||||
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::<f64>() / 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -285,6 +285,25 @@ impl From<TraceStoreError> 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<String>,
|
||||
rows: Vec<(String, ScalarKind, Receiver<(Timestamp, Vec<Scalar>)>)>,
|
||||
outcomes: Vec<(String, Receiver<Result<(), TraceStoreError>>)>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<dyn FnMut(Timestamp, Cell) + Send>),
|
||||
}
|
||||
let mut resolved: Vec<(String, ScalarKind, Resolved)> = Vec::new();
|
||||
let mut skipped: Vec<String> = 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<dyn Node> = match sub {
|
||||
|
||||
@@ -121,12 +121,15 @@ pub fn stop_rule_for_regime(regime: Option<aura_research::RiskRegime>) -> 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<PrimitiveBuilder> {
|
||||
pub fn cost_nodes_for(
|
||||
specs: &[aura_research::CostSpec],
|
||||
instrument: &str,
|
||||
) -> Result<Vec<PrimitiveBuilder>, 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<String> = 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]
|
||||
|
||||
+28
-6
@@ -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 <blueprint>.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.
|
||||
|
||||
@@ -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 <blueprint.json>`, 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 <target>`, dispatches
|
||||
|
||||
@@ -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<String>`), 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
|
||||
|
||||
@@ -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<String>), 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
|
||||
|
||||
Reference in New Issue
Block a user