Files
Aura/docs/plans/0042-seed-as-input.md
T
Brummel f7230a5068 plan: 0042 seed-as-input — seeded source + live RunManifest.seed
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
2026-06-15 12:10:21 +02:00

14 KiB

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.rssim_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:

    #[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:

/// 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:

pub use harness::{BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, Target, VecSource};

to:

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.

    /// 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);
    }
  • 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:

use aura_engine::{
    f64_field, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness,
    RunManifest, RunReport, SourceSpec, SweepFamily, Target, VecSource,
};

to:

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:

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):

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,:

        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):

        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:

                manifest: sim_optimal_manifest(params, window),

with:

                manifest: sim_optimal_manifest(params, window, 0),

Caller in run_macd (lines 564-572):

        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).