audit(0096): cycle close — axis discovery over a loaded blueprint; ledger refreshed (C24); rm ephemeral spec/plan

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
This commit is contained in:
2026-07-01 14:59:36 +02:00
parent bd2003e217
commit 485f3c8c89
3 changed files with 11 additions and 651 deletions
+11 -2
View File
@@ -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 <blueprint.json> --list-axes` lists a loaded blueprint's open
sweepable knobs (one `<name>:<kind>` 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
@@ -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 <blueprint.json> --list-axes` — a read-only query
sub-mode that lists a loaded blueprint's open sweepable knobs (one
`<name>:<kind>` 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<Scalar>)>,
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 <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
}
}
```
- [ ] **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. ✓
@@ -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 <blueprint.json>
--list-axes` prints one `<name>:<kind>` 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 <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).
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<Scalar>)>, 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 <bp.json> --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: <path>:
<e>` (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: <msg>` + 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 <filter>`, where the existing
blueprint-sweep E2Es live): `aura sweep <open fixture> --list-axes` → stdout
exactly the two lines, exit 0; `aura sweep <closed fixture> --list-axes` →
empty stdout, exit 0; `aura sweep <open fixture> --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 <blueprint.json> --list-axes` prints one `<name>:<kind>` 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.