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
This commit is contained in:
@@ -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<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:
|
||||
|
||||
```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).
|
||||
Reference in New Issue
Block a user