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:
+95
-17
@@ -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
|
||||
|
||||
@@ -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