Skip to main content

exo_consensus/
scoring.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17use std::collections::BTreeSet;
18
19/// Scoring inputs for the Panel Confidence Index.
20pub struct PanelConfidenceInputs {
21    pub models_agreeing: u32,
22    pub total_models: u32,
23    pub converged: bool,
24    pub rounds_to_convergence: u32,
25    pub max_rounds: u32,
26    pub devil_found_serious_objection: bool,
27    pub minority_reports_count: u32,
28}
29
30/// Return a canonical deterministic claim set: trimmed, lowercased, sorted, and
31/// deduplicated. Empty claims are removed.
32pub fn canonical_claim_set(claims: &[String]) -> Vec<String> {
33    claims
34        .iter()
35        .map(|claim| claim.trim().to_lowercase())
36        .filter(|claim| !claim.is_empty())
37        .collect::<BTreeSet<_>>()
38        .into_iter()
39        .collect()
40}
41
42/// Calculate convergence between structured key-claim sets (in bps, 0-10000).
43/// Free-form text is not parsed here; callers must provide explicit claim
44/// evidence for each model position.
45pub fn calculate_convergence(claim_sets: &[Vec<String>]) -> u64 {
46    if claim_sets.is_empty() {
47        return 0;
48    }
49
50    let mut all_claims = BTreeSet::new();
51    let mut model_claims = Vec::new();
52
53    for claims in claim_sets {
54        let canonical = canonical_claim_set(claims);
55        let set = canonical.into_iter().collect::<BTreeSet<_>>();
56        for claim in &set {
57            all_claims.insert(claim.clone());
58        }
59        model_claims.push(set);
60    }
61
62    if all_claims.is_empty() {
63        return 0;
64    }
65    if claim_sets.len() == 1 {
66        return 10000;
67    }
68
69    let total_unique = u64::try_from(all_claims.len()).unwrap_or(0);
70    let shared_claims = u64::try_from(
71        all_claims
72            .iter()
73            .filter(|c| model_claims.iter().all(|mc| mc.contains(*c)))
74            .count(),
75    )
76    .unwrap_or(0);
77
78    (shared_claims * 10000) / total_unique
79}
80
81/// Return claims whose support across models meets the configured threshold.
82pub fn consensus_claims_at_threshold(
83    claim_sets: &[Vec<String>],
84    threshold_bps: u64,
85) -> Vec<String> {
86    if claim_sets.is_empty() {
87        return Vec::new();
88    }
89
90    let canonical_sets = claim_sets
91        .iter()
92        .map(|claims| {
93            canonical_claim_set(claims)
94                .into_iter()
95                .collect::<BTreeSet<_>>()
96        })
97        .collect::<Vec<_>>();
98    let mut all_claims = BTreeSet::new();
99    for claims in &canonical_sets {
100        for claim in claims {
101            all_claims.insert(claim.clone());
102        }
103    }
104
105    let model_count = u64::try_from(canonical_sets.len()).unwrap_or(0);
106    if model_count == 0 {
107        return Vec::new();
108    }
109
110    all_claims
111        .into_iter()
112        .filter(|claim| {
113            let support = u64::try_from(
114                canonical_sets
115                    .iter()
116                    .filter(|claims| claims.contains(claim))
117                    .count(),
118            )
119            .unwrap_or(0);
120            (support * 10000) / model_count >= threshold_bps
121        })
122        .collect()
123}
124
125/// Calculate Panel Confidence Index in bps (0–10000).
126/// Weights: model agreement (50%), speed of convergence (30%), devil's advocate (20%).
127///
128/// Inputs are clamped defensively: `models_agreeing` is capped at `total_models`,
129/// and the speed-component numerator is capped at `max_rounds`, so callers that
130/// pass out-of-range values (e.g. `rounds_to_convergence = 0`, which the original
131/// formula would otherwise translate to > 3000 bps) still receive a bounded score.
132pub fn calculate_panel_confidence(inputs: &PanelConfidenceInputs) -> u64 {
133    let mut score = 0;
134
135    // 1. Model Agreement (50%)
136    if inputs.total_models > 0 {
137        // Clamp agreeing to total so callers that pass malformed inputs
138        // (agreeing > total) cannot produce a score above 5000.
139        let agreeing = std::cmp::min(
140            u64::from(inputs.models_agreeing),
141            u64::from(inputs.total_models),
142        );
143        let agreement_bps = (agreeing * 5000) / u64::from(inputs.total_models);
144        score += agreement_bps;
145    }
146
147    // 2. Speed of Convergence (30%)
148    if inputs.converged && inputs.max_rounds > 0 {
149        // Original formula: (max - r + 1) / max * 3000.
150        // When r = 1 and max = N, this gives the full 3000 (fastest observed convergence).
151        // Guard against r = 0 (or any r < 1) which would push numerator above max
152        // and overshoot 3000.
153        let r = std::cmp::min(inputs.rounds_to_convergence, inputs.max_rounds);
154        let remainder = u64::from(inputs.max_rounds)
155            .saturating_sub(u64::from(r))
156            .saturating_add(1);
157        let numerator = std::cmp::min(remainder, u64::from(inputs.max_rounds));
158        let speed_bps = (numerator * 3000) / u64::from(inputs.max_rounds);
159        score += speed_bps;
160    }
161
162    // 3. Devil's Advocate (20%)
163    if !inputs.devil_found_serious_objection {
164        score += 2000;
165    }
166
167    score
168}
169
170// ===========================================================================
171// Property tests — A-010: prove convergence scoring never panics and always
172// returns a score in [0, 10000] regardless of input shape.
173// ===========================================================================
174
175#[cfg(test)]
176#[allow(clippy::unwrap_used, clippy::expect_used)]
177mod proptests {
178    use proptest::prelude::*;
179
180    use super::*;
181
182    proptest! {
183        /// `calculate_convergence` must never panic on arbitrary input and
184        /// must always return a score within the valid bps range.
185        #[test]
186        fn convergence_never_panics_and_bounded(positions in proptest::collection::vec(proptest::collection::vec(".*", 0..10), 0..20)) {
187            let score = calculate_convergence(&positions);
188            prop_assert!(score <= 10000, "score {score} out of range");
189        }
190
191        /// Identical positions must always score 10000 (perfect convergence),
192        /// regardless of claim shape.
193        #[test]
194        fn identical_positions_always_ten_thousand(
195            pos in proptest::collection::vec("[A-Za-z0-9][A-Za-z0-9 _-]{0,32}", 1..10)
196        ) {
197            let refs = vec![pos; 5];
198            let score = calculate_convergence(&refs);
199            prop_assert_eq!(score, 10000);
200        }
201
202        /// `calculate_panel_confidence` must never panic and always return
203        /// a bounded score.
204        #[test]
205        fn panel_confidence_bounded(
206            models_agreeing in 0u32..=1000,
207            total_models in 0u32..=1000,
208            rounds_to_convergence in 0u32..=100,
209            max_rounds in 0u32..=100,
210            devil in any::<bool>(),
211            minority in 0u32..=1000,
212        ) {
213            let inputs = PanelConfidenceInputs {
214                models_agreeing,
215                total_models,
216                converged: true,
217                rounds_to_convergence,
218                max_rounds,
219                devil_found_serious_objection: devil,
220                minority_reports_count: minority,
221            };
222            let score = calculate_panel_confidence(&inputs);
223            // Max theoretical: 5000 (agreement) + 3000 (speed) + 2000 (devil) = 10000.
224            prop_assert!(score <= 10000, "score {score} exceeds 10000 bps ceiling");
225        }
226    }
227
228    /// Boundary: empty input returns 0.
229    #[test]
230    fn empty_positions_returns_zero() {
231        assert_eq!(calculate_convergence(&[]), 0);
232    }
233
234    /// Boundary: single position returns 10000 (trivially self-consistent).
235    #[test]
236    fn single_position_returns_ten_thousand() {
237        assert_eq!(calculate_convergence(&[vec!["only one".into()]]), 10000);
238    }
239
240    /// Boundary: all-empty claim sets should not panic and should yield 0
241    /// because no explicit claim evidence exists.
242    #[test]
243    fn all_empty_strings_do_not_panic() {
244        let score = calculate_convergence(&[Vec::new(), Vec::new(), Vec::new()]);
245        assert_eq!(score, 0);
246    }
247
248    /// Boundary: single-claim positions with disjoint claims score zero.
249    #[test]
250    fn disjoint_single_claims_score_zero() {
251        let score = calculate_convergence(&[
252            vec!["A".to_string()],
253            vec!["B".to_string()],
254            vec!["C".to_string()],
255        ]);
256        assert_eq!(score, 0);
257    }
258
259    #[test]
260    fn panel_confidence_does_not_award_speed_without_convergence() {
261        let converged = PanelConfidenceInputs {
262            models_agreeing: 3,
263            total_models: 3,
264            converged: true,
265            rounds_to_convergence: 1,
266            max_rounds: 3,
267            devil_found_serious_objection: false,
268            minority_reports_count: 0,
269        };
270        let converged_score = calculate_panel_confidence(&converged);
271        let not_converged = PanelConfidenceInputs {
272            converged: false,
273            ..converged
274        };
275
276        assert_eq!(converged_score, 10_000);
277        assert_eq!(calculate_panel_confidence(&not_converged), 7_000);
278    }
279}