1use std::collections::BTreeMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::composite::{CompositeScore, ScoreConfig};
20use crate::oos::OosDecayReport;
21use crate::rediscovery::RediscoveryVerdict;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum FailReason {
30 FailedPassK,
33 DsrBelowBar,
36 ProcessViolation,
38 BootstrapInsignificant,
41 MandateBreached,
43 HighSelectionGap,
46 IsRediscovery,
49 OosDecay,
52}
53
54#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
58pub struct DisqualThresholds {
59 pub dsr_bar: f64,
61 pub alpha: f64,
63 pub selection_gap_max: f64,
65 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 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
92pub 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 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 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
140pub 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 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 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 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 assert!(counts.values().all(|&c| c <= 2));
292 }
293}