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, ElasticAction, ElasticDecision, Features, FinalOutcome, GENESIS_HASH, GateResult,
15 Mode, ModelRef, PolicyRef, 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 elastic: Option<&'a firstpass_core::config::ElasticConfig>,
68 pub features: Features,
70 pub start_rung: u32,
78 pub tenant_id: String,
80 pub session_id: String,
82 pub prompt_hash: String,
84 pub api: String,
86 pub policy_id: String,
88}
89
90pub async fn route_enforce(ctx: EnforceCtx<'_>) -> (EngineOutcome, Trace) {
95 let LadderRun {
98 attempts,
99 spent,
100 gate_cost_total,
101 best,
102 mut served_rung,
103 hard_error,
104 elastic,
105 } = if ctx.speculation == 0 {
106 run_serial(&ctx).await
107 } else {
108 run_speculative(&ctx).await
109 };
110
111 let (outcome, served_from, served_tokens) = match (served_rung, &best) {
113 (Some(_), Some((_, resp))) => (
114 EngineOutcome::Served(resp.clone()),
115 ServedFrom::Attempt,
116 (resp.in_tokens, resp.out_tokens),
117 ),
118 (None, Some((idx, resp))) => {
119 served_rung = Some(*idx);
121 (
122 EngineOutcome::Served(resp.clone()),
123 ServedFrom::BestAttempt,
124 (resp.in_tokens, resp.out_tokens),
125 )
126 }
127 (_, None) => {
128 let msg = hard_error.unwrap_or_else(|| "all rungs failed".to_owned());
129 (EngineOutcome::Failed(msg), ServedFrom::Error, (0, 0))
130 }
131 };
132
133 let top_model = ctx.ladder.last().map(String::as_str).unwrap_or_default();
135 let baseline = ctx
136 .prices
137 .cost_usd(top_model, served_tokens.0, served_tokens.1)
138 .unwrap_or(spent);
139
140 let total_latency_ms = attempts.iter().map(|a| a.latency_ms).sum();
141 let escalations = attempts.len().saturating_sub(1) as u32;
142
143 let mut trace = Trace {
144 trace_id: Uuid::now_v7(),
145 prev_hash: GENESIS_HASH.to_owned(),
146 tenant_id: ctx.tenant_id,
147 session_id: ctx.session_id,
148 ts: Timestamp::now(),
149 mode: Mode::Enforce,
150 policy: PolicyRef {
151 id: ctx.policy_id,
152 explore: false,
153 propensity: None, mode_profile: None, },
156 request: RequestInfo {
157 api: ctx.api,
158 prompt_hash: ctx.prompt_hash,
159 features: ctx.features,
160 },
161 attempts,
162 deferred: vec![],
163 final_: FinalOutcome {
164 served_rung,
165 served_from,
166 total_cost_usd: spent,
167 gate_cost_usd: gate_cost_total,
168 total_latency_ms,
169 escalations,
170 counterfactual_baseline_usd: baseline,
171 savings_usd: 0.0,
172 },
173 probe: None,
174 rollout: None,
175 shadow: None,
176 predicted_pass: None,
177 elastic,
178 };
179 trace.recompute_savings();
180 (outcome, trace)
181}
182
183struct LadderRun {
187 attempts: Vec<Attempt>,
188 spent: f64,
189 gate_cost_total: f64,
190 best: Option<(u32, ModelResponse)>,
191 served_rung: Option<u32>,
192 hard_error: Option<String>,
193 elastic: Option<ElasticDecision>,
196}
197
198fn should_serve(
208 serve_threshold: Option<f64>,
209 gate_results: &[GateResult],
210 verdict: Verdict,
211) -> bool {
212 match serve_threshold {
213 None => verdict == Verdict::Pass,
214 Some(t) => gate_score(gate_results, verdict) >= t,
215 }
216}
217
218async fn eval_gates(
224 ctx: &EnforceCtx<'_>,
225 req: &ModelRequest,
226 resp: &ModelResponse,
227 include: impl Fn(&str) -> bool,
228) -> Vec<GateResult> {
229 let mut out: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
230 for g in ctx.gates {
231 if !include(g.id()) {
232 continue;
233 }
234 if !ctx.health.enabled(&ctx.tenant_id, g.id()) {
235 tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
236 continue;
237 }
238 let r = g.evaluate(req, resp).await;
239 ctx.health
240 .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
241 out.push(r);
242 }
243 out
244}
245
246async fn run_serial(ctx: &EnforceCtx<'_>) -> LadderRun {
249 let mut attempts: Vec<Attempt> = Vec::new();
250 let mut spent = 0.0_f64;
251 let mut gate_cost_total = 0.0_f64;
252 let mut best: Option<(u32, ModelResponse)> = None;
253 let mut served_rung: Option<u32> = None;
254 let mut hard_error: Option<String> = None;
255 let mut last_elastic: Option<ElasticDecision> = None;
258
259 let start = (ctx.start_rung as usize).min(ctx.ladder.len().saturating_sub(1));
260 let rungs_available = ctx.ladder.len().saturating_sub(start);
263 let rung_end = start + (ctx.max_rungs as usize).min(rungs_available);
264 for (i, model_str) in ctx.ladder[start..rung_end].iter().enumerate() {
265 let idx = (start + i) as u32;
266 let start = Instant::now();
267
268 let provider = match ModelRef::parse(model_str) {
271 Ok(m) => ctx.providers.get(&m.provider),
272 Err(_) => None,
273 };
274 let Some(provider) = provider else {
275 let ms = elapsed_ms(start);
276 attempts.push(abstain_attempt(
277 idx,
278 model_str,
279 "unknown",
280 reason::PROVIDER_ERROR,
281 ms,
282 ));
283 continue;
284 };
285
286 let mut req = ctx.base_request.clone();
287 req.model = model_str.clone();
288
289 match provider.complete(&req, ctx.auth).await {
290 Err(err) if err.is_failover_eligible() => {
291 let ms = elapsed_ms(start);
293 attempts.push(abstain_attempt(
294 idx,
295 model_str,
296 provider.id(),
297 reason::PROVIDER_ERROR,
298 ms,
299 ));
300 continue;
301 }
302 Err(err) => {
303 let ms = elapsed_ms(start);
305 let (r, msg) = hard_reason(&err);
306 attempts.push(abstain_attempt(idx, model_str, provider.id(), r, ms));
307 hard_error = Some(msg);
308 break;
309 }
310 Ok(resp) => {
311 let ms = elapsed_ms(start);
312 let model_cost = ctx
313 .prices
314 .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
315 .unwrap_or(0.0);
316 spent += model_cost;
317
318 let expensive: &[String] = ctx
321 .elastic
322 .map_or(&[][..], |e| e.expensive_gates.as_slice());
323 let is_expensive = |id: &str| expensive.iter().any(|x| x == id);
324
325 let fail_closed: std::collections::HashSet<&str> = ctx
326 .gates
327 .iter()
328 .filter(|g| g.abstain_fails_closed())
329 .map(|g| g.id())
330 .collect();
331
332 let mut gate_results = eval_gates(ctx, &req, &resp, |id| !is_expensive(id)).await;
335
336 let mut elastic_decision: Option<ElasticDecision> = None;
339 if let Some(el) = ctx.elastic {
340 let vis_verdict = aggregate_with_policy(&gate_results, &fail_closed);
341 let signal = gate_score(&gate_results, vis_verdict);
342 let action = if signal >= el.lambda {
343 ElasticAction::ServeSkip
346 } else if signal <= 0.0 {
347 ElasticAction::EscalateNow
349 } else {
350 let mut exp = eval_gates(ctx, &req, &resp, &is_expensive).await;
352 gate_results.append(&mut exp);
353 ElasticAction::Verified
354 };
355 elastic_decision = Some(ElasticDecision {
356 action,
357 signal,
358 lambda: el.lambda,
359 alpha: el.alpha,
360 delta: el.delta,
361 calibration_id: el.calibration_id.clone(),
362 });
363 }
364
365 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
366 gate_cost_total += gc;
367 spent += gc;
368
369 let verdict = aggregate_with_policy(&gate_results, &fail_closed);
370 let serve = if matches!(
374 elastic_decision.as_ref().map(|d| d.action),
375 Some(ElasticAction::ServeSkip)
376 ) {
377 true
378 } else {
379 should_serve(ctx.serve_threshold, &gate_results, verdict)
380 };
381 last_elastic = elastic_decision;
382 attempts.push(Attempt {
383 rung: idx,
384 model: model_str.clone(),
385 provider: provider.id().to_owned(),
386 in_tokens: resp.in_tokens,
387 out_tokens: resp.out_tokens,
388 cost_usd: model_cost,
389 latency_ms: ms,
390 gates: gate_results,
391 verdict,
392 });
393 best = Some((idx, resp));
394
395 if serve {
396 served_rung = Some(idx);
397 break;
398 }
399 if let Some(cap) = ctx.budget_per_request_usd
401 && spent >= cap
402 && (idx as usize) + 1 < rung_end
403 {
404 break;
405 }
406 }
407 }
408 }
409
410 LadderRun {
411 attempts,
412 spent,
413 gate_cost_total,
414 best,
415 served_rung,
416 hard_error,
417 elastic: last_elastic,
418 }
419}
420
421async fn run_speculative(ctx: &EnforceCtx<'_>) -> LadderRun {
426 let mut attempts: Vec<Attempt> = Vec::new();
427 let mut spent = 0.0_f64;
428 let mut gate_cost_total = 0.0_f64;
429 let mut best: Option<(u32, ModelResponse)> = None;
430 let mut served_rung: Option<u32> = None;
431 let mut hard_error: Option<String> = None;
432
433 let start = (ctx.start_rung as usize).min(ctx.ladder.len().saturating_sub(1));
434 let rungs_available = ctx.ladder.len().saturating_sub(start);
436 let rung_end = start + (ctx.max_rungs as usize).min(rungs_available);
437 let speculation = ctx.speculation as usize;
438 let mut inflight: HashMap<usize, JoinHandle<Result<ModelResponse, ProviderError>>> =
439 HashMap::new();
440
441 let mut idx = start;
442 let mut done = false;
444 while idx < rung_end && !done {
445 let window_end = (idx + speculation).min(rung_end - 1);
448 for j in idx..=window_end {
449 if inflight.contains_key(&j) {
450 continue;
451 }
452 if j > idx
453 && let Some(cap) = ctx.budget_per_request_usd
454 && spent >= cap
455 {
456 continue;
457 }
458 if let Some(handle) = spawn_rung(ctx, j) {
459 inflight.insert(j, handle);
460 }
461 }
462
463 let model_str = &ctx.ladder[idx];
464 let provider = match ModelRef::parse(model_str) {
465 Ok(m) => ctx.providers.get(&m.provider),
466 Err(_) => None,
467 };
468 let Some(provider) = provider else {
469 attempts.push(abstain_attempt(
471 idx as u32,
472 model_str,
473 "unknown",
474 reason::PROVIDER_ERROR,
475 0,
476 ));
477 idx += 1;
478 continue;
479 };
480 let Some(handle) = inflight.remove(&idx) else {
482 attempts.push(abstain_attempt(
483 idx as u32,
484 model_str,
485 provider.id(),
486 reason::PROVIDER_ERROR,
487 0,
488 ));
489 idx += 1;
490 continue;
491 };
492 let t0 = Instant::now();
493 let joined = handle.await;
494 let ms = elapsed_ms(t0);
495
496 match joined {
497 Err(_) => {
499 attempts.push(abstain_attempt(
500 idx as u32,
501 model_str,
502 provider.id(),
503 reason::PROVIDER_ERROR,
504 ms,
505 ));
506 idx += 1;
507 }
508 Ok(Err(err)) if err.is_failover_eligible() => {
509 attempts.push(abstain_attempt(
510 idx as u32,
511 model_str,
512 provider.id(),
513 reason::PROVIDER_ERROR,
514 ms,
515 ));
516 idx += 1;
517 }
518 Ok(Err(err)) => {
519 let (r, msg) = hard_reason(&err);
521 attempts.push(abstain_attempt(idx as u32, model_str, provider.id(), r, ms));
522 hard_error = Some(msg);
523 done = true;
524 }
525 Ok(Ok(resp)) => {
526 let model_cost = ctx
527 .prices
528 .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
529 .unwrap_or(0.0);
530 spent += model_cost;
531
532 let mut req = ctx.base_request.clone();
533 req.model = model_str.clone();
534 let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
535 for g in ctx.gates {
536 if !ctx.health.enabled(&ctx.tenant_id, g.id()) {
537 tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
538 continue;
539 }
540 let r = g.evaluate(&req, &resp).await;
541 ctx.health
542 .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
543 gate_results.push(r);
544 }
545 let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
546 gate_cost_total += gc;
547 spent += gc;
548
549 let fail_closed: std::collections::HashSet<&str> = ctx
550 .gates
551 .iter()
552 .filter(|g| g.abstain_fails_closed())
553 .map(|g| g.id())
554 .collect();
555 let verdict = aggregate_with_policy(&gate_results, &fail_closed);
556 let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
557 attempts.push(Attempt {
558 rung: idx as u32,
559 model: model_str.clone(),
560 provider: provider.id().to_owned(),
561 in_tokens: resp.in_tokens,
562 out_tokens: resp.out_tokens,
563 cost_usd: model_cost,
564 latency_ms: ms,
565 gates: gate_results,
566 verdict,
567 });
568 best = Some((idx as u32, resp));
569
570 if serve {
571 served_rung = Some(idx as u32);
572 done = true;
573 } else if let Some(cap) = ctx.budget_per_request_usd
574 && spent >= cap
575 && idx + 1 < rung_end
576 {
577 done = true;
578 }
579 idx += 1;
580 }
581 }
582 }
583
584 for (j, handle) in inflight.drain() {
589 if handle.is_finished() {
590 if let Ok(Ok(resp)) = handle.await {
591 spent += ctx
592 .prices
593 .cost_usd(&ctx.ladder[j], resp.in_tokens, resp.out_tokens)
594 .unwrap_or(0.0);
595 }
596 } else {
597 handle.abort();
598 }
599 }
600
601 LadderRun {
602 attempts,
603 spent,
604 gate_cost_total,
605 best,
606 served_rung,
607 hard_error,
608 elastic: None,
611 }
612}
613
614fn spawn_rung(
617 ctx: &EnforceCtx<'_>,
618 j: usize,
619) -> Option<JoinHandle<Result<ModelResponse, ProviderError>>> {
620 let model_str = ctx.ladder.get(j)?;
621 let provider = match ModelRef::parse(model_str) {
622 Ok(m) => ctx.providers.get(&m.provider)?,
623 Err(_) => return None,
624 };
625 let mut req = ctx.base_request.clone();
626 req.model = model_str.clone();
627 let auth = ctx.auth.clone();
628 Some(tokio::spawn(
629 async move { provider.complete(&req, &auth).await },
630 ))
631}
632
633fn elapsed_ms(start: Instant) -> u64 {
634 u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
635}
636
637fn abstain_attempt(rung: u32, model: &str, provider: &str, reason: &str, ms: u64) -> Attempt {
639 Attempt {
640 rung,
641 model: model.to_owned(),
642 provider: provider.to_owned(),
643 in_tokens: 0,
644 out_tokens: 0,
645 cost_usd: 0.0,
646 latency_ms: ms,
647 gates: vec![GateResult::abstain(provider, reason, ms)],
648 verdict: Verdict::Abstain,
649 }
650}
651
652fn hard_reason(err: &ProviderError) -> (&'static str, String) {
654 match err {
655 ProviderError::Http { status, .. } => {
656 (reason::PROVIDER_ERROR, format!("upstream http {status}"))
657 }
658 ProviderError::Decode(_) => (reason::PROVIDER_ERROR, "upstream decode error".to_owned()),
659 ProviderError::Transport(_) => (
660 reason::PROVIDER_ERROR,
661 "upstream transport error".to_owned(),
662 ),
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669 use crate::gate::{JsonValidGate, NonEmptyGate};
670 use crate::provider::{MockProvider, Provider};
671 use serde_json::Value;
672 use std::collections::HashMap;
673 use std::sync::Arc;
674
675 const HAIKU: &str = "anthropic/claude-haiku-4-5";
676 const SONNET: &str = "anthropic/claude-sonnet-5";
677 const OPUS: &str = "anthropic/claude-opus-4-8";
678 const GPT: &str = "openai/gpt-5.5";
679
680 fn resp(model: &str, text: &str) -> ModelResponse {
681 ModelResponse {
682 model: model.to_owned(),
683 text: text.to_owned(),
684 in_tokens: 1000,
685 out_tokens: 500,
686 raw: Value::Null,
687 }
688 }
689
690 fn base_request() -> ModelRequest {
691 ModelRequest {
692 model: String::new(),
693 system: None,
694 messages: vec![crate::provider::ChatMessage {
695 role: "user".into(),
696 content: "hi".into(),
697 }],
698 max_tokens: 256,
699 tools: Value::Null,
700 raw: Value::Null,
701 }
702 }
703
704 fn registry(
706 outcomes: Vec<(&str, &str, Result<ModelResponse, ProviderError>)>,
707 ) -> ProviderRegistry {
708 let mut by_provider: HashMap<
709 String,
710 HashMap<String, Result<ModelResponse, ProviderError>>,
711 > = HashMap::new();
712 for (provider, model, out) in outcomes {
713 by_provider
714 .entry(provider.to_owned())
715 .or_default()
716 .insert(model.to_owned(), out);
717 }
718 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
719 for (pid, outs) in by_provider {
720 map.insert(pid.clone(), Arc::new(MockProvider::new(pid, outs)));
721 }
722 ProviderRegistry::from_map(map)
723 }
724
725 #[allow(clippy::too_many_arguments)]
726 fn ctx<'a>(
727 ladder: &'a [String],
728 gates: &'a [Box<dyn Gate>],
729 req: &'a ModelRequest,
730 providers: &'a ProviderRegistry,
731 auth: &'a Auth,
732 prices: &'a PriceTable,
733 budget: Option<f64>,
734 health: &'a GateHealthRegistry,
735 ) -> EnforceCtx<'a> {
736 EnforceCtx {
737 ladder,
738 gates,
739 health,
740 base_request: req,
741 providers,
742 auth,
743 prices,
744 budget_per_request_usd: budget,
745 max_rungs: 3,
746 speculation: 0,
747 serve_threshold: None,
748 elastic: None,
749 features: Features::new(firstpass_core::TaskKind::CodeEdit),
750 start_rung: 0,
751 tenant_id: "acme".into(),
752 session_id: "sess-1".into(),
753 prompt_hash: "deadbeef".into(),
754 api: "anthropic.messages".into(),
755 policy_id: "static-ladder@v0".into(),
756 }
757 }
758
759 #[tokio::test]
760 async fn serve_first_pass_no_escalation_with_savings() {
761 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
762 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
763 let req = base_request();
764 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, r#"{"ok":1}"#)))]);
765 let (auth, prices) = (Auth::default(), PriceTable::defaults());
766 let health = GateHealthRegistry::new();
767 let (out, trace) = route_enforce(ctx(
768 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
769 ))
770 .await;
771
772 match out {
773 EngineOutcome::Served(r) => assert_eq!(r.model, HAIKU),
774 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
775 }
776 assert_eq!(trace.attempts.len(), 1);
777 assert_eq!(trace.final_.escalations, 0);
778 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
779 assert_eq!(trace.final_.served_rung, Some(0));
780 assert!(
781 trace.final_.savings_usd > 0.0,
782 "top-rung baseline should exceed haiku cost"
783 );
784 }
785
786 #[tokio::test]
787 async fn escalate_on_gate_fail() {
788 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
789 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
790 let req = base_request();
791 let providers = registry(vec![
793 ("anthropic", HAIKU, Ok(resp(HAIKU, " "))),
794 ("anthropic", SONNET, Ok(resp(SONNET, "real answer"))),
795 ]);
796 let (auth, prices) = (Auth::default(), PriceTable::defaults());
797 let health = GateHealthRegistry::new();
798 let (out, trace) = route_enforce(ctx(
799 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
800 ))
801 .await;
802
803 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
804 assert_eq!(trace.attempts.len(), 2);
805 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
806 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
807 assert_eq!(trace.final_.escalations, 1);
808 assert_eq!(trace.final_.served_rung, Some(1));
809 }
810
811 #[tokio::test]
812 async fn cross_provider_failover_on_transport_error() {
813 let ladder = vec![HAIKU.to_owned(), GPT.to_owned()];
815 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
816 let req = base_request();
817 let providers = registry(vec![
818 (
819 "anthropic",
820 HAIKU,
821 Err(ProviderError::Transport("connection refused".into())),
822 ),
823 ("openai", GPT, Ok(resp(GPT, "answer from openai"))),
824 ]);
825 let (auth, prices) = (Auth::default(), PriceTable::defaults());
826 let health = GateHealthRegistry::new();
827 let (out, trace) = route_enforce(ctx(
828 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
829 ))
830 .await;
831
832 assert!(matches!(out, EngineOutcome::Served(r) if r.model == GPT));
833 assert_eq!(trace.attempts[0].verdict, Verdict::Abstain);
834 assert_eq!(
835 trace.attempts[0].gates[0].reason.as_deref(),
836 Some(reason::PROVIDER_ERROR)
837 );
838 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
839 assert_eq!(trace.final_.served_rung, Some(1));
840 }
841
842 #[tokio::test]
843 async fn budget_cap_stops_escalation() {
844 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
845 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
846 let req = base_request();
847 let providers = registry(vec![
849 ("anthropic", HAIKU, Ok(resp(HAIKU, ""))),
850 ("anthropic", SONNET, Ok(resp(SONNET, ""))),
851 ("anthropic", OPUS, Ok(resp(OPUS, ""))),
852 ]);
853 let (auth, prices) = (Auth::default(), PriceTable::defaults());
854 let health = GateHealthRegistry::new();
855 let (_out, trace) = route_enforce(ctx(
856 &ladder,
857 &gates,
858 &req,
859 &providers,
860 &auth,
861 &prices,
862 Some(0.0),
863 &health,
864 ))
865 .await;
866 assert!(
867 trace.attempts.len() < 3,
868 "budget should cut escalation short, got {}",
869 trace.attempts.len()
870 );
871 }
872
873 #[tokio::test]
874 async fn all_fail_serves_best_attempt() {
875 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
876 let gates: Vec<Box<dyn Gate>> = vec![Box::new(JsonValidGate)]; let req = base_request();
878 let providers = registry(vec![
879 ("anthropic", HAIKU, Ok(resp(HAIKU, "not json"))),
880 ("anthropic", SONNET, Ok(resp(SONNET, "still not json"))),
881 ]);
882 let (auth, prices) = (Auth::default(), PriceTable::defaults());
883 let health = GateHealthRegistry::new();
884 let (out, trace) = route_enforce(ctx(
885 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
886 ))
887 .await;
888
889 assert!(
890 matches!(out, EngineOutcome::Served(r) if r.model == SONNET),
891 "serves highest attempt"
892 );
893 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
894 assert_eq!(trace.final_.served_rung, Some(1));
895 }
896
897 #[tokio::test]
898 async fn hard_4xx_does_not_escalate() {
899 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
900 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
901 let req = base_request();
902 let providers = registry(vec![
903 (
904 "anthropic",
905 HAIKU,
906 Err(ProviderError::Http {
907 status: 400,
908 body: "bad request".into(),
909 }),
910 ),
911 ("anthropic", SONNET, Ok(resp(SONNET, "would have worked"))),
912 ]);
913 let (auth, prices) = (Auth::default(), PriceTable::defaults());
914 let health = GateHealthRegistry::new();
915 let (out, trace) = route_enforce(ctx(
916 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
917 ))
918 .await;
919
920 assert!(
921 matches!(out, EngineOutcome::Failed(_)),
922 "4xx is a hard error, not failover"
923 );
924 assert_eq!(
925 trace.attempts.len(),
926 1,
927 "must not escalate past a client error"
928 );
929 assert_eq!(trace.final_.served_from, ServedFrom::Error);
930 }
931
932 #[tokio::test]
933 async fn counterfactual_and_savings_math() {
934 let ladder = vec![HAIKU.to_owned(), OPUS.to_owned()];
935 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
936 let req = base_request();
937 let served = resp(HAIKU, "answer");
938 let (in_t, out_t) = (served.in_tokens, served.out_tokens);
939 let providers = registry(vec![("anthropic", HAIKU, Ok(served))]);
940 let (auth, prices) = (Auth::default(), PriceTable::defaults());
941 let health = GateHealthRegistry::new();
942 let (_out, trace) = route_enforce(ctx(
943 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
944 ))
945 .await;
946
947 let expected_baseline = prices.cost_usd(OPUS, in_t, out_t).unwrap();
948 assert!((trace.final_.counterfactual_baseline_usd - expected_baseline).abs() < 1e-12);
949 let expected_savings = expected_baseline - trace.final_.total_cost_usd;
950 assert!((trace.final_.savings_usd - expected_savings).abs() < 1e-12);
951 assert!(trace.final_.savings_usd > 0.0);
952 }
953
954 #[tokio::test]
955 async fn produced_trace_is_chain_verifiable() {
956 let ladder = vec![HAIKU.to_owned()];
957 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
958 let req = base_request();
959 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "ok")))]);
960 let (auth, prices) = (Auth::default(), PriceTable::defaults());
961 let health = GateHealthRegistry::new();
962 let (_out, trace) = route_enforce(ctx(
963 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
964 ))
965 .await;
966
967 assert!(firstpass_core::verify_chain(std::slice::from_ref(&trace), GENESIS_HASH).is_ok());
969 let json = serde_json::to_string(&trace).unwrap();
971 let _back: Trace = serde_json::from_str(&json).unwrap();
972 }
973
974 #[tokio::test]
975 async fn auto_disabled_gate_is_skipped_by_the_engine() {
976 let ladder = vec![HAIKU.to_owned()];
979 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
980 let req = base_request();
981 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "")))]); let (auth, prices) = (Auth::default(), PriceTable::defaults());
983
984 let health = GateHealthRegistry::new().with_budget("non-empty", 4, 0.5);
987 for _ in 0..4 {
988 health.record("acme", "non-empty", true);
989 }
990 assert!(
991 !health.enabled("acme", "non-empty"),
992 "precondition: gate is auto-disabled"
993 );
994
995 let (out, trace) = route_enforce(ctx(
996 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
997 ))
998 .await;
999 assert!(matches!(out, EngineOutcome::Served(_)));
1000 assert_eq!(trace.final_.served_rung, Some(0));
1001 assert!(
1002 trace.attempts[0].gates.is_empty(),
1003 "disabled gate must be skipped, not run"
1004 );
1005 }
1006
1007 fn counted_registry(
1010 outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
1011 ) -> (
1012 ProviderRegistry,
1013 std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1014 ) {
1015 let mut outs = HashMap::new();
1016 for (model, out) in outcomes {
1017 outs.insert(model.to_owned(), out);
1018 }
1019 let mock = MockProvider::new("anthropic", outs);
1020 let log = mock.call_log();
1021 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1022 map.insert("anthropic".to_owned(), Arc::new(mock));
1023 (ProviderRegistry::from_map(map), log)
1024 }
1025
1026 #[tokio::test]
1027 async fn speculation_prefetches_next_rung_but_serves_identically() {
1028 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1029 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1030 let req = base_request();
1031 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1032
1033 let (providers, log) = counted_registry(vec![
1035 (HAIKU, Ok(resp(HAIKU, "answer"))),
1036 (SONNET, Ok(resp(SONNET, "other"))),
1037 ]);
1038 let health = GateHealthRegistry::new();
1039 let (serial_out, serial_trace) = route_enforce(ctx(
1040 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1041 ))
1042 .await;
1043 assert_eq!(
1044 *log.lock().unwrap(),
1045 vec![HAIKU.to_owned()],
1046 "serial must not touch rung 1 when rung 0 passes"
1047 );
1048
1049 let (providers, log) = counted_registry(vec![
1051 (HAIKU, Ok(resp(HAIKU, "answer"))),
1052 (SONNET, Ok(resp(SONNET, "other"))),
1053 ]);
1054 let health = GateHealthRegistry::new();
1055 let mut c = ctx(
1056 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1057 );
1058 c.speculation = 1;
1059 let (spec_out, spec_trace) = route_enforce(c).await;
1060
1061 assert!(
1062 log.lock().unwrap().contains(&SONNET.to_owned()),
1063 "speculation must fire rung 1 ahead: {:?}",
1064 *log.lock().unwrap()
1065 );
1066
1067 let (a, b) = match (serial_out, spec_out) {
1069 (EngineOutcome::Served(a), EngineOutcome::Served(b)) => (a, b),
1070 _ => panic!("both variants must serve"),
1071 };
1072 assert_eq!(
1073 (a.model, a.text, a.out_tokens),
1074 (b.model, b.text, b.out_tokens)
1075 );
1076 assert_eq!(spec_trace.final_.served_rung, Some(0));
1077 assert_eq!(spec_trace.attempts.len(), 1, "only rung 0 is gated");
1078 assert!(
1080 spec_trace.final_.total_cost_usd > serial_trace.final_.total_cost_usd,
1081 "speculative waste must show in total cost: spec={} serial={}",
1082 spec_trace.final_.total_cost_usd,
1083 serial_trace.final_.total_cost_usd
1084 );
1085 }
1086
1087 #[tokio::test]
1088 async fn speculation_preserves_escalation_result() {
1089 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1090 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1091 let req = base_request();
1092 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1093 let (providers, _log) = counted_registry(vec![
1095 (HAIKU, Ok(resp(HAIKU, ""))),
1096 (SONNET, Ok(resp(SONNET, "real answer"))),
1097 ]);
1098 let health = GateHealthRegistry::new();
1099 let mut c = ctx(
1100 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1101 );
1102 c.speculation = 2; let (out, trace) = route_enforce(c).await;
1104 match out {
1105 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1106 EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
1107 }
1108 assert_eq!(trace.final_.served_rung, Some(1));
1109 assert_eq!(trace.attempts.len(), 2);
1110 assert_eq!(trace.final_.escalations, 1);
1111 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
1112 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1113 }
1114
1115 #[tokio::test]
1121 async fn latency_ab_speculative_beats_serial_p95() {
1122 const DELAY: u64 = 40;
1123 const N: usize = 30;
1124 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1125 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1126 let req = base_request();
1127 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1128
1129 fn pctl(sorted: &[u64], p: f64) -> u64 {
1130 let i = (((sorted.len() - 1) as f64) * p).round() as usize;
1131 sorted[i]
1132 }
1133
1134 let mut serial = Vec::with_capacity(N);
1135 let mut spec = Vec::with_capacity(N);
1136 for run in 0..(2 * N) {
1137 let speculation = u32::from(run >= N); let mut outs: HashMap<String, Result<ModelResponse, ProviderError>> = HashMap::new();
1139 outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "ok")));
1141 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1142 map.insert(
1143 "anthropic".to_owned(),
1144 Arc::new(MockProvider::new("anthropic", outs).with_delay(DELAY)),
1145 );
1146 let providers = ProviderRegistry::from_map(map);
1147 let health = GateHealthRegistry::new();
1148 let mut c = ctx(
1149 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1150 );
1151 c.speculation = speculation;
1152 let start = std::time::Instant::now();
1153 let _ = route_enforce(c).await;
1154 let ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1155 if speculation == 0 {
1156 serial.push(ms);
1157 } else {
1158 spec.push(ms);
1159 }
1160 }
1161 serial.sort_unstable();
1162 spec.sort_unstable();
1163 println!(
1164 "latency A/B (per-rung {DELAY}ms, escalate every request):\n serial p50={} p95={} p99={}\n spec(k=1) p50={} p95={} p99={}",
1165 pctl(&serial, 0.5),
1166 pctl(&serial, 0.95),
1167 pctl(&serial, 0.99),
1168 pctl(&spec, 0.5),
1169 pctl(&spec, 0.95),
1170 pctl(&spec, 0.99),
1171 );
1172 assert!(
1174 pctl(&spec, 0.95) * 4 < pctl(&serial, 0.95) * 3,
1175 "spec p95 {}ms should beat serial p95 {}ms by >25%",
1176 pctl(&spec, 0.95),
1177 pctl(&serial, 0.95)
1178 );
1179 }
1180
1181 #[tokio::test]
1182 async fn speculation_never_fires_past_max_rungs() {
1183 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1184 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1185 let req = base_request();
1186 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1187 let (providers, log) = counted_registry(vec![
1189 (HAIKU, Ok(resp(HAIKU, ""))),
1190 (SONNET, Ok(resp(SONNET, ""))),
1191 (OPUS, Ok(resp(OPUS, ""))),
1192 ]);
1193 let health = GateHealthRegistry::new();
1194 let mut c = ctx(
1195 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1196 );
1197 c.max_rungs = 2;
1198 c.speculation = 5; let (out, trace) = route_enforce(c).await;
1200
1201 assert!(
1202 !log.lock().unwrap().contains(&OPUS.to_owned()),
1203 "must not fire beyond max_rungs: {:?}",
1204 *log.lock().unwrap()
1205 );
1206 assert_eq!(trace.attempts.len(), 2);
1207 assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
1208 assert_eq!(trace.final_.served_rung, Some(1));
1209 match out {
1210 EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1211 EngineOutcome::Failed(e) => panic!("expected best-attempt served, got {e}"),
1212 }
1213 }
1214
1215 #[tokio::test]
1216 async fn speculation_cuts_wall_clock_vs_serial() {
1217 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1222 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1223 let req = base_request();
1224 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1225
1226 let build = || {
1227 let mut outs = HashMap::new();
1228 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);
1231 let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1232 map.insert("anthropic".to_owned(), Arc::new(mock));
1233 ProviderRegistry::from_map(map)
1234 };
1235
1236 let providers = build();
1237 let health = GateHealthRegistry::new();
1238 let t = std::time::Instant::now();
1239 let _ = route_enforce(ctx(
1240 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1241 ))
1242 .await;
1243 let serial = t.elapsed();
1244
1245 let providers = build();
1246 let health = GateHealthRegistry::new();
1247 let mut c = ctx(
1248 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1249 );
1250 c.speculation = 1;
1251 let t = std::time::Instant::now();
1252 let _ = route_enforce(c).await;
1253 let spec = t.elapsed();
1254
1255 assert!(
1256 spec < serial * 3 / 4,
1257 "speculation must overlap rung latencies: serial={serial:?} spec={spec:?}"
1258 );
1259 }
1260
1261 #[derive(Debug)]
1264 struct ScoreGate;
1265
1266 #[async_trait::async_trait]
1267 impl Gate for ScoreGate {
1268 fn id(&self) -> &str {
1269 "score"
1270 }
1271
1272 async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
1273 let score = resp.text.trim().parse::<f64>().unwrap_or(0.0);
1274 GateResult {
1275 gate_id: self.id().to_owned(),
1276 verdict: Verdict::Pass,
1277 score: Some(firstpass_core::Score::clamped(score)),
1278 cost_usd: 0.0,
1279 ms: 0,
1280 reason: None,
1281 evidence_ref: None,
1282 }
1283 }
1284 }
1285
1286 #[tokio::test]
1287 async fn serve_threshold_escalates_past_low_scoring_rung() {
1288 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1291 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1292 let req = base_request();
1293 let providers = registry(vec![
1294 ("anthropic", HAIKU, Ok(resp(HAIKU, "0.5"))),
1295 ("anthropic", SONNET, Ok(resp(SONNET, "0.9"))),
1296 ]);
1297 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1298 let health = GateHealthRegistry::new();
1299 let mut c = ctx(
1300 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1301 );
1302 c.serve_threshold = Some(0.8);
1303 let (out, trace) = route_enforce(c).await;
1304
1305 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1306 assert_eq!(trace.attempts.len(), 2);
1307 assert_eq!(trace.attempts[0].verdict, Verdict::Pass);
1309 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1310 assert_eq!(trace.final_.escalations, 1);
1311 assert_eq!(trace.final_.served_rung, Some(1));
1312 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1313 }
1314
1315 #[tokio::test]
1316 async fn serve_threshold_does_not_serve_a_pass_below_threshold() {
1317 let ladder = vec![HAIKU.to_owned()];
1321 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1322 let req = base_request();
1323 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.3")))]);
1324 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1325 let health = GateHealthRegistry::new();
1326 let mut c = ctx(
1327 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1328 );
1329 c.serve_threshold = Some(0.8);
1330 let (out, trace) = route_enforce(c).await;
1331
1332 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1333 assert_eq!(
1334 trace.attempts[0].verdict,
1335 Verdict::Pass,
1336 "gate verdict was Pass"
1337 );
1338 assert_eq!(
1339 trace.final_.served_from,
1340 ServedFrom::BestAttempt,
1341 "score below threshold must fall back, not serve as a normal pass"
1342 );
1343 }
1344
1345 #[tokio::test]
1346 async fn serve_threshold_none_serves_on_verdict_regardless_of_score() {
1347 let ladder = vec![HAIKU.to_owned()];
1350 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1351 let req = base_request();
1352 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.1")))]);
1353 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1354 let health = GateHealthRegistry::new();
1355 let c = ctx(
1356 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1357 );
1358 assert_eq!(c.serve_threshold, None);
1359 let (out, trace) = route_enforce(c).await;
1360
1361 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1362 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1363 }
1364
1365 #[tokio::test]
1370 async fn bandit_off_start_rung_zero_is_byte_identical() {
1371 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1372 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1373 let req = base_request();
1374 let (providers, log) = counted_registry(vec![
1375 (HAIKU, Ok(resp(HAIKU, "answer"))),
1376 (SONNET, Ok(resp(SONNET, "other"))),
1377 ]);
1378 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1379 let health = GateHealthRegistry::new();
1380 let c = ctx(
1381 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1382 );
1383 assert_eq!(c.start_rung, 0, "default start_rung must be 0 (bandit off)");
1384 let (out, trace) = route_enforce(c).await;
1385 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1387 assert_eq!(trace.final_.served_rung, Some(0));
1388 assert_eq!(*log.lock().unwrap(), vec![HAIKU.to_owned()]);
1389 }
1390
1391 #[tokio::test]
1393 async fn start_rung_1_skips_rung_0_and_serves_rung_1() {
1394 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1395 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1396 let req = base_request();
1397 let (providers, log) = counted_registry(vec![
1398 (HAIKU, Ok(resp(HAIKU, "haiku answer"))),
1399 (SONNET, Ok(resp(SONNET, "sonnet answer"))),
1400 ]);
1401 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1402 let health = GateHealthRegistry::new();
1403 let mut c = ctx(
1404 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1405 );
1406 c.start_rung = 1; let (out, trace) = route_enforce(c).await;
1408
1409 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1411 assert_eq!(trace.final_.served_rung, Some(1));
1412 assert_eq!(trace.attempts.len(), 1, "only rung 1 attempted");
1413 assert_eq!(trace.attempts[0].rung, 1);
1414 assert!(
1415 !log.lock().unwrap().contains(&HAIKU.to_owned()),
1416 "rung 0 must never fire when start_rung=1: {:?}",
1417 *log.lock().unwrap()
1418 );
1419 }
1420
1421 #[tokio::test]
1423 async fn start_rung_1_escalates_to_rung_2_on_gate_fail() {
1424 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1425 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1426 let req = base_request();
1427 let (providers, log) = counted_registry(vec![
1429 (HAIKU, Ok(resp(HAIKU, "haiku"))),
1430 (SONNET, Ok(resp(SONNET, ""))), (OPUS, Ok(resp(OPUS, "real answer"))),
1432 ]);
1433 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1434 let health = GateHealthRegistry::new();
1435 let mut c = ctx(
1436 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1437 );
1438 c.start_rung = 1;
1439 let (out, trace) = route_enforce(c).await;
1440
1441 assert!(matches!(out, EngineOutcome::Served(r) if r.model == OPUS));
1442 assert_eq!(trace.final_.served_rung, Some(2));
1443 assert_eq!(trace.attempts.len(), 2); assert_eq!(trace.attempts[0].rung, 1);
1445 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
1446 assert_eq!(trace.attempts[1].rung, 2);
1447 assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1448 assert!(
1450 !log.lock().unwrap().contains(&HAIKU.to_owned()),
1451 "rung 0 must not fire with start_rung=1"
1452 );
1453 }
1454
1455 #[tokio::test]
1458 async fn max_rungs_respected_with_nonzero_start_rung() {
1459 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1460 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1461 let req = base_request();
1462 let (providers, _log) = counted_registry(vec![
1464 (HAIKU, Ok(resp(HAIKU, ""))),
1465 (SONNET, Ok(resp(SONNET, ""))),
1466 (OPUS, Ok(resp(OPUS, ""))),
1467 ]);
1468 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1469 let health = GateHealthRegistry::new();
1470 let mut c = ctx(
1471 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1472 );
1473 c.start_rung = 1;
1474 c.max_rungs = 1;
1475 let (_out, trace) = route_enforce(c).await;
1476
1477 assert_eq!(
1478 trace.attempts.len(),
1479 1,
1480 "max_rungs=1 must limit to one attempt even with start_rung=1"
1481 );
1482 assert_eq!(trace.attempts[0].rung, 1, "the one attempt must be rung 1");
1483 }
1484
1485 #[tokio::test]
1488 async fn invariant_failed_start_rung_never_served_escalates_to_passing_rung() {
1489 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1490 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1491 let req = base_request();
1492 let (providers, _log) = counted_registry(vec![
1493 (HAIKU, Ok(resp(HAIKU, "haiku"))),
1494 (SONNET, Ok(resp(SONNET, " "))), (OPUS, Ok(resp(OPUS, "the correct answer"))),
1496 ]);
1497 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1498 let health = GateHealthRegistry::new();
1499 let mut c = ctx(
1500 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1501 );
1502 c.start_rung = 1;
1503 let (out, trace) = route_enforce(c).await;
1504
1505 match out {
1507 EngineOutcome::Served(r) => {
1508 assert_eq!(r.model, OPUS, "must serve the passing rung's model");
1509 assert_eq!(
1510 r.text, "the correct answer",
1511 "served text must be from the passing rung"
1512 );
1513 }
1514 EngineOutcome::Failed(e) => panic!("expected served answer, got error: {e}"),
1515 }
1516 assert_eq!(trace.final_.served_rung, Some(2));
1517 assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1518 assert_eq!(trace.attempts[0].rung, 1);
1520 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
1521 }
1522
1523 #[derive(Debug)]
1525 struct AbstainGate {
1526 fail_closed: bool,
1527 }
1528
1529 #[async_trait::async_trait]
1530 impl Gate for AbstainGate {
1531 fn id(&self) -> &str {
1532 "flaky"
1533 }
1534 async fn evaluate(&self, _req: &ModelRequest, _resp: &ModelResponse) -> GateResult {
1535 GateResult::abstain(self.id(), "timeout", 0)
1536 }
1537 fn abstain_fails_closed(&self) -> bool {
1538 self.fail_closed
1539 }
1540 }
1541
1542 #[tokio::test]
1543 async fn fail_open_abstain_serves_fail_closed_abstain_escalates() {
1544 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1545 let req = base_request();
1546 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1547 let health = GateHealthRegistry::new();
1548
1549 let gates: Vec<Box<dyn Gate>> = vec![Box::new(AbstainGate { fail_closed: false })];
1551 let providers = registry(vec![
1552 ("anthropic", HAIKU, Ok(resp(HAIKU, "cheap answer"))),
1553 ("anthropic", SONNET, Ok(resp(SONNET, "expensive answer"))),
1554 ]);
1555 let (out, trace) = route_enforce(ctx(
1556 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1557 ))
1558 .await;
1559 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1560 assert_eq!(trace.final_.served_rung, Some(0));
1561
1562 let gates: Vec<Box<dyn Gate>> = vec![Box::new(AbstainGate { fail_closed: true })];
1565 let providers = registry(vec![
1566 ("anthropic", HAIKU, Ok(resp(HAIKU, "cheap answer"))),
1567 ("anthropic", SONNET, Ok(resp(SONNET, "expensive answer"))),
1568 ]);
1569 let (out, trace) = route_enforce(ctx(
1570 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1571 ))
1572 .await;
1573 assert_eq!(trace.attempts.len(), 2, "fail-closed abstain must escalate");
1576 assert_eq!(trace.final_.escalations, 1);
1577 assert_eq!(
1578 trace.attempts[0].gates[0].verdict,
1579 Verdict::Abstain,
1580 "receipt records the honest abstain, not a rewritten Fail"
1581 );
1582 assert!(matches!(out, EngineOutcome::Served(_)));
1583 }
1584
1585 fn elastic_cfg(expensive: &[&str], lambda: f64) -> firstpass_core::config::ElasticConfig {
1588 firstpass_core::config::ElasticConfig {
1589 expensive_gates: expensive.iter().map(|s| (*s).to_owned()).collect(),
1590 lambda,
1591 alpha: Some(0.10),
1592 delta: Some(0.05),
1593 calibration_id: Some("cal-mbpp-v1".to_owned()),
1594 }
1595 }
1596
1597 #[tokio::test]
1598 async fn elastic_off_records_no_elastic_field() {
1599 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1603 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1604 let req = base_request();
1605 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "answer")))]);
1606 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1607 let health = GateHealthRegistry::new();
1608 let (_out, trace) = route_enforce(ctx(
1609 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1610 ))
1611 .await;
1612 assert!(trace.elastic.is_none());
1613 let json = serde_json::to_string(&trace).unwrap();
1614 assert!(
1615 !json.contains("elastic"),
1616 "elastic must not appear in the wire form when off"
1617 );
1618 }
1619
1620 #[tokio::test]
1621 async fn elastic_serve_skip_skips_expensive_gate_and_records_receipt() {
1622 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1625 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate), Box::new(JsonValidGate)];
1626 let req = base_request();
1627 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.9")))]);
1628 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1629 let health = GateHealthRegistry::new();
1630 let el = elastic_cfg(&["json-valid"], 0.5);
1631 let mut c = ctx(
1632 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1633 );
1634 c.elastic = Some(&el);
1635 let (out, trace) = route_enforce(c).await;
1636
1637 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1638 assert_eq!(trace.final_.served_rung, Some(0));
1639 assert_eq!(trace.attempts[0].gates.len(), 1);
1641 assert_eq!(trace.attempts[0].gates[0].gate_id, "score");
1642 let d = trace.elastic.expect("elastic decision recorded");
1643 assert_eq!(d.action, ElasticAction::ServeSkip);
1644 assert!((d.signal - 0.9).abs() < 1e-9);
1645 assert!((d.lambda - 0.5).abs() < 1e-9);
1646 assert_eq!(d.alpha, Some(0.10));
1648 assert_eq!(d.delta, Some(0.05));
1649 assert_eq!(d.calibration_id.as_deref(), Some("cal-mbpp-v1"));
1650 }
1651
1652 #[tokio::test]
1653 async fn elastic_escalate_now_skips_expensive_and_escalates() {
1654 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1657 let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate), Box::new(JsonValidGate)];
1658 let req = base_request();
1659 let providers = registry(vec![
1660 ("anthropic", HAIKU, Ok(resp(HAIKU, " "))),
1661 ("anthropic", SONNET, Ok(resp(SONNET, "real answer"))),
1662 ]);
1663 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1664 let health = GateHealthRegistry::new();
1665 let el = elastic_cfg(&["json-valid"], 0.5);
1666 let mut c = ctx(
1667 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1668 );
1669 c.elastic = Some(&el);
1670 let (out, trace) = route_enforce(c).await;
1671
1672 assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1673 assert_eq!(trace.final_.escalations, 1);
1674 assert_eq!(trace.final_.served_rung, Some(1));
1675 assert_eq!(trace.attempts[0].gates.len(), 1);
1677 assert_eq!(trace.attempts[0].gates[0].gate_id, "non-empty");
1678 assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
1679 assert_eq!(trace.elastic.unwrap().action, ElasticAction::ServeSkip);
1681 }
1682
1683 #[tokio::test]
1684 async fn elastic_ambiguous_middle_runs_expensive_gate() {
1685 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1688 let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate), Box::new(JsonValidGate)];
1689 let req = base_request();
1690 let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.3")))]);
1692 let (auth, prices) = (Auth::default(), PriceTable::defaults());
1693 let health = GateHealthRegistry::new();
1694 let el = elastic_cfg(&["json-valid"], 0.8);
1695 let mut c = ctx(
1696 &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1697 );
1698 c.elastic = Some(&el);
1699 let (out, trace) = route_enforce(c).await;
1700
1701 assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1702 assert_eq!(trace.final_.served_rung, Some(0));
1703 assert_eq!(trace.attempts[0].gates.len(), 2);
1705 let ids: Vec<&str> = trace.attempts[0]
1706 .gates
1707 .iter()
1708 .map(|g| g.gate_id.as_str())
1709 .collect();
1710 assert!(ids.contains(&"score") && ids.contains(&"json-valid"));
1711 let d = trace.elastic.unwrap();
1712 assert_eq!(d.action, ElasticAction::Verified);
1713 assert!((d.signal - 0.3).abs() < 1e-9);
1714 }
1715}