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
|
||||
|
||||
Reference in New Issue
Block a user