plan: 0030 named param binding — sweep axes (iteration 2)

Iteration-2 plan for spec 0030 (the sweep side): two tasks.
- Task 1 (aura-engine): resolve_axes (shares resolve's name->slot mapping; adds the
  EmptyAxis check and a per-element axis kind-check, a superset of GridSpace::new so
  the downstream .expect() is infallible), Composite::axis / SweepBinder /
  SweepBinder::sweep, the SweepBinder re-export, and the engine tests (round-trip,
  EmptyAxis, MissingKnob, per-element KindMismatch on a mixed axis, a builder
  no-panic wrong-kind test, and a GridSpace .points() parity test). RED-first via a
  todo!() resolve_axes stub. EmptyAxis is now constructed (was defined in iter 1).
- Task 2 (aura-cli): convert sweep_family() grid construction to .axis(...).sweep(...)
  (keeping the per-point closure and the local `space` it reads for manifest params);
  drop the now-orphaned GridSpace import (the clippy -D warnings trap this iteration).

The sweep JSON param goldens (main.rs + tests/cli_run.rs) are name/value/order-
preserving and stay green unchanged. Engine core untouched (C1/C12/C19/C23).

refs #35
This commit is contained in:
2026-06-11 00:24:12 +02:00
parent 64b19ab3d5
commit abd76c9756
@@ -0,0 +1,425 @@
# Named param binding — Iteration 2 (sweep axes) — Implementation Plan
> **Parent spec:** `docs/specs/0030-named-param-binding.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
> this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Add the sweep-axes named-binding layer — `Composite::axis`
`SweepBinder``SweepBinder::sweep`, backed by `resolve_axes` (per-element
kind-checked, a superset of `GridSpace::new`) — and convert the CLI sample sweep
to it.
**Architecture:** A pure authoring layer in `aura-engine` over the existing
`GridSpace::new` / `sweep` primitives (engine core untouched, C1/C12/C19/C23).
`resolve_axes` shares `resolve`'s name→slot mapping (iter 1), adds the `EmptyAxis`
check and a per-element axis kind-check; because its validation is a superset of
`GridSpace::new`'s, the downstream `GridSpace::new(...).expect(...)` is infallible.
The single-run side (iter 1) is done and committed (64b19ab).
**Tech Stack:** Rust; `crates/aura-engine/src/blueprint.rs` (new items + tests),
`crates/aura-engine/src/lib.rs` (re-export), `crates/aura-cli/src/main.rs` (sweep
conversion + drop the orphaned `GridSpace` import).
---
**Files this plan creates or modifies:**
- Modify: `crates/aura-engine/src/blueprint.rs` — add free `fn resolve_axes`
(adjacent to `fn resolve` ~:324-363), `Composite::axis`, `struct SweepBinder` +
`impl SweepBinder` (adjacent to `Binder` ~:302-320), a new `use` for the sweep
primitives, and iter-2 tests in `#[cfg(test)] mod tests`.
- Modify: `crates/aura-engine/src/lib.rs:40-43` — add `SweepBinder` to the
`pub use blueprint::{…}` re-export (`BindError, Binder` already present).
- Modify: `crates/aura-cli/src/main.rs:226-266` — convert `sweep_family()`'s grid
construction to `.axis(…).sweep(…)`; drop the now-unused `GridSpace` from the
import at `:13`.
- Test: `crates/aura-engine/src/blueprint.rs` (test module) — `resolve_axes`
round-trip/parity, `EmptyAxis`, `MissingKnob` (sweep), per-element
`KindMismatch` (resolve-direct, mixed axis), a builder no-panic wrong-kind test,
and a GridSpace `.points()` parity test.
- Unchanged (content-pin twins — stay green, no edit): `crates/aura-cli/src/main.rs:541-544`
and `crates/aura-cli/tests/cli_run.rs:214` (sweep JSON param goldens — the
conversion preserves names/values/order).
---
## Task 1: Engine — `resolve_axes`, `Composite::axis`, `SweepBinder`
**Files:**
- Modify: `crates/aura-engine/src/blueprint.rs` (new items near the iter-1
`Binder`/`resolve` block ~:300-363; new `use`; tests)
- Modify: `crates/aura-engine/src/lib.rs:40-43`
- [ ] **Step 1: Add the skeleton (axis, SweepBinder, stubbed `resolve_axes`) + imports + re-export**
In `crates/aura-engine/src/blueprint.rs`, add a `use` for the sweep primitives
near the existing `use crate::harness::{…};` (~line 21). Use the collision-free
forms (`sweep` is both a module and a re-exported fn at the crate root):
```rust
use crate::sweep::sweep;
use crate::{GridSpace, RunReport, SweepFamily};
```
Add `Composite::axis` inside an `impl Composite` block (e.g. just after
`Composite::with`, ~line 274):
```rust
/// Begin binding this blueprint's knobs **by name** as sweep axes (the fluent
/// authoring alternative to a positional `GridSpace`). The bound name is the
/// exact `param_space()` name (path-qualified for a composite-interior knob,
/// bare for a root-level knob).
pub fn axis(self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
let axis = vals.into_iter().map(Into::into).collect();
SweepBinder { bp: self, axes: vec![(name.to_string(), axis)] }
}
```
Add the `SweepBinder` type + impl adjacent to `Binder` (~after line 320), with a
stubbed `resolve_axes` (filled in Step 3):
```rust
/// A fluent accumulator of named sweep axes, terminated by [`SweepBinder::sweep`].
/// Axes are resolved against `param_space()` once, at the terminal; resolution is a
/// superset of `GridSpace::new`'s checks, so the grid it builds cannot fail.
pub struct SweepBinder {
bp: Composite,
axes: Vec<(String, Vec<Scalar>)>,
}
impl SweepBinder {
/// Bind one more sweep axis by name; raw literals lower via `Into<Scalar>`.
pub fn axis(mut self, name: &str, vals: impl IntoIterator<Item = impl Into<Scalar>>) -> SweepBinder {
self.axes.push((name.to_string(), vals.into_iter().map(Into::into).collect()));
self
}
/// Resolve the named axes against `param_space()` into a positional grid and
/// run the disjoint sweep.
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
where
F: Fn(&[Scalar]) -> RunReport + Sync,
{
let space = self.bp.param_space();
let ordered = resolve_axes(&space, &self.axes)?;
let grid = GridSpace::new(&space, ordered)
.expect("named layer pre-validates arity/kind/non-empty");
Ok(sweep(&grid, run_one))
}
}
/// Resolve named sweep axes to a positional `Vec<Vec<Scalar>>` in `param_space()`
/// slot order. (Body filled in Step 3.)
fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<Vec<Vec<Scalar>>, BindError> {
let _ = (space, axes);
todo!("resolve_axes")
}
```
In `crates/aura-engine/src/lib.rs`, the re-export block at lines 40-43 currently
reads:
```rust
pub use blueprint::{
aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField,
ParamAlias, Role,
};
```
Add `SweepBinder` (alphabetical, after `Role`):
```rust
pub use blueprint::{
aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField,
ParamAlias, Role, SweepBinder,
};
```
- [ ] **Step 2: Add all Task-1 tests, run, verify RED**
In the `#[cfg(test)] mod tests` of `crates/aura-engine/src/blueprint.rs`, add an
explicit import for the grid primitives the parity test needs (the test module
does not import sweep symbols today), placed with the module's other `use`s
(~:831-834):
```rust
use crate::GridSpace;
```
Then add these tests:
```rust
#[test]
fn resolve_axes_named_equals_positional() {
// named axes resolve to the positional Vec<Vec<Scalar>> in slot order,
// order-independent on the binding side
let space = vec![
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
];
let axes = vec![
("scale".to_string(), vec![Scalar::F64(0.5)]),
("sma_cross.fast".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]),
("sma_cross.slow".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]),
];
assert_eq!(
resolve_axes(&space, &axes),
Ok(vec![
vec![Scalar::I64(2), Scalar::I64(3)],
vec![Scalar::I64(4), Scalar::I64(5)],
vec![Scalar::F64(0.5)],
]),
);
}
#[test]
fn resolve_axes_empty_axis() {
let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
assert_eq!(
resolve_axes(&space, &[("a".to_string(), vec![])]),
Err(BindError::EmptyAxis("a".to_string())),
);
}
#[test]
fn resolve_axes_missing_knob() {
let space = vec![
ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
];
assert_eq!(
resolve_axes(&space, &[("a".to_string(), vec![Scalar::I64(1)])]),
Err(BindError::MissingKnob("b".to_string())),
);
}
#[test]
fn resolve_axes_per_element_kind_mismatch() {
// a MIXED-kind axis: the second element mismatches; per-element check must
// catch it (a first-element-only check would pass it through to a panic).
let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
let axes = vec![("scale".to_string(), vec![Scalar::F64(0.5), Scalar::I64(1)])];
assert_eq!(
resolve_axes(&space, &axes),
Err(BindError::KindMismatch {
knob: "scale".to_string(),
expected: ScalarKind::F64,
got: ScalarKind::I64,
}),
);
}
#[test]
fn named_sweep_rejects_wrong_kind_axis_without_panic() {
// builder path: an F64 axis bound to an I64 slot is rejected as a clean
// BindError, never reaching GridSpace::new's .expect() — the run closure
// must not be invoked.
let (bp, _eq, _ex) = composite_sma_cross_harness();
let result = bp
.axis("sma_cross.fast", [2.0, 3.0]) // F64 values for the I64 slot
.axis("sma_cross.slow", [4])
.axis("scale", [0.5])
.sweep(|_: &[Scalar]| -> RunReport { panic!("axis pre-validation must reject before running") });
assert_eq!(
result,
Err(BindError::KindMismatch {
knob: "sma_cross.fast".to_string(),
expected: ScalarKind::I64,
got: ScalarKind::F64,
}),
);
}
#[test]
fn named_axes_grid_parity_with_positional() {
// named axes enumerate the SAME GridSpace points (same order) as the
// positional grid over the sample param-space.
let space = composite_sma_cross_harness().0.param_space();
let named = resolve_axes(
&space,
&[
("sma_cross.fast".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]),
("sma_cross.slow".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]),
("scale".to_string(), vec![Scalar::F64(0.5)]),
],
)
.expect("named axes resolve");
let positional = vec![
vec![Scalar::I64(2), Scalar::I64(3)],
vec![Scalar::I64(4), Scalar::I64(5)],
vec![Scalar::F64(0.5)],
];
let named_pts = GridSpace::new(&space, named).expect("named grid").points();
let pos_pts = GridSpace::new(&space, positional).expect("positional grid").points();
assert_eq!(named_pts, pos_pts, "named and positional grids must enumerate identically");
}
```
Run: `cargo test -p aura-engine resolve_axes_ named_sweep_rejects_wrong_kind_axis_without_panic named_axes_grid_parity_with_positional 2>&1 | tail -25`
Expected: FAIL — every new test panics with `not yet implemented: resolve_axes`
(the `todo!("resolve_axes")` stub; the builder/parity tests reach it via
`SweepBinder::sweep` / direct call).
- [ ] **Step 3: Implement `resolve_axes`, run, verify GREEN**
In `crates/aura-engine/src/blueprint.rs`, replace the `resolve_axes` stub body
from Step 1 with:
```rust
fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<Vec<Vec<Scalar>>, BindError> {
// Phase 1 — per axis in input order: claim slots by exact name (a, b, c, d).
let mut claimed: Vec<Option<Vec<Scalar>>> = vec![None; space.len()];
for (name, values) in axes {
let matches: Vec<usize> = space
.iter()
.enumerate()
.filter(|(_, p)| p.name == *name)
.map(|(i, _)| i)
.collect();
match matches.as_slice() {
[] => return Err(BindError::UnknownKnob(name.clone())), // a
[idx] => {
if values.is_empty() {
return Err(BindError::EmptyAxis(name.clone())); // c
}
if claimed[*idx].is_some() {
return Err(BindError::DuplicateBinding(name.clone())); // d
}
claimed[*idx] = Some(values.clone());
}
_ => return Err(BindError::AmbiguousKnob(name.clone())), // b
}
}
// Phase 2 — slot walk in param_space() order: completeness (e) + per-element kind (f).
let mut ordered = Vec::with_capacity(space.len());
for (i, p) in space.iter().enumerate() {
match &claimed[i] {
None => return Err(BindError::MissingKnob(p.name.clone())), // e
Some(values) => {
for v in values {
if v.kind() != p.kind {
return Err(BindError::KindMismatch {
knob: p.name.clone(),
expected: p.kind,
got: v.kind(),
}); // f — first offending element in axis order
}
}
ordered.push(values.clone());
}
}
}
Ok(ordered)
}
```
Run: `cargo test -p aura-engine resolve_axes_ named_sweep_rejects_wrong_kind_axis_without_panic named_axes_grid_parity_with_positional 2>&1 | tail -25`
Expected: PASS — all six new tests green.
- [ ] **Step 4: Full workspace gate**
Run: `cargo build --workspace 2>&1 | tail -5`
Expected: success.
Run: `cargo test --workspace 2>&1 | tail -15`
Expected: all suites PASS (six new + all pre-existing).
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean. (`resolve_axes` is called by `SweepBinder::sweep`;
`axis`/`SweepBinder`/`sweep` are `pub`-re-exported; the previously-unconstructed
`BindError::EmptyAxis` is now constructed in `resolve_axes`, so no `dead_code`.)
## Task 2: CLI — convert the sample sweep to `.axis(…).sweep(…)`
**Files:**
- Modify: `crates/aura-cli/src/main.rs:226-266` (`sweep_family`) and `:13` (import)
- [ ] **Step 1: Convert `sweep_family()` grid construction to named axes**
In `crates/aura-cli/src/main.rs`, replace the body of `sweep_family()` (lines
227-265 — the `let space = …` + `GridSpace::new(…).expect(…)` + `sweep(&grid, …)`)
with the named form. Keep the per-point closure body **verbatim** (it still reads
`space` for the manifest params); only the grid construction and the `sweep(&grid,
…)` wrapper change:
```rust
fn sweep_family() -> SweepFamily {
let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space();
bp.axis("sma_cross.fast", [2, 3])
.axis("sma_cross.slow", [4, 5])
.axis("scale", [0.5])
.sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("grid points are kind-checked against param_space");
let prices = synthetic_prices();
let window = (
prices.first().expect("non-empty stream").0,
prices.last().expect("non-empty stream").0,
);
h.run(vec![prices]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
let params = space
.iter()
.zip(point)
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
.collect();
RunReport {
manifest: RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
window,
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
},
metrics: summarize(&equity, &exposure),
}
})
.expect("the built-in named grid matches the sample param-space")
}
```
Then remove the now-orphaned `GridSpace` from the import block at line 13. The
block currently reads:
```rust
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
```
Remove `GridSpace, ` (it is the only symbol the conversion orphans — `sweep`,
`SweepFamily`, `RunReport`, `RunManifest`, `summarize`, `f64_field` all remain
used; `Scalar` is used elsewhere and is imported separately). Result:
```rust
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
```
- [ ] **Step 2: Run the sweep goldens (both roots), verify GREEN**
Run: `cargo test -p aura-cli sweep_report_renders_four_points_in_odometer_order 2>&1 | tail -8`
Expected: PASS — the unit golden (`main.rs:541-544`) is unchanged; the named axes
produce the same `"params":{"sma_cross.fast":N,"sma_cross.slow":M,"scale":0.5}`
lines in odometer order.
Run: `cargo test -p aura-cli --test cli_run sweep_prints_four_json_lines_and_exits_zero 2>&1 | tail -8`
Expected: PASS — the integration golden (`tests/cli_run.rs:214`) is unchanged
(the binary's first line still `"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}`).
- [ ] **Step 3: Full workspace gate**
Run: `cargo test --workspace 2>&1 | tail -15`
Expected: all suites PASS.
Run: `cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10`
Expected: clean (the `GridSpace` import was dropped; no unused-import warning).