Skip to content

Commit 33b565b

Browse files
hyperpolymathclaude
andcommitted
feat(arbiter): Bayesian + Dempster-Shafer + Pareto arbitration trio
Three new arbitration mechanisms in src/rust/verification/ extending beyond the existing simple majority-vote in portfolio.rs: - bayesian_arbiter.rs: posterior over verdict given evidence stream. Log-odds accumulation for numerical stability; Shannon-entropy reporting; calibrated default per-prover likelihoods (Z3/CVC5 0.95/0.92, ITPs 0.98/0.95, ATPs 0.93/0.90, auto-active 0.90/0.88, default 0.85/0.85). Timeouts contribute LR=1.0. 5 tests. - dempster_shafer.rs: belief-mass combination via Dempster's rule with explicit Empty conflict set. ArbiterError::HighConflict trip at k>0.95. Helper proven_mass(t,timeout) ramps mass from 0.95→0.50 as runtime approaches timeout. 6 tests. - pareto_arbiter.rs: multi-objective Pareto frontier over (confidence↑, latency↓, axiom_cost↓, certificate_size↓) with Tiebreak {MinAxiom, MinLatency, MaxConfidence, MinCertificate}. 6 tests. mod.rs registers the three new modules (additive only). Lane: prover-corpus-saturation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1fb3544 commit 33b565b

4 files changed

Lines changed: 939 additions & 0 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
// SPDX-License-Identifier: MPL-2.0
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
3+
4+
//! Bayesian arbiter for prover-evidence fusion.
5+
//!
6+
//! Treats each prover as a noisy binary sensor with calibrated
7+
//! (precision-when-true, precision-when-false) likelihoods, then
8+
//! combines independent observations into a posterior over the
9+
//! verdict using log-odds accumulation (numerically stable).
10+
//!
11+
//! This is a complement to the existing simple-majority
12+
//! [`super::portfolio::PortfolioSolver`]: where portfolio reports
13+
//! a categorical agreement summary, this module returns a
14+
//! probability distribution + Shannon entropy.
15+
16+
#![allow(dead_code)]
17+
18+
use std::collections::HashMap;
19+
20+
use serde::{Deserialize, Serialize};
21+
22+
use crate::provers::ProverKind;
23+
24+
/// Verdict produced by a prover for a single goal.
25+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26+
pub enum Verdict {
27+
Proven,
28+
Refuted,
29+
Timeout,
30+
Unknown,
31+
Error,
32+
}
33+
34+
/// A single piece of prover evidence to be combined into the posterior.
35+
#[derive(Debug, Clone, Serialize, Deserialize)]
36+
pub struct ProverEvidence {
37+
pub prover: ProverKind,
38+
pub verdict: Verdict,
39+
pub time_ms: u64,
40+
/// Optional prover-self-reported confidence (0..1) — currently
41+
/// used as a soft modulator on the configured likelihood.
42+
pub confidence_self_reported: Option<f64>,
43+
}
44+
45+
/// Per-prover likelihood: P(verdict=true | actually-true)
46+
/// and P(verdict=true | actually-false) — i.e. (precision, 1-FPR).
47+
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
48+
struct Likelihood {
49+
/// P(prover says "Proven" | goal is actually true).
50+
p_correct_given_true: f64,
51+
/// P(prover says "Refuted" | goal is actually false).
52+
p_correct_given_false: f64,
53+
}
54+
55+
impl Default for Likelihood {
56+
fn default() -> Self {
57+
Self {
58+
p_correct_given_true: 0.85,
59+
p_correct_given_false: 0.85,
60+
}
61+
}
62+
}
63+
64+
/// Posterior over the verdict frame after Bayesian combination.
65+
#[derive(Debug, Clone, Serialize, Deserialize)]
66+
pub struct PosteriorVerdict {
67+
pub p_proven: f64,
68+
pub p_refuted: f64,
69+
pub p_unknown: f64,
70+
pub entropy_bits: f64,
71+
pub winning: Verdict,
72+
}
73+
74+
/// Bayesian arbiter — accumulates per-prover likelihoods and
75+
/// fuses evidence streams into a posterior.
76+
#[derive(Debug, Clone)]
77+
pub struct BayesianArbiter {
78+
/// Prior probability that the goal is true (Proven).
79+
prior_p_true: f64,
80+
/// Per-prover likelihood overrides; missing entries use defaults.
81+
likelihoods: HashMap<ProverKind, Likelihood>,
82+
}
83+
84+
impl BayesianArbiter {
85+
/// Create with a uniform-ish prior (commonly 0.5 for neutrality
86+
/// or skewed when historical priors are known).
87+
pub fn new(prior_p_true: f64) -> Self {
88+
let clamped = prior_p_true.clamp(1e-6, 1.0 - 1e-6);
89+
Self {
90+
prior_p_true: clamped,
91+
likelihoods: default_likelihoods(),
92+
}
93+
}
94+
95+
/// Override the likelihood for a specific prover.
96+
pub fn with_prover_likelihood(
97+
mut self,
98+
prover: ProverKind,
99+
p_correct_given_true: f64,
100+
p_correct_given_false: f64,
101+
) -> Self {
102+
self.likelihoods.insert(
103+
prover,
104+
Likelihood {
105+
p_correct_given_true: p_correct_given_true.clamp(1e-6, 1.0 - 1e-6),
106+
p_correct_given_false: p_correct_given_false.clamp(1e-6, 1.0 - 1e-6),
107+
},
108+
);
109+
self
110+
}
111+
112+
fn likelihood_for(&self, prover: ProverKind) -> Likelihood {
113+
self.likelihoods.get(&prover).copied().unwrap_or_default()
114+
}
115+
116+
/// Combine evidence into a posterior. Timeout/Unknown/Error
117+
/// observations contribute a likelihood ratio of 1.0 (no update).
118+
pub fn combine(&self, evidence: &[ProverEvidence]) -> PosteriorVerdict {
119+
// Log-odds accumulation: start from prior log-odds,
120+
// sum log(LR) for each Proven/Refuted observation.
121+
let mut log_odds_true = logit(self.prior_p_true);
122+
123+
for e in evidence {
124+
let lk = self.likelihood_for(e.prover);
125+
let (p_obs_given_true, p_obs_given_false) = match e.verdict {
126+
Verdict::Proven => (lk.p_correct_given_true, 1.0 - lk.p_correct_given_false),
127+
Verdict::Refuted => (1.0 - lk.p_correct_given_true, lk.p_correct_given_false),
128+
Verdict::Timeout | Verdict::Unknown | Verdict::Error => continue,
129+
};
130+
// Soft modulation by self-reported confidence (if present).
131+
let weight = e.confidence_self_reported.unwrap_or(1.0).clamp(0.0, 1.0);
132+
let lr = (p_obs_given_true / p_obs_given_false).max(1e-12);
133+
log_odds_true += weight * lr.ln();
134+
}
135+
136+
let p_true = sigmoid(log_odds_true);
137+
// Reserve a small mass for "Unknown" proportional to
138+
// how many timeouts/unknowns we saw (heuristic — keeps
139+
// entropy informative even when posterior is decisive).
140+
let n_total = evidence.len().max(1) as f64;
141+
let n_no_info = evidence
142+
.iter()
143+
.filter(|e| {
144+
matches!(
145+
e.verdict,
146+
Verdict::Timeout | Verdict::Unknown | Verdict::Error
147+
)
148+
})
149+
.count() as f64;
150+
let p_unknown = (n_no_info / n_total).clamp(0.0, 0.5);
151+
let scale = 1.0 - p_unknown;
152+
let p_proven = p_true * scale;
153+
let p_refuted = (1.0 - p_true) * scale;
154+
155+
let entropy_bits = shannon_entropy_bits(&[p_proven, p_refuted, p_unknown]);
156+
let winning = if p_proven >= p_refuted && p_proven >= p_unknown {
157+
Verdict::Proven
158+
} else if p_refuted >= p_unknown {
159+
Verdict::Refuted
160+
} else {
161+
Verdict::Unknown
162+
};
163+
164+
PosteriorVerdict {
165+
p_proven,
166+
p_refuted,
167+
p_unknown,
168+
entropy_bits,
169+
winning,
170+
}
171+
}
172+
}
173+
174+
impl Default for BayesianArbiter {
175+
fn default() -> Self {
176+
Self::new(0.5)
177+
}
178+
}
179+
180+
/// Built-in calibration table for the Tier-1 backends. Numbers are
181+
/// nominal — calibration against echidna's empirical corpus belongs
182+
/// in a follow-up.
183+
fn default_likelihoods() -> HashMap<ProverKind, Likelihood> {
184+
let mut m = HashMap::new();
185+
let high_smt = Likelihood {
186+
p_correct_given_true: 0.95,
187+
p_correct_given_false: 0.92,
188+
};
189+
let atp = Likelihood {
190+
p_correct_given_true: 0.93,
191+
p_correct_given_false: 0.90,
192+
};
193+
let itp = Likelihood {
194+
p_correct_given_true: 0.98,
195+
p_correct_given_false: 0.95,
196+
};
197+
let auto_active = Likelihood {
198+
p_correct_given_true: 0.90,
199+
p_correct_given_false: 0.88,
200+
};
201+
202+
m.insert(ProverKind::Z3, high_smt);
203+
m.insert(ProverKind::CVC5, high_smt);
204+
m.insert(ProverKind::Vampire, atp);
205+
m.insert(ProverKind::EProver, atp);
206+
m.insert(ProverKind::Coq, itp);
207+
m.insert(ProverKind::Lean, itp);
208+
m.insert(ProverKind::Isabelle, itp);
209+
m.insert(ProverKind::Agda, itp);
210+
m.insert(ProverKind::Idris2, itp);
211+
m.insert(ProverKind::Dafny, auto_active);
212+
m.insert(ProverKind::Why3, auto_active);
213+
m
214+
}
215+
216+
fn logit(p: f64) -> f64 {
217+
let p = p.clamp(1e-12, 1.0 - 1e-12);
218+
(p / (1.0 - p)).ln()
219+
}
220+
221+
fn sigmoid(x: f64) -> f64 {
222+
if x >= 0.0 {
223+
let z = (-x).exp();
224+
1.0 / (1.0 + z)
225+
} else {
226+
let z = x.exp();
227+
z / (1.0 + z)
228+
}
229+
}
230+
231+
fn shannon_entropy_bits(ps: &[f64]) -> f64 {
232+
let log2 = std::f64::consts::LN_2;
233+
let mut h = 0.0;
234+
for &p in ps {
235+
if p > 0.0 {
236+
h -= p * (p.ln() / log2);
237+
}
238+
}
239+
h
240+
}
241+
242+
#[cfg(test)]
243+
mod tests {
244+
use super::*;
245+
246+
fn ev(prover: ProverKind, verdict: Verdict) -> ProverEvidence {
247+
ProverEvidence {
248+
prover,
249+
verdict,
250+
time_ms: 100,
251+
confidence_self_reported: None,
252+
}
253+
}
254+
255+
#[test]
256+
fn single_z3_proven_yields_high_posterior() {
257+
let arb = BayesianArbiter::new(0.5);
258+
let post = arb.combine(&[ev(ProverKind::Z3, Verdict::Proven)]);
259+
assert!(
260+
post.p_proven > 0.9,
261+
"expected p_proven > 0.9, got {}",
262+
post.p_proven
263+
);
264+
assert_eq!(post.winning, Verdict::Proven);
265+
}
266+
267+
#[test]
268+
fn conflicting_z3_proven_and_coq_refuted_depends_on_priors() {
269+
// Coq has higher precision than Z3 in the default table —
270+
// so a tied 1-vs-1 should lean toward Refuted.
271+
let arb = BayesianArbiter::new(0.5);
272+
let post = arb.combine(&[
273+
ev(ProverKind::Z3, Verdict::Proven),
274+
ev(ProverKind::Coq, Verdict::Refuted),
275+
]);
276+
assert!(
277+
post.p_refuted > post.p_proven,
278+
"Coq's higher precision should outweigh Z3: got p_proven={} p_refuted={}",
279+
post.p_proven,
280+
post.p_refuted
281+
);
282+
283+
// With a heavily Proven-skewed prior, Z3's Proven can win.
284+
let arb_skewed = BayesianArbiter::new(0.99);
285+
let post_skewed = arb_skewed.combine(&[
286+
ev(ProverKind::Z3, Verdict::Proven),
287+
ev(ProverKind::Coq, Verdict::Refuted),
288+
]);
289+
assert!(
290+
post_skewed.p_proven > post.p_proven,
291+
"prior skew should shift the posterior"
292+
);
293+
}
294+
295+
#[test]
296+
fn timeouts_only_leave_entropy_at_prior_entropy() {
297+
let arb = BayesianArbiter::new(0.5);
298+
let post = arb.combine(&[
299+
ev(ProverKind::Z3, Verdict::Timeout),
300+
ev(ProverKind::Coq, Verdict::Timeout),
301+
]);
302+
// With a uniform prior, the proven/refuted split should be balanced.
303+
assert!((post.p_proven - post.p_refuted).abs() < 1e-9);
304+
// And a chunk of mass should be on Unknown (since all evidence is timeout).
305+
assert!(
306+
post.p_unknown > 0.0,
307+
"expected some Unknown mass, got {}",
308+
post.p_unknown
309+
);
310+
assert!(
311+
post.entropy_bits > 0.5,
312+
"entropy should remain near maximal, got {}",
313+
post.entropy_bits
314+
);
315+
}
316+
317+
#[test]
318+
fn empty_evidence_returns_prior() {
319+
let arb = BayesianArbiter::new(0.7);
320+
let post = arb.combine(&[]);
321+
// p_unknown=0 (no timeouts), so p_proven ≈ prior, p_refuted ≈ 1-prior.
322+
assert!((post.p_proven - 0.7).abs() < 1e-6);
323+
assert!((post.p_refuted - 0.3).abs() < 1e-6);
324+
}
325+
326+
#[test]
327+
fn unknown_prover_uses_default_likelihood() {
328+
// Storm isn't in the default table; should fall back to 0.85/0.85.
329+
let arb = BayesianArbiter::new(0.5);
330+
let post = arb.combine(&[ev(ProverKind::Storm, Verdict::Proven)]);
331+
assert!(post.p_proven > 0.5);
332+
assert!(post.p_proven < 0.9); // weaker than a Z3 Proven
333+
}
334+
}

0 commit comments

Comments
 (0)