datasynth_eval/banking/
false_positive_quality.rs1use serde::{Deserialize, Serialize};
10
11use crate::error::EvalResult;
12
13#[derive(Debug, Clone)]
15pub struct LabelData {
16 pub is_suspicious: bool,
17 pub is_false_positive: bool,
18 pub has_fp_reason: bool,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct FalsePositiveThresholds {
24 pub min_fp_rate: f64,
26 pub max_fp_rate: f64,
28 pub max_overlap_rate: f64,
30 pub min_reason_coverage: f64,
32}
33
34impl Default for FalsePositiveThresholds {
35 fn default() -> Self {
36 Self {
37 min_fp_rate: 0.01,
38 max_fp_rate: 0.30,
39 max_overlap_rate: 0.0,
40 min_reason_coverage: 0.95,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct FalsePositiveAnalysis {
48 pub total_transactions: usize,
49 pub suspicious_count: usize,
50 pub false_positive_count: usize,
51 pub overlap_count: usize,
52 pub missing_reason_count: usize,
53 pub fp_rate: f64,
54 pub passes: bool,
55 pub issues: Vec<String>,
56}
57
58pub struct FalsePositiveAnalyzer {
59 pub thresholds: FalsePositiveThresholds,
60}
61
62impl FalsePositiveAnalyzer {
63 pub fn new() -> Self {
64 Self {
65 thresholds: FalsePositiveThresholds::default(),
66 }
67 }
68
69 pub fn with_thresholds(thresholds: FalsePositiveThresholds) -> Self {
70 Self { thresholds }
71 }
72
73 pub fn analyze(&self, labels: &[LabelData]) -> EvalResult<FalsePositiveAnalysis> {
74 let total = labels.len();
75 let mut suspicious = 0usize;
76 let mut fp = 0usize;
77 let mut overlap = 0usize;
78 let mut missing_reason = 0usize;
79
80 for l in labels {
81 if l.is_suspicious {
82 suspicious += 1;
83 }
84 if l.is_false_positive {
85 fp += 1;
86 if l.is_suspicious {
87 overlap += 1;
88 }
89 if !l.has_fp_reason {
90 missing_reason += 1;
91 }
92 }
93 }
94
95 let fp_rate = if total > 0 {
96 fp as f64 / total as f64
97 } else {
98 0.0
99 };
100 let overlap_rate = if total > 0 {
101 overlap as f64 / total as f64
102 } else {
103 0.0
104 };
105 let reason_coverage = if fp > 0 {
106 1.0 - (missing_reason as f64 / fp as f64)
107 } else {
108 1.0
109 };
110
111 let mut issues = Vec::new();
112 if total > 0 {
113 if fp_rate < self.thresholds.min_fp_rate {
114 issues.push(format!(
115 "FP rate {:.2}% below minimum {:.2}% (realistic datasets need false positives)",
116 fp_rate * 100.0,
117 self.thresholds.min_fp_rate * 100.0,
118 ));
119 }
120 if fp_rate > self.thresholds.max_fp_rate {
121 issues.push(format!(
122 "FP rate {:.2}% above maximum {:.2}%",
123 fp_rate * 100.0,
124 self.thresholds.max_fp_rate * 100.0,
125 ));
126 }
127 }
128 if overlap_rate > self.thresholds.max_overlap_rate {
129 issues.push(format!(
130 "{overlap} transactions marked both is_suspicious AND is_false_positive (label inconsistency)"
131 ));
132 }
133 if fp > 0 && reason_coverage < self.thresholds.min_reason_coverage {
134 issues.push(format!(
135 "{missing_reason} of {fp} false positives missing false_positive_reason"
136 ));
137 }
138
139 Ok(FalsePositiveAnalysis {
140 total_transactions: total,
141 suspicious_count: suspicious,
142 false_positive_count: fp,
143 overlap_count: overlap,
144 missing_reason_count: missing_reason,
145 fp_rate,
146 passes: issues.is_empty(),
147 issues,
148 })
149 }
150}
151
152impl Default for FalsePositiveAnalyzer {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158#[cfg(test)]
159#[allow(clippy::unwrap_used)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn test_clean_fp_passes() {
165 let labels: Vec<LabelData> = (0..100)
166 .map(|i| LabelData {
167 is_suspicious: i < 2,
168 is_false_positive: (2..7).contains(&i), has_fp_reason: (2..7).contains(&i),
170 })
171 .collect();
172 let analyzer = FalsePositiveAnalyzer::new();
173 let result = analyzer.analyze(&labels).unwrap();
174 assert!(result.passes, "Issues: {:?}", result.issues);
175 assert_eq!(result.false_positive_count, 5);
176 }
177
178 #[test]
179 fn test_overlap_detected() {
180 let labels = vec![LabelData {
182 is_suspicious: true,
183 is_false_positive: true,
184 has_fp_reason: true,
185 }];
186 let analyzer = FalsePositiveAnalyzer::new();
187 let result = analyzer.analyze(&labels).unwrap();
188 assert!(!result.passes);
189 assert_eq!(result.overlap_count, 1);
190 }
191
192 #[test]
193 fn test_missing_reason_detected() {
194 let labels: Vec<LabelData> = (0..20)
195 .map(|i| LabelData {
196 is_suspicious: false,
197 is_false_positive: i < 2,
198 has_fp_reason: false, })
200 .collect();
201 let analyzer = FalsePositiveAnalyzer::new();
202 let result = analyzer.analyze(&labels).unwrap();
203 assert!(!result.passes);
204 assert!(result.issues.iter().any(|i| i.contains("reason")));
205 }
206}