From dec07809df158936b3be7aa9cbb8c6e2ab688b1c Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 1 Jul 2026 17:28:02 +0200 Subject: [PATCH 1/2] fix(cli): aura run refuses an open blueprint with a clean exit 2, not a panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aura run ` bootstraps the loaded signal over the EMPTY param point (a closed-blueprint assumption) and `.expect()`s `compile_with_params`, which panics (exit 101, ParamArity) when the blueprint carries >=1 free knob. The sibling `aura mc` already refuses an open blueprint cleanly at the dispatch boundary; `run` now does the same. The `["run", ..]` `.json`-discriminator arm probes the wrapped param_space (`blueprint_axis_probe`, the single source of that wrap) and, when it is non-empty, emits a "run requires a closed blueprint … N free knob(s) — bind them or use `aura sweep --axis`" diagnostic and exits 2 — mirroring `blueprint_mc_family`'s closed-guard. No panic on a naive consumer's first move (author an open blueprint, run it). RED-first: cli_run.rs `aura_run_rejects_an_open_blueprint_without_panicking` pins exit 2 + the named requirement + no `panicked`. Surfaced by the World/C21 milestone fieldtest. closes #176 refs #170 --- crates/aura-cli/src/main.rs | 12 ++++++++++++ crates/aura-cli/tests/cli_run.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 18214c0..010c3a4 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -4256,6 +4256,18 @@ fn main() { eprintln!("aura: {path}: {e:?}"); std::process::exit(2); }); + // Refuse an open (free-knob) blueprint at the dispatch boundary, mirroring + // `blueprint_mc_family`'s closed-guard: `run` bootstraps over the EMPTY point, so a + // free knob would panic in `compile_with_params` — reject it clean instead (#176). + let free = blueprint_axis_probe(&doc).param_space(); + if !free.is_empty() { + eprintln!( + "aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \ + bind them or use `aura sweep --axis`", + free.len() + ); + std::process::exit(2); + } let BlueprintRunArgs { params, data, seed } = parse_blueprint_run_args(&rest[1..]) .unwrap_or_else(|msg| { eprintln!("aura: {msg}"); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index f6dc88a..cf31213 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3655,6 +3655,37 @@ fn aura_mc_rejects_an_open_blueprint() { let _ = std::fs::remove_dir_all(&cwd); } +/// E2E (#176): `aura run ` refuses an open blueprint the same way the +/// sibling `aura mc` does — a clean named error + exit 2, NEVER a `compile_with_params` +/// arity panic (exit 101). The `run` dispatch bootstraps over the EMPTY point (a +/// closed-blueprint assumption), so a free knob must be rejected at the dispatch boundary +/// before that bootstrap, mirroring `blueprint_mc_family`'s closed-guard. +#[test] +fn aura_run_rejects_an_open_blueprint_without_panicking() { + let cwd = temp_cwd("run-blueprint-open-reject"); + let fixture = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let out = Command::new(BIN) + .args(["run", &fixture]) + .current_dir(&cwd) + .output() + .expect("spawn aura run open-blueprint"); + let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); + assert_eq!( + out.status.code(), + Some(2), + "an open blueprint fails clean (exit 2, not a panic/exit 101): stderr={stderr}" + ); + assert!( + stderr.contains("closed blueprint"), + "names the closed-blueprint requirement (mirroring `aura mc`): {stderr}" + ); + assert!( + !stderr.contains("panicked"), + "must refuse at the dispatch boundary, never panic: {stderr}" + ); + let _ = std::fs::remove_dir_all(&cwd); +} + /// E2E (negative): `aura reproduce ` is a hard error (exit non-zero + named /// cause on stderr), distinct from `runs family `'s treat-as-empty exit 0. #[test] From 6a775f700f9f7fc943b9f669d026c0a39424c70d Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 1 Jul 2026 17:41:09 +0200 Subject: [PATCH 2/2] fix(cli): walkforward validates its --axis grid once, not once per window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `aura walkforward --axis =` emitted a rejected axis's diagnostic more than once (a racy 1-3x) when the grid did not resolve. `blueprint_walkforward_family` resolved the axes *inside* the per-window closure, and `walk_forward` fans that closure out across the windows in parallel — so several windows each `eprintln!` the same `BindError` and `exit(2)` before any one exit tears the process down. Axis resolution is window-independent, so it is hoisted to a single dispatch-boundary pre-flight (mirroring `aura sweep`, which validates its axes once before any member runs): the first in-sample window is resolved up front, surfacing the `BindError` exactly once; the per-window closure then resolves already-validated axes and cannot re-raise (`expect`). RED-first: cli_run.rs `aura_walkforward_emits_an_unknown_axis_rejection_once` drives the verb 20x and asserts the rejection is single every run (the emit count was racy, so a single invocation would be a flaky assertion). Surfaced by the World/C21 milestone fieldtest. closes #177 refs #170 --- crates/aura-cli/src/main.rs | 18 +++++++++++++++- crates/aura-cli/tests/cli_run.rs | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 010c3a4..e533df8 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -3306,9 +3306,25 @@ fn blueprint_walkforward_family( let space = blueprint_axis_probe(doc).param_space(); let topo = topology_hash(&blueprint_from_json(doc, &|t| std_vocabulary(t)) .expect("doc parse-validated at the dispatch boundary; reload is infallible")); + // Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep` + // (which resolves its axes a single time before any member runs). `walk_forward` fans + // the per-window closure out across the windows in parallel, so a `BindError` raised + // *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one + // exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic, + // so pre-flighting the first IS window here surfaces the error exactly once (it fails in + // `resolve_axes`, before any member runs); a per-window resolve then cannot re-raise. + let first_is = WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) + .expect("roller config validated just above") + .next() + .expect("roller yields >= 1 window (validated above)") + .is; + if let Err(e) = blueprint_sweep_over(doc, axes, first_is.0, first_is.1, data) { + eprintln!("aura: {e:?}"); + std::process::exit(2); + } walk_forward(roller, space.clone(), |w: WindowBounds| { let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data) - .unwrap_or_else(|e| { eprintln!("aura: {e:?}"); std::process::exit(2); }); + .expect("axes validated in the dispatch-boundary pre-flight"); let (best, selection) = match select_winner(&is_family, "sqn_normalized", select, Some(&lattice)) { Ok(v) => v, Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index cf31213..a778e65 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -3638,6 +3638,41 @@ fn aura_walkforward_over_a_blueprint_rejects_an_unknown_axis() { assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}"); } +/// E2E (#177): a rejected `--axis` on a loaded walk-forward blueprint is reported +/// EXACTLY ONCE, however many IS/OOS windows the roll would have spanned — mirroring +/// the sibling `aura sweep` / `aura mc` paths, which validate the axis ONCE up front +/// and emit their rejection a single time. The property this protects: axis +/// resolution is a single pre-flight, not a per-window re-raise. +/// +/// The CLOSED fixture has an empty param_space, so ANY `--axis` name is an +/// `UnknownKnob`. The pre-fix code validates the axis INSIDE the per-window closure +/// that `walk_forward` fans out in parallel across the windows, so more than one +/// window `eprintln!`s the same rejection before the racing `exit(2)` tears the +/// process down — a race whose emit count is 1 or 2 per run. A single invocation +/// would therefore be a flaky assertion (it emits once ~1/3 of the time by luck), so +/// this drives the verb repeatedly: observing the double-emit at least once is then +/// near-certain, and the assertion is that EVERY run emits the rejection exactly +/// once. The fix (hoist axis resolution to one pre-flight before the windowed +/// fan-out) makes every run deterministically single. +#[test] +fn aura_walkforward_emits_an_unknown_axis_rejection_once() { + let bp = format!("{}/tests/fixtures/stage1_signal.json", env!("CARGO_MANIFEST_DIR")); + for attempt in 1..=20 { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura")) + .args(["walkforward", &bp, "--axis", "graph.fast.length=2,3"]) + .output() + .expect("spawn aura walkforward (unknown axis)"); + assert_eq!(out.status.code(), Some(2), "an unknown axis must fail clean, not panic"); + let stderr = String::from_utf8(out.stderr).expect("utf-8"); + assert!(!stderr.contains("panicked"), "must reject cleanly, never panic: {stderr}"); + let emits = stderr.matches("UnknownKnob").count(); + assert_eq!( + emits, 1, + "attempt {attempt}: the axis rejection must be emitted exactly once, got {emits}: {stderr:?}", + ); + } +} + /// E2E (acc 2): `aura mc ` is rejected with a named error + exit 2 /// (NOT a panic / exit 101) — MC binds no axis, so a free knob has no binder. #[test]