1use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Result, SdkError};
23
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct VerdictRecord {
31 pub validator_id: String,
32 pub candidate_id: String,
33 pub missed: bool,
34}
35
36impl VerdictRecord {
37 pub fn new(
38 validator_id: impl Into<String>,
39 candidate_id: impl Into<String>,
40 missed: bool,
41 ) -> Self {
42 Self {
43 validator_id: validator_id.into(),
44 candidate_id: candidate_id.into(),
45 missed,
46 }
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct IndependenceStats {
53 pub miss_rates: BTreeMap<String, f64>,
55 pub correlations: BTreeMap<(String, String), f64>,
57 pub rho_eff: Option<f64>,
59}
60
61pub fn miss_std(q: f64) -> f64 {
63 (q * (1.0 - q)).max(0.0).sqrt()
64}
65
66pub fn compute(records: &[VerdictRecord]) -> Result<IndependenceStats> {
71 if records.is_empty() {
72 return Err(SdkError::Domain("no verdict records".into()));
73 }
74
75 let mut by_validator: BTreeMap<String, BTreeMap<String, bool>> = BTreeMap::new();
77 for r in records {
78 by_validator
79 .entry(r.validator_id.clone())
80 .or_default()
81 .insert(r.candidate_id.clone(), r.missed);
82 }
83
84 let mut miss_rates = BTreeMap::new();
86 for (v, verdicts) in &by_validator {
87 let n = verdicts.len() as f64;
88 let misses = verdicts.values().filter(|&&m| m).count() as f64;
89 miss_rates.insert(v.clone(), misses / n);
90 }
91
92 let validators: Vec<&String> = by_validator.keys().collect();
94 let mut correlations = BTreeMap::new();
95 let mut rho_eff: Option<f64> = None;
96
97 for a in 0..validators.len() {
98 for b in (a + 1)..validators.len() {
99 let vi = validators[a];
100 let vj = validators[b];
101 let mi = by_validator[vi].clone();
102 let mj = by_validator[vj].clone();
103
104 let joint: Vec<(bool, bool)> = mi
106 .iter()
107 .filter_map(|(c, &m_i)| mj.get(c).map(|&m_j| (m_i, m_j)))
108 .collect();
109 if joint.is_empty() {
110 continue;
111 }
112
113 let kappa = pearson_phi(&joint);
114 correlations.insert((vi.clone(), vj.clone()), kappa);
115
116 let qi = miss_rates[vi];
117 let qj = miss_rates[vj];
118 let bound = qi * qj + kappa * miss_std(qi) * miss_std(qj);
119 rho_eff = Some(match rho_eff {
120 Some(current) => current.min(bound),
121 None => bound,
122 });
123 }
124 }
125
126 Ok(IndependenceStats {
127 miss_rates,
128 correlations,
129 rho_eff,
130 })
131}
132
133fn pearson_phi(pairs: &[(bool, bool)]) -> f64 {
136 let n = pairs.len() as f64;
137 let to_f = |b: bool| if b { 1.0 } else { 0.0 };
138 let sum_x: f64 = pairs.iter().map(|&(x, _)| to_f(x)).sum();
139 let sum_y: f64 = pairs.iter().map(|&(_, y)| to_f(y)).sum();
140 let mean_x = sum_x / n;
141 let mean_y = sum_y / n;
142 let mut cov = 0.0;
143 let mut var_x = 0.0;
144 let mut var_y = 0.0;
145 for &(x, y) in pairs {
146 let dx = to_f(x) - mean_x;
147 let dy = to_f(y) - mean_y;
148 cov += dx * dy;
149 var_x += dx * dx;
150 var_y += dy * dy;
151 }
152 if var_x <= f64::EPSILON || var_y <= f64::EPSILON {
153 return 0.0;
154 }
155 cov / (var_x.sqrt() * var_y.sqrt())
156}
157
158pub fn attenuate_weight(weight: f64, correlation_with_counted: f64) -> f64 {
163 weight * (1.0 - correlation_with_counted.clamp(0.0, 1.0))
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn miss_std_matches_bernoulli() {
172 assert!((miss_std(0.5) - 0.5).abs() < 1e-12);
173 assert_eq!(miss_std(0.0), 0.0);
174 assert_eq!(miss_std(1.0), 0.0);
175 }
176
177 #[test]
178 fn independent_validators_have_low_rho_eff() {
179 let records = vec![
182 VerdictRecord::new("a", "c1", true),
183 VerdictRecord::new("b", "c1", false),
184 VerdictRecord::new("a", "c2", false),
185 VerdictRecord::new("b", "c2", true),
186 VerdictRecord::new("a", "c3", true),
187 VerdictRecord::new("b", "c3", false),
188 VerdictRecord::new("a", "c4", false),
189 VerdictRecord::new("b", "c4", true),
190 ];
191 let stats = compute(&records).unwrap();
192 let kappa = stats
193 .correlations
194 .get(&("a".into(), "b".into()))
195 .copied()
196 .unwrap();
197 assert!(kappa < 0.0, "expected anti-correlation, got {kappa}");
198 assert!(stats.rho_eff.unwrap() >= 0.0);
199 }
200
201 #[test]
202 fn redundant_validators_are_attenuated() {
203 let records = vec![
205 VerdictRecord::new("a", "c1", true),
206 VerdictRecord::new("b", "c1", true),
207 VerdictRecord::new("a", "c2", false),
208 VerdictRecord::new("b", "c2", false),
209 VerdictRecord::new("a", "c3", true),
210 VerdictRecord::new("b", "c3", true),
211 ];
212 let stats = compute(&records).unwrap();
213 let kappa = stats
214 .correlations
215 .get(&("a".into(), "b".into()))
216 .copied()
217 .unwrap();
218 assert!(
219 (kappa - 1.0).abs() < 1e-9,
220 "expected perfect correlation, got {kappa}"
221 );
222 assert_eq!(attenuate_weight(2.0, kappa), 0.0);
223 }
224
225 #[test]
226 fn empty_ledger_is_error() {
227 assert!(compute(&[]).is_err());
228 }
229}