1use std::collections::BTreeSet;
18
19pub 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
30pub 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
42pub 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
81pub 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
125pub fn calculate_panel_confidence(inputs: &PanelConfidenceInputs) -> u64 {
133 let mut score = 0;
134
135 if inputs.total_models > 0 {
137 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 if inputs.converged && inputs.max_rounds > 0 {
149 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 if !inputs.devil_found_serious_objection {
164 score += 2000;
165 }
166
167 score
168}
169
170#[cfg(test)]
176#[allow(clippy::unwrap_used, clippy::expect_used)]
177mod proptests {
178 use proptest::prelude::*;
179
180 use super::*;
181
182 proptest! {
183 #[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 #[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 #[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 prop_assert!(score <= 10000, "score {score} exceeds 10000 bps ceiling");
225 }
226 }
227
228 #[test]
230 fn empty_positions_returns_zero() {
231 assert_eq!(calculate_convergence(&[]), 0);
232 }
233
234 #[test]
236 fn single_position_returns_ten_thousand() {
237 assert_eq!(calculate_convergence(&[vec!["only one".into()]]), 10000);
238 }
239
240 #[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 #[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(¬_converged), 7_000);
278 }
279}