Skip to main content

perspt_sdk/
independence.rs

1//! Measured verifier independence (PSP-8 System 6 / Gate G).
2//!
3//! Verifier independence SHALL be measured, not assumed. From the per-candidate
4//! verdict history the SDK computes each validator's miss rate `q_i` and the
5//! pairwise miss correlation `kappa_ij`, then the effective conjunctive ensemble
6//! miss bound
7//!
8//! ```text
9//! rho_eff = min_{i<j} ( q_i q_j + kappa_ij sigma_i sigma_j ),
10//!           sigma_i = sqrt(q_i (1 - q_i)).
11//! ```
12//!
13//! Residual weights are attenuated for validators whose measured correlation
14//! with an already-counted validator is high, so a redundant validator does not
15//! contribute the weight of an independent one. Status views surface `rho_eff`
16//! rather than a raw count of validators.
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::{Result, SdkError};
23
24/// One validator's verdict on one candidate: did it *miss* an unsafe state?
25///
26/// `missed == true` means the validator accepted (passed) a candidate that was
27/// later found unsafe — i.e. a false negative. These are the events whose rate
28/// and correlation determine ensemble strength.
29#[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/// Independence statistics computed from the verdict ledger.
51#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
52pub struct IndependenceStats {
53    /// Per-validator miss rate `q_i`.
54    pub miss_rates: BTreeMap<String, f64>,
55    /// Pairwise miss correlation `kappa_ij`, keyed by ordered pair.
56    pub correlations: BTreeMap<(String, String), f64>,
57    /// Effective conjunctive ensemble miss bound `rho_eff`, if computable.
58    pub rho_eff: Option<f64>,
59}
60
61/// Standard deviation of a Bernoulli miss indicator: `sigma = sqrt(q(1-q))`.
62pub fn miss_std(q: f64) -> f64 {
63    (q * (1.0 - q)).max(0.0).sqrt()
64}
65
66/// Compute independence statistics from a verdict ledger.
67///
68/// Only candidates evaluated by *both* validators contribute to a pairwise
69/// correlation. The miss rate uses all of a validator's verdicts.
70pub fn compute(records: &[VerdictRecord]) -> Result<IndependenceStats> {
71    if records.is_empty() {
72        return Err(SdkError::Domain("no verdict records".into()));
73    }
74
75    // Group verdicts: validator -> (candidate -> missed).
76    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    // Per-validator miss rate q_i.
85    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    // Pairwise miss correlation kappa_ij over jointly-evaluated candidates.
93    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            // Joint candidates.
105            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
133/// Phi coefficient (Pearson correlation for two binary variables) over paired
134/// miss indicators. Returns 0 when either variable has zero variance.
135fn 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
158/// Attenuate a residual weight by measured correlation with an already-counted
159/// validator: `w_eff = w * (1 - max(0, kappa))`. A perfectly correlated
160/// (redundant) validator contributes no additional weight; an uncorrelated or
161/// anti-correlated one keeps its full weight.
162pub 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        // Two validators, anti-correlated misses: when one misses the other
180        // catches. rho_eff should be below the product-of-rates upper estimate.
181        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        // Perfectly correlated misses -> correlation 1 -> attenuated to zero.
204        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}