1use crate::calibrate::gate_score;
10use crate::gate::{Gate, GateHealthRegistry, aggregate_with_policy};
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 start_rung: u32,
72 pub tenant_id: String,
74 pub session_id: String,
76 pub prompt_hash: String,
78 pub api: String,
80 pub policy_id: String,
82}
83
84pub async fn route_enforce(ctx: EnforceCtx<'_>) -> (EngineOutcome, Trace) {
89 let LadderRun {
92 attempts,
93 spent,
94 gate_cost_total,
95 best,
96 mut served_rung,
97 hard_error,
98 } = if ctx.speculation == 0 {
99 run_serial(&ctx).await
100 } else {
101 run_speculative(&ctx).await
102 };
103
104 let (outcome, served_from, served_tokens) = match (served_rung, &best) {
106 (Some(_), Some((_, resp))) => (
107 EngineOutcome::Served(resp.clone()),
108 ServedFrom::Attempt,
109 (resp.in_tokens, resp.out_tokens),
110 ),
111 (None, Some((idx, resp))) => {
112 served_rung = Some(*idx);
114 (
115 EngineOutcome::Served(resp.clone()),
116 ServedFrom::BestAttempt,
117 (resp.in_tokens, resp.out_tokens),
118 )
119 }
120 (_, None) => {
121 let msg = hard_error.unwrap_or_else(|| "all rungs failed".to_owned());
122 (EngineOutcome::Failed(msg), ServedFrom::Error, (0, 0))
123 }
124 };
125
126 let top_model = ctx.ladder.last().map(String::as_str).unwrap_or_default();
128 let baseline = ctx
129 .prices
130 .cost_usd(top_model, served_tokens.0, served_tokens.1)
131 .unwrap_or(spent);
132
133 let total_latency_ms = attempts.iter().map(|a| a.latency_ms).sum();
134 let escalations = attempts.len().saturating_sub(1) as u32;
135
136 let mut trace = Trace {
137 trace_id: Uuid::now_v7(),
138 prev_hash: GENESIS_HASH.to_owned(),
139 tenant_id: ctx.tenant_id,
140 session_id: ctx.session_id,
141 ts: Timestamp::now(),
142 mode: Mode::Enforce,
143 policy: PolicyRef {
144 id: ctx.policy_id,
145 explore: false,
146 propensity: None, },
148 request: RequestInfo {
149 api: ctx.api,
150 prompt_hash: ctx.prompt_hash,
151 features: ctx.features,
152 },
153 attempts,
154 deferred: vec![],
155 final_: FinalOutcome {
156 served_rung,
157 served_from,
158 total_cost_usd: spent,
159 gate_cost_usd: gate_cost_total,
160 total_latency_ms,
161 escalations,
162 counterfactual_baseline_usd: baseline,
163 savings_usd: 0.0,
164 },
165 };
166 trace.recompute_savings();
167 (outcome, trace)
168}
169
170struct LadderRun {
174 attempts: Vec<Attempt>,
175 spent: f64,
176 gate_cost_total: f64,
177 best: Option<(u32, ModelResponse)>,
178 served_rung: Option<u32>,
179 hard_error: Option<String>,
180}
181
182fn should_serve(
192 serve_threshold: Option<f64>,
193 gate_results: &[GateResult],
194 verdict: Verdict,
195) -> bool {
196 match serve_threshold {
197 None => verdict == Verdict::Pass,
198 Some(t) => gate_score(gate_results, verdict) >= t,
199 }
200}
201
202async fn run_serial(ctx: &EnforceCtx<'_>) -> LadderRun {
205 let mut attempts: Vec<Attempt> = Vec::new();
206 let mut spent = 0.0_f64;
207 let mut gate_cost_total = 0.0_f64;
208 let mut best: Option<(u32, ModelResponse)> = None;
209 let mut served_rung: Option<u32> = None;
210 let mut hard_error: Option<String> = None;
211
212 let start = (ctx.start_rung as usize).min(ctx.ladder.len().saturating_sub(1));
213 let rungs_available = ctx.ladder.len().saturating_sub(start);
216 let rung_end = start + (ctx.max_rungs as usize).min(rungs_available);
217 for (i, model_str) in ctx.ladder[start..rung_end].iter().enumerate() {
218 let idx = (start + i) as u32;
219 let start = Instant::now();
220
221 let provider = match ModelRef::parse(model_str) {
224 Ok(m) => ctx.providers.get(&m.provider),
225 Err(_) => None,
226 };
227 let Some(provider) = provider else {
228 let ms = elapsed_ms(start);
229 attempts.push(abstain_attempt(
230 idx,
231 model_str,
232 "unknown",
233 reason::PROVIDER_ERROR,
234 ms,
235 ));
236 continue;
237 };
238
239 let mut req = ctx.base_request.clone();
240 req.model = model_str.clone();
241
242 match provider.complete(&req, ctx.auth).await {
243 Err(err) if err.is_failover_eligible() => {
244 let ms = elapsed_ms(start);
246 attempts.push(abstain_attempt(
247 idx,
248 model_str,
249 provider.id(),
250 reason::PROVIDER_ERROR,
251 ms,
252 ));
253 continue;
254 }
255 Err(err) => {
256 let ms = elapsed_ms(start);
258 let (r, msg) = hard_reason(&err);
259 attempts.push(abstain_attempt(idx, model_str, provider.id(), r, ms));
260 hard_error = Some(msg);
261 break;
262 }
263 Ok(resp) => {
264 let ms = elapsed_ms(start);
265 let model_cost = ctx
266 .prices
267 .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
268 .unwrap_or(0.0);
269 spent += model_cost;
270
271 let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
274 for g in ctx.gates {
275 if !ctx.health.enabled(&ctx.tenant_id, g.id()) {
276 tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
277 continue;
278 }
279 let r = g.evaluate(&req, &resp).await;
280 ctx.health
281 .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
282 gate_results.push(r);
283 }
284 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
285 gate_cost_total += gc;
286 spent += gc;
287
288 let fail_closed: std::collections::HashSet<&str> = ctx
289 .gates
290 .iter()
291 .filter(|g| g.abstain_fails_closed())
292 .map(|g| g.id())
293 .collect();
294 let verdict = aggregate_with_policy(&gate_results, &fail_closed);
295 let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
296 attempts.push(Attempt {
297 rung: idx,
298 model: model_str.clone(),
299 provider: provider.id().to_owned(),
300 in_tokens: resp.in_tokens,
301 out_tokens: resp.out_tokens,
302 cost_usd: model_cost,
303 latency_ms: ms,
304 gates: gate_results,
305 verdict,
306 });
307 best = Some((idx, resp));
308
309 if serve {
310 served_rung = Some(idx);
311 break;
312 }
313 if let Some(cap) = ctx.budget_per_request_usd
315 && spent >= cap
316 && (idx as usize) + 1 < rung_end
317 {
318 break;
319 }
320 }
321 }
322 }
323
324 LadderRun {
325 attempts,
326 spent,
327 gate_cost_total,
328 best,
329 served_rung,
330 hard_error,
331 }
332}
333
334async fn run_speculative(ctx: &EnforceCtx<'_>) -> LadderRun {
339 let mut attempts: Vec<Attempt> = Vec::new();
340 let mut spent = 0.0_f64;
341 let mut gate_cost_total = 0.0_f64;
342 let mut best: Option<(u32, ModelResponse)> = None;
343 let mut served_rung: Option<u32> = None;
344 let mut hard_error: Option<String> = None;
345
346 let start = (ctx.start_rung as usize).min(ctx.ladder.len().saturating_sub(1));
347 let rungs_available = ctx.ladder.len().saturating_sub(start);
349 let rung_end = start + (ctx.max_rungs as usize).min(rungs_available);
350 let speculation = ctx.speculation as usize;
351 let mut inflight: HashMap<usize, JoinHandle<Result<ModelResponse, ProviderError>>> =
352 HashMap::new();
353
354 let mut idx = start;
355 let mut done = false;
357 while idx < rung_end && !done {
358 let window_end = (idx + speculation).min(rung_end - 1);
361 for j in idx..=window_end {
362 if inflight.contains_key(&j) {
363 continue;
364 }
365 if j > idx
366 && let Some(cap) = ctx.budget_per_request_usd
367 && spent >= cap
368 {
369 continue;
370 }
371 if let Some(handle) = spawn_rung(ctx, j) {
372 inflight.insert(j, handle);
373 }
374 }
375
376 let model_str = &ctx.ladder[idx];
377 let provider = match ModelRef::parse(model_str) {
378 Ok(m) => ctx.providers.get(&m.provider),
379 Err(_) => None,
380 };
381 let Some(provider) = provider else {
382 attempts.push(abstain_attempt(
384 idx as u32,
385 model_str,
386 "unknown",
387 reason::PROVIDER_ERROR,
388 0,
389 ));
390 idx += 1;
391 continue;
392 };
393 let Some(handle) = inflight.remove(&idx) else {
395 attempts.push(abstain_attempt(
396 idx as u32,
397 model_str,
398 provider.id(),
399 reason::PROVIDER_ERROR,
400 0,
401 ));
402 idx += 1;
403 continue;
404 };
405 let t0 = Instant::now();
406 let joined = handle.await;
407 let ms = elapsed_ms(t0);
408
409 match joined {
410 Err(_) => {
412 attempts.push(abstain_attempt(
413 idx as u32,
414 model_str,
415 provider.id(),
416 reason::PROVIDER_ERROR,
417 ms,
418 ));
419 idx += 1;
420 }
421 Ok(Err(err)) if err.is_failover_eligible() => {
422 attempts.push(abstain_attempt(
423 idx as u32,
424 model_str,
425 provider.id(),
426 reason::PROVIDER_ERROR,
427 ms,
428 ));
429 idx += 1;
430 }
431 Ok(Err(err)) => {
432 let (r, msg) = hard_reason(&err);
434 attempts.push(abstain_attempt(idx as u32, model_str, provider.id(), r, ms));
435 hard_error = Some(msg);
436 done = true;
437 }
438 Ok(Ok(resp)) => {
439 let model_cost = ctx
440 .prices
441 .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
442 .unwrap_or(0.0);
443 spent += model_cost;
444
445 let mut req = ctx.base_request.clone();
446 req.model = model_str.clone();
447 let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
448 for g in ctx.gates {
449 if !ctx.health.enabled(&ctx.tenant_id, g.id()) {
450 tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
451 continue;
452 }
453 let r = g.evaluate(&req, &resp).await;
454 ctx.health
455 .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
456 gate_results.push(r);
457 }
458 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
459 gate_cost_total += gc;
460 spent += gc;
461
462 let fail_closed: std::collections::HashSet<&str> = ctx
463 .gates
464 .iter()
465 .filter(|g| g.abstain_fails_closed())
466 .map(|g| g.id())
467 .collect();
468 let verdict = aggregate_with_policy(&gate_results, &fail_closed);
469 let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
470 attempts.push(Attempt {
471 rung: idx as u32,
472 model: model_str.clone(),
473 provider: provider.id().to_owned(),
474 in_tokens: resp.in_tokens,
475 out_tokens: resp.out_tokens,
476 cost_usd: model_cost,
477 latency_ms: ms,
478 gates: gate_results,
479 verdict,
480 });
481 best = Some((idx as u32, resp));
482
483 if serve {
484 served_rung = Some(idx as u32);
485 done = true;
486 } else if let Some(cap) = ctx.budget_per_request_usd
487 && spent >= cap
488 && idx + 1 < rung_end
489 {
490 done = true;
491 }
492 idx += 1;
493 }
494 }
495 }
496
497 for (j, handle) in inflight.drain() {
502 if handle.is_finished() {
503 if let Ok(Ok(resp)) = handle.await {
504 spent += ctx
505 .prices
506 .cost_usd(&ctx.ladder[j], resp.in_tokens, resp.out_tokens)
507 .unwrap_or(0.0);
508 }
509 } else {
510 handle.abort();
511 }
512 }
513
514 LadderRun {
515 attempts,
516 spent,
517 gate_cost_total,
518 best,
519 served_rung,
520 hard_error,
521 }
522}
523
524fn spawn_rung(
527 ctx: &EnforceCtx<'_>,
528 j: usize,
529) -> Option<JoinHandle<Result<ModelResponse, ProviderError>>> {
530 let model_str = ctx.ladder.get(j)?;
531 let provider = match ModelRef::parse(model_str) {
532 Ok(m) => ctx.providers.get(&m.provider)?,
533 Err(_) => return None,
534 };
535 let mut req = ctx.base_request.clone();
536 req.model = model_str.clone();
537 let auth = ctx.auth.clone();
538 Some(tokio::spawn(
539 async move { provider.complete(&req, &auth).await },
540 ))
541}
542
543fn elapsed_ms(start: Instant) -> u64 {
544 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
545}
546
547fn abstain_attempt(rung: u32, model: &str, provider: &str, reason: &str, ms: u64) -> Attempt {
549 Attempt {
550 rung,
551 model: model.to_owned(),
552 provider: provider.to_owned(),
553 in_tokens: 0,
554 out_tokens: 0,
555 cost_usd: 0.0,
556 latency_ms: ms,
557 gates: vec![GateResult::abstain(provider, reason, ms)],
558 verdict: Verdict::Abstain,
559 }
560}
561
562fn hard_reason(err: &ProviderError) -> (&'static str, String) {
564 match err {
565 ProviderError::Http { status, .. } => {
566 (reason::PROVIDER_ERROR, format!("upstream http {status}"))
567 }
568 ProviderError::Decode(_) => (reason::PROVIDER_ERROR, "upstream decode error".to_owned()),
569 ProviderError::Transport(_) => (
570 reason::PROVIDER_ERROR,
571 "upstream transport error".to_owned(),
572 ),
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use crate::gate::{JsonValidGate, NonEmptyGate};
580 use crate::provider::{MockProvider, Provider};
581 use serde_json::Value;
582 use std::collections::HashMap;
583 use std::sync::Arc;
584
585 const HAIKU: &str = "anthropic/claude-haiku-4-5";
586 const SONNET: &str = "anthropic/claude-sonnet-5";
587 const OPUS: &str = "anthropic/claude-opus-4-8";
588 const GPT: &str = "openai/gpt-5.5";
589
590 fn resp(model: &str, text: &str) -> ModelResponse {
591 ModelResponse {
592 model: model.to_owned(),
593 text: text.to_owned(),
594 in_tokens: 1000,
595 out_tokens: 500,
596 raw: Value::Null,
597 }
598 }
599
600 fn base_request() -> ModelRequest {
601 ModelRequest {
602 model: String::new(),
603 system: None,
604 messages: vec![crate::provider::ChatMessage {
605 role: "user".into(),
606 content: "hi".into(),
607 }],
608 max_tokens: 256,
609 tools: Value::Null,
610 raw: Value::Null,
611 }
612 }
613
614 fn registry(
616 outcomes: Vec<(&str, &str, Result<ModelResponse, ProviderError>)>,
617 ) -> ProviderRegistry {
618 let mut by_provider: HashMap<
619 String,
620 HashMap<String, Result<ModelResponse, ProviderError>>,
621 > = HashMap::new();
622 for (provider, model, out) in outcomes {
623 by_provider
624 .entry(provider.to_owned())
625 .or_default()
626 .insert(model.to_owned(), out);
627 }
628 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
629 for (pid, outs) in by_provider {
630 map.insert(pid.clone(), Arc::new(MockProvider::new(pid, outs)));
631 }
632 ProviderRegistry::from_map(map)
633 }
634
635 #[allow(clippy::too_many_arguments)]
636 fn ctx<'a>(
637 ladder: &'a [String],
638 gates: &'a [Box<dyn Gate>],
639 req: &'a ModelRequest,
640 providers: &'a ProviderRegistry,
641 auth: &'a Auth,
642 prices: &'a PriceTable,
643 budget: Option<f64>,
644 health: &'a GateHealthRegistry,
645 ) -> EnforceCtx<'a> {
646 EnforceCtx {
647 ladder,
648 gates,
649 health,
650 base_request: req,
651 providers,
652 auth,
653 prices,
654 budget_per_request_usd: budget,
655 max_rungs: 3,
656 speculation: 0,
657 serve_threshold: None,
658 features: Features::new(firstpass_core::TaskKind::CodeEdit),
659 start_rung: 0,
660 tenant_id: "acme".into(),
661 session_id: "sess-1".into(),
662 prompt_hash: "deadbeef".into(),
663 api: "anthropic.messages".into(),
664 policy_id: "static-ladder@v0".into(),
665 }
666 }
667
668 #[tokio::test]
669 async fn serve_first_pass_no_escalation_with_savings() {
670 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
671 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
672 let req = base_request();
673 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, r#"{"ok":1}"#)))]);
674 let (auth, prices) = (Auth::default(), PriceTable::defaults());
675 let health = GateHealthRegistry::new();
676 let (out, trace) = route_enforce(ctx(
677 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
678 ))
679 .await;
680
681 match out {
682 EngineOutcome::Served(r) => assert_eq!(r.model, HAIKU),
683 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
684 }
685 assert_eq!(trace.attempts.len(), 1);
686 assert_eq!(trace.final_.escalations, 0);
687 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
688 assert_eq!(trace.final_.served_rung, Some(0));
689 assert!(
690 trace.final_.savings_usd > 0.0,
691 "top-rung baseline should exceed haiku cost"
692 );
693 }
694
695 #[tokio::test]
696 async fn escalate_on_gate_fail() {
697 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
698 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
699 let req = base_request();
700 let providers = registry(vec![
702 ("anthropic", HAIKU, Ok(resp(HAIKU, " "))),
703 ("anthropic", SONNET, Ok(resp(SONNET, "real answer"))),
704 ]);
705 let (auth, prices) = (Auth::default(), PriceTable::defaults());
706 let health = GateHealthRegistry::new();
707 let (out, trace) = route_enforce(ctx(
708 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
709 ))
710 .await;
711
712 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
713 assert_eq!(trace.attempts.len(), 2);
714 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
715 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
716 assert_eq!(trace.final_.escalations, 1);
717 assert_eq!(trace.final_.served_rung, Some(1));
718 }
719
720 #[tokio::test]
721 async fn cross_provider_failover_on_transport_error() {
722 let ladder = vec![HAIKU.to_owned(), GPT.to_owned()];
724 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
725 let req = base_request();
726 let providers = registry(vec![
727 (
728 "anthropic",
729 HAIKU,
730 Err(ProviderError::Transport("connection refused".into())),
731 ),
732 ("openai", GPT, Ok(resp(GPT, "answer from openai"))),
733 ]);
734 let (auth, prices) = (Auth::default(), PriceTable::defaults());
735 let health = GateHealthRegistry::new();
736 let (out, trace) = route_enforce(ctx(
737 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
738 ))
739 .await;
740
741 assert!(matches!(out, EngineOutcome::Served(r) if r.model == GPT));
742 assert_eq!(trace.attempts[0].verdict, Verdict::Abstain);
743 assert_eq!(
744 trace.attempts[0].gates[0].reason.as_deref(),
745 Some(reason::PROVIDER_ERROR)
746 );
747 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
748 assert_eq!(trace.final_.served_rung, Some(1));
749 }
750
751 #[tokio::test]
752 async fn budget_cap_stops_escalation() {
753 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
754 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
755 let req = base_request();
756 let providers = registry(vec![
758 ("anthropic", HAIKU, Ok(resp(HAIKU, ""))),
759 ("anthropic", SONNET, Ok(resp(SONNET, ""))),
760 ("anthropic", OPUS, Ok(resp(OPUS, ""))),
761 ]);
762 let (auth, prices) = (Auth::default(), PriceTable::defaults());
763 let health = GateHealthRegistry::new();
764 let (_out, trace) = route_enforce(ctx(
765 &ladder,
766 &gates,
767 &req,
768 &providers,
769 &auth,
770 &prices,
771 Some(0.0),
772 &health,
773 ))
774 .await;
775 assert!(
776 trace.attempts.len() < 3,
777 "budget should cut escalation short, got {}",
778 trace.attempts.len()
779 );
780 }
781
782 #[tokio::test]
783 async fn all_fail_serves_best_attempt() {
784 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
785 let gates: Vec<Box<dyn Gate>> = vec![Box::new(JsonValidGate)]; let req = base_request();
787 let providers = registry(vec![
788 ("anthropic", HAIKU, Ok(resp(HAIKU, "not json"))),
789 ("anthropic", SONNET, Ok(resp(SONNET, "still not json"))),
790 ]);
791 let (auth, prices) = (Auth::default(), PriceTable::defaults());
792 let health = GateHealthRegistry::new();
793 let (out, trace) = route_enforce(ctx(
794 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
795 ))
796 .await;
797
798 assert!(
799 matches!(out, EngineOutcome::Served(r) if r.model == SONNET),
800 "serves highest attempt"
801 );
802 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
803 assert_eq!(trace.final_.served_rung, Some(1));
804 }
805
806 #[tokio::test]
807 async fn hard_4xx_does_not_escalate() {
808 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
809 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
810 let req = base_request();
811 let providers = registry(vec![
812 (
813 "anthropic",
814 HAIKU,
815 Err(ProviderError::Http {
816 status: 400,
817 body: "bad request".into(),
818 }),
819 ),
820 ("anthropic", SONNET, Ok(resp(SONNET, "would have worked"))),
821 ]);
822 let (auth, prices) = (Auth::default(), PriceTable::defaults());
823 let health = GateHealthRegistry::new();
824 let (out, trace) = route_enforce(ctx(
825 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
826 ))
827 .await;
828
829 assert!(
830 matches!(out, EngineOutcome::Failed(_)),
831 "4xx is a hard error, not failover"
832 );
833 assert_eq!(
834 trace.attempts.len(),
835 1,
836 "must not escalate past a client error"
837 );
838 assert_eq!(trace.final_.served_from, ServedFrom::Error);
839 }
840
841 #[tokio::test]
842 async fn counterfactual_and_savings_math() {
843 let ladder = vec![HAIKU.to_owned(), OPUS.to_owned()];
844 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
845 let req = base_request();
846 let served = resp(HAIKU, "answer");
847 let (in_t, out_t) = (served.in_tokens, served.out_tokens);
848 let providers = registry(vec![("anthropic", HAIKU, Ok(served))]);
849 let (auth, prices) = (Auth::default(), PriceTable::defaults());
850 let health = GateHealthRegistry::new();
851 let (_out, trace) = route_enforce(ctx(
852 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
853 ))
854 .await;
855
856 let expected_baseline = prices.cost_usd(OPUS, in_t, out_t).unwrap();
857 assert!((trace.final_.counterfactual_baseline_usd - expected_baseline).abs() < 1e-12);
858 let expected_savings = expected_baseline - trace.final_.total_cost_usd;
859 assert!((trace.final_.savings_usd - expected_savings).abs() < 1e-12);
860 assert!(trace.final_.savings_usd > 0.0);
861 }
862
863 #[tokio::test]
864 async fn produced_trace_is_chain_verifiable() {
865 let ladder = vec![HAIKU.to_owned()];
866 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
867 let req = base_request();
868 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "ok")))]);
869 let (auth, prices) = (Auth::default(), PriceTable::defaults());
870 let health = GateHealthRegistry::new();
871 let (_out, trace) = route_enforce(ctx(
872 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
873 ))
874 .await;
875
876 assert!(firstpass_core::verify_chain(std::slice::from_ref(&trace), GENESIS_HASH).is_ok());
878 let json = serde_json::to_string(&trace).unwrap();
880 let _back: Trace = serde_json::from_str(&json).unwrap();
881 }
882
883 #[tokio::test]
884 async fn auto_disabled_gate_is_skipped_by_the_engine() {
885 let ladder = vec![HAIKU.to_owned()];
888 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
889 let req = base_request();
890 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "")))]); let (auth, prices) = (Auth::default(), PriceTable::defaults());
892
893 let health = GateHealthRegistry::new().with_budget("non-empty", 4, 0.5);
896 for _ in 0..4 {
897 health.record("acme", "non-empty", true);
898 }
899 assert!(
900 !health.enabled("acme", "non-empty"),
901 "precondition: gate is auto-disabled"
902 );
903
904 let (out, trace) = route_enforce(ctx(
905 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
906 ))
907 .await;
908 assert!(matches!(out, EngineOutcome::Served(_)));
909 assert_eq!(trace.final_.served_rung, Some(0));
910 assert!(
911 trace.attempts[0].gates.is_empty(),
912 "disabled gate must be skipped, not run"
913 );
914 }
915
916 fn counted_registry(
919 outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
920 ) -> (
921 ProviderRegistry,
922 std::sync::Arc<std::sync::Mutex<Vec<String>>>,
923 ) {
924 let mut outs = HashMap::new();
925 for (model, out) in outcomes {
926 outs.insert(model.to_owned(), out);
927 }
928 let mock = MockProvider::new("anthropic", outs);
929 let log = mock.call_log();
930 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
931 map.insert("anthropic".to_owned(), Arc::new(mock));
932 (ProviderRegistry::from_map(map), log)
933 }
934
935 #[tokio::test]
936 async fn speculation_prefetches_next_rung_but_serves_identically() {
937 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
938 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
939 let req = base_request();
940 let (auth, prices) = (Auth::default(), PriceTable::defaults());
941
942 let (providers, log) = counted_registry(vec![
944 (HAIKU, Ok(resp(HAIKU, "answer"))),
945 (SONNET, Ok(resp(SONNET, "other"))),
946 ]);
947 let health = GateHealthRegistry::new();
948 let (serial_out, serial_trace) = route_enforce(ctx(
949 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
950 ))
951 .await;
952 assert_eq!(
953 *log.lock().unwrap(),
954 vec![HAIKU.to_owned()],
955 "serial must not touch rung 1 when rung 0 passes"
956 );
957
958 let (providers, log) = counted_registry(vec![
960 (HAIKU, Ok(resp(HAIKU, "answer"))),
961 (SONNET, Ok(resp(SONNET, "other"))),
962 ]);
963 let health = GateHealthRegistry::new();
964 let mut c = ctx(
965 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
966 );
967 c.speculation = 1;
968 let (spec_out, spec_trace) = route_enforce(c).await;
969
970 assert!(
971 log.lock().unwrap().contains(&SONNET.to_owned()),
972 "speculation must fire rung 1 ahead: {:?}",
973 *log.lock().unwrap()
974 );
975
976 let (a, b) = match (serial_out, spec_out) {
978 (EngineOutcome::Served(a), EngineOutcome::Served(b)) => (a, b),
979 _ => panic!("both variants must serve"),
980 };
981 assert_eq!(
982 (a.model, a.text, a.out_tokens),
983 (b.model, b.text, b.out_tokens)
984 );
985 assert_eq!(spec_trace.final_.served_rung, Some(0));
986 assert_eq!(spec_trace.attempts.len(), 1, "only rung 0 is gated");
987 assert!(
989 spec_trace.final_.total_cost_usd > serial_trace.final_.total_cost_usd,
990 "speculative waste must show in total cost: spec={} serial={}",
991 spec_trace.final_.total_cost_usd,
992 serial_trace.final_.total_cost_usd
993 );
994 }
995
996 #[tokio::test]
997 async fn speculation_preserves_escalation_result() {
998 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
999 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1000 let req = base_request();
1001 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1002 let (providers, _log) = counted_registry(vec![
1004 (HAIKU, Ok(resp(HAIKU, ""))),
1005 (SONNET, Ok(resp(SONNET, "real answer"))),
1006 ]);
1007 let health = GateHealthRegistry::new();
1008 let mut c = ctx(
1009 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1010 );
1011 c.speculation = 2; let (out, trace) = route_enforce(c).await;
1013 match out {
1014 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1015 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
1016 }
1017 assert_eq!(trace.final_.served_rung, Some(1));
1018 assert_eq!(trace.attempts.len(), 2);
1019 assert_eq!(trace.final_.escalations, 1);
1020 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
1021 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1022 }
1023
1024 #[tokio::test]
1030 async fn latency_ab_speculative_beats_serial_p95() {
1031 const DELAY: u64 = 40;
1032 const N: usize = 30;
1033 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1034 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1035 let req = base_request();
1036 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1037
1038 fn pctl(sorted: &[u64], p: f64) -> u64 {
1039 let i = (((sorted.len() - 1) as f64) * p).round() as usize;
1040 sorted[i]
1041 }
1042
1043 let mut serial = Vec::with_capacity(N);
1044 let mut spec = Vec::with_capacity(N);
1045 for run in 0..(2 * N) {
1046 let speculation = u32::from(run >= N); let mut outs: HashMap<String, Result<ModelResponse, ProviderError>> = HashMap::new();
1048 outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "ok")));
1050 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1051 map.insert(
1052 "anthropic".to_owned(),
1053 Arc::new(MockProvider::new("anthropic", outs).with_delay(DELAY)),
1054 );
1055 let providers = ProviderRegistry::from_map(map);
1056 let health = GateHealthRegistry::new();
1057 let mut c = ctx(
1058 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1059 );
1060 c.speculation = speculation;
1061 let start = std::time::Instant::now();
1062 let _ = route_enforce(c).await;
1063 let ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1064 if speculation == 0 {
1065 serial.push(ms);
1066 } else {
1067 spec.push(ms);
1068 }
1069 }
1070 serial.sort_unstable();
1071 spec.sort_unstable();
1072 println!(
1073 "latency A/B (per-rung {DELAY}ms, escalate every request):\n serial p50={} p95={} p99={}\n spec(k=1) p50={} p95={} p99={}",
1074 pctl(&serial, 0.5),
1075 pctl(&serial, 0.95),
1076 pctl(&serial, 0.99),
1077 pctl(&spec, 0.5),
1078 pctl(&spec, 0.95),
1079 pctl(&spec, 0.99),
1080 );
1081 assert!(
1083 pctl(&spec, 0.95) * 4 < pctl(&serial, 0.95) * 3,
1084 "spec p95 {}ms should beat serial p95 {}ms by >25%",
1085 pctl(&spec, 0.95),
1086 pctl(&serial, 0.95)
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn speculation_never_fires_past_max_rungs() {
1092 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1093 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1094 let req = base_request();
1095 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1096 let (providers, log) = counted_registry(vec![
1098 (HAIKU, Ok(resp(HAIKU, ""))),
1099 (SONNET, Ok(resp(SONNET, ""))),
1100 (OPUS, Ok(resp(OPUS, ""))),
1101 ]);
1102 let health = GateHealthRegistry::new();
1103 let mut c = ctx(
1104 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1105 );
1106 c.max_rungs = 2;
1107 c.speculation = 5; let (out, trace) = route_enforce(c).await;
1109
1110 assert!(
1111 !log.lock().unwrap().contains(&OPUS.to_owned()),
1112 "must not fire beyond max_rungs: {:?}",
1113 *log.lock().unwrap()
1114 );
1115 assert_eq!(trace.attempts.len(), 2);
1116 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
1117 assert_eq!(trace.final_.served_rung, Some(1));
1118 match out {
1119 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1120 EngineOutcome::Failed(e) => panic!("expected best-attempt served, got {e}"),
1121 }
1122 }
1123
1124 #[tokio::test]
1125 async fn speculation_cuts_wall_clock_vs_serial() {
1126 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1131 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1132 let req = base_request();
1133 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1134
1135 let build = || {
1136 let mut outs = HashMap::new();
1137 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);
1140 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1141 map.insert("anthropic".to_owned(), Arc::new(mock));
1142 ProviderRegistry::from_map(map)
1143 };
1144
1145 let providers = build();
1146 let health = GateHealthRegistry::new();
1147 let t = std::time::Instant::now();
1148 let _ = route_enforce(ctx(
1149 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1150 ))
1151 .await;
1152 let serial = t.elapsed();
1153
1154 let providers = build();
1155 let health = GateHealthRegistry::new();
1156 let mut c = ctx(
1157 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1158 );
1159 c.speculation = 1;
1160 let t = std::time::Instant::now();
1161 let _ = route_enforce(c).await;
1162 let spec = t.elapsed();
1163
1164 assert!(
1165 spec < serial * 3 / 4,
1166 "speculation must overlap rung latencies: serial={serial:?} spec={spec:?}"
1167 );
1168 }
1169
1170 #[derive(Debug)]
1173 struct ScoreGate;
1174
1175 #[async_trait::async_trait]
1176 impl Gate for ScoreGate {
1177 fn id(&self) -> &str {
1178 "score"
1179 }
1180
1181 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
1182 let score = resp.text.trim().parse::<f64>().unwrap_or(0.0);
1183 GateResult {
1184 gate_id: self.id().to_owned(),
1185 verdict: Verdict::Pass,
1186 score: Some(firstpass_core::Score::clamped(score)),
1187 cost_usd: 0.0,
1188 ms: 0,
1189 reason: None,
1190 evidence_ref: None,
1191 }
1192 }
1193 }
1194
1195 #[tokio::test]
1196 async fn serve_threshold_escalates_past_low_scoring_rung() {
1197 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1200 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1201 let req = base_request();
1202 let providers = registry(vec![
1203 ("anthropic", HAIKU, Ok(resp(HAIKU, "0.5"))),
1204 ("anthropic", SONNET, Ok(resp(SONNET, "0.9"))),
1205 ]);
1206 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1207 let health = GateHealthRegistry::new();
1208 let mut c = ctx(
1209 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1210 );
1211 c.serve_threshold = Some(0.8);
1212 let (out, trace) = route_enforce(c).await;
1213
1214 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1215 assert_eq!(trace.attempts.len(), 2);
1216 assert_eq!(trace.attempts[0].verdict, Verdict::Pass);
1218 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1219 assert_eq!(trace.final_.escalations, 1);
1220 assert_eq!(trace.final_.served_rung, Some(1));
1221 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1222 }
1223
1224 #[tokio::test]
1225 async fn serve_threshold_does_not_serve_a_pass_below_threshold() {
1226 let ladder = vec![HAIKU.to_owned()];
1230 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1231 let req = base_request();
1232 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.3")))]);
1233 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1234 let health = GateHealthRegistry::new();
1235 let mut c = ctx(
1236 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1237 );
1238 c.serve_threshold = Some(0.8);
1239 let (out, trace) = route_enforce(c).await;
1240
1241 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1242 assert_eq!(
1243 trace.attempts[0].verdict,
1244 Verdict::Pass,
1245 "gate verdict was Pass"
1246 );
1247 assert_eq!(
1248 trace.final_.served_from,
1249 ServedFrom::BestAttempt,
1250 "score below threshold must fall back, not serve as a normal pass"
1251 );
1252 }
1253
1254 #[tokio::test]
1255 async fn serve_threshold_none_serves_on_verdict_regardless_of_score() {
1256 let ladder = vec![HAIKU.to_owned()];
1259 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1260 let req = base_request();
1261 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.1")))]);
1262 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1263 let health = GateHealthRegistry::new();
1264 let c = ctx(
1265 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1266 );
1267 assert_eq!(c.serve_threshold, None);
1268 let (out, trace) = route_enforce(c).await;
1269
1270 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1271 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1272 }
1273
1274 #[tokio::test]
1279 async fn bandit_off_start_rung_zero_is_byte_identical() {
1280 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1281 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1282 let req = base_request();
1283 let (providers, log) = counted_registry(vec![
1284 (HAIKU, Ok(resp(HAIKU, "answer"))),
1285 (SONNET, Ok(resp(SONNET, "other"))),
1286 ]);
1287 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1288 let health = GateHealthRegistry::new();
1289 let c = ctx(
1290 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1291 );
1292 assert_eq!(c.start_rung, 0, "default start_rung must be 0 (bandit off)");
1293 let (out, trace) = route_enforce(c).await;
1294 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1296 assert_eq!(trace.final_.served_rung, Some(0));
1297 assert_eq!(*log.lock().unwrap(), vec![HAIKU.to_owned()]);
1298 }
1299
1300 #[tokio::test]
1302 async fn start_rung_1_skips_rung_0_and_serves_rung_1() {
1303 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1304 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1305 let req = base_request();
1306 let (providers, log) = counted_registry(vec![
1307 (HAIKU, Ok(resp(HAIKU, "haiku answer"))),
1308 (SONNET, Ok(resp(SONNET, "sonnet answer"))),
1309 ]);
1310 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1311 let health = GateHealthRegistry::new();
1312 let mut c = ctx(
1313 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1314 );
1315 c.start_rung = 1; let (out, trace) = route_enforce(c).await;
1317
1318 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1320 assert_eq!(trace.final_.served_rung, Some(1));
1321 assert_eq!(trace.attempts.len(), 1, "only rung 1 attempted");
1322 assert_eq!(trace.attempts[0].rung, 1);
1323 assert!(
1324 !log.lock().unwrap().contains(&HAIKU.to_owned()),
1325 "rung 0 must never fire when start_rung=1: {:?}",
1326 *log.lock().unwrap()
1327 );
1328 }
1329
1330 #[tokio::test]
1332 async fn start_rung_1_escalates_to_rung_2_on_gate_fail() {
1333 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1334 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1335 let req = base_request();
1336 let (providers, log) = counted_registry(vec![
1338 (HAIKU, Ok(resp(HAIKU, "haiku"))),
1339 (SONNET, Ok(resp(SONNET, ""))), (OPUS, Ok(resp(OPUS, "real answer"))),
1341 ]);
1342 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1343 let health = GateHealthRegistry::new();
1344 let mut c = ctx(
1345 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1346 );
1347 c.start_rung = 1;
1348 let (out, trace) = route_enforce(c).await;
1349
1350 assert!(matches!(out, EngineOutcome::Served(r) if r.model == OPUS));
1351 assert_eq!(trace.final_.served_rung, Some(2));
1352 assert_eq!(trace.attempts.len(), 2); assert_eq!(trace.attempts[0].rung, 1);
1354 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
1355 assert_eq!(trace.attempts[1].rung, 2);
1356 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1357 assert!(
1359 !log.lock().unwrap().contains(&HAIKU.to_owned()),
1360 "rung 0 must not fire with start_rung=1"
1361 );
1362 }
1363
1364 #[tokio::test]
1367 async fn max_rungs_respected_with_nonzero_start_rung() {
1368 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1369 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1370 let req = base_request();
1371 let (providers, _log) = counted_registry(vec![
1373 (HAIKU, Ok(resp(HAIKU, ""))),
1374 (SONNET, Ok(resp(SONNET, ""))),
1375 (OPUS, Ok(resp(OPUS, ""))),
1376 ]);
1377 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1378 let health = GateHealthRegistry::new();
1379 let mut c = ctx(
1380 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1381 );
1382 c.start_rung = 1;
1383 c.max_rungs = 1;
1384 let (_out, trace) = route_enforce(c).await;
1385
1386 assert_eq!(
1387 trace.attempts.len(),
1388 1,
1389 "max_rungs=1 must limit to one attempt even with start_rung=1"
1390 );
1391 assert_eq!(trace.attempts[0].rung, 1, "the one attempt must be rung 1");
1392 }
1393
1394 #[tokio::test]
1397 async fn invariant_failed_start_rung_never_served_escalates_to_passing_rung() {
1398 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1399 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1400 let req = base_request();
1401 let (providers, _log) = counted_registry(vec![
1402 (HAIKU, Ok(resp(HAIKU, "haiku"))),
1403 (SONNET, Ok(resp(SONNET, " "))), (OPUS, Ok(resp(OPUS, "the correct answer"))),
1405 ]);
1406 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1407 let health = GateHealthRegistry::new();
1408 let mut c = ctx(
1409 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1410 );
1411 c.start_rung = 1;
1412 let (out, trace) = route_enforce(c).await;
1413
1414 match out {
1416 EngineOutcome::Served(r) => {
1417 assert_eq!(r.model, OPUS, "must serve the passing rung's model");
1418 assert_eq!(
1419 r.text, "the correct answer",
1420 "served text must be from the passing rung"
1421 );
1422 }
1423 EngineOutcome::Failed(e) => panic!("expected served answer, got error: {e}"),
1424 }
1425 assert_eq!(trace.final_.served_rung, Some(2));
1426 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1427 assert_eq!(trace.attempts[0].rung, 1);
1429 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
1430 }
1431
1432 #[derive(Debug)]
1434 struct AbstainGate {
1435 fail_closed: bool,
1436 }
1437
1438 #[async_trait::async_trait]
1439 impl Gate for AbstainGate {
1440 fn id(&self) -> &str {
1441 "flaky"
1442 }
1443 async fn evaluate(&self, _req: &ModelRequest, _resp: &ModelResponse) -> GateResult {
1444 GateResult::abstain(self.id(), "timeout", 0)
1445 }
1446 fn abstain_fails_closed(&self) -> bool {
1447 self.fail_closed
1448 }
1449 }
1450
1451 #[tokio::test]
1452 async fn fail_open_abstain_serves_fail_closed_abstain_escalates() {
1453 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1454 let req = base_request();
1455 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1456 let health = GateHealthRegistry::new();
1457
1458 let gates: Vec<Box<dyn Gate>> = vec![Box::new(AbstainGate { fail_closed: false })];
1460 let providers = registry(vec![
1461 ("anthropic", HAIKU, Ok(resp(HAIKU, "cheap answer"))),
1462 ("anthropic", SONNET, Ok(resp(SONNET, "expensive answer"))),
1463 ]);
1464 let (out, trace) = route_enforce(ctx(
1465 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1466 ))
1467 .await;
1468 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1469 assert_eq!(trace.final_.served_rung, Some(0));
1470
1471 let gates: Vec<Box<dyn Gate>> = vec![Box::new(AbstainGate { fail_closed: true })];
1474 let providers = registry(vec![
1475 ("anthropic", HAIKU, Ok(resp(HAIKU, "cheap answer"))),
1476 ("anthropic", SONNET, Ok(resp(SONNET, "expensive answer"))),
1477 ]);
1478 let (out, trace) = route_enforce(ctx(
1479 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1480 ))
1481 .await;
1482 assert_eq!(trace.attempts.len(), 2, "fail-closed abstain must escalate");
1485 assert_eq!(trace.final_.escalations, 1);
1486 assert_eq!(
1487 trace.attempts[0].gates[0].verdict,
1488 Verdict::Abstain,
1489 "receipt records the honest abstain, not a rewritten Fail"
1490 );
1491 assert!(matches!(out, EngineOutcome::Served(_)));
1492 }
1493}