From 91ef69ed7b691416f89f6ab36d1d89e07be5ec15 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 17 Jun 2026 14:14:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-engine):=20RandomBinder=20=E2=80=94?= =?UTF-8?q?=20by-name=20random=20param-sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 --- crates/aura-engine/src/blueprint.rs | 148 +++++++++++++++++++++++++++- crates/aura-engine/src/lib.rs | 4 +- crates/aura-engine/src/sweep.rs | 8 ++ 3 files changed, 156 insertions(+), 4 deletions(-) diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 3049737..6e0af9c 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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(self, count: usize, seed: u64, run_one: F) -> Result + 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` /// 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)]) -> Result` 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, 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 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::>(), 0); + let exposure = f64_field(&rx_ex.try_iter().collect::>(), 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> in slot order, diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index e0a49eb..86f57f2 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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; diff --git a/crates/aura-engine/src/sweep.rs b/crates/aura-engine/src/sweep.rs index f155e7e..e524a5f 100644 --- a/crates/aura-engine/src/sweep.rs +++ b/crates/aura-engine/src/sweep.rs @@ -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