feat(aura-engine,aura-cli): named param binding — sweep axes .axis()/.sweep() (0030 iter 2)
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<SweepFamily, BindError>).
- 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
This commit is contained in:
+36
-40
@@ -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::<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),
|
||||
}
|
||||
})
|
||||
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")
|
||||
}
|
||||
|
||||
/// Render a sweep family as one `RunReport` JSON line per point. Test helper:
|
||||
|
||||
@@ -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<Scalar>) -> 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<Item = impl Into<Scalar>>) -> 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<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> {
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -829,10 +917,117 @@ fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, Co
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::GridSpace;
|
||||
use aura_core::{Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
|
||||
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_named_equals_positional_vector() {
|
||||
// order-independent: shuffled bindings resolve to slot order
|
||||
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user