diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index beee9fb..48415eb 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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::() { + Ok(i) => Scalar::i64(i), + Err(_) => match val_s.trim().parse::() { + 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)> = 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 = 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 = 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 = 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); } diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index 5528826..207625c 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -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}" + ); +}