1use crate::calibrate::gate_score;
10use crate::gate::{Gate, GateHealthRegistry, aggregate};
11use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderError, ProviderRegistry};
12use firstpass_core::verdict::reason;
13use firstpass_core::{
14 Attempt, Features, FinalOutcome, GENESIS_HASH, GateResult, Mode, ModelRef, PolicyRef,
15 PriceTable, RequestInfo, ServedFrom, Trace, Verdict,
16};
17use jiff::Timestamp;
18use std::collections::HashMap;
19use std::time::Instant;
20use tokio::task::JoinHandle;
21use uuid::Uuid;
22
23#[derive(Debug)]
25pub enum EngineOutcome {
26 Served(ModelResponse),
29 Failed(String),
31}
32
33#[derive(Debug)]
36pub struct EnforceCtx<'a> {
37 pub ladder: &'a [String],
39 pub gates: &'a [Box<dyn Gate>],
41 pub health: &'a GateHealthRegistry,
43 pub base_request: &'a ModelRequest,
45 pub providers: &'a ProviderRegistry,
47 pub auth: &'a Auth,
49 pub prices: &'a PriceTable,
51 pub budget_per_request_usd: Option<f64>,
53 pub max_rungs: u32,
55 pub speculation: u32,
58 pub serve_threshold: Option<f64>,
62 pub features: Features,
64 pub tenant_id: String,
66 pub session_id: String,
68 pub prompt_hash: String,
70 pub api: String,
72 pub policy_id: String,
74}
75
76pub async fn route_enforce(ctx: EnforceCtx<'_>) -> (EngineOutcome, Trace) {
81 let LadderRun {
84 attempts,
85 spent,
86 gate_cost_total,
87 best,
88 mut served_rung,
89 hard_error,
90 } = if ctx.speculation == 0 {
91 run_serial(&ctx).await
92 } else {
93 run_speculative(&ctx).await
94 };
95
96 let (outcome, served_from, served_tokens) = match (served_rung, &best) {
98 (Some(_), Some((_, resp))) => (
99 EngineOutcome::Served(resp.clone()),
100 ServedFrom::Attempt,
101 (resp.in_tokens, resp.out_tokens),
102 ),
103 (None, Some((idx, resp))) => {
104 served_rung = Some(*idx);
106 (
107 EngineOutcome::Served(resp.clone()),
108 ServedFrom::BestAttempt,
109 (resp.in_tokens, resp.out_tokens),
110 )
111 }
112 (_, None) => {
113 let msg = hard_error.unwrap_or_else(|| "all rungs failed".to_owned());
114 (EngineOutcome::Failed(msg), ServedFrom::Error, (0, 0))
115 }
116 };
117
118 let top_model = ctx.ladder.last().map(String::as_str).unwrap_or_default();
120 let baseline = ctx
121 .prices
122 .cost_usd(top_model, served_tokens.0, served_tokens.1)
123 .unwrap_or(spent);
124
125 let total_latency_ms = attempts.iter().map(|a| a.latency_ms).sum();
126 let escalations = attempts.len().saturating_sub(1) as u32;
127
128 let mut trace = Trace {
129 trace_id: Uuid::now_v7(),
130 prev_hash: GENESIS_HASH.to_owned(),
131 tenant_id: ctx.tenant_id,
132 session_id: ctx.session_id,
133 ts: Timestamp::now(),
134 mode: Mode::Enforce,
135 policy: PolicyRef {
136 id: ctx.policy_id,
137 explore: false,
138 },
139 request: RequestInfo {
140 api: ctx.api,
141 prompt_hash: ctx.prompt_hash,
142 features: ctx.features,
143 },
144 attempts,
145 deferred: vec![],
146 final_: FinalOutcome {
147 served_rung,
148 served_from,
149 total_cost_usd: spent,
150 gate_cost_usd: gate_cost_total,
151 total_latency_ms,
152 escalations,
153 counterfactual_baseline_usd: baseline,
154 savings_usd: 0.0,
155 },
156 };
157 trace.recompute_savings();
158 (outcome, trace)
159}
160
161struct LadderRun {
165 attempts: Vec<Attempt>,
166 spent: f64,
167 gate_cost_total: f64,
168 best: Option<(u32, ModelResponse)>,
169 served_rung: Option<u32>,
170 hard_error: Option<String>,
171}
172
173fn should_serve(
183 serve_threshold: Option<f64>,
184 gate_results: &[GateResult],
185 verdict: Verdict,
186) -> bool {
187 match serve_threshold {
188 None => verdict == Verdict::Pass,
189 Some(t) => gate_score(gate_results, verdict) >= t,
190 }
191}
192
193async fn run_serial(ctx: &EnforceCtx<'_>) -> LadderRun {
196 let mut attempts: Vec<Attempt> = Vec::new();
197 let mut spent = 0.0_f64;
198 let mut gate_cost_total = 0.0_f64;
199 let mut best: Option<(u32, ModelResponse)> = None;
200 let mut served_rung: Option<u32> = None;
201 let mut hard_error: Option<String> = None;
202
203 let rung_limit = (ctx.max_rungs as usize).min(ctx.ladder.len());
204 for (idx, model_str) in ctx.ladder.iter().take(rung_limit).enumerate() {
205 let idx = idx as u32;
206 let start = Instant::now();
207
208 let provider = match ModelRef::parse(model_str) {
211 Ok(m) => ctx.providers.get(&m.provider),
212 Err(_) => None,
213 };
214 let Some(provider) = provider else {
215 let ms = elapsed_ms(start);
216 attempts.push(abstain_attempt(
217 idx,
218 model_str,
219 "unknown",
220 reason::PROVIDER_ERROR,
221 ms,
222 ));
223 continue;
224 };
225
226 let mut req = ctx.base_request.clone();
227 req.model = model_str.clone();
228
229 match provider.complete(&req, ctx.auth).await {
230 Err(err) if err.is_failover_eligible() => {
231 let ms = elapsed_ms(start);
233 attempts.push(abstain_attempt(
234 idx,
235 model_str,
236 provider.id(),
237 reason::PROVIDER_ERROR,
238 ms,
239 ));
240 continue;
241 }
242 Err(err) => {
243 let ms = elapsed_ms(start);
245 let (r, msg) = hard_reason(&err);
246 attempts.push(abstain_attempt(idx, model_str, provider.id(), r, ms));
247 hard_error = Some(msg);
248 break;
249 }
250 Ok(resp) => {
251 let ms = elapsed_ms(start);
252 let model_cost = ctx
253 .prices
254 .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
255 .unwrap_or(0.0);
256 spent += model_cost;
257
258 let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
261 for g in ctx.gates {
262 if !ctx.health.enabled(g.id()) {
263 tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
264 continue;
265 }
266 let r = g.evaluate(&req, &resp).await;
267 ctx.health.record(g.id(), r.verdict == Verdict::Abstain);
268 gate_results.push(r);
269 }
270 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
271 gate_cost_total += gc;
272 spent += gc;
273
274 let verdict = aggregate(&gate_results);
275 let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
276 attempts.push(Attempt {
277 rung: idx,
278 model: model_str.clone(),
279 provider: provider.id().to_owned(),
280 in_tokens: resp.in_tokens,
281 out_tokens: resp.out_tokens,
282 cost_usd: model_cost,
283 latency_ms: ms,
284 gates: gate_results,
285 verdict,
286 });
287 best = Some((idx, resp));
288
289 if serve {
290 served_rung = Some(idx);
291 break;
292 }
293 if let Some(cap) = ctx.budget_per_request_usd
295 && spent >= cap
296 && (idx as usize) + 1 < rung_limit
297 {
298 break;
299 }
300 }
301 }
302 }
303
304 LadderRun {
305 attempts,
306 spent,
307 gate_cost_total,
308 best,
309 served_rung,
310 hard_error,
311 }
312}
313
314async fn run_speculative(ctx: &EnforceCtx<'_>) -> LadderRun {
319 let mut attempts: Vec<Attempt> = Vec::new();
320 let mut spent = 0.0_f64;
321 let mut gate_cost_total = 0.0_f64;
322 let mut best: Option<(u32, ModelResponse)> = None;
323 let mut served_rung: Option<u32> = None;
324 let mut hard_error: Option<String> = None;
325
326 let rung_limit = (ctx.max_rungs as usize).min(ctx.ladder.len());
327 let speculation = ctx.speculation as usize;
328 let mut inflight: HashMap<usize, JoinHandle<Result<ModelResponse, ProviderError>>> =
329 HashMap::new();
330
331 let mut idx = 0usize;
332 let mut done = false;
334 while idx < rung_limit && !done {
335 let window_end = (idx + speculation).min(rung_limit - 1);
338 for j in idx..=window_end {
339 if inflight.contains_key(&j) {
340 continue;
341 }
342 if j > idx
343 && let Some(cap) = ctx.budget_per_request_usd
344 && spent >= cap
345 {
346 continue;
347 }
348 if let Some(handle) = spawn_rung(ctx, j) {
349 inflight.insert(j, handle);
350 }
351 }
352
353 let model_str = &ctx.ladder[idx];
354 let provider = match ModelRef::parse(model_str) {
355 Ok(m) => ctx.providers.get(&m.provider),
356 Err(_) => None,
357 };
358 let Some(provider) = provider else {
359 attempts.push(abstain_attempt(
361 idx as u32,
362 model_str,
363 "unknown",
364 reason::PROVIDER_ERROR,
365 0,
366 ));
367 idx += 1;
368 continue;
369 };
370 let Some(handle) = inflight.remove(&idx) else {
372 attempts.push(abstain_attempt(
373 idx as u32,
374 model_str,
375 provider.id(),
376 reason::PROVIDER_ERROR,
377 0,
378 ));
379 idx += 1;
380 continue;
381 };
382 let start = Instant::now();
383 let joined = handle.await;
384 let ms = elapsed_ms(start);
385
386 match joined {
387 Err(_) => {
389 attempts.push(abstain_attempt(
390 idx as u32,
391 model_str,
392 provider.id(),
393 reason::PROVIDER_ERROR,
394 ms,
395 ));
396 idx += 1;
397 }
398 Ok(Err(err)) if err.is_failover_eligible() => {
399 attempts.push(abstain_attempt(
400 idx as u32,
401 model_str,
402 provider.id(),
403 reason::PROVIDER_ERROR,
404 ms,
405 ));
406 idx += 1;
407 }
408 Ok(Err(err)) => {
409 let (r, msg) = hard_reason(&err);
411 attempts.push(abstain_attempt(idx as u32, model_str, provider.id(), r, ms));
412 hard_error = Some(msg);
413 done = true;
414 }
415 Ok(Ok(resp)) => {
416 let model_cost = ctx
417 .prices
418 .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
419 .unwrap_or(0.0);
420 spent += model_cost;
421
422 let mut req = ctx.base_request.clone();
423 req.model = model_str.clone();
424 let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
425 for g in ctx.gates {
426 if !ctx.health.enabled(g.id()) {
427 tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
428 continue;
429 }
430 let r = g.evaluate(&req, &resp).await;
431 ctx.health.record(g.id(), r.verdict == Verdict::Abstain);
432 gate_results.push(r);
433 }
434 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
435 gate_cost_total += gc;
436 spent += gc;
437
438 let verdict = aggregate(&gate_results);
439 let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
440 attempts.push(Attempt {
441 rung: idx as u32,
442 model: model_str.clone(),
443 provider: provider.id().to_owned(),
444 in_tokens: resp.in_tokens,
445 out_tokens: resp.out_tokens,
446 cost_usd: model_cost,
447 latency_ms: ms,
448 gates: gate_results,
449 verdict,
450 });
451 best = Some((idx as u32, resp));
452
453 if serve {
454 served_rung = Some(idx as u32);
455 done = true;
456 } else if let Some(cap) = ctx.budget_per_request_usd
457 && spent >= cap
458 && idx + 1 < rung_limit
459 {
460 done = true;
461 }
462 idx += 1;
463 }
464 }
465 }
466
467 for (j, handle) in inflight.drain() {
472 if handle.is_finished() {
473 if let Ok(Ok(resp)) = handle.await {
474 spent += ctx
475 .prices
476 .cost_usd(&ctx.ladder[j], resp.in_tokens, resp.out_tokens)
477 .unwrap_or(0.0);
478 }
479 } else {
480 handle.abort();
481 }
482 }
483
484 LadderRun {
485 attempts,
486 spent,
487 gate_cost_total,
488 best,
489 served_rung,
490 hard_error,
491 }
492}
493
494fn spawn_rung(
497 ctx: &EnforceCtx<'_>,
498 j: usize,
499) -> Option<JoinHandle<Result<ModelResponse, ProviderError>>> {
500 let model_str = ctx.ladder.get(j)?;
501 let provider = match ModelRef::parse(model_str) {
502 Ok(m) => ctx.providers.get(&m.provider)?,
503 Err(_) => return None,
504 };
505 let mut req = ctx.base_request.clone();
506 req.model = model_str.clone();
507 let auth = ctx.auth.clone();
508 Some(tokio::spawn(
509 async move { provider.complete(&req, &auth).await },
510 ))
511}
512
513fn elapsed_ms(start: Instant) -> u64 {
514 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
515}
516
517fn abstain_attempt(rung: u32, model: &str, provider: &str, reason: &str, ms: u64) -> Attempt {
519 Attempt {
520 rung,
521 model: model.to_owned(),
522 provider: provider.to_owned(),
523 in_tokens: 0,
524 out_tokens: 0,
525 cost_usd: 0.0,
526 latency_ms: ms,
527 gates: vec![GateResult::abstain(provider, reason, ms)],
528 verdict: Verdict::Abstain,
529 }
530}
531
532fn hard_reason(err: &ProviderError) -> (&'static str, String) {
534 match err {
535 ProviderError::Http { status, .. } => {
536 (reason::PROVIDER_ERROR, format!("upstream http {status}"))
537 }
538 ProviderError::Decode(_) => (reason::PROVIDER_ERROR, "upstream decode error".to_owned()),
539 ProviderError::Transport(_) => (
540 reason::PROVIDER_ERROR,
541 "upstream transport error".to_owned(),
542 ),
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::gate::{JsonValidGate, NonEmptyGate};
550 use crate::provider::{MockProvider, Provider};
551 use serde_json::Value;
552 use std::collections::HashMap;
553 use std::sync::Arc;
554
555 const HAIKU: &str = "anthropic/claude-haiku-4-5";
556 const SONNET: &str = "anthropic/claude-sonnet-5";
557 const OPUS: &str = "anthropic/claude-opus-4-8";
558 const GPT: &str = "openai/gpt-5.5";
559
560 fn resp(model: &str, text: &str) -> ModelResponse {
561 ModelResponse {
562 model: model.to_owned(),
563 text: text.to_owned(),
564 in_tokens: 1000,
565 out_tokens: 500,
566 raw: Value::Null,
567 }
568 }
569
570 fn base_request() -> ModelRequest {
571 ModelRequest {
572 model: String::new(),
573 system: None,
574 messages: vec![crate::provider::ChatMessage {
575 role: "user".into(),
576 content: "hi".into(),
577 }],
578 max_tokens: 256,
579 tools: Value::Null,
580 }
581 }
582
583 fn registry(
585 outcomes: Vec<(&str, &str, Result<ModelResponse, ProviderError>)>,
586 ) -> ProviderRegistry {
587 let mut by_provider: HashMap<
588 String,
589 HashMap<String, Result<ModelResponse, ProviderError>>,
590 > = HashMap::new();
591 for (provider, model, out) in outcomes {
592 by_provider
593 .entry(provider.to_owned())
594 .or_default()
595 .insert(model.to_owned(), out);
596 }
597 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
598 for (pid, outs) in by_provider {
599 map.insert(pid.clone(), Arc::new(MockProvider::new(pid, outs)));
600 }
601 ProviderRegistry::from_map(map)
602 }
603
604 #[allow(clippy::too_many_arguments)]
605 fn ctx<'a>(
606 ladder: &'a [String],
607 gates: &'a [Box<dyn Gate>],
608 req: &'a ModelRequest,
609 providers: &'a ProviderRegistry,
610 auth: &'a Auth,
611 prices: &'a PriceTable,
612 budget: Option<f64>,
613 health: &'a GateHealthRegistry,
614 ) -> EnforceCtx<'a> {
615 EnforceCtx {
616 ladder,
617 gates,
618 health,
619 base_request: req,
620 providers,
621 auth,
622 prices,
623 budget_per_request_usd: budget,
624 max_rungs: 3,
625 speculation: 0,
626 serve_threshold: None,
627 features: Features::new(firstpass_core::TaskKind::CodeEdit),
628 tenant_id: "acme".into(),
629 session_id: "sess-1".into(),
630 prompt_hash: "deadbeef".into(),
631 api: "anthropic.messages".into(),
632 policy_id: "static-ladder@v0".into(),
633 }
634 }
635
636 #[tokio::test]
637 async fn serve_first_pass_no_escalation_with_savings() {
638 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
639 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
640 let req = base_request();
641 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, r#"{"ok":1}"#)))]);
642 let (auth, prices) = (Auth::default(), PriceTable::defaults());
643 let health = GateHealthRegistry::new();
644 let (out, trace) = route_enforce(ctx(
645 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
646 ))
647 .await;
648
649 match out {
650 EngineOutcome::Served(r) => assert_eq!(r.model, HAIKU),
651 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
652 }
653 assert_eq!(trace.attempts.len(), 1);
654 assert_eq!(trace.final_.escalations, 0);
655 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
656 assert_eq!(trace.final_.served_rung, Some(0));
657 assert!(
658 trace.final_.savings_usd > 0.0,
659 "top-rung baseline should exceed haiku cost"
660 );
661 }
662
663 #[tokio::test]
664 async fn escalate_on_gate_fail() {
665 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
666 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
667 let req = base_request();
668 let providers = registry(vec![
670 ("anthropic", HAIKU, Ok(resp(HAIKU, " "))),
671 ("anthropic", SONNET, Ok(resp(SONNET, "real answer"))),
672 ]);
673 let (auth, prices) = (Auth::default(), PriceTable::defaults());
674 let health = GateHealthRegistry::new();
675 let (out, trace) = route_enforce(ctx(
676 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
677 ))
678 .await;
679
680 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
681 assert_eq!(trace.attempts.len(), 2);
682 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
683 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
684 assert_eq!(trace.final_.escalations, 1);
685 assert_eq!(trace.final_.served_rung, Some(1));
686 }
687
688 #[tokio::test]
689 async fn cross_provider_failover_on_transport_error() {
690 let ladder = vec![HAIKU.to_owned(), GPT.to_owned()];
692 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
693 let req = base_request();
694 let providers = registry(vec![
695 (
696 "anthropic",
697 HAIKU,
698 Err(ProviderError::Transport("connection refused".into())),
699 ),
700 ("openai", GPT, Ok(resp(GPT, "answer from openai"))),
701 ]);
702 let (auth, prices) = (Auth::default(), PriceTable::defaults());
703 let health = GateHealthRegistry::new();
704 let (out, trace) = route_enforce(ctx(
705 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
706 ))
707 .await;
708
709 assert!(matches!(out, EngineOutcome::Served(r) if r.model == GPT));
710 assert_eq!(trace.attempts[0].verdict, Verdict::Abstain);
711 assert_eq!(
712 trace.attempts[0].gates[0].reason.as_deref(),
713 Some(reason::PROVIDER_ERROR)
714 );
715 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
716 assert_eq!(trace.final_.served_rung, Some(1));
717 }
718
719 #[tokio::test]
720 async fn budget_cap_stops_escalation() {
721 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
722 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
723 let req = base_request();
724 let providers = registry(vec![
726 ("anthropic", HAIKU, Ok(resp(HAIKU, ""))),
727 ("anthropic", SONNET, Ok(resp(SONNET, ""))),
728 ("anthropic", OPUS, Ok(resp(OPUS, ""))),
729 ]);
730 let (auth, prices) = (Auth::default(), PriceTable::defaults());
731 let health = GateHealthRegistry::new();
732 let (_out, trace) = route_enforce(ctx(
733 &ladder,
734 &gates,
735 &req,
736 &providers,
737 &auth,
738 &prices,
739 Some(0.0),
740 &health,
741 ))
742 .await;
743 assert!(
744 trace.attempts.len() < 3,
745 "budget should cut escalation short, got {}",
746 trace.attempts.len()
747 );
748 }
749
750 #[tokio::test]
751 async fn all_fail_serves_best_attempt() {
752 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
753 let gates: Vec<Box<dyn Gate>> = vec![Box::new(JsonValidGate)]; let req = base_request();
755 let providers = registry(vec![
756 ("anthropic", HAIKU, Ok(resp(HAIKU, "not json"))),
757 ("anthropic", SONNET, Ok(resp(SONNET, "still not json"))),
758 ]);
759 let (auth, prices) = (Auth::default(), PriceTable::defaults());
760 let health = GateHealthRegistry::new();
761 let (out, trace) = route_enforce(ctx(
762 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
763 ))
764 .await;
765
766 assert!(
767 matches!(out, EngineOutcome::Served(r) if r.model == SONNET),
768 "serves highest attempt"
769 );
770 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
771 assert_eq!(trace.final_.served_rung, Some(1));
772 }
773
774 #[tokio::test]
775 async fn hard_4xx_does_not_escalate() {
776 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
777 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
778 let req = base_request();
779 let providers = registry(vec![
780 (
781 "anthropic",
782 HAIKU,
783 Err(ProviderError::Http {
784 status: 400,
785 body: "bad request".into(),
786 }),
787 ),
788 ("anthropic", SONNET, Ok(resp(SONNET, "would have worked"))),
789 ]);
790 let (auth, prices) = (Auth::default(), PriceTable::defaults());
791 let health = GateHealthRegistry::new();
792 let (out, trace) = route_enforce(ctx(
793 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
794 ))
795 .await;
796
797 assert!(
798 matches!(out, EngineOutcome::Failed(_)),
799 "4xx is a hard error, not failover"
800 );
801 assert_eq!(
802 trace.attempts.len(),
803 1,
804 "must not escalate past a client error"
805 );
806 assert_eq!(trace.final_.served_from, ServedFrom::Error);
807 }
808
809 #[tokio::test]
810 async fn counterfactual_and_savings_math() {
811 let ladder = vec![HAIKU.to_owned(), OPUS.to_owned()];
812 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
813 let req = base_request();
814 let served = resp(HAIKU, "answer");
815 let (in_t, out_t) = (served.in_tokens, served.out_tokens);
816 let providers = registry(vec![("anthropic", HAIKU, Ok(served))]);
817 let (auth, prices) = (Auth::default(), PriceTable::defaults());
818 let health = GateHealthRegistry::new();
819 let (_out, trace) = route_enforce(ctx(
820 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
821 ))
822 .await;
823
824 let expected_baseline = prices.cost_usd(OPUS, in_t, out_t).unwrap();
825 assert!((trace.final_.counterfactual_baseline_usd - expected_baseline).abs() < 1e-12);
826 let expected_savings = expected_baseline - trace.final_.total_cost_usd;
827 assert!((trace.final_.savings_usd - expected_savings).abs() < 1e-12);
828 assert!(trace.final_.savings_usd > 0.0);
829 }
830
831 #[tokio::test]
832 async fn produced_trace_is_chain_verifiable() {
833 let ladder = vec![HAIKU.to_owned()];
834 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
835 let req = base_request();
836 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "ok")))]);
837 let (auth, prices) = (Auth::default(), PriceTable::defaults());
838 let health = GateHealthRegistry::new();
839 let (_out, trace) = route_enforce(ctx(
840 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
841 ))
842 .await;
843
844 assert!(firstpass_core::verify_chain(std::slice::from_ref(&trace), GENESIS_HASH).is_ok());
846 let json = serde_json::to_string(&trace).unwrap();
848 let _back: Trace = serde_json::from_str(&json).unwrap();
849 }
850
851 #[tokio::test]
852 async fn auto_disabled_gate_is_skipped_by_the_engine() {
853 let ladder = vec![HAIKU.to_owned()];
856 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
857 let req = base_request();
858 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "")))]); let (auth, prices) = (Auth::default(), PriceTable::defaults());
860
861 let health = GateHealthRegistry::new().with_budget("non-empty", 4, 0.5);
863 for _ in 0..4 {
864 health.record("non-empty", true);
865 }
866 assert!(
867 !health.enabled("non-empty"),
868 "precondition: gate is auto-disabled"
869 );
870
871 let (out, trace) = route_enforce(ctx(
872 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
873 ))
874 .await;
875 assert!(matches!(out, EngineOutcome::Served(_)));
876 assert_eq!(trace.final_.served_rung, Some(0));
877 assert!(
878 trace.attempts[0].gates.is_empty(),
879 "disabled gate must be skipped, not run"
880 );
881 }
882
883 fn counted_registry(
886 outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
887 ) -> (
888 ProviderRegistry,
889 std::sync::Arc<std::sync::Mutex<Vec<String>>>,
890 ) {
891 let mut outs = HashMap::new();
892 for (model, out) in outcomes {
893 outs.insert(model.to_owned(), out);
894 }
895 let mock = MockProvider::new("anthropic", outs);
896 let log = mock.call_log();
897 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
898 map.insert("anthropic".to_owned(), Arc::new(mock));
899 (ProviderRegistry::from_map(map), log)
900 }
901
902 #[tokio::test]
903 async fn speculation_prefetches_next_rung_but_serves_identically() {
904 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
905 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
906 let req = base_request();
907 let (auth, prices) = (Auth::default(), PriceTable::defaults());
908
909 let (providers, log) = counted_registry(vec![
911 (HAIKU, Ok(resp(HAIKU, "answer"))),
912 (SONNET, Ok(resp(SONNET, "other"))),
913 ]);
914 let health = GateHealthRegistry::new();
915 let (serial_out, serial_trace) = route_enforce(ctx(
916 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
917 ))
918 .await;
919 assert_eq!(
920 *log.lock().unwrap(),
921 vec![HAIKU.to_owned()],
922 "serial must not touch rung 1 when rung 0 passes"
923 );
924
925 let (providers, log) = counted_registry(vec![
927 (HAIKU, Ok(resp(HAIKU, "answer"))),
928 (SONNET, Ok(resp(SONNET, "other"))),
929 ]);
930 let health = GateHealthRegistry::new();
931 let mut c = ctx(
932 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
933 );
934 c.speculation = 1;
935 let (spec_out, spec_trace) = route_enforce(c).await;
936
937 assert!(
938 log.lock().unwrap().contains(&SONNET.to_owned()),
939 "speculation must fire rung 1 ahead: {:?}",
940 *log.lock().unwrap()
941 );
942
943 let (a, b) = match (serial_out, spec_out) {
945 (EngineOutcome::Served(a), EngineOutcome::Served(b)) => (a, b),
946 _ => panic!("both variants must serve"),
947 };
948 assert_eq!(
949 (a.model, a.text, a.out_tokens),
950 (b.model, b.text, b.out_tokens)
951 );
952 assert_eq!(spec_trace.final_.served_rung, Some(0));
953 assert_eq!(spec_trace.attempts.len(), 1, "only rung 0 is gated");
954 assert!(
956 spec_trace.final_.total_cost_usd > serial_trace.final_.total_cost_usd,
957 "speculative waste must show in total cost: spec={} serial={}",
958 spec_trace.final_.total_cost_usd,
959 serial_trace.final_.total_cost_usd
960 );
961 }
962
963 #[tokio::test]
964 async fn speculation_preserves_escalation_result() {
965 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
966 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
967 let req = base_request();
968 let (auth, prices) = (Auth::default(), PriceTable::defaults());
969 let (providers, _log) = counted_registry(vec![
971 (HAIKU, Ok(resp(HAIKU, ""))),
972 (SONNET, Ok(resp(SONNET, "real answer"))),
973 ]);
974 let health = GateHealthRegistry::new();
975 let mut c = ctx(
976 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
977 );
978 c.speculation = 2; let (out, trace) = route_enforce(c).await;
980 match out {
981 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
982 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
983 }
984 assert_eq!(trace.final_.served_rung, Some(1));
985 assert_eq!(trace.attempts.len(), 2);
986 assert_eq!(trace.final_.escalations, 1);
987 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
988 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
989 }
990
991 #[tokio::test]
997 async fn latency_ab_speculative_beats_serial_p95() {
998 const DELAY: u64 = 40;
999 const N: usize = 30;
1000 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1001 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1002 let req = base_request();
1003 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1004
1005 fn pctl(sorted: &[u64], p: f64) -> u64 {
1006 let i = (((sorted.len() - 1) as f64) * p).round() as usize;
1007 sorted[i]
1008 }
1009
1010 let mut serial = Vec::with_capacity(N);
1011 let mut spec = Vec::with_capacity(N);
1012 for run in 0..(2 * N) {
1013 let speculation = u32::from(run >= N); let mut outs: HashMap<String, Result<ModelResponse, ProviderError>> = HashMap::new();
1015 outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "ok")));
1017 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1018 map.insert(
1019 "anthropic".to_owned(),
1020 Arc::new(MockProvider::new("anthropic", outs).with_delay(DELAY)),
1021 );
1022 let providers = ProviderRegistry::from_map(map);
1023 let health = GateHealthRegistry::new();
1024 let mut c = ctx(
1025 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1026 );
1027 c.speculation = speculation;
1028 let start = std::time::Instant::now();
1029 let _ = route_enforce(c).await;
1030 let ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1031 if speculation == 0 {
1032 serial.push(ms);
1033 } else {
1034 spec.push(ms);
1035 }
1036 }
1037 serial.sort_unstable();
1038 spec.sort_unstable();
1039 println!(
1040 "latency A/B (per-rung {DELAY}ms, escalate every request):\n serial p50={} p95={} p99={}\n spec(k=1) p50={} p95={} p99={}",
1041 pctl(&serial, 0.5),
1042 pctl(&serial, 0.95),
1043 pctl(&serial, 0.99),
1044 pctl(&spec, 0.5),
1045 pctl(&spec, 0.95),
1046 pctl(&spec, 0.99),
1047 );
1048 assert!(
1050 pctl(&spec, 0.95) * 4 < pctl(&serial, 0.95) * 3,
1051 "spec p95 {}ms should beat serial p95 {}ms by >25%",
1052 pctl(&spec, 0.95),
1053 pctl(&serial, 0.95)
1054 );
1055 }
1056
1057 #[tokio::test]
1058 async fn speculation_never_fires_past_max_rungs() {
1059 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1060 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1061 let req = base_request();
1062 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1063 let (providers, log) = counted_registry(vec![
1065 (HAIKU, Ok(resp(HAIKU, ""))),
1066 (SONNET, Ok(resp(SONNET, ""))),
1067 (OPUS, Ok(resp(OPUS, ""))),
1068 ]);
1069 let health = GateHealthRegistry::new();
1070 let mut c = ctx(
1071 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1072 );
1073 c.max_rungs = 2;
1074 c.speculation = 5; let (out, trace) = route_enforce(c).await;
1076
1077 assert!(
1078 !log.lock().unwrap().contains(&OPUS.to_owned()),
1079 "must not fire beyond max_rungs: {:?}",
1080 *log.lock().unwrap()
1081 );
1082 assert_eq!(trace.attempts.len(), 2);
1083 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
1084 assert_eq!(trace.final_.served_rung, Some(1));
1085 match out {
1086 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1087 EngineOutcome::Failed(e) => panic!("expected best-attempt served, got {e}"),
1088 }
1089 }
1090
1091 #[tokio::test]
1092 async fn speculation_cuts_wall_clock_vs_serial() {
1093 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1098 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1099 let req = base_request();
1100 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1101
1102 let build = || {
1103 let mut outs = HashMap::new();
1104 outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "real answer"))); let mock = MockProvider::new("anthropic", outs).with_delay(80);
1107 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1108 map.insert("anthropic".to_owned(), Arc::new(mock));
1109 ProviderRegistry::from_map(map)
1110 };
1111
1112 let providers = build();
1113 let health = GateHealthRegistry::new();
1114 let t = std::time::Instant::now();
1115 let _ = route_enforce(ctx(
1116 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1117 ))
1118 .await;
1119 let serial = t.elapsed();
1120
1121 let providers = build();
1122 let health = GateHealthRegistry::new();
1123 let mut c = ctx(
1124 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1125 );
1126 c.speculation = 1;
1127 let t = std::time::Instant::now();
1128 let _ = route_enforce(c).await;
1129 let spec = t.elapsed();
1130
1131 assert!(
1132 spec < serial * 3 / 4,
1133 "speculation must overlap rung latencies: serial={serial:?} spec={spec:?}"
1134 );
1135 }
1136
1137 #[derive(Debug)]
1140 struct ScoreGate;
1141
1142 #[async_trait::async_trait]
1143 impl Gate for ScoreGate {
1144 fn id(&self) -> &str {
1145 "score"
1146 }
1147
1148 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
1149 let score = resp.text.trim().parse::<f64>().unwrap_or(0.0);
1150 GateResult {
1151 gate_id: self.id().to_owned(),
1152 verdict: Verdict::Pass,
1153 score: Some(firstpass_core::Score::clamped(score)),
1154 cost_usd: 0.0,
1155 ms: 0,
1156 reason: None,
1157 evidence_ref: None,
1158 }
1159 }
1160 }
1161
1162 #[tokio::test]
1163 async fn serve_threshold_escalates_past_low_scoring_rung() {
1164 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1167 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1168 let req = base_request();
1169 let providers = registry(vec![
1170 ("anthropic", HAIKU, Ok(resp(HAIKU, "0.5"))),
1171 ("anthropic", SONNET, Ok(resp(SONNET, "0.9"))),
1172 ]);
1173 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1174 let health = GateHealthRegistry::new();
1175 let mut c = ctx(
1176 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1177 );
1178 c.serve_threshold = Some(0.8);
1179 let (out, trace) = route_enforce(c).await;
1180
1181 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1182 assert_eq!(trace.attempts.len(), 2);
1183 assert_eq!(trace.attempts[0].verdict, Verdict::Pass);
1185 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1186 assert_eq!(trace.final_.escalations, 1);
1187 assert_eq!(trace.final_.served_rung, Some(1));
1188 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1189 }
1190
1191 #[tokio::test]
1192 async fn serve_threshold_does_not_serve_a_pass_below_threshold() {
1193 let ladder = vec![HAIKU.to_owned()];
1197 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1198 let req = base_request();
1199 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.3")))]);
1200 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1201 let health = GateHealthRegistry::new();
1202 let mut c = ctx(
1203 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1204 );
1205 c.serve_threshold = Some(0.8);
1206 let (out, trace) = route_enforce(c).await;
1207
1208 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1209 assert_eq!(
1210 trace.attempts[0].verdict,
1211 Verdict::Pass,
1212 "gate verdict was Pass"
1213 );
1214 assert_eq!(
1215 trace.final_.served_from,
1216 ServedFrom::BestAttempt,
1217 "score below threshold must fall back, not serve as a normal pass"
1218 );
1219 }
1220
1221 #[tokio::test]
1222 async fn serve_threshold_none_serves_on_verdict_regardless_of_score() {
1223 let ladder = vec![HAIKU.to_owned()];
1226 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1227 let req = base_request();
1228 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.1")))]);
1229 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1230 let health = GateHealthRegistry::new();
1231 let c = ctx(
1232 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1233 );
1234 assert_eq!(c.serve_threshold, None);
1235 let (out, trace) = route_enforce(c).await;
1236
1237 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1238 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1239 }
1240}