feat(campaign): aura-campaign crate — types, member_metric, preflight (0107 tasks 4-5)

New leaf library crate: the campaign-execution semantics home (#198
home decision — reusable beyond the CLI; NOT C21's project-side
World). CellSpec + the MemberRunner seam (the only thing a consumer
implements), MemberFault/ExecFault (Display-free, prose at the binary
seam), member_metric over the 14 per-member scalars (third roster
site, refs #190 — drift fails safe as a preflight refusal), and
preflight: v1 pipeline shape std::sweep [std::gate]* [std::walk_forward],
rankable-metric and gate-metric rosters, plateau-in-wf and
deflate+plateau refusals, i64-fit guard on wf lengths — all before any
member runs.

Post-loop: the held quality finding (untested i64-fit arm) verified
real and closed by hand — preflight_refuses_wf_length_exceeding_i64.

Gates: aura-campaign 14/0, clippy -D warnings clean.

refs #198
This commit is contained in:
2026-07-03 19:59:21 +02:00
parent b43def7d75
commit 3a4f1c4597
5 changed files with 762 additions and 0 deletions
Generated
+13
View File
@@ -105,6 +105,19 @@ dependencies = [
"serde_json",
]
[[package]]
name = "aura-campaign"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-core",
"aura-engine",
"aura-registry",
"aura-research",
"serde",
"serde_json",
]
[[package]]
name = "aura-cli"
version = "0.1.0"
+1
View File
@@ -18,6 +18,7 @@ members = [
"crates/aura-ingest",
"crates/aura-registry",
"crates/aura-research",
"crates/aura-campaign",
]
[workspace.package]
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "aura-campaign"
edition.workspace = true
version.workspace = true
license.workspace = true
publish.workspace = true
# Deliberately NO aura-ingest / aura-std / aura-composites: harness and data
# binding enter through the one-method MemberRunner seam (src/lib.rs), so the
# deploy-condemned CLI scaffolding never becomes a library dep and a
# differently-binding consumer (playground, tests) implements the same seam.
[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.
aura-engine = { path = "../aura-engine" }
# 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.
aura-registry = { path = "../aura-registry" }
# the campaign/process document types this crate executes.
aura-research = { path = "../aura-research" }
# realization payloads derive serde (the registry per-case policy, INDEX.md).
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+585
View File
@@ -0,0 +1,585 @@
//! aura-campaign — the campaign-execution library (#198, cycle 0107).
//!
//! Campaign *semantics* as a reusable leaf crate: cell enumeration over a
//! campaign document's (strategy, instrument, window) matrix, preflight of
//! the v1 executable pipeline shape, per-member gate evaluation, winner
//! selection, and realization assembly over the registry's family machinery.
//! Harness construction and data binding enter exclusively through the
//! one-method [`MemberRunner`] seam, so every consumer (the CLI today; the
//! playground and tests tomorrow) binds its own runner while the execution
//! semantics live here once — this crate is NOT the World (C12/C21): it
//! realizes one campaign document; it owns no topology, no data sources,
//! and no UI.
use std::collections::BTreeMap;
use aura_core::Scalar;
use aura_engine::RunReport;
use aura_registry::RegistryError;
use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, SelectRule, StageBlock};
/// One structural cell of the campaign matrix: (strategy, instrument,
/// window) — #198 decision 7.
pub struct CellSpec {
pub strategy_ordinal: usize,
/// Resolved blueprint content id (== the topology hash).
pub strategy_id: String,
/// Canonical blueprint bytes from the store.
pub blueprint_json: String,
/// The campaign's tuning axes (raw `param_space()` names).
pub axes: BTreeMap<String, Axis>,
pub instrument: String,
/// Inclusive epoch-ms bounds.
pub window_ms: (i64, i64),
}
/// The harness/data binding seam — the ONLY thing a consumer implements.
/// Params arrive as (raw axis name, value) pairs; the implementation binds
/// them to its harness convention and runs the member over `cell.instrument`
/// restricted to `window_ms` (inclusive epoch-ms, a sub-range of
/// `cell.window_ms` — a walk-forward stage passes sub-windows).
pub trait MemberRunner: Sync {
fn run_member(
&self,
cell: &CellSpec,
params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault>;
}
/// Display-free member faults (the consumer phrases them — the RefFault
/// pattern).
#[derive(Clone, Debug, PartialEq)]
pub enum MemberFault {
NoData { instrument: String, window_ms: (i64, i64) },
Bind(String),
Run(String),
}
/// Preflight + runtime refusals. Display-free and by-identifier: the
/// consumer phrases them (the DocFault/RefFault pattern).
#[derive(Debug)]
pub enum ExecFault {
/// v1 boundary: `std::monte_carlo` / `std::generalize` are not executable.
UnsupportedStage { stage: usize, block: String },
/// The pipeline is not `std::sweep (std::gate)* (std::walk_forward)?`.
PipelineShape { detail: String },
/// A sweep/walk_forward selection metric outside the registry's rankable
/// roster.
UnrankableMetric { stage: usize, metric: String },
/// A gate predicate metric that is not a per-member scalar (e.g. an
/// annotation name such as `deflated_score`).
GateMetricNotPerMember { stage: usize, metric: String },
/// `plateau:*` selection in walk_forward (a gated survivor subset has no
/// grid lattice to smooth over).
PlateauInWalkForward { stage: usize },
/// sweep `deflate: true` with a non-argmax select rule.
DeflatePlateauConflict { stage: usize },
/// `WindowRoller` construction refusals at runtime.
Window { stage: usize, detail: String },
Member(MemberFault),
Registry(RegistryError),
}
/// The 14 per-member scalars a gate predicate may reference: the 3
/// `RunMetrics` scalars plus the 11 `RMetrics` scalars. The three
/// selection-annotation names (`deflated_score` / `overfit_probability` /
/// `neighbourhood_score`) are deliberately NOT here — they describe a
/// selection, not a member. Hand-copied roster (the third metric-roster site
/// beside aura-research's 17-name vocabulary and aura-registry's rankable
/// set, #190); drift fails safe: an unknown name is a preflight refusal,
/// never a wrong number.
pub const PER_MEMBER_METRICS: &[&str] = &[
"total_pips", "max_drawdown", "bias_sign_flips",
"expectancy_r", "n_trades", "win_rate", "avg_win_r", "avg_loss_r",
"profit_factor", "max_r_drawdown", "n_open_at_end", "sqn_normalized",
"sqn", "net_expectancy_r",
];
/// The registry's rankable roster (`resolve_metric`'s name set) — the metrics
/// a sweep/walk_forward stage may select on. Hand-copied (#190); drift fails
/// safe (a name the registry would refuse is refused here first, before any
/// member runs).
pub const RANKABLE_METRICS: &[&str] = &[
"total_pips", "max_drawdown", "bias_sign_flips",
"sqn", "sqn_normalized", "expectancy_r", "net_expectancy_r",
];
/// Deflation resample count — the shipped CLI `select_winner` constant.
/// Duplicated against `aura-cli`'s private copy, tracked in #199.
pub const DEFLATION_N_RESAMPLES: usize = 1000;
/// Deflation moving-block length — the shipped CLI `select_winner` constant.
/// Duplicated against `aura-cli`'s private copy, tracked in #199.
pub const DEFLATION_BLOCK_LEN: usize = 5;
/// Resolve one of the 14 [`PER_MEMBER_METRICS`] against a member's report.
/// An R-metric name against `metrics.r == None` reads `None` (conservative
/// and deterministic: a gate predicate over `None` fails the member).
/// Annotation names and unknown names read `None`.
pub fn member_metric(report: &RunReport, name: &str) -> Option<f64> {
let m = &report.metrics;
match name {
"total_pips" => Some(m.total_pips),
"max_drawdown" => Some(m.max_drawdown),
"bias_sign_flips" => Some(m.bias_sign_flips as f64),
_ => {
let r = m.r.as_ref()?;
match name {
"expectancy_r" => Some(r.expectancy_r),
"n_trades" => Some(r.n_trades as f64),
"win_rate" => Some(r.win_rate),
"avg_win_r" => Some(r.avg_win_r),
"avg_loss_r" => Some(r.avg_loss_r),
"profit_factor" => Some(r.profit_factor),
"max_r_drawdown" => Some(r.max_r_drawdown),
"n_open_at_end" => Some(r.n_open_at_end as f64),
"sqn_normalized" => Some(r.sqn_normalized),
"sqn" => Some(r.sqn),
"net_expectancy_r" => Some(r.net_expectancy_r),
_ => None,
}
}
}
}
/// Whether `value <cmp> threshold` holds — the gate's comparator arm.
// Consumed by `execute`'s gate stage; the allow drops once that lands.
#[allow(dead_code)]
fn predicate_holds(cmp: &Cmp, value: f64, threshold: f64) -> bool {
match cmp {
Cmp::Gt => value > threshold,
Cmp::Ge => value >= threshold,
Cmp::Lt => value < threshold,
Cmp::Le => value <= threshold,
}
}
/// Statically refuse everything refusable before any member runs (the F7
/// lesson applied forward): the v1 executable pipeline shape is exactly
/// `std::sweep (std::gate)* (std::walk_forward)?`; every sweep/walk_forward
/// selection metric is in [`RANKABLE_METRICS`]; every gate predicate metric
/// is in [`PER_MEMBER_METRICS`]; walk_forward must not select `plateau:*`
/// (a gated survivor subset has no grid lattice); sweep `deflate: true`
/// composes only with `argmax`; walk_forward lengths must fit `i64` (the
/// roller's Timestamp unit). The campaign parameter is the seam for
/// campaign-level static checks (none in v1, hence unused).
// Consumed by `execute`; the allow drops once that lands.
#[allow(dead_code)]
pub(crate) fn preflight(process: &ProcessDoc, _campaign: &CampaignDoc) -> Result<(), ExecFault> {
// v1 boundary first: an mc/generalize stage refuses wherever it sits,
// before any shape complaint about the same stage.
for (i, stage) in process.pipeline.iter().enumerate() {
let block = match stage {
StageBlock::MonteCarlo { .. } => "std::monte_carlo",
StageBlock::Generalize { .. } => "std::generalize",
_ => continue,
};
return Err(ExecFault::UnsupportedStage { stage: i, block: block.to_string() });
}
// shape: exactly `std::sweep (std::gate)* (std::walk_forward)?`.
if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) {
return Err(ExecFault::PipelineShape {
detail: "the first stage must be std::sweep".to_string(),
});
}
let last = process.pipeline.len() - 1;
for (i, stage) in process.pipeline.iter().enumerate().skip(1) {
match stage {
StageBlock::Sweep { .. } => {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: only the first stage may be std::sweep"),
});
}
StageBlock::WalkForward { .. } if i != last => {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: std::walk_forward must be the final stage"),
});
}
_ => {}
}
}
// per-stage slot rules (shape already established above).
for (i, stage) in process.pipeline.iter().enumerate() {
match stage {
StageBlock::Sweep { metric, select, deflate } => {
if !RANKABLE_METRICS.contains(&metric.as_str()) {
return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() });
}
if *deflate && *select != SelectRule::Argmax {
return Err(ExecFault::DeflatePlateauConflict { stage: i });
}
}
StageBlock::Gate { all } => {
for p in all {
if !PER_MEMBER_METRICS.contains(&p.metric.as_str()) {
return Err(ExecFault::GateMetricNotPerMember {
stage: i,
metric: p.metric.clone(),
});
}
}
}
StageBlock::WalkForward {
in_sample_ms,
out_of_sample_ms,
step_ms,
mode: _,
metric,
select,
} => {
if !RANKABLE_METRICS.contains(&metric.as_str()) {
return Err(ExecFault::UnrankableMetric { stage: i, metric: metric.clone() });
}
if matches!(select, SelectRule::PlateauMean | SelectRule::PlateauWorst) {
return Err(ExecFault::PlateauInWalkForward { stage: i });
}
for (field, len) in [
("in_sample_ms", *in_sample_ms),
("out_of_sample_ms", *out_of_sample_ms),
("step_ms", *step_ms),
] {
if i64::try_from(len).is_err() {
return Err(ExecFault::PipelineShape {
detail: format!("stage {i}: walk_forward {field} does not fit i64"),
});
}
}
}
StageBlock::MonteCarlo { .. } | StageBlock::Generalize { .. } => {
unreachable!("refused by the v1 scan above")
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp};
/// A report with every per-member scalar planted to a distinct value
/// (1..=14 in `PER_MEMBER_METRICS` order), r block present.
fn report_with_r() -> RunReport {
RunReport {
manifest: RunManifest {
commit: "test".to_string(),
params: vec![],
window: (Timestamp(0), Timestamp(1)),
seed: 0,
broker: "sim".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics {
total_pips: 1.0,
max_drawdown: 2.0,
bias_sign_flips: 3,
r: Some(RMetrics {
expectancy_r: 4.0,
n_trades: 5,
win_rate: 6.0,
avg_win_r: 7.0,
avg_loss_r: 8.0,
profit_factor: 9.0,
max_r_drawdown: 10.0,
n_open_at_end: 11,
sqn_normalized: 12.0,
sqn: 13.0,
net_expectancy_r: 14.0,
conviction_terciles_r: [0.0; 3],
trade_rs: Vec::new(),
}),
},
}
}
#[test]
fn member_metric_resolves_all_fourteen_names() {
let rep = report_with_r();
let expected: &[(&str, f64)] = &[
("total_pips", 1.0),
("max_drawdown", 2.0),
("bias_sign_flips", 3.0),
("expectancy_r", 4.0),
("n_trades", 5.0),
("win_rate", 6.0),
("avg_win_r", 7.0),
("avg_loss_r", 8.0),
("profit_factor", 9.0),
("max_r_drawdown", 10.0),
("n_open_at_end", 11.0),
("sqn_normalized", 12.0),
("sqn", 13.0),
("net_expectancy_r", 14.0),
];
assert_eq!(expected.len(), 14);
assert_eq!(PER_MEMBER_METRICS.len(), 14);
for (name, want) in expected {
assert!(
PER_MEMBER_METRICS.contains(name),
"{name} missing from PER_MEMBER_METRICS"
);
assert_eq!(member_metric(&rep, name), Some(*want), "metric {name}");
}
}
#[test]
fn member_metric_r_names_none_without_r_block() {
let mut rep = report_with_r();
rep.metrics.r = None;
// the three run-level scalars still resolve...
assert_eq!(member_metric(&rep, "total_pips"), Some(1.0));
assert_eq!(member_metric(&rep, "max_drawdown"), Some(2.0));
assert_eq!(member_metric(&rep, "bias_sign_flips"), Some(3.0));
// ...and every R name reads None (a gate predicate over it fails).
for name in [
"expectancy_r", "n_trades", "win_rate", "avg_win_r", "avg_loss_r",
"profit_factor", "max_r_drawdown", "n_open_at_end", "sqn_normalized",
"sqn", "net_expectancy_r",
] {
assert_eq!(member_metric(&rep, name), None, "R metric {name} without r block");
}
}
#[test]
fn member_metric_refuses_annotation_and_unknown_names() {
let rep = report_with_r();
for name in [
"deflated_score", "overfit_probability", "neighbourhood_score", "no_such_metric",
] {
assert_eq!(member_metric(&rep, name), None, "{name} must not resolve");
assert!(
!PER_MEMBER_METRICS.contains(&name),
"{name} must not be in PER_MEMBER_METRICS"
);
}
}
#[test]
fn predicate_holds_covers_all_four_cmps() {
assert!(predicate_holds(&Cmp::Gt, 1.0, 0.0));
assert!(!predicate_holds(&Cmp::Gt, 0.0, 0.0));
assert!(predicate_holds(&Cmp::Ge, 0.0, 0.0));
assert!(!predicate_holds(&Cmp::Ge, -0.1, 0.0));
assert!(predicate_holds(&Cmp::Lt, -1.0, 0.0));
assert!(!predicate_holds(&Cmp::Lt, 0.0, 0.0));
assert!(predicate_holds(&Cmp::Le, 0.0, 0.0));
assert!(!predicate_holds(&Cmp::Le, 0.1, 0.0));
}
// -----------------------------------------------------------------
// preflight
// -----------------------------------------------------------------
use aura_core::ScalarKind;
use aura_research::{
DataSection, DocKind, DocRef, Predicate, Presentation, ProcessRef, StrategyEntry, WfMode,
Window,
};
fn process_of(pipeline: Vec<StageBlock>) -> ProcessDoc {
ProcessDoc {
format_version: 1,
kind: DocKind::Process,
name: "p".to_string(),
description: None,
pipeline,
}
}
/// A minimal intrinsically-valid campaign; preflight's campaign parameter
/// carries no v1 rules, so one fixture serves every test.
fn campaign() -> CampaignDoc {
CampaignDoc {
format_version: 1,
kind: DocKind::Campaign,
name: "c".to_string(),
description: None,
data: DataSection {
instruments: vec!["EURUSD".to_string()],
windows: vec![Window { from_ms: 0, to_ms: 10_000 }],
},
strategies: vec![StrategyEntry {
r#ref: DocRef::ContentId("0".repeat(64)),
axes: BTreeMap::from([(
"len".to_string(),
Axis {
kind: ScalarKind::I64,
values: vec![Scalar::i64(2), Scalar::i64(3)],
},
)]),
}],
process: ProcessRef { r#ref: DocRef::ContentId("1".repeat(64)) },
seed: 7,
presentation: Presentation { persist_taps: vec![], emit: vec![] },
}
}
fn sweep_stage(metric: &str, select: SelectRule, deflate: bool) -> StageBlock {
StageBlock::Sweep { metric: metric.to_string(), select, deflate }
}
fn gate_stage(metric: &str) -> StageBlock {
StageBlock::Gate {
all: vec![Predicate { metric: metric.to_string(), cmp: Cmp::Gt, value: 0.0 }],
}
}
fn wf_stage(metric: &str, select: SelectRule) -> StageBlock {
StageBlock::WalkForward {
in_sample_ms: 4000,
out_of_sample_ms: 1000,
step_ms: 1000,
mode: WfMode::Rolling,
metric: metric.to_string(),
select,
}
}
#[test]
fn preflight_accepts_the_v1_shape() {
let c = campaign();
// the full v1 shape: sweep (gate)* (walk_forward)?
let full = process_of(vec![
sweep_stage("sqn_normalized", SelectRule::Argmax, true),
gate_stage("net_expectancy_r"),
wf_stage("sqn_normalized", SelectRule::Argmax),
]);
assert!(preflight(&full, &c).is_ok());
// degenerate accepted shapes: bare sweep (plateau select without
// deflate is legal on a sweep); sweep + gates without walk_forward.
let bare = process_of(vec![sweep_stage("total_pips", SelectRule::PlateauMean, false)]);
assert!(preflight(&bare, &c).is_ok());
let gated = process_of(vec![
sweep_stage("expectancy_r", SelectRule::Argmax, false),
gate_stage("n_trades"),
gate_stage("win_rate"),
]);
assert!(preflight(&gated, &c).is_ok());
}
#[test]
fn preflight_refuses_mc_and_generalize_stages() {
let c = campaign();
let mc = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
StageBlock::MonteCarlo { resamples: 100, block_len: 5 },
]);
assert!(matches!(
preflight(&mc, &c),
Err(ExecFault::UnsupportedStage { stage: 1, block }) if block == "std::monte_carlo"
));
let g = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
StageBlock::Generalize { metric: "expectancy_r".to_string() },
]);
assert!(matches!(
preflight(&g, &c),
Err(ExecFault::UnsupportedStage { stage: 1, block }) if block == "std::generalize"
));
}
#[test]
fn preflight_refuses_non_sweep_first_and_double_sweep() {
let c = campaign();
let gate_first = process_of(vec![gate_stage("expectancy_r")]);
assert!(matches!(preflight(&gate_first, &c), Err(ExecFault::PipelineShape { .. })));
let empty = process_of(vec![]);
assert!(matches!(preflight(&empty, &c), Err(ExecFault::PipelineShape { .. })));
let double = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
sweep_stage("sqn", SelectRule::Argmax, false),
]);
assert!(matches!(preflight(&double, &c), Err(ExecFault::PipelineShape { .. })));
// walk_forward anywhere but last is a shape refusal too.
let wf_mid = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
wf_stage("sqn", SelectRule::Argmax),
gate_stage("expectancy_r"),
]);
assert!(matches!(preflight(&wf_mid, &c), Err(ExecFault::PipelineShape { .. })));
}
#[test]
fn preflight_refuses_unrankable_select_metric() {
let c = campaign();
// win_rate is a per-member scalar but NOT in the registry's rankable roster.
let sweep_bad = process_of(vec![sweep_stage("win_rate", SelectRule::Argmax, false)]);
assert!(matches!(
preflight(&sweep_bad, &c),
Err(ExecFault::UnrankableMetric { stage: 0, metric }) if metric == "win_rate"
));
let wf_bad = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
wf_stage("profit_factor", SelectRule::Argmax),
]);
assert!(matches!(
preflight(&wf_bad, &c),
Err(ExecFault::UnrankableMetric { stage: 1, metric }) if metric == "profit_factor"
));
}
#[test]
fn preflight_refuses_annotation_gate_metric() {
let c = campaign();
let p = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
gate_stage("deflated_score"),
]);
assert!(matches!(
preflight(&p, &c),
Err(ExecFault::GateMetricNotPerMember { stage: 1, metric }) if metric == "deflated_score"
));
}
#[test]
fn preflight_refuses_plateau_in_walk_forward() {
let c = campaign();
for select in [SelectRule::PlateauMean, SelectRule::PlateauWorst] {
let p = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
wf_stage("sqn", select),
]);
assert!(matches!(
preflight(&p, &c),
Err(ExecFault::PlateauInWalkForward { stage: 1 })
));
}
}
#[test]
fn preflight_refuses_wf_length_exceeding_i64() {
let c = campaign();
let p = process_of(vec![
sweep_stage("sqn", SelectRule::Argmax, false),
StageBlock::WalkForward {
in_sample_ms: u64::MAX,
out_of_sample_ms: 1000,
step_ms: 1000,
mode: WfMode::Rolling,
metric: "sqn".to_string(),
select: SelectRule::Argmax,
},
]);
assert!(matches!(
preflight(&p, &c),
Err(ExecFault::PipelineShape { detail })
if detail.contains("in_sample_ms does not fit i64")
));
}
#[test]
fn preflight_refuses_deflate_with_plateau() {
let c = campaign();
for select in [SelectRule::PlateauMean, SelectRule::PlateauWorst] {
let p = process_of(vec![sweep_stage("sqn", select, true)]);
assert!(matches!(
preflight(&p, &c),
Err(ExecFault::DeflatePlateauConflict { stage: 0 })
));
}
}
}
@@ -0,0 +1,138 @@
//! End-to-end coverage for aura-campaign's public seam (cycle 0107 tasks
//! 4-5): `MemberRunner` is documented as the ONE binding point every
//! consumer implements ("every consumer ... binds its own runner while the
//! execution semantics live here once"), so these tests drive it as an
//! external consumer would — through `&dyn MemberRunner`, never an internal
//! function — and check the seam's plumbing plus the cross-roster invariant
//! `preflight` silently depends on. The in-crate unit tests in `lib.rs` pin
//! `member_metric`'s full 14-name mapping and `preflight`'s (crate-private)
//! shape rules against hand-built fixtures; these tests pin what a real,
//! external, dynamically-dispatched consumer sees.
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};
fn cell() -> CellSpec {
CellSpec {
strategy_ordinal: 0,
strategy_id: "0".repeat(64),
blueprint_json: "{}".to_string(),
axes: BTreeMap::new(),
instrument: "EURUSD".to_string(),
window_ms: (0, 20),
}
}
/// A minimal external `MemberRunner`: ignores its params and produces
/// metrics via the real `aura_engine::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;
impl MemberRunner for FixedRunner {
fn run_member(
&self,
_cell: &CellSpec,
_params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault> {
let equity = vec![(Timestamp(0), 0.0), (Timestamp(1), 3.0), (Timestamp(2), 1.0)];
let metrics = summarize(&equity, &[]);
Ok(RunReport {
manifest: RunManifest {
commit: "e2e".to_string(),
params: vec![],
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics,
})
}
}
/// Property: `MemberRunner` is object-safe and dispatches through
/// `&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()`
/// output the runner produced is what `member_metric` reads back through the
/// crate's public per-name resolver.
#[test]
fn member_runner_seam_dispatches_through_dyn_trait_object_and_carries_the_window() {
let runner = FixedRunner;
let dyn_runner: &dyn MemberRunner = &runner;
let report = dyn_runner
.run_member(&cell(), &[("len".to_string(), Scalar::i64(3))], (10, 20))
.expect("member run succeeds");
// the call-site sub-window (10, 20), not cell().window_ms == (0, 20),
// reached the runner unmodified
assert_eq!(report.manifest.window, (Timestamp(10), Timestamp(20)));
// the real summarize() reduction over [0.0, 3.0, 1.0] is what
// member_metric reads back: last == 1.0, peak-to-trough == 2.0
assert_eq!(member_metric(&report, "total_pips"), Some(1.0));
assert_eq!(member_metric(&report, "max_drawdown"), Some(2.0));
}
/// Property: every metric name in `RANKABLE_METRICS` (the roster `preflight`
/// permits a `std::sweep`/`std::walk_forward` stage to SELECT on) is also a
/// member of `PER_MEMBER_METRICS` and resolves to `Some` via `member_metric`.
/// The two rosters are independently hand-copied (documented drift risk,
/// #190); were they to diverge, `preflight` would accept a selection metric
/// that `member_metric` silently reads back as `None` for every member — a
/// sweep/walk_forward stage that always empties, not a compile error or an
/// early refusal.
#[test]
fn rankable_metrics_are_all_per_member_resolvable() {
let report = RunReport {
manifest: RunManifest {
commit: "e2e".to_string(),
params: vec![],
window: (Timestamp(0), Timestamp(1)),
seed: 0,
broker: "test".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics: RunMetrics {
total_pips: 1.0,
max_drawdown: 2.0,
bias_sign_flips: 3,
r: Some(RMetrics {
expectancy_r: 4.0,
n_trades: 5,
win_rate: 6.0,
avg_win_r: 7.0,
avg_loss_r: 8.0,
profit_factor: 9.0,
max_r_drawdown: 10.0,
n_open_at_end: 11,
sqn_normalized: 12.0,
sqn: 13.0,
net_expectancy_r: 14.0,
conviction_terciles_r: [0.0; 3],
trade_rs: Vec::new(),
}),
},
};
for &metric in RANKABLE_METRICS {
assert!(
PER_MEMBER_METRICS.contains(&metric),
"{metric} is rankable but missing from PER_MEMBER_METRICS"
);
assert!(
member_metric(&report, metric).is_some(),
"{metric} is rankable but member_metric returned None"
);
}
}