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