From fb8cabf13f997fc7718ad78975e2440ae2e69d70 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 15 Jun 2026 23:28:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura-engine):=20sweep=20named-binding=20?= =?UTF-8?q?=E2=80=94=20zip=5Fparams=20+=20SweepFamily.named=5Fparams=20(C1?= =?UTF-8?q?2=20#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread a derived named view of a sweep point so consumers stop hand-zipping param_space() names onto the bare &[Scalar]. The free function zip_params(space, point) -> Vec<(String, Scalar)> in aura-core is the one shared projection; GridSpace retains the ParamSpec list it already receives in new() (it was validated then discarded); SweepFamily carries it (stamped once in sweep_with_threads) and exposes named_params(i). The three CLI hand-zip sites collapse to one zip_params call each. The run-closure signature `Fn(&[Scalar]) -> RunReport` is byte-unchanged — the named view is a derived projection (C23: slot is identity, name is derived), not a closure-currency change — so #52's RandomSpace plugs into the same sweep() execution layer with zero signature reconciliation (#71 firewall held). sim_optimal_manifest now takes typed Vec<(String, Scalar)> and does the lossy f64 collapse internally (one place; the manifest's f64-precursor field owns its own lossiness — the typed-manifest upgrade stays the deferred typed-param-space item, a non-goal here). All eight call sites migrated: three sweep closures pass zip_params, five hand-listing callers (run_sample, run_sample_real, mc_family, run_macd, run_sample_seeded) pass Scalar::F64 literals. Behaviour-preserving: the collapse output is identical to the old per-site zip, so aura sweep / walkforward / run output is byte-identical; SweepFamily.space threaded into the aura-registry optimize test literal. Verified by the orchestrator: cargo test --workspace green (4 new tests: zip_params x2, sweep_family_carries_param_space, family_named_params_round_trips), cargo clippy --workspace --all-targets -D warnings clean. (rust-analyzer emitted stale mid-edit diagnostics; the real cargo build/test is clean.) refs #57 --- crates/aura-cli/src/main.rs | 60 +++++++++++++-------------------- crates/aura-core/src/lib.rs | 4 ++- crates/aura-core/src/node.rs | 42 +++++++++++++++++++++++ crates/aura-engine/src/sweep.rs | 43 +++++++++++++++++++++-- crates/aura-registry/src/lib.rs | 1 + 5 files changed, 111 insertions(+), 39 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 3d00564..86df3f6 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -13,7 +13,7 @@ mod render; -use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_core::{zip_params, Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, Composite, Edge, FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode, RunManifest, RunReport, @@ -128,10 +128,13 @@ fn sample_harness() -> ( /// label keeps it a single source (and a single edit point for #22's per-asset /// pip work). fn sim_optimal_manifest( - params: Vec<(String, f64)>, + params: Vec<(String, Scalar)>, window: (Timestamp, Timestamp), seed: u64, ) -> RunManifest { + // The lossy f64 collapse lives here — the manifest field (the deferred typed + // param-space precursor) owns its own lossiness; callers pass typed Scalars. + let params = params.into_iter().map(|(n, s)| (n, scalar_as_param_f64(&s))).collect(); RunManifest { commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(), params, @@ -160,9 +163,9 @@ fn run_sample() -> RunReport { RunReport { manifest: sim_optimal_manifest( vec![ - ("sma_fast".to_string(), 2.0), - ("sma_slow".to_string(), 4.0), - ("exposure_scale".to_string(), 0.5), + ("sma_fast".to_string(), Scalar::F64(2.0)), + ("sma_slow".to_string(), Scalar::F64(4.0)), + ("exposure_scale".to_string(), Scalar::F64(0.5)), ], window, 0, @@ -223,9 +226,9 @@ fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option) -> Ru RunReport { manifest: sim_optimal_manifest( vec![ - ("sma_fast".to_string(), 2.0), - ("sma_slow".to_string(), 4.0), - ("exposure_scale".to_string(), 0.5), + ("sma_fast".to_string(), Scalar::F64(2.0)), + ("sma_slow".to_string(), Scalar::F64(4.0)), + ("exposure_scale".to_string(), Scalar::F64(0.5)), ], window, 0, @@ -383,13 +386,8 @@ fn sweep_family() -> SweepFamily { h.run(sources); 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: sim_optimal_manifest(params, window, 0), + manifest: sim_optimal_manifest(zip_params(&space, point), window, 0), metrics: summarize(&equity, &exposure), } }) @@ -500,13 +498,8 @@ fn sweep_over(from: Timestamp, to: Timestamp) -> SweepFamily { h.run(sources); 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: sim_optimal_manifest(params, window, 0), + manifest: sim_optimal_manifest(zip_params(&space, point), window, 0), metrics: summarize(&equity, &exposure), } }) @@ -527,13 +520,8 @@ fn run_oos(params: &[Scalar], from: Timestamp, to: Timestamp) -> (Vec<(Timestamp h.run(sources); let equity = f64_field(&rx_eq.try_iter().collect::>(), 0); let exposure = f64_field(&rx_ex.try_iter().collect::>(), 0); - let named = space - .iter() - .zip(params) - .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) - .collect(); let report = RunReport { - manifest: sim_optimal_manifest(named, window, 0), + manifest: sim_optimal_manifest(zip_params(&space, params), window, 0), metrics: summarize(&equity, &exposure), }; (equity, report) @@ -612,9 +600,9 @@ fn mc_family() -> McFamily { RunReport { manifest: sim_optimal_manifest( vec![ - ("sma_fast".to_string(), 2.0), - ("sma_slow".to_string(), 4.0), - ("exposure_scale".to_string(), 0.5), + ("sma_fast".to_string(), Scalar::F64(2.0)), + ("sma_slow".to_string(), Scalar::F64(4.0)), + ("exposure_scale".to_string(), Scalar::F64(0.5)), ], window, seed, @@ -868,10 +856,10 @@ fn run_macd() -> RunReport { RunReport { manifest: sim_optimal_manifest( vec![ - ("ema_fast".to_string(), 2.0), - ("ema_slow".to_string(), 4.0), - ("ema_signal".to_string(), 3.0), - ("exposure_scale".to_string(), 0.5), + ("ema_fast".to_string(), Scalar::F64(2.0)), + ("ema_slow".to_string(), Scalar::F64(4.0)), + ("ema_signal".to_string(), Scalar::F64(3.0)), + ("exposure_scale".to_string(), Scalar::F64(0.5)), ], window, 0, @@ -969,9 +957,9 @@ mod tests { let report = RunReport { manifest: sim_optimal_manifest( vec![ - ("sma_fast".to_string(), 2.0), - ("sma_slow".to_string(), 4.0), - ("exposure_scale".to_string(), 0.5), + ("sma_fast".to_string(), Scalar::F64(2.0)), + ("sma_slow".to_string(), Scalar::F64(4.0)), + ("exposure_scale".to_string(), Scalar::F64(0.5)), ], window, seed, diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index 5edfce7..1e44e27 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -39,5 +39,7 @@ pub use any::AnyColumn; pub use column::{Column, Window}; pub use ctx::Ctx; pub use error::KindMismatch; -pub use node::{FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder}; +pub use node::{ + zip_params, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, +}; pub use scalar::{Scalar, ScalarKind, Timestamp}; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index dc77080..4f9c55a 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -70,6 +70,14 @@ pub struct ParamSpec { pub kind: ScalarKind, } +/// Pair each param-space name with the co-indexed positional value — the +/// inverse of positional binding (C23): names are a derived view, not identity. +/// `space` and `point` are co-indexed in `param_space()` slot order; a length +/// mismatch (contract violation) truncates to the shorter, never panics. +pub fn zip_params(space: &[ParamSpec], point: &[Scalar]) -> Vec<(String, Scalar)> { + space.iter().zip(point).map(|(ps, v)| (ps.name.clone(), *v)).collect() +} + /// One param bound to a structural constant by [`PrimitiveBuilder::bind`] — a /// render/debug surface only (C23), dropped at lowering. `pos` is the slot's /// position in the node's ORIGINAL (pre-bind) param list, so the model serializer @@ -522,3 +530,37 @@ mod tests { let _ = b.bind("length", Scalar::F64(2.0)); // F64 value for an I64 slot } } + +#[cfg(test)] +mod zip_params_tests { + use super::{zip_params, ParamSpec}; + use crate::{Scalar, ScalarKind}; + + #[test] + fn zip_params_pairs_names_with_values_in_slot_order() { + let space = vec![ + ParamSpec { name: "fast".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }, + ]; + let point = vec![Scalar::I64(2), Scalar::F64(0.5)]; + assert_eq!( + zip_params(&space, &point), + vec![ + ("fast".to_string(), Scalar::I64(2)), + ("scale".to_string(), Scalar::F64(0.5)), + ], + ); + } + + #[test] + fn zip_params_matches_hand_zip() { + let space = vec![ + ParamSpec { name: "a".into(), kind: ScalarKind::I64 }, + ParamSpec { name: "b".into(), kind: ScalarKind::I64 }, + ]; + let point = vec![Scalar::I64(7), Scalar::I64(9)]; + let hand: Vec<(String, Scalar)> = + space.iter().zip(&point).map(|(ps, v)| (ps.name.clone(), *v)).collect(); + assert_eq!(zip_params(&space, &point), hand); + } +} diff --git a/crates/aura-engine/src/sweep.rs b/crates/aura-engine/src/sweep.rs index 5918312..0e28f51 100644 --- a/crates/aura-engine/src/sweep.rs +++ b/crates/aura-engine/src/sweep.rs @@ -4,7 +4,7 @@ //! per-point run-to-metrics closure is the author's (harness-specific sink glue //! the engine cannot generically own — C8/C18). -use aura_core::{ParamSpec, Scalar, ScalarKind}; +use aura_core::{zip_params, ParamSpec, Scalar, ScalarKind}; use crate::RunReport; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -13,6 +13,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; /// points (C12.1 grid axis). #[derive(Debug)] pub struct GridSpace { + space: Vec, axes: Vec>, } @@ -40,7 +41,7 @@ impl GridSpace { } } } - Ok(Self { axes }) + Ok(Self { space: space.to_vec(), axes }) } /// The number of grid points (`∏ |axis_i|`), always `>= 1` (a valid grid has @@ -77,6 +78,12 @@ impl GridSpace { } } } + + /// The param-space (names + kinds) this grid was validated against, retained + /// for the family to carry — the derived-name source (C23: names, not identity). + pub fn param_specs(&self) -> &[ParamSpec] { + &self.space + } } /// A structural fault constructing a `GridSpace` — the typed gate before any run. @@ -105,9 +112,18 @@ pub struct SweepPoint { /// enumeration (odometer) order, independent of thread completion order. #[derive(Clone, Debug, PartialEq)] pub struct SweepFamily { + pub space: Vec, pub points: Vec, } +impl SweepFamily { + /// The i-th point's params paired with their names — a derived view over the + /// carried param-space (reuses [`zip_params`]); no new per-point state. + pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> { + zip_params(&self.space, &self.points[i].params) + } +} + /// Run `run_one` over every grid point, disjointly in parallel (C1), and collect /// the family in enumeration order. `run_one` builds + bootstraps + runs + /// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs @@ -173,6 +189,7 @@ where let points = space.points(); let reports = run_indexed(points.len(), nthreads, |i| run_one(&points[i])); SweepFamily { + space: space.param_specs().to_vec(), points: points .into_iter() .zip(reports) @@ -325,6 +342,28 @@ mod tests { } } + #[test] + fn sweep_family_carries_param_space() { + let space = composite_sma_cross_harness().0.param_space(); + let family = sweep(&sma_cross_grid(), run_point); + assert_eq!(family.space, space); + } + + #[test] + fn family_named_params_round_trips() { + let space = composite_sma_cross_harness().0.param_space(); + let family = sweep(&sma_cross_grid(), run_point); + // odometer-first point is [I64(2), I64(4), F64(0.5)] + let expected: Vec<(String, Scalar)> = space + .iter() + .cloned() + .zip(family.points[0].params.clone()) + .map(|(ps, v)| (ps.name, v)) + .collect(); + assert_eq!(family.named_params(0), expected); + assert_eq!(family.named_params(0)[0].1, Scalar::I64(2)); + } + #[test] fn family_is_deterministic_across_thread_counts() { let grid = sma_cross_grid(); diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index 50bf38a..97caf0c 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -276,6 +276,7 @@ mod tests { report: report_with(pips, 0.5, 0), }; let family = SweepFamily { + space: vec![], points: vec![ point(0.0, 1.0), // 0: also-ran point(10.0, 3.0), // 1: tied max, earliest -> the winner