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(&ctx.tenant_id, 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
268 .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
269 gate_results.push(r);
270 }
271 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
272 gate_cost_total += gc;
273 spent += gc;
274
275 let verdict = aggregate(&gate_results);
276 let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
277 attempts.push(Attempt {
278 rung: idx,
279 model: model_str.clone(),
280 provider: provider.id().to_owned(),
281 in_tokens: resp.in_tokens,
282 out_tokens: resp.out_tokens,
283 cost_usd: model_cost,
284 latency_ms: ms,
285 gates: gate_results,
286 verdict,
287 });
288 best = Some((idx, resp));
289
290 if serve {
291 served_rung = Some(idx);
292 break;
293 }
294 if let Some(cap) = ctx.budget_per_request_usd
296 && spent >= cap
297 && (idx as usize) + 1 < rung_limit
298 {
299 break;
300 }
301 }
302 }
303 }
304
305 LadderRun {
306 attempts,
307 spent,
308 gate_cost_total,
309 best,
310 served_rung,
311 hard_error,
312 }
313}
314
315async fn run_speculative(ctx: &EnforceCtx<'_>) -> LadderRun {
320 let mut attempts: Vec<Attempt> = Vec::new();
321 let mut spent = 0.0_f64;
322 let mut gate_cost_total = 0.0_f64;
323 let mut best: Option<(u32, ModelResponse)> = None;
324 let mut served_rung: Option<u32> = None;
325 let mut hard_error: Option<String> = None;
326
327 let rung_limit = (ctx.max_rungs as usize).min(ctx.ladder.len());
328 let speculation = ctx.speculation as usize;
329 let mut inflight: HashMap<usize, JoinHandle<Result<ModelResponse, ProviderError>>> =
330 HashMap::new();
331
332 let mut idx = 0usize;
333 let mut done = false;
335 while idx < rung_limit && !done {
336 let window_end = (idx + speculation).min(rung_limit - 1);
339 for j in idx..=window_end {
340 if inflight.contains_key(&j) {
341 continue;
342 }
343 if j > idx
344 && let Some(cap) = ctx.budget_per_request_usd
345 && spent >= cap
346 {
347 continue;
348 }
349 if let Some(handle) = spawn_rung(ctx, j) {
350 inflight.insert(j, handle);
351 }
352 }
353
354 let model_str = &ctx.ladder[idx];
355 let provider = match ModelRef::parse(model_str) {
356 Ok(m) => ctx.providers.get(&m.provider),
357 Err(_) => None,
358 };
359 let Some(provider) = provider else {
360 attempts.push(abstain_attempt(
362 idx as u32,
363 model_str,
364 "unknown",
365 reason::PROVIDER_ERROR,
366 0,
367 ));
368 idx += 1;
369 continue;
370 };
371 let Some(handle) = inflight.remove(&idx) else {
373 attempts.push(abstain_attempt(
374 idx as u32,
375 model_str,
376 provider.id(),
377 reason::PROVIDER_ERROR,
378 0,
379 ));
380 idx += 1;
381 continue;
382 };
383 let start = Instant::now();
384 let joined = handle.await;
385 let ms = elapsed_ms(start);
386
387 match joined {
388 Err(_) => {
390 attempts.push(abstain_attempt(
391 idx as u32,
392 model_str,
393 provider.id(),
394 reason::PROVIDER_ERROR,
395 ms,
396 ));
397 idx += 1;
398 }
399 Ok(Err(err)) if err.is_failover_eligible() => {
400 attempts.push(abstain_attempt(
401 idx as u32,
402 model_str,
403 provider.id(),
404 reason::PROVIDER_ERROR,
405 ms,
406 ));
407 idx += 1;
408 }
409 Ok(Err(err)) => {
410 let (r, msg) = hard_reason(&err);
412 attempts.push(abstain_attempt(idx as u32, model_str, provider.id(), r, ms));
413 hard_error = Some(msg);
414 done = true;
415 }
416 Ok(Ok(resp)) => {
417 let model_cost = ctx
418 .prices
419 .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
420 .unwrap_or(0.0);
421 spent += model_cost;
422
423 let mut req = ctx.base_request.clone();
424 req.model = model_str.clone();
425 let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
426 for g in ctx.gates {
427 if !ctx.health.enabled(&ctx.tenant_id, g.id()) {
428 tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
429 continue;
430 }
431 let r = g.evaluate(&req, &resp).await;
432 ctx.health
433 .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
434 gate_results.push(r);
435 }
436 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
437 gate_cost_total += gc;
438 spent += gc;
439
440 let verdict = aggregate(&gate_results);
441 let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
442 attempts.push(Attempt {
443 rung: idx as u32,
444 model: model_str.clone(),
445 provider: provider.id().to_owned(),
446 in_tokens: resp.in_tokens,
447 out_tokens: resp.out_tokens,
448 cost_usd: model_cost,
449 latency_ms: ms,
450 gates: gate_results,
451 verdict,
452 });
453 best = Some((idx as u32, resp));
454
455 if serve {
456 served_rung = Some(idx as u32);
457 done = true;
458 } else if let Some(cap) = ctx.budget_per_request_usd
459 && spent >= cap
460 && idx + 1 < rung_limit
461 {
462 done = true;
463 }
464 idx += 1;
465 }
466 }
467 }
468
469 for (j, handle) in inflight.drain() {
474 if handle.is_finished() {
475 if let Ok(Ok(resp)) = handle.await {
476 spent += ctx
477 .prices
478 .cost_usd(&ctx.ladder[j], resp.in_tokens, resp.out_tokens)
479 .unwrap_or(0.0);
480 }
481 } else {
482 handle.abort();
483 }
484 }
485
486 LadderRun {
487 attempts,
488 spent,
489 gate_cost_total,
490 best,
491 served_rung,
492 hard_error,
493 }
494}
495
496fn spawn_rung(
499 ctx: &EnforceCtx<'_>,
500 j: usize,
501) -> Option<JoinHandle<Result<ModelResponse, ProviderError>>> {
502 let model_str = ctx.ladder.get(j)?;
503 let provider = match ModelRef::parse(model_str) {
504 Ok(m) => ctx.providers.get(&m.provider)?,
505 Err(_) => return None,
506 };
507 let mut req = ctx.base_request.clone();
508 req.model = model_str.clone();
509 let auth = ctx.auth.clone();
510 Some(tokio::spawn(
511 async move { provider.complete(&req, &auth).await },
512 ))
513}
514
515fn elapsed_ms(start: Instant) -> u64 {
516 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
517}
518
519fn abstain_attempt(rung: u32, model: &str, provider: &str, reason: &str, ms: u64) -> Attempt {
521 Attempt {
522 rung,
523 model: model.to_owned(),
524 provider: provider.to_owned(),
525 in_tokens: 0,
526 out_tokens: 0,
527 cost_usd: 0.0,
528 latency_ms: ms,
529 gates: vec![GateResult::abstain(provider, reason, ms)],
530 verdict: Verdict::Abstain,
531 }
532}
533
534fn hard_reason(err: &ProviderError) -> (&'static str, String) {
536 match err {
537 ProviderError::Http { status, .. } => {
538 (reason::PROVIDER_ERROR, format!("upstream http {status}"))
539 }
540 ProviderError::Decode(_) => (reason::PROVIDER_ERROR, "upstream decode error".to_owned()),
541 ProviderError::Transport(_) => (
542 reason::PROVIDER_ERROR,
543 "upstream transport error".to_owned(),
544 ),
545 }
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use crate::gate::{JsonValidGate, NonEmptyGate};
552 use crate::provider::{MockProvider, Provider};
553 use serde_json::Value;
554 use std::collections::HashMap;
555 use std::sync::Arc;
556
557 const HAIKU: &str = "anthropic/claude-haiku-4-5";
558 const SONNET: &str = "anthropic/claude-sonnet-5";
559 const OPUS: &str = "anthropic/claude-opus-4-8";
560 const GPT: &str = "openai/gpt-5.5";
561
562 fn resp(model: &str, text: &str) -> ModelResponse {
563 ModelResponse {
564 model: model.to_owned(),
565 text: text.to_owned(),
566 in_tokens: 1000,
567 out_tokens: 500,
568 raw: Value::Null,
569 }
570 }
571
572 fn base_request() -> ModelRequest {
573 ModelRequest {
574 model: String::new(),
575 system: None,
576 messages: vec![crate::provider::ChatMessage {
577 role: "user".into(),
578 content: "hi".into(),
579 }],
580 max_tokens: 256,
581 tools: Value::Null,
582 }
583 }
584
585 fn registry(
587 outcomes: Vec<(&str, &str, Result<ModelResponse, ProviderError>)>,
588 ) -> ProviderRegistry {
589 let mut by_provider: HashMap<
590 String,
591 HashMap<String, Result<ModelResponse, ProviderError>>,
592 > = HashMap::new();
593 for (provider, model, out) in outcomes {
594 by_provider
595 .entry(provider.to_owned())
596 .or_default()
597 .insert(model.to_owned(), out);
598 }
599 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
600 for (pid, outs) in by_provider {
601 map.insert(pid.clone(), Arc::new(MockProvider::new(pid, outs)));
602 }
603 ProviderRegistry::from_map(map)
604 }
605
606 #[allow(clippy::too_many_arguments)]
607 fn ctx<'a>(
608 ladder: &'a [String],
609 gates: &'a [Box<dyn Gate>],
610 req: &'a ModelRequest,
611 providers: &'a ProviderRegistry,
612 auth: &'a Auth,
613 prices: &'a PriceTable,
614 budget: Option<f64>,
615 health: &'a GateHealthRegistry,
616 ) -> EnforceCtx<'a> {
617 EnforceCtx {
618 ladder,
619 gates,
620 health,
621 base_request: req,
622 providers,
623 auth,
624 prices,
625 budget_per_request_usd: budget,
626 max_rungs: 3,
627 speculation: 0,
628 serve_threshold: None,
629 features: Features::new(firstpass_core::TaskKind::CodeEdit),
630 tenant_id: "acme".into(),
631 session_id: "sess-1".into(),
632 prompt_hash: "deadbeef".into(),
633 api: "anthropic.messages".into(),
634 policy_id: "static-ladder@v0".into(),
635 }
636 }
637
638 #[tokio::test]
639 async fn serve_first_pass_no_escalation_with_savings() {
640 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
641 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
642 let req = base_request();
643 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, r#"{"ok":1}"#)))]);
644 let (auth, prices) = (Auth::default(), PriceTable::defaults());
645 let health = GateHealthRegistry::new();
646 let (out, trace) = route_enforce(ctx(
647 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
648 ))
649 .await;
650
651 match out {
652 EngineOutcome::Served(r) => assert_eq!(r.model, HAIKU),
653 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
654 }
655 assert_eq!(trace.attempts.len(), 1);
656 assert_eq!(trace.final_.escalations, 0);
657 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
658 assert_eq!(trace.final_.served_rung, Some(0));
659 assert!(
660 trace.final_.savings_usd > 0.0,
661 "top-rung baseline should exceed haiku cost"
662 );
663 }
664
665 #[tokio::test]
666 async fn escalate_on_gate_fail() {
667 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
668 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
669 let req = base_request();
670 let providers = registry(vec![
672 ("anthropic", HAIKU, Ok(resp(HAIKU, " "))),
673 ("anthropic", SONNET, Ok(resp(SONNET, "real answer"))),
674 ]);
675 let (auth, prices) = (Auth::default(), PriceTable::defaults());
676 let health = GateHealthRegistry::new();
677 let (out, trace) = route_enforce(ctx(
678 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
679 ))
680 .await;
681
682 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
683 assert_eq!(trace.attempts.len(), 2);
684 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
685 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
686 assert_eq!(trace.final_.escalations, 1);
687 assert_eq!(trace.final_.served_rung, Some(1));
688 }
689
690 #[tokio::test]
691 async fn cross_provider_failover_on_transport_error() {
692 let ladder = vec![HAIKU.to_owned(), GPT.to_owned()];
694 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
695 let req = base_request();
696 let providers = registry(vec![
697 (
698 "anthropic",
699 HAIKU,
700 Err(ProviderError::Transport("connection refused".into())),
701 ),
702 ("openai", GPT, Ok(resp(GPT, "answer from openai"))),
703 ]);
704 let (auth, prices) = (Auth::default(), PriceTable::defaults());
705 let health = GateHealthRegistry::new();
706 let (out, trace) = route_enforce(ctx(
707 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
708 ))
709 .await;
710
711 assert!(matches!(out, EngineOutcome::Served(r) if r.model == GPT));
712 assert_eq!(trace.attempts[0].verdict, Verdict::Abstain);
713 assert_eq!(
714 trace.attempts[0].gates[0].reason.as_deref(),
715 Some(reason::PROVIDER_ERROR)
716 );
717 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
718 assert_eq!(trace.final_.served_rung, Some(1));
719 }
720
721 #[tokio::test]
722 async fn budget_cap_stops_escalation() {
723 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
724 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
725 let req = base_request();
726 let providers = registry(vec![
728 ("anthropic", HAIKU, Ok(resp(HAIKU, ""))),
729 ("anthropic", SONNET, Ok(resp(SONNET, ""))),
730 ("anthropic", OPUS, Ok(resp(OPUS, ""))),
731 ]);
732 let (auth, prices) = (Auth::default(), PriceTable::defaults());
733 let health = GateHealthRegistry::new();
734 let (_out, trace) = route_enforce(ctx(
735 &ladder,
736 &gates,
737 &req,
738 &providers,
739 &auth,
740 &prices,
741 Some(0.0),
742 &health,
743 ))
744 .await;
745 assert!(
746 trace.attempts.len() < 3,
747 "budget should cut escalation short, got {}",
748 trace.attempts.len()
749 );
750 }
751
752 #[tokio::test]
753 async fn all_fail_serves_best_attempt() {
754 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
755 let gates: Vec<Box<dyn Gate>> = vec![Box::new(JsonValidGate)]; let req = base_request();
757 let providers = registry(vec![
758 ("anthropic", HAIKU, Ok(resp(HAIKU, "not json"))),
759 ("anthropic", SONNET, Ok(resp(SONNET, "still not json"))),
760 ]);
761 let (auth, prices) = (Auth::default(), PriceTable::defaults());
762 let health = GateHealthRegistry::new();
763 let (out, trace) = route_enforce(ctx(
764 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
765 ))
766 .await;
767
768 assert!(
769 matches!(out, EngineOutcome::Served(r) if r.model == SONNET),
770 "serves highest attempt"
771 );
772 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
773 assert_eq!(trace.final_.served_rung, Some(1));
774 }
775
776 #[tokio::test]
777 async fn hard_4xx_does_not_escalate() {
778 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
779 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
780 let req = base_request();
781 let providers = registry(vec![
782 (
783 "anthropic",
784 HAIKU,
785 Err(ProviderError::Http {
786 status: 400,
787 body: "bad request".into(),
788 }),
789 ),
790 ("anthropic", SONNET, Ok(resp(SONNET, "would have worked"))),
791 ]);
792 let (auth, prices) = (Auth::default(), PriceTable::defaults());
793 let health = GateHealthRegistry::new();
794 let (out, trace) = route_enforce(ctx(
795 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
796 ))
797 .await;
798
799 assert!(
800 matches!(out, EngineOutcome::Failed(_)),
801 "4xx is a hard error, not failover"
802 );
803 assert_eq!(
804 trace.attempts.len(),
805 1,
806 "must not escalate past a client error"
807 );
808 assert_eq!(trace.final_.served_from, ServedFrom::Error);
809 }
810
811 #[tokio::test]
812 async fn counterfactual_and_savings_math() {
813 let ladder = vec![HAIKU.to_owned(), OPUS.to_owned()];
814 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
815 let req = base_request();
816 let served = resp(HAIKU, "answer");
817 let (in_t, out_t) = (served.in_tokens, served.out_tokens);
818 let providers = registry(vec![("anthropic", HAIKU, Ok(served))]);
819 let (auth, prices) = (Auth::default(), PriceTable::defaults());
820 let health = GateHealthRegistry::new();
821 let (_out, trace) = route_enforce(ctx(
822 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
823 ))
824 .await;
825
826 let expected_baseline = prices.cost_usd(OPUS, in_t, out_t).unwrap();
827 assert!((trace.final_.counterfactual_baseline_usd - expected_baseline).abs() < 1e-12);
828 let expected_savings = expected_baseline - trace.final_.total_cost_usd;
829 assert!((trace.final_.savings_usd - expected_savings).abs() < 1e-12);
830 assert!(trace.final_.savings_usd > 0.0);
831 }
832
833 #[tokio::test]
834 async fn produced_trace_is_chain_verifiable() {
835 let ladder = vec![HAIKU.to_owned()];
836 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
837 let req = base_request();
838 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "ok")))]);
839 let (auth, prices) = (Auth::default(), PriceTable::defaults());
840 let health = GateHealthRegistry::new();
841 let (_out, trace) = route_enforce(ctx(
842 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
843 ))
844 .await;
845
846 assert!(firstpass_core::verify_chain(std::slice::from_ref(&trace), GENESIS_HASH).is_ok());
848 let json = serde_json::to_string(&trace).unwrap();
850 let _back: Trace = serde_json::from_str(&json).unwrap();
851 }
852
853 #[tokio::test]
854 async fn auto_disabled_gate_is_skipped_by_the_engine() {
855 let ladder = vec![HAIKU.to_owned()];
858 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
859 let req = base_request();
860 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "")))]); let (auth, prices) = (Auth::default(), PriceTable::defaults());
862
863 let health = GateHealthRegistry::new().with_budget("non-empty", 4, 0.5);
866 for _ in 0..4 {
867 health.record("acme", "non-empty", true);
868 }
869 assert!(
870 !health.enabled("acme", "non-empty"),
871 "precondition: gate is auto-disabled"
872 );
873
874 let (out, trace) = route_enforce(ctx(
875 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
876 ))
877 .await;
878 assert!(matches!(out, EngineOutcome::Served(_)));
879 assert_eq!(trace.final_.served_rung, Some(0));
880 assert!(
881 trace.attempts[0].gates.is_empty(),
882 "disabled gate must be skipped, not run"
883 );
884 }
885
886 fn counted_registry(
889 outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
890 ) -> (
891 ProviderRegistry,
892 std::sync::Arc<std::sync::Mutex<Vec<String>>>,
893 ) {
894 let mut outs = HashMap::new();
895 for (model, out) in outcomes {
896 outs.insert(model.to_owned(), out);
897 }
898 let mock = MockProvider::new("anthropic", outs);
899 let log = mock.call_log();
900 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
901 map.insert("anthropic".to_owned(), Arc::new(mock));
902 (ProviderRegistry::from_map(map), log)
903 }
904
905 #[tokio::test]
906 async fn speculation_prefetches_next_rung_but_serves_identically() {
907 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
908 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
909 let req = base_request();
910 let (auth, prices) = (Auth::default(), PriceTable::defaults());
911
912 let (providers, log) = counted_registry(vec![
914 (HAIKU, Ok(resp(HAIKU, "answer"))),
915 (SONNET, Ok(resp(SONNET, "other"))),
916 ]);
917 let health = GateHealthRegistry::new();
918 let (serial_out, serial_trace) = route_enforce(ctx(
919 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
920 ))
921 .await;
922 assert_eq!(
923 *log.lock().unwrap(),
924 vec![HAIKU.to_owned()],
925 "serial must not touch rung 1 when rung 0 passes"
926 );
927
928 let (providers, log) = counted_registry(vec![
930 (HAIKU, Ok(resp(HAIKU, "answer"))),
931 (SONNET, Ok(resp(SONNET, "other"))),
932 ]);
933 let health = GateHealthRegistry::new();
934 let mut c = ctx(
935 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
936 );
937 c.speculation = 1;
938 let (spec_out, spec_trace) = route_enforce(c).await;
939
940 assert!(
941 log.lock().unwrap().contains(&SONNET.to_owned()),
942 "speculation must fire rung 1 ahead: {:?}",
943 *log.lock().unwrap()
944 );
945
946 let (a, b) = match (serial_out, spec_out) {
948 (EngineOutcome::Served(a), EngineOutcome::Served(b)) => (a, b),
949 _ => panic!("both variants must serve"),
950 };
951 assert_eq!(
952 (a.model, a.text, a.out_tokens),
953 (b.model, b.text, b.out_tokens)
954 );
955 assert_eq!(spec_trace.final_.served_rung, Some(0));
956 assert_eq!(spec_trace.attempts.len(), 1, "only rung 0 is gated");
957 assert!(
959 spec_trace.final_.total_cost_usd > serial_trace.final_.total_cost_usd,
960 "speculative waste must show in total cost: spec={} serial={}",
961 spec_trace.final_.total_cost_usd,
962 serial_trace.final_.total_cost_usd
963 );
964 }
965
966 #[tokio::test]
967 async fn speculation_preserves_escalation_result() {
968 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
969 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
970 let req = base_request();
971 let (auth, prices) = (Auth::default(), PriceTable::defaults());
972 let (providers, _log) = counted_registry(vec![
974 (HAIKU, Ok(resp(HAIKU, ""))),
975 (SONNET, Ok(resp(SONNET, "real answer"))),
976 ]);
977 let health = GateHealthRegistry::new();
978 let mut c = ctx(
979 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
980 );
981 c.speculation = 2; let (out, trace) = route_enforce(c).await;
983 match out {
984 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
985 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
986 }
987 assert_eq!(trace.final_.served_rung, Some(1));
988 assert_eq!(trace.attempts.len(), 2);
989 assert_eq!(trace.final_.escalations, 1);
990 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
991 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
992 }
993
994 #[tokio::test]
1000 async fn latency_ab_speculative_beats_serial_p95() {
1001 const DELAY: u64 = 40;
1002 const N: usize = 30;
1003 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1004 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1005 let req = base_request();
1006 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1007
1008 fn pctl(sorted: &[u64], p: f64) -> u64 {
1009 let i = (((sorted.len() - 1) as f64) * p).round() as usize;
1010 sorted[i]
1011 }
1012
1013 let mut serial = Vec::with_capacity(N);
1014 let mut spec = Vec::with_capacity(N);
1015 for run in 0..(2 * N) {
1016 let speculation = u32::from(run >= N); let mut outs: HashMap<String, Result<ModelResponse, ProviderError>> = HashMap::new();
1018 outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "ok")));
1020 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1021 map.insert(
1022 "anthropic".to_owned(),
1023 Arc::new(MockProvider::new("anthropic", outs).with_delay(DELAY)),
1024 );
1025 let providers = ProviderRegistry::from_map(map);
1026 let health = GateHealthRegistry::new();
1027 let mut c = ctx(
1028 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1029 );
1030 c.speculation = speculation;
1031 let start = std::time::Instant::now();
1032 let _ = route_enforce(c).await;
1033 let ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1034 if speculation == 0 {
1035 serial.push(ms);
1036 } else {
1037 spec.push(ms);
1038 }
1039 }
1040 serial.sort_unstable();
1041 spec.sort_unstable();
1042 println!(
1043 "latency A/B (per-rung {DELAY}ms, escalate every request):\n serial p50={} p95={} p99={}\n spec(k=1) p50={} p95={} p99={}",
1044 pctl(&serial, 0.5),
1045 pctl(&serial, 0.95),
1046 pctl(&serial, 0.99),
1047 pctl(&spec, 0.5),
1048 pctl(&spec, 0.95),
1049 pctl(&spec, 0.99),
1050 );
1051 assert!(
1053 pctl(&spec, 0.95) * 4 < pctl(&serial, 0.95) * 3,
1054 "spec p95 {}ms should beat serial p95 {}ms by >25%",
1055 pctl(&spec, 0.95),
1056 pctl(&serial, 0.95)
1057 );
1058 }
1059
1060 #[tokio::test]
1061 async fn speculation_never_fires_past_max_rungs() {
1062 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1063 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1064 let req = base_request();
1065 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1066 let (providers, log) = counted_registry(vec![
1068 (HAIKU, Ok(resp(HAIKU, ""))),
1069 (SONNET, Ok(resp(SONNET, ""))),
1070 (OPUS, Ok(resp(OPUS, ""))),
1071 ]);
1072 let health = GateHealthRegistry::new();
1073 let mut c = ctx(
1074 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1075 );
1076 c.max_rungs = 2;
1077 c.speculation = 5; let (out, trace) = route_enforce(c).await;
1079
1080 assert!(
1081 !log.lock().unwrap().contains(&OPUS.to_owned()),
1082 "must not fire beyond max_rungs: {:?}",
1083 *log.lock().unwrap()
1084 );
1085 assert_eq!(trace.attempts.len(), 2);
1086 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
1087 assert_eq!(trace.final_.served_rung, Some(1));
1088 match out {
1089 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1090 EngineOutcome::Failed(e) => panic!("expected best-attempt served, got {e}"),
1091 }
1092 }
1093
1094 #[tokio::test]
1095 async fn speculation_cuts_wall_clock_vs_serial() {
1096 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1101 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1102 let req = base_request();
1103 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1104
1105 let build = || {
1106 let mut outs = HashMap::new();
1107 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);
1110 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1111 map.insert("anthropic".to_owned(), Arc::new(mock));
1112 ProviderRegistry::from_map(map)
1113 };
1114
1115 let providers = build();
1116 let health = GateHealthRegistry::new();
1117 let t = std::time::Instant::now();
1118 let _ = route_enforce(ctx(
1119 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1120 ))
1121 .await;
1122 let serial = t.elapsed();
1123
1124 let providers = build();
1125 let health = GateHealthRegistry::new();
1126 let mut c = ctx(
1127 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1128 );
1129 c.speculation = 1;
1130 let t = std::time::Instant::now();
1131 let _ = route_enforce(c).await;
1132 let spec = t.elapsed();
1133
1134 assert!(
1135 spec < serial * 3 / 4,
1136 "speculation must overlap rung latencies: serial={serial:?} spec={spec:?}"
1137 );
1138 }
1139
1140 #[derive(Debug)]
1143 struct ScoreGate;
1144
1145 #[async_trait::async_trait]
1146 impl Gate for ScoreGate {
1147 fn id(&self) -> &str {
1148 "score"
1149 }
1150
1151 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
1152 let score = resp.text.trim().parse::<f64>().unwrap_or(0.0);
1153 GateResult {
1154 gate_id: self.id().to_owned(),
1155 verdict: Verdict::Pass,
1156 score: Some(firstpass_core::Score::clamped(score)),
1157 cost_usd: 0.0,
1158 ms: 0,
1159 reason: None,
1160 evidence_ref: None,
1161 }
1162 }
1163 }
1164
1165 #[tokio::test]
1166 async fn serve_threshold_escalates_past_low_scoring_rung() {
1167 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1170 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1171 let req = base_request();
1172 let providers = registry(vec![
1173 ("anthropic", HAIKU, Ok(resp(HAIKU, "0.5"))),
1174 ("anthropic", SONNET, Ok(resp(SONNET, "0.9"))),
1175 ]);
1176 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1177 let health = GateHealthRegistry::new();
1178 let mut c = ctx(
1179 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1180 );
1181 c.serve_threshold = Some(0.8);
1182 let (out, trace) = route_enforce(c).await;
1183
1184 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1185 assert_eq!(trace.attempts.len(), 2);
1186 assert_eq!(trace.attempts[0].verdict, Verdict::Pass);
1188 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1189 assert_eq!(trace.final_.escalations, 1);
1190 assert_eq!(trace.final_.served_rung, Some(1));
1191 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1192 }
1193
1194 #[tokio::test]
1195 async fn serve_threshold_does_not_serve_a_pass_below_threshold() {
1196 let ladder = vec![HAIKU.to_owned()];
1200 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1201 let req = base_request();
1202 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.3")))]);
1203 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1204 let health = GateHealthRegistry::new();
1205 let mut c = ctx(
1206 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1207 );
1208 c.serve_threshold = Some(0.8);
1209 let (out, trace) = route_enforce(c).await;
1210
1211 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1212 assert_eq!(
1213 trace.attempts[0].verdict,
1214 Verdict::Pass,
1215 "gate verdict was Pass"
1216 );
1217 assert_eq!(
1218 trace.final_.served_from,
1219 ServedFrom::BestAttempt,
1220 "score below threshold must fall back, not serve as a normal pass"
1221 );
1222 }
1223
1224 #[tokio::test]
1225 async fn serve_threshold_none_serves_on_verdict_regardless_of_score() {
1226 let ladder = vec![HAIKU.to_owned()];
1229 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1230 let req = base_request();
1231 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.1")))]);
1232 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1233 let health = GateHealthRegistry::new();
1234 let c = ctx(
1235 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1236 );
1237 assert_eq!(c.serve_threshold, None);
1238 let (out, trace) = route_enforce(c).await;
1239
1240 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1241 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1242 }
1243}