Files
Aura/crates/aura-campaign/tests/metric_vocabulary_e2e.rs
claude ab3f16879b feat(runner, cli, research): metric/tap schema carriers + the C29 coverage walk
C29 compile/unit seam, tasks 6-10 -- iteration 1 of the self-description
plan complete: every engine-shipped vocabulary entry now carries a
gate-clean one-line meaning.

- aura-runner: the internal tap recording sink threads its doc (the
  last unthreaded NodeSchema site) -- first full workspace build since
  the field became required.
- aura-cli scaffold: the sample node's template carries a doc line plus
  a maintenance comment, so every scaffolded extension crate starts
  gate-clean; both extension-vocabulary fixture exemplars thread real
  meaning lines (the load seam walks these in iteration 2). New E2E:
  the scaffold's own doc must pass its own gate -- an alibi doc in the
  template would teach the wrong pattern to every new project, and the
  compiler checks only the field's presence, never its shape.
- aura-research: metric_vocabulary()/tap_vocabulary() promote from bare
  name arrays to MetricSchema/TapSchema carriers (id + doc, 17 + 4
  authored lines); callers adapt to .id with byte-identical output
  (introspection goldens unchanged). The #190 guard keeps its triple --
  the doc column adds no fourth roster site.
- Coverage: tests/self_description.rs walks all five vocabularies
  (blocks, metrics, taps, folds, std nodes) through doc_gate -- the
  guard that keeps any future edit from blanking a doc.

Adjudicated review residue: the 17 metric doc strings are surfaced by
no CLI path yet -- deliberate; task 8 pins callers byte-identical, the
surfacing belongs to the CLI self-description work (#315). Plan-cited
coordinates lib.rs:1003/:1283 resolved to the real caller sites
(:1019/:1299); the plan's literal test code was reconciled to the real
accessors (FoldRegistry::core().roster(), Env::std()).

Gates: cargo test --workspace green (0 failed, incl. the new coverage
walk + scaffold E2E); cargo clippy --workspace --all-targets --
-D warnings clean.

refs #316
2026-07-23 17:19:01 +02:00

138 lines
6.6 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(|m| m.id.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)"
);
}
/// #147: the rankable roster is single-sourced from the vocabulary impl, the
/// rosters nest (rankable ⊆ per-member ⊆ research vocabulary), and the legacy
/// CLI alias keeps resolving. Any drift fails here, loudly.
#[test]
fn rankable_roster_is_single_sourced_and_nested() {
use aura_engine::MetricVocabulary;
assert_eq!(
aura_campaign::RANKABLE_METRICS,
<RunMetrics as MetricVocabulary>::known(),
"campaign roster must BE the vocabulary's roster"
);
for name in aura_campaign::RANKABLE_METRICS {
assert!(
aura_campaign::PER_MEMBER_METRICS.contains(name),
"rankable metric '{name}' missing from PER_MEMBER_METRICS"
);
}
for name in aura_campaign::PER_MEMBER_METRICS {
assert!(
aura_research::metric_vocabulary().iter().any(|m| m.id == *name),
"per-member metric '{name}' missing from the research vocabulary"
);
}
// the pre-rename CLI alias must keep resolving (back-compat).
assert!(<RunMetrics as MetricVocabulary>::resolve("exposure_sign_flips").is_some());
}