Skip to main content

datasynth_eval/banking/
false_positive_quality.rs

1//! False positive quality evaluator.
2//!
3//! Validates that false positive injections are:
4//! - Within the expected rate range (not too few, not too many)
5//! - Mutually exclusive with `is_suspicious = true` (an FP is clean ground truth
6//!   that looks suspicious)
7//! - Accompanied by a non-empty `false_positive_reason`
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::EvalResult;
12
13/// Label data needed for FP quality evaluation.
14#[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/// Thresholds for FP quality.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct FalsePositiveThresholds {
24    /// Minimum FP rate (realistic datasets should have some)
25    pub min_fp_rate: f64,
26    /// Maximum FP rate (otherwise signal-to-noise ratio breaks)
27    pub max_fp_rate: f64,
28    /// Maximum allowed overlap between is_suspicious and is_false_positive (should be 0)
29    pub max_overlap_rate: f64,
30    /// Minimum fraction of FPs with populated reason
31    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/// False positive quality analysis result.
46#[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)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn test_clean_fp_passes() {
164        let labels: Vec<LabelData> = (0..100)
165            .map(|i| LabelData {
166                is_suspicious: i < 2,
167                is_false_positive: (2..7).contains(&i), // 5% FP
168                has_fp_reason: (2..7).contains(&i),
169            })
170            .collect();
171        let analyzer = FalsePositiveAnalyzer::new();
172        let result = analyzer.analyze(&labels).unwrap();
173        assert!(result.passes, "Issues: {:?}", result.issues);
174        assert_eq!(result.false_positive_count, 5);
175    }
176
177    #[test]
178    fn test_overlap_detected() {
179        // A txn marked both suspicious AND false positive — inconsistent
180        let labels = vec![LabelData {
181            is_suspicious: true,
182            is_false_positive: true,
183            has_fp_reason: true,
184        }];
185        let analyzer = FalsePositiveAnalyzer::new();
186        let result = analyzer.analyze(&labels).unwrap();
187        assert!(!result.passes);
188        assert_eq!(result.overlap_count, 1);
189    }
190
191    #[test]
192    fn test_missing_reason_detected() {
193        let labels: Vec<LabelData> = (0..20)
194            .map(|i| LabelData {
195                is_suspicious: false,
196                is_false_positive: i < 2,
197                has_fp_reason: false, // no reason populated!
198            })
199            .collect();
200        let analyzer = FalsePositiveAnalyzer::new();
201        let result = analyzer.analyze(&labels).unwrap();
202        assert!(!result.passes);
203        assert!(result.issues.iter().any(|i| i.contains("reason")));
204    }
205}