diff --git a/docs/plans/0096-discover-a-loaded-blueprints-sweepable-axes.md b/docs/plans/0096-discover-a-loaded-blueprints-sweepable-axes.md new file mode 100644 index 0000000..4837a90 --- /dev/null +++ b/docs/plans/0096-discover-a-loaded-blueprints-sweepable-axes.md @@ -0,0 +1,415 @@ +# Discover a loaded blueprint's sweepable axes — Implementation Plan + +> **Parent spec:** `docs/specs/0096-discover-a-loaded-blueprints-sweepable-axes.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to +> run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Add `aura sweep --list-axes` — a read-only query +sub-mode that lists a loaded blueprint's open sweepable knobs (one +`:` per line, exactly as `--axis` names them) then exits. + +**Architecture:** The axis names `--axis` accepts are produced by the sweep's +harness-wrapping (`wrap_stage1r(loaded_signal)` nests the signal composite, so +`collect_params` prefixes the names — `stage1_signal.fast.length`, not the raw +`fast.length`). So the query lands on the SWEEP verb and reuses the SAME probe +the sweep resolves against, via a new shared `blueprint_axis_probe` helper that +also subsumes the two existing inline copies of that probe +(`blueprint_sweep_family`, `blueprint_mc_family`) — one source of truth for the +wrapped namespace. All changes are in `crates/aura-cli`; engine/registry +untouched (invariants 1/8/9). + +**Tech Stack:** `crates/aura-cli/src/main.rs` (the dispatch, the arg grammar, +the two new fns, the probe refactor), `crates/aura-cli/tests/cli_run.rs` +(E2Es). Read-only references: `Composite::param_space` /`collect_params` +(`crates/aura-engine/src/blueprint.rs`), `ParamSpec`/`ScalarKind` +(`crates/aura-core`). + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-cli/src/main.rs` — new `blueprint_axis_probe` + + `list_blueprint_axes` fns; `SweepBlueprintArgs.list_axes` field; + `parse_sweep_blueprint_args` `--list-axes` recognition + standalone-guard; + `["sweep", ..]` dispatch short-circuit; refactor `blueprint_sweep_family` + (~:3186-3191) and `blueprint_mc_family` (~:3235-3240) to the shared probe. +- Test: `crates/aura-cli/src/main.rs` (`#[cfg(test)] mod tests`, ~:4193) — + unit `blueprint_axis_probe` + unit `parse_sweep_blueprint_args` list-axes. +- Test: `crates/aura-cli/tests/cli_run.rs` — 4 new E2Es beside the existing + sweep block (~:3269-3610). + +Line numbers are the current-tree anchors from recon; they may drift ±a few — +match on the surrounding code, not the number. + +--- + +## Task 1: Shared probe helper `blueprint_axis_probe` (single-source the wrapped namespace) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (new fn ~:3166; refactor ~:3186-3191 and ~:3235-3240) +- Test: `crates/aura-cli/src/main.rs` (`mod tests`) + +- [ ] **Step 1: Write the failing unit test** + +Add to the `#[cfg(test)] mod tests` block (near the existing +`blueprint_sweep_member_equals_single_run_and_shares_topology_hash`). It reads +the two committed fixtures via `include_str!` (path relative to `src/main.rs`): + +```rust +#[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()); +} +``` + +- [ ] **Step 2: Run the test to verify it fails (RED — unresolved name)** + +Run: `cargo test -p aura-cli --bin aura blueprint_axis_probe` +Expected: FAIL to compile — `cannot find function 'blueprint_axis_probe' in this scope`. + +- [ ] **Step 3: Add the `blueprint_axis_probe` helper** + +Insert just above `fn blueprint_sweep_family` (~:3166): + +```rust +/// 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) +} +``` + +- [ ] **Step 4: Subsume the `blueprint_sweep_family` inline probe** + +In `blueprint_sweep_family`, replace the four `let (tx_*, _) = mpsc::channel();` +lines plus `let probe = wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, +false, true, None);` (~:3186-3190) with a single call. The following +`let space = probe.param_space();` and the later `probe.axis(...)` stay +unchanged (the helper returns the `Composite`, so both the borrow and the +consume still work): + +```rust + // a single throwaway floated build, only to resolve param_space (borrow) then seed + // 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(); +``` + +- [ ] **Step 5: Subsume the `blueprint_mc_family` inline probe** + +In `blueprint_mc_family`, replace the four `let (tx_*, _) = mpsc::channel();` +lines plus `let space = wrap_stage1r(reload(doc), tx_eq, tx_ex, tx_r, tx_req, +false, true, None).param_space();` (~:3235-3240) with: + +```rust + // 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(); +``` + +- [ ] **Step 6: Verify the new test passes and the refactor is behaviour-preserving** + +Run: `cargo test -p aura-cli --bin aura blueprint_axis_probe` +Expected: PASS (1 test). + +Run: `cargo test -p aura-cli --bin aura blueprint_mc_family` +Expected: PASS (the existing `blueprint_mc_family_seeds_differ` and +`blueprint_mc_family_rejects_an_open_blueprint` — the closed-check still works, +proving the MC probe refactor preserved `param_space`). + +Run: `cargo test -p aura-cli --bin aura blueprint_sweep_member_equals_single_run_and_shares_topology_hash` +Expected: PASS (the sweep probe refactor preserved axis resolution). + +Run: `cargo build --workspace` +Expected: `Finished` — 0 errors. + +--- + +## Task 2: The `--list-axes` surface (arg field, parse grammar, list fn, dispatch) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (`SweepBlueprintArgs` ~:3907; `parse_sweep_blueprint_args` ~:3917-3945; new `list_blueprint_axes` fn; dispatch ~:4112-4118) +- Test: `crates/aura-cli/src/main.rs` (`mod tests`) + +- [ ] **Step 1: Write the failing unit test** + +Add beside the existing `parse_sweep_blueprint_args_grammar` test: + +```rust +#[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); +} +``` + +- [ ] **Step 2: Run the test to verify it fails (RED — no `list_axes` field)** + +Run: `cargo test -p aura-cli --bin aura parse_sweep_blueprint_args` +Expected: FAIL to compile — `no field 'list_axes' on type 'SweepBlueprintArgs'`. + +- [ ] **Step 3: Add the `list_axes` field to `SweepBlueprintArgs`** + +At ~:3907: + +```rust +struct SweepBlueprintArgs { + axes: Vec<(String, Vec)>, + name: String, + persist: bool, + data: DataChoice, + list_axes: bool, +} +``` + +- [ ] **Step 4: Recognize `--list-axes` (valueless) + standalone-guard in `parse_sweep_blueprint_args`** + +`--list-axes` carries no value, but the loop consumes a value per flag +(`t.split_first()`), so handle it *before* that split. Add a `list_axes` local, +a valueless arm at the top of the loop, and a post-loop standalone branch. +The standalone check is `rest.len() != 1` — `parse` receives only the flags +tail, so a lone `--list-axes` means the tail is exactly one token. + +Replace the body from `let mut tail = rest;` through the final `Ok(...)` +(~:3922-3944) with: + +```rust + 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; + continue; + } + match *flag { + "--axis" => { + let (n, csv) = value.split_once('=').ok_or_else(usage)?; + if n.is_empty() || axes.iter().any(|(a, _)| a == n) { return Err(usage()); } // empty / duplicate axis + let vals = parse_scalar_csv(csv).ok_or_else(usage)?; + axes.push((n.to_string(), vals)); + } + "--name" if name.is_none() => name = Some(((*value).to_string(), false)), + "--trace" if name.is_none() => name = Some(((*value).to_string(), true)), + _ => return Err(usage()), + } + 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)?, list_axes: false }) +``` + +- [ ] **Step 5: Add the `list_blueprint_axes` fn** + +Insert next to `blueprint_axis_probe` (above `blueprint_sweep_family`): + +```rust +/// `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 + } +} +``` + +- [ ] **Step 6: Thread the dispatch destructure + short-circuit** + +In the `["sweep", rest @ ..]` blueprint branch (~:4112-4118), extend the +destructure with `list_axes` and short-circuit to the listing before the run: + +```rust + 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; +``` + +- [ ] **Step 7: Verify the unit test passes and the workspace builds** + +Run: `cargo test -p aura-cli --bin aura parse_sweep_blueprint_args` +Expected: PASS (the existing `parse_sweep_blueprint_args_grammar` + +`parse_sweep_blueprint_args_list_axes_is_standalone`). + +Run: `cargo build --workspace` +Expected: `Finished` — 0 errors (the `SweepBlueprintArgs` field addition is +threaded at both its construction sites — the two `Ok(SweepBlueprintArgs {…})` +in `parse_sweep_blueprint_args` and the dispatch destructure — so the build is +whole). + +--- + +## Task 3: E2E coverage (the four user-facing behaviours) + +**Files:** +- Test: `crates/aura-cli/tests/cli_run.rs` (new tests beside the sweep block ~:3269-3610) + +- [ ] **Step 1: Write the four E2Es** + +Use the sweep block's fixture-path convention — an absolute +`env!("CARGO_MANIFEST_DIR")` path (cwd-independent; `--list-axes` writes nothing +to disk, so no `temp_cwd` is needed). The malformed case mirrors +`aura_run_rejects_an_unknown_node_blueprint`. + +```rust +#[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"); +} + +#[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"); +} + +#[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}"); +} + +#[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}"); +} +``` + +- [ ] **Step 2: Run the four E2Es** + +Run: `cargo test -p aura-cli --test cli_run aura_sweep_list_axes` +Expected: PASS — 4 tests (`aura_sweep_list_axes_prints_prefixed_names`, +`..._closed_is_empty`, `..._rejects_other_flags`, `..._rejects_malformed_blueprint`). +The single filter `aura_sweep_list_axes` matches exactly these 4 named tests. + +- [ ] **Step 3: Full-suite + lint gate** + +Run: `cargo test --workspace` +Expected: PASS — no regressions across the workspace. + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: `Finished` — 0 warnings. + +--- + +## Self-review (planner Step 5) + +1. **Spec coverage:** §Goal/§Architecture → Tasks 1-2 (probe on the sweep verb, + prefixed names); §Concrete code shapes → Tasks 1-2 (all four shown shapes: + `blueprint_axis_probe`, `list_blueprint_axes`, `SweepBlueprintArgs.list_axes`, + dispatch); §Error handling → Task 2 Step 4 (standalone guard) + Task 3 Step 1 + (malformed + closed cases); §Testing strategy (all four deliverable tests) → + Task 1 unit, Task 2 unit, Task 3 E2Es; §Acceptance criteria 1-6 → covered by + the E2Es + the build/clippy gate (Task 3 Step 3). ✓ +2. **Placeholder scan:** no TBD/TODO/"similar to"/"add appropriate". ✓ +3. **Type/name consistency:** `blueprint_axis_probe`, `list_blueprint_axes`, + `SweepBlueprintArgs.list_axes`, `parse_sweep_blueprint_args`, + `blueprint_sweep_family`, `blueprint_mc_family`, `wrap_stage1r`, `ScalarKind` + consistent across tasks and match the current tree. ✓ +4. **Step granularity:** each step is one edit or one command. ✓ +5. **No commit steps.** ✓ +6. **Pin/replacement contiguity:** the E2E asserts `stdout == + "stage1_signal.fast.length:I64\nstage1_signal.slow.length:I64\n"` and + `stderr.contains("--list-axes lists axes")` / `"UnknownNodeType"` — each pinned + substring appears contiguously in the corresponding impl (`println!` format + and the `Err("--list-axes lists axes and takes no other flags")` literal / + the existing engine `UnknownNodeType` error). ✓ +7. **Compile-gate vs. deferred-caller:** the `SweepBlueprintArgs.list_axes` + field addition (Task 2 Step 3) breaks exactly two sites — the two + `Ok(SweepBlueprintArgs {…})` returns (threaded in Step 4) and the dispatch + destructure (threaded in Step 6) — ALL inside Task 2, before its Step 7 build + gate. No caller deferred past a build gate. ✓ +8. **Verification filters resolve:** `blueprint_axis_probe` (new, Task 1 Step 1), + `blueprint_mc_family` (existing `blueprint_mc_family_seeds_differ` + + `_rejects_an_open_blueprint`), `blueprint_sweep_member_equals_single_run_and_shares_topology_hash` + (existing), `parse_sweep_blueprint_args` (existing `_grammar` + new + `_list_axes_is_standalone`), `aura_sweep_list_axes` (the 4 new E2Es, shared + prefix) — every filter matches ≥1 named test in the post-task tree. ✓