feat(aura-cli): exec --override (the #246 residue) + the measurement leg
Slice 2 of the #319 retirement. The blueprint leg now carries both run shapes: a bias signal via run_signal_r, a no-bias measurement blueprint (>=1 declared tap) via run_measurement — record lines byte-identical to run's. --override NODE.PARAM=VALUE (repeatable) is the one deliberate sugar residue: validated through the shared override_paths prose, the closed-guard runs on the reopened probe (space == override set), values bind in reopened param_space order so the manifest records them raw under params ("what varied"). A measurement blueprint refuses --override explicitly (its space is not wrap_r-wrapped) rather than dropping it silently. refs #319
This commit is contained in:
+100
-19
@@ -45,7 +45,10 @@ use aura_measurement::information_coefficient;
|
||||
// `blueprint_axis_probe_reopened` is production code only from `verb_sugar.rs`
|
||||
// (a sibling module reaching it via `crate::blueprint_axis_probe_reopened`,
|
||||
// the crate-root re-export this `use` gives it), not from this module itself.
|
||||
use aura_runner::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, RunData};
|
||||
use aura_runner::member::{
|
||||
blueprint_axis_probe, blueprint_axis_probe_reopened, override_paths, reopen_all, run_signal_r,
|
||||
RunData,
|
||||
};
|
||||
use aura_runner::{TapPlan, TapSubscription};
|
||||
#[cfg(test)]
|
||||
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
|
||||
@@ -1956,10 +1959,7 @@ fn dispatch_exec(a: ExecCmd, env: &aura_runner::project::Env) {
|
||||
let target = Some(a.target.clone());
|
||||
let blueprint_path = is_blueprint_file(&target).filter(|p| !is_campaign_document_file(p));
|
||||
match blueprint_path {
|
||||
Some(path) => {
|
||||
let _ = &a.r#override; // threaded in Task 3, not this task
|
||||
exec_blueprint_leg(path, &a.tap, env)
|
||||
}
|
||||
Some(path) => exec_blueprint_leg(path, &a.r#override, &a.tap, env),
|
||||
None => {
|
||||
if !a.tap.is_empty() {
|
||||
eprintln!(
|
||||
@@ -1975,14 +1975,43 @@ document declares its persisted taps itself"
|
||||
|
||||
/// The `exec` blueprint leg (#319): a synthetic single run of a fully-bound
|
||||
/// blueprint envelope, composed 1:1 from `dispatch_run`'s `.json`-branch
|
||||
/// building blocks (`main.rs:1970-2029` at the time of writing) — same
|
||||
/// intake gate, same closed-blueprint guard, same tap plan, same
|
||||
/// `run_signal_r` call and record-line serializer, so the record line stays
|
||||
/// byte-identical to `run`'s. `--override` is NOT handled here (Task 3);
|
||||
/// `run`'s `--params`/`--seed`/`--real`/`--from`/`--to` do not exist on
|
||||
/// `exec` at all (invariant 7 — a real-data or seeded single run is a
|
||||
/// one-cell campaign document).
|
||||
fn exec_blueprint_leg(path: &str, tap: &[String], env: &aura_runner::project::Env) {
|
||||
/// building blocks — same intake gate, same shape dispatch (a `bias` output
|
||||
/// → the strategy path; else ≥1 declared tap → a bare measurement run; else
|
||||
/// refuse), same tap plan, same `run_signal_r`/`run_measurement` calls and
|
||||
/// record-line serializer, so the record line stays byte-identical to
|
||||
/// `run`'s. `--override` (Task 3, the #246 reopen residue) rides the
|
||||
/// strategy path only — a measurement blueprint refuses it with one prose
|
||||
/// line (its `param_space()` is not wrap_r-wrapped, so the strategy path's
|
||||
/// probe/reopen recipe below does not apply to it materially). `run`'s
|
||||
/// `--params`/`--seed`/`--real`/`--from`/`--to` do not exist on `exec` at
|
||||
/// all (invariant 7 — a real-data or seeded single run is a one-cell
|
||||
/// campaign document).
|
||||
fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &aura_runner::project::Env) {
|
||||
// --override NODE.PARAM=VALUE (#319 Task 3): lexed with the same
|
||||
// int-then-float rule `parse_scalar_csv` uses for `--axis` values.
|
||||
let mut ov: Vec<(String, Scalar)> = Vec::new();
|
||||
for tok in overrides {
|
||||
let Some((path_s, val_s)) = tok.split_once('=') else {
|
||||
eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`");
|
||||
std::process::exit(2);
|
||||
};
|
||||
if path_s.trim().is_empty() {
|
||||
eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let val = match val_s.trim().parse::<i64>() {
|
||||
Ok(i) => Scalar::i64(i),
|
||||
Err(_) => match val_s.trim().parse::<f64>() {
|
||||
Ok(f) => Scalar::f64(f),
|
||||
Err(_) => {
|
||||
eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`");
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
};
|
||||
ov.push((path_s.trim().to_string(), val));
|
||||
}
|
||||
|
||||
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {path}: {e}");
|
||||
std::process::exit(2);
|
||||
@@ -2004,6 +2033,7 @@ fn exec_blueprint_leg(path: &str, tap: &[String], env: &aura_runner::project::En
|
||||
std::process::exit(2);
|
||||
}
|
||||
let has_bias = signal.output().iter().any(|o| o.name == "bias");
|
||||
let has_tap = !signal.taps().is_empty();
|
||||
let tap_plan = match tap_plan_from_args(tap) {
|
||||
Ok(p) => p,
|
||||
Err(m) => {
|
||||
@@ -2012,12 +2042,26 @@ fn exec_blueprint_leg(path: &str, tap: &[String], env: &aura_runner::project::En
|
||||
}
|
||||
};
|
||||
if has_bias {
|
||||
// #246 override set (Task 3): validated against the un-reopened
|
||||
// wrapped OPEN space via `override_paths` (the strict, erroring
|
||||
// variant `blueprint_sweep_family` also resolves its `--axis` grid
|
||||
// through), so an unmatched path refuses with that same shared
|
||||
// prose (`member.rs:768-771`) rather than a second wording.
|
||||
let raw_space = blueprint_axis_probe(&doc, env).param_space();
|
||||
let axes: Vec<(String, Vec<Scalar>)> = ov.iter().map(|(p, v)| (p.clone(), vec![*v])).collect();
|
||||
let paths = override_paths(&axes, &raw_space, &signal).unwrap_or_else(|e| {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
// Refuse an open (free-knob) blueprint at the dispatch boundary,
|
||||
// mirroring `dispatch_run`'s own closed-blueprint guard (#176): exec
|
||||
// bootstraps over the EMPTY point, so a free knob would panic in
|
||||
// `compile_with_params` — reject it clean instead.
|
||||
let free = blueprint_axis_probe(&doc, env).param_space();
|
||||
if !free.is_empty() {
|
||||
// `compile_with_params` — reject it clean instead. The guard now
|
||||
// runs on the REOPENED probe (#319 Task 3): its space must equal
|
||||
// exactly the override set (empty when there is none), never leave
|
||||
// an unaccounted-for free knob.
|
||||
let free = blueprint_axis_probe_reopened(&doc, env, &paths).param_space();
|
||||
if free.len() != paths.len() {
|
||||
eprintln!(
|
||||
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
|
||||
bind them or use `aura sweep --axis`",
|
||||
@@ -2025,13 +2069,50 @@ fn exec_blueprint_leg(path: &str, tap: &[String], env: &aura_runner::project::En
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let params: Vec<Scalar> = Vec::new();
|
||||
// The override values, in the REOPENED param_space order —
|
||||
// `run_signal_r` zips this 1:1 against the reopened signal's own
|
||||
// `param_space()` (built below) into the manifest (`member.rs:540-543`),
|
||||
// landing each overridden value in `params` ("what varied") and
|
||||
// dropping it from `defaults`.
|
||||
let ov_by_path: std::collections::HashMap<&str, Scalar> =
|
||||
ov.iter().map(|(p, v)| (p.as_str(), *v)).collect();
|
||||
let params: Vec<Scalar> = free
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let raw = aura_runner::axes::wrapped_to_raw_axis(&p.name);
|
||||
*ov_by_path.get(raw).expect("override set derived from these exact overrides")
|
||||
})
|
||||
.collect();
|
||||
let signal = reopen_all(signal, &paths);
|
||||
let report = run_signal_r(signal, ¶ms, RunData::Synthetic, 0, env, tap_plan);
|
||||
println!("{}", report.to_json());
|
||||
} else if has_tap {
|
||||
if !overrides.is_empty() {
|
||||
eprintln!(
|
||||
"aura: exec: --override applies only to a strategy blueprint (a `bias` \
|
||||
output); this measurement blueprint exposes none"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
// Measurement path (#319 Task 2b): wrap_r-free closed guard via the
|
||||
// signal's own open knobs (`blueprint_axis_probe` would weld wrap_r
|
||||
// and panic on a no-`bias` signal) — verbatim from `dispatch_run`'s
|
||||
// measurement arm.
|
||||
if !signal.param_space().is_empty() {
|
||||
eprintln!(
|
||||
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
|
||||
bind them or use `aura sweep --axis`",
|
||||
signal.param_space().len()
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let params: Vec<Scalar> = Vec::new();
|
||||
let report = run_measurement(signal, ¶ms, RunData::Synthetic, 0, env, tap_plan);
|
||||
println!("{}", report.to_json());
|
||||
} else {
|
||||
eprintln!(
|
||||
"aura: `aura exec` needs a `bias` output (a strategy); this blueprint exposes \
|
||||
none — the measurement leg is not yet carried by exec"
|
||||
"aura: `aura run` needs either a `bias` output (a strategy) or ≥1 \
|
||||
declared tap (a measurement); this blueprint exposes neither"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
@@ -395,3 +395,100 @@ fn exec_blueprint_tap_selector_persists_a_fold_summary_row() {
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 2b: the measurement leg of exec's blueprint target.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A no-bias (measurement) blueprint runs through `exec` exactly as through
|
||||
/// `run`: same record shape (`manifest` + `taps`, no R `metrics` — a
|
||||
/// measurement run has no broker) — mirrors
|
||||
/// `run_measurement.rs::measurement_blueprint_runs_bare_and_emits_its_tap`
|
||||
/// (a `MeasurementReport` carries no `metrics` field at all, unlike a
|
||||
/// strategy `RunReport` — `aura_engine::report::MeasurementReport`'s doc
|
||||
/// comment states this explicitly).
|
||||
#[test]
|
||||
fn exec_measurement_blueprint_emits_the_record_line() {
|
||||
let cwd = temp_cwd("exec-measurement-blueprint");
|
||||
let bp_path = cwd.join("m.json");
|
||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||
|
||||
let (out, code) = run_code_in(&cwd, &["exec", bp_path.to_str().expect("utf-8 path")]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
let line = out.lines().find(|l| l.starts_with('{')).expect("record line");
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("record parses");
|
||||
assert!(v.get("manifest").is_some(), "record: {out}");
|
||||
assert_eq!(v["taps"], serde_json::json!(["fast_tap"]));
|
||||
assert!(v.get("metrics").is_none(), "a measurement run has no R metrics: {out}");
|
||||
assert_eq!(v["manifest"]["broker"], "measurement");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 3: `--override` on the blueprint leg.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The one deliberate sugar residue: a bound-param override, recorded raw in
|
||||
/// `manifest.params` ("what varied") and dropped from `manifest.defaults`
|
||||
/// ("what was held") — the #246 reopen mechanism, now reachable through
|
||||
/// `exec`. `examples/r_sma.json` binds `fast.length` to 2 (its "fast" `SMA`
|
||||
/// node) — the same bound param
|
||||
/// `cli_run.rs::sweep_dissolved_accepts_an_axis_over_a_bound_param` reopens
|
||||
/// via `--axis` on the sweep-dissolved campaign path.
|
||||
#[test]
|
||||
fn exec_blueprint_override_reopens_and_records_the_value_in_params() {
|
||||
let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "fast.length=8"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
let line = out.lines().find(|l| l.starts_with('{')).expect("record line");
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("record parses");
|
||||
let params = v["manifest"]["params"].as_array().expect("params array");
|
||||
assert!(
|
||||
params.iter().any(|e| e[0] == "fast.length" && e[1] == serde_json::json!({"I64": 8})),
|
||||
"the overridden value must land in params: {out}"
|
||||
);
|
||||
let defaults = v["manifest"]["defaults"].as_array().expect("defaults array");
|
||||
assert!(
|
||||
!defaults.iter().any(|e| e[0] == "fast.length"),
|
||||
"an overridden bound param drops out of defaults: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without `--override`, the same blueprint's bound values stay untouched:
|
||||
/// `fast.length` is a held default, not a varied param.
|
||||
#[test]
|
||||
fn exec_blueprint_without_override_runs_bound_values_untouched() {
|
||||
let (out, code) = run_code(&["exec", "examples/r_sma.json"]);
|
||||
assert_eq!(code, Some(0), "stdout/stderr: {out}");
|
||||
let line = out.lines().find(|l| l.starts_with('{')).expect("record line");
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("record parses");
|
||||
let params = v["manifest"]["params"].as_array().expect("params array");
|
||||
assert!(
|
||||
!params.iter().any(|e| e[0] == "fast.length"),
|
||||
"no override means fast.length stays out of params: {out}"
|
||||
);
|
||||
let defaults = v["manifest"]["defaults"].as_array().expect("defaults array");
|
||||
assert!(
|
||||
defaults.iter().any(|e| e[0] == "fast.length" && e[1] == serde_json::json!({"I64": 2})),
|
||||
"fast.length is a held default at its bound value 2: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A malformed override token (no `NODE.PARAM=VALUE` shape) refuses as usage.
|
||||
#[test]
|
||||
fn exec_override_malformed_token_refuses_with_usage() {
|
||||
let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "fast.length"]);
|
||||
assert_eq!(code, Some(2), "stdout/stderr: {out}");
|
||||
assert!(out.contains("--override"), "stdout/stderr: {out}");
|
||||
assert!(out.contains("NODE.PARAM=VALUE"), "stdout/stderr: {out}");
|
||||
}
|
||||
|
||||
/// An override path naming no param of the blueprint (open or bound) refuses
|
||||
/// with `override_paths`' own prose (`member.rs:768-771`).
|
||||
#[test]
|
||||
fn exec_override_unknown_path_refuses_with_the_override_prose() {
|
||||
let (out, code) = run_code(&["exec", "examples/r_sma.json", "--override", "nope.knob=1"]);
|
||||
assert_eq!(code, Some(1), "stdout/stderr: {out}");
|
||||
assert!(
|
||||
out.contains("names no param of this blueprint (open or bound)"),
|
||||
"stdout/stderr: {out}"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user