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)]
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), // 5% FP
169                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        // A txn marked both suspicious AND false positive — inconsistent
181        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, // no reason populated!
199            })
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}