feat(aura-engine): RandomBinder — by-name random param-sweep

Add `RandomBinder`, the by-name sibling to `SweepBinder`, so a random
sweep (`RandomSpace`, C12.1) can be built by name against `param_space()`
instead of by positional `Vec<ParamRange>` slot order. By-name resolution
makes a same-kind transposition (e.g. swapping the I64 ranges for
`fast.length` and `slow.length`) structurally impossible — the failure
class `RandomSpace::new`'s positional validation passes silently.

Direct structural mirror of `SweepBinder`: `Composite::range` opens the
binder, `.range(name, ParamRange)` accumulates, `.sweep(count, seed, run)`
resolves the named ranges via the shared `resolve_into` (new `resolve_ranges`
caller) and runs the disjoint sweep. New `BindError::EmptyRange` mirrors
`EmptyAxis`; `ParamRange::is_empty` homes the non-empty invariant the named
layer pre-checks so `RandomSpace::new` cannot fail.

closes #79
This commit is contained in:
2026-06-17 14:14:35 +02:00
parent db3308fcc5
commit 91ef69ed7b
3 changed files with 156 additions and 4 deletions
+146 -2
View File
@@ -20,7 +20,7 @@ use aura_core::{
use crate::harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
use crate::sweep::sweep;
use crate::{GridSpace, RunReport, SweepFamily};
use crate::{GridSpace, ParamRange, RandomSpace, 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
@@ -317,6 +317,14 @@ impl Composite {
let axis = vals.into_iter().map(Into::into).collect();
SweepBinder { bp: self, axes: vec![(name.to_string(), axis)] }
}
/// Begin binding this blueprint's knobs **by name** as random-sweep ranges (the
/// fluent authoring alternative to a positional `RandomSpace`). The bound name is
/// the exact `param_space()` name (path-qualified for a composite-interior knob,
/// bare for a root-level knob).
pub fn range(self, name: &str, range: ParamRange) -> RandomBinder {
RandomBinder { bp: self, ranges: vec![(name.to_string(), range)] }
}
}
/// A fault in resolving a named binding against `param_space()` — the authoring
@@ -335,6 +343,8 @@ pub enum BindError {
DuplicateBinding(String),
/// (sweep, iteration 2) An axis was given zero values.
EmptyAxis(String),
/// (random sweep) A named range was empty.
EmptyRange(String),
/// The resolved point passed name resolution but failed downstream bootstrap.
Compile(CompileError),
}
@@ -393,6 +403,38 @@ impl SweepBinder {
}
}
/// A fluent accumulator of named random-sweep ranges, terminated by
/// [`RandomBinder::sweep`]. Ranges are resolved against `param_space()` once, at the
/// terminal; resolution is a superset of `RandomSpace::new`'s checks, so the space it
/// builds cannot fail.
pub struct RandomBinder {
bp: Composite,
ranges: Vec<(String, ParamRange)>,
}
impl RandomBinder {
/// Bind one more random-sweep range by name.
pub fn range(mut self, name: &str, range: ParamRange) -> RandomBinder {
self.ranges.push((name.to_string(), range));
self
}
/// Resolve the named ranges against `param_space()` into a positional
/// `RandomSpace` and run the disjoint sweep. `count`/`seed` are `RandomSpace`'s
/// extra inputs — the only signature difference from [`SweepBinder::sweep`].
pub fn sweep<F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily, BindError>
where
F: Fn(&[Cell]) -> RunReport + Sync,
{
let space = self.bp.param_space();
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
let ordered = resolve_ranges(&space, &self.ranges)?;
let rs = RandomSpace::new(&space, ordered, count, seed)
.expect("named layer pre-validates arity/kind/non-empty");
Ok(sweep(&rs, run_one))
}
}
/// The shared two-phase named-binding resolution against `param_space()`, generic
/// over the per-slot payload `T` (one `Scalar` for [`resolve`], a `Vec<Scalar>`
/// axis for [`resolve_axes`]). The two callers differ only in the per-element work
@@ -474,6 +516,25 @@ fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<V
)
}
/// Resolve named random-sweep ranges to a positional `Vec<ParamRange>` in
/// `param_space()` slot order. Thin caller over [`resolve_into`]: a range rejects an
/// empty interval at claim time (`EmptyRange`, before the duplicate check) and
/// kind-checks the range against the slot's declared kind. A non-numeric slot needs
/// no separate check — a `ParamRange` is always I64/F64, so `kind_ok` rejects it as a
/// `KindMismatch` before `RandomSpace::new` (whose `NonNumericRange` gate) is reached.
fn resolve_ranges(space: &[ParamSpec], ranges: &[(String, ParamRange)]) -> Result<Vec<ParamRange>, BindError> {
resolve_into(
space,
ranges,
|name, r: &ParamRange| r.is_empty().then(|| BindError::EmptyRange(name.to_string())),
|p, r: &ParamRange| (r.kind() != p.kind).then(|| BindError::KindMismatch {
knob: p.name.clone(),
expected: p.kind,
got: r.kind(),
}),
)
}
/// Structural validation (param-value-independent): the `param_space()` name
/// projection is the by-name knob address space (C12/C19) and must be injective —
/// a duplicated path is a knob no binding can select alone. The first duplicate in
@@ -838,11 +899,94 @@ fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, Co
mod tests {
use super::*;
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
use crate::VecSource;
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc;
/// Build + bootstrap + run + drain + summarize one swept point into a
/// `RunReport`, using a fresh harness per point (disjoint runs, C1). A free
/// `fn` (Copy + Sync) so it serves both as the `sweep`/binder closure and as a
/// direct reference. Deterministic, so the same point reproduces its report
/// exactly — that is what makes two families comparable for equality.
fn run_point(point: &[Cell]) -> RunReport {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_cells(point)
.expect("enumerated/sampled points are pre-validated against the param-space");
h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
RunReport {
manifest: RunManifest {
commit: "test".to_string(),
params: Vec::new(),
window: (Timestamp(0), Timestamp(0)),
seed: 0,
broker: "test".to_string(),
},
metrics: summarize(&equity, &exposure),
}
}
/// Property (the reason `RandomBinder` exists): building a random sweep **by
/// name** is order-independent and binds the same knob to the same range
/// regardless of `.range(...)` call order — exactly the safety the grid's
/// `SweepBinder` already gives. The two I64 slots `sma_cross.fast.length` and
/// `sma_cross.slow.length` are the **same-kind transposition** pair: swapping
/// their two positional `ParamRange`s passes `RandomSpace::new`'s positional
/// validation silently yet tunes the wrong knob over the wrong interval. By-name
/// resolution makes that structurally impossible. Pinned at the observable
/// `SweepFamily` boundary: the family built `.range(fast).range(slow)` equals
/// the one built transposed `.range(slow).range(fast)`, and both equal the
/// *correctly-ordered* positional `RandomSpace::new`. (`count`/`seed` are
/// `RandomSpace`'s extra inputs; same seed => seed-determined, comparable
/// points, C1.)
#[test]
fn random_binder_by_name_is_order_independent_and_equals_positional() {
let (count, seed) = (8usize, 0xC0FFEEu64);
let fast = ParamRange::i64(2, 3);
let slow = ParamRange::i64(4, 5);
let scale = ParamRange::f64(0.25, 1.5);
// by name, fast-then-slow
let in_order = composite_sma_cross_harness()
.0
.range("sma_cross.fast.length", fast)
.range("sma_cross.slow.length", slow)
.range("exposure.scale", scale)
.sweep(count, seed, run_point)
.expect("named ranges resolve against param_space()");
// by name, slow-then-fast (the same-kind transposition that mis-binds the
// positional API) — must produce the identical family
let transposed = composite_sma_cross_harness()
.0
.range("sma_cross.slow.length", slow)
.range("sma_cross.fast.length", fast)
.range("exposure.scale", scale)
.sweep(count, seed, run_point)
.expect("named ranges resolve regardless of call order");
// the correctly-ordered positional RandomSpace (param_space() slot order
// is [fast, slow, scale]) — the ground truth the by-name layer must match
let space = composite_sma_cross_harness().0.param_space();
let positional = sweep(
&RandomSpace::new(&space, vec![fast, slow, scale], count, seed)
.expect("correctly-ordered positional ranges validate"),
run_point,
);
assert_eq!(
in_order, transposed,
"by-name random sweep is order-independent (the same-kind transposition guard)",
);
assert_eq!(
in_order, positional,
"by-name random sweep equals the correctly-ordered positional RandomSpace",
);
}
#[test]
fn resolve_axes_named_equals_positional() {
// named axes resolve to the positional Vec<Vec<Scalar>> in slot order,
+2 -2
View File
@@ -52,8 +52,8 @@ mod sweep;
mod walkforward;
pub use blueprint::{
BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
SweepBinder,
BindError, Binder, BlueprintNode, CompileError, Composite, OutField, RandomBinder,
Role, SweepBinder,
};
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
pub use graph_model::model_to_json;
+8
View File
@@ -163,6 +163,14 @@ impl ParamRange {
pub fn kind(&self) -> ScalarKind {
self.lo.kind()
}
/// `true` iff the range admits no value: I64 `lo > hi`, F64 `lo >= hi`. The home
/// of the non-empty invariant the named binder pre-checks.
pub fn is_empty(&self) -> bool {
match self.kind() {
ScalarKind::I64 => self.lo.as_i64() > self.hi.as_i64(),
_ => self.lo.as_f64() >= self.hi.as_f64(),
}
}
}
/// `count` seeded uniform points over per-slot continuous `ranges`, validated