94aaa4cde8
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
110 lines
5.5 KiB
Rust
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)"
|
|
);
|
|
}
|