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