Files
claude a56ab7859d refactor: cut the engine's backtest-metrics edge via RunReport<M>
C28 phase 2 (Stratification); realizes item 1 of the deferred #147. The
engine's production surface no longer names a backtest-metric type:

- RunReport becomes generic over its metric payload M; sweep/mc/walkforward/
  blueprint thread the parameter (SweepPoint<M>, SweepFamily<M>, WindowRun<M>,
  WalkForwardResult<M>). RunManifest stays concrete and engine-owned (its
  selection: Option<FamilySelection> embeds the foundation-grade analysis type).
- summarize and the MC assembly (McDraw/McFamily/McAggregate/RBootstrap/
  r_bootstrap/monte_carlo) move to aura-backtest - McAggregate::from_draws reads
  RunMetrics fields by name, so generifying it is the phase-6 metric-vocabulary
  abstraction (#147 item 2), still deferred; wholesale relocation is the honest
  cut. The concrete instantiation lives in aura-backtest as
  `type RunReport = aura_engine::RunReport<RunMetrics>` + sibling aliases.
- the statistics kernel (MetricStats/quantile/resample_block/SplitMix64) moves
  to the aura-analysis foundation; the engine re-imports it (inner->foundation,
  legal) and re-exports it so existing consumers stay source-compatible.

Dependency inversion in one commit: aura-engine drops aura-backtest from
[dependencies] (back to dev-deps for its SimBroker/RunMetrics test fixtures);
aura-backtest gains aura-engine. Cycle-free for lib targets - the cycle closes
only through the engine's dev-dep edge, the pattern aura-vocabulary already
uses. aura-backtest reaches the kernel transitively through the engine
re-export, so no aura-backtest -> aura-analysis edge exists (the C28 ladder
permits backtest -> {core, engine} only). run_indexed / SplitMix64::next_f64
widened pub(crate) -> pub for cross-crate use.

Consumers (registry/campaign/cli/composites/ingest/bench) rewired by import
path only, no call-site logic changed. The c28_layering structural test extends
to the full ladder: aura-analysis (no aura-* deps), aura-engine ⊆ {core,
analysis}, aura-backtest ⊆ {core, engine}.

Behaviour-preserving: 1448/0 tests, clippy -D warnings clean, serde shapes
byte-identical (C18 - RunReport<M> keeps field order manifest,metrics; the
CLI pre-serialized-splice contract unchanged), moved code traceable via git
rename detection. Cycle-introduced broken intra-doc links fixed.

closes #292
2026-07-20 13:25:07 +02:00

175 lines
6.9 KiB
Rust

//! End-to-end coverage for `ListSpace` (cycle 0107 task 2, C12.1): an explicit,
//! non-lattice point set — modelled on `random_sweep_e2e.rs`'s worked-author
//! pattern — driven through the PUBLIC `sweep` surface with a REAL bootstrapped
//! harness (not a fake closure), so the property under test is that `ListSpace`
//! integrates with the actual bootstrap -> run -> summarize pipeline the same
//! way `GridSpace`/`RandomSpace` do, exercising the exact use case `ListSpace`
//! exists for: re-running an arbitrary member subset (e.g. a gate's survivors)
//! that no cartesian `GridSpace` can represent.
use std::sync::mpsc;
use aura_core::{Cell, Firing, ScalarKind, Timestamp};
use aura_engine::{
f64_field, sweep, BlueprintNode, Composite, Edge, ListSpace, OutField, ParamSpec,
Role, RunManifest, RunReport, Scalar, SweepError, Target, VecSource,
};
use aura_backtest::{summarize, RunMetrics, SimBroker};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
/// Seven synthetic F64 ticks — the same fixture shape as `random_sweep_e2e.rs`,
/// short enough that small SMA windows warm up deterministically.
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 same SMA-cross `[fast: I64, slow: I64, scale: F64]` harness as
/// `random_sweep_e2e.rs`, built through the public builder API.
#[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 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 1, to: 4, slot: 0, from_field: 0 },
],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
(bp, rx_eq, rx_ex)
}
/// Build + bootstrap + run + summarize one point into a real `RunReport` — the
/// full pipeline `ListSpace` must survive end-to-end, mirroring
/// `random_sweep_e2e.rs::run_point`.
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
let (bp, rx_eq, rx_ex) = sma_cross_harness();
let mut h = bp.bootstrap_with_cells(point).expect("ListSpace validates points before sweep");
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: "list-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),
}
}
/// Property: `ListSpace` reproduces the exact use case it exists for — an
/// arbitrary, non-lattice member subset (e.g. what a gate stage's survivor
/// ordinals would pick back out of a prior sweep family) driven through a REAL
/// bootstrapped harness produces one finite-metric `RunReport` per given point,
/// in the given order — never the cartesian product a `GridSpace` over the same
/// values would enumerate.
#[test]
fn list_space_reruns_an_arbitrary_survivor_subset_through_a_real_harness() {
let space = sma_cross_harness().0.param_space();
// a hand-picked, non-lattice subset: point 1 and point 2 share no axis value
// pairwise with what a full [fast]x[slow]x[scale] grid over these values
// would also produce as *other* combinations — this is a strict subset, not
// a grid, and `ListSpace` must run exactly these three, nothing else.
let survivors = vec![
vec![Scalar::i64(2), Scalar::i64(4), Scalar::f64(1.0)],
vec![Scalar::i64(3), Scalar::i64(5), Scalar::f64(0.5)],
vec![Scalar::i64(2), Scalar::i64(5), Scalar::f64(0.75)],
];
let ls = ListSpace::new(&space, survivors.clone()).expect("valid survivor subset");
let family = sweep(&ls, run_point);
assert_eq!(family.points.len(), 3, "exactly the given survivor points ran, no more");
for (i, expected) in survivors.iter().enumerate() {
let named = family.named_params(i);
let got: Vec<Scalar> = named.into_iter().map(|(_, v)| v).collect();
assert_eq!(&got, expected, "point {i} carried through in input order, untouched");
assert!(
family.points[i].report.metrics.total_pips.is_finite(),
"point {i} ran through the real bootstrap -> run -> summarize pipeline"
);
}
}
/// Property: the typed validation gate on `ListSpace::new` rejects a
/// wrong-kind value BEFORE any run — a `F64` value in the harness's `I64`
/// `fast` slot returns the public `SweepError::KindMismatch`, never a panic
/// and never a run of the malformed point.
#[test]
fn list_space_rejects_wrong_kind_value_before_any_run() {
let space = sma_cross_harness().0.param_space();
let err = ListSpace::new(
&space,
vec![vec![Scalar::f64(2.0), Scalar::i64(4), Scalar::f64(1.0)]],
)
.expect_err("fast slot is I64, not F64");
assert_eq!(
err,
SweepError::KindMismatch {
slot: 0,
value_index: 0,
expected: ScalarKind::I64,
got: ScalarKind::F64,
}
);
// and the schema type stays reachable even on the rejection path (a real
// author inspects it to correct the point)
let _: &[ParamSpec] = &space;
}