1use std::collections::BTreeMap;
30
31use serde::{Deserialize, Serialize};
32
33use crate::error::{Result, SdkError};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
41pub struct VerdictRecord {
42 pub validator_id: String,
43 pub candidate_id: String,
44 pub missed: bool,
45}
46
47impl VerdictRecord {
48 pub fn new(
49 validator_id: impl Into<String>,
50 candidate_id: impl Into<String>,
51 missed: bool,
52 ) -> Self {
53 Self {
54 validator_id: validator_id.into(),
55 candidate_id: candidate_id.into(),
56 missed,
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63pub struct PairStats {
64 pub q_i: f64,
66 pub q_j: f64,
67 pub kappa: Option<f64>,
70 pub joint_miss: f64,
72 pub joint_miss_upper: f64,
75 pub samples: usize,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum EnsembleCertification {
83 Certified,
85 InsufficientEvidence,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct IndependenceStats {
92 pub miss_rates: BTreeMap<String, f64>,
94 pub pairs: BTreeMap<(String, String), PairStats>,
96 pub rho_eff: Option<f64>,
99 pub validators: usize,
102 pub certification: EnsembleCertification,
103}
104
105pub fn miss_std(q: f64) -> f64 {
107 (q * (1.0 - q)).max(0.0).sqrt()
108}
109
110pub fn pairwise_joint_miss(q_i: f64, q_j: f64, kappa: f64) -> f64 {
112 q_i * q_j + kappa * miss_std(q_i) * miss_std(q_j)
113}
114
115pub const DEFAULT_MIN_PAIR_SAMPLES: usize = 20;
117
118pub fn compute(records: &[VerdictRecord]) -> Result<IndependenceStats> {
120 compute_with_floor(records, DEFAULT_MIN_PAIR_SAMPLES)
121}
122
123pub fn compute_with_floor(
125 records: &[VerdictRecord],
126 min_pair_samples: usize,
127) -> Result<IndependenceStats> {
128 if records.is_empty() {
129 return Err(SdkError::Domain("no verdict records".into()));
130 }
131
132 let mut by_validator: BTreeMap<String, BTreeMap<String, bool>> = BTreeMap::new();
133 for r in records {
134 by_validator
135 .entry(r.validator_id.clone())
136 .or_default()
137 .insert(r.candidate_id.clone(), r.missed);
138 }
139
140 let mut miss_rates = BTreeMap::new();
142 for (v, verdicts) in &by_validator {
143 let n = verdicts.len() as f64;
144 let misses = verdicts.values().filter(|&&m| m).count() as f64;
145 miss_rates.insert(v.clone(), misses / n);
146 }
147
148 let validators: Vec<&String> = by_validator.keys().collect();
149 let mut pairs = BTreeMap::new();
150 let mut rho_eff: Option<f64> = None;
151 let mut all_pairs_certified = !validators.is_empty() && validators.len() > 1;
152
153 for a in 0..validators.len() {
154 for b in (a + 1)..validators.len() {
155 let (vi, vj) = (validators[a], validators[b]);
156 let joint: Vec<(bool, bool)> = by_validator[vi]
157 .iter()
158 .filter_map(|(c, &m_i)| by_validator[vj].get(c).map(|&m_j| (m_i, m_j)))
159 .collect();
160 if joint.is_empty() {
161 all_pairs_certified = false;
162 continue;
163 }
164 let stats = pair_stats(&joint);
165 if stats.samples < min_pair_samples {
166 all_pairs_certified = false;
167 }
168 rho_eff = Some(match rho_eff {
169 Some(current) => current.min(stats.joint_miss_upper),
170 None => stats.joint_miss_upper,
171 });
172 pairs.insert((vi.clone(), vj.clone()), stats);
173 }
174 }
175
176 let certification = if all_pairs_certified && !pairs.is_empty() {
177 EnsembleCertification::Certified
178 } else {
179 EnsembleCertification::InsufficientEvidence
180 };
181 Ok(IndependenceStats {
182 miss_rates,
183 pairs,
184 rho_eff: match certification {
187 EnsembleCertification::Certified => rho_eff,
188 EnsembleCertification::InsufficientEvidence => None,
189 },
190 validators: validators.len(),
191 certification,
192 })
193}
194
195fn pair_stats(joint: &[(bool, bool)]) -> PairStats {
197 let n = joint.len() as f64;
198 let q_i = joint.iter().filter(|&&(x, _)| x).count() as f64 / n;
199 let q_j = joint.iter().filter(|&&(_, y)| y).count() as f64 / n;
200 let joint_miss = joint.iter().filter(|&&(x, y)| x && y).count() as f64 / n;
201 let degenerate = q_i == 0.0 || q_i == 1.0 || q_j == 0.0 || q_j == 1.0;
202 let kappa = if degenerate {
203 None
204 } else {
205 Some(pearson_phi(joint))
206 };
207 let joint_miss_upper = (joint_miss + ((1.0 / 0.05f64).ln() / (2.0 * n)).sqrt()).min(1.0);
209 PairStats {
210 q_i,
211 q_j,
212 kappa,
213 joint_miss,
214 joint_miss_upper,
215 samples: joint.len(),
216 }
217}
218
219fn pearson_phi(pairs: &[(bool, bool)]) -> f64 {
222 let n = pairs.len() as f64;
223 let to_f = |b: bool| if b { 1.0 } else { 0.0 };
224 let mean_x: f64 = pairs.iter().map(|&(x, _)| to_f(x)).sum::<f64>() / n;
225 let mean_y: f64 = pairs.iter().map(|&(_, y)| to_f(y)).sum::<f64>() / n;
226 let (mut cov, mut var_x, mut var_y) = (0.0, 0.0, 0.0);
227 for &(x, y) in pairs {
228 let dx = to_f(x) - mean_x;
229 let dy = to_f(y) - mean_y;
230 cov += dx * dy;
231 var_x += dx * dx;
232 var_y += dy * dy;
233 }
234 if var_x <= f64::EPSILON || var_y <= f64::EPSILON {
235 return 0.0;
236 }
237 cov / (var_x.sqrt() * var_y.sqrt())
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243
244 #[test]
245 fn miss_std_matches_bernoulli() {
246 assert!((miss_std(0.5) - 0.5).abs() < 1e-12);
247 assert_eq!(miss_std(0.0), 0.0);
248 assert_eq!(miss_std(1.0), 0.0);
249 }
250
251 #[test]
252 fn lemma_5_1_reproduces_the_papers_mc3_arithmetic() {
253 let joint = pairwise_joint_miss(0.1, 0.1, 0.5);
256 assert!((joint - 0.055).abs() < 1e-12, "{joint}");
257 }
258
259 #[test]
260 fn marginals_and_correlation_use_the_matched_stratum() {
261 let records = vec![
264 VerdictRecord::new("a", "c1", true),
265 VerdictRecord::new("a", "c2", false),
266 VerdictRecord::new("a", "c3", false),
267 VerdictRecord::new("a", "c4", false),
268 VerdictRecord::new("b", "c1", true),
269 VerdictRecord::new("b", "c2", false),
270 ];
271 let stats = compute_with_floor(&records, 2).unwrap();
272 let pair = &stats.pairs[&("a".to_string(), "b".to_string())];
273 assert_eq!(pair.samples, 2);
274 assert!((pair.q_i - 0.5).abs() < 1e-12, "matched q_i, not 0.25");
275 }
276
277 #[test]
278 fn degenerate_marginals_use_the_joint_miss_never_kappa_zero() {
279 let records = vec![
282 VerdictRecord::new("a", "c1", false),
283 VerdictRecord::new("a", "c2", false),
284 VerdictRecord::new("b", "c1", true),
285 VerdictRecord::new("b", "c2", false),
286 ];
287 let stats = compute_with_floor(&records, 2).unwrap();
288 let pair = &stats.pairs[&("a".to_string(), "b".to_string())];
289 assert_eq!(pair.kappa, None);
290 assert_eq!(pair.joint_miss, 0.0);
291 }
292
293 #[test]
294 fn below_the_sample_floor_no_bound_is_displayed() {
295 let records = vec![
296 VerdictRecord::new("a", "c1", true),
297 VerdictRecord::new("b", "c1", true),
298 ];
299 let stats = compute(&records).unwrap();
300 assert_eq!(
301 stats.certification,
302 EnsembleCertification::InsufficientEvidence
303 );
304 assert_eq!(
305 stats.rho_eff, None,
306 "insufficient evidence displays no number"
307 );
308 }
309
310 #[test]
311 fn three_validator_bound_is_never_tighter_than_its_best_pair() {
312 let mut records = Vec::new();
315 for i in 0..40 {
316 let candidate = format!("c{i}");
317 let a_miss = i % 10 == 0;
318 let b_miss = i % 10 == 0; let c_miss = i % 13 == 0; records.push(VerdictRecord::new("a", &candidate, a_miss));
321 records.push(VerdictRecord::new("b", &candidate, b_miss));
322 records.push(VerdictRecord::new("c", &candidate, c_miss));
323 }
324 let stats = compute(&records).unwrap();
325 assert_eq!(stats.validators, 3);
326 assert_eq!(stats.certification, EnsembleCertification::Certified);
327 let best_pair = stats
328 .pairs
329 .values()
330 .map(|p| p.joint_miss_upper)
331 .fold(f64::INFINITY, f64::min);
332 assert_eq!(stats.rho_eff.unwrap(), best_pair);
333 let best_point = stats
336 .pairs
337 .values()
338 .map(|p| p.joint_miss)
339 .fold(f64::INFINITY, f64::min);
340 assert!(stats.rho_eff.unwrap() >= best_point);
341 }
342
343 #[test]
344 fn empty_ledger_is_error() {
345 assert!(compute(&[]).is_err());
346 }
347}