1ebb94c1b8
RunManifest gains defaults: Vec<(String, Scalar)> — the wrap-prefixed bound_param_space() of the signal, read after axis reopening, so a bound param an axis overrode has already left the space and flows through params instead (disjoint by construction; verified end to end: a sweep member's overridden fast.length sits in params while slow.length/bias.scale sit in defaults). params keeps its "what varied" semantics and stays the reproduce input. One-directional serde widening (#[serde(default)]) per the selection/instrument/topology_hash idiom — old records deserialize with an empty defaults; unlike the Option fields it always serializes, mirroring params. ~20 struct-literal sites across five crates gained the field (compile-mandated breadth, no behaviour change at those sites). The C14 ledger records the underlying decision (2026-07-13): generated outcome records spend redundancy on direct readability (single writer, cannot drift); authored intent artifacts admit none (every redundancy is a drift site) — so the fix lands in the manifest, never the blueprint. Verification: RED test run_manifest_stamps_untouched_bound_defaults green; cargo build --workspace; cargo test --workspace green; clippy -D warnings on the touched crates; binary-level sweep exclusivity check.
249 lines
11 KiB
Rust
249 lines
11 KiB
Rust
//! End-to-end coverage for the random param-sweep axis (C12.1):
|
|
//! `RandomSpace` + `ParamRange` driven through the **public** `sweep` surface a
|
|
//! downstream researcher actually writes (the worked author example).
|
|
//!
|
|
//! The in-module unit tests in `sweep.rs` reach into crate internals
|
|
//! (`bootstrap_with_cells`, `sweep_with_threads`, `SplitMix64`); these tests use
|
|
//! only the exported API, so they pin the properties a real consumer observes:
|
|
//! a `SweepFamily` of `RunReport`s, the typed `SweepError` gate, and the
|
|
//! `named_params` view. The blueprint is reconstructed here (the crate-private
|
|
//! `test_fixtures` harness is unreachable from an integration test) through the
|
|
//! public `Composite` builder + `aura-std` nodes, so the test exercises the same
|
|
//! published surface the worked example does.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Cell, Firing, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, OutField, ParamRange, ParamSpec,
|
|
RandomSpace, Role, RunManifest, RunReport, Scalar, Space, SweepError, Target, VecSource,
|
|
};
|
|
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
|
|
|
|
/// Seven synthetic F64 ticks (mirrors the crate-private `synthetic_prices`):
|
|
/// short enough that small SMA windows warm up, so every run yields finite,
|
|
/// non-degenerate metrics. Deterministic input fixture.
|
|
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
|
[
|
|
(1_i64, 1.0000_f64),
|
|
(2, 1.0010),
|
|
(3, 1.0030),
|
|
(4, 1.0060),
|
|
(5, 1.0040),
|
|
(6, 1.0010),
|
|
(7, 0.9990),
|
|
]
|
|
.iter()
|
|
.map(|&(t, p)| (Timestamp(t), Scalar::f64(p)))
|
|
.collect()
|
|
}
|
|
|
|
/// The SMA-cross signal-quality harness built through the PUBLIC builder API:
|
|
/// `[fast: I64, slow: I64, scale: F64]` param-space, ending in an equity sink and
|
|
/// an exposure sink. Returns the blueprint plus its two recording receivers (a
|
|
/// fresh channel pair per build, so each swept point runs disjointly — C1).
|
|
#[allow(clippy::type_complexity)]
|
|
fn sma_cross_harness() -> (
|
|
Composite,
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
|
) {
|
|
let (tx_eq, rx_eq) = mpsc::channel();
|
|
let (tx_ex, rx_ex) = mpsc::channel();
|
|
let sma_cross = Composite::new(
|
|
"sma_cross",
|
|
vec![
|
|
Sma::builder().named("fast").into(),
|
|
Sma::builder().named("slow").into(),
|
|
Sub::builder().into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
|
source: None,
|
|
}],
|
|
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
|
);
|
|
let bp = Composite::new(
|
|
"root",
|
|
vec![
|
|
BlueprintNode::Composite(sma_cross),
|
|
Bias::builder().into(),
|
|
SimBroker::builder(0.0001).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
|
],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Bias
|
|
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
|
|
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
|
|
],
|
|
vec![Role {
|
|
name: "src".into(),
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
|
|
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
|
|
],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![], // root ends in sinks
|
|
);
|
|
(bp, rx_eq, rx_ex)
|
|
}
|
|
|
|
/// Build + bootstrap + run + summarize one swept point into a `RunReport`, using
|
|
/// only the public surface. A fresh harness (fresh sink channels) per point keeps
|
|
/// the runs disjoint (C1). The manifest is a fixed minimal fixture — only the
|
|
/// metrics carry the run, so determinism makes this reproduce a point exactly.
|
|
fn run_point(point: &[Cell]) -> RunReport {
|
|
let (bp, rx_eq, rx_ex) = sma_cross_harness();
|
|
let mut h = bp
|
|
.bootstrap_with_cells(point)
|
|
.expect("RandomSpace-drawn points are pre-validated against the param-space");
|
|
h.run(vec![Box::new(VecSource::new(synthetic_prices()))]);
|
|
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
|
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
|
RunReport {
|
|
manifest: RunManifest {
|
|
commit: "random-sweep-e2e".to_string(),
|
|
params: Vec::new(),
|
|
defaults: Vec::new(),
|
|
window: (Timestamp(0), Timestamp(0)),
|
|
seed: 0,
|
|
broker: "test".to_string(),
|
|
selection: None,
|
|
instrument: None,
|
|
topology_hash: None,
|
|
project: None,
|
|
},
|
|
metrics: summarize(&equity, &exposure),
|
|
}
|
|
}
|
|
|
|
/// A `RandomSpace` over the harness's `[fast, slow, scale]` param-space: integer
|
|
/// windows drawn from the 7-tick fixture's proven domain (so SMAs warm up) and a
|
|
/// continuous scale. The integer ranges are exact single points so the family
|
|
/// stays small but every kind/arm is exercised.
|
|
fn sma_cross_random(count: usize, seed: u64) -> RandomSpace {
|
|
let space = sma_cross_harness().0.param_space();
|
|
RandomSpace::new(
|
|
&space,
|
|
vec![
|
|
ParamRange::i64(2, 3), // fast in [2, 3]
|
|
ParamRange::i64(4, 5), // slow in [4, 5]
|
|
ParamRange::f64(0.25, 1.5), // scale in [0.25, 1.5)
|
|
],
|
|
count,
|
|
seed,
|
|
)
|
|
.expect("ranges match the sample param-space kinds")
|
|
}
|
|
|
|
/// Property: a `RandomSpace` sweep is fully seed-determined end-to-end — the same
|
|
/// `(ranges, count, seed)` driven through the public `sweep` produces a
|
|
/// bit-identical `SweepFamily` of `RunReport`s, run after run (C1). The whole
|
|
/// pipeline (seeded draw -> bootstrap -> run -> summarize) reproduces, observed at
|
|
/// the published JSON boundary, not the internal `points()`.
|
|
#[test]
|
|
fn random_sweep_is_reproducible_at_the_report_boundary() {
|
|
let render = |seed: u64| -> Vec<String> {
|
|
sweep(&sma_cross_random(8, seed), run_point)
|
|
.points
|
|
.iter()
|
|
.map(|p| p.report.to_json())
|
|
.collect()
|
|
};
|
|
let a = render(0xC0FFEE);
|
|
let b = render(0xC0FFEE);
|
|
assert_eq!(a.len(), 8, "count points were swept");
|
|
assert_eq!(a, b, "same (ranges, count, seed) => bit-identical family (C1)");
|
|
}
|
|
|
|
/// Property: a different seed produces a different family — the sweep genuinely
|
|
/// samples the seed, it does not collapse to a constant set of points. Observed
|
|
/// via the public `named_params` coordinate view, never an internal field.
|
|
#[test]
|
|
fn random_sweep_seed_changes_the_family() {
|
|
let coords = |seed: u64| -> Vec<Vec<Scalar>> {
|
|
let family = sweep(&sma_cross_random(8, seed), run_point);
|
|
(0..family.points.len())
|
|
.map(|i| family.named_params(i).into_iter().map(|(_, v)| v).collect())
|
|
.collect()
|
|
};
|
|
assert_ne!(
|
|
coords(1),
|
|
coords(2),
|
|
"different seeds => different swept coordinate sets",
|
|
);
|
|
}
|
|
|
|
/// Property: every coordinate the sweep actually ran on lies inside its declared
|
|
/// `ParamRange` — the I64 slots inclusive `[lo, hi]`, the F64 slot half-open
|
|
/// `[lo, hi)`. A regression that let a draw escape its range would silently run
|
|
/// the strategy out of its declared domain; this pins the bound at the observable
|
|
/// `named_params` view of the family that was run.
|
|
#[test]
|
|
fn swept_points_stay_inside_their_declared_ranges() {
|
|
let family = sweep(&sma_cross_random(200, 0xABCDEF), run_point);
|
|
assert_eq!(family.points.len(), 200);
|
|
for i in 0..family.points.len() {
|
|
let named = family.named_params(i);
|
|
let fast = named[0].1.as_i64();
|
|
let slow = named[1].1.as_i64();
|
|
let scale = named[2].1.as_f64();
|
|
assert!((2..=3).contains(&fast), "fast in [2,3] inclusive, got {fast}");
|
|
assert!((4..=5).contains(&slow), "slow in [4,5] inclusive, got {slow}");
|
|
assert!((0.25..1.5).contains(&scale), "scale in [0.25,1.5), got {scale}");
|
|
// and the run that consumed this in-range point produced a finite metric
|
|
assert!(family.points[i].report.metrics.total_pips.is_finite());
|
|
}
|
|
}
|
|
|
|
/// Property: the typed validation gate rejects a non-numeric param slot BEFORE
|
|
/// any run. A `Bool` slot cannot carry a continuous range (it is degenerate), so
|
|
/// `RandomSpace::new` returns the public `SweepError::NonNumericRange` value — an
|
|
/// observable typed error at the published API, not a panic and not a swept run.
|
|
#[test]
|
|
fn bool_slot_is_rejected_as_non_numeric_before_any_run() {
|
|
let space = vec![ParamSpec { name: "flag".into(), kind: ScalarKind::Bool }];
|
|
let err = RandomSpace::new(&space, vec![ParamRange::i64(0, 1)], 10, 0)
|
|
.expect_err("a Bool slot is not range-sampleable");
|
|
assert_eq!(err, SweepError::NonNumericRange { slot: 0, kind: ScalarKind::Bool });
|
|
}
|
|
|
|
/// Property: a `count == 0` `RandomSpace` is a valid, explicit empty family (not
|
|
/// the "accidental collapse" an empty grid axis would be) — `sweep` over it
|
|
/// returns an empty `SweepFamily` while still carrying the param-space schema, so
|
|
/// a downstream `named_params` consumer sees a well-formed empty result.
|
|
#[test]
|
|
fn zero_count_sweep_is_a_well_formed_empty_family() {
|
|
let space = sma_cross_harness().0.param_space();
|
|
let rs = sma_cross_random(0, 0);
|
|
assert!(rs.is_empty(), "count == 0 is the explicit empty space");
|
|
let family = sweep(&rs, run_point);
|
|
assert!(family.points.is_empty(), "no points swept");
|
|
assert_eq!(family.space, space, "the empty family still carries the schema");
|
|
}
|
|
|
|
/// Property: `GridSpace` and `RandomSpace` are interchangeable through the `Space`
|
|
/// trait `sweep` is generic over — the same generic helper drives either
|
|
/// enumeration. This is the trait abstraction the cut introduced, observed via the
|
|
/// public `Space::param_specs`, kept from regressing back to a `GridSpace`-only
|
|
/// `sweep` signature.
|
|
#[test]
|
|
fn random_space_is_driven_through_the_space_trait() {
|
|
fn schema_len<S: Space>(s: &S) -> usize {
|
|
s.param_specs().len()
|
|
}
|
|
let rs = sma_cross_random(4, 7);
|
|
assert_eq!(schema_len(&rs), 3, "the [fast, slow, scale] schema reaches the trait surface");
|
|
// and the generic `sweep` accepts it by value of the same bound
|
|
let family = sweep(&rs, run_point);
|
|
assert_eq!(family.points.len(), 4);
|
|
}
|