From f7230a506806d269b0e313a5b5cdcd9501363156 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 15 Jun 2026 12:10:21 +0200 Subject: [PATCH] =?UTF-8?q?plan:=200042=20seed-as-input=20=E2=80=94=20seed?= =?UTF-8?q?ed=20source=20+=20live=20RunManifest.seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tasks: (1) aura-engine SplitMix64 PRNG + pub SyntheticSpec::source(seed) -> impl Source beside VecSource, with producer RED tests; (2) aura-cli sim_optimal_manifest gains a seed param (four callers pass 0), plus a test-only run_sample_seeded + SeededTrace and three e2e RED tests pinning the #66 acceptance. RED-first throughout. refs #66 --- docs/plans/0042-seed-as-input.md | 397 +++++++++++++++++++++++++++++++ 1 file changed, 397 insertions(+) create mode 100644 docs/plans/0042-seed-as-input.md diff --git a/docs/plans/0042-seed-as-input.md b/docs/plans/0042-seed-as-input.md new file mode 100644 index 0000000..67670ba --- /dev/null +++ b/docs/plans/0042-seed-as-input.md @@ -0,0 +1,397 @@ +# Seed-as-input — Implementation Plan + +> **Parent spec:** `docs/specs/0042-seed-as-input.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Make `RunManifest.seed` a live captured input — ship a seeded source +(`Fn(u64) -> impl Source`) whose stream is fully seed-determined, and thread the +seed into the manifest at the construction site (Fork B). + +**Architecture:** Two crates. (1) `aura-engine` gains a private `SplitMix64` +PRNG and a `pub SyntheticSpec` with `fn source(&self, seed: u64) -> impl Source`, +placed beside the existing `Source` producer `VecSource` in `harness.rs` (same +category, same module as the `Source` trait it implements). (2) `aura-cli`'s +`sim_optimal_manifest` gains a `seed: u64` parameter; its four existing callers +pass `0` (unchanged behaviour), and a test-only `run_sample_seeded` exercises a +live seed and returns the drained sink trace for a trace-level determinism +assertion. The engine event loop is untouched — it sees only a `Source` (C3). + +**Tech Stack:** Rust, `aura-engine` (`harness.rs`, `lib.rs`), `aura-cli` +(`main.rs`), the existing `Source`/`VecSource` seam, `sample_harness` + +`f64_field`/`summarize` fold. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-engine/src/harness.rs` — add `SplitMix64` (private) + + `SyntheticSpec` (pub) + `impl SyntheticSpec::source` after the + `impl Source for VecSource` block (insert after line 91, before `FlatGraph` at + line 98); add two producer RED tests in the `#[cfg(test)] mod tests` block. +- Modify: `crates/aura-engine/src/lib.rs:46` — add `SyntheticSpec` to the + `pub use harness::{…}` re-export list. +- Modify: `crates/aura-cli/src/main.rs` — `sim_optimal_manifest` def (125-133) + gains `seed: u64`; thread `0` into its four callers (154, 216, 385, 564); add + `SyntheticSpec` to the `use aura_engine::{…}` import (17-20); add `SeededTrace` + + `run_sample_seeded` + three e2e RED tests in the `#[cfg(test)] mod tests` + block (opens at 608). + +--- + +### Task 1: Seeded source producer (`aura-engine`) + +**Files:** +- Modify: `crates/aura-engine/src/harness.rs` (insert after line 91; tests in the `mod tests` block) +- Modify: `crates/aura-engine/src/lib.rs:46` + +- [ ] **Step 1: Write the failing producer tests** + +In `crates/aura-engine/src/harness.rs`, inside the existing `#[cfg(test)] mod tests` +block (after the `f64_stream` helper, ~line 396), add: + +```rust + #[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)); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p aura-engine seeded_source` +Expected: FAIL — does not compile: `cannot find struct, variant or union type +`SyntheticSpec` in this scope` (the producer does not exist yet). + +- [ ] **Step 3: Write the PRNG + seeded source** + +In `crates/aura-engine/src/harness.rs`, immediately after the +`impl Source for VecSource { … }` block (which ends at line 91) and before the +`FlatGraph` doc comment (line 93), insert: + +```rust +/// 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.) + pub fn source(&self, seed: u64) -> impl Source { + 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) + } +} +``` + +Then in `crates/aura-engine/src/lib.rs:46`, add `SyntheticSpec` to the harness +re-export (alphabetically, after `SourceSpec`, before `Target`). Change: + +```rust +pub use harness::{BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, Target, VecSource}; +``` + +to: + +```rust +pub use harness::{ + BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SyntheticSpec, Target, VecSource, +}; +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test -p aura-engine seeded_source` +Expected: PASS — `test result: ok. 2 passed`. + +--- + +### Task 2: Thread the seed into the manifest + seeded run path (`aura-cli`) + +**Files:** +- Modify: `crates/aura-cli/src/main.rs` (def 125-133; callers 154, 216, 385, 564; imports 17-20; tests block at 608) + +- [ ] **Step 1: Write the failing end-to-end tests + the seeded run vehicle** + +In `crates/aura-cli/src/main.rs`, inside the `#[cfg(test)] mod tests` block +(after `use super::*;` at ~line 609), add `SeededTrace`, `run_sample_seeded`, and +the three tests. `run_sample_seeded` lives here under `#[cfg(test)]` deliberately: +this cycle ships no production caller for it (no `--seed` flag — spec Non-goals), +so a non-test definition would be `dead_code` and fail `clippy -D warnings`. It is +this cycle's test vehicle; #68 writes its own engine-side consumer. + +```rust + /// 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); + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `cargo test -p aura-cli seed` +Expected: FAIL — does not compile: `cannot find … SyntheticSpec` (not yet +imported) and `this function takes 2 arguments but 3 were supplied` +(`sim_optimal_manifest` still has the old signature). + +- [ ] **Step 3: Add the seed parameter, thread it, and import `SyntheticSpec`** + +3a. In `crates/aura-cli/src/main.rs:17-20`, add `SyntheticSpec` to the +`aura_engine` import (alphabetically). Change: + +```rust +use aura_engine::{ + f64_field, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness, + RunManifest, RunReport, SourceSpec, SweepFamily, Target, VecSource, +}; +``` + +to: + +```rust +use aura_engine::{ + f64_field, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness, + RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource, +}; +``` + +3b. Change the `sim_optimal_manifest` definition (lines 125-133). Replace: + +```rust +fn sim_optimal_manifest(params: Vec<(String, f64)>, window: (Timestamp, Timestamp)) -> RunManifest { + RunManifest { + commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(), + params, + window, + seed: 0, + broker: "sim-optimal(pip_size=0.0001)".to_string(), + } +} +``` + +with (add the `seed: u64` parameter; the body field becomes `seed`): + +```rust +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, + broker: "sim-optimal(pip_size=0.0001)".to_string(), + } +} +``` + +3c. Thread `0` into all four existing callers (every site must be updated in this +step — the signature change is a hard compile error until all four are threaded). + +Caller in `run_sample` (lines 154-161) — replace the `window,` line that closes +the argument list with `window,` followed by `0,`: + +```rust + manifest: sim_optimal_manifest( + vec![ + ("sma_fast".to_string(), 2.0), + ("sma_slow".to_string(), 4.0), + ("exposure_scale".to_string(), 0.5), + ], + window, + 0, + ), +``` + +Caller in `run_sample_real` (lines 216-223): + +```rust + manifest: sim_optimal_manifest( + vec![ + ("sma_fast".to_string(), 2.0), + ("sma_slow".to_string(), 4.0), + ("exposure_scale".to_string(), 0.5), + ], + window, + 0, + ), +``` + +Caller in the sweep grid-report closure (line 385) — replace: + +```rust + manifest: sim_optimal_manifest(params, window), +``` + +with: + +```rust + manifest: sim_optimal_manifest(params, window, 0), +``` + +Caller in `run_macd` (lines 564-572): + +```rust + 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), + ], + window, + 0, + ), +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `cargo test -p aura-cli seed` +Expected: PASS — `test result: ok. 3 passed` (the filter `seed` matches exactly +`same_seed_bit_identical_trace`, `different_seed_different_trace`, +`seed_recorded_in_manifest`; no pre-existing aura-cli test name contains `seed`). + +- [ ] **Step 5: Full-workspace regression + lint gate** + +Run: `cargo test --workspace` +Expected: PASS — the whole suite is green, including the four seed-free +determinism tests that pin the unchanged callers +(`run_sample_is_deterministic_and_non_trivial`, `sweep_report_is_deterministic`, +`run_macd_compiles_from_nested_composite_and_is_deterministic`, +`run_sample_real_streams_real_close_bars_deterministically`) and the +`report.rs` serde tests that pin `"seed":0`. + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: 0 warnings (no `dead_code`: `run_sample_seeded`/`SeededTrace` are under +`#[cfg(test)]`; the `seed` parameter is used by all four production callers).