From bd2003e217e6be8fcebb1c636294bc97c8b6b515 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 1 Jul 2026 14:54:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(0096):=20list=20a=20loaded=20blueprint's?= =?UTF-8?q?=20sweepable=20axes=20=E2=80=94=20aura=20sweep=20=20--?= =?UTF-8?q?list-axes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A data-level author can now discover a loaded blueprint's open sweepable knobs before sweeping: `aura sweep --list-axes` prints one `:` 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 --- crates/aura-cli/src/main.rs | 112 ++++++++++++++++++++++++----- crates/aura-cli/tests/cli_run.rs | 118 +++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 17 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 88c82e4..39a5bff 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 --list-axes`: one `:` 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