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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user