diff --git a/docs/plans/0030-named-param-binding-single-run.md b/docs/plans/0030-named-param-binding-single-run.md new file mode 100644 index 0000000..3ea3be3 --- /dev/null +++ b/docs/plans/0030-named-param-binding-single-run.md @@ -0,0 +1,385 @@ +# 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::with` → +`Binder` → `Binder::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` 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`): + +```rust +/// 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`. + pub fn with(mut self, name: &str, v: impl Into) -> Binder { + self.bound.push((name.to_string(), v.into())); + self + } + + /// Resolve the accumulated bindings against `param_space()` and bootstrap. + pub fn bootstrap(self) -> Result { + 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` in `param_space()` slot +/// order. (Body filled in Step 3.) +fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result, 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): + +```rust + /// 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) -> Binder { + Binder { bp: self, bound: vec![(name.to_string(), v.into())] } + } +``` + +In `crates/aura-engine/src/lib.rs`, line 41 currently reads: + +```rust +pub use blueprint::{aliases_on, signature_of, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role}; +``` + +Replace it with (adds `BindError, Binder`): + +```rust +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): + +```rust + #[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::>(); + let named_ex = comp_ex.try_iter().collect::>(); + + 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::>(); + let pos_ex_v = pos_ex.try_iter().collect::>(); + + 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: + +```rust +fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result, BindError> { + // Phase 1 — per binding in input order: claim slots by exact name (a, b, d). + let mut claimed: Vec> = vec![None; space.len()]; + for (name, value) in bound { + 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 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: + +```rust + 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): + +```rust + 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.