Files
Aura/docs/plans/0030-named-param-binding-single-run.md
T
Brummel 7cd990f789 plan: 0030 named param binding — single-run (iteration 1)
Iteration-1 plan for spec 0030 (the single-run side): two tasks.
- Task 1 (aura-engine): BindError vocabulary, the shared `resolve` core
  (two-phase total error order), Composite::with / Binder / Binder::bootstrap,
  the lib.rs re-export, and the engine tests (resolve round-trip, one per
  single-run BindError variant, two precedence tests, the C1 named≡positional
  equivalence test). RED-first via a todo!() resolve stub, GREEN on impl.
- Task 2 (aura-cli): convert the sample single-run test to .with(...).bootstrap()
  using the exact param_space() names (sma_cross.fast/sma_cross.slow/scale).

The full BindError enum (incl. EmptyAxis) is defined now and re-exported, so the
unconstructed sweep-only variant is reachable API (not dead_code) — EmptyAxis is
constructed in iteration 2. Engine core untouched (C1/C12/C19/C23).

refs #35
2026-06-11 00:09:29 +02:00

15 KiB

Named param binding — Iteration 1 (single run) — 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 single-run named-binding authoring layer — Composite::withBinderBinder::bootstrap, backed by the shared resolve core and the BindError vocabulary — and convert the CLI sample single-run to it.

Architecture: A pure authoring layer in aura-engine over the existing Composite::param_space / bootstrap_with_params primitives (engine core untouched, C1/C12/C19/C23 preserved). resolve maps named bindings to a positional Vec<Scalar> against param_space() using a total error order; Binder is the fluent accumulator. The sweep side (axis/SweepBinder/ resolve_axes/EmptyAxis construction) is iteration 2 and out of scope here.

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 (sample conversion).


Files this plan creates or modifies:

  • Modify: crates/aura-engine/src/blueprint.rs — add enum BindError, struct Binder, impl Binder, free fn resolve, and Composite::with; add unit + equivalence tests in the #[cfg(test)] mod tests.
  • Modify: crates/aura-engine/src/lib.rs:41 — add BindError, Binder to the pub use blueprint::{…} re-export.
  • Test: crates/aura-engine/src/blueprint.rs (test module) — resolve round-trip, one assertion per single-run BindError variant, two precedence tests, one C1 named≡positional equivalence test.
  • Modify: crates/aura-cli/src/main.rs:514-525 — convert the sample_blueprint_with_sinks_bootstraps_runs_and_drains test to .with(…).bootstrap().

Task 1: Engine — BindError, resolve, Composite::with, Binder

Files:

  • Modify: crates/aura-engine/src/blueprint.rs (new top-level items near CompileError ~271; Composite::with in impl Composite ~144-267; tests in #[cfg(test)] mod tests)

  • Modify: crates/aura-engine/src/lib.rs:41

  • Step 1: Add the skeleton (enum, builder, stubbed resolve) + re-export

In crates/aura-engine/src/blueprint.rs, add these top-level items (place them just above the CompileError definition, ~line 270; ScalarKind, Scalar, ParamSpec are already imported at the top, Harness via crate::harness):

/// A fault in resolving a named binding against `param_space()` — the authoring
/// layer over `bootstrap_with_params` / `sweep`. Name-qualified: each message
/// names the offending knob, not a slot index. The total error order is documented
/// on the resolution path; the first failing check wins.
#[derive(Debug, PartialEq)]
pub enum BindError {
    /// A bound name matches no `param_space()` slot's exact name.
    UnknownKnob(String),
    /// A `param_space()` slot was left unbound.
    MissingKnob(String),
    /// A bound value's kind does not equal the slot's declared kind.
    KindMismatch { knob: String, expected: ScalarKind, got: ScalarKind },
    /// The same name was bound twice.
    DuplicateBinding(String),
    /// A name matches the exact name of more than one slot.
    AmbiguousKnob(String),
    /// (sweep, iteration 2) An axis was given zero values.
    EmptyAxis(String),
    /// The resolved point passed name resolution but failed downstream bootstrap.
    Compile(CompileError),
}

/// A fluent accumulator of named knob bindings for a single run, terminated by
/// [`Binder::bootstrap`]. Bindings are resolved against `param_space()` once, at
/// the terminal.
pub struct Binder {
    bp: Composite,
    bound: Vec<(String, Scalar)>,
}

impl Binder {
    /// Bind one more knob by name; raw literals lower via `Into<Scalar>`.
    pub fn with(mut self, name: &str, v: impl Into<Scalar>) -> Binder {
        self.bound.push((name.to_string(), v.into()));
        self
    }

    /// Resolve the accumulated bindings against `param_space()` and bootstrap.
    pub fn bootstrap(self) -> Result<Harness, BindError> {
        let space = self.bp.param_space();
        let point = resolve(&space, &self.bound)?;
        self.bp.bootstrap_with_params(point).map_err(BindError::Compile)
    }
}

/// Resolve named bindings to a positional `Vec<Scalar>` in `param_space()` slot
/// order. (Body filled in Step 3.)
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
    let _ = (space, bound);
    todo!("resolve")
}

Then add Composite::with inside the existing impl Composite block (anywhere among its methods, e.g. just after bootstrap ~line 267):

    /// Begin binding this blueprint's knobs **by name** for a single run (the
    /// fluent alternative to a positional `bootstrap_with_params` vector). The
    /// bound name is the exact `param_space()` name — path-qualified for a knob
    /// inside a composite (`sma_cross.fast`), bare for a root-level knob (`scale`).
    pub fn with(self, name: &str, v: impl Into<Scalar>) -> Binder {
        Binder { bp: self, bound: vec![(name.to_string(), v.into())] }
    }

In crates/aura-engine/src/lib.rs, line 41 currently reads:

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

Replace it with (adds BindError, Binder):

pub use blueprint::{aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role};
  • Step 2: Add all Task-1 tests, run, verify RED

In the #[cfg(test)] mod tests of crates/aura-engine/src/blueprint.rs, add (the module already has use super::*;, and synthetic_prices + composite_sma_cross_harness fixtures):

    #[test]
    fn resolve_named_equals_positional_vector() {
        // order-independent: shuffled bindings resolve to slot order
        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 bound = vec![
            ("scale".to_string(), Scalar::F64(0.5)),
            ("sma_cross.fast".to_string(), Scalar::I64(2)),
            ("sma_cross.slow".to_string(), Scalar::I64(4)),
        ];
        assert_eq!(
            resolve(&space, &bound),
            Ok(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]),
        );
    }

    #[test]
    fn resolve_unknown_knob() {
        let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
        assert_eq!(
            resolve(&space, &[("nope".to_string(), Scalar::F64(0.5))]),
            Err(BindError::UnknownKnob("nope".to_string())),
        );
    }

    #[test]
    fn resolve_missing_knob() {
        let space = vec![
            ParamSpec { name: "a".into(), kind: ScalarKind::I64 },
            ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
        ];
        assert_eq!(
            resolve(&space, &[("a".to_string(), Scalar::I64(1))]),
            Err(BindError::MissingKnob("b".to_string())),
        );
    }

    #[test]
    fn resolve_kind_mismatch() {
        let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
        assert_eq!(
            resolve(&space, &[("scale".to_string(), Scalar::I64(2))]),
            Err(BindError::KindMismatch {
                knob: "scale".to_string(),
                expected: ScalarKind::F64,
                got: ScalarKind::I64,
            }),
        );
    }

    #[test]
    fn resolve_duplicate_binding() {
        let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
        assert_eq!(
            resolve(
                &space,
                &[("a".to_string(), Scalar::I64(1)), ("a".to_string(), Scalar::I64(2))],
            ),
            Err(BindError::DuplicateBinding("a".to_string())),
        );
    }

    #[test]
    fn resolve_ambiguous_knob() {
        // two slots with the identical exact name
        let space = vec![
            ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
            ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
        ];
        assert_eq!(
            resolve(&space, &[("dup".to_string(), Scalar::I64(1))]),
            Err(BindError::AmbiguousKnob("dup".to_string())),
        );
    }

    #[test]
    fn resolve_precedence_unknown_before_kind_mismatch() {
        // Phase-1 (unknown) wins over Phase-2 (kind mismatch)
        let space = vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }];
        let bound = vec![
            ("typo".to_string(), Scalar::I64(9)),
            ("scale".to_string(), Scalar::I64(2)),
        ];
        assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
    }

    #[test]
    fn resolve_precedence_unknown_before_duplicate() {
        // intra-binding: check a (unknown) precedes check d (duplicate)
        let space = vec![ParamSpec { name: "a".into(), kind: ScalarKind::I64 }];
        let bound = vec![
            ("typo".to_string(), Scalar::I64(1)),
            ("typo".to_string(), Scalar::I64(2)),
        ];
        assert_eq!(resolve(&space, &bound), Err(BindError::UnknownKnob("typo".to_string())));
    }

    #[test]
    fn named_binder_runs_bit_identical_to_positional() {
        // C1 equivalence: the named builder bootstraps to a run bit-identical to
        // the positional vector, over the sample composite harness.
        let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
        let mut named = bp
            .with("sma_cross.fast", 2)
            .with("sma_cross.slow", 4)
            .with("scale", 0.5)
            .bootstrap()
            .expect("named binding resolves and bootstraps");
        named.run(vec![synthetic_prices()]);
        let named_eq = comp_eq.try_iter().collect::<Vec<_>>();
        let named_ex = comp_ex.try_iter().collect::<Vec<_>>();

        let (bp2, pos_eq, pos_ex) = composite_sma_cross_harness();
        let mut positional = bp2
            .bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
            .expect("positional bootstrap");
        positional.run(vec![synthetic_prices()]);
        let pos_eq_v = pos_eq.try_iter().collect::<Vec<_>>();
        let pos_ex_v = pos_ex.try_iter().collect::<Vec<_>>();

        assert!(!named_eq.is_empty(), "named run drained empty");
        assert_eq!(named_eq, pos_eq_v, "equity stream: named must equal positional");
        assert_eq!(named_ex, pos_ex_v, "exposure stream: named must equal positional");
    }

Run: cargo test -p aura-engine resolve_ named_binder_runs_bit_identical_to_positional 2>&1 | tail -20 (filters match the nine new tests by their resolve_* prefix + the equivalence test name; all currently call resolve via the todo!() stub). Expected: FAIL — every new test panics with not yet implemented: resolve (the todo!("resolve") stub).

  • Step 3: Implement resolve (total error order), run, verify GREEN

In crates/aura-engine/src/blueprint.rs, replace the resolve stub body from Step 1 with:

fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
    // Phase 1 — per binding in input order: claim slots by exact name (a, b, d).
    let mut claimed: Vec<Option<Scalar>> = vec![None; space.len()];
    for (name, value) in bound {
        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 claimed[*idx].is_some() {
                    return Err(BindError::DuplicateBinding(name.clone())); // d
                }
                claimed[*idx] = Some(*value);
            }
            _ => return Err(BindError::AmbiguousKnob(name.clone())), // b
        }
    }
    // Phase 2 — slot walk in param_space() order (e, f).
    let mut point = 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(v) => {
                if v.kind() != p.kind {
                    return Err(BindError::KindMismatch {
                        knob: p.name.clone(),
                        expected: p.kind,
                        got: v.kind(),
                    }); // f
                }
                point.push(v);
            }
        }
    }
    Ok(point)
}

Run: cargo test -p aura-engine resolve_ named_binder_runs_bit_identical_to_positional 2>&1 | tail -20 Expected: PASS — all nine new tests green.

  • Step 4: Full workspace gate

Run: cargo build --workspace 2>&1 | tail -5 Expected: success, no errors.

Run: cargo test --workspace 2>&1 | tail -15 Expected: all suites PASS (the nine new + all pre-existing).

Run: cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -10 Expected: clean — no warnings. (resolve is used by Binder::bootstrap; BindError/Binder/with are pub-re-exported so the unconstructed EmptyAxis variant is reachable API, not dead_code.)

Task 2: CLI — convert the sample single-run to .with(…).bootstrap()

Files:

  • Modify: crates/aura-cli/src/main.rs:514-525 (test sample_blueprint_with_sinks_bootstraps_runs_and_drains)

  • Step 1: Convert the bootstrap call

In crates/aura-cli/src/main.rs, in the test sample_blueprint_with_sinks_bootstraps_runs_and_drains, replace:

        let mut h = bp
            .bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
            .expect("sample blueprint compiles under a valid point");

with (the exact param_space() names: composite-interior knobs path-qualified, the root scale bare):

        let mut h = bp
            .with("sma_cross.fast", 2)
            .with("sma_cross.slow", 4)
            .with("scale", 0.5)
            .bootstrap()
            .expect("sample blueprint compiles under a valid point");

(with/bootstrap are methods on the already-imported Composite / the re-exported Binder; no new use is required. If the compiler reports Scalar is now unused in this test, leave it — it is still used elsewhere in the test module; do not remove the import.)

  • Step 2: Run the converted test, verify GREEN (behaviour-preserving)

Run: cargo test -p aura-cli sample_blueprint_with_sinks_bootstraps_runs_and_drains 2>&1 | tail -10 Expected: PASS — 1 passed (the named form bootstraps and drains both sinks exactly as the positional form did).

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