feat(aura-engine): seeded source + live RunManifest.seed (C12 seed-as-input)

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<dyn Source + 'static>
  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
This commit is contained in:
2026-06-15 12:16:03 +02:00
parent f7230a5068
commit c9f0e438e1
3 changed files with 173 additions and 4 deletions
+74 -3
View File
@@ -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<i64>, to_ms: Option<i64>) -> 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<Scalar>)>,
exposure: Vec<(Timestamp, Vec<Scalar>)>,
}
/// 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<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = 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() {
+96
View File
@@ -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<dyn Source + 'static>` 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))]);
+3 -1
View File
@@ -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