From c9f0e438e1c1b70889657ef536f34fbcef45d9bd Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 15 Jun 2026 12:16:03 +0200 Subject: [PATCH] feat(aura-engine): seeded source + live RunManifest.seed (C12 seed-as-input) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A seeded source whose stream is fully determined by a u64 seed, making RunManifest.seed a live captured input instead of the dead 0 it was. This is C12's reconciliation of stochastic runs with C1: a run is bit-identical for a fixed seed (the seed is a captured input, not hidden nondeterminism), and a different seed gives a different-but-reproducible run. Precondition for the Monte-Carlo family (#68) and random param-sweep (#52). Engine (aura-engine): - SplitMix64: a ~10-line private, dependency-free PRNG. Chosen over a rand-family crate for guaranteed bit-stability across toolchains/crate versions — the whole value of seed-as-input is reproducibility, and a crate whose algorithm is not version-stable would silently break a recorded seed on a dep bump. No new direct dep. - SyntheticSpec + source(seed) -> impl Source: the Fn(u64) -> impl Source contract (Fork B — seed at the data-generation edge, outside the engine graph). Returns a Source, never a Vec, so the MC family can re-seed N times without materializing N streams. First cut collects to a VecSource internally; the signature does not name Vec, so a lazy generator is a drop-in replacement. Re-exported from lib.rs beside VecSource. CLI (aura-cli): - sim_optimal_manifest gains a seed: u64 param recorded into the manifest (the single Fork B capture site). All four existing seed-free callers (run_sample, run_sample_real, the sweep grid-report, run_macd) pass 0 unchanged — their determinism tests stay green. - A test-only run_sample_seeded + SeededTrace exercise a live seed and expose the drained sink trace, so the three e2e tests assert bit-identity at the trace level (strictly stronger than the folded 3-field metrics). Tests (RED-first): 2 engine producer tests (same-seed identical / different-seed differs) + 3 cli e2e tests (bit-identical trace, different-seed metrics differ, seed recorded in manifest). Full workspace green; clippy -D warnings clean. Errata vs plan 0042 (two compile-forced deviations, neither alters the spec contract): - source returns `impl Source + use<>`: under edition 2024 a bare `impl Source` captures the &self lifetime, which blocks boxing as Box for run() (E0597). The source owns its data, so use<> (captures nothing) is correct. - SyntheticSpec is imported inside `#[cfg(test)] mod tests` in main.rs: it is used only by the seeded test vehicle, so a top-level import is unused in the non-test build under clippy -D warnings. closes #66 --- crates/aura-cli/src/main.rs | 77 ++++++++++++++++++++++++- crates/aura-engine/src/harness.rs | 96 +++++++++++++++++++++++++++++++ crates/aura-engine/src/lib.rs | 4 +- 3 files changed, 173 insertions(+), 4 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 509c906..e483deb 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -122,12 +122,16 @@ fn sample_harness() -> ( /// built-in harnesses — only `params` and `window` vary. Centralizing the broker /// 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)>, window: (Timestamp, Timestamp)) -> RunManifest { +fn sim_optimal_manifest( + params: Vec<(String, f64)>, + window: (Timestamp, Timestamp), + seed: u64, +) -> RunManifest { RunManifest { commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(), params, window, - seed: 0, + seed, broker: "sim-optimal(pip_size=0.0001)".to_string(), } } @@ -158,6 +162,7 @@ fn run_sample() -> RunReport { ("exposure_scale".to_string(), 0.5), ], window, + 0, ), metrics, } @@ -220,6 +225,7 @@ fn run_sample_real(symbol: &str, from_ms: Option, to_ms: Option) -> Ru ("exposure_scale".to_string(), 0.5), ], window, + 0, ), metrics, } @@ -382,7 +388,7 @@ fn sweep_family() -> SweepFamily { .map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v))) .collect(); RunReport { - manifest: sim_optimal_manifest(params, window), + manifest: sim_optimal_manifest(params, window, 0), metrics: summarize(&equity, &exposure), } }) @@ -569,6 +575,7 @@ fn run_macd() -> RunReport { ("exposure_scale".to_string(), 0.5), ], window, + 0, ), metrics, } @@ -607,6 +614,70 @@ fn main() { #[cfg(test)] mod tests { use super::*; + // `SyntheticSpec` is used only by the seeded test vehicle below, so it is + // imported here rather than at module top (a top-level import would be + // `unused` in the non-test binary build under `clippy -D warnings`). + use aura_engine::SyntheticSpec; + + /// The drained sink trace of a seeded run — the recorded rows of the equity + /// and exposure sinks. Compared row-for-row so the C1 seed-determinism + /// property is tested at the trace level (strictly stronger than the folded + /// 3-field metrics). `PartialEq` not `Eq`: `Scalar` carries `f64`. + #[derive(Debug, PartialEq)] + struct SeededTrace { + equity: Vec<(Timestamp, Vec)>, + exposure: Vec<(Timestamp, Vec)>, + } + + /// A seeded run of the sample harness: the synthetic stream is generated + /// from `seed`, that same seed is recorded into the manifest, and the + /// drained sink trace is returned alongside the report. Every byte of both + /// is a function of `seed`. + fn run_sample_seeded(seed: u64) -> (RunReport, SeededTrace) { + let (mut h, rx_eq, rx_ex) = sample_harness(); + let spec = SyntheticSpec { start: 1.0, len: 64, step: 1 }; + let window = (Timestamp(1), Timestamp((spec.len as i64 - 1) * spec.step + 1)); + h.run(vec![Box::new(spec.source(seed))]); + let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); + let metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); + 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), + ], + window, + seed, + ), + metrics, + }; + (report, SeededTrace { equity: eq_rows, exposure: ex_rows }) + } + + #[test] + fn same_seed_bit_identical_trace() { + // Bit-identical sink trace for a fixed seed (acceptance bullet 1, C1). + let (_, trace_a) = run_sample_seeded(42); + let (_, trace_b) = run_sample_seeded(42); + assert_eq!(trace_a, trace_b); + } + + #[test] + fn different_seed_different_trace() { + // Different seeds perturb the trace (acceptance bullet 2). + let (a, _) = run_sample_seeded(1); + let (b, _) = run_sample_seeded(2); + assert_ne!(a.metrics, b.metrics); + } + + #[test] + fn seed_recorded_in_manifest() { + // The seed that drove the run is recorded (acceptance bullet 3). + let (report, _) = run_sample_seeded(7); + assert_eq!(report.manifest.seed, 7); + } #[test] fn sample_blueprint_with_sinks_bootstraps_runs_and_drains() { diff --git a/crates/aura-engine/src/harness.rs b/crates/aura-engine/src/harness.rs index fa08953..8ebf1da 100644 --- a/crates/aura-engine/src/harness.rs +++ b/crates/aura-engine/src/harness.rs @@ -90,6 +90,72 @@ impl Source for VecSource { } } +/// A tiny, fully-deterministic, dependency-free PRNG (SplitMix64). The seed +/// completely determines the sequence; no external entropy, no global state. +/// Bit-stable across toolchains and crate versions — the property C1 needs for +/// seed-as-input reproducibility (C12). +struct SplitMix64 { + state: u64, +} + +impl SplitMix64 { + fn new(seed: u64) -> Self { + Self { state: seed } + } + fn next_u64(&mut self) -> u64 { + self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.state; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + /// A uniform `f64` in `[0, 1)` from the top 53 bits. + fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64) + } +} + +/// Config for a seeded synthetic price walk — everything *except* the seed. +/// Partially applying it (`|seed| spec.source(seed)`) yields the +/// `Fn(u64) -> impl Source` contract the Monte-Carlo family (#68) consumes +/// (C12 seed-as-input). The produced stream is an ordinary ingestion-boundary +/// `Source` (C3), generated at the data-generation edge outside the engine graph. +#[derive(Clone, Copy, Debug)] +pub struct SyntheticSpec { + /// Opening price. + pub start: f64, + /// Number of M1 steps. + pub len: usize, + /// Timestamp increment per step. + pub step: i64, +} + +impl SyntheticSpec { + /// Produce a synthetic price stream **fully determined by `seed`** (C1/C12): + /// a multiplicative random walk, each step a small seeded perturbation. + /// Returns a `Source` (the C3 ingestion producer), NOT a `Vec` — so a + /// Monte-Carlo family can re-seed without materializing N streams (#68). + /// (First cut collects to a `VecSource` internally; the signature does not + /// name `Vec`, so a later lazy generator is a drop-in replacement.) + /// + /// `use<>`: the returned source owns its data (it captures nothing from + /// `&self` — `start`/`len`/`step` are copied into the stream before return), + /// so under the edition-2024 capture rules it is `'static` and boxable into + /// the `Box` the k-way merge holds (C3). + pub fn source(&self, seed: u64) -> impl Source + use<> { + let mut rng = SplitMix64::new(seed); + let mut price = self.start; + let stream: Vec<(Timestamp, Scalar)> = (0..self.len) + .map(|i| { + let shock = (rng.next_f64() - 0.5) * 0.004; // ±0.2% per step + price *= 1.0 + shock; + (Timestamp(i as i64 * self.step + 1), Scalar::F64(price)) + }) + .collect(); + VecSource::new(stream) + } +} + /// The flat, type-erased, index-wired output of `Composite::compile` — the target /// `Harness::bootstrap` consumes (C23's "flat graph", now a named type). `signatures[i]` /// is the static signature of `nodes[i]` (gathered from each primitive's builder at @@ -395,6 +461,36 @@ mod tests { points.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect() } + #[test] + fn seeded_source_same_seed_same_stream() { + // Same seed -> bit-identical stream (C1/C12 determinism). + let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 }; + let drain = |seed| { + let mut s = spec.source(seed); + let mut out: Vec<(Timestamp, Scalar)> = Vec::new(); + while let Some(item) = Source::next(&mut s) { + out.push(item); + } + out + }; + assert_eq!(drain(7), drain(7)); + } + + #[test] + fn seeded_source_different_seed_differs() { + // Different seeds -> different streams. + let spec = SyntheticSpec { start: 1.0, len: 32, step: 1 }; + let drain = |seed| { + let mut s = spec.source(seed); + let mut out: Vec<(Timestamp, Scalar)> = Vec::new(); + while let Some(item) = Source::next(&mut s) { + out.push(item); + } + out + }; + assert_ne!(drain(1), drain(2)); + } + #[test] fn vec_source_peek_is_non_consuming_and_idempotent() { let mut s = VecSource::new(vec![(Timestamp(1), Scalar::F64(10.0)), (Timestamp(2), Scalar::F64(20.0))]); diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index 4b5d7b4..9b7f934 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -43,7 +43,9 @@ pub use blueprint::{ }; pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle}; pub use graph_model::model_to_json; -pub use harness::{BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, Target, VecSource}; +pub use harness::{ + BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SyntheticSpec, Target, VecSource, +}; pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport}; pub use sweep::{sweep, GridSpace, SweepError, SweepFamily, SweepPoint}; // #29: re-export the core scalar vocabulary a Blueprint builder needs