From 485f3c8c89b9b6ac60459318ce1de238bc448e6f Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 1 Jul 2026 14:59:36 +0200 Subject: [PATCH] =?UTF-8?q?audit(0096):=20cycle=20close=20=E2=80=94=20axis?= =?UTF-8?q?=20discovery=20over=20a=20loaded=20blueprint;=20ledger=20refres?= =?UTF-8?q?hed=20(C24);=20rm=20ephemeral=20spec/plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cycle 0096 tidy (clean). Architect drift review over ebebc24..HEAD: no drift, no debt. Regression: no dedicated script (architect is the sole gate); its "What holds" is non-empty (design commitments confirmed against the diff + a green test run, not the commit body): - Inv. 8 / 9: the cycle touches only crates/aura-cli (main.rs + cli_run.rs + the ephemeral spec/plan) — no engine/registry/blueprint.rs change; those are read-only callees. - Inv. 1: --list-axes is a pure read-only probe (short-circuits before run_blueprint_sweep; builds the wrapped composite, reads param_space, prints — no run, no seed, no engine execution). - Behaviour-preserving single-source: blueprint_axis_probe is token-identical to the two former inline probes; three copies (sweep/mc/list) collapse to one; the reload closures survive (topology_hash + per-member run); clippy -D warnings clean, no dead code; the subsumed tests (blueprint_sweep_member_equals_single_run_and_shares_topology_hash, blueprint_mc_family_rejects_an_open_blueprint) stayed green. - The blueprint_axis_probe .expect is not a latent panic: the ["sweep",..] arm validates blueprint_from_json first (exit 2), pinned by the shipped aura_sweep_list_axes_rejects_malformed_blueprint E2E (no panic). Ledger: refreshed the C24 realization block (docs/design/INDEX.md) — reclassified the "axis-name discovery surface" from tracked debt to shipped (cycle 0096), recording the single-sourced blueprint_axis_probe and the listed==swept-by- construction property. Per-member trace-writing (#168) remains the one tracked debt in that block. Milestone World/C21 stays OPEN — the walk-forward half (#173, IS-refit, now unblocked by this #169) remains. Ephemeral spec/plan git-rm'd per the lifecycle convention. refs #169 --- docs/design/INDEX.md | 13 +- ...over-a-loaded-blueprints-sweepable-axes.md | 415 ------------------ ...over-a-loaded-blueprints-sweepable-axes.md | 234 ---------- 3 files changed, 11 insertions(+), 651 deletions(-) delete mode 100644 docs/plans/0096-discover-a-loaded-blueprints-sweepable-axes.md delete mode 100644 docs/specs/0096-discover-a-loaded-blueprints-sweepable-axes.md diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index ac4a5e1..ce2bbb0 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -1902,8 +1902,17 @@ axes to re-fit per window, so it could only be a non-IS-refit windowed eval — collide with the hard-wired `walkforward` verb's IS-optimizing guarantee. The synthetic-walk DGP is the machinery, not trader-grade MC statistics; a real-data block-bootstrap — and the `synthetic_walk_sources` `len:60`↔warm-up coupling it retires (a deep-lookback closed blueprint -warms poorly → silent-vacuous draws today) — ride #172. Per-member trace-writing on `--trace` and -an axis-name discovery surface are tracked debt. +warms poorly → silent-vacuous draws today) — ride #172. **Axis-name discovery shipped (cycle +0096, #169):** `aura sweep --list-axes` lists a loaded blueprint's open +sweepable knobs (one `:` per line, `param_space()` order), then exits — the names it +prints are exactly what `--axis` binds. A single `blueprint_axis_probe` helper now single-sources +the wrapped probe (`wrap_stage1r(loaded_signal).param_space()`) for the sweep terminal, the MC +closed-check, AND the listing (three former inline copies → one), so **listed == swept by +construction** (and stays so across #159's harness retirement — the listing tracks whatever the +sweep actually resolves, never a second source of truth). The names are prefixed by the current +stage1-r wrapping (`stage1_signal.fast.length`, the nested-composite prefix), not the raw +`param_space` — which is why the discovery lives on the sweep verb (it owns the wrapping), not +`graph introspect`. Per-member trace-writing on `--trace` (#168) remains tracked debt. **Content-addressed reproduction shipped (cycle 0094, #158, C18)**: `topology_hash` landed cycle 0092; re-deriving a member's FlatGraph from a stored manifest + the diff --git a/docs/plans/0096-discover-a-loaded-blueprints-sweepable-axes.md b/docs/plans/0096-discover-a-loaded-blueprints-sweepable-axes.md deleted file mode 100644 index 4837a90..0000000 --- a/docs/plans/0096-discover-a-loaded-blueprints-sweepable-axes.md +++ /dev/null @@ -1,415 +0,0 @@ -# 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. ✓ diff --git a/docs/specs/0096-discover-a-loaded-blueprints-sweepable-axes.md b/docs/specs/0096-discover-a-loaded-blueprints-sweepable-axes.md deleted file mode 100644 index 0fb21a9..0000000 --- a/docs/specs/0096-discover-a-loaded-blueprints-sweepable-axes.md +++ /dev/null @@ -1,234 +0,0 @@ -# Discover a loaded blueprint's sweepable axis names — Design Spec - -**Date:** 2026-07-01 -**Status:** Draft — awaiting user spec review -**Authors:** orchestrator + Claude - -## Goal - -Give a data-level blueprint author a way to **list a loaded blueprint's -sweepable axis names before sweeping it** — `aura sweep ---list-axes` prints one `:` line per open knob, exactly as the -`--axis` flag names them, then exits. Today the only way to learn those names -is to guess an axis and read a `MissingKnob`/`UnknownKnob` error — and that -error names only the *rejected* knob, never the valid set, so there is no -discovery path at all (#169, surfaced by the cycle-0093 architect drift -review). This is the prerequisite for #173 (IS-refit walk-forward), which -must know which axes to re-fit per in-sample window. - -## Architecture - -The load-bearing fact (grounded against the current tree, recorded as a -derived decision on #169): the axis names `--axis` accepts are **produced by -the sweep's own harness-wrapping, not by the raw blueprint**. A -loaded-blueprint sweep resolves axes against -`wrap_stage1r(reload(doc)).param_space()` (`crates/aura-cli/src/main.rs`, -`blueprint_sweep_family`): the loaded signal composite (`name == -"stage1_signal"`) is nested as a `BlueprintNode::Composite`, and -`collect_params` (`crates/aura-engine/src/blueprint.rs`) prefixes a nested -composite's params with that composite's own `name`. So the swept names are -**prefixed** — `stage1_signal.fast.length`, not the raw `fast.length` the -blueprint's own `param_space()` yields. - -Consequence for the surface: **only the sweep verb can honestly list the -names**, because only it owns the wrapping. So `--list-axes` is a query -sub-mode on `aura sweep`, computing the *same* probe the sweep resolves -against, via a single shared helper `blueprint_axis_probe`. `graph -introspect --axes` over the raw blueprint would print `fast.length` (names -the sweep rejects); teaching `graph introspect` to apply `wrap_stage1r` -would couple the general read-only-query surface to one research harness. -Correctness beats query-surface cohesion. Single-sourcing the probe also -keeps `--list-axes` correct by construction when the hard-wired stage1-r -wrapping is retired (#159): the listed names track whatever the sweep -actually resolves, with no second source of truth. - -Scope: `crates/aura-cli` only. No `aura-engine` / `aura-registry` change — -pure read-only CLI introspection. Domain invariants 1 (determinism), 8 -(frozen engine), 9 (registry domain-free) untouched. - -## Concrete code shapes - -### Worked user program (the acceptance evidence) - -An author holds a blueprint file and wants to sweep it, but does not know the -axis names: - -```console -$ aura sweep crates/aura-cli/tests/fixtures/stage1_signal_open.json --list-axes -stage1_signal.fast.length:I64 -stage1_signal.slow.length:I64 - -$ # now they can sweep, using exactly those names: -$ aura sweep crates/aura-cli/tests/fixtures/stage1_signal_open.json \ - --axis stage1_signal.fast.length=2,3 --axis stage1_signal.slow.length=4,6 -# ... runs a 2x2 sweep family ... -``` - -A **closed** blueprint (every knob bound) has no open axes — the listing is -empty and exit is 0, a valid "nothing to sweep here" answer, not an error: - -```console -$ aura sweep crates/aura-cli/tests/fixtures/stage1_signal.json --list-axes -$ echo $? -0 -``` - -`--list-axes` is a query, mutually exclusive with an actual sweep: - -```console -$ aura sweep …open.json --list-axes --axis stage1_signal.fast.length=2,3 -aura: --list-axes lists axes and takes no other flags -$ echo $? -2 -``` - -The two printed names are byte-for-byte what `--axis` binds — empirically, on -the current tree, `--axis stage1_signal.fast.length=2,3` passes name -resolution while `--axis fast.length=2,3` returns `UnknownKnob("fast.length")`. - -### Implementation shape (secondary) - -**New shared probe helper** — single source for the wrapped axis namespace, -used by BOTH the sweep terminal and `--list-axes`: - -```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. -/// The reload is infallible under the SAME dispatch-boundary contract -/// `blueprint_sweep_family` already relies on (main.rs ~3175-3178): the doc -/// is `blueprint_from_json`-validated at the `["sweep", ..]` boundary -/// (main.rs 4108-4110) before this runs, so a malformed doc has already -/// exited 2 and never reaches the `.expect`. This cycle's malformed-sweep -/// E2E pins that guard on the `--list-axes` path (see Testing strategy). -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) -} -``` - -`blueprint_sweep_family` (main.rs ~3186-3191) replaces its inline channel + -`wrap_stage1r` probe construction with `let probe = blueprint_axis_probe(doc);` -(it still consumes `probe` via `.axis()`, so the helper returns the `Composite`, -not just the space). The listing path takes only the space: - -```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). -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 - } -} -``` - -**Arg grammar** — `SweepBlueprintArgs` gains a `list_axes: bool`; -`parse_sweep_blueprint_args` recognizes `--list-axes` and enforces that it -appears **alone** (no `--axis`/`--name`/`--trace`/`--real`): - -```rust -struct SweepBlueprintArgs { axes: Vec<(String, Vec)>, name: String, - persist: bool, data: DataChoice, list_axes: bool } -// parse: on "--list-axes" set list_axes = true; if list_axes && any other -// flag was seen -> Err("--list-axes lists axes and takes no other flags"). -``` - -**Dispatch** — the existing `["sweep", rest @ ..]` blueprint branch -(main.rs ~4112-4118), after the boundary read + `blueprint_from_json` -validation that already reject a malformed file: - -```rust -let SweepBlueprintArgs { axes, name, persist, data, list_axes } = - parse_sweep_blueprint_args(&rest[1..]).unwrap_or_else(|msg| { eprintln!("aura: {msg}"); exit(2) }); -if list_axes { - list_blueprint_axes(&doc); - return; -} -run_blueprint_sweep(&doc, &axes, &name, persist, DataSource::from_choice(data)); -``` - -## Components - -- `blueprint_axis_probe(doc) -> Composite` (new, main.rs) — the single-sourced - wrapped probe; `blueprint_sweep_family` refactored to call it. -- `list_blueprint_axes(doc)` (new, main.rs) — prints `name:kind` per open knob. -- `SweepBlueprintArgs.list_axes` + `parse_sweep_blueprint_args` (modified) — - parse `--list-axes`, enforce standalone. -- `["sweep", ..]` blueprint dispatch (modified) — short-circuit to the listing. - -## Data flow - -`aura sweep --list-axes` → boundary read + `blueprint_from_json` -validate (existing) → `parse_sweep_blueprint_args` sets `list_axes` → -`list_blueprint_axes(doc)` → `blueprint_axis_probe(doc)` wraps the reloaded -signal in stage1-r scaffolding (stop bound, reduce, no cost) → -`.param_space()` → print each `name:kind` in order → exit 0. - -## Error handling - -- **Malformed / unreadable blueprint** → the `["sweep", ..]` dispatch reads the - `.json` path and runs `blueprint_from_json` validation *before* arg parsing - (main.rs 4098-4111), so a malformed doc exits 2 with a named `aura: : - ` (empirically today: `unknown_node.json` → `aura: …: UnknownNodeType("Nope")`, - exit 2, no panic) *before* `list_blueprint_axes` — hence `blueprint_axis_probe`'s - `.expect` is unreachable on this path. That guard is currently pinned only on the - `run` verb (`aura_run_rejects_an_unknown_node_blueprint`), **not** the `sweep` - verb; **this cycle delivers the E2E that pins it on the `sweep --list-axes` path** - (reusing `unknown_node.json`), closing the gap the new `.expect` would otherwise - leave untested. No new error path is added — the fix is the missing *test*. -- **`--list-axes` combined with any other flag** → `parse_sweep_blueprint_args` - returns `Err("--list-axes lists axes and takes no other flags")`, rendered - `aura: ` + exit 2. -- **Closed blueprint (0 open knobs)** → empty stdout, exit 0. Not an error. -- No panics on any user-reachable input; the probe reload is infallible only - because the doc was validated at the boundary (same contract as the sweep). - -## Testing strategy - -- **Unit — probe names** (`cargo test -p aura-cli --bin aura - blueprint_axis_probe`): load `stage1_signal_open.json`, assert - `blueprint_axis_probe(doc).param_space()` names == `["stage1_signal.fast.length", - "stage1_signal.slow.length"]` with kinds `[I64, I64]`; load `stage1_signal.json`, - assert the space is empty. This pins the prefixed-name fact (grounded by the - existing `param_space_is_flat_path_qualified_and_slot_disambiguated` / - `param_space_reflects_only_open_knobs` / - `param_space_empty_for_paramless_and_empty_blueprints` in - `crates/aura-engine/src/blueprint.rs`). -- **Unit — arg grammar** (`cargo test -p aura-cli --bin aura - parse_sweep_blueprint_args`): `["--list-axes"]` → `list_axes: true`; - `["--list-axes","--axis","stage1_signal.fast.length=2,3"]` → `Err`. -- **E2E** (`cargo test -p aura-cli --test cli_run `, where the existing - blueprint-sweep E2Es live): `aura sweep --list-axes` → stdout - exactly the two lines, exit 0; `aura sweep --list-axes` → - empty stdout, exit 0; `aura sweep --list-axes --axis - stage1_signal.fast.length=2,3` → exit 2 + `--list-axes lists axes` on stderr. -- **E2E — malformed-blueprint safety** (same `--test cli_run`): `aura sweep - crates/aura-cli/tests/fixtures/unknown_node.json --list-axes` → exit 2 + a named - `aura: …: UnknownNodeType(…)` on stderr + **no panic** (`stderr` free of - `panicked`). This pins the dispatch-boundary guard on the `sweep --list-axes` - path, so `blueprint_axis_probe`'s `.expect` is provably unreachable — the gap - the grounding-check flagged (the guard is otherwise pinned only on the `run` - verb). - -## Acceptance criteria - -1. `aura sweep --list-axes` prints one `:` line per - open sweepable knob, in `param_space()` order, exit 0 — and each printed name - is byte-for-byte accepted by `--axis` on the same verb (`stage1_signal.fast.length`, - not `fast.length`). -2. A closed blueprint prints nothing and exits 0. -3. `--list-axes` combined with any other flag is a usage error (exit 2). -4. A malformed/unreadable blueprint on the `--list-axes` path is rejected with a - named `aura:` error and exit 2, never a panic — pinned by a new `sweep - --list-axes` E2E over `unknown_node.json` (closing the gap that the guard was - pinned only on the `run` verb). -5. No `aura-engine` / `aura-registry` change; invariants 1, 8, 9 preserved. -6. `cargo build --workspace`, `cargo test --workspace`, and `cargo clippy - --workspace --all-targets -- -D warnings` are green.