From abd76c97564776a3e18dc52738728cc187993522 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 11 Jun 2026 00:24:12 +0200 Subject: [PATCH] =?UTF-8?q?plan:=200030=20named=20param=20binding=20?= =?UTF-8?q?=E2=80=94=20sweep=20axes=20(iteration=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/plans/0030-named-param-binding-sweep.md | 425 +++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 docs/plans/0030-named-param-binding-sweep.md diff --git a/docs/plans/0030-named-param-binding-sweep.md b/docs/plans/0030-named-param-binding-sweep.md new file mode 100644 index 0000000..c160bd9 --- /dev/null +++ b/docs/plans/0030-named-param-binding-sweep.md @@ -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>) -> 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)>, +} + +impl SweepBinder { + /// Bind one more sweep axis by name; raw literals lower via `Into`. + pub fn axis(mut self, name: &str, vals: impl IntoIterator>) -> 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(self, run_one: F) -> Result + 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>` in `param_space()` +/// slot order. (Body filled in Step 3.) +fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec)]) -> Result>, 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> 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)]) -> Result>, BindError> { + // Phase 1 — per axis in input order: claim slots by exact name (a, b, c, d). + let mut claimed: Vec>> = vec![None; space.len()]; + for (name, values) in axes { + let matches: Vec = 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::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 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).