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:
2026-07-01 14:54:45 +02:00
parent 581c86b091
commit bd2003e217
2 changed files with 213 additions and 17 deletions
+95 -17
View File
@@ -3154,6 +3154,36 @@ fn run_blueprint_member(
RunReport { manifest, metrics: m }
}
/// The exact wrapped probe the loaded-blueprint sweep resolves its axes
/// against: the loaded signal wrapped in the stage1-r scaffolding (stop bound,
/// reduce, no cost), taps discarded. `param_space()` on it is the axis
/// namespace `--axis` binds; `.axis()` consumes it to seed a sweep. Single
/// source for the sweep terminal, the MC closed-check, AND `--list-axes`, so
/// the listed names track the swept names by construction (incl. across #159's
/// harness retirement). The reload is infallible under the SAME
/// dispatch-boundary contract the callers already rely on: the doc is
/// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]`
/// boundary before this runs, so a malformed doc has already exited 2 and
/// never reaches the `.expect`.
fn blueprint_axis_probe(doc: &str) -> Composite {
let signal = blueprint_from_json(doc, &|t| std_vocabulary(t))
.expect("doc parse-validated at the dispatch boundary; reload is infallible");
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
wrap_stage1r(signal, tx_eq, tx_ex, tx_r, tx_req, false, true, None)
}
/// `aura sweep <blueprint.json> --list-axes`: one `<name>:<kind>` line per open
/// sweepable knob, in `param_space()` order; a closed blueprint prints nothing.
/// Names are exactly what `--axis` binds (same probe as the sweep terminal).
fn list_blueprint_axes(doc: &str) {
for p in blueprint_axis_probe(doc).param_space() {
println!("{}:{:?}", p.name, p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp
}
}
/// Sweep a serialized signal `doc` over user-named param-space axes — the structural
/// twin of [`stage1_r_sweep_family`], with three deviations. (1) The signal source is
/// `wrap_stage1r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built
@@ -3180,14 +3210,9 @@ fn blueprint_sweep_family(
let pip = data.pip_size();
let window = data.full_window();
// a single throwaway floated build, only to resolve param_space (borrow) then seed
// the named axes (move) — its taps are never drained. Mirrors stage1_r_sweep_family:
// param_space takes &self, .axis() consumes self, so one build suffices. The signal
// is wrapped exactly as the single run wraps it (stop bound, reduce, no cost).
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
let probe = wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, false, true, None);
// the named axes (move) — its taps are never drained. The wrapped probe is the one
// `blueprint_axis_probe` single-sources (identical `false, true, None` wrap).
let probe = blueprint_axis_probe(doc);
let space = probe.param_space();
// seed the named axes verbatim: the first via Composite::axis (consumes the probe),
// the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the
@@ -3231,13 +3256,9 @@ fn blueprint_mc_family(doc: &str, n_seeds: u64, data: &DataSource) -> Result<McF
};
let topo = topology_hash(&reload(doc));
let pip = data.pip_size();
// probe the wrapped param_space (as blueprint_sweep_family does); MC needs it empty.
let (tx_eq, _) = mpsc::channel();
let (tx_ex, _) = mpsc::channel();
let (tx_r, _) = mpsc::channel();
let (tx_req, _) = mpsc::channel();
let space =
wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, false, true, None).param_space();
// probe the wrapped param_space (the same probe the sweep resolves against);
// MC needs it empty. `blueprint_axis_probe` is the single source of that wrap.
let space = blueprint_axis_probe(doc).param_space();
if !space.is_empty() {
// Exit-free like blueprint_sweep_family: the builder's single error contract is this
// returned message (no hidden process exit), so the rejection is unit-testable; the IO
@@ -3909,6 +3930,7 @@ struct SweepBlueprintArgs {
name: String,
persist: bool,
data: DataChoice,
list_axes: bool,
}
/// Parse the `aura sweep <blueprint.json> …` tail: repeatable `--axis <name>=<csv>`
@@ -3919,8 +3941,14 @@ fn parse_sweep_blueprint_args(rest: &[&str]) -> Result<SweepBlueprintArgs, Strin
let mut axes: Vec<(String, Vec<Scalar>)> = Vec::new();
let mut name: Option<(String, bool)> = None; // (name, persist)
let mut data = RealWindowGrammar::default();
let mut list_axes = false;
let mut tail = rest;
while let Some((flag, t)) = tail.split_first() {
if *flag == "--list-axes" {
list_axes = true;
tail = t;
continue;
}
let (value, t) = t.split_first().ok_or_else(usage)?;
if data.accept(flag, value, &usage)? {
tail = t;
@@ -3939,9 +3967,22 @@ fn parse_sweep_blueprint_args(rest: &[&str]) -> Result<SweepBlueprintArgs, Strin
}
tail = t;
}
if list_axes {
// A query, not a sweep: it must stand alone (the flags tail is only itself).
if rest.len() != 1 {
return Err("--list-axes lists axes and takes no other flags".to_string());
}
return Ok(SweepBlueprintArgs {
axes,
name: "sweep".to_string(),
persist: false,
data: data.finish(&usage)?,
list_axes: true,
});
}
if axes.is_empty() { return Err(usage()); } // a sweep needs at least one axis
let (name, persist) = name.unwrap_or_else(|| ("sweep".to_string(), false));
Ok(SweepBlueprintArgs { axes, name, persist, data: data.finish(&usage)? })
Ok(SweepBlueprintArgs { axes, name, persist, data: data.finish(&usage)?, list_axes: false })
}
/// Parsed tail of `aura mc <blueprint.json> …`: the seed count + the family name.
@@ -4109,11 +4150,15 @@ fn main() {
eprintln!("aura: {path}: {e:?}");
std::process::exit(2);
}
let SweepBlueprintArgs { axes, name, persist, data } =
let SweepBlueprintArgs { axes, name, persist, data, list_axes } =
parse_sweep_blueprint_args(&rest[1..]).unwrap_or_else(|msg| {
eprintln!("aura: {msg}");
std::process::exit(2);
});
if list_axes {
list_blueprint_axes(&doc);
return;
}
run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data));
return;
}
@@ -5294,6 +5339,23 @@ mod tests {
assert!(parse_sweep_blueprint_args(&["--axis", "x=1", "--name", "a", "--trace", "b"]).is_err()); // name + trace together
}
/// Property: `--list-axes` is a standalone query sub-mode — it parses alone
/// (setting `list_axes` and binding no axes), is mutually exclusive with a real
/// sweep in either flag order, and its absence leaves `list_axes` false while a
/// bare sweep still requires >= 1 axis.
#[test]
fn parse_sweep_blueprint_args_list_axes_is_standalone() {
let a = parse_sweep_blueprint_args(&["--list-axes"]).expect("--list-axes alone parses");
assert!(a.list_axes);
assert!(a.axes.is_empty());
// mutually exclusive with a real sweep, in either order:
assert!(parse_sweep_blueprint_args(&["--list-axes", "--axis", "stage1_signal.fast.length=2,3"]).is_err());
assert!(parse_sweep_blueprint_args(&["--axis", "stage1_signal.fast.length=2,3", "--list-axes"]).is_err());
// a bare sweep (no --list-axes) still requires >= 1 axis and reports list_axes=false:
let b = parse_sweep_blueprint_args(&["--axis", "stage1_signal.fast.length=2,3"]).expect("bare sweep parses");
assert!(!b.list_axes);
}
/// Property: the `aura mc <blueprint.json>` grammar requires `--seeds <n>` with
/// `n >= 1`, defaults the family name to "mc", and — synthetic-only this cycle —
/// rejects `--real` (and any other unknown flag) as a strict usage error, never a
@@ -5377,6 +5439,22 @@ mod tests {
assert_ne!(k4, k6, "distinct grid points key distinctly");
}
#[test]
fn blueprint_axis_probe_lists_prefixed_open_knobs() {
// The open fixture's two SMA lengths are the sweepable knobs; the probe
// wraps the signal (name "stage1_signal") so the names are prefixed —
// exactly what `--axis` binds.
let open = include_str!("../tests/fixtures/stage1_signal_open.json");
let space = blueprint_axis_probe(open).param_space();
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["stage1_signal.fast.length", "stage1_signal.slow.length"]);
assert!(space.iter().all(|p| matches!(p.kind, ScalarKind::I64)));
// A closed blueprint (both lengths bound) has no open axes.
let closed = include_str!("../tests/fixtures/stage1_signal.json");
assert!(blueprint_axis_probe(closed).param_space().is_empty());
}
#[test]
fn blueprint_mc_family_seeds_differ() {
// MC over a CLOSED signal (both SMA knobs bound): 3 seeds -> 3 draws, one shared
+118
View File
@@ -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);
}