feat(0096): list a loaded blueprint's sweepable axes — aura sweep <bp.json> --list-axes
A data-level author can now discover a loaded blueprint's open sweepable knobs before sweeping: `aura sweep <blueprint.json> --list-axes` prints one `<name>:<kind>` line per open knob, in param_space() order, then exits 0. Before this the only way to learn the names was to guess a `--axis` and read the MissingKnob/UnknownKnob error — which names only the rejected knob, never the valid set. Prerequisite for #173 (IS-refit walk-forward must know which axes to re-fit per in-sample window). Design (derived, recorded on #169): the query lands on the SWEEP verb, not `graph introspect`, because the accepted axis names are produced by the sweep's own harness-wrapping (wrap_stage1r nests the loaded signal as a BlueprintNode::Composite named "stage1_signal", so collect_params prefixes them — stage1_signal.fast.length, not the raw fast.length). Only the sweep verb owns that wrapping. A new blueprint_axis_probe helper single-sources the wrapped probe and now subsumes the two previously-inline copies (blueprint_sweep_family + blueprint_mc_family), so listed == swept by construction (and stays so across #159's harness retirement) — no second source of truth. --list-axes is a standalone query (valueless flag handled before the per-flag value-consume; rejected with exit 2 if combined with any other flag). A closed blueprint prints nothing (exit 0). A malformed blueprint is rejected at the existing dispatch boundary (exit 2, UnknownNodeType), never a panic. Tests: unit blueprint_axis_probe (prefixed names + empty for closed) + unit parse grammar (standalone), and five cli_run E2Es — prints names, closed-empty, rejects-other-flags, malformed-safety (no panic), and a round-trip that DERIVES the names from --list-axes and feeds them back into a working sweep (pins the listed==swept invariant #169 exists to close). Verified myself: cargo build --workspace, cargo test --workspace, cargo clippy --workspace --all-targets -D warnings all green; no regression in the refactored sweep/mc probes. Engine and registry untouched (invariants 1/8/9). closes #169
This commit is contained in:
@@ -3631,3 +3631,121 @@ fn aura_reproduce_rejects_a_missing_stored_blueprint() {
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// E2E (#169): `aura sweep <open blueprint.json> --list-axes` prints one
|
||||
/// `<name>:<kind>` line per open sweepable knob, in `param_space()` order, and
|
||||
/// exits 0. The names are prefixed by the wrapping (stage1_signal.*) — exactly
|
||||
/// the strings `--axis` binds — so a user can discover then sweep without guessing.
|
||||
#[test]
|
||||
fn aura_sweep_list_axes_prints_prefixed_names() {
|
||||
let bp = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["sweep", &bp, "--list-axes"])
|
||||
.output()
|
||||
.expect("spawn aura sweep --list-axes");
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf-8");
|
||||
assert_eq!(stdout, "stage1_signal.fast.length:I64\nstage1_signal.slow.length:I64\n");
|
||||
}
|
||||
|
||||
/// E2E (#169): `--list-axes` on a CLOSED blueprint (all knobs bound) prints
|
||||
/// nothing and exits 0 — an empty axis set is the honest answer, not an error.
|
||||
#[test]
|
||||
fn aura_sweep_list_axes_closed_is_empty() {
|
||||
let bp = format!("{}/tests/fixtures/stage1_signal.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["sweep", &bp, "--list-axes"])
|
||||
.output()
|
||||
.expect("spawn aura sweep --list-axes (closed)");
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
assert!(out.stdout.is_empty(), "a closed blueprint has no open axes");
|
||||
}
|
||||
|
||||
/// E2E (#169): `--list-axes` is a standalone query — combining it with a real
|
||||
/// sweep flag (`--axis`) is a usage error (exit 2), naming that it lists axes
|
||||
/// and takes no other flags. A query cannot also seed a sweep.
|
||||
#[test]
|
||||
fn aura_sweep_list_axes_rejects_other_flags() {
|
||||
let bp = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["sweep", &bp, "--list-axes", "--axis", "stage1_signal.fast.length=2,3"])
|
||||
.output()
|
||||
.expect("spawn aura sweep --list-axes + --axis");
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||||
assert!(stderr.contains("--list-axes lists axes"), "stderr was: {stderr}");
|
||||
}
|
||||
|
||||
/// E2E (#169, safety): `--list-axes` over a malformed blueprint rejects cleanly
|
||||
/// (exit 2, naming UnknownNodeType) rather than panicking — the axis probe reloads
|
||||
/// the doc, so the dispatch-boundary validation must run first, never a raw `.expect`.
|
||||
#[test]
|
||||
fn aura_sweep_list_axes_rejects_malformed_blueprint() {
|
||||
let bp = format!("{}/tests/fixtures/unknown_node.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["sweep", &bp, "--list-axes"])
|
||||
.output()
|
||||
.expect("spawn aura sweep --list-axes (malformed)");
|
||||
assert_eq!(out.status.code(), Some(2), "malformed blueprint must be rejected, not panic");
|
||||
let stderr = String::from_utf8(out.stderr).expect("utf-8");
|
||||
assert!(stderr.contains("UnknownNodeType"), "stderr was: {stderr}");
|
||||
assert!(!stderr.contains("panicked"), "must reject cleanly, not panic: {stderr}");
|
||||
}
|
||||
|
||||
/// E2E (#169, the load-bearing "listed == swept" invariant): a name emitted by
|
||||
/// `--list-axes` is accepted VERBATIM by `--axis` — the discovered axis namespace
|
||||
/// and the swept axis namespace are the same set (shared-probe construction). The
|
||||
/// axis names here are DERIVED from the `--list-axes` output, never hardcoded, then
|
||||
/// fed straight back into a real sweep: the sweep exits 0 and persists a `Sweep`
|
||||
/// family, proving every listed name resolved (no UnknownKnob). This is the
|
||||
/// discovery-removes-guessing property #169 exists to close — were the list and
|
||||
/// sweep surfaces ever to diverge, a discovered name would be rejected here and this
|
||||
/// round-trip would fail, whereas the literal-pinned per-surface tests could not see it.
|
||||
#[test]
|
||||
fn aura_sweep_list_axes_names_round_trip_into_a_working_sweep() {
|
||||
let cwd = temp_cwd("blueprint-list-axes-roundtrip");
|
||||
let bp = format!("{}/tests/fixtures/stage1_signal_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
// 1) discover the axis names (do NOT hardcode them) — this is what a user reads.
|
||||
let list = Command::new(BIN)
|
||||
.args(["sweep", &bp, "--list-axes"])
|
||||
.output()
|
||||
.expect("spawn aura sweep --list-axes");
|
||||
assert_eq!(list.status.code(), Some(0));
|
||||
let listed = String::from_utf8(list.stdout).expect("utf-8");
|
||||
let names: Vec<String> = listed
|
||||
.lines()
|
||||
.map(|l| l.split(':').next().expect("a `name:kind` line").to_string())
|
||||
.collect();
|
||||
assert!(!names.is_empty(), "the open fixture must list >= 1 axis");
|
||||
|
||||
// 2) feed the DISCOVERED names straight back into a real sweep. Values are
|
||||
// position-assigned (first axis small, rest large) to stay in a non-degenerate
|
||||
// SMA regime without re-hardcoding the names; each axis takes two values.
|
||||
let mut args: Vec<String> = vec!["sweep".into(), bp.clone()];
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
args.push("--axis".into());
|
||||
args.push(format!("{name}={}", if i == 0 { "2,4" } else { "8,16" }));
|
||||
}
|
||||
let sweep = Command::new(BIN)
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura sweep over discovered axes");
|
||||
assert!(
|
||||
sweep.status.success(),
|
||||
"every listed name must be a valid --axis; exit={:?} stderr={}",
|
||||
sweep.status,
|
||||
String::from_utf8_lossy(&sweep.stderr)
|
||||
);
|
||||
|
||||
// 3) the sweep persisted a Sweep family whose grid spans exactly the discovered
|
||||
// axes (each bound to two values -> 2^k members) — the names resolved end-to-end.
|
||||
let fams = Command::new(BIN).args(["runs", "families"]).current_dir(&cwd).output().expect("spawn families");
|
||||
let fo = String::from_utf8(fams.stdout).expect("utf-8");
|
||||
assert!(fo.contains("\"kind\":\"Sweep\""), "families: {fo}");
|
||||
let expected = 1usize << names.len(); // two values per discovered axis
|
||||
assert!(fo.contains(&format!("\"members\":{expected}")), "grid over the discovered axes: {fo}");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user