Files
Aura/crates/aura-campaign/tests/metric_vocabulary_e2e.rs
T
claude 94aaa4cde8 refactor: split aura-analysis into statistics and backtest metrics
C28 phase 5 (Stratification): the backtest reductions - RunMetrics, RMetrics,
summarize_r, r_metrics_from_rs, and the position-event table - move verbatim
into aura-backtest::metrics, beside the position_management producer whose
record layout they read (the r_col/cost_col lockstep contract is now
intra-crate; its stale "aura-std" comment corrected). aura-analysis is reduced
to the domain-free half: the multiple-comparison hurdle math (inv_norm_cdf,
expected_max_of_normals) and the selection-provenance types
(FamilySelection/SelectionMode - kept beside the statistics so
RunManifest.selection embeds a foundation-grade type), [dependencies] = serde
only.

The engine re-export surface is name-unchanged (report.rs re-imports from both
crates), so no indirect consumer needed a source edit; aura-backtest moves from
aura-engine's dev-dependencies into [dependencies] as a commented TRANSIENT
widening of the C28 violation, removed by the phase-2 edge cut on this branch.
The campaign drift guard imports its pinned types from their new source
crates. Behaviour-preserving: 1448/0 tests, clippy -D warnings clean, serde
shapes byte-identical (C18), moved code traceable via git copy detection.

closes #291
2026-07-20 12:23:17 +02:00

110 lines
5.5 KiB
Rust

//! Cross-crate guard for #190: pins `aura_research::metric_vocabulary()` (the
//! hand-written 17-name declared metric roster) against the ACTUAL scalar
//! fields the three metric-bearing types serialize — `RunMetrics`/`RMetrics`
//! from `aura-backtest` (via `summarize_r`), `FamilySelection` from
//! `aura-analysis` — not a second hand-list that could independently drift.
//! `aura-campaign` is the one crate that already depends on the type sources
//! and `aura-research` (the vocabulary), so this pin lives here
//! (`aura-research` deliberately has no dep on either type crate —
//! `metric_vocabulary`'s own doc comment names this as an accepted residual).
use std::collections::BTreeSet;
use aura_analysis::{FamilySelection, SelectionMode};
use aura_backtest::{summarize_r, RunMetrics};
/// The names of every field of a serialized sample whose JSON value is a
/// `Number` — i.e. a scalar metric readout, not a string/enum tag, a nested
/// object, or an array (per-tercile / per-trade series).
fn scalar_field_names(sample: &serde_json::Value) -> BTreeSet<String> {
sample
.as_object()
.expect("a metric-bearing type serializes to a JSON object")
.iter()
.filter(|(_, v)| v.is_number())
.map(|(k, _)| k.clone())
.collect()
}
/// Property: the declared metric vocabulary (`aura_research::metric_vocabulary()`)
/// is exhaustive against — and carries no stale entry beyond — the scalar
/// fields the three metric-bearing `aura-analysis` types
/// (`RMetrics`/`RunMetrics`/`FamilySelection`) actually ship, enumerated via
/// their real `serde::Serialize` impls rather than a second hand-copy.
///
/// If a field is added, renamed, or removed on any of the three types without
/// updating `metric_vocabulary()`, this test goes red: a new metric becomes
/// un-gateable (present in the type, absent from the vocabulary), or a stale
/// entry survives the vocabulary after its field is gone (present in the
/// vocabulary, absent from every type). Today the two rosters agree, so this
/// is a drift guard, not a repro: it must pass on arrival.
#[test]
fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
// RMetrics: the real empty-input reduction (`summarize_r(&[], &[])`) is a
// genuine producer, not a hand-typed literal — every scalar field is
// unconditionally present (no `Option`/`skip_serializing_if` on this type).
let r_metrics = summarize_r(&[], &[]);
// RunMetrics: `r: None` keeps the nested `RMetrics` block out of this
// sample's JSON entirely (an `Object`, not a `Number`, so it would be
// filtered out anyway; `None` just avoids double-counting its fields here).
let run_metrics = RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None };
// FamilySelection: an explicit literal that populates exactly the three
// annotation SCORES the vocabulary declares (`deflated_score`,
// `overfit_probability`, `neighbourhood_score`) and leaves their sibling
// annotation IDENTIFIERS (`n_resamples`/`block_len`/`seed`/`n_neighbours`)
// absent — real producers never mix an Argmax score with a Plateau score,
// but this is a serde-shape probe, not a reproduction of a real selection
// run, so the mix is only used to exercise every annotation field name once.
let selection = FamilySelection {
selection_metric: "expectancy_r".to_string(),
n_trials: 4,
raw_winner_metric: 1.0,
mode: SelectionMode::Argmax,
deflated_score: Some(0.5),
overfit_probability: Some(0.1),
n_resamples: None,
block_len: None,
seed: None,
neighbourhood_score: Some(0.9),
n_neighbours: None,
};
let mut scalar_fields = BTreeSet::new();
scalar_fields.extend(scalar_field_names(&serde_json::to_value(&r_metrics).unwrap()));
scalar_fields.extend(scalar_field_names(&serde_json::to_value(&run_metrics).unwrap()));
let mut selection_scalars = scalar_field_names(&serde_json::to_value(&selection).unwrap());
// `n_trials` / `raw_winner_metric` are selection PROVENANCE (how many
// trials ran; the raw pre-deflation score) about which existing metric
// the selection optimized, not a metric readout in their own right --
// structurally the same exclusion the `Number` filter above already
// applies to `selection_metric` (a `String` naming that same metric) and
// `mode` (the selection rule tag). Never part of the declared vocabulary.
selection_scalars.remove("n_trials");
selection_scalars.remove("raw_winner_metric");
scalar_fields.extend(selection_scalars);
let vocabulary: BTreeSet<String> =
aura_research::metric_vocabulary().iter().map(|s| s.to_string()).collect();
let missing_from_vocabulary: Vec<_> = scalar_fields.difference(&vocabulary).collect();
let stale_in_vocabulary: Vec<_> = vocabulary.difference(&scalar_fields).collect();
assert!(
missing_from_vocabulary.is_empty(),
"shipped scalar metric(s) {missing_from_vocabulary:?} are un-gateable: \
absent from aura_research::metric_vocabulary()"
);
assert!(
stale_in_vocabulary.is_empty(),
"aura_research::metric_vocabulary() entry/entries {stale_in_vocabulary:?} are stale: \
no shipped scalar field of RMetrics/RunMetrics/FamilySelection carries that name"
);
assert_eq!(
vocabulary.len(),
17,
"sanity count: 11 RMetrics + 3 RunMetrics + 3 FamilySelection scalars \
(a mismatched count here means a field is nested/missed by this flat, \
top-level enumeration)"
);
}