From 64b19ab3d5aff3c85045d67765ffeb2869c3480d Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 11 Jun 2026 00:15:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-engine,aura-cli):=20named=20param=20b?= =?UTF-8?q?inding=20=E2=80=94=20single-run=20.with()/.bootstrap()=20(0030?= =?UTF-8?q?=20iter=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind a blueprint's open knobs by name for a single run instead of by a positional, Scalar-wrapped Vec in param_space() order: bp.with("sma_cross.fast", 2).with("sma_cross.slow", 4).with("scale", 0.5).bootstrap() A pure authoring layer over the existing Composite::param_space / bootstrap_with_params primitives; the engine run loop and the construction core are untouched (C1/C12/C19/C23 preserved). Raw literals lower via Into (the literal fixes the variant: 2->I64, 0.5->F64, pinned by e97906a); the match key is the exact param_space() name — path-qualified for a composite-interior knob (sma_cross.fast), bare for a root-level knob (scale). This iteration (single-run side): - BindError vocabulary (name-qualified); the full enum incl. the sweep-only EmptyAxis is defined and re-exported now, so the unconstructed variant is reachable public API, not dead_code — EmptyAxis is constructed in iteration 2 (the sweep side). - resolve(): the shared name-resolution core, a total error order — Phase 1 per binding in input order (a UnknownKnob / b AmbiguousKnob / d DuplicateBinding), Phase 2 slot walk in param_space() order (e MissingKnob / f KindMismatch); first failing check wins. - Composite::with -> Binder -> Binder::bootstrap; .with() not .bind() (bind is reserved for #55's structural-constant overlay). - The CLI sample single-run test converted to the named form (behaviour-preserving). Verified (run by the orchestrator, not the agent's claim): 9 new aura-engine tests green — resolve round-trip + one per single-run BindError variant + two precedence tests + the C1 named-equals-positional equivalence over the sample composite harness; the converted CLI sample test green; full `cargo test --workspace` green and `cargo clippy --workspace --all-targets -- -D warnings` clean. Iteration 2 (the sweep side: axis / SweepBinder / resolve_axes / EmptyAxis + the sweep CLI conversion) is next. refs #35 --- crates/aura-cli/src/main.rs | 5 +- crates/aura-engine/src/blueprint.rs | 224 ++++++++++++++++++++++++++++ crates/aura-engine/src/lib.rs | 3 +- 3 files changed, 230 insertions(+), 2 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 5175e2a..5335805 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -517,7 +517,10 @@ mod tests { // so a caller can bootstrap one point, run it, and drain both sinks. let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(); let mut h = bp - .bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]) + .with("sma_cross.fast", 2) + .with("sma_cross.slow", 4) + .with("scale", 0.5) + .bootstrap() .expect("sample blueprint compiles under a valid point"); h.run(vec![synthetic_prices()]); assert!(!rx_eq.try_iter().collect::>().is_empty(), "equity sink drained empty"); diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 2fa3815..4cbee44 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -264,6 +264,102 @@ impl Composite { pub fn bootstrap(self) -> Result { self.bootstrap_with_params(vec![]) } + + /// 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())] } + } +} + +/// 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> { + // 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) } /// A construction-phase fault, caught before the flat compilat reaches @@ -737,6 +833,134 @@ mod tests { use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc; + #[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"); + } + /// One f64 input port with `Firing::Any` (the common case for these fixtures). fn f64_any() -> PortSpec { PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() } diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 80f518f..f74519c 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -38,7 +38,8 @@ mod report; mod sweep; pub use blueprint::{ - aliases_on, signature_of, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role, + aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, + ParamAlias, Role, }; pub use graph_model::model_to_json; pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};