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:
2026-07-25 18:41:14 +02:00
parent 78b80ec0fd
commit da19e27b6a
2 changed files with 197 additions and 19 deletions
+100 -19
View File
@@ -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, &params, 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, &params, 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);
}
+97
View File
@@ -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}"
);
}