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
This commit is contained in:
2026-07-20 13:25:07 +02:00
parent 94aaa4cde8
commit a56ab7859d
45 changed files with 768 additions and 593 deletions
Generated
+8
View File
@@ -101,6 +101,7 @@ name = "aura-analysis"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
@@ -108,6 +109,10 @@ name = "aura-backtest"
version = "0.1.0"
dependencies = [
"aura-core",
"aura-engine",
"aura-std",
"aura-strategy",
"rayon",
"serde",
"serde_json",
]
@@ -117,6 +122,7 @@ name = "aura-bench"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-core",
"aura-engine",
"aura-ingest",
@@ -236,6 +242,8 @@ dependencies = [
name = "aura-registry"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-backtest",
"aura-core",
"aura-engine",
"aura-research",
+5
View File
@@ -11,3 +11,8 @@ publish.workspace = true
# (de)serialization path for the run registry (C18). Deterministic output
# (C1-safe). The backtest metric types moved to aura-backtest (#291).
serde = { workspace = true }
[dev-dependencies]
# serde_json is used only by the moved MetricStats round-trip test (#291 Task
# 1); production JSON rendering lives at the engine/backtest boundary.
serde_json = { workspace = true }
+165
View File
@@ -102,6 +102,102 @@ pub fn expected_max_of_normals(k: usize) -> f64 {
+ GAMMA * inv_norm_cdf(1.0 - 1.0 / (kf * std::f64::consts::E))
}
/// 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).
pub struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
pub fn new(seed: u64) -> Self {
Self { state: seed }
}
pub 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.
pub fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
}
}
/// Mean + a fixed quantile set of one metric across the realizations. `p50` is
/// the median (the two coincide by definition), so "mean/median/quantiles" is
/// `mean` + `p50` + the surrounding quantiles, with no redundant field.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MetricStats {
pub mean: f64,
pub p5: f64,
pub p25: f64,
pub p50: f64, // == median
pub p75: f64,
pub p95: f64,
}
impl MetricStats {
/// Mean + the fixed type-7 quantile set over a value set. Sorts a copy;
/// `values` must be finite and non-empty. The shared reduction behind both the
/// MC aggregate (a metric across draws) and the walk-forward param-stability
/// summary (a param across windows; `aura-engine`'s `param_stability`).
pub fn from_values(values: &[f64]) -> MetricStats {
let mut xs = values.to_vec();
xs.sort_by(|a, b| a.partial_cmp(b).expect("values are finite"));
MetricStats {
mean: xs.iter().sum::<f64>() / xs.len() as f64,
p5: quantile(&xs, 0.05),
p25: quantile(&xs, 0.25),
p50: quantile(&xs, 0.50),
p75: quantile(&xs, 0.75),
p95: quantile(&xs, 0.95),
}
}
}
/// Linear-interpolation quantile (the numpy/Excel "type 7" default) over a
/// pre-sorted, non-empty slice. `p` in `[0, 1]`. `rank = p * (n - 1)`;
/// interpolate between the two bracketing order statistics. `n == 1` returns the
/// sole value.
pub fn quantile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = p * (n - 1) as f64;
let lo = rank.floor() as usize;
let frac = rank - lo as f64;
if lo + 1 < n {
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
} else {
sorted[n - 1]
}
}
/// One moving-block resample of `rs` to length `rs.len()` (non-circular; the final
/// block is truncated so the resample has exactly `n` values). `block_len` must be
/// pre-clamped to `[1, rs.len()]` by the caller. Pure given the `rng` state — the
/// shared kernel behind `r_bootstrap` and the trials-deflation reality-check (C1).
pub fn resample_block(rs: &[f64], block_len: usize, rng: &mut SplitMix64) -> Vec<f64> {
let n = rs.len();
debug_assert!(
(1..=n).contains(&block_len),
"resample_block requires block_len in [1, rs.len()] (caller pre-clamps); \
got block_len={block_len}, rs.len()={n}",
);
let mut sample: Vec<f64> = Vec::with_capacity(n);
while sample.len() < n {
let start = (rng.next_u64() % (n - block_len + 1) as u64) as usize;
let take = block_len.min(n - sample.len());
sample.extend_from_slice(&rs[start..start + take]);
}
sample
}
#[cfg(test)]
mod tests {
use super::*;
@@ -125,4 +221,73 @@ mod tests {
assert!((1.0..1.1).contains(&e4)); // ~1.05 (not the √(2 ln 4)=1.66 asymptote)
assert!((2.4..2.7).contains(&e100)); // ~2.53
}
#[test]
fn quantile_endpoints_and_singleton() {
// singleton returns the sole value; p=0 -> min, p=1 -> max.
assert_eq!(quantile(&[42.0], 0.0), 42.0);
assert_eq!(quantile(&[42.0], 0.5), 42.0);
assert_eq!(quantile(&[42.0], 1.0), 42.0);
let xs = [1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(quantile(&xs, 0.0), 1.0);
assert_eq!(quantile(&xs, 1.0), 5.0);
}
#[test]
fn metric_stats_from_values_matches_known_fixture() {
// type-7 quantile + mean over [0,1,2,3,4], directly on the
// extracted reduction: mean=2.0, p50=2.0, p5≈0.2, p95≈3.8 (same numbers the
// aggregate fixture pins, now on from_values).
let s = MetricStats::from_values(&[0.0, 1.0, 2.0, 3.0, 4.0]);
assert_eq!(s.mean, 2.0);
assert_eq!(s.p50, 2.0);
assert!((s.p5 - 0.2).abs() < 1e-9, "p5 = {}", s.p5);
assert!((s.p95 - 3.8).abs() < 1e-9, "p95 = {}", s.p95);
}
#[test]
fn metric_stats_serde_round_trips() {
// MetricStats gains serde (consistent with the
// report types) so the CLI summary renders it and #70 lineage can persist.
let s = MetricStats::from_values(&[1.0, 2.0, 3.0]);
let json = serde_json::to_string(&s).expect("serialize MetricStats");
let back: MetricStats = serde_json::from_str(&json).expect("deserialize MetricStats");
assert_eq!(back, s);
}
#[test]
fn resample_block_returns_n_values_from_contiguous_runs() {
// The extracted kernel's standalone contract (now a public entry point):
// given a pre-clamped block_len in [1, n], it yields exactly `n` values, each
// appearing in `rs`, arranged as contiguous runs of `rs` (the final run
// truncated to reach exactly n). Distinct powers of ten make membership a sum
// check. n=5, block_len=2 -> runs of length 2, 2, 1.
let rs = [1.0, 10.0, 100.0, 1000.0, 10000.0];
let n = rs.len();
let block_len = 2;
let mut rng = SplitMix64::new(7);
let sample = resample_block(&rs, block_len, &mut rng);
assert_eq!(sample.len(), n, "resample has exactly n values");
assert!(
sample.iter().all(|v| rs.contains(v)),
"every resampled value is drawn from rs",
);
// Reconstruct the contiguous-run structure: walk the sample in blocks of
// `block_len` (last truncated) and assert each block is a contiguous slice of rs.
let mut filled = 0usize;
while filled < n {
let take = block_len.min(n - filled);
let run = &sample[filled..filled + take];
let start = rs
.iter()
.position(|v| v == &run[0])
.expect("run head is in rs");
assert_eq!(
run,
&rs[start..start + take],
"each run is a contiguous slice of rs",
);
filled += take;
}
}
}
+18
View File
@@ -7,6 +7,14 @@ publish.workspace = true
[dependencies]
aura-core = { path = "../aura-core" }
# aura-engine is the metric-generic engine this crate instantiates with
# `M = RunMetrics` (`RunReport`/`SweepFamily`/etc. aliases), whose
# `run_indexed` the moved MC assembly drives, and whose re-exported
# `MetricStats`/`resample_block`/`SplitMix64` (foundation-grade, from
# aura-analysis) the moved `mc.rs` reduces through — no direct aura-analysis
# edge, keeping the C28 ladder one-hop (#292, C28 phase 2). Outer-rung
# dependency — the engine itself only dev-depends back on this crate.
aura-engine = { path = "../aura-engine" }
# serde gives the backtest metric types (`RunMetrics`/`RMetrics`/`PositionEvent`)
# their typed (de)serialization path for the run registry (C18; moved with the
# types from aura-analysis, #291). Deterministic output (C1-safe).
@@ -17,3 +25,13 @@ serde = { workspace = true }
# for production lives in aura-engine's RunReport::to_json). Test-only, so it
# never enters the frozen artifact's library link (C16).
serde_json = { workspace = true }
# aura-std / aura-strategy back the moved `mc.rs`/`metrics.rs` test fixtures
# (Sma/Sub/Recorder, Bias) — the crate-private `aura-engine::test_fixtures`
# harness is unreachable from here, so the fixture is reconstructed through
# the public node vocabulary instead (test-only, mirrors the aura-engine
# integration tests' own pattern).
aura-std = { path = "../aura-std" }
aura-strategy = { path = "../aura-strategy" }
# rayon backs the thread-count-explicit `monte_carlo_with_threads` test
# wrapper (moved verbatim from aura-engine's mc.rs, #291) — test-only.
rayon = "1"
+19 -3
View File
@@ -1,15 +1,22 @@
//! `aura-backtest` — the backtest layer (C28): simulated execution without
//! money (the `SimBroker` pip yardstick, position management), measured in R,
//! plus the backtest metric reductions over the recorded streams
//! (`metrics`, moved from `aura-analysis` by #291). Depends only on
//! `aura-core` (+ serde for the metric types' C18 wire shapes).
//! (`metrics`, moved from `aura-analysis` by #291) and the Monte-Carlo
//! assembly (`mc`, moved from `aura-engine` by #291/#292, C28 phase 2). The
//! outer rung of the metric-generic engine (C28): `RunReport` here is the
//! concrete `M = RunMetrics` instantiation of `aura_engine::RunReport<M>`.
mod mc;
mod metrics;
mod position_management;
mod sim_broker;
// `assemble_mc` stays module-private (it was never `pub` in the pre-#291 engine
// lib.rs re-export either — only its two callers, `monte_carlo` and the
// `#[cfg(test)]` thread-count wrapper, are public/test surface).
pub use mc::{monte_carlo, r_bootstrap, McAggregate, McDraw, McFamily, RBootstrap};
pub use metrics::{
derive_position_events, r_metrics_from_rs, summarize_r, PositionAction,
derive_position_events, r_metrics_from_rs, summarize, summarize_r, PositionAction,
PositionEvent, RMetrics, RunMetrics,
};
pub use position_management::{
@@ -17,3 +24,12 @@ pub use position_management::{
RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH,
};
pub use sim_broker::SimBroker;
/// The trading instantiation of the engine's metric-generic run record (C28
/// phase 2, #292): source-compatible concrete names for every consumer that
/// deals in backtest metrics.
pub type RunReport = aura_engine::RunReport<RunMetrics>;
pub type SweepPoint = aura_engine::SweepPoint<RunMetrics>;
pub type SweepFamily = aura_engine::SweepFamily<RunMetrics>;
pub type WindowRun = aura_engine::WindowRun<RunMetrics>;
pub type WalkForwardResult = aura_engine::WalkForwardResult<RunMetrics>;
@@ -3,18 +3,25 @@
//! a seed set — each seed a disjoint C1 realization — and collects an
//! [`McFamily`]: the per-seed [`McDraw`]s in seed-input order plus a stored
//! [`McAggregate`] (mean + quantiles of all three run metrics across the
//! realizations). It reuses the disjoint-parallel [`run_indexed`](crate::sweep)
//! realizations). It reuses the disjoint-parallel [`run_indexed`](aura_engine::run_indexed)
//! core `sweep` drives — the varying dimension is the *seed*, not a tuning param.
//! Eager-agnostic (C12/#71): the API takes seeds + a per-draw closure, never a
//! materialized stream `Vec`; the seed -> `Source` construction is a closure-body
//! concern.
//! concern. Moved verbatim from `aura-engine::mc` (#291, C28 phase 2): the
//! engine stays metric-agnostic, so the concrete `RunMetrics` instantiation of
//! `RunReport` lives here, the outer C28 rung.
use crate::sweep::run_indexed;
use crate::{RunMetrics, RunReport, Scalar};
use crate::harness::SplitMix64;
// MetricStats/resample_block/SplitMix64 are foundation-grade (aura-analysis),
// but reached here through aura-engine's re-export rather than a direct
// aura-analysis dependency — the C28 ladder (c28_layering) permits
// aura-backtest -> {aura-core, aura-engine} only, not a same-rung-skipping
// direct edge to the foundation crate.
use aura_core::Scalar;
use aura_engine::{run_indexed, resample_block, MetricStats, SplitMix64};
use crate::{RunMetrics, RunReport};
/// One Monte-Carlo realization: the seed that drove it and the full `RunReport`.
/// Self-describing, analog to [`SweepPoint`](crate::SweepPoint).
/// Self-describing, analog to [`SweepPoint`](aura_engine::SweepPoint).
#[derive(Clone, Debug, PartialEq)]
pub struct McDraw {
pub seed: u64,
@@ -23,7 +30,7 @@ pub struct McDraw {
/// The result family of a Monte-Carlo run — one [`McDraw`] per seed, in
/// seed-**input** order (independent of thread completion), plus the stored
/// [`McAggregate`]. Analog to [`SweepFamily`](crate::SweepFamily).
/// [`McAggregate`]. Analog to [`SweepFamily`](aura_engine::SweepFamily).
#[derive(Clone, Debug, PartialEq)]
pub struct McFamily {
pub draws: Vec<McDraw>,
@@ -41,38 +48,6 @@ pub struct McAggregate {
pub bias_sign_flips: MetricStats,
}
/// Mean + a fixed quantile set of one metric across the realizations. `p50` is
/// the median (the two coincide by definition), so "mean/median/quantiles" is
/// `mean` + `p50` + the surrounding quantiles, with no redundant field.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct MetricStats {
pub mean: f64,
pub p5: f64,
pub p25: f64,
pub p50: f64, // == median
pub p75: f64,
pub p95: f64,
}
impl MetricStats {
/// Mean + the fixed type-7 quantile set over a value set. Sorts a copy;
/// `values` must be finite and non-empty. The shared reduction behind both the
/// MC aggregate (a metric across draws) and the walk-forward param-stability
/// summary (a param across windows, [`crate::param_stability`]).
pub fn from_values(values: &[f64]) -> MetricStats {
let mut xs = values.to_vec();
xs.sort_by(|a, b| a.partial_cmp(b).expect("values are finite"));
MetricStats {
mean: xs.iter().sum::<f64>() / xs.len() as f64,
p5: quantile(&xs, 0.05),
p25: quantile(&xs, 0.25),
p50: quantile(&xs, 0.50),
p75: quantile(&xs, 0.75),
p95: quantile(&xs, 0.95),
}
}
}
impl McAggregate {
/// Pure reduction over the realizations — recomputable from `draws` alone (it
/// is exactly what [`monte_carlo`] stored). `draws` must be non-empty (a
@@ -91,25 +66,6 @@ impl McAggregate {
}
}
/// Linear-interpolation quantile (the numpy/Excel "type 7" default) over a
/// pre-sorted, non-empty slice. `p` in `[0, 1]`. `rank = p * (n - 1)`;
/// interpolate between the two bracketing order statistics. `n == 1` returns the
/// sole value.
fn quantile(sorted: &[f64], p: f64) -> f64 {
let n = sorted.len();
if n == 1 {
return sorted[0];
}
let rank = p * (n - 1) as f64;
let lo = rank.floor() as usize;
let frac = rank - lo as f64;
if lo + 1 < n {
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
} else {
sorted[n - 1]
}
}
/// The single assembly path behind both [`monte_carlo`] and the thread-count-
/// explicit test wrapper: run `run_one` over every seed via `run_indexed` (on
/// whichever pool is ambient when called), build the per-seed draws in
@@ -139,7 +95,7 @@ where
///
/// Precondition: `seeds` is non-empty (a Monte-Carlo over zero realizations has
/// no defined aggregate). A `debug_assert!` guards it; the `-> McFamily`
/// signature is preserved (no `Result`), matching [`sweep`](crate::sweep).
/// signature is preserved (no `Result`), matching [`sweep`](aura_engine::sweep).
pub fn monte_carlo<F>(base_point: &[Scalar], seeds: &[u64], run_one: F) -> McFamily
where
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
@@ -186,26 +142,6 @@ pub struct RBootstrap {
pub n_resamples: usize,
}
/// One moving-block resample of `rs` to length `rs.len()` (non-circular; the final
/// block is truncated so the resample has exactly `n` values). `block_len` must be
/// pre-clamped to `[1, rs.len()]` by the caller. Pure given the `rng` state — the
/// shared kernel behind `r_bootstrap` and the trials-deflation reality-check (C1).
pub fn resample_block(rs: &[f64], block_len: usize, rng: &mut SplitMix64) -> Vec<f64> {
let n = rs.len();
debug_assert!(
(1..=n).contains(&block_len),
"resample_block requires block_len in [1, rs.len()] (caller pre-clamps); \
got block_len={block_len}, rs.len()={n}",
);
let mut sample: Vec<f64> = Vec::with_capacity(n);
while sample.len() < n {
let start = (rng.next_u64() % (n - block_len + 1) as u64) as usize;
let take = block_len.min(n - sample.len());
sample.extend_from_slice(&rs[start..start + take]);
}
sample
}
/// Moving-block bootstrap of `rs` (non-circular; the final block of each resample is
/// truncated so the resample has exactly `n` values). `block_len` is clamped to
/// `[1, n]`. Empty `rs` or zero resamples -> an all-zero `RBootstrap`. Pure given
@@ -236,9 +172,83 @@ pub fn r_bootstrap(rs: &[f64], n_resamples: usize, block_len: usize, seed: u64)
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::composite_sma_cross_harness;
use crate::{f64_field, summarize, RunManifest, SyntheticSpec, Timestamp};
use aura_core::Scalar;
use aura_engine::{
f64_field, BlueprintNode, Composite, Edge, OutField, Role, RunManifest, SyntheticSpec,
Target, Timestamp,
};
use aura_core::{Firing, Scalar, ScalarKind};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use crate::{summarize, SimBroker};
use std::sync::mpsc;
// The SMA-cross signal-quality harness fixture, reconstructed here through
// the PUBLIC aura-engine/aura-std/aura-strategy/aura-backtest surface —
// `aura-engine::test_fixtures` is `#[cfg(test)]`-private to the engine crate
// itself and unreachable from here (the same reason `random_sweep_e2e.rs` /
// `list_sweep_e2e.rs` carry their own copy). Bodies verbatim from
// `aura-engine/src/test_fixtures.rs`'s `sma_cross`/`composite_sma_cross_harness`.
/// The SMA-cross signal as a reusable value-empty composite: one input role
/// (price), one output (fast-minus-slow spread). Lengths injected at compile.
fn sma_cross() -> Composite {
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() }],
)
}
/// The signal-quality harness as a value-empty composite blueprint with two
/// recording sinks (equity, exposure).
#[allow(clippy::type_complexity)]
fn composite_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 bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(sma_cross()),
Bias::builder().named("bias").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![], // output: the root ends in sinks
);
(bp, rx_eq, rx_ex)
}
/// Build + bootstrap + run + drain + summarize one (seed, point) into a
/// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `monte_carlo`
@@ -371,75 +381,6 @@ mod tests {
assert!((agg.total_pips.p95 - 3.8).abs() < 1e-9, "p95 = {}", agg.total_pips.p95);
}
#[test]
fn quantile_endpoints_and_singleton() {
// singleton returns the sole value; p=0 -> min, p=1 -> max.
assert_eq!(quantile(&[42.0], 0.0), 42.0);
assert_eq!(quantile(&[42.0], 0.5), 42.0);
assert_eq!(quantile(&[42.0], 1.0), 42.0);
let xs = [1.0, 2.0, 3.0, 4.0, 5.0];
assert_eq!(quantile(&xs, 0.0), 1.0);
assert_eq!(quantile(&xs, 1.0), 5.0);
}
#[test]
fn metric_stats_from_values_matches_known_fixture() {
// type-7 quantile + mean over [0,1,2,3,4], directly on the
// extracted reduction: mean=2.0, p50=2.0, p5≈0.2, p95≈3.8 (same numbers the
// aggregate fixture pins, now on from_values).
let s = MetricStats::from_values(&[0.0, 1.0, 2.0, 3.0, 4.0]);
assert_eq!(s.mean, 2.0);
assert_eq!(s.p50, 2.0);
assert!((s.p5 - 0.2).abs() < 1e-9, "p5 = {}", s.p5);
assert!((s.p95 - 3.8).abs() < 1e-9, "p95 = {}", s.p95);
}
#[test]
fn metric_stats_serde_round_trips() {
// MetricStats gains serde (consistent with the
// report types) so the CLI summary renders it and #70 lineage can persist.
let s = MetricStats::from_values(&[1.0, 2.0, 3.0]);
let json = serde_json::to_string(&s).expect("serialize MetricStats");
let back: MetricStats = serde_json::from_str(&json).expect("deserialize MetricStats");
assert_eq!(back, s);
}
#[test]
fn resample_block_returns_n_values_from_contiguous_runs() {
// The extracted kernel's standalone contract (now a public entry point):
// given a pre-clamped block_len in [1, n], it yields exactly `n` values, each
// appearing in `rs`, arranged as contiguous runs of `rs` (the final run
// truncated to reach exactly n). Distinct powers of ten make membership a sum
// check. n=5, block_len=2 -> runs of length 2, 2, 1.
let rs = [1.0, 10.0, 100.0, 1000.0, 10000.0];
let n = rs.len();
let block_len = 2;
let mut rng = SplitMix64::new(7);
let sample = resample_block(&rs, block_len, &mut rng);
assert_eq!(sample.len(), n, "resample has exactly n values");
assert!(
sample.iter().all(|v| rs.contains(v)),
"every resampled value is drawn from rs",
);
// Reconstruct the contiguous-run structure: walk the sample in blocks of
// `block_len` (last truncated) and assert each block is a contiguous slice of rs.
let mut filled = 0usize;
while filled < n {
let take = block_len.min(n - filled);
let run = &sample[filled..filled + take];
let start = rs
.iter()
.position(|v| v == &run[0])
.expect("run head is in rs");
assert_eq!(
run,
&rs[start..start + take],
"each run is a contiguous slice of rs",
);
filled += take;
}
}
#[test]
fn r_bootstrap_empty_series_is_all_zero() {
let b = r_bootstrap(&[], 100, 1, 7);
+205 -1
View File
@@ -8,7 +8,34 @@
//! Every type's serde shape is byte-pinned by C18 goldens; no float expression
//! re-associated.
use aura_core::{Scalar, Timestamp};
use aura_core::{Scalar, SeriesFold, Timestamp};
/// Reduce a run's recorded pip-equity + exposure streams into summary metrics.
/// Pure — identical inputs yield identical metrics (C1/C12). Timestamps are
/// carried in the input to match exactly what a sink records; the reduction
/// itself is value-only (it does not read the timestamps).
pub fn summarize(
equity: &[(Timestamp, f64)],
exposure: &[(Timestamp, f64)],
) -> RunMetrics {
// total_pips + max_drawdown fold over equity; sign_flips folds over exposure.
// SeriesFold is the single arithmetic source of truth (shared with the
// SeriesReducer sink), so this is byte-identical to the prior inline loops.
let mut eqf = SeriesFold::new();
for &(_, v) in equity {
eqf.fold(v);
}
let mut exf = SeriesFold::new();
for &(_, v) in exposure {
exf.fold(v);
}
RunMetrics {
total_pips: eqf.last,
max_drawdown: eqf.max_drawdown,
bias_sign_flips: exf.sign_flips,
r: None,
}
}
/// Summary metrics reduced from a run's recorded streams — the `-> metrics`
/// half of C12's atomic sim unit. Pure function of the recorded streams.
@@ -1048,4 +1075,181 @@ mod tests {
assert_eq!(m.expectancy_r, 0.0);
assert_eq!(m.sqn, 0.0);
}
// The `summarize` end-to-end + unit tests, moved verbatim from
// `aura-engine::report` (#291, C28 phase 2): `run_once` drives a real
// bootstrapped Harness through public aura-engine/aura-std/aura-strategy
// APIs, so it needs no engine-private item.
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
use aura_engine::{f64_field, Edge, FlatGraph, Harness, RunManifest, RunReport, SourceSpec, Target, VecSource};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use crate::SimBroker;
use std::sync::mpsc;
/// The declared signature of a `Recorder` over one f64 column (the sink shape
/// the two-sink harness uses).
fn f64_recorder_sig() -> NodeSchema {
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
}
}
/// Build an f64 source stream from (timestamp, value) points (mirrors the
/// harness.rs test helper; the e2e test needs its own copy — the harness
/// test module's is private to that module).
fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect()
}
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
#[allow(clippy::type_complexity)]
fn build_two_sink_harness() -> (
Harness,
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 h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Bias::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec::raw(ScalarKind::F64, vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker
])],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
taps: Vec::new(),
})
.expect("valid signal-quality DAG");
(h, rx_eq, rx_ex)
}
fn run_once() -> RunReport<RunMetrics> {
let (mut h, rx_eq, rx_ex) = build_two_sink_harness();
h.run(vec![Box::new(VecSource::new(f64_stream(&[
(1, 1.0000),
(2, 1.0010),
(3, 1.0025),
(4, 1.0020),
(5, 1.0040),
])))]);
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 equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: "test-commit".to_string(),
params: vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
defaults: vec![],
window: (Timestamp(1), Timestamp(5)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics,
}
}
#[test]
fn report_is_deterministic_end_to_end() {
let r1 = run_once();
let r2 = run_once();
// a run actually emitted metrics over a non-empty pip curve
assert!(r1.metrics.total_pips.is_finite());
// same manifest -> same metrics (C1/C12): two runs are bit-identical
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
.enumerate()
.map(|(i, &v)| (Timestamp(i as i64 + 1), v))
.collect()
}
#[test]
fn summarize_total_pips_is_last_cumulative_value() {
let equity = samples(&[0.0, 5.0, 4.0, 12.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.total_pips, 12.0);
}
#[test]
fn summarize_is_zero_on_empty_streams() {
let m = summarize(&[], &[]);
assert_eq!(m.total_pips, 0.0);
assert_eq!(m.max_drawdown, 0.0);
assert_eq!(m.bias_sign_flips, 0);
}
#[test]
fn summarize_max_drawdown_is_worst_peak_to_trough() {
// peak 10 then trough 5 (drop 5), recovers to 8; worst drop is 5,
// not the final drop (10 -> 8 = 2).
let equity = samples(&[0.0, 10.0, 5.0, 8.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 5.0);
}
#[test]
fn summarize_max_drawdown_zero_on_monotonic_curve() {
let equity = samples(&[0.0, 1.0, 2.0, 3.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 0.0);
}
#[test]
fn summarize_sign_flips_counts_signum_changes() {
// signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3.
let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 3);
}
#[test]
fn summarize_sign_flips_zero_on_constant_sign() {
let exposure = samples(&[0.2, 0.5, 1.0, 0.7]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 0);
}
}
+4
View File
@@ -36,3 +36,7 @@ zip = "2"
# fingerprint coverage.
aura-registry = { path = "../aura-registry" }
aura-analysis = { path = "../aura-analysis" }
# the winner_fingerprint lockstep guard's RBootstrap literal (moved from
# aura-engine, #291/#292) — test-only, same coupling-guard rationale as the
# other three dev-deps above.
aura-backtest = { path = "../aura-backtest" }
+2 -1
View File
@@ -369,7 +369,8 @@ mod tests {
#[test]
fn winner_fingerprint_stays_in_lockstep_with_the_registry_record_types() {
use aura_analysis::{FamilySelection, SelectionMode};
use aura_engine::{MetricStats, RBootstrap};
use aura_backtest::RBootstrap;
use aura_engine::MetricStats;
use aura_registry::{CampaignRunRecord, CellRealization, StageRealization, StageSelection};
let stats = MetricStats { mean: 0.1, p5: 0.0, p25: 0.05, p50: 0.1, p75: 0.15, p95: 0.2 };
+7 -7
View File
@@ -12,8 +12,14 @@ publish.workspace = true
[dependencies]
# the scalar vocabulary: params cross the MemberRunner seam as (name, Scalar) pairs.
aura-core = { path = "../aura-core" }
# RunReport (the member result type) + the sweep/walk_forward orchestration machinery.
# the sweep/walk_forward orchestration machinery (metric-agnostic; RunReport
# itself is generic here — the M = RunMetrics instantiation is aura-backtest's).
aura-engine = { path = "../aura-engine" }
# aura-backtest carries the trading instantiation (M = RunMetrics: RunReport/
# SweepFamily/SweepPoint/WindowRun aliases, RMetrics, r_bootstrap) — production
# code uses it directly now (#291/#292, C28 phase 2), promoted from
# [dev-dependencies].
aura-backtest = { path = "../aura-backtest" }
# FamilySelection — the selection-provenance type stage selections carry.
aura-analysis = { path = "../aura-analysis" }
# family + campaign-run stores and the optimize/optimize_plateau/optimize_deflated selectors.
@@ -28,9 +34,3 @@ serde_json = { workspace = true }
# work-stealing pool the engine's run_indexed already uses — parallelism across
# sims, never within one. Direct versioned dep, mirroring aura-engine.
rayon = "1"
[dev-dependencies]
# aura-backtest supplies RunMetrics/summarize_r for the metric-vocabulary drift
# guard's direct-source import (tests/metric_vocabulary_e2e.rs). Production
# code reaches these types via the aura-engine re-export instead.
aura-backtest = { path = "../aura-backtest" }
+5 -5
View File
@@ -12,11 +12,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use aura_analysis::{FamilySelection, SelectionMode};
use aura_backtest::{r_bootstrap, RunMetrics, RunReport, SweepFamily, SweepPoint, WindowRun};
use aura_core::{zip_params, Cell, ParamSpec, Scalar, Timestamp};
use aura_engine::{
r_bootstrap, sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics,
RunReport, Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller,
WindowRun,
sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest,
Space, WalkForwardError, WindowBounds, WindowRoller,
};
use aura_registry::{
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
@@ -1172,7 +1172,7 @@ fn run_walk_forward_stage(
// Keep only real OOS reports (drop faulted-fold placeholders by the broker
// sentinel) before building the family.
let surviving: Vec<&aura_engine::WindowOutcome> = result
let surviving: Vec<&aura_engine::WindowOutcome<RunMetrics>> = result
.windows
.iter()
.filter(|w| w.run.oos_report.manifest.broker != FAULTED_MEMBER_PLACEHOLDER_BROKER)
@@ -1211,6 +1211,6 @@ enum WfStageOutcome {
/// The surviving (non-faulted) walk-forward windows' OOS reports, in roll
/// order — the same mapping `walkforward_member_reports` does over a full
/// result, applied to the filtered survivor slice.
fn walkforward_member_reports_of(windows: &[&aura_engine::WindowOutcome]) -> Vec<RunReport> {
fn walkforward_member_reports_of(windows: &[&aura_engine::WindowOutcome<RunMetrics>]) -> Vec<RunReport> {
windows.iter().map(|w| w.run.oos_report.clone()).collect()
}
+5 -3
View File
@@ -20,7 +20,7 @@ use std::collections::BTreeMap;
use std::num::NonZeroUsize;
use aura_core::Scalar;
use aura_engine::RunReport;
use aura_backtest::RunReport;
use aura_registry::{check_r_metric, RegistryError};
use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, RiskRegime, SelectRule, StageBlock};
@@ -388,7 +388,8 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
#[cfg(test)]
mod tests {
use super::*;
use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp};
use aura_backtest::{RMetrics, RunMetrics};
use aura_engine::{RunManifest, Timestamp};
use aura_research::SweepSelection;
/// A report with every per-member scalar planted to a distinct value
@@ -991,8 +992,9 @@ mod wf_tests {
use std::sync::Mutex;
use aura_analysis::SelectionMode;
use aura_backtest::{RunMetrics, RunReport};
use aura_core::{Scalar, ScalarKind};
use aura_engine::{RollMode, RunManifest, RunMetrics, RunReport, Timestamp, WindowRoller};
use aura_engine::{RollMode, RunManifest, Timestamp, WindowRoller};
use aura_registry::{FamilyKind, Registry};
use aura_research::{
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation,
+2 -1
View File
@@ -7,8 +7,9 @@ use std::collections::BTreeMap;
use aura_campaign::{
execute, CellSpec, ExecFault, MemberFault, MemberRunner, DEFAULT_PARALLEL_INSTRUMENTS,
};
use aura_backtest::{r_bootstrap, RMetrics, RunMetrics, RunReport};
use aura_core::{Scalar, ScalarKind, Timestamp};
use aura_engine::{r_bootstrap, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode};
use aura_engine::{RunManifest, SelectionMode};
use aura_registry::{generalization, FamilyKind, Registry, RegistryError, StageBootstrap};
use aura_research::{
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc,
@@ -12,7 +12,8 @@
use std::collections::BTreeMap;
use aura_campaign::{member_metric, CellSpec, MemberFault, MemberRunner, PER_MEMBER_METRICS, RANKABLE_METRICS};
use aura_engine::{summarize, RMetrics, RunManifest, RunMetrics, RunReport, Scalar, Timestamp};
use aura_backtest::{summarize, RMetrics, RunMetrics, RunReport};
use aura_engine::{RunManifest, Scalar, Timestamp};
fn cell() -> CellSpec {
CellSpec {
@@ -28,7 +29,7 @@ fn cell() -> CellSpec {
}
/// A minimal external `MemberRunner`: ignores its params and produces
/// metrics via the real `aura_engine::summarize` reduction (not a hand-typed
/// metrics via the real `aura_backtest::summarize` reduction (not a hand-typed
/// `RunMetrics` literal), echoing the `window_ms` it was called with into
/// the manifest — exactly what a real consumer's harness-binding code does.
struct FixedRunner;
@@ -64,7 +65,7 @@ impl MemberRunner for FixedRunner {
/// `&dyn MemberRunner` (the shape a heterogeneous, per-consumer roster of
/// runners requires); the exact sub-window given at the call site — not
/// `cell.window_ms`, a walk-forward stage passes a narrower sub-range —
/// reaches the runner unmodified; and the real `aura_engine::summarize()`
/// reaches the runner unmodified; and the real `aura_backtest::summarize()`
/// output the runner produced is what `member_metric` reads back through the
/// crate's public per-name resolver.
#[test]
+3 -2
View File
@@ -19,11 +19,12 @@ use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::{mpsc, Arc};
use aura_backtest::{summarize, summarize_r, RunReport};
use aura_campaign::{CampaignOutcome, CellOutcome, CellSpec, ExecFault, MemberFault, MemberRunner};
use aura_core::{Cell, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
use aura_engine::{
blueprint_from_json, f64_field, summarize, summarize_r, ColumnarTrace, Composite,
FamilySelection, RunReport,
blueprint_from_json, f64_field, ColumnarTrace, Composite,
FamilySelection,
};
use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns};
use aura_registry::{CampaignRunRecord, WriteKind};
+12 -8
View File
@@ -16,13 +16,13 @@ use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
use aura_core::{zip_params, Cell, Firing, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp};
use aura_composites::{cost_graph, risk_executor, StopRule};
use aura_engine::{
blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, monte_carlo, param_stability,
r_metrics_from_rs, summarize, summarize_r, walk_forward, window_of, BindError, BlueprintNode,
Composite, FamilySelection, GraphBuilder, Harness, JoinedRow, McAggregate,
McFamily, MeasurementReport, RBootstrap, RollMode, RunManifest, RunMetrics, RunReport,
blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, param_stability,
walk_forward, window_of, BindError, BlueprintNode,
Composite, FamilySelection, GraphBuilder, Harness, JoinedRow,
MeasurementReport, RollMode, RunManifest,
SelectionMode,
SweepFamily, SweepPoint, SyntheticSpec, VecSource, WalkForwardResult,
WindowBounds, WindowRoller, WindowRun,
SyntheticSpec, VecSource,
WindowBounds, WindowRoller,
};
use aura_registry::{
check_r_metric, group_families, mc_member_reports, optimize_deflated,
@@ -30,7 +30,11 @@ use aura_registry::{
FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces,
DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES,
};
use aura_backtest::{SimBroker, PM_FIELD_NAMES, PM_RECORD_KINDS};
use aura_backtest::{
monte_carlo, r_metrics_from_rs, summarize, summarize_r, McAggregate, McFamily, RBootstrap,
RunMetrics, RunReport, SimBroker, SweepFamily, SweepPoint, WalkForwardResult, WindowRun,
PM_FIELD_NAMES, PM_RECORD_KINDS,
};
use aura_std::{
GatedRecorder, LinComb, Recorder, RollingMax, RollingMin, SeriesReducer, Sub,
};
@@ -6347,7 +6351,7 @@ mod tests {
// breaks a test instead of silently shipping. Pins the user-visible wire
// shape of the mc R-bootstrap render (the parser + engine primitive are
// covered elsewhere; this is the output-shape layer).
let boot = aura_engine::r_bootstrap(&[1.0, -0.5, 2.0, -1.0], 64, 2, 7);
let boot = aura_backtest::r_bootstrap(&[1.0, -0.5, 2.0, -1.0], 64, 2, 7);
let line = mc_r_bootstrap_json(&boot);
let v: serde_json::Value = serde_json::from_str(&line).expect("canonical json line");
let obj = &v["mc_r_bootstrap"];
+3 -3
View File
@@ -363,7 +363,7 @@ pub(crate) fn run_sweep_sugar(
/// itself enforces.
fn cross_instrument_members(
outcome: &aura_campaign::CampaignOutcome,
) -> Vec<aura_engine::RunReport> {
) -> Vec<aura_backtest::RunReport> {
outcome
.cells
.iter()
@@ -1180,7 +1180,7 @@ mod tests {
/// A minimal `RunReport` stamped with `instrument`, distinguishable in a
/// members list by nothing but that stamp — the field
/// `cross_instrument_members` preserves verbatim from a nominating cell.
fn fake_report(instrument: &str) -> aura_engine::RunReport {
fn fake_report(instrument: &str) -> aura_backtest::RunReport {
aura_engine::RunReport {
manifest: aura_engine::RunManifest {
commit: String::new(),
@@ -1194,7 +1194,7 @@ mod tests {
topology_hash: None,
project: None,
},
metrics: aura_engine::RunMetrics {
metrics: aura_backtest::RunMetrics {
total_pips: 0.0,
max_drawdown: 0.0,
bias_sign_flips: 0,
@@ -9,8 +9,8 @@ use aura_core::{
ScalarKind, Timestamp,
};
use aura_composites::{risk_executor, StopRule};
use aura_engine::{summarize_r, GraphBuilder, RunMetrics, VecSource};
use aura_backtest::{PM_FIELD_NAMES, PM_RECORD_KINDS};
use aura_engine::{GraphBuilder, VecSource};
use aura_backtest::{summarize_r, RunMetrics, PM_FIELD_NAMES, PM_RECORD_KINDS};
use aura_std::Recorder;
use std::sync::mpsc::channel;
@@ -225,7 +225,7 @@ fn risk_executor_vol_tf_stop_arm_bootstraps_and_folds() {
/// folding the dense R-record. `exec` is the variant under test (bound or open); `params`
/// is the positional point the open variant needs (empty for the bound one). Shared by the
/// open-vs-bound equivalence test below so the two arms run byte-identical inputs.
fn fold_executor(exec: aura_engine::Composite, params: Vec<Scalar>) -> aura_engine::RMetrics {
fn fold_executor(exec: aura_engine::Composite, params: Vec<Scalar>) -> aura_backtest::RMetrics {
let (tx, rx) = channel();
let mut g = GraphBuilder::new("vol_open_harness");
let price = g.source_role("price", ScalarKind::F64);
+6 -5
View File
@@ -12,11 +12,6 @@ aura-core = { path = "../aura-core" }
# (#136, reduced to the domain-free half by #291). Re-exported via `report::`
# so callers resolve the types through aura-engine unchanged (C18).
aura-analysis = { path = "../aura-analysis" }
# aura-backtest holds the backtest metric reductions re-exported through
# `report::` (#291). TRANSIENT widening of the known C28 layer violation: the
# phase-2 edge cut (#147) removes this production edge again on the same
# branch.
aura-backtest = { path = "../aura-backtest" }
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
# it gives the run-report types a typed (de)serialization path for the run
# registry (cycle 0029). serde's output is deterministic (C1-safe).
@@ -34,6 +29,12 @@ serde_json = { workspace = true }
rayon = "1"
[dev-dependencies]
# aura-backtest is TEST-ONLY (back to its pre-#291 home, C28 phase 2 D3): the
# engine's own tests instantiate `RunReport<M>` with `M = RunMetrics`
# (SimBroker fixtures, summarize/RunMetrics literals) via aura-backtest's
# concrete aliases. The library code stays metric-agnostic — `RunReport<M>`
# names no concrete metric type — so aura-backtest is out of [dependencies].
aura-backtest = { path = "../aura-backtest" }
# aura-std is a TEST-ONLY dependency: the engine's own integration tests
# (r_sma_e2e, random_sweep_e2e, ger40_breakout) drive real standard nodes through
# a bootstrapped Harness. The engine's library code never names a concrete node — it
+13 -9
View File
@@ -560,9 +560,10 @@ impl SweepBinder {
/// Resolve the named axes against `param_space()` into a positional grid and
/// run the disjoint sweep. `sweep` is [`SweepBinder::sweep_with_lattice`] with
/// the lattice dropped, so every existing caller is byte-unchanged.
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
pub fn sweep<M, F>(self, run_one: F) -> Result<SweepFamily<M>, BindError>
where
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
self.sweep_with_lattice(run_one).map(|(family, _lattice)| family)
}
@@ -571,9 +572,10 @@ impl SweepBinder {
/// in `param_space()` / odometer order). The lattice is what a plateau
/// neighbourhood walk needs (cycle 0077); only the engine's post-`resolve_axes`
/// grid holds it in the correct order.
pub fn sweep_with_lattice<F>(self, run_one: F) -> Result<(SweepFamily, Vec<usize>), BindError>
pub fn sweep_with_lattice<M, F>(self, run_one: F) -> Result<(SweepFamily<M>, Vec<usize>), BindError>
where
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let space = self.bp.param_space();
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
@@ -604,9 +606,10 @@ impl RandomBinder {
/// Resolve the named ranges against `param_space()` into a positional
/// `RandomSpace` and run the disjoint sweep. `count`/`seed` are `RandomSpace`'s
/// extra inputs — the only signature difference from [`SweepBinder::sweep`].
pub fn sweep<F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily, BindError>
pub fn sweep<M, F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily<M>, BindError>
where
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let space = self.bp.param_space();
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
@@ -1382,7 +1385,8 @@ fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, Co
mod tests {
use super::*;
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
use crate::{f64_field, ParamRange, RandomSpace, RunManifest, VecSource};
use aura_backtest::{summarize, RunMetrics};
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
use aura_backtest::SimBroker;
use aura_std::{Ema, LinComb, Recorder, Sma, Sub};
@@ -1858,7 +1862,7 @@ mod tests {
/// `fn` (Copy + Sync) so it serves both as the `sweep`/binder closure and as a
/// direct reference. Deterministic, so the same point reproduces its report
/// exactly — that is what makes two families comparable for equality.
fn run_point(point: &[Cell]) -> RunReport {
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_cells(point)
@@ -2038,7 +2042,7 @@ mod tests {
.axis("sma_cross.fast.length", [2.0, 3.0]) // F64 values for the I64 slot
.axis("sma_cross.slow.length", [4])
.axis("bias.scale", [0.5])
.sweep(|_: &[Cell]| -> RunReport { panic!("axis pre-validation must reject before running") });
.sweep(|_: &[Cell]| -> RunReport<RunMetrics> { panic!("axis pre-validation must reject before running") });
assert_eq!(
result,
Err(BindError::KindMismatch {
+1 -25
View File
@@ -21,6 +21,7 @@
//! sources are four cycles, so RustAst's `cycle_id`-equality barrier could never
//! fire across sources — the timestamp generalizes it faithfully.
pub use aura_analysis::SplitMix64;
use aura_core::{AnyColumn, Cell, Ctx, Firing, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
/// Forwards one field (`from_field`) of a producer's output record into a
@@ -220,31 +221,6 @@ pub fn window_of(sources: &[Box<dyn Source>]) -> Option<(Timestamp, Timestamp)>
acc
}
/// 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).
pub struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
pub fn new(seed: u64) -> Self {
Self { state: seed }
}
pub 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.
pub(crate) 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
+28 -30
View File
@@ -13,28 +13,27 @@
//! - [`BootstrapError`] — wiring faults caught once, at bootstrap (kind
//! mismatch, bad index, directed cycle).
//!
//! Delivered in cycle 0009 — the run report surface:
//! Delivered in cycle 0009, generic since #292 — the run-record surface:
//!
//! - [`RunMetrics`] / [`RunManifest`] / [`RunReport`] — the `(manifest, metrics)`
//! pair C18 mandates per run, with [`RunReport::to_json`] for the structured
//! C14 face;
//! - [`summarize`] — the post-run pure reduction over a run's recorded
//! pip-equity + exposure streams into [`RunMetrics`]; [`f64_field`] bridges a
//! recording sink's `Vec<Scalar>` rows to it.
//! - [`RunReport`] / [`RunManifest`] — the `(manifest, metrics)` pair C18
//! mandates per run. [`RunReport`] is generic over its metric payload `M`:
//! the engine names no concrete metric type; the backtest layer
//! (`aura-backtest`) instantiates it and hosts the pip/R reductions. C14 face
//! via [`RunReport::to_json`]; [`f64_field`] bridges a recording sink's
//! `Vec<Scalar>` rows into the `(ts, f64)` streams a metric reduction folds.
//!
//! Orchestration families of the atomic sim unit
//! (`(topology + params + data-window + seed)`) ship along three of C12's axes: the
//! **grid** axis via [`sweep`] over a [`GridSpace`] into a [`SweepFamily`], the
//! **window** axis via [`walk_forward`] over a [`WindowRoller`] into a
//! [`WalkForwardResult`] (C12 axis 3: rolling in-sample/out-of-sample splits, the
//! in-sample optimize closure-supplied), and
//! the **seed** axis via [`monte_carlo`] over a seed set into an [`McFamily`]
//! (C12 axis 4: Monte-Carlo *is* a sweep over seeds; both drive one shared
//! disjoint-parallel executor). The **seed** input itself ships too:
//! [`SyntheticSpec`] is a seeded `Source` producer (`Fn(u64) -> impl Source`,
//! C12 seed-as-input) whose stream is fully seed-determined, making
//! [`RunManifest`]'s seed a live captured input; the atomic unit's `-> metrics`
//! reduction ships via [`summarize`].
//! (`(topology + params + data-window + seed)`) ship along C12's axes: the
//! **grid** axis via [`sweep`] over a [`GridSpace`] into a [`SweepFamily`], and
//! the **window** axis via [`walk_forward`] over a [`WindowRoller`] into a
//! [`WalkForwardResult`] (C12 axis 3: rolling in-sample/out-of-sample splits,
//! the in-sample optimize closure-supplied). The **seed** axis (C12 axis 4:
//! Monte-Carlo *is* a sweep over seeds) is assembled one rung out in
//! `aura-backtest`, over the same shared disjoint-parallel executor this crate
//! provides. The **seed** input itself ships here: [`SyntheticSpec`] is a
//! seeded `Source` producer (`Fn(u64) -> impl Source`, C12 seed-as-input) whose
//! stream is fully seed-determined, making [`RunManifest`]'s seed a live
//! captured input.
//!
//! Still to come (subsequent cycles): the broker-independent position-event
//! output and downstream broker nodes (C10), the random param-sweep
@@ -48,7 +47,6 @@ mod builder;
mod construction;
mod graph_model;
mod harness;
mod mc;
mod report;
mod sweep;
mod walkforward;
@@ -69,19 +67,19 @@ pub use harness::{
SourceSpec, SplitMix64, SyntheticSpec, TapBindError, Target, VecSource,
};
pub use report::{
derive_position_events, expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
r_metrics_from_rs, summarize, summarize_r, ColumnarTrace, FamilySelection, JoinedRow,
MeasurementReport, PositionAction, PositionEvent, ProjectProvenance, RMetrics, RunManifest,
RunMetrics, RunReport, SelectionMode,
expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
ColumnarTrace, FamilySelection, JoinedRow,
MeasurementReport, ProjectProvenance, RunManifest,
RunReport, SelectionMode,
};
pub use sweep::{
sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily,
SweepPoint,
};
pub use mc::{
monte_carlo, r_bootstrap, resample_block, McAggregate, McDraw, McFamily, MetricStats,
RBootstrap,
run_indexed, sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError,
SweepFamily, SweepPoint,
};
// `resample_block`/`MetricStats` are foundation-grade (aura-analysis, #291);
// re-exported here (unchanged exported name set) so existing
// `aura_engine::{resample_block, MetricStats}` consumers stay source-compatible.
pub use aura_analysis::{resample_block, MetricStats};
pub use walkforward::{
param_stability, walk_forward, RollMode, WalkForwardError, WalkForwardResult,
WindowBounds, WindowOutcome, WindowRoller, WindowRun,
+20 -219
View File
@@ -8,21 +8,17 @@
//! cycle 0029) — the same encoder the run registry uses, so a record's stdout
//! and on-disk shapes coincide.
use aura_core::{Scalar, ScalarKind, SeriesFold, Timestamp};
use aura_core::{Scalar, ScalarKind, Timestamp};
use std::collections::HashMap;
// The backtest reductions (R-metrics, the position-event table) live in
// `aura-backtest::metrics`, the domain-free hurdle math + selection provenance
// in `aura-analysis` (issues #136, #291); both are re-imported here so
// The backtest reductions (R-metrics, the position-event table, `summarize`)
// moved to `aura-backtest::metrics` (C28 phase 2, #291/#292 — the engine's
// production edge to aura-backtest is cut, D4); the domain-free hurdle math +
// selection provenance stays re-imported here from `aura-analysis` so
// `report::`'s namespace — and therefore `aura-engine`'s `lib.rs` re-export and
// every `crate::report::X` reference — resolves unchanged (C18 byte-identity).
// `summarize` (below) bridges trace columns into these types at the engine
// boundary, so it stays here.
// every `crate::report::X` reference — resolves unchanged for those names
// (C18 byte-identity).
pub use aura_analysis::{expected_max_of_normals, inv_norm_cdf, FamilySelection, SelectionMode};
pub use aura_backtest::{
derive_position_events, r_metrics_from_rs, summarize_r, PositionAction,
PositionEvent, RMetrics, RunMetrics,
};
/// The reproducible run descriptor (C18). **Caller-supplied**: the engine
/// cannot introspect a git commit, an RNG seed, or a broker label — the World
@@ -92,16 +88,18 @@ pub struct ProjectProvenance {
pub commit: Option<String>,
}
/// A run's full structured result: the descriptor plus the metrics it
/// reproduces. The durable run record of C18 ("stores manifests + metrics,
/// re-derives full results on demand").
/// A run's full structured result: the descriptor plus the metric payload `M`
/// it reproduces. Metric-agnostic (C28): the engine never names a concrete
/// metric type; the backtest layer instantiates `M` (see `aura-backtest`). The
/// durable run record of C18 ("stores manifests + metrics, re-derives full
/// results on demand").
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RunReport {
pub struct RunReport<M> {
pub manifest: RunManifest,
pub metrics: RunMetrics,
pub metrics: M,
}
impl RunReport {
impl<M: serde::Serialize> RunReport<M> {
/// Render the canonical, machine-readable JSON (C14) via serde — the same
/// encoder the run registry uses on disk, so a record's stdout shape and its
/// `runs.jsonl` shape are byte-identical. `params` is an array of
@@ -130,35 +128,9 @@ impl MeasurementReport {
}
}
/// Reduce a run's recorded pip-equity + exposure streams into summary metrics.
/// Pure — identical inputs yield identical metrics (C1/C12). Timestamps are
/// carried in the input to match exactly what a sink records; the reduction
/// itself is value-only (it does not read the timestamps).
pub fn summarize(
equity: &[(Timestamp, f64)],
exposure: &[(Timestamp, f64)],
) -> RunMetrics {
// total_pips + max_drawdown fold over equity; sign_flips folds over exposure.
// SeriesFold is the single arithmetic source of truth (shared with the
// SeriesReducer sink), so this is byte-identical to the prior inline loops.
let mut eqf = SeriesFold::new();
for &(_, v) in equity {
eqf.fold(v);
}
let mut exf = SeriesFold::new();
for &(_, v) in exposure {
exf.fold(v);
}
RunMetrics {
total_pips: eqf.last,
max_drawdown: eqf.max_drawdown,
bias_sign_flips: exf.sign_flips,
r: None,
}
}
/// Bridge a recording sink's recorded `(ts, row)` stream to [`summarize`]:
/// extract one `f64` field of each row into `(ts, f64)` samples. Panics if a
/// Bridge a recording sink's recorded `(ts, row)` stream to a metric reduction
/// (e.g. `aura-backtest`'s `summarize`): extract one `f64` field of each row
/// into `(ts, f64)` samples. Panics if a
/// row has no such field or the field is not an `f64` scalar — a wiring bug (a
/// sink's declared kinds are fixed at bootstrap, so a correctly-wired
/// equity/exposure sink always yields `f64` at field 0), surfaced like the
@@ -312,12 +284,7 @@ fn scalar_to_f64(s: Scalar) -> f64 {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edge, FlatGraph, Harness, SourceSpec, Target, VecSource};
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
use aura_backtest::SimBroker;
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use std::sync::mpsc;
use aura_backtest::RunMetrics;
#[test]
fn runmanifest_without_selection_field_deserialises_to_none() {
@@ -472,172 +439,6 @@ mod tests {
assert_eq!(sel.n_neighbours, None);
}
/// The declared signature of a `Recorder` over one f64 column (the sink shape
/// the two-sink harness uses).
fn f64_recorder_sig() -> NodeSchema {
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
output: vec![],
params: vec![],
}
}
/// Build an f64 source stream from (timestamp, value) points (mirrors the
/// harness.rs test helper; the e2e test needs its own copy — the harness
/// test module's is private to that module).
fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect()
}
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
#[allow(clippy::type_complexity)]
fn build_two_sink_harness() -> (
Harness,
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 h = Harness::bootstrap(FlatGraph {
nodes: vec![
Box::new(Sma::new(2)), // 0
Box::new(Sma::new(4)), // 1
Box::new(Sub::new()), // 2
Box::new(Bias::new(0.5)), // 3
Box::new(SimBroker::new(0.0001)), // 4
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
],
signatures: vec![
Sma::builder().schema().clone(),
Sma::builder().schema().clone(),
Sub::builder().schema().clone(),
Bias::builder().schema().clone(),
SimBroker::builder(0.0001).schema().clone(),
f64_recorder_sig(),
f64_recorder_sig(),
],
sources: vec![SourceSpec::raw(ScalarKind::F64, vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
Target { node: 4, slot: 1 }, // price into the broker
])],
edges: vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
],
taps: Vec::new(),
})
.expect("valid signal-quality DAG");
(h, rx_eq, rx_ex)
}
fn run_once() -> RunReport {
let (mut h, rx_eq, rx_ex) = build_two_sink_harness();
h.run(vec![Box::new(VecSource::new(f64_stream(&[
(1, 1.0000),
(2, 1.0010),
(3, 1.0025),
(4, 1.0020),
(5, 1.0040),
])))]);
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 equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: "test-commit".to_string(),
params: vec![
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("bias_scale".to_string(), Scalar::f64(0.5)),
],
defaults: vec![],
window: (Timestamp(1), Timestamp(5)),
seed: 0,
broker: "sim-optimal(pip_size=0.0001)".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics,
}
}
#[test]
fn report_is_deterministic_end_to_end() {
let r1 = run_once();
let r2 = run_once();
// a run actually emitted metrics over a non-empty pip curve
assert!(r1.metrics.total_pips.is_finite());
// same manifest -> same metrics (C1/C12): two runs are bit-identical
assert_eq!(r1.metrics, r2.metrics);
assert_eq!(r1.to_json(), r2.to_json());
}
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
values
.iter()
.enumerate()
.map(|(i, &v)| (Timestamp(i as i64 + 1), v))
.collect()
}
#[test]
fn summarize_total_pips_is_last_cumulative_value() {
let equity = samples(&[0.0, 5.0, 4.0, 12.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.total_pips, 12.0);
}
#[test]
fn summarize_is_zero_on_empty_streams() {
let m = summarize(&[], &[]);
assert_eq!(m.total_pips, 0.0);
assert_eq!(m.max_drawdown, 0.0);
assert_eq!(m.bias_sign_flips, 0);
}
#[test]
fn summarize_max_drawdown_is_worst_peak_to_trough() {
// peak 10 then trough 5 (drop 5), recovers to 8; worst drop is 5,
// not the final drop (10 -> 8 = 2).
let equity = samples(&[0.0, 10.0, 5.0, 8.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 5.0);
}
#[test]
fn summarize_max_drawdown_zero_on_monotonic_curve() {
let equity = samples(&[0.0, 1.0, 2.0, 3.0]);
let m = summarize(&equity, &[]);
assert_eq!(m.max_drawdown, 0.0);
}
#[test]
fn summarize_sign_flips_counts_signum_changes() {
// signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3.
let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 3);
}
#[test]
fn summarize_sign_flips_zero_on_constant_sign() {
let exposure = samples(&[0.2, 0.5, 1.0, 0.7]);
let m = summarize(&[], &exposure);
assert_eq!(m.bias_sign_flips, 0);
}
#[test]
fn f64_field_projects_the_named_field() {
let rows = vec![
@@ -739,7 +540,7 @@ mod tests {
let json = serde_json::to_string(&report).expect("serialize RunReport");
// window is a 2-element [from, to] array (Timestamp newtype is transparent)
assert!(json.contains("\"window\":[1,6]"), "window shape: {json}");
let back: RunReport = serde_json::from_str(&json).expect("deserialize RunReport");
let back: RunReport<RunMetrics> = serde_json::from_str(&json).expect("deserialize RunReport");
assert_eq!(back, report);
}
+19 -15
View File
@@ -344,20 +344,20 @@ impl Space for ListSpace {
/// and `report` carries the run's `(manifest, metrics)` — the unit the run
/// registry indexes (C18).
#[derive(Clone, Debug, PartialEq)]
pub struct SweepPoint {
pub struct SweepPoint<M> {
pub params: Vec<Cell>,
pub report: RunReport,
pub report: RunReport<M>,
}
/// The ordered result family of a sweep — one `SweepPoint` per grid point, in
/// enumeration (odometer) order, independent of thread completion order.
#[derive(Clone, Debug, PartialEq)]
pub struct SweepFamily {
pub struct SweepFamily<M> {
pub space: Vec<ParamSpec>,
pub points: Vec<SweepPoint>,
pub points: Vec<SweepPoint<M>>,
}
impl SweepFamily {
impl<M> SweepFamily<M> {
/// The i-th point's params paired with their names — a derived view over the
/// carried param-space (reuses [`zip_params`]); no new per-point state.
pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> {
@@ -372,9 +372,10 @@ impl SweepFamily {
/// enumeration order. Kept as one source of truth so the two callers can never
/// drift on assembly logic (grid points, `RandomSpace` draws) while their pool
/// selection differs.
fn assemble_sweep<F>(space: Vec<ParamSpec>, points: Vec<Vec<Cell>>, run_one: &F) -> SweepFamily
fn assemble_sweep<M, F>(space: Vec<ParamSpec>, points: Vec<Vec<Cell>>, run_one: &F) -> SweepFamily<M>
where
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let reports = run_indexed(points.len(), |i| run_one(&points[i]));
SweepFamily {
@@ -395,10 +396,11 @@ where
/// shared rayon pool (`run_indexed`): a nested sweep / walk-forward shares its
/// workers rather than each spawning its own `available_parallelism()`
/// OS-thread batch.
pub fn sweep<S, F>(space: &S, run_one: F) -> SweepFamily
pub fn sweep<S, M, F>(space: &S, run_one: F) -> SweepFamily<M>
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let points = space.points();
assemble_sweep(space.param_specs().to_vec(), points, &run_one)
@@ -421,7 +423,7 @@ where
// passing `run_one` directly would move it into `Map` and require `F: Send`, a
// bound the `_with_threads` wrappers' `install`-borrowed callers don't carry.
#[allow(clippy::redundant_closure)]
pub(crate) fn run_indexed<T, F>(n: usize, run_one: F) -> Vec<T>
pub fn run_indexed<T, F>(n: usize, run_one: F) -> Vec<T>
where
T: Send,
F: Fn(usize) -> T + Sync,
@@ -441,10 +443,11 @@ where
/// *borrows* `run_one` and moves the already-owned `space`/`points`, so no
/// `Send` bound on `F` is needed.
#[cfg(test)]
fn sweep_with_threads<S, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily
fn sweep_with_threads<S, M, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily<M>
where
S: Space,
F: Fn(&[Cell]) -> RunReport + Sync,
M: Send,
F: Fn(&[Cell]) -> RunReport<M> + Sync,
{
let points = space.points();
let param_specs = space.param_specs().to_vec();
@@ -459,7 +462,8 @@ where
mod tests {
use super::*;
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
use crate::{f64_field, summarize, RunManifest, VecSource};
use crate::{f64_field, RunManifest, VecSource};
use aura_backtest::{summarize, RunMetrics};
use aura_core::{Cell, ParamSpec, Scalar, ScalarKind, Timestamp};
fn i64_space(n: usize) -> Vec<ParamSpec> {
@@ -720,7 +724,7 @@ mod tests {
/// AND a direct reference for the "sweep == N independent runs" comparison.
/// The manifest is a minimal fixed fixture — the metrics are the run's, and
/// determinism makes `run_point` reproduce a point's report exactly.
fn run_point(point: &[Cell]) -> RunReport {
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
let mut h = bp
.bootstrap_with_cells(point)
@@ -945,7 +949,7 @@ mod tests {
/// A fake report whose manifest `commit` encodes the input cells — enough to
/// prove the sweep closure ran on exactly the given point, without a harness
/// run. A free `fn` (Sync) so it serves directly as the `sweep` closure.
fn tagged_report(point: &[Cell]) -> RunReport {
fn tagged_report(point: &[Cell]) -> RunReport<RunMetrics> {
RunReport {
manifest: RunManifest {
commit: point.iter().map(|c| c.i64().to_string()).collect::<Vec<_>>().join(","),
+27 -23
View File
@@ -120,23 +120,23 @@ impl Iterator for WindowRoller {
/// What a per-window run yields: the params chosen on the in-sample window, the
/// recorded OOS pip-equity segment (for stitching), and the OOS [`RunReport`] (the
/// per-window C18 record). Self-describing, analog to
/// [`SweepPoint`](crate::SweepPoint)/[`McDraw`](crate::McDraw).
/// [`SweepPoint`](crate::SweepPoint) (and the backtest layer's `McDraw`).
#[derive(Clone, Debug, PartialEq)]
pub struct WindowRun {
pub struct WindowRun<M> {
/// The chosen params as the tag-free coordinate [`Cell`]s the inner sweep
/// produced (C7: the kind lives once on [`WalkForwardResult::space`], not at the
/// value). The named/typed view is `zip_params(&result.space, &chosen_params)` —
/// the same idiom as [`SweepFamily::named_params`](crate::SweepFamily).
pub chosen_params: Vec<Cell>,
pub oos_equity: Vec<(Timestamp, f64)>,
pub oos_report: RunReport,
pub oos_report: RunReport<M>,
}
/// One completed window: its bounds + the run the closure produced, in roll order.
#[derive(Clone, Debug, PartialEq)]
pub struct WindowOutcome {
pub struct WindowOutcome<M> {
pub bounds: WindowBounds,
pub run: WindowRun,
pub run: WindowRun<M>,
}
/// The result family of a walk-forward. Analog to [`SweepFamily`](crate::SweepFamily)
@@ -146,7 +146,7 @@ pub struct WindowOutcome {
/// points and `optimize`/`rank_by` are computed on demand). Non-redundant by
/// construction.
#[derive(Clone, Debug, PartialEq)]
pub struct WalkForwardResult {
pub struct WalkForwardResult<M> {
/// The param-space schema (kinds + slot names), once for the whole family —
/// the kinds the per-window [`WindowRun::chosen_params`] cells are read against
/// (C7). Identical for every window (same blueprint). Mirrors
@@ -154,7 +154,7 @@ pub struct WalkForwardResult {
pub space: Vec<ParamSpec>,
/// Per-window outcomes in roll order (window 0 first), independent of thread
/// completion (C1).
pub windows: Vec<WindowOutcome>,
pub windows: Vec<WindowOutcome<M>>,
/// The stitched continuous OOS pip-equity curve: each window's OOS segment
/// offset by the running sum of all prior segments' final cumulative values,
/// so equity carries forward across the IS/OOS boundaries (continuous, not a
@@ -165,7 +165,7 @@ pub struct WalkForwardResult {
pub stitched_oos_equity: Vec<(Timestamp, f64)>,
}
impl WalkForwardResult {
impl<M> WalkForwardResult<M> {
/// The chosen params of the `window`-th outcome paired with their names — a
/// derived view over the carried param-space (reuses [`zip_params`]); the
/// walk-forward mirror of [`SweepFamily::named_params`](crate::SweepFamily).
@@ -179,16 +179,17 @@ impl WalkForwardResult {
/// `run_indexed` (on whichever pool is ambient when called), stitch the OOS
/// equity, and assemble the result. One source of truth so the two callers can
/// never drift on assembly logic while their pool selection differs.
fn assemble_walk_forward<F>(
fn assemble_walk_forward<M, F>(
space: Vec<ParamSpec>,
bounds: Vec<WindowBounds>,
run_window: &F,
) -> WalkForwardResult
) -> WalkForwardResult<M>
where
F: Fn(WindowBounds) -> WindowRun + Sync,
M: Send,
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
{
let runs = run_indexed(bounds.len(), |i| run_window(bounds[i]));
let windows: Vec<WindowOutcome> = bounds
let windows: Vec<WindowOutcome<M>> = bounds
.into_iter()
.zip(runs)
.map(|(bounds, run)| WindowOutcome { bounds, run })
@@ -209,9 +210,10 @@ where
/// stream `Vec` in this API. Precondition: the roller yields `>= 1` window
/// (`WindowRoller::new` rejects an empty roll). Param stability is a separate
/// on-demand reduction ([`param_stability`]), never computed here.
pub fn walk_forward<F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult
pub fn walk_forward<M, F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult<M>
where
F: Fn(WindowBounds) -> WindowRun + Sync,
M: Send,
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
{
let bounds: Vec<WindowBounds> = roller.collect();
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
@@ -227,14 +229,15 @@ where
/// `walk_forward` (`F: Sync`): the install closure only borrows `run_window`,
/// never moves it.
#[cfg(test)]
fn walk_forward_with_threads<F>(
fn walk_forward_with_threads<M, F>(
roller: WindowRoller,
space: Vec<ParamSpec>,
nthreads: usize,
run_window: F,
) -> WalkForwardResult
) -> WalkForwardResult<M>
where
F: Fn(WindowBounds) -> WindowRun + Sync,
M: Send,
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
{
let bounds: Vec<WindowBounds> = roller.collect();
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
@@ -249,7 +252,7 @@ where
/// segment's samples are offset by the running sum of all prior segments' final
/// cumulative values, so equity carries forward across windows (no per-window
/// reset). An empty segment adds `0.0` to the offset and no points.
fn stitch(windows: &[WindowOutcome]) -> Vec<(Timestamp, f64)> {
fn stitch<M>(windows: &[WindowOutcome<M>]) -> Vec<(Timestamp, f64)> {
let mut out = Vec::new();
let mut offset = 0.0_f64;
for w in windows {
@@ -275,7 +278,7 @@ fn stitch(windows: &[WindowOutcome]) -> Vec<(Timestamp, f64)> {
/// value, a `bool` slot to `0.0`/`1.0` (so `mean` is the fraction of windows that
/// chose `true`). A `timestamp` slot is structurally impossible (C20: a timestamp
/// is a structural axis, never a numeric knob).
pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
pub fn param_stability<M>(result: &WalkForwardResult<M>) -> Vec<MetricStats> {
if result.windows.is_empty() {
return Vec::new();
}
@@ -305,9 +308,10 @@ pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
#[cfg(test)]
mod tests {
use super::*;
use crate::{summarize, RunManifest};
use crate::RunManifest;
use aura_backtest::{summarize, RunMetrics};
fn dummy_report() -> RunReport {
fn dummy_report() -> RunReport<RunMetrics> {
RunReport {
manifest: RunManifest {
commit: "t".to_string(),
@@ -336,7 +340,7 @@ mod tests {
/// A WindowOutcome with explicit OOS equity + chosen params (zero bounds — the
/// stitch/stability reductions ignore bounds).
fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> WindowOutcome {
fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> WindowOutcome<RunMetrics> {
WindowOutcome {
bounds: WindowBounds {
is: (Timestamp(0), Timestamp(0)),
@@ -349,7 +353,7 @@ mod tests {
/// A deterministic per-window run keyed by the OOS start, so distinct windows
/// differ. No real harness — these tests pin the orchestration (roll order,
/// stitch, stability); mc/sweep cover the harness path. Free `fn` => `Sync`.
fn run_window_fixture(w: WindowBounds) -> WindowRun {
fn run_window_fixture(w: WindowBounds) -> WindowRun<RunMetrics> {
let Timestamp(k) = w.oos.0;
WindowRun {
chosen_params: vec![Cell::from_i64(k % 3), Cell::from_f64(k as f64 * 0.5)],
+3 -3
View File
@@ -11,10 +11,10 @@ use std::sync::mpsc;
use aura_core::{Cell, Firing, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, ListSpace, OutField, ParamSpec,
f64_field, sweep, BlueprintNode, Composite, Edge, ListSpace, OutField, ParamSpec,
Role, RunManifest, RunReport, Scalar, SweepError, Target, VecSource,
};
use aura_backtest::SimBroker;
use aura_backtest::{summarize, RunMetrics, SimBroker};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
@@ -91,7 +91,7 @@ fn sma_cross_harness() -> (
/// 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 {
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()))]);
@@ -1,11 +1,13 @@
//! End-to-end verification of the C10 derived position-event table (#114) at the
//! public crate boundary: the World consumes `PositionAction` / `PositionEvent`
//! via the `aura_engine` re-exports and persists the audit table the same way
//! the run registry persists a `RunReport` — through serde_json. These tests pin
//! the *consumer-visible wire shape* (the persisted column form), distinct from
//! the crate-internal type-level unit tests in `report.rs`.
//! via the `aura_backtest` re-exports (moved from `aura_engine`, #291) and
//! persists the audit table the same way the run registry persists a
//! `RunReport` — through serde_json. These tests pin the *consumer-visible
//! wire shape* (the persisted column form), distinct from the crate-internal
//! type-level unit tests in `aura-backtest::metrics`.
use aura_engine::{PositionAction, PositionEvent, Timestamp};
use aura_backtest::{PositionAction, PositionEvent};
use aura_engine::Timestamp;
/// A minimal stop-and-reverse audit table as a broker would derive it: open
/// long, then at one later instant Close-before-open into a short (a reversal,
+4 -2
View File
@@ -26,8 +26,10 @@
//! `r_col_indices_match_producer_field_layout` test pins that contract.
use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp};
use aura_engine::{PositionAction, RMetrics, derive_position_events, summarize_r};
use aura_backtest::{PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH, PositionManagement};
use aura_backtest::{
derive_position_events, summarize_r, PositionAction, PositionManagement, RMetrics,
PM_FIELD_NAMES, PM_RECORD_KINDS, PM_WIDTH,
};
use aura_strategy::{ConstantCost, CostSum, FixedStop, VolSlippageCost};
// The indices `report.rs::summarize_r` reads the dense record by. Re-declared here
+3 -3
View File
@@ -15,10 +15,10 @@ use std::sync::mpsc;
use aura_core::{Cell, Firing, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, OutField, ParamRange, ParamSpec,
f64_field, sweep, BlueprintNode, Composite, Edge, OutField, ParamRange, ParamSpec,
RandomSpace, Role, RunManifest, RunReport, Scalar, Space, SweepError, Target, VecSource,
};
use aura_backtest::SimBroker;
use aura_backtest::{summarize, RunMetrics, SimBroker};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
@@ -102,7 +102,7 @@ fn sma_cross_harness() -> (
/// 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 {
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
let (bp, rx_eq, rx_ex) = sma_cross_harness();
let mut h = bp
.bootstrap_with_cells(point)
@@ -5,8 +5,7 @@
//! the full record, and a `SeriesReducer` summary equals `summarize`'s fields.
use aura_core::{AnyColumn, Ctx, Node, Scalar, ScalarKind, Timestamp};
use aura_engine::{summarize, summarize_r};
use aura_backtest::{PM_RECORD_KINDS, PM_WIDTH};
use aura_backtest::{summarize, summarize_r, PM_RECORD_KINDS, PM_WIDTH};
use aura_std::{GatedRecorder, SeriesReducer};
use std::sync::mpsc;
@@ -24,7 +24,7 @@
use std::sync::Arc;
use aura_core::{Cell, Scalar, Timestamp};
use aura_engine::{summarize, RunMetrics};
use aura_backtest::{summarize, RunMetrics};
use chrono_tz::Europe::Berlin;
use aura_ingest::{default_data_server, open_ohlc, DataServer, DEFAULT_DATA_PATH};
@@ -16,7 +16,8 @@
use std::sync::Arc;
use aura_core::{Cell, Scalar, Timestamp};
use aura_engine::{sweep, summarize, GridSpace, RunManifest, RunReport};
use aura_backtest::{summarize, RunReport};
use aura_engine::{sweep, GridSpace, RunManifest};
use chrono_tz::Europe::Berlin;
use aura_ingest::{default_data_server, open_ohlc, DataServer, DEFAULT_DATA_PATH};
@@ -18,9 +18,10 @@
use std::sync::Arc;
use aura_core::{Cell, Scalar, Timestamp};
use aura_backtest::{summarize, RunReport, WindowRun};
use aura_engine::{
sweep, summarize, walk_forward, GridSpace, RollMode, RunManifest, RunReport, WindowBounds,
WindowRoller, WindowRun,
sweep, walk_forward, GridSpace, RollMode, RunManifest, WindowBounds,
WindowRoller,
};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
@@ -40,10 +40,10 @@ use std::sync::mpsc;
use aura_core::{NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{
join_on_ts, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness, RunManifest,
RunReport, SourceSpec, Target,
join_on_ts, Composite, Edge, FlatGraph, GraphBuilder, Harness, RunManifest,
SourceSpec, Target,
};
use aura_backtest::SimBroker;
use aura_backtest::{summarize, RunReport, SimBroker};
use aura_market::{Resample, Session};
use aura_std::{And, Delay, EqConst, Gt, Latch, Recorder};
use chrono::TimeZone;
@@ -31,7 +31,7 @@ fn run_real(
server: &Arc<DataServer>,
from: Timestamp,
to: Timestamp,
) -> (aura_engine::RunReport, Vec<(f64, f64)>) {
) -> (aura_backtest::RunReport, Vec<(f64, f64)>) {
let sources =
open_ohlc(server, SYMBOL, from, to).expect("GER40 has data in the Sept-2024 window");
let (mut h, taps) = build_harness();
@@ -21,9 +21,10 @@
use std::sync::Arc;
use aura_core::{Cell, Scalar, Timestamp};
use aura_backtest::{summarize, RunReport, WindowRun};
use aura_engine::{
sweep, summarize, walk_forward, GridSpace, RollMode, RunManifest, RunReport, WindowBounds,
WindowRoller, WindowRun,
sweep, walk_forward, GridSpace, RollMode, RunManifest, WindowBounds,
WindowRoller,
};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
+2 -2
View File
@@ -9,11 +9,11 @@ use std::sync::Arc;
use aura_core::{Firing, NodeSchema, PortSpec, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Edge, FlatGraph, Harness, RunManifest, RunReport, SourceSpec, Target,
f64_field, Edge, FlatGraph, Harness, RunManifest, SourceSpec, Target,
VecSource,
};
use aura_ingest::load_m1_window;
use aura_backtest::SimBroker;
use aura_backtest::{summarize, RunReport, SimBroker};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use data_server::{DataServer, DEFAULT_DATA_PATH};
+2 -2
View File
@@ -9,11 +9,11 @@ use std::sync::Arc;
use aura_core::{Firing, NodeSchema, PortSpec, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Edge, FlatGraph, Harness, RunManifest, RunReport, Source, SourceSpec,
f64_field, Edge, FlatGraph, Harness, RunManifest, Source, SourceSpec,
Target,
};
use aura_ingest::{M1Field, M1FieldSource};
use aura_backtest::SimBroker;
use aura_backtest::{summarize, RunReport, SimBroker};
use aura_std::{Recorder, Sma, Sub};
use aura_strategy::Bias;
use data_server::loader::CHUNK_SIZE;
+9
View File
@@ -10,6 +10,15 @@ publish.workspace = true
# back (admitted under the amended C16 per-case policy, INDEX.md). RunReport
# derives serde in aura-engine.
aura-engine = { path = "../aura-engine" }
# aura-backtest carries the trading instantiation of the metric-generic engine
# (M = RunMetrics: RunReport/SweepFamily/SweepPoint/McFamily/RBootstrap/
# WalkForwardResult aliases, RMetrics, r_metrics_from_rs) the registry indexes
# and ranks (#291/#292, C28 phase 2 — process column, C28-legal).
aura-backtest = { path = "../aura-backtest" }
# aura-analysis is the foundation statistics kernel (MetricStats/resample_block/
# SplitMix64) `null_best_of_k`'s trials-deflation reality-check reduces through
# (#291).
aura-analysis = { path = "../aura-analysis" }
# the document stores' content-id primitive (put_process/put_campaign key on
# aura_research::content_id_of, the same hash the doc types canonicalize to);
# also PrimitiveBuilder, the referential tier's resolver signature.
+2 -3
View File
@@ -10,9 +10,8 @@
//! (`Registry::append`, `RunReport::to_json`) is untouched and still emits the
//! tagged form, so this is a one-directional widening, not a format change.
use aura_engine::{
FamilySelection, ProjectProvenance, RunManifest, RunMetrics, RunReport, Scalar, Timestamp,
};
use aura_backtest::{RunMetrics, RunReport};
use aura_engine::{FamilySelection, ProjectProvenance, RunManifest, Scalar, Timestamp};
use serde::de::{self, Deserializer};
use serde::Deserialize;
+9 -8
View File
@@ -18,10 +18,11 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use aura_core::PrimitiveBuilder;
use aura_analysis::{resample_block, MetricStats, SplitMix64};
use aura_backtest::{r_metrics_from_rs, RunReport, SweepFamily, SweepPoint};
use aura_engine::{
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, r_metrics_from_rs,
resample_block, FamilySelection, MetricStats, RunReport, SelectionMode, SplitMix64,
SweepFamily, SweepPoint,
blueprint_from_json, blueprint_identity_json, expected_max_of_normals, FamilySelection,
SelectionMode,
};
use aura_research::{CampaignDoc, DocRef};
@@ -528,7 +529,7 @@ fn resolve_metric(name: &str) -> Result<Metric, RegistryError> {
/// block; a missing block reads `NEG_INFINITY` (the worst rank for the
/// higher-is-better R keys), preserving `metric_cmp`'s documented contract.
fn metric_value(rep: &RunReport, m: Metric) -> f64 {
fn r_get(rep: &RunReport, f: impl Fn(&aura_engine::RMetrics) -> f64) -> f64 {
fn r_get(rep: &RunReport, f: impl Fn(&aura_backtest::RMetrics) -> f64) -> f64 {
rep.metrics.r.as_ref().map(f).unwrap_or(f64::NEG_INFINITY)
}
match m {
@@ -943,8 +944,8 @@ impl From<std::io::Error> for RegistryError {
mod tests {
use super::*;
use aura_core::{Cell, Scalar, Timestamp};
use aura_engine::RMetrics;
use aura_engine::{RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};
use aura_backtest::{RMetrics, RunMetrics};
use aura_engine::RunManifest;
fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
RunReport {
@@ -1422,7 +1423,7 @@ mod tests {
// `summarize_r`, which RETAINS it — and `optimize_deflated`'s R arm
// resamples exactly that conduit, so a fixture without it cannot reach
// the R-arm path under test.
let mut r = aura_engine::r_metrics_from_rs(&net_trade_rs);
let mut r = aura_backtest::r_metrics_from_rs(&net_trade_rs);
r.net_trade_rs = net_trade_rs;
SweepPoint {
params: vec![],
@@ -2530,7 +2531,7 @@ mod tests {
#[test]
fn campaign_run_record_roundtrips_with_annotator_fields() {
use aura_engine::r_bootstrap;
use aura_backtest::r_bootstrap;
let path = temp_family_dir("campaign_run_annotator_roundtrip");
let reg = Registry::open(&path);
// r_bootstrap is pure given its seed (C1), and serde_json round-trips
+2 -3
View File
@@ -19,9 +19,8 @@ use std::fs;
use std::io::Write;
use aura_core::Scalar;
use aura_engine::{
FamilySelection, McFamily, RBootstrap, RunReport, SweepFamily, WalkForwardResult,
};
use aura_backtest::{McFamily, RBootstrap, RunReport, SweepFamily, WalkForwardResult};
use aura_engine::FamilySelection;
use serde::{Deserialize, Serialize};
use crate::{Generalization, Registry, RegistryError};
@@ -12,7 +12,8 @@ use std::fs;
use std::path::PathBuf;
use aura_core::{Scalar, Timestamp};
use aura_engine::{FamilySelection, RunManifest, RunMetrics, RunReport, SelectionMode};
use aura_backtest::{RunMetrics, RunReport};
use aura_engine::{FamilySelection, RunManifest, SelectionMode};
use aura_registry::{
CampaignRunRecord, CellRealization, FamilyKind, Registry, StageRealization, StageSelection,
};
+7 -11
View File
@@ -35,19 +35,15 @@ fn prod_intra_workspace_deps(crate_name: &str) -> Vec<String> {
fn node_crates_obey_the_c28_import_direction() {
// (crate, the intra-workspace [dependencies] C28 permits for its layer)
let allowed: &[(&str, &[&str])] = &[
("aura-std", &["aura-core"]), // engine
("aura-market", &["aura-core"]), // market
("aura-strategy", &["aura-core"]), // strategy
("aura-backtest", &["aura-core"]), // backtest (may gain aura-strategy when it imports one)
("aura-analysis", &[]), // foundation: no aura-* deps
("aura-std", &["aura-core"]),
("aura-market", &["aura-core"]),
("aura-strategy", &["aura-core"]),
("aura-engine", &["aura-core", "aura-analysis"]),
("aura-backtest", &["aura-core", "aura-engine"]),
(
"aura-vocabulary",
&[
"aura-core",
"aura-std",
"aura-market",
"aura-strategy",
"aura-backtest",
],
&["aura-core", "aura-std", "aura-market", "aura-strategy", "aura-backtest"],
),
];
for (crate_name, permitted) in allowed {
+26 -21
View File
@@ -2666,26 +2666,27 @@ measurement and backtest instead of baked in R-only — this is #147).
shell", #286).** This contract states the *target*. At HEAD the crates cut by
mechanical role (vocabulary / reductions / documents / orchestration / CLI), not
cleanly by layer, so the layering is realized only partially:
- The dependency *direction* already nearly obeys the rule. The one hard
production violation is the engine's re-export of the backtest metrics —
since the #291 split, `aura-engine → aura-backtest`: `report.rs` re-exports
the R-metrics and the position-event table (`RMetrics`/`RunMetrics`/
`summarize_r`/`r_metrics_from_rs`, `PositionEvent`/`PositionAction`/
`derive_position_events`), and the generic-statistics kernel in `mc.rs`
carries R/bias-typed fields (`McAggregate.bias_sign_flips`, `RBootstrap.e_r`)
inside otherwise domain-free structs. The `aura-engine → aura-analysis` edge
is no longer a violation: post-#291 that crate holds only domain-free
statistics + selection provenance (`FamilySelection` for
`RunManifest.selection`), foundation-grade under this contract.
- **That violation is the exact surface of the deliberately-deferred #147.**
Cutting it cleanly needs the metric genericity (`RunReport<M>` plus abstracting
the registry deflation vocabulary) that #136 ratified *deferring* (user-ratified
2026-06-27: the registry `Metric`/`metric_cmp`/deflation vocabulary "stays in
the registry by design … forcing genericity would be a one-implementor
abstraction", deferred to the World/C21 layer — see the C16 realization note).
The stratification supplies the *second* metric consumer (measurement) that the
deferral was waiting on, so retiring #147 and cutting this edge are one coupled
decision — reserved to the user, since it reverses a ratified deferral.
- The dependency *direction* now obeys the rule across the engine stack. The
`aura-engine → aura-backtest` production violation is cut (phase 2, #292):
`RunReport` is generic over its metric payload `M` (the engine names no
concrete metric type), the pip/R reductions and the MC assembly (`summarize`,
`McAggregate`/`RBootstrap`/`monte_carlo`) moved to `aura-backtest`, and the
statistics kernel (`MetricStats`/`quantile`/`resample_block`/`SplitMix64`)
moved to the `aura-analysis` foundation. The engine's `[dependencies]` are now
`aura-core` + `aura-analysis` only; the backtest layer instantiates
`M = RunMetrics` (`aura-backtest → aura-engine`, an outer→inner edge). The
ladder direction is enforced for the engine/backtest/analysis rungs by the
structural test (`c28_layering`, seven-row table).
- **#147 split, partially retired (user-ratified 2026-07-20).** Item 1 — the
metric genericity `RunReport<M>` — shipped as phase 2 above (#292). Item 2 —
abstracting the registry `Metric`/`metric_cmp`/deflation vocabulary — stays
deferred: the registry sits in the process column, whose trading-awareness
this contract permits (`aura-registry → aura-research`, column-internal and
legal), and the tree still holds exactly one metric consumer — a measurement
run reports tap names, not metrics. It lifts when measurement supplies its
first deflatable metric (a tap-to-scalar reduction carrying its own null
model, #290); the original one-implementor rationale (#136, user-ratified
2026-06-27) holds until then. See #147 for the full disposition.
- The `aura-std` four-layer roster is now cut by layer (phase 4, #288): `aura-std`
holds the engine nodes only (arithmetic/logic/rolling + the generic sinks);
`aura-market` (`session`, `resample`), `aura-strategy` (`bias`/`stop_rule`/
@@ -2701,7 +2702,11 @@ cleanly by layer, so the layering is realized only partially:
column-internal and legal.
- Phased realization (each independently shippable; behaviour byte-identical
except the purely additive shape dispatch): (1) this contract; (2) cut the
`engine → analysis` edge — coupled to the #147 decision above; (3) the #286
engine's backtest-metrics edge via `RunReport<M>`**done** (#292:
metric-generic run record, the pip/R reductions + MC assembly relocated to
`aura-backtest`, the statistics kernel to `aura-analysis`, dependency
inversion, ladder enforced by the structural test; realizes item 1 of #147);
(3) the #286
shape dispatch plus the per-run scaffold as a library (seeds an `aura-backtest`
crate, gives *measurement* its run verb, removes the measured O(cycles) run-time
retention); (4) split the `aura-std` roster along engine / market / strategy /