First cycle of the inferential-validation milestone. Adds optimize_deflated beside optimize: an additive selection-provenance record (deflated score + overfit probability) stamped on the OOS winner's manifest, never re-ranking (C23). R arm = centred moving-block reality-check reusing the r_bootstrap kernel; total_pips arm = expected-max-of-K dispersion floor. Shared RunManifest.selection carrier with a SelectionMode::Plateau slot reserved for #145. refs #144
18 KiB
Deflate the sweep winner's metric for the number of trials — Design Spec
Date: 2026-06-26 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Cycle 0076 — first cycle of the milestone Inferential validation (defend against false discovery at sweep scale). Closes #144 (refs the milestone).
Goal
The World's in-sample sweep picks the single best member by a bare argmax
(optimize, aura-registry/src/lib.rs:186). The more members a family contains,
the higher the best member's metric climbs by chance alone, so the winner's
sqn_normalized / expectancy_r is reported at face value regardless of how many
configurations competed for the top slot — the family-scale false-discovery the
milestone exists to defend against.
This cycle adds a selection-provenance record that, without changing which member wins (additive only — C23), reports how inflated the winner's metric is by the size of the search it won:
- a deflated score — the winner's metric minus the edge a search of the same size produces under a no-edge null;
- an overfit probability — the chance a search of
Kmembers yields the observed winner under that null;
both stamped on the surviving out-of-sample winner's manifest (C18), reproducible from a recorded seed (C1).
This is framed as aura's own selection-honesty discipline; it introduces no new ranking key and never re-orders a family.
Architecture
One new family-level selector sits above the closed metric_cmp / Metric
direction source, beside optimize, in the registry that already owns selection
(aura-registry). It calls optimize for the byte-identical argmax winner, then
computes the deflation as an additive companion:
- R arm (
selection_metric ∈ {sqn, sqn_normalized, expectancy_r, net_expectancy_r}): an empirical centred moving-block reality-check. Each member's per-trade R series (metrics.r.trade_rs, the in-memory conduit cycle 0075 already populates) is mean-subtracted to impose the no-edge null, then resampled with the same moving-block kernelr_bootstrapuses (aSplitMix64block draw —aura-engine/src/mc.rs); the per-resample best-of-Kmember metric forms the null-max distribution the winner is judged against. total_pipsarm (the legacy SmaCross walk-forward, notrade_rs): a closed-form dispersion-floor deflation — the winner minus the expected-maximum-of-Kinflation implied by the cross-member metric dispersion.
The record rides inside each member's existing RunReport.manifest as a new
optional field, using the one-directional serde widening already proven by
RunMetrics.r, so the family store (FamilyRunRecord / append_family) and every
legacy runs.jsonl / families.jsonl line are untouched.
The two walk-forward in-sample selection seams (walkforward_family,
aura-cli/src/main.rs:1795 total_pips arm, :1810 R arm) call the new selector and
stamp the record onto each window's chosen OOS report. The read-only
runs family … rank display surfaces it.
Crate placement (C9): the record type FamilySelection is an aura-engine type
(beside RunManifest / RMetrics), because RunManifest is an aura-engine type
and aura-registry depends on aura-engine — defining it in the registry would
invert the dependency. The selector optimize_deflated lives in aura-registry
(which owns optimize / metric_cmp) and constructs the engine-side record.
Concrete code shapes
User-facing program (the acceptance evidence)
A Stage-1 R walk-forward over a real symbol, sweeping a K = 2×2 = 4 in-sample
grid per window — unchanged invocation, new provenance on the result:
$ aura walkforward --strategy stage1-r --real EURUSD --from 1672531200000 --to 1735689600000 \
--fast 50,100 --slow 200,400 --stop-length 14 --stop-k 2.0
Each window's chosen OOS run now persists a selection block on its manifest:
// one member line in runs/families/<id> … families.jsonl
"manifest": {
"commit": "c192dfd…",
"params": [["signals.trend.fast.length", {"I64":100}], …],
"window": [1704067200000000000, 1706745600000000000],
"seed": 0,
"broker": "sim-optimal(pip_size=1e-4)",
"selection": {
"selection_metric": "sqn_normalized",
"n_trials": 4,
"raw_winner_metric": 1.83,
"deflated_score": 0.21, // winner − p95(null best-of-K); > 0 ⇒ survives
"overfit_probability": 0.06, // (count(nullmax ≥ winner) + 1)/(n+1)
"mode": "Argmax",
"n_resamples": 1000,
"block_len": 5,
"seed": 42
}
}
The legacy total_pips SmaCross arm records the same block with
"selection_metric":"total_pips" and overfit_probability omitted (the
dispersion-floor arm computes no empirical probability); selection_metric plus the
absent probability disambiguate the arm. A pre-0076 line carries no selection
field and reads back as None, byte-unchanged.
The provenance is surfaced read-only beside the raw metric:
$ aura runs family stage1-r-3 rank sqn_normalized
# … each member, best-first, now with: sqn_normalized=1.83 deflated=0.21 P(overfit)=0.06
New record type (aura-engine/src/report.rs, beside RMetrics)
/// Selection-provenance for a sweep winner: how its metric was deflated for the
/// number of configurations it beat. Additive (C23) — recorded, never re-ranking.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct FamilySelection {
pub selection_metric: String, // the metric the family was optimised against
pub n_trials: usize, // K — the family size the winner beat
pub raw_winner_metric: f64, // the argmax winner's face-value metric (audit)
pub deflated_score: f64, // trials-adjusted score (per-arm; see the selector)
/// Empirical data-snooping p-value, R arm only; `None` on the dispersion-floor
/// (`total_pips`) arm. `Option` (not a sentinel) keeps `PartialEq`/serde clean.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub overfit_probability: Option<f64>,
pub mode: SelectionMode,
pub n_resamples: usize, // resample provenance — reproduces the statistic
pub block_len: usize,
pub seed: u64,
}
/// Which selection objective produced the record. `Argmax` is the bare-best
/// pick (this cycle). The enum is left **open**: cycle 0145 adds a `Plateau*`
/// variant on this same field, so peak-vs-plateau runs stay distinguishable.
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum SelectionMode {
Argmax,
}
RunManifest — 5 → 6 fields (aura-engine/src/report.rs:420)
// before
pub struct RunManifest {
pub commit: String,
pub params: Vec<(String, Scalar)>,
pub window: (Timestamp, Timestamp),
pub seed: u64,
pub broker: String,
}
// after — one-directional widening, identical idiom to RunMetrics.r
pub struct RunManifest {
pub commit: String,
pub params: Vec<(String, Scalar)>,
pub window: (Timestamp, Timestamp),
pub seed: u64,
pub broker: String,
/// Selection provenance, present only on a sweep/walk-forward winner; a
/// standalone run and every pre-0076 line read back as `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selection: Option<FamilySelection>,
}
compat.rs legacy read-path — mirror + destructure (aura-registry/src/compat.rs)
// RunManifestRead (compat.rs:27) gains the same optional field …
struct RunManifestRead {
commit: String,
params: Vec<(String, ScalarRead)>,
window: (Timestamp, Timestamp),
seed: u64,
broker: String,
#[serde(default)]
selection: Option<FamilySelection>, // legacy lines → None
}
// … and BOTH the destructure (compat.rs:65) and the RunManifest build lift it:
let RunManifestRead { commit, params, window, seed, broker, selection } = r.manifest;
RunManifest { commit, params: /* … */, window, seed, broker, selection }
(The hard 5-field destructure fails to compile the moment the 6th field exists — this edit is the load-bearing, most-forgettable change; pinned by the existing compat round-trip tests.)
The selector (aura-registry/src/lib.rs, beside optimize)
/// `optimize`'s argmax winner PLUS its trials-deflation provenance. The returned
/// `SweepPoint` is byte-identical to `optimize(family, metric)` (additive, C23);
/// `FamilySelection` is the new companion record. Deterministic given `seed`.
pub fn optimize_deflated(
family: &SweepFamily, metric: &str, n_resamples: usize, block_len: usize, seed: u64,
) -> Result<(SweepPoint, FamilySelection), RegistryError> {
let winner = optimize(family, metric)?; // unchanged argmax
let m = resolve_metric(metric)?; // shared with metric_cmp
let k = family.points.len();
let raw = metric_value(&winner.report, m); // extracted from metric_cmp
let (deflated_score, overfit_probability) = if let Some(get_rs) = r_series_of(m) {
// R arm: centred moving-block reality-check over each member's trade_rs.
let null_max = null_best_of_k(family, m, get_rs, n_resamples, block_len, seed);
let stats = MetricStats::from_values(&null_max); // reuse mc.rs
let p_over = (null_max.iter().filter(|&&x| x >= raw).count() + 1) as f64
/ (n_resamples + 1) as f64; // (z+1)/(N+1)
(raw - stats.p95, Some(p_over))
} else {
// total_pips arm: closed-form expected-max-of-K dispersion floor.
let sd = member_sd(family, m);
(raw - sd * expected_max_of_normals(k), None)
};
Ok((winner.clone(), FamilySelection {
selection_metric: metric.to_string(), n_trials: k, raw_winner_metric: raw,
deflated_score, overfit_probability, mode: SelectionMode::Argmax,
n_resamples, block_len, seed,
}))
}
Supporting shapes (planner fills the bodies):
// aura-registry: refactor metric_cmp's value-read into a shared helper so the
// comparator and the deflation read one metric source (direction stays in metric_cmp).
fn resolve_metric(name: &str) -> Result<Metric, RegistryError>; // the existing name→enum match
fn metric_value(rep: &RunReport, m: Metric) -> f64; // r_get / total_pips read, lifted
fn r_series_of(m: Metric) -> Option<fn(&RunReport) -> &[f64]>; // Some for R metrics → trade_rs; None for total_pips/dd/flips
// null-max distribution: for i in 0..n_resamples, rng_i = SplitMix64::new(seed ^ i);
// per member k, resample the CENTRED series, recompute the same metric; M_i = max_k.
fn null_best_of_k(family, m, get_rs, n_resamples, block_len, seed) -> Vec<f64>;
fn member_metric_from_rs(rs: &[f64], m: Metric) -> f64; // r_metrics_from_rs(rs) → project field
// aura-engine/src/mc.rs: extract the moving-block draw so the kernel is shared,
// byte-identical (r_bootstrap keeps its golden test).
pub fn resample_block(rs: &[f64], block_len: usize, rng: &mut SplitMix64) -> Vec<f64>;
// aura-engine: a small pure inverse-normal-CDF + the expected-max-of-K coefficient.
pub fn inv_norm_cdf(p: f64) -> f64; // rational approximation, golden-tested
pub fn expected_max_of_normals(k: usize) -> f64; // (1−γ)Φ⁻¹(1−1/K)+γΦ⁻¹(1−1/(Ke)), γ=0.5772…
Call-site stamping (aura-cli/src/main.rs, walkforward_family)
// :1810 R arm — was: let best = optimize(&is_family, "sqn_normalized")…;
let (best, selection) =
optimize_deflated(&is_family, "sqn_normalized", DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED)
.expect("sqn_normalized is a known metric");
let (oos_equity, mut oos_report) = run_oos_r(&best.params, w.oos.0, w.oos.1, trace, data);
oos_report.manifest.selection = Some(selection); // stamp onto the OOS winner
WindowRun { chosen_params: best.params, oos_equity, oos_report }
// :1795 total_pips arm — identical shape with "total_pips" + run_oos.
DEFLATION_N_RESAMPLES / DEFLATION_BLOCK_LEN / DEFLATION_SEED are module
constants this cycle (recorded in the manifest → reproducible); CLI flags for them
are a deferred refinement. runs_family (:2192) prints deflated + P(overfit)
beside the raw metric when manifest.selection.is_some().
Components
| Component | Crate / file | Change |
|---|---|---|
FamilySelection, SelectionMode |
aura-engine/report.rs |
new serde types beside RMetrics |
RunManifest.selection |
aura-engine/report.rs:420 |
new Option field, serde-widened |
resample_block |
aura-engine/mc.rs |
extracted from r_bootstrap (kernel shared, byte-identical) |
inv_norm_cdf, expected_max_of_normals |
aura-engine (new stats item) |
pure, deterministic, golden-tested |
RunManifestRead.selection + destructure |
aura-registry/compat.rs:27,65 |
mirror + 6-field destructure + build |
optimize_deflated, metric_value, resolve_metric, null_best_of_k |
aura-registry/lib.rs |
new selector + metric_cmp value-read refactor |
walkforward_family stamping |
aura-cli/main.rs:1795,1810 |
call selector, stamp OOS manifest |
runs_family display |
aura-cli/main.rs:2192 |
print deflated + overfit when present |
Data flow
walkforward_familybuilds the in-sampleSweepFamilyper window (unchanged).optimize_deflatedcallsoptimize→ the same argmaxSweepPoint; reads the winner's raw metric viametric_value.- R arm: for each resample
i,SplitMix64::new(seed ^ i)drivesresample_blockover every member's mean-centredtrade_rs; the metric is recomputed (member_metric_from_rs) per member and reduced to the best-of-KM_i. The winner is judged against{M_i}:deflated_score = raw − p95(M),overfit_probability = (count(M_i ≥ raw) + 1)/(n+1). - total_pips arm:
deflated_score = raw − member_sd · expected_max_of_normals(K),overfit_probability = None. - The
FamilySelectionis stamped onto the chosen window'soos_report.manifest, which flows throughappend_familyunchanged and persists infamilies.jsonl. - On read,
compat.rslifts the field (legacy lines →None);runs_familydisplays it.
Error handling
- An empty resample budget (
n_resamples == 0) or a member with emptytrade_rsdegrades exactly asr_bootstrapalready does (all-zero kernel output); the deflation then equals the raw metric (no spurious penalty).block_lenis clamped[1, n]by the shared kernel. - A family whose winner has
r: Noneon the R arm (notrade_rs) contributes a zero null-max contribution for that member — never a panic (mirrorsmetric_cmp'sNEG_INFINITYtreatment of a missingrblock). - A degenerate
K ≤ 1family (a single member — no search) carries no multiple-comparison inflation:expected_max_of_normals(K ≤ 1) = 0.0(guarding theΦ⁻¹(1 − 1/K) → Φ⁻¹(0) = −∞divergence), sodeflated_score == rawandoverfit_probabilityreduces to the floor1/(n+1). optimize_deflatedreturnsRegistryError::UnknownMetricfor an unknown name (same asoptimize), before any resampling.- No new exit path in the CLI: the selector cannot fail where
optimizesucceeds.
Testing strategy
- C1 determinism (primary): same family +
(seed, n_resamples, block_len)→ bit-identicaloverfit_probabilityanddeflated_score, across thread counts (the best-of-Kreduction collects in member/odometer order, never completion order). A fixed-trade_rsfixture pins exact values. - C23 additive (tripwire):
optimize_deflated(f, m, …).0 == optimize(f, m)for every metric — the deflation never changes which point wins. - C2 IS-only: the selector reads only the in-sample family's members; a test
asserts no OOS report is touched (the call site passes
is_family). - Statistic correctness: on a fabricated family where one member has a real edge
and the rest are zero-centred noise,
overfit_probabilityis low anddeflated_score > 0; on an all-noise family (no member has edge),overfit_probabilityis high (≈ K/(K+…)) anddeflated_score ≤ 0. The centring is pinned: an uncentred control would give ≈0.5 — the test asserts the centred construction does not. inv_norm_cdfgolden: known quantiles (Φ⁻¹(0.975)=1.959964…, Φ⁻¹(0.5)=0, symmetry) to a tight tolerance;expected_max_of_normals(K)monotone increasing, small-Ksane (K=4 ≈ 1.03, not the √(2 ln 4)=1.66 asymptote).- C14/C18 back-compat: a pre-0076
families.jsonlline (noselection) loads asNone; a stamped manifest round-trips byte-identically; the existingruns.jsonlgolden and the SMA/total_pipswalk-forward + synthetic-MC goldens stay green (add, don't break). resample_blockextraction:r_bootstrap's existing determinism golden stays bit-identical (proves the kernel was extracted, not changed).
Acceptance criteria
- A Stage-1 R walk-forward stamps a
FamilySelectionon each OOS winner's manifest;aura runs family … ranksurfaces the deflated score and overfit probability beside the raw metric — the worked program above runs and produces the shown block. - The argmax winner is unchanged for every metric (C23 tripwire green).
overfit_probabilityanddeflated_scoreare reproducible from the recorded(seed, n_resamples, block_len)(C1).- Legacy
runs.jsonl/families.jsonllines load unchanged and all pre-existing goldens stay green (C18/C14). - The deflation is frictionless Stage-1 R; no Stage-2 cost enters.
Out of scope (deferred)
- The effective-independent-trials advisory (
n_eff_trials):SweepFamilycarries no axis cardinalities (sweep.rs:291), so its per-axis inference mis-shapes off a full cartesian grid — deferred to a follow-on, addable by the same serde widening. - A reload-time recompute of the R-arm statistic (
trade_rsisserde(skip), so it runs only live inwalkforward_familypre-persist — a recompute needs a wire-shape change). - Re-ranking a family by the deflated score (a different design decision — would change which member wins; belongs in the ledger, not this cycle).
- CLI flags for the resampling config; #145's
Plateau*variants (the enum slot is reserved, not filled).