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

319 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `Source`s 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:
```rust
// #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
```rust
// 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)
```rust
// 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_differs``spec.source(1)` and `spec.source(2)`
drain to different streams.
**End-to-end seed-as-input tests (aura-cli) — the issue's acceptance:**
3. `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.
4. `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.)
5. `seed_recorded_in_manifest``run_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`).