1use 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#[derive(Debug)]
43pub struct ConsistencyGate {
44 id: String,
45 provider: Arc<dyn Provider>,
46 sample_model: String,
48 auth: Auth,
49 k: u32,
50 threshold: f64,
51 prices: PriceTable,
53}
54
55impl ConsistencyGate {
56 #[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 let mut sample_req = req.clone();
91 sample_req.model = self.sample_model.clone();
92
93 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 if let Ok(Ok(sample)) = res {
107 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 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 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
160fn normalize(text: &str) -> String {
162 text.trim()
163 .to_lowercase()
164 .split_whitespace()
165 .collect::<Vec<_>>()
166 .join(" ")
167}
168
169fn 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(¤t_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
198fn 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 } 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 #[tokio::test]
268 async fn agreeing_samples_pass_at_threshold_0_6() {
269 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 #[tokio::test]
294 async fn disagreeing_samples_fail_at_threshold_0_6() {
295 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 #[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 #[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 #[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 assert_eq!(extract_final_answer("result: 100 units"), "100");
383 assert_eq!(extract_final_answer("no numbers here"), "no numbers here");
385 assert_eq!(extract_final_answer(""), "");
387 }
388
389 #[test]
390 fn jaccard_similarity_boundary_values() {
391 assert!((jaccard("a b c", "a b c") - 1.0).abs() < 1e-9);
393 assert!((jaccard("a b", "c d") - 0.0).abs() < 1e-9);
395 let j = jaccard("a b c", "b c d");
397 assert!((j - 0.5).abs() < 1e-9, "expected 0.5, got {j}");
398 assert!((jaccard("", "") - 1.0).abs() < 1e-9);
400 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 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 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}