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
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
# 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 4–5 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`).
|
||||
Reference in New Issue
Block a user