Files
Aura/docs/plans/0030-named-param-binding-sweep.md
T
Brummel abd76c9756 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
2026-06-11 00:24:12 +02:00

17 KiB

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::axisSweepBinderSweepBinder::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):

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):

    /// 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):

/// 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:

pub use blueprint::{
    aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField,
    ParamAlias, Role,
};

Add SweepBinder (alphabetical, after Role):

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 uses (~:831-834):

    use crate::GridSpace;

Then add these tests:

    #[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:

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:

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:

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:

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).