Skip to main content

firstpass_proxy/
consistency.rs

1//! Self-consistency uncertainty gate: measure a model's own confidence by resampling.
2//!
3//! **Research basis**
4//! - Wang et al. 2022 ("Self-Consistency Improves Chain of Thought Reasoning in Language
5//!   Models"): sampling multiple reasoning paths and taking the most-consistent answer
6//!   significantly improves accuracy over greedy decoding. Answers a model reliably reproduces
7//!   are far likelier correct.
8//! - Farquhar et al., *Nature* 2024 ("Detecting Hallucinations in Large Language Models Using
9//!   Semantic Entropy"): disagreement across samples — semantic entropy — flags hallucination.
10//!   A continuous agreement score lets the conformal threshold machinery calibrate the serve
11//!   cutoff against a target failure rate.
12//!
13//! **Mechanism**: call the same model `k` times on the original request (concurrently — serial
14//! would multiply added latency by k), then score the candidate's agreement with each sample.
15//! Per-sample agreement: (a) exact final-answer match (1.0 if the last number in the text, or
16//! the whole normalized text, matches) or (b) token-set Jaccard similarity. Gate score is the
17//! mean over all usable samples, clamped `[0, 1]`. Verdict = Pass iff score ≥ threshold.
18//!
19//! **maker == checker is ALLOWED** — unlike [`crate::judge::JudgeGate`], self-consistency is
20//! definitionally self-referential: the model samples *itself*. This is not a bug; it is the
21//! mechanism. The gate enforces no maker ≠ checker constraint.
22//!
23//! **Honest scope**
24//! - ponytail: lexical agreement (final-number extraction + token-set Jaccard) is a cheap proxy
25//!   for semantic clustering. Strongest on short, factual, or structured outputs (numbers,
26//!   labels, code identifiers). Upgrade to entailment-based semantic clustering via a judge
27//!   model for long-form or heavily paraphrastic outputs.
28//! - Cost: k extra model calls per request — that is the honest price of measured confidence.
29//!   Pair with a cheap model (e.g. haiku) on the first rung so the sampling cost stays low;
30//!   expensive frontier models only bear escalation cost when cheaper rungs fail.
31
32use std::sync::Arc;
33use std::time::Instant;
34
35use async_trait::async_trait;
36use firstpass_core::{GateResult, Score, Verdict, cost::PriceTable};
37
38use crate::gate::Gate;
39use crate::provider::{Auth, ModelRequest, ModelResponse, Provider};
40
41/// A gate that scores the candidate by agreement with `k` fresh samples of the same model.
42#[derive(Debug)]
43pub struct ConsistencyGate {
44    id: String,
45    provider: Arc<dyn Provider>,
46    /// `provider/model` used for resampling. May equal the candidate's model — expected.
47    sample_model: String,
48    auth: Auth,
49    k: u32,
50    threshold: f64,
51    /// Prices the k sample calls so their cost lands on the receipt ([`GateResult::cost_usd`]).
52    prices: PriceTable,
53}
54
55impl ConsistencyGate {
56    /// Build a consistency gate. `provider` serves `sample_model`; `auth` carries credentials;
57    /// `prices` prices the k sample calls onto the receipt.
58    #[must_use]
59    pub fn new(
60        id: impl Into<String>,
61        provider: Arc<dyn Provider>,
62        sample_model: impl Into<String>,
63        auth: Auth,
64        k: u32,
65        threshold: f64,
66        prices: PriceTable,
67    ) -> Self {
68        Self {
69            id: id.into(),
70            provider,
71            sample_model: sample_model.into(),
72            auth,
73            k,
74            threshold,
75            prices,
76        }
77    }
78}
79
80#[async_trait]
81impl Gate for ConsistencyGate {
82    fn id(&self) -> &str {
83        &self.id
84    }
85
86    async fn evaluate(&self, req: &ModelRequest, resp: &ModelResponse) -> GateResult {
87        let start = Instant::now();
88
89        // Build the sample request: same conversation, model swapped to the configured sampler.
90        let mut sample_req = req.clone();
91        sample_req.model = self.sample_model.clone();
92
93        // Fire k sample calls concurrently — serial would multiply added latency by k.
94        let mut set = tokio::task::JoinSet::new();
95        for _ in 0..self.k {
96            let provider = Arc::clone(&self.provider);
97            let req_clone = sample_req.clone();
98            let auth_clone = self.auth.clone();
99            set.spawn(async move { provider.complete(&req_clone, &auth_clone).await });
100        }
101
102        let mut samples: Vec<String> = Vec::new();
103        let mut sample_cost = 0.0_f64;
104        while let Some(res) = set.join_next().await {
105            // Provider error or task panic → drop this sample, score over the rest.
106            if let Ok(Ok(sample)) = res {
107                // Each sample is a real model call — price it onto the receipt so the router's
108                // budget/savings math sees the true cost of measured confidence.
109                sample_cost += self
110                    .prices
111                    .cost_usd(&self.sample_model, sample.in_tokens, sample.out_tokens)
112                    .unwrap_or(0.0);
113                samples.push(sample.text);
114            }
115        }
116
117        let ms = elapsed_ms(start);
118
119        // Zero usable samples → abstain; never fabricate a score.
120        if samples.is_empty() {
121            let mut r = GateResult::abstain(&self.id, "no_usable_samples", ms);
122            r.evidence_ref = Some(format!("all {} sample calls failed", self.k));
123            r.cost_usd = sample_cost;
124            return r;
125        }
126
127        let candidate_norm = normalize(&resp.text);
128        let candidate_answer = extract_final_answer(&candidate_norm);
129
130        let total: f64 = samples
131            .iter()
132            .map(|s| {
133                let snorm = normalize(s);
134                let sanswer = extract_final_answer(&snorm);
135                if candidate_answer == sanswer {
136                    1.0_f64
137                } else {
138                    // ponytail: token-set Jaccard over full normalized text. Upgrade to
139                    // entailment-based clustering via a judge model for semantic agreement on
140                    // long-form or paraphrastic outputs.
141                    jaccard(&candidate_norm, &snorm)
142                }
143            })
144            .sum();
145
146        let score_val = (total / samples.len() as f64).clamp(0.0, 1.0);
147        let verdict = if score_val >= self.threshold {
148            Verdict::Pass
149        } else {
150            Verdict::Fail
151        };
152
153        let mut r = GateResult::deterministic(&self.id, verdict, ms);
154        r.score = Score::new(score_val).ok();
155        r.cost_usd = sample_cost;
156        r
157    }
158}
159
160/// Normalize text for comparison: trim, lowercase, collapse internal whitespace to single spaces.
161fn normalize(text: &str) -> String {
162    text.trim()
163        .to_lowercase()
164        .split_whitespace()
165        .collect::<Vec<_>>()
166        .join(" ")
167}
168
169/// Extract the final answer from normalized text: the last contiguous ASCII-digit sequence,
170/// or the whole string when no digits are present.
171///
172/// This is the cheap "what number did the model settle on?" proxy from self-consistency
173/// literature. Strongest on single-number answers (arithmetic, retrieval).
174///
175// ponytail: digit-only extraction. Upgrade to full expression parsing if answers include
176// decimals, fractions, or labelled fields (e.g. "answer: 42.5").
177fn extract_final_answer(normalized: &str) -> String {
178    let mut last_num = String::new();
179    let mut current_num = String::new();
180    for c in normalized.chars() {
181        if c.is_ascii_digit() {
182            current_num.push(c);
183        } else if !current_num.is_empty() {
184            last_num.clone_from(&current_num);
185            current_num.clear();
186        }
187    }
188    if !current_num.is_empty() {
189        last_num = current_num;
190    }
191    if last_num.is_empty() {
192        normalized.to_owned()
193    } else {
194        last_num
195    }
196}
197
198/// Token-set Jaccard similarity: `|A ∩ B| / |A ∪ B|`, tokens split on whitespace.
199/// Two empty strings → 1.0 (identical); one empty vs one non-empty → 0.0.
200fn jaccard(a: &str, b: &str) -> f64 {
201    let tokens_a: std::collections::HashSet<&str> = a.split_whitespace().collect();
202    let tokens_b: std::collections::HashSet<&str> = b.split_whitespace().collect();
203    let inter = tokens_a.intersection(&tokens_b).count();
204    let union = tokens_a.len() + tokens_b.len() - inter;
205    if union == 0 {
206        1.0 // both empty → identical
207    } else {
208        inter as f64 / union as f64
209    }
210}
211
212fn elapsed_ms(start: Instant) -> u64 {
213    u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use crate::provider::{ChatMessage, MockProvider, ProviderError};
220    use serde_json::Value;
221    use std::collections::HashMap;
222    use std::sync::Arc;
223
224    fn make_req() -> ModelRequest {
225        ModelRequest {
226            model: "anthropic/claude-haiku-4-5".to_owned(),
227            system: None,
228            messages: vec![ChatMessage::text("user", "What is 6 * 7?")],
229            max_tokens: 64,
230            tools: Value::Null,
231            raw: Value::Null,
232        }
233    }
234
235    fn make_resp(model: &str, text: &str) -> ModelResponse {
236        ModelResponse {
237            model: model.to_owned(),
238            text: text.to_owned(),
239            in_tokens: 10,
240            out_tokens: 10,
241            raw: Value::Null,
242        }
243    }
244
245    fn provider_returning(sample_model: &str, text: &str) -> Arc<dyn Provider> {
246        let mut outcomes = HashMap::new();
247        outcomes.insert(sample_model.to_owned(), Ok(make_resp(sample_model, text)));
248        Arc::new(MockProvider::new(
249            sample_model.split('/').next().unwrap_or("mock"),
250            outcomes,
251        ))
252    }
253
254    fn provider_erroring(sample_model: &str) -> Arc<dyn Provider> {
255        let mut outcomes = HashMap::new();
256        outcomes.insert(
257            sample_model.to_owned(),
258            Err(ProviderError::Transport("down".to_owned())),
259        );
260        Arc::new(MockProvider::new(
261            sample_model.split('/').next().unwrap_or("mock"),
262            outcomes,
263        ))
264    }
265
266    /// Agreeing samples (same final answer "42", phrasing differs) → high score, Pass at 0.6.
267    #[tokio::test]
268    async fn agreeing_samples_pass_at_threshold_0_6() {
269        // k=3 samples all return "The answer is 42" while candidate says "Therefore the answer
270        // is 42." — final answers agree (both extract to "42") → score 1.0 → Pass.
271        let gate = ConsistencyGate::new(
272            "uncertainty",
273            provider_returning("anthropic/sampler", "The answer is 42"),
274            "anthropic/sampler",
275            Auth::default(),
276            3,
277            0.6,
278            PriceTable::default(),
279        );
280        let candidate = make_resp("anthropic/claude-haiku-4-5", "Therefore the answer is 42.");
281        let result = gate.evaluate(&make_req(), &candidate).await;
282        assert_eq!(result.verdict, Verdict::Pass, "final-answer match → Pass");
283        let score = result
284            .score
285            .expect("score must be present on a non-abstain verdict");
286        assert!(
287            score.value() >= 0.9,
288            "all samples agree → score near 1.0; got {score}"
289        );
290    }
291
292    /// Disagreeing samples (different numbers) → low score, Fail at threshold 0.6.
293    #[tokio::test]
294    async fn disagreeing_samples_fail_at_threshold_0_6() {
295        // k=3 samples return bare "7" while candidate says "The answer is 42".
296        // Different final answers ("7" vs "42"); Jaccard("the answer is 42", "7") = 0/5 = 0.0,
297        // well below threshold 0.6 → Fail.
298        let gate = ConsistencyGate::new(
299            "uncertainty",
300            provider_returning("anthropic/sampler", "7"),
301            "anthropic/sampler",
302            Auth::default(),
303            3,
304            0.6,
305            PriceTable::default(),
306        );
307        let candidate = make_resp("anthropic/claude-haiku-4-5", "The answer is 42");
308        let result = gate.evaluate(&make_req(), &candidate).await;
309        assert_eq!(
310            result.verdict,
311            Verdict::Fail,
312            "different final answers → Fail"
313        );
314    }
315
316    /// Exactly k calls are fired — confirming the concurrent fan-out.
317    #[tokio::test]
318    async fn exactly_k_sample_calls_are_fired() {
319        let k: u32 = 3;
320        let mut outcomes = HashMap::new();
321        outcomes.insert(
322            "anthropic/sampler".to_owned(),
323            Ok(make_resp("anthropic/sampler", "42")),
324        );
325        let mock = MockProvider::new("anthropic", outcomes);
326        let call_log = mock.call_log();
327        let gate = ConsistencyGate::new(
328            "uncertainty",
329            Arc::new(mock),
330            "anthropic/sampler",
331            Auth::default(),
332            k,
333            0.5,
334            PriceTable::default(),
335        );
336        gate.evaluate(&make_req(), &make_resp("anthropic/claude-haiku-4-5", "42"))
337            .await;
338        let log = call_log.lock().unwrap();
339        assert_eq!(log.len(), k as usize, "exactly k calls must be fired");
340        assert!(
341            log.iter().all(|m| m == "anthropic/sampler"),
342            "all calls target the configured sample model"
343        );
344    }
345
346    /// All samples error → Abstain with reason "no_usable_samples".
347    #[tokio::test]
348    async fn all_samples_error_abstains() {
349        let gate = ConsistencyGate::new(
350            "uncertainty",
351            provider_erroring("anthropic/sampler"),
352            "anthropic/sampler",
353            Auth::default(),
354            3,
355            0.6,
356            PriceTable::default(),
357        );
358        let candidate = make_resp("anthropic/claude-haiku-4-5", "42");
359        let result = gate.evaluate(&make_req(), &candidate).await;
360        assert_eq!(result.verdict, Verdict::Abstain);
361        assert_eq!(
362            result.reason.as_deref(),
363            Some("no_usable_samples"),
364            "all-error abstain carries the expected reason"
365        );
366    }
367
368    // ---- unit tests for the scoring helpers ----
369
370    #[test]
371    fn normalize_trims_lowercases_and_collapses_whitespace() {
372        assert_eq!(normalize("  Hello   World  "), "hello world");
373        assert_eq!(normalize("THE ANSWER IS 42"), "the answer is 42");
374        assert_eq!(normalize(""), "");
375    }
376
377    #[test]
378    fn extract_final_answer_returns_last_digit_sequence() {
379        assert_eq!(extract_final_answer("the answer is 42"), "42");
380        assert_eq!(extract_final_answer("3 times 7 equals 21"), "21");
381        // Trailing non-digit — still gets the last number.
382        assert_eq!(extract_final_answer("result: 100 units"), "100");
383        // No digits → whole string.
384        assert_eq!(extract_final_answer("no numbers here"), "no numbers here");
385        // Empty string.
386        assert_eq!(extract_final_answer(""), "");
387    }
388
389    #[test]
390    fn jaccard_similarity_boundary_values() {
391        // Identical tokens → 1.0.
392        assert!((jaccard("a b c", "a b c") - 1.0).abs() < 1e-9);
393        // Disjoint tokens → 0.0.
394        assert!((jaccard("a b", "c d") - 0.0).abs() < 1e-9);
395        // Half overlap: {a,b,c} ∩ {b,c,d} = {b,c}, union = 4 → 0.5.
396        let j = jaccard("a b c", "b c d");
397        assert!((j - 0.5).abs() < 1e-9, "expected 0.5, got {j}");
398        // Both empty → identical (1.0).
399        assert!((jaccard("", "") - 1.0).abs() < 1e-9);
400        // One empty → 0.0.
401        assert!((jaccard("a b", "") - 0.0).abs() < 1e-9);
402    }
403
404    #[tokio::test]
405    async fn sample_cost_lands_on_the_receipt() {
406        use firstpass_core::cost::ModelPrice;
407        // k=3 samples at 10 in / 10 out tokens each, priced 1.0/5.0 per Mtok.
408        let prices = PriceTable::new().with_override(
409            "anthropic/sampler",
410            ModelPrice {
411                input_per_mtok: 1.0,
412                output_per_mtok: 5.0,
413            },
414        );
415        let gate = ConsistencyGate::new(
416            "uncertainty",
417            provider_returning("anthropic/sampler", "42"),
418            "anthropic/sampler",
419            Auth::default(),
420            3,
421            0.5,
422            prices,
423        );
424        let r = gate
425            .evaluate(&make_req(), &make_resp("anthropic/sampler", "42"))
426            .await;
427        // 3 samples x (10/1e6*1.0 + 10/1e6*5.0) = 3 x 6e-5 = 1.8e-4.
428        let expected = 3.0 * (10.0 / 1e6 * 1.0 + 10.0 / 1e6 * 5.0);
429        assert!(
430            (r.cost_usd - expected).abs() < 1e-12,
431            "k sample calls priced onto the receipt: got {}, want {expected}",
432            r.cost_usd
433        );
434    }
435}