Skip to main content

sharpebench_core/
disqualification.rs

1//! Disqualification-reason taxonomy — a legibility layer over the composite score.
2//!
3//! [`crate::composite::score_agent`] already decides *whether* an agent is
4//! rank-eligible, but it collapses five independent gates into a single
5//! `rank_eligible` bool. When an agent is demoted a human (or a review queue) wants
6//! the *reasons*, not just the verdict — did it fail pass^k, breach its mandate, or
7//! is its edge indistinguishable from noise? This module reads the signals the
8//! scorer already computed and names them.
9//!
10//! It is **pure legibility**: it changes no scoring or eligibility semantics and
11//! computes nothing new — every reason is a comparison against a field the
12//! [`CompositeScore`] (plus optional out-of-band decay / rediscovery evidence)
13//! already carries.
14
15use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::composite::{CompositeScore, ScoreConfig};
20use crate::oos::OosDecayReport;
21use crate::rediscovery::RediscoveryVerdict;
22
23/// A single reason an agent was (or should be) demoted. The first five mirror the
24/// hard eligibility gates in [`crate::composite::score_agent`]; the last three are
25/// advisory quality flags the scorer reports but does not gate on, surfaced here so
26/// they are legible alongside the hard failures.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum FailReason {
30    /// A run failed the per-run PSR bar (pass^k, mode All): the edge isn't reliable
31    /// on every seed×window.
32    FailedPassK,
33    /// The pooled Deflated Sharpe did not clear `dsr_bar` — the edge does not
34    /// survive deflation for the number of strategies tried.
35    DsrBelowBar,
36    /// A block-severity process violation (risk-gate bypass, ignored halt, …).
37    ProcessViolation,
38    /// The bootstrap edge test was not significant (`bootstrap_p >= alpha`): the
39    /// return stream is statistically indistinguishable from noise.
40    BootstrapInsignificant,
41    /// The agent breached its trading mandate (e.g. the drawdown cap).
42    MandateBreached,
43    /// Advisory: a large best-minus-median candidate gap — the headline result
44    /// looks like a lucky pick from a family of tried strategies, not a robust edge.
45    HighSelectionGap,
46    /// Advisory: the pooled return stream is a near-duplicate of a known prior
47    /// strategy (recycling, not novelty).
48    IsRediscovery,
49    /// Advisory: the edge decays out of sample — little of the in-sample metric is
50    /// retained in later windows.
51    OosDecay,
52}
53
54/// Thresholds for the disqualification classifier. The eligibility-gate bars mirror
55/// a [`ScoreConfig`] (so the taxonomy agrees with the scorer that produced the
56/// score); the advisory bars are tunable review-queue knobs.
57#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
58pub struct DisqualThresholds {
59    /// Deflated-Sharpe bar (mirror of `ScoreConfig::dsr_bar`).
60    pub dsr_bar: f64,
61    /// Bootstrap significance level (mirror of `ScoreConfig::alpha`).
62    pub alpha: f64,
63    /// Selection gap above which the headline is flagged as a lucky pick.
64    pub selection_gap_max: f64,
65    /// Out-of-sample retention below which the edge is flagged as decayed.
66    pub oos_retention_min: f64,
67}
68
69impl Default for DisqualThresholds {
70    fn default() -> Self {
71        Self {
72            dsr_bar: 0.95,
73            alpha: 0.05,
74            selection_gap_max: 0.20,
75            oos_retention_min: 0.50,
76        }
77    }
78}
79
80impl DisqualThresholds {
81    /// Take the eligibility-gate bars from a [`ScoreConfig`] so the taxonomy uses
82    /// exactly the bars the scorer applied; advisory bars keep their defaults.
83    pub fn from_score_config(cfg: &ScoreConfig) -> Self {
84        Self {
85            dsr_bar: cfg.dsr_bar,
86            alpha: cfg.alpha,
87            ..Self::default()
88        }
89    }
90}
91
92/// Classify every disqualification/quality signal that fired for one agent.
93///
94/// Returns the reasons in a stable order (the enum's declaration order). An empty
95/// result means no signal fired — the agent cleared every hard gate and tripped no
96/// advisory flag given the evidence provided. `oos` / `rediscovery` are optional:
97/// when `None`, the corresponding advisory reason is simply never emitted.
98pub fn classify_disqualification(
99    score: &CompositeScore,
100    thresholds: &DisqualThresholds,
101    oos: Option<&OosDecayReport>,
102    rediscovery: Option<&RediscoveryVerdict>,
103) -> Vec<FailReason> {
104    let mut reasons = Vec::new();
105
106    // Hard eligibility gates (order matches the enum / the scorer's AND chain).
107    if !score.passed_k {
108        reasons.push(FailReason::FailedPassK);
109    }
110    if score.deflated_sharpe < thresholds.dsr_bar {
111        reasons.push(FailReason::DsrBelowBar);
112    }
113    if !score.process_ok {
114        reasons.push(FailReason::ProcessViolation);
115    }
116    if score.bootstrap_p >= thresholds.alpha {
117        reasons.push(FailReason::BootstrapInsignificant);
118    }
119    if !score.mandate_ok {
120        reasons.push(FailReason::MandateBreached);
121    }
122
123    // Advisory quality flags (reported by the scorer / supplied out of band).
124    if score
125        .selection_gap
126        .is_some_and(|g| g > thresholds.selection_gap_max)
127    {
128        reasons.push(FailReason::HighSelectionGap);
129    }
130    if rediscovery.is_some_and(|v| v.is_rediscovery) {
131        reasons.push(FailReason::IsRediscovery);
132    }
133    if oos.is_some_and(|r| r.retention < thresholds.oos_retention_min) {
134        reasons.push(FailReason::OosDecay);
135    }
136
137    reasons
138}
139
140/// Suite-level rollup: how many agents in a scored field tripped each reason.
141///
142/// Uses [`DisqualThresholds::default`] and no out-of-band evidence, so it covers the
143/// signals intrinsic to a [`CompositeScore`] (the five hard gates plus the reported
144/// selection gap). A [`BTreeMap`] keeps the output ordering deterministic.
145pub fn rollup(scores: &[CompositeScore]) -> BTreeMap<FailReason, usize> {
146    let thresholds = DisqualThresholds::default();
147    let mut counts: BTreeMap<FailReason, usize> = BTreeMap::new();
148    for s in scores {
149        for reason in classify_disqualification(s, &thresholds, None, None) {
150            *counts.entry(reason).or_insert(0) += 1;
151        }
152    }
153    counts
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::composite::{score_agent, AgentSubmission, Run};
160    use crate::process::{ProcessEvent, Trace};
161
162    fn run(mean_ret: f64, amp: f64, n: usize) -> Run {
163        Run {
164            returns: (0..n)
165                .map(|i| mean_ret + amp * (i as f64 * 0.7).sin())
166                .collect(),
167            trace: Trace::default(),
168            confidences: Vec::new(),
169            outcomes: Vec::new(),
170            cost: 0.0,
171        }
172    }
173
174    fn agent(id: &str, runs: Vec<Run>) -> AgentSubmission {
175        AgentSubmission {
176            agent_id: id.to_string(),
177            runs,
178            in_sample_trials: 0,
179            candidates: Vec::new(),
180        }
181    }
182
183    fn thresholds() -> DisqualThresholds {
184        DisqualThresholds::from_score_config(&ScoreConfig::default())
185    }
186
187    #[test]
188    fn skilled_agent_has_no_reasons() {
189        let s = score_agent(
190            &agent("skilled", (0..5).map(|_| run(0.002, 0.0005, 60)).collect()),
191            &ScoreConfig::default(),
192        );
193        assert!(s.rank_eligible);
194        assert!(classify_disqualification(&s, &thresholds(), None, None).is_empty());
195    }
196
197    #[test]
198    fn lucky_agent_fails_pass_k() {
199        let mut runs = vec![run(0.02, 0.002, 60)];
200        runs.extend((0..4).map(|_| run(0.0, 0.003, 60)));
201        let s = score_agent(&agent("lucky", runs), &ScoreConfig::default());
202        let reasons = classify_disqualification(&s, &thresholds(), None, None);
203        assert!(reasons.contains(&FailReason::FailedPassK), "{reasons:?}");
204    }
205
206    #[test]
207    fn process_violation_is_named() {
208        let mut runs: Vec<Run> = (0..5).map(|_| run(0.002, 0.0005, 60)).collect();
209        runs[0].trace.events.push(ProcessEvent::OrderPlaced {
210            risk_gate_passed: false,
211        });
212        let s = score_agent(&agent("violator", runs), &ScoreConfig::default());
213        let reasons = classify_disqualification(&s, &thresholds(), None, None);
214        assert!(
215            reasons.contains(&FailReason::ProcessViolation),
216            "{reasons:?}"
217        );
218    }
219
220    #[test]
221    fn noise_agent_fails_dsr_and_bootstrap() {
222        // A zero-drift, noisy agent: no real edge, so it can't clear the DSR bar and
223        // its bootstrap edge test is insignificant.
224        let s = score_agent(
225            &agent("noise", (0..5).map(|_| run(0.0, 0.02, 60)).collect()),
226            &ScoreConfig::default(),
227        );
228        let reasons = classify_disqualification(&s, &thresholds(), None, None);
229        assert!(reasons.contains(&FailReason::DsrBelowBar), "{reasons:?}");
230        assert!(
231            reasons.contains(&FailReason::BootstrapInsignificant),
232            "{reasons:?}"
233        );
234    }
235
236    #[test]
237    fn advisory_flags_require_evidence() {
238        let mut s = score_agent(
239            &agent("s", (0..5).map(|_| run(0.002, 0.0005, 60)).collect()),
240            &ScoreConfig::default(),
241        );
242        // Selection gap is on the score itself; inject a large one.
243        s.selection_gap = Some(0.5);
244        let oos = OosDecayReport {
245            window_metrics: vec![1.0, 0.1],
246            in_sample: 1.0,
247            out_of_sample: 0.1,
248            retention: 0.1,
249            monotone_decay: true,
250        };
251        let redisc = RediscoveryVerdict {
252            is_rediscovery: true,
253            max_similarity: 0.99,
254            nearest_index: Some(0),
255            threshold: 0.97,
256        };
257        let reasons = classify_disqualification(&s, &thresholds(), Some(&oos), Some(&redisc));
258        assert!(
259            reasons.contains(&FailReason::HighSelectionGap),
260            "{reasons:?}"
261        );
262        assert!(reasons.contains(&FailReason::OosDecay), "{reasons:?}");
263        assert!(reasons.contains(&FailReason::IsRediscovery), "{reasons:?}");
264
265        // Without the out-of-band evidence, the advisory reasons vanish (but the
266        // score-intrinsic selection gap stays).
267        let bare = classify_disqualification(&s, &thresholds(), None, None);
268        assert!(bare.contains(&FailReason::HighSelectionGap));
269        assert!(!bare.contains(&FailReason::OosDecay));
270        assert!(!bare.contains(&FailReason::IsRediscovery));
271    }
272
273    #[test]
274    fn rollup_counts_across_the_field() {
275        let skilled = score_agent(
276            &agent("skilled", (0..5).map(|_| run(0.002, 0.0005, 60)).collect()),
277            &ScoreConfig::default(),
278        );
279        let noise1 = score_agent(
280            &agent("noise1", (0..5).map(|_| run(0.0, 0.02, 60)).collect()),
281            &ScoreConfig::default(),
282        );
283        let noise2 = score_agent(
284            &agent("noise2", (0..5).map(|_| run(0.0, 0.02, 60)).collect()),
285            &ScoreConfig::default(),
286        );
287        let counts = rollup(&[skilled, noise1, noise2]);
288        assert_eq!(counts.get(&FailReason::DsrBelowBar), Some(&2));
289        assert_eq!(counts.get(&FailReason::BootstrapInsignificant), Some(&2));
290        // The skilled agent contributes no reasons, so no key counts all three.
291        assert!(counts.values().all(|&c| c <= 2));
292    }
293}