Skip to main content

firstpass_proxy/
router.rs

1//! The enforce-mode escalation engine (SPEC §7.1) — the crown jewel.
2//!
3//! Cheapest rung first: call the model, gate the output, serve the first output that passes;
4//! escalate exactly one rung on gate failure, up to a ladder/budget/`max_rungs` ceiling. A
5//! failover-eligible provider error (transport / 5xx) abstains and moves to the next rung — so
6//! cross-provider failover falls out of the same loop (§7.2). This is the real-typed, async
7//! version of the `Firstpass` policy proven in `firstpass-bench`; the semantics are identical.
8
9use crate::calibrate::gate_score;
10use crate::gate::{Gate, GateHealthRegistry, aggregate};
11use crate::provider::{Auth, ModelRequest, ModelResponse, ProviderError, ProviderRegistry};
12use firstpass_core::verdict::reason;
13use firstpass_core::{
14    Attempt, Features, FinalOutcome, GENESIS_HASH, GateResult, Mode, ModelRef, PolicyRef,
15    PriceTable, RequestInfo, ServedFrom, Trace, Verdict,
16};
17use jiff::Timestamp;
18use std::collections::HashMap;
19use std::time::Instant;
20use tokio::task::JoinHandle;
21use uuid::Uuid;
22
23/// The outcome of an enforce-mode routing decision.
24#[derive(Debug)]
25pub enum EngineOutcome {
26    /// An output was served (from a passing attempt, or the best attempt when the ladder was
27    /// exhausted without a pass).
28    Served(ModelResponse),
29    /// Nothing could be served — every rung errored, or a hard (non-failover) error occurred.
30    Failed(String),
31}
32
33/// Everything the engine needs for one decision. Borrowed to avoid cloning the ladder/request
34/// per call; owned trace-context strings so the resulting [`Trace`] is self-contained.
35#[derive(Debug)]
36pub struct EnforceCtx<'a> {
37    /// Model ladder, cheapest first, as `provider/model` strings.
38    pub ladder: &'a [String],
39    /// Gates run against each attempt's output (already resolved).
40    pub gates: &'a [Box<dyn Gate>],
41    /// Per-gate error budgets: a gate over budget is skipped (auto-disabled) this request.
42    pub health: &'a GateHealthRegistry,
43    /// The base request; its `model` is overwritten per rung.
44    pub base_request: &'a ModelRequest,
45    /// Provider lookup.
46    pub providers: &'a ProviderRegistry,
47    /// BYOK credentials for this request.
48    pub auth: &'a Auth,
49    /// Price table for cost + counterfactual math.
50    pub prices: &'a PriceTable,
51    /// Per-request USD cap (`None` = uncapped).
52    pub budget_per_request_usd: Option<f64>,
53    /// Hard ceiling on rungs attempted this request.
54    pub max_rungs: u32,
55    /// Prefetch depth: fire this many rungs ahead concurrently while gating in ladder order.
56    /// `0` = serial (the default): one call at a time, byte-identical to the original engine.
57    pub speculation: u32,
58    /// Calibrated conformal serve threshold (SPEC §10.1): a rung serves iff its aggregate gate
59    /// score is `>=` this value. `None` (the default) keeps the original rule — serve iff the
60    /// aggregate gate verdict is `Pass` — byte-identical to the original engine.
61    pub serve_threshold: Option<f64>,
62    /// Feature vector routed on (recorded in the trace).
63    pub features: Features,
64    /// Tenant id.
65    pub tenant_id: String,
66    /// Session id.
67    pub session_id: String,
68    /// Salted prompt hash (never the raw prompt).
69    pub prompt_hash: String,
70    /// Wire API label, e.g. `"anthropic.messages"`.
71    pub api: String,
72    /// Policy identity, e.g. `"static-ladder@v0"`.
73    pub policy_id: String,
74}
75
76/// Run the enforce-mode ladder and produce both the outcome and its audit trace.
77///
78/// The trace's `prev_hash` is left as [`GENESIS_HASH`]; the trace-store writer overwrites it with
79/// the real chain head when persisting (keeping the single-writer chain invariant).
80pub async fn route_enforce(ctx: EnforceCtx<'_>) -> (EngineOutcome, Trace) {
81    // Speculation is off by default (serial); the serial path is the original, proven engine, left
82    // untouched. Both paths produce the same ladder state; only the tail (serve + trace) is shared.
83    let LadderRun {
84        attempts,
85        spent,
86        gate_cost_total,
87        best,
88        mut served_rung,
89        hard_error,
90    } = if ctx.speculation == 0 {
91        run_serial(&ctx).await
92    } else {
93        run_speculative(&ctx).await
94    };
95
96    // Decide what to serve.
97    let (outcome, served_from, served_tokens) = match (served_rung, &best) {
98        (Some(_), Some((_, resp))) => (
99            EngineOutcome::Served(resp.clone()),
100            ServedFrom::Attempt,
101            (resp.in_tokens, resp.out_tokens),
102        ),
103        (None, Some((idx, resp))) => {
104            // No pass, but we produced output: serve the best (highest) attempt seen.
105            served_rung = Some(*idx);
106            (
107                EngineOutcome::Served(resp.clone()),
108                ServedFrom::BestAttempt,
109                (resp.in_tokens, resp.out_tokens),
110            )
111        }
112        (_, None) => {
113            let msg = hard_error.unwrap_or_else(|| "all rungs failed".to_owned());
114            (EngineOutcome::Failed(msg), ServedFrom::Error, (0, 0))
115        }
116    };
117
118    // Counterfactual: what the top rung would have cost for the served token counts.
119    let top_model = ctx.ladder.last().map(String::as_str).unwrap_or_default();
120    let baseline = ctx
121        .prices
122        .cost_usd(top_model, served_tokens.0, served_tokens.1)
123        .unwrap_or(spent);
124
125    let total_latency_ms = attempts.iter().map(|a| a.latency_ms).sum();
126    let escalations = attempts.len().saturating_sub(1) as u32;
127
128    let mut trace = Trace {
129        trace_id: Uuid::now_v7(),
130        prev_hash: GENESIS_HASH.to_owned(),
131        tenant_id: ctx.tenant_id,
132        session_id: ctx.session_id,
133        ts: Timestamp::now(),
134        mode: Mode::Enforce,
135        policy: PolicyRef {
136            id: ctx.policy_id,
137            explore: false,
138        },
139        request: RequestInfo {
140            api: ctx.api,
141            prompt_hash: ctx.prompt_hash,
142            features: ctx.features,
143        },
144        attempts,
145        deferred: vec![],
146        final_: FinalOutcome {
147            served_rung,
148            served_from,
149            total_cost_usd: spent,
150            gate_cost_usd: gate_cost_total,
151            total_latency_ms,
152            escalations,
153            counterfactual_baseline_usd: baseline,
154            savings_usd: 0.0,
155        },
156    };
157    trace.recompute_savings();
158    (outcome, trace)
159}
160
161/// The ladder state both engine variants produce; the shared tail turns it into a served outcome
162/// and audit trace. `spent`/`gate_cost_total` are running USD totals; `best` is the highest attempt
163/// that produced gradable output; `served_rung` is `Some` only when a gate actually passed.
164struct LadderRun {
165    attempts: Vec<Attempt>,
166    spent: f64,
167    gate_cost_total: f64,
168    best: Option<(u32, ModelResponse)>,
169    served_rung: Option<u32>,
170    hard_error: Option<String>,
171}
172
173/// The shared serve decision (SPEC §10.1), used by both [`run_serial`] and [`run_speculative`] so
174/// they can never disagree.
175///
176/// - `serve_threshold == None` (the default): serve iff the aggregate gate verdict is `Pass` —
177///   byte-identical to the original engine.
178/// - `serve_threshold == Some(t)`: serve iff the rung's aggregate gate score is `>= t`, regardless
179///   of verdict — a calibrated conformal threshold overrides the pass/fail cutoff. The score is
180///   computed by [`gate_score`], the same mean-of-numeric-gate-scores rule `calibrate` uses so
181///   calibration and serving agree.
182fn should_serve(
183    serve_threshold: Option<f64>,
184    gate_results: &[GateResult],
185    verdict: Verdict,
186) -> bool {
187    match serve_threshold {
188        None => verdict == Verdict::Pass,
189        Some(t) => gate_score(gate_results, verdict) >= t,
190    }
191}
192
193/// Serial engine: one rung at a time — call, gate, serve the first pass, escalate on fail. This is
194/// the original, proven loop; `speculation == 0` routes here unchanged.
195async fn run_serial(ctx: &EnforceCtx<'_>) -> LadderRun {
196    let mut attempts: Vec<Attempt> = Vec::new();
197    let mut spent = 0.0_f64;
198    let mut gate_cost_total = 0.0_f64;
199    let mut best: Option<(u32, ModelResponse)> = None;
200    let mut served_rung: Option<u32> = None;
201    let mut hard_error: Option<String> = None;
202
203    let rung_limit = (ctx.max_rungs as usize).min(ctx.ladder.len());
204    for (idx, model_str) in ctx.ladder.iter().take(rung_limit).enumerate() {
205        let idx = idx as u32;
206        let start = Instant::now();
207
208        // Resolve provider from `provider/model`. A missing provider/malformed ref is treated as
209        // a failover-eligible abstain: record it and try the next rung rather than hard-failing.
210        let provider = match ModelRef::parse(model_str) {
211            Ok(m) => ctx.providers.get(&m.provider),
212            Err(_) => None,
213        };
214        let Some(provider) = provider else {
215            let ms = elapsed_ms(start);
216            attempts.push(abstain_attempt(
217                idx,
218                model_str,
219                "unknown",
220                reason::PROVIDER_ERROR,
221                ms,
222            ));
223            continue;
224        };
225
226        let mut req = ctx.base_request.clone();
227        req.model = model_str.clone();
228
229        match provider.complete(&req, ctx.auth).await {
230            Err(err) if err.is_failover_eligible() => {
231                // Transport / 5xx: abstain and fail over to the next rung.
232                let ms = elapsed_ms(start);
233                attempts.push(abstain_attempt(
234                    idx,
235                    model_str,
236                    provider.id(),
237                    reason::PROVIDER_ERROR,
238                    ms,
239                ));
240                continue;
241            }
242            Err(err) => {
243                // Hard error (4xx / decode): do not escalate — the request itself is the problem.
244                let ms = elapsed_ms(start);
245                let (r, msg) = hard_reason(&err);
246                attempts.push(abstain_attempt(idx, model_str, provider.id(), r, ms));
247                hard_error = Some(msg);
248                break;
249            }
250            Ok(resp) => {
251                let ms = elapsed_ms(start);
252                let model_cost = ctx
253                    .prices
254                    .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
255                    .unwrap_or(0.0);
256                spent += model_cost;
257
258                // Run gates sequentially (they're I/O — subprocess/model), skipping any the
259                // error budget has auto-disabled, and feeding each outcome back to the budget.
260                let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
261                for g in ctx.gates {
262                    if !ctx.health.enabled(&ctx.tenant_id, g.id()) {
263                        tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
264                        continue;
265                    }
266                    let r = g.evaluate(&req, &resp).await;
267                    ctx.health
268                        .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
269                    gate_results.push(r);
270                }
271                let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
272                gate_cost_total += gc;
273                spent += gc;
274
275                let verdict = aggregate(&gate_results);
276                let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
277                attempts.push(Attempt {
278                    rung: idx,
279                    model: model_str.clone(),
280                    provider: provider.id().to_owned(),
281                    in_tokens: resp.in_tokens,
282                    out_tokens: resp.out_tokens,
283                    cost_usd: model_cost,
284                    latency_ms: ms,
285                    gates: gate_results,
286                    verdict,
287                });
288                best = Some((idx, resp));
289
290                if serve {
291                    served_rung = Some(idx);
292                    break;
293                }
294                // Gate failed → escalate, unless the budget is already spent and a next rung exists.
295                if let Some(cap) = ctx.budget_per_request_usd
296                    && spent >= cap
297                    && (idx as usize) + 1 < rung_limit
298                {
299                    break;
300                }
301            }
302        }
303    }
304
305    LadderRun {
306        attempts,
307        spent,
308        gate_cost_total,
309        best,
310        served_rung,
311        hard_error,
312    }
313}
314
315/// Speculative engine: prefetch up to `speculation` rungs ahead concurrently, but gate strictly in
316/// ladder order and serve the first rung whose gate passes. The SERVED result is therefore
317/// byte-identical to [`run_serial`] — only latency (prefetched rungs are already in flight) and
318/// honest wasted spend (speculative calls that completed but weren't served) differ.
319async fn run_speculative(ctx: &EnforceCtx<'_>) -> LadderRun {
320    let mut attempts: Vec<Attempt> = Vec::new();
321    let mut spent = 0.0_f64;
322    let mut gate_cost_total = 0.0_f64;
323    let mut best: Option<(u32, ModelResponse)> = None;
324    let mut served_rung: Option<u32> = None;
325    let mut hard_error: Option<String> = None;
326
327    let rung_limit = (ctx.max_rungs as usize).min(ctx.ladder.len());
328    let speculation = ctx.speculation as usize;
329    let mut inflight: HashMap<usize, JoinHandle<Result<ModelResponse, ProviderError>>> =
330        HashMap::new();
331
332    let mut idx = 0usize;
333    // `done` = a rung passed or hard-errored: stop consuming, then cancel/harvest the rest.
334    let mut done = false;
335    while idx < rung_limit && !done {
336        // Fire the window [idx ..= idx+speculation] concurrently. The rung we must gate now (idx)
337        // always fires; rungs ahead only while under budget, so speculation can't blow the cap.
338        let window_end = (idx + speculation).min(rung_limit - 1);
339        for j in idx..=window_end {
340            if inflight.contains_key(&j) {
341                continue;
342            }
343            if j > idx
344                && let Some(cap) = ctx.budget_per_request_usd
345                && spent >= cap
346            {
347                continue;
348            }
349            if let Some(handle) = spawn_rung(ctx, j) {
350                inflight.insert(j, handle);
351            }
352        }
353
354        let model_str = &ctx.ladder[idx];
355        let provider = match ModelRef::parse(model_str) {
356            Ok(m) => ctx.providers.get(&m.provider),
357            Err(_) => None,
358        };
359        let Some(provider) = provider else {
360            // Malformed ref / unknown provider: abstain and fail over (no task was spawned).
361            attempts.push(abstain_attempt(
362                idx as u32,
363                model_str,
364                "unknown",
365                reason::PROVIDER_ERROR,
366                0,
367            ));
368            idx += 1;
369            continue;
370        };
371        // Provider resolved ⇒ we spawned a task for `idx`; await it in strict ladder order.
372        let Some(handle) = inflight.remove(&idx) else {
373            attempts.push(abstain_attempt(
374                idx as u32,
375                model_str,
376                provider.id(),
377                reason::PROVIDER_ERROR,
378                0,
379            ));
380            idx += 1;
381            continue;
382        };
383        let start = Instant::now();
384        let joined = handle.await;
385        let ms = elapsed_ms(start);
386
387        match joined {
388            // Task panicked or was aborted out from under us: treat as a transport abstain.
389            Err(_) => {
390                attempts.push(abstain_attempt(
391                    idx as u32,
392                    model_str,
393                    provider.id(),
394                    reason::PROVIDER_ERROR,
395                    ms,
396                ));
397                idx += 1;
398            }
399            Ok(Err(err)) if err.is_failover_eligible() => {
400                attempts.push(abstain_attempt(
401                    idx as u32,
402                    model_str,
403                    provider.id(),
404                    reason::PROVIDER_ERROR,
405                    ms,
406                ));
407                idx += 1;
408            }
409            Ok(Err(err)) => {
410                // Hard error (4xx / decode): do not escalate — the request itself is the problem.
411                let (r, msg) = hard_reason(&err);
412                attempts.push(abstain_attempt(idx as u32, model_str, provider.id(), r, ms));
413                hard_error = Some(msg);
414                done = true;
415            }
416            Ok(Ok(resp)) => {
417                let model_cost = ctx
418                    .prices
419                    .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
420                    .unwrap_or(0.0);
421                spent += model_cost;
422
423                let mut req = ctx.base_request.clone();
424                req.model = model_str.clone();
425                let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
426                for g in ctx.gates {
427                    if !ctx.health.enabled(&ctx.tenant_id, g.id()) {
428                        tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
429                        continue;
430                    }
431                    let r = g.evaluate(&req, &resp).await;
432                    ctx.health
433                        .record(&ctx.tenant_id, g.id(), r.verdict == Verdict::Abstain);
434                    gate_results.push(r);
435                }
436                let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
437                gate_cost_total += gc;
438                spent += gc;
439
440                let verdict = aggregate(&gate_results);
441                let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
442                attempts.push(Attempt {
443                    rung: idx as u32,
444                    model: model_str.clone(),
445                    provider: provider.id().to_owned(),
446                    in_tokens: resp.in_tokens,
447                    out_tokens: resp.out_tokens,
448                    cost_usd: model_cost,
449                    latency_ms: ms,
450                    gates: gate_results,
451                    verdict,
452                });
453                best = Some((idx as u32, resp));
454
455                if serve {
456                    served_rung = Some(idx as u32);
457                    done = true;
458                } else if let Some(cap) = ctx.budget_per_request_usd
459                    && spent >= cap
460                    && idx + 1 < rung_limit
461                {
462                    done = true;
463                }
464                idx += 1;
465            }
466        }
467    }
468
469    // Speculative rungs we never gated: those already finished DID bill us (honest waste, recorded
470    // in `spent`); those still in flight are cancelled — `abort()` drops the in-flight reqwest.
471    // ponytail: harvest is best-effort — a call that finishes between is_finished() and abort() is
472    // dropped uncounted; exact wasted-spend under cancellation is unknowable, don't fabricate it.
473    for (j, handle) in inflight.drain() {
474        if handle.is_finished() {
475            if let Ok(Ok(resp)) = handle.await {
476                spent += ctx
477                    .prices
478                    .cost_usd(&ctx.ladder[j], resp.in_tokens, resp.out_tokens)
479                    .unwrap_or(0.0);
480            }
481        } else {
482            handle.abort();
483        }
484    }
485
486    LadderRun {
487        attempts,
488        spent,
489        gate_cost_total,
490        best,
491        served_rung,
492        hard_error,
493    }
494}
495
496/// Spawn a rung's `complete()` as a concurrent task, or `None` if the model ref is malformed or its
497/// provider isn't registered (the consume path records that abstain in ladder order).
498fn spawn_rung(
499    ctx: &EnforceCtx<'_>,
500    j: usize,
501) -> Option<JoinHandle<Result<ModelResponse, ProviderError>>> {
502    let model_str = ctx.ladder.get(j)?;
503    let provider = match ModelRef::parse(model_str) {
504        Ok(m) => ctx.providers.get(&m.provider)?,
505        Err(_) => return None,
506    };
507    let mut req = ctx.base_request.clone();
508    req.model = model_str.clone();
509    let auth = ctx.auth.clone();
510    Some(tokio::spawn(
511        async move { provider.complete(&req, &auth).await },
512    ))
513}
514
515fn elapsed_ms(start: Instant) -> u64 {
516    u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
517}
518
519/// An attempt that produced no gradable output (provider error / missing provider).
520fn abstain_attempt(rung: u32, model: &str, provider: &str, reason: &str, ms: u64) -> Attempt {
521    Attempt {
522        rung,
523        model: model.to_owned(),
524        provider: provider.to_owned(),
525        in_tokens: 0,
526        out_tokens: 0,
527        cost_usd: 0.0,
528        latency_ms: ms,
529        gates: vec![GateResult::abstain(provider, reason, ms)],
530        verdict: Verdict::Abstain,
531    }
532}
533
534/// Map a hard (non-failover) provider error to an abstain reason + a caller-facing message.
535fn hard_reason(err: &ProviderError) -> (&'static str, String) {
536    match err {
537        ProviderError::Http { status, .. } => {
538            (reason::PROVIDER_ERROR, format!("upstream http {status}"))
539        }
540        ProviderError::Decode(_) => (reason::PROVIDER_ERROR, "upstream decode error".to_owned()),
541        ProviderError::Transport(_) => (
542            reason::PROVIDER_ERROR,
543            "upstream transport error".to_owned(),
544        ),
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use crate::gate::{JsonValidGate, NonEmptyGate};
552    use crate::provider::{MockProvider, Provider};
553    use serde_json::Value;
554    use std::collections::HashMap;
555    use std::sync::Arc;
556
557    const HAIKU: &str = "anthropic/claude-haiku-4-5";
558    const SONNET: &str = "anthropic/claude-sonnet-5";
559    const OPUS: &str = "anthropic/claude-opus-4-8";
560    const GPT: &str = "openai/gpt-5.5";
561
562    fn resp(model: &str, text: &str) -> ModelResponse {
563        ModelResponse {
564            model: model.to_owned(),
565            text: text.to_owned(),
566            in_tokens: 1000,
567            out_tokens: 500,
568            raw: Value::Null,
569        }
570    }
571
572    fn base_request() -> ModelRequest {
573        ModelRequest {
574            model: String::new(),
575            system: None,
576            messages: vec![crate::provider::ChatMessage {
577                role: "user".into(),
578                content: "hi".into(),
579            }],
580            max_tokens: 256,
581            tools: Value::Null,
582        }
583    }
584
585    /// Build a registry where each provider id answers a per-model outcome map.
586    fn registry(
587        outcomes: Vec<(&str, &str, Result<ModelResponse, ProviderError>)>,
588    ) -> ProviderRegistry {
589        let mut by_provider: HashMap<
590            String,
591            HashMap<String, Result<ModelResponse, ProviderError>>,
592        > = HashMap::new();
593        for (provider, model, out) in outcomes {
594            by_provider
595                .entry(provider.to_owned())
596                .or_default()
597                .insert(model.to_owned(), out);
598        }
599        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
600        for (pid, outs) in by_provider {
601            map.insert(pid.clone(), Arc::new(MockProvider::new(pid, outs)));
602        }
603        ProviderRegistry::from_map(map)
604    }
605
606    #[allow(clippy::too_many_arguments)]
607    fn ctx<'a>(
608        ladder: &'a [String],
609        gates: &'a [Box<dyn Gate>],
610        req: &'a ModelRequest,
611        providers: &'a ProviderRegistry,
612        auth: &'a Auth,
613        prices: &'a PriceTable,
614        budget: Option<f64>,
615        health: &'a GateHealthRegistry,
616    ) -> EnforceCtx<'a> {
617        EnforceCtx {
618            ladder,
619            gates,
620            health,
621            base_request: req,
622            providers,
623            auth,
624            prices,
625            budget_per_request_usd: budget,
626            max_rungs: 3,
627            speculation: 0,
628            serve_threshold: None,
629            features: Features::new(firstpass_core::TaskKind::CodeEdit),
630            tenant_id: "acme".into(),
631            session_id: "sess-1".into(),
632            prompt_hash: "deadbeef".into(),
633            api: "anthropic.messages".into(),
634            policy_id: "static-ladder@v0".into(),
635        }
636    }
637
638    #[tokio::test]
639    async fn serve_first_pass_no_escalation_with_savings() {
640        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
641        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
642        let req = base_request();
643        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, r#"{"ok":1}"#)))]);
644        let (auth, prices) = (Auth::default(), PriceTable::defaults());
645        let health = GateHealthRegistry::new();
646        let (out, trace) = route_enforce(ctx(
647            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
648        ))
649        .await;
650
651        match out {
652            EngineOutcome::Served(r) => assert_eq!(r.model, HAIKU),
653            EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
654        }
655        assert_eq!(trace.attempts.len(), 1);
656        assert_eq!(trace.final_.escalations, 0);
657        assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
658        assert_eq!(trace.final_.served_rung, Some(0));
659        assert!(
660            trace.final_.savings_usd > 0.0,
661            "top-rung baseline should exceed haiku cost"
662        );
663    }
664
665    #[tokio::test]
666    async fn escalate_on_gate_fail() {
667        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
668        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
669        let req = base_request();
670        // Haiku returns empty (fails non-empty); Sonnet returns text (passes).
671        let providers = registry(vec![
672            ("anthropic", HAIKU, Ok(resp(HAIKU, "   "))),
673            ("anthropic", SONNET, Ok(resp(SONNET, "real answer"))),
674        ]);
675        let (auth, prices) = (Auth::default(), PriceTable::defaults());
676        let health = GateHealthRegistry::new();
677        let (out, trace) = route_enforce(ctx(
678            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
679        ))
680        .await;
681
682        assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
683        assert_eq!(trace.attempts.len(), 2);
684        assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
685        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
686        assert_eq!(trace.final_.escalations, 1);
687        assert_eq!(trace.final_.served_rung, Some(1));
688    }
689
690    #[tokio::test]
691    async fn cross_provider_failover_on_transport_error() {
692        // Rung 0 is anthropic (transport error), rung 1 is openai (succeeds).
693        let ladder = vec![HAIKU.to_owned(), GPT.to_owned()];
694        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
695        let req = base_request();
696        let providers = registry(vec![
697            (
698                "anthropic",
699                HAIKU,
700                Err(ProviderError::Transport("connection refused".into())),
701            ),
702            ("openai", GPT, Ok(resp(GPT, "answer from openai"))),
703        ]);
704        let (auth, prices) = (Auth::default(), PriceTable::defaults());
705        let health = GateHealthRegistry::new();
706        let (out, trace) = route_enforce(ctx(
707            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
708        ))
709        .await;
710
711        assert!(matches!(out, EngineOutcome::Served(r) if r.model == GPT));
712        assert_eq!(trace.attempts[0].verdict, Verdict::Abstain);
713        assert_eq!(
714            trace.attempts[0].gates[0].reason.as_deref(),
715            Some(reason::PROVIDER_ERROR)
716        );
717        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
718        assert_eq!(trace.final_.served_rung, Some(1));
719    }
720
721    #[tokio::test]
722    async fn budget_cap_stops_escalation() {
723        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
724        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
725        let req = base_request();
726        // All fail the gate (empty), so it would climb — but a tiny budget cuts it short.
727        let providers = registry(vec![
728            ("anthropic", HAIKU, Ok(resp(HAIKU, ""))),
729            ("anthropic", SONNET, Ok(resp(SONNET, ""))),
730            ("anthropic", OPUS, Ok(resp(OPUS, ""))),
731        ]);
732        let (auth, prices) = (Auth::default(), PriceTable::defaults());
733        let health = GateHealthRegistry::new();
734        let (_out, trace) = route_enforce(ctx(
735            &ladder,
736            &gates,
737            &req,
738            &providers,
739            &auth,
740            &prices,
741            Some(0.0),
742            &health,
743        ))
744        .await;
745        assert!(
746            trace.attempts.len() < 3,
747            "budget should cut escalation short, got {}",
748            trace.attempts.len()
749        );
750    }
751
752    #[tokio::test]
753    async fn all_fail_serves_best_attempt() {
754        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
755        let gates: Vec<Box<dyn Gate>> = vec![Box::new(JsonValidGate)]; // demand JSON
756        let req = base_request();
757        let providers = registry(vec![
758            ("anthropic", HAIKU, Ok(resp(HAIKU, "not json"))),
759            ("anthropic", SONNET, Ok(resp(SONNET, "still not json"))),
760        ]);
761        let (auth, prices) = (Auth::default(), PriceTable::defaults());
762        let health = GateHealthRegistry::new();
763        let (out, trace) = route_enforce(ctx(
764            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
765        ))
766        .await;
767
768        assert!(
769            matches!(out, EngineOutcome::Served(r) if r.model == SONNET),
770            "serves highest attempt"
771        );
772        assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
773        assert_eq!(trace.final_.served_rung, Some(1));
774    }
775
776    #[tokio::test]
777    async fn hard_4xx_does_not_escalate() {
778        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
779        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
780        let req = base_request();
781        let providers = registry(vec![
782            (
783                "anthropic",
784                HAIKU,
785                Err(ProviderError::Http {
786                    status: 400,
787                    body: "bad request".into(),
788                }),
789            ),
790            ("anthropic", SONNET, Ok(resp(SONNET, "would have worked"))),
791        ]);
792        let (auth, prices) = (Auth::default(), PriceTable::defaults());
793        let health = GateHealthRegistry::new();
794        let (out, trace) = route_enforce(ctx(
795            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
796        ))
797        .await;
798
799        assert!(
800            matches!(out, EngineOutcome::Failed(_)),
801            "4xx is a hard error, not failover"
802        );
803        assert_eq!(
804            trace.attempts.len(),
805            1,
806            "must not escalate past a client error"
807        );
808        assert_eq!(trace.final_.served_from, ServedFrom::Error);
809    }
810
811    #[tokio::test]
812    async fn counterfactual_and_savings_math() {
813        let ladder = vec![HAIKU.to_owned(), OPUS.to_owned()];
814        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
815        let req = base_request();
816        let served = resp(HAIKU, "answer");
817        let (in_t, out_t) = (served.in_tokens, served.out_tokens);
818        let providers = registry(vec![("anthropic", HAIKU, Ok(served))]);
819        let (auth, prices) = (Auth::default(), PriceTable::defaults());
820        let health = GateHealthRegistry::new();
821        let (_out, trace) = route_enforce(ctx(
822            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
823        ))
824        .await;
825
826        let expected_baseline = prices.cost_usd(OPUS, in_t, out_t).unwrap();
827        assert!((trace.final_.counterfactual_baseline_usd - expected_baseline).abs() < 1e-12);
828        let expected_savings = expected_baseline - trace.final_.total_cost_usd;
829        assert!((trace.final_.savings_usd - expected_savings).abs() < 1e-12);
830        assert!(trace.final_.savings_usd > 0.0);
831    }
832
833    #[tokio::test]
834    async fn produced_trace_is_chain_verifiable() {
835        let ladder = vec![HAIKU.to_owned()];
836        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
837        let req = base_request();
838        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "ok")))]);
839        let (auth, prices) = (Auth::default(), PriceTable::defaults());
840        let health = GateHealthRegistry::new();
841        let (_out, trace) = route_enforce(ctx(
842            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
843        ))
844        .await;
845
846        // A single trace with the genesis prev_hash must form a valid 1-long chain.
847        assert!(firstpass_core::verify_chain(std::slice::from_ref(&trace), GENESIS_HASH).is_ok());
848        // And it must round-trip through JSON (wire/audit contract).
849        let json = serde_json::to_string(&trace).unwrap();
850        let _back: Trace = serde_json::from_str(&json).unwrap();
851    }
852
853    #[tokio::test]
854    async fn auto_disabled_gate_is_skipped_by_the_engine() {
855        // An empty candidate would FAIL the non-empty gate — but once that gate is auto-disabled
856        // (over its error budget), the engine skips it, so rung 0 serves with no gate verdict.
857        let ladder = vec![HAIKU.to_owned()];
858        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
859        let req = base_request();
860        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "")))]); // empty text
861        let (auth, prices) = (Auth::default(), PriceTable::defaults());
862
863        // Drive the "non-empty" budget over threshold so it is disabled before the run, for the
864        // same tenant `ctx()` below uses ("acme") — the budget is per-(tenant, gate).
865        let health = GateHealthRegistry::new().with_budget("non-empty", 4, 0.5);
866        for _ in 0..4 {
867            health.record("acme", "non-empty", true);
868        }
869        assert!(
870            !health.enabled("acme", "non-empty"),
871            "precondition: gate is auto-disabled"
872        );
873
874        let (out, trace) = route_enforce(ctx(
875            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
876        ))
877        .await;
878        assert!(matches!(out, EngineOutcome::Served(_)));
879        assert_eq!(trace.final_.served_rung, Some(0));
880        assert!(
881            trace.attempts[0].gates.is_empty(),
882            "disabled gate must be skipped, not run"
883        );
884    }
885
886    /// Like [`registry`], but every model is served by one `anthropic` mock, and its shared call
887    /// log is returned so a test can see which rungs `complete()` actually fired.
888    fn counted_registry(
889        outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
890    ) -> (
891        ProviderRegistry,
892        std::sync::Arc<std::sync::Mutex<Vec<String>>>,
893    ) {
894        let mut outs = HashMap::new();
895        for (model, out) in outcomes {
896            outs.insert(model.to_owned(), out);
897        }
898        let mock = MockProvider::new("anthropic", outs);
899        let log = mock.call_log();
900        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
901        map.insert("anthropic".to_owned(), Arc::new(mock));
902        (ProviderRegistry::from_map(map), log)
903    }
904
905    #[tokio::test]
906    async fn speculation_prefetches_next_rung_but_serves_identically() {
907        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
908        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
909        let req = base_request();
910        let (auth, prices) = (Auth::default(), PriceTable::defaults());
911
912        // Serial baseline: rung 0 passes → rung 1 is never even called.
913        let (providers, log) = counted_registry(vec![
914            (HAIKU, Ok(resp(HAIKU, "answer"))),
915            (SONNET, Ok(resp(SONNET, "other"))),
916        ]);
917        let health = GateHealthRegistry::new();
918        let (serial_out, serial_trace) = route_enforce(ctx(
919            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
920        ))
921        .await;
922        assert_eq!(
923            *log.lock().unwrap(),
924            vec![HAIKU.to_owned()],
925            "serial must not touch rung 1 when rung 0 passes"
926        );
927
928        // Speculative (k=1): rung 1 fires concurrently, but rung 0 still serves.
929        let (providers, log) = counted_registry(vec![
930            (HAIKU, Ok(resp(HAIKU, "answer"))),
931            (SONNET, Ok(resp(SONNET, "other"))),
932        ]);
933        let health = GateHealthRegistry::new();
934        let mut c = ctx(
935            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
936        );
937        c.speculation = 1;
938        let (spec_out, spec_trace) = route_enforce(c).await;
939
940        assert!(
941            log.lock().unwrap().contains(&SONNET.to_owned()),
942            "speculation must fire rung 1 ahead: {:?}",
943            *log.lock().unwrap()
944        );
945
946        // Served result is byte-identical to serial (same rung, same bytes).
947        let (a, b) = match (serial_out, spec_out) {
948            (EngineOutcome::Served(a), EngineOutcome::Served(b)) => (a, b),
949            _ => panic!("both variants must serve"),
950        };
951        assert_eq!(
952            (a.model, a.text, a.out_tokens),
953            (b.model, b.text, b.out_tokens)
954        );
955        assert_eq!(spec_trace.final_.served_rung, Some(0));
956        assert_eq!(spec_trace.attempts.len(), 1, "only rung 0 is gated");
957        // Honest waste: the completed speculative rung's cost is recorded in the total.
958        assert!(
959            spec_trace.final_.total_cost_usd > serial_trace.final_.total_cost_usd,
960            "speculative waste must show in total cost: spec={} serial={}",
961            spec_trace.final_.total_cost_usd,
962            serial_trace.final_.total_cost_usd
963        );
964    }
965
966    #[tokio::test]
967    async fn speculation_preserves_escalation_result() {
968        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
969        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
970        let req = base_request();
971        let (auth, prices) = (Auth::default(), PriceTable::defaults());
972        // Rung 0 empty (fails non-empty), rung 1 real (passes) — prefetched concurrently.
973        let (providers, _log) = counted_registry(vec![
974            (HAIKU, Ok(resp(HAIKU, ""))),
975            (SONNET, Ok(resp(SONNET, "real answer"))),
976        ]);
977        let health = GateHealthRegistry::new();
978        let mut c = ctx(
979            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
980        );
981        c.speculation = 2; // window wider than the ladder must clamp, not panic
982        let (out, trace) = route_enforce(c).await;
983        match out {
984            EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
985            EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
986        }
987        assert_eq!(trace.final_.served_rung, Some(1));
988        assert_eq!(trace.attempts.len(), 2);
989        assert_eq!(trace.final_.escalations, 1);
990        assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
991        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
992    }
993
994    /// Latency A/B: p50/p95/p99 of serial vs speculative escalation over many requests. Every request
995    /// escalates (rung 0 fails the gate) and each rung costs ~DELAY ms; serial pays both rungs
996    /// sequentially while speculation prefetches rung 1 during rung 0's gate. Run with `--nocapture`
997    /// to see the distribution. Offline (mock delays) but the exact shape a live serial-vs-spec A/B
998    /// produces — swap the mock for a live provider for real-provider numbers.
999    #[tokio::test]
1000    async fn latency_ab_speculative_beats_serial_p95() {
1001        const DELAY: u64 = 40;
1002        const N: usize = 30;
1003        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1004        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1005        let req = base_request();
1006        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1007
1008        fn pctl(sorted: &[u64], p: f64) -> u64 {
1009            let i = (((sorted.len() - 1) as f64) * p).round() as usize;
1010            sorted[i]
1011        }
1012
1013        let mut serial = Vec::with_capacity(N);
1014        let mut spec = Vec::with_capacity(N);
1015        for run in 0..(2 * N) {
1016            let speculation = u32::from(run >= N); // first N serial, next N speculative
1017            let mut outs: HashMap<String, Result<ModelResponse, ProviderError>> = HashMap::new();
1018            outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); // empty → fails gate → escalate
1019            outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "ok")));
1020            let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1021            map.insert(
1022                "anthropic".to_owned(),
1023                Arc::new(MockProvider::new("anthropic", outs).with_delay(DELAY)),
1024            );
1025            let providers = ProviderRegistry::from_map(map);
1026            let health = GateHealthRegistry::new();
1027            let mut c = ctx(
1028                &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1029            );
1030            c.speculation = speculation;
1031            let start = std::time::Instant::now();
1032            let _ = route_enforce(c).await;
1033            let ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1034            if speculation == 0 {
1035                serial.push(ms);
1036            } else {
1037                spec.push(ms);
1038            }
1039        }
1040        serial.sort_unstable();
1041        spec.sort_unstable();
1042        println!(
1043            "latency A/B (per-rung {DELAY}ms, escalate every request):\n  serial     p50={} p95={} p99={}\n  spec(k=1)  p50={} p95={} p99={}",
1044            pctl(&serial, 0.5),
1045            pctl(&serial, 0.95),
1046            pctl(&serial, 0.99),
1047            pctl(&spec, 0.5),
1048            pctl(&spec, 0.95),
1049            pctl(&spec, 0.99),
1050        );
1051        // Speculation runs rung 0 + rung 1 concurrently → ~1 rung of latency vs ~2 serial.
1052        assert!(
1053            pctl(&spec, 0.95) * 4 < pctl(&serial, 0.95) * 3,
1054            "spec p95 {}ms should beat serial p95 {}ms by >25%",
1055            pctl(&spec, 0.95),
1056            pctl(&serial, 0.95)
1057        );
1058    }
1059
1060    #[tokio::test]
1061    async fn speculation_never_fires_past_max_rungs() {
1062        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1063        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1064        let req = base_request();
1065        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1066        // All fail the gate → best-attempt fallback serves the highest reached rung.
1067        let (providers, log) = counted_registry(vec![
1068            (HAIKU, Ok(resp(HAIKU, ""))),
1069            (SONNET, Ok(resp(SONNET, ""))),
1070            (OPUS, Ok(resp(OPUS, ""))),
1071        ]);
1072        let health = GateHealthRegistry::new();
1073        let mut c = ctx(
1074            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1075        );
1076        c.max_rungs = 2;
1077        c.speculation = 5; // huge window, but the ceiling is 2 rungs
1078        let (out, trace) = route_enforce(c).await;
1079
1080        assert!(
1081            !log.lock().unwrap().contains(&OPUS.to_owned()),
1082            "must not fire beyond max_rungs: {:?}",
1083            *log.lock().unwrap()
1084        );
1085        assert_eq!(trace.attempts.len(), 2);
1086        assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
1087        assert_eq!(trace.final_.served_rung, Some(1));
1088        match out {
1089            EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1090            EngineOutcome::Failed(e) => panic!("expected best-attempt served, got {e}"),
1091        }
1092    }
1093
1094    #[tokio::test]
1095    async fn speculation_cuts_wall_clock_vs_serial() {
1096        // The latency payoff, verified offline: rung 0 fails the gate, so serial pays rung 0 + rung
1097        // 1 latency *sequentially*; speculation fires both concurrently and finishes in ~one rung's
1098        // time. Timing-based, but the margin (a full 80ms rung) dwarfs scheduler jitter. This proves
1099        // the overlap mechanism — absolute live p95 still needs a real-provider run.
1100        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1101        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1102        let req = base_request();
1103        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1104
1105        let build = || {
1106            let mut outs = HashMap::new();
1107            outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); // fails non-empty
1108            outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "real answer"))); // passes
1109            let mock = MockProvider::new("anthropic", outs).with_delay(80);
1110            let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1111            map.insert("anthropic".to_owned(), Arc::new(mock));
1112            ProviderRegistry::from_map(map)
1113        };
1114
1115        let providers = build();
1116        let health = GateHealthRegistry::new();
1117        let t = std::time::Instant::now();
1118        let _ = route_enforce(ctx(
1119            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1120        ))
1121        .await;
1122        let serial = t.elapsed();
1123
1124        let providers = build();
1125        let health = GateHealthRegistry::new();
1126        let mut c = ctx(
1127            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1128        );
1129        c.speculation = 1;
1130        let t = std::time::Instant::now();
1131        let _ = route_enforce(c).await;
1132        let spec = t.elapsed();
1133
1134        assert!(
1135            spec < serial * 3 / 4,
1136            "speculation must overlap rung latencies: serial={serial:?} spec={spec:?}"
1137        );
1138    }
1139
1140    /// A gate that always passes but scores by parsing the candidate text as `f64` — lets a test
1141    /// drive an exact aggregate score without depending on a real gate's scoring internals.
1142    #[derive(Debug)]
1143    struct ScoreGate;
1144
1145    #[async_trait::async_trait]
1146    impl Gate for ScoreGate {
1147        fn id(&self) -> &str {
1148            "score"
1149        }
1150
1151        async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
1152            let score = resp.text.trim().parse::<f64>().unwrap_or(0.0);
1153            GateResult {
1154                gate_id: self.id().to_owned(),
1155                verdict: Verdict::Pass,
1156                score: Some(firstpass_core::Score::clamped(score)),
1157                cost_usd: 0.0,
1158                ms: 0,
1159                reason: None,
1160                evidence_ref: None,
1161            }
1162        }
1163    }
1164
1165    #[tokio::test]
1166    async fn serve_threshold_escalates_past_low_scoring_rung() {
1167        // Both rungs pass ScoreGate's verdict; only score decides. Haiku scores 0.5 (< 0.8, must
1168        // escalate even though it "passed"); Sonnet scores 0.9 (>= 0.8, serves).
1169        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1170        let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1171        let req = base_request();
1172        let providers = registry(vec![
1173            ("anthropic", HAIKU, Ok(resp(HAIKU, "0.5"))),
1174            ("anthropic", SONNET, Ok(resp(SONNET, "0.9"))),
1175        ]);
1176        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1177        let health = GateHealthRegistry::new();
1178        let mut c = ctx(
1179            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1180        );
1181        c.serve_threshold = Some(0.8);
1182        let (out, trace) = route_enforce(c).await;
1183
1184        assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1185        assert_eq!(trace.attempts.len(), 2);
1186        // Both rungs actually passed the gate's verdict — proving escalation was score-driven.
1187        assert_eq!(trace.attempts[0].verdict, Verdict::Pass);
1188        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1189        assert_eq!(trace.final_.escalations, 1);
1190        assert_eq!(trace.final_.served_rung, Some(1));
1191        assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1192    }
1193
1194    #[tokio::test]
1195    async fn serve_threshold_does_not_serve_a_pass_below_threshold() {
1196        // A single-rung ladder: the gate passes it, but its score (0.3) is below the 0.8
1197        // threshold, so it must NOT serve as a normal pass — it can only be reached via the
1198        // best-attempt fallback once the ladder is exhausted.
1199        let ladder = vec![HAIKU.to_owned()];
1200        let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1201        let req = base_request();
1202        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.3")))]);
1203        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1204        let health = GateHealthRegistry::new();
1205        let mut c = ctx(
1206            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1207        );
1208        c.serve_threshold = Some(0.8);
1209        let (out, trace) = route_enforce(c).await;
1210
1211        assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1212        assert_eq!(
1213            trace.attempts[0].verdict,
1214            Verdict::Pass,
1215            "gate verdict was Pass"
1216        );
1217        assert_eq!(
1218            trace.final_.served_from,
1219            ServedFrom::BestAttempt,
1220            "score below threshold must fall back, not serve as a normal pass"
1221        );
1222    }
1223
1224    #[tokio::test]
1225    async fn serve_threshold_none_serves_on_verdict_regardless_of_score() {
1226        // Same low-scoring rung as above, but with no threshold configured: today's rule (verdict
1227        // alone) must serve it as a normal pass.
1228        let ladder = vec![HAIKU.to_owned()];
1229        let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1230        let req = base_request();
1231        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.1")))]);
1232        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1233        let health = GateHealthRegistry::new();
1234        let c = ctx(
1235            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1236        );
1237        assert_eq!(c.serve_threshold, None);
1238        let (out, trace) = route_enforce(c).await;
1239
1240        assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1241        assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1242    }
1243}