d858caf67b
docs/specs and docs/plans were retired (prior commit); the source/test
comments that cited them ("spec 0050 §4.1", "spec §Testing N", "per spec",
"the spec's ...") now point at nothing. Strip every such pointer while
preserving the technical substance, the design-ledger contract refs
(C1/C11/C20/C34/C12.1/...), and the Gitea issue refs (#41).
Comment/doc edits only across 20 files — no logic change; full workspace
suite green, clippy clean.
244 lines
10 KiB
Rust
244 lines
10 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::{Exposure, 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),
|
|
Exposure::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 -> Exposure
|
|
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(),
|
|
window: (Timestamp(0), Timestamp(0)),
|
|
seed: 0,
|
|
broker: "test".to_string(),
|
|
},
|
|
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);
|
|
}
|