datasynth_eval/privacy/
mod.rs1pub mod linkage;
10pub mod membership_inference;
11pub mod metrics;
12
13pub use linkage::{LinkageAttack, LinkageConfig, LinkageResults};
14pub use membership_inference::{MembershipInferenceAttack, MiaConfig, MiaResults};
15pub use metrics::{NistAlignmentReport, NistCriterion, SynQPMatrix, SynQPQuadrant};
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PrivacyEvaluation {
22 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub membership_inference: Option<MiaResults>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub linkage: Option<LinkageResults>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub nist_alignment: Option<NistAlignmentReport>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub synqp: Option<SynQPMatrix>,
34 pub passes: bool,
36 pub failures: Vec<String>,
38}
39
40impl Default for PrivacyEvaluation {
41 fn default() -> Self {
42 Self {
43 membership_inference: None,
44 linkage: None,
45 nist_alignment: None,
46 synqp: None,
47 passes: true,
48 failures: Vec::new(),
49 }
50 }
51}
52
53impl PrivacyEvaluation {
54 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn update_status(&mut self) {
61 self.failures.clear();
62
63 if let Some(ref mia) = self.membership_inference {
64 if !mia.passes {
65 self.failures.push(format!(
66 "MIA: AUC-ROC {:.4} exceeds threshold {:.4}",
67 mia.auc_roc, mia.auc_threshold
68 ));
69 }
70 }
71
72 if let Some(ref linkage) = self.linkage {
73 if !linkage.passes {
74 self.failures.push(format!(
75 "Linkage: re-identification rate {:.2}%, k-anonymity {}",
76 linkage.re_identification_rate * 100.0,
77 linkage.k_anonymity_achieved,
78 ));
79 }
80 }
81
82 if let Some(ref nist) = self.nist_alignment {
83 if !nist.passes {
84 self.failures.push(format!(
85 "NIST alignment: score {:.0}% (requires >= 71%)",
86 nist.alignment_score * 100.0
87 ));
88 }
89 }
90
91 self.passes = self.failures.is_empty();
92 }
93}
94
95#[cfg(test)]
96#[allow(clippy::unwrap_used)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn test_privacy_evaluation_default() {
102 let eval = PrivacyEvaluation::default();
103 assert!(eval.passes);
104 assert!(eval.failures.is_empty());
105 assert!(eval.membership_inference.is_none());
106 assert!(eval.linkage.is_none());
107 }
108
109 #[test]
110 fn test_privacy_evaluation_with_failures() {
111 let mut eval = PrivacyEvaluation::new();
112
113 eval.membership_inference = Some(MiaResults {
114 auc_roc: 0.85,
115 accuracy: 0.80,
116 precision: 0.78,
117 recall: 0.82,
118 passes: false,
119 n_members: 100,
120 n_non_members: 100,
121 auc_threshold: 0.6,
122 });
123
124 eval.update_status();
125 assert!(!eval.passes);
126 assert_eq!(eval.failures.len(), 1);
127 assert!(eval.failures[0].contains("MIA"));
128 }
129
130 #[test]
131 fn test_privacy_evaluation_all_pass() {
132 let mut eval = PrivacyEvaluation::new();
133
134 eval.membership_inference = Some(MiaResults {
135 auc_roc: 0.52,
136 accuracy: 0.50,
137 precision: 0.50,
138 recall: 0.50,
139 passes: true,
140 n_members: 100,
141 n_non_members: 100,
142 auc_threshold: 0.6,
143 });
144
145 eval.linkage = Some(LinkageResults {
146 re_identification_rate: 0.01,
147 k_anonymity_achieved: 10,
148 unique_qi_combos_original: 50,
149 unique_qi_combos_synthetic: 48,
150 overlapping_combos: 30,
151 uniquely_linked: 1,
152 total_synthetic: 100,
153 passes: true,
154 });
155
156 eval.update_status();
157 assert!(eval.passes);
158 assert!(eval.failures.is_empty());
159 }
160
161 #[test]
162 fn test_privacy_evaluation_serde() {
163 let eval = PrivacyEvaluation::default();
164 let json = serde_json::to_string(&eval).unwrap();
165 let parsed: PrivacyEvaluation = serde_json::from_str(&json).unwrap();
166 assert!(parsed.passes);
167 }
168}