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