From 937257e3687ed0bb228816dfb40711083e4edb74 Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 11 Jun 2026 00:30:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-engine,aura-cli):=20named=20param=20b?= =?UTF-8?q?inding=20=E2=80=94=20sweep=20axes=20.axis()/.sweep()=20(0030=20?= =?UTF-8?q?iter=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind a sweep's axes by name instead of by a positional GridSpace: bp.axis("sma_cross.fast", [2, 3]).axis("sma_cross.slow", [4, 5]).axis("scale", [0.5]).sweep(run) The sweep half of spec 0030, completing the named-binding feature (#35). A pure authoring layer over the existing GridSpace::new / sweep primitives; engine core untouched (C1/C12/C19/C23). This iteration: - resolve_axes(): shares resolve's name->slot mapping (iter 1), adds the EmptyAxis check (the variant defined in iter 1 is now first constructed here) and a per-element axis kind-check (every element of each axis, first offending element in axis order). Its validation is a strict superset of GridSpace::new's (arity / non-empty / per-element kind), so SweepBinder::sweep's downstream GridSpace::new(...).expect(...) is infallible by construction — it can never panic on author input. - Composite::axis -> SweepBinder -> SweepBinder::sweep (Result). - The CLI sample sweep converted to the named form (behaviour-preserving). Plan correction (verified by the orchestrator): the iteration plan said only the GridSpace import was orphaned by the CLI conversion, but replacing the free sweep(&grid, ..) call with the .sweep(..) method also orphans the free `sweep` import — both were dropped from aura-cli to satisfy clippy -D warnings. The Scalar import (from aura_core) stays used and was untouched. Verified (run by the orchestrator): 6 new aura-engine tests green — resolve_axes round-trip, EmptyAxis, MissingKnob, the per-element KindMismatch on a mixed axis (the superset/no-panic guarantee), a builder no-panic wrong-kind test, and a GridSpace .points() parity test; the two sweep JSON goldens (sweep_report_renders_four_points_in_odometer_order + the cli_run integration golden) green unchanged (name/value/order-preserving conversion); full `cargo test --workspace` green and `cargo clippy --workspace --all-targets -- -D warnings` clean. refs #35 --- crates/aura-cli/src/main.rs | 76 +++++------ crates/aura-engine/src/blueprint.rs | 195 ++++++++++++++++++++++++++++ crates/aura-engine/src/lib.rs | 2 +- 3 files changed, 232 insertions(+), 41 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 5335805..cb320c5 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -10,7 +10,7 @@ mod render; use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ - f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness, + f64_field, summarize, BlueprintNode, Composite, Edge, FlatGraph, Harness, OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target, }; use aura_registry::{rank_by, Registry}; @@ -224,45 +224,41 @@ fn scalar_as_param_f64(s: &Scalar) -> f64 { /// bootstraps it under the point vector, runs it, and folds the drained sinks to /// metrics — the per-point closure the engine `sweep` drives disjointly. fn sweep_family() -> SweepFamily { - let space = sample_blueprint_with_sinks().0.param_space(); - let grid = GridSpace::new( - &space, - vec![ - vec![Scalar::I64(2), Scalar::I64(3)], // fast ∈ {2, 3} - vec![Scalar::I64(4), Scalar::I64(5)], // slow ∈ {4, 5} - vec![Scalar::F64(0.5)], // scale ∈ {0.5} - ], - ) - .expect("the built-in grid matches the sample param-space"); - sweep(&grid, |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), - } - }) + 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") } /// Render a sweep family as one `RunReport` JSON line per point. Test helper: diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 4cbee44..bc4ec38 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -19,6 +19,8 @@ use aura_core::{ }; use crate::harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target}; +use crate::sweep::sweep; +use crate::{GridSpace, RunReport, SweepFamily}; /// One re-exported field of a composite's output record: an interior /// `(node, output-field)` surfaced at the boundary under `name`. `name` is a @@ -272,6 +274,15 @@ impl Composite { pub fn with(self, name: &str, v: impl Into) -> Binder { Binder { bp: self, bound: vec![(name.to_string(), v.into())] } } + + /// 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)] } + } } /// A fault in resolving a named binding against `param_space()` — the authoring @@ -319,6 +330,83 @@ impl Binder { } } +/// 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> { + // 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) +} + /// 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> { @@ -829,10 +917,117 @@ fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result> 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"); + } + #[test] fn resolve_named_equals_positional_vector() { // order-independent: shuffled bindings resolve to slot order diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index f74519c..aa21628 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -39,7 +39,7 @@ mod sweep; pub use blueprint::{ aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, - ParamAlias, Role, + ParamAlias, Role, SweepBinder, }; pub use graph_model::model_to_json; pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};