use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::error::{Result, SdkError};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct VerdictRecord {
pub validator_id: String,
pub candidate_id: String,
pub missed: bool,
}
impl VerdictRecord {
pub fn new(
validator_id: impl Into<String>,
candidate_id: impl Into<String>,
missed: bool,
) -> Self {
Self {
validator_id: validator_id.into(),
candidate_id: candidate_id.into(),
missed,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PairStats {
pub q_i: f64,
pub q_j: f64,
pub kappa: Option<f64>,
pub joint_miss: f64,
pub joint_miss_upper: f64,
pub samples: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EnsembleCertification {
Certified,
InsufficientEvidence,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IndependenceStats {
pub miss_rates: BTreeMap<String, f64>,
pub pairs: BTreeMap<(String, String), PairStats>,
pub rho_eff: Option<f64>,
pub validators: usize,
pub certification: EnsembleCertification,
}
pub fn miss_std(q: f64) -> f64 {
(q * (1.0 - q)).max(0.0).sqrt()
}
pub fn pairwise_joint_miss(q_i: f64, q_j: f64, kappa: f64) -> f64 {
q_i * q_j + kappa * miss_std(q_i) * miss_std(q_j)
}
pub const DEFAULT_MIN_PAIR_SAMPLES: usize = 20;
pub fn compute(records: &[VerdictRecord]) -> Result<IndependenceStats> {
compute_with_floor(records, DEFAULT_MIN_PAIR_SAMPLES)
}
pub fn compute_with_floor(
records: &[VerdictRecord],
min_pair_samples: usize,
) -> Result<IndependenceStats> {
if records.is_empty() {
return Err(SdkError::Domain("no verdict records".into()));
}
let mut by_validator: BTreeMap<String, BTreeMap<String, bool>> = BTreeMap::new();
for r in records {
by_validator
.entry(r.validator_id.clone())
.or_default()
.insert(r.candidate_id.clone(), r.missed);
}
let mut miss_rates = BTreeMap::new();
for (v, verdicts) in &by_validator {
let n = verdicts.len() as f64;
let misses = verdicts.values().filter(|&&m| m).count() as f64;
miss_rates.insert(v.clone(), misses / n);
}
let validators: Vec<&String> = by_validator.keys().collect();
let mut pairs = BTreeMap::new();
let mut rho_eff: Option<f64> = None;
let mut all_pairs_certified = !validators.is_empty() && validators.len() > 1;
for a in 0..validators.len() {
for b in (a + 1)..validators.len() {
let (vi, vj) = (validators[a], validators[b]);
let joint: Vec<(bool, bool)> = by_validator[vi]
.iter()
.filter_map(|(c, &m_i)| by_validator[vj].get(c).map(|&m_j| (m_i, m_j)))
.collect();
if joint.is_empty() {
all_pairs_certified = false;
continue;
}
let stats = pair_stats(&joint);
if stats.samples < min_pair_samples {
all_pairs_certified = false;
}
rho_eff = Some(match rho_eff {
Some(current) => current.min(stats.joint_miss_upper),
None => stats.joint_miss_upper,
});
pairs.insert((vi.clone(), vj.clone()), stats);
}
}
let certification = if all_pairs_certified && !pairs.is_empty() {
EnsembleCertification::Certified
} else {
EnsembleCertification::InsufficientEvidence
};
Ok(IndependenceStats {
miss_rates,
pairs,
rho_eff: match certification {
EnsembleCertification::Certified => rho_eff,
EnsembleCertification::InsufficientEvidence => None,
},
validators: validators.len(),
certification,
})
}
fn pair_stats(joint: &[(bool, bool)]) -> PairStats {
let n = joint.len() as f64;
let q_i = joint.iter().filter(|&&(x, _)| x).count() as f64 / n;
let q_j = joint.iter().filter(|&&(_, y)| y).count() as f64 / n;
let joint_miss = joint.iter().filter(|&&(x, y)| x && y).count() as f64 / n;
let degenerate = q_i == 0.0 || q_i == 1.0 || q_j == 0.0 || q_j == 1.0;
let kappa = if degenerate {
None
} else {
Some(pearson_phi(joint))
};
let joint_miss_upper = (joint_miss + ((1.0 / 0.05f64).ln() / (2.0 * n)).sqrt()).min(1.0);
PairStats {
q_i,
q_j,
kappa,
joint_miss,
joint_miss_upper,
samples: joint.len(),
}
}
fn pearson_phi(pairs: &[(bool, bool)]) -> f64 {
let n = pairs.len() as f64;
let to_f = |b: bool| if b { 1.0 } else { 0.0 };
let mean_x: f64 = pairs.iter().map(|&(x, _)| to_f(x)).sum::<f64>() / n;
let mean_y: f64 = pairs.iter().map(|&(_, y)| to_f(y)).sum::<f64>() / n;
let (mut cov, mut var_x, mut var_y) = (0.0, 0.0, 0.0);
for &(x, y) in pairs {
let dx = to_f(x) - mean_x;
let dy = to_f(y) - mean_y;
cov += dx * dy;
var_x += dx * dx;
var_y += dy * dy;
}
if var_x <= f64::EPSILON || var_y <= f64::EPSILON {
return 0.0;
}
cov / (var_x.sqrt() * var_y.sqrt())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn miss_std_matches_bernoulli() {
assert!((miss_std(0.5) - 0.5).abs() < 1e-12);
assert_eq!(miss_std(0.0), 0.0);
assert_eq!(miss_std(1.0), 0.0);
}
#[test]
fn lemma_5_1_reproduces_the_papers_mc3_arithmetic() {
let joint = pairwise_joint_miss(0.1, 0.1, 0.5);
assert!((joint - 0.055).abs() < 1e-12, "{joint}");
}
#[test]
fn marginals_and_correlation_use_the_matched_stratum() {
let records = vec![
VerdictRecord::new("a", "c1", true),
VerdictRecord::new("a", "c2", false),
VerdictRecord::new("a", "c3", false),
VerdictRecord::new("a", "c4", false),
VerdictRecord::new("b", "c1", true),
VerdictRecord::new("b", "c2", false),
];
let stats = compute_with_floor(&records, 2).unwrap();
let pair = &stats.pairs[&("a".to_string(), "b".to_string())];
assert_eq!(pair.samples, 2);
assert!((pair.q_i - 0.5).abs() < 1e-12, "matched q_i, not 0.25");
}
#[test]
fn degenerate_marginals_use_the_joint_miss_never_kappa_zero() {
let records = vec![
VerdictRecord::new("a", "c1", false),
VerdictRecord::new("a", "c2", false),
VerdictRecord::new("b", "c1", true),
VerdictRecord::new("b", "c2", false),
];
let stats = compute_with_floor(&records, 2).unwrap();
let pair = &stats.pairs[&("a".to_string(), "b".to_string())];
assert_eq!(pair.kappa, None);
assert_eq!(pair.joint_miss, 0.0);
}
#[test]
fn below_the_sample_floor_no_bound_is_displayed() {
let records = vec![
VerdictRecord::new("a", "c1", true),
VerdictRecord::new("b", "c1", true),
];
let stats = compute(&records).unwrap();
assert_eq!(
stats.certification,
EnsembleCertification::InsufficientEvidence
);
assert_eq!(
stats.rho_eff, None,
"insufficient evidence displays no number"
);
}
#[test]
fn three_validator_bound_is_never_tighter_than_its_best_pair() {
let mut records = Vec::new();
for i in 0..40 {
let candidate = format!("c{i}");
let a_miss = i % 10 == 0;
let b_miss = i % 10 == 0; let c_miss = i % 13 == 0; records.push(VerdictRecord::new("a", &candidate, a_miss));
records.push(VerdictRecord::new("b", &candidate, b_miss));
records.push(VerdictRecord::new("c", &candidate, c_miss));
}
let stats = compute(&records).unwrap();
assert_eq!(stats.validators, 3);
assert_eq!(stats.certification, EnsembleCertification::Certified);
let best_pair = stats
.pairs
.values()
.map(|p| p.joint_miss_upper)
.fold(f64::INFINITY, f64::min);
assert_eq!(stats.rho_eff.unwrap(), best_pair);
let best_point = stats
.pairs
.values()
.map(|p| p.joint_miss)
.fold(f64::INFINITY, f64::min);
assert!(stats.rho_eff.unwrap() >= best_point);
}
#[test]
fn empty_ledger_is_error() {
assert!(compute(&[]).is_err());
}
}