test(campaign): guard the metric vocabulary against the shipped metric types (#190)
metric_vocabulary() hand-lists 17 scalar metric names; a field added or renamed on RMetrics/RunMetrics/FamilySelection (aura-analysis) silently desynced the gate/stage checks — a new scalar stayed un-gateable until every roster was hand-updated (the 3-copy drift class #160 closed for node type ids). Pin the vocabulary against the types' real serde output instead of a second hand-copy: enumerate the scalar (JSON-Number) fields of real instances of the three metric-bearing types and assert set-equality with metric_vocabulary(). A new un-gated scalar (present in the type, absent from the vocabulary) or a stale vocabulary entry (present in the vocabulary, gone from every type) now goes red. RMetrics comes from the real producer summarize_r; RunMetrics and FamilySelection from exhaustive struct literals, so a new field also breaks the test's compilation until it is consciously classified. Selection provenance numbers (n_trials, raw_winner_metric) and the Optional annotation identifiers are excluded with rationale — the types mix scalar metrics with scalar provenance and carry no type-level marker between them, so that one distinction stays explicit. Green on arrival (the rosters agree today); the roster-macro maximal shape that would remove the hand-list entirely waits on #147 (where the metric vocabulary's home is decided). Guard test only, no production change. closes #190
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
//! 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 `aura-analysis` types serialize — not a
|
||||
//! second hand-list that could independently drift. `aura-campaign` is the
|
||||
//! one crate that already depends on both `aura-analysis` (the types) and
|
||||
//! `aura-research` (the vocabulary), so it is the only place this pin can be
|
||||
//! written today (`aura-research` deliberately has no `aura-analysis` dep —
|
||||
//! `metric_vocabulary`'s own doc comment names this as an accepted residual).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aura_analysis::{summarize_r, FamilySelection, RunMetrics, SelectionMode};
|
||||
|
||||
/// 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)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user