Files
Aura/docs/specs/0042-seed-as-input.md
T
Brummel 5604c420c5 spec: 0042 seed-as-input — seeded source + live RunManifest.seed (boss-signed)
A seeded source whose stream is fully determined by a u64 seed, wired so the
seed reaches RunManifest.seed at the manifest-construction site (Fork B: seed at
the data-generation edge, outside the engine graph). Contract is a producer
Fn(u64) -> impl Source, never seed -> Vec, so the Monte-Carlo family can re-seed
N times without materializing N streams. Deterministic dependency-free PRNG
(SplitMix64) for C1 bit-stability. Precondition for #68 (Monte-Carlo) and #52
(random sweep).

Auto-signed under /boss spec auto-sign: objective gates green (precondition,
self-review, grounding-check PASS) and a unanimous five-lens spec-skeptic panel
(criterion, grounding, scope-fork, ambiguity, plan-readiness all SOUND) after one
editorial-repair round.

refs #66
2026-06-15 12:03:43 +02:00

15 KiB
Raw Blame History

Seed-as-input: a seeded source whose stream is fully seed-determined — Design Spec

Date: 2026-06-15 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Make RunManifest.seed a live captured input (C12) instead of the dead 0 it is today. Deliver the precondition for the Monte-Carlo family (#68) and random param-sweep (#52): a seeded source — a producer whose entire output stream is determined by a u64 seed — wired so that the seed reaches RunManifest.seed at the manifest-construction site.

This is C12's reconciliation of stochastic runs with C1: a run is bit-identical for a fixed seed, and the seed is a captured input like the data window or the params, not hidden nondeterminism. A different seed gives a different — but itself fully reproducible — run.

Settled in the seeding issue (#66 body + its 2026-06-15 09:26 comment, authored by the user):

  • Fork B (settled). The seed lives at the recording / data-generation edge, outside the engine graph, and is recorded into RunManifest.seed at the manifest-construction site. The engine event loop never sees a seed; it drives Sources as it already does (C3/C6, no look-ahead). Determinism is preserved by an explicit seed input, never broken — the seed perturbs the data the engine replays, upstream of the engine.
  • The contract is a producer, never a Vec. The seed→stream contract is Fn(u64) -> impl Source (the Source trait at crates/aura-engine/src/harness.rs:57, from #71), never fn seeded_prices(seed) -> Vec<(Timestamp, Scalar)>. A seed -> Vec shape would calcify eager into the whole Monte-Carlo family (#68 re-seeds N times); a materialized-Vec definition is exactly the eager wall World-II's source seam exists to avoid. A first cut MAY collect to a VecSource internally; the contract's signature must not name Vec.

Non-goals

  • No aura run --seed CLI flag this cycle. The acceptance is bootstrap-level determinism (two bootstraps, same seed → identical trace), which is pinned by Rust tests against a seeded run path — not by a CLI surface. The Monte-Carlo family (#68) consumes the seed→source contract programmatically (a McFamily re-seeds N times, analog to SweepFamily), never via a CLI flag. A --seed flag is a future demonstrator, deliberately out of scope here.
  • No Monte-Carlo family (#68) and no random sweep (#52). This cycle ships only their shared precondition — the seeded-source contract and a live manifest.seed. The families are separate issues.
  • No typed param/seed space. RunManifest.seed stays a bare u64, matching the existing manifest shape.

Architecture

Three pieces, each at its settled altitude.

  1. A deterministic, dependency-free PRNG (engine-side). The seed fully determines the sequence; no external entropy, no global state, bit-stable across toolchains and crate versions. Bit-stability is the substantive reason for an in-house PRNG over a rand-family crate: the entire value of seed-as-input is reproducibility (C1), and a crate whose generator algorithm is not guaranteed stable across major versions would silently break a recorded seed's reproducibility on a dependency bump. The workspace has no direct RNG dependency today (only getrandom, transitively); a ~10-line SplitMix64 adds no new direct dep and gives a documented, portable bit-exact stream. (This is the dependency-policy per-case call: what a vetted crate would buy here — entropy sources, many distributions — is precisely what C12 forbids inside a replay.)

  2. A seeded synthetic price source (engine-side, alongside the existing Source producers VecSource / M1FieldSource). A Fn(u64) -> impl Source producer: given a seed, it yields a deterministic synthetic M1 price walk as a Source. The reference shape is RustAst's seeded synthetic OHLC generator (rtl/streams/register.rs). The non-seed knobs (start price, length, step) are config carried by a small spec value, so that partially applying the config yields the literal Fn(u64) -> impl Source the MC family will hold and vary.

  3. The seed captured into the manifest (CLI / harness-construction site, outside the engine — Fork B). The CLI's sim_optimal_manifest gains a seed parameter. It has four current callers, all seed-free today: run_sample, run_sample_real, the sweep/showcase grid-report builder, and run_macd. All four pass 0 and are unchanged in behaviour (so their currently-green determinism tests — run_sample_is_deterministic_and_non_trivial, sweep_report_is_deterministic, run_macd_…_is_deterministic, run_sample_real_streams_real_close_bars_deterministically — stay green), while a new seeded run path passes the real seed. This is the single site where the seed that drove the run becomes the recorded manifest.seed.

Placement rationale (C9 engine/project separation): the seeded source is reusable World-layer data-generation infrastructure consumed by the orchestration families (SweepFamily/McFamily live in aura-engine), not a CLI demo. It belongs beside the other Source producers in aura-engine, the same category as VecSource. The exact module (extend harness.rs vs a new source module) is the planner's call.

Concrete code shapes

Worked example — what a consumer writes (the empirical evidence)

The north-star consumer is the Monte-Carlo family (#68). What seed-as-input must make writable is exactly this — a closure from seed to a runnable source, re-seeded N times, every run reproducible and seed-tagged:

// #68 will write (sketch — NOT part of this cycle, shown to justify the contract):
let spec = SyntheticSpec { start: 1.0, len: 64, step: 1 };
let seed_to_source = |seed: u64| spec.source(seed);   // Fn(u64) -> impl Source
for seed in 0..1_000 {
    let report = run_seeded(seed, seed_to_source(seed)); // each run bit-identical for its seed
    // report.manifest.seed == seed   ← the captured input
}

If the contract were seed -> Vec, that loop would materialize 1000 price vectors; with impl Source it streams each lazily (O(one window) resident). This is why the contract names Source, not Vec.

Delivered this cycle — the seeded producer

// aura-engine, beside VecSource. Before: no seeded source exists.

/// 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.
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)`) is the `Fn(u64) -> impl
/// Source` contract the Monte-Carlo family consumes.
#[derive(Clone, Copy, Debug)]
pub struct SyntheticSpec {
    pub start: f64,   // opening price
    pub len: usize,   // number of M1 steps
    pub step: i64,    // timestamp increment per step
}

impl SyntheticSpec {
    /// Produce a synthetic price stream **fully determined by `seed`** (C12).
    /// Returns a `Source` (the C3 ingestion producer), NOT a `Vec` — so a
    /// Monte-Carlo family can re-seed without materializing N streams (#68).
    /// A multiplicative random walk: each step a small seeded perturbation.
    /// (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)
    }
}

Delivered this cycle — the seed captured into the manifest (Fork B site)

// aura-cli. Before:
fn sim_optimal_manifest(params: Vec<(String, f64)>, window: (Timestamp, Timestamp)) -> RunManifest {
    RunManifest { commit: , params, window, seed: 0, broker:  } // seed hardcoded 0
}

// After: seed becomes a parameter — the single manifest-construction site that
// records the seed that drove the run (Fork B). Seed-free callers pass 0.
fn sim_optimal_manifest(
    params: Vec<(String, f64)>,
    window: (Timestamp, Timestamp),
    seed: u64,
) -> RunManifest {
    RunManifest { commit: , params, window, seed, broker:  }
}

/// The drained sink trace of a seeded run — the recorded rows of the equity and
/// exposure sinks. Exposed so the C1 seed-determinism property is testable at
/// the *trace* level (bit-identical rows), not only through the folded metrics
/// (a strictly weaker check: distinct traces can fold to equal 3-field metrics).
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. Analog to `run_sample`, but every
/// byte of both the report and the trace is a function of `seed`. (The
/// trace-returning shape is new on the seeded path only; the four existing
/// run paths keep their `RunReport`-only return — no blast radius there.)
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".into(), 2.0), ("sma_slow".into(), 4.0), ("exposure_scale".into(), 0.5)],
            window,
            seed, // ← live, no longer 0
        ),
        metrics,
    };
    (report, SeededTrace { equity: eq_rows, exposure: ex_rows })
}

All four existing call sites (run_sample, run_sample_real, the sweep/showcase grid-report builder, run_macd) pass seed: 0 and keep their byte-identical output (seed-free synthetic / real runs stay 0, as the manifest doc already states). Only run_sample_seeded returns the trace alongside the report.

Components

Component Where Change
SplitMix64 aura-engine (private) new — deterministic PRNG
SyntheticSpec + ::source(seed) -> impl Source aura-engine (pub) new — the Fn(u64) -> impl Source contract
sim_optimal_manifest aura-cli gains a seed: u64 param; all four existing callers (run_sample, run_sample_real, sweep grid-report, run_macd) pass 0
SeededTrace + run_sample_seeded aura-cli new — the seeded run path; records the seed and returns the drained sink trace
RunManifest.seed aura-engine (unchanged type) no longer always 0; now reachable as a live value

Data flow

seed: u64
  │
  ├──> SyntheticSpec::source(seed) ──> impl Source ──> Harness::run(vec![..])  (engine: unchanged C3 merge / C6 loop)
  │                                                          │
  │                                                          └──> sinks ──> RunMetrics
  │
  └──> sim_optimal_manifest(.., seed) ──> RunManifest { seed, .. }
                                                          │
                          RunReport { manifest, metrics } ┘   (manifest.seed == the seed that drove the run)

The seed forks once, upstream of the engine: into the data generator and into the manifest. The engine loop is untouched — it sees only a Source.

Error handling

No new failure modes. len == 0 yields an empty Source (the engine already handles an exhausted source: run breaks immediately, sinks drain empty — same as any empty stream). The PRNG cannot fail (pure integer ops). The seed is a plain u64; every value is valid.

Testing strategy

RED-first. Two layers:

Producer unit tests (aura-engine):

  1. seeded_source_same_seed_same_stream — drain spec.source(7) twice; the two Vec<(Timestamp, Scalar)> streams are bit-identical.
  2. seeded_source_different_seed_differsspec.source(1) and spec.source(2) drain to different streams.

End-to-end seed-as-input tests (aura-cli) — the issue's acceptance:

  1. same_seed_bit_identical_trace — the SeededTrace (equity + exposure rows, the recorded (Timestamp, Vec<Scalar>) vectors) returned by run_sample_seeded(42) equals the SeededTrace of a second run_sample_seeded(42). The assertion is on the rows themselves, not on the folded metrics — that is the literal "bit-identical sink trace" of acceptance bullet 1, strictly stronger than a metrics comparison.
  2. different_seed_different_trace — the RunReport.metrics of run_sample_seeded(1) differs from that of run_sample_seeded(2). (Acceptance bullet 2 — different seeds perturb the trace.)
  3. seed_recorded_in_manifestrun_sample_seeded(7)'s RunReport.manifest.seed == 7. (Acceptance bullet 3 — no longer always 0.)

(Each test destructures the (RunReport, SeededTrace) tuple: test 3 reads the trace, tests 45 read the report.)

Determinism (C1) is the property under test throughout: a fixed seed is a fixed run, a different seed a different-but-reproducible run.

Acceptance criteria

From #66, RED-first:

  • Two bootstraps of the same blueprint with the same seed produce a bit-identical sink trace.
  • Two different seeds produce different traces.
  • The seed that drove the run is recorded in RunManifest.seed (no longer always 0).

Plus the contract invariant that makes this the right precondition for #68/#52:

  • The seed→stream contract has signature Fn(u64) -> impl Source — it does not name Vec (a first impl may collect to VecSource internally).
  • All four existing seed-free runs (run_sample, run_sample_real, the sweep/showcase grid-report builder, run_macd) keep manifest.seed == 0 and byte-identical output (their determinism tests stay green).

Contracts touched: C12 (seed-as-input), C1 (determinism preserved by an explicit seed input, not broken), C3 (the seeded source is an ordinary ingestion-boundary Source).