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(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.record(g.id(), r.verdict == Verdict::Abstain);
268                    gate_results.push(r);
269                }
270                let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
271                gate_cost_total += gc;
272                spent += gc;
273
274                let verdict = aggregate(&gate_results);
275                let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
276                attempts.push(Attempt {
277                    rung: idx,
278                    model: model_str.clone(),
279                    provider: provider.id().to_owned(),
280                    in_tokens: resp.in_tokens,
281                    out_tokens: resp.out_tokens,
282                    cost_usd: model_cost,
283                    latency_ms: ms,
284                    gates: gate_results,
285                    verdict,
286                });
287                best = Some((idx, resp));
288
289                if serve {
290                    served_rung = Some(idx);
291                    break;
292                }
293                // Gate failed → escalate, unless the budget is already spent and a next rung exists.
294                if let Some(cap) = ctx.budget_per_request_usd
295                    && spent >= cap
296                    && (idx as usize) + 1 < rung_limit
297                {
298                    break;
299                }
300            }
301        }
302    }
303
304    LadderRun {
305        attempts,
306        spent,
307        gate_cost_total,
308        best,
309        served_rung,
310        hard_error,
311    }
312}
313
314/// Speculative engine: prefetch up to `speculation` rungs ahead concurrently, but gate strictly in
315/// ladder order and serve the first rung whose gate passes. The SERVED result is therefore
316/// byte-identical to [`run_serial`] — only latency (prefetched rungs are already in flight) and
317/// honest wasted spend (speculative calls that completed but weren't served) differ.
318async fn run_speculative(ctx: &EnforceCtx<'_>) -> LadderRun {
319    let mut attempts: Vec<Attempt> = Vec::new();
320    let mut spent = 0.0_f64;
321    let mut gate_cost_total = 0.0_f64;
322    let mut best: Option<(u32, ModelResponse)> = None;
323    let mut served_rung: Option<u32> = None;
324    let mut hard_error: Option<String> = None;
325
326    let rung_limit = (ctx.max_rungs as usize).min(ctx.ladder.len());
327    let speculation = ctx.speculation as usize;
328    let mut inflight: HashMap<usize, JoinHandle<Result<ModelResponse, ProviderError>>> =
329        HashMap::new();
330
331    let mut idx = 0usize;
332    // `done` = a rung passed or hard-errored: stop consuming, then cancel/harvest the rest.
333    let mut done = false;
334    while idx < rung_limit && !done {
335        // Fire the window [idx ..= idx+speculation] concurrently. The rung we must gate now (idx)
336        // always fires; rungs ahead only while under budget, so speculation can't blow the cap.
337        let window_end = (idx + speculation).min(rung_limit - 1);
338        for j in idx..=window_end {
339            if inflight.contains_key(&j) {
340                continue;
341            }
342            if j > idx
343                && let Some(cap) = ctx.budget_per_request_usd
344                && spent >= cap
345            {
346                continue;
347            }
348            if let Some(handle) = spawn_rung(ctx, j) {
349                inflight.insert(j, handle);
350            }
351        }
352
353        let model_str = &ctx.ladder[idx];
354        let provider = match ModelRef::parse(model_str) {
355            Ok(m) => ctx.providers.get(&m.provider),
356            Err(_) => None,
357        };
358        let Some(provider) = provider else {
359            // Malformed ref / unknown provider: abstain and fail over (no task was spawned).
360            attempts.push(abstain_attempt(
361                idx as u32,
362                model_str,
363                "unknown",
364                reason::PROVIDER_ERROR,
365                0,
366            ));
367            idx += 1;
368            continue;
369        };
370        // Provider resolved ⇒ we spawned a task for `idx`; await it in strict ladder order.
371        let Some(handle) = inflight.remove(&idx) else {
372            attempts.push(abstain_attempt(
373                idx as u32,
374                model_str,
375                provider.id(),
376                reason::PROVIDER_ERROR,
377                0,
378            ));
379            idx += 1;
380            continue;
381        };
382        let start = Instant::now();
383        let joined = handle.await;
384        let ms = elapsed_ms(start);
385
386        match joined {
387            // Task panicked or was aborted out from under us: treat as a transport abstain.
388            Err(_) => {
389                attempts.push(abstain_attempt(
390                    idx as u32,
391                    model_str,
392                    provider.id(),
393                    reason::PROVIDER_ERROR,
394                    ms,
395                ));
396                idx += 1;
397            }
398            Ok(Err(err)) if err.is_failover_eligible() => {
399                attempts.push(abstain_attempt(
400                    idx as u32,
401                    model_str,
402                    provider.id(),
403                    reason::PROVIDER_ERROR,
404                    ms,
405                ));
406                idx += 1;
407            }
408            Ok(Err(err)) => {
409                // Hard error (4xx / decode): do not escalate — the request itself is the problem.
410                let (r, msg) = hard_reason(&err);
411                attempts.push(abstain_attempt(idx as u32, model_str, provider.id(), r, ms));
412                hard_error = Some(msg);
413                done = true;
414            }
415            Ok(Ok(resp)) => {
416                let model_cost = ctx
417                    .prices
418                    .cost_usd(model_str, resp.in_tokens, resp.out_tokens)
419                    .unwrap_or(0.0);
420                spent += model_cost;
421
422                let mut req = ctx.base_request.clone();
423                req.model = model_str.clone();
424                let mut gate_results: Vec<GateResult> = Vec::with_capacity(ctx.gates.len());
425                for g in ctx.gates {
426                    if !ctx.health.enabled(g.id()) {
427                        tracing::warn!(gate = %g.id(), "skipping auto-disabled gate");
428                        continue;
429                    }
430                    let r = g.evaluate(&req, &resp).await;
431                    ctx.health.record(g.id(), r.verdict == Verdict::Abstain);
432                    gate_results.push(r);
433                }
434                let gc: f64 = gate_results.iter().map(|g| g.cost_usd).sum();
435                gate_cost_total += gc;
436                spent += gc;
437
438                let verdict = aggregate(&gate_results);
439                let serve = should_serve(ctx.serve_threshold, &gate_results, verdict);
440                attempts.push(Attempt {
441                    rung: idx as u32,
442                    model: model_str.clone(),
443                    provider: provider.id().to_owned(),
444                    in_tokens: resp.in_tokens,
445                    out_tokens: resp.out_tokens,
446                    cost_usd: model_cost,
447                    latency_ms: ms,
448                    gates: gate_results,
449                    verdict,
450                });
451                best = Some((idx as u32, resp));
452
453                if serve {
454                    served_rung = Some(idx as u32);
455                    done = true;
456                } else if let Some(cap) = ctx.budget_per_request_usd
457                    && spent >= cap
458                    && idx + 1 < rung_limit
459                {
460                    done = true;
461                }
462                idx += 1;
463            }
464        }
465    }
466
467    // Speculative rungs we never gated: those already finished DID bill us (honest waste, recorded
468    // in `spent`); those still in flight are cancelled — `abort()` drops the in-flight reqwest.
469    // ponytail: harvest is best-effort — a call that finishes between is_finished() and abort() is
470    // dropped uncounted; exact wasted-spend under cancellation is unknowable, don't fabricate it.
471    for (j, handle) in inflight.drain() {
472        if handle.is_finished() {
473            if let Ok(Ok(resp)) = handle.await {
474                spent += ctx
475                    .prices
476                    .cost_usd(&ctx.ladder[j], resp.in_tokens, resp.out_tokens)
477                    .unwrap_or(0.0);
478            }
479        } else {
480            handle.abort();
481        }
482    }
483
484    LadderRun {
485        attempts,
486        spent,
487        gate_cost_total,
488        best,
489        served_rung,
490        hard_error,
491    }
492}
493
494/// Spawn a rung's `complete()` as a concurrent task, or `None` if the model ref is malformed or its
495/// provider isn't registered (the consume path records that abstain in ladder order).
496fn spawn_rung(
497    ctx: &EnforceCtx<'_>,
498    j: usize,
499) -> Option<JoinHandle<Result<ModelResponse, ProviderError>>> {
500    let model_str = ctx.ladder.get(j)?;
501    let provider = match ModelRef::parse(model_str) {
502        Ok(m) => ctx.providers.get(&m.provider)?,
503        Err(_) => return None,
504    };
505    let mut req = ctx.base_request.clone();
506    req.model = model_str.clone();
507    let auth = ctx.auth.clone();
508    Some(tokio::spawn(
509        async move { provider.complete(&req, &auth).await },
510    ))
511}
512
513fn elapsed_ms(start: Instant) -> u64 {
514    u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX)
515}
516
517/// An attempt that produced no gradable output (provider error / missing provider).
518fn abstain_attempt(rung: u32, model: &str, provider: &str, reason: &str, ms: u64) -> Attempt {
519    Attempt {
520        rung,
521        model: model.to_owned(),
522        provider: provider.to_owned(),
523        in_tokens: 0,
524        out_tokens: 0,
525        cost_usd: 0.0,
526        latency_ms: ms,
527        gates: vec![GateResult::abstain(provider, reason, ms)],
528        verdict: Verdict::Abstain,
529    }
530}
531
532/// Map a hard (non-failover) provider error to an abstain reason + a caller-facing message.
533fn hard_reason(err: &ProviderError) -> (&'static str, String) {
534    match err {
535        ProviderError::Http { status, .. } => {
536            (reason::PROVIDER_ERROR, format!("upstream http {status}"))
537        }
538        ProviderError::Decode(_) => (reason::PROVIDER_ERROR, "upstream decode error".to_owned()),
539        ProviderError::Transport(_) => (
540            reason::PROVIDER_ERROR,
541            "upstream transport error".to_owned(),
542        ),
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::gate::{JsonValidGate, NonEmptyGate};
550    use crate::provider::{MockProvider, Provider};
551    use serde_json::Value;
552    use std::collections::HashMap;
553    use std::sync::Arc;
554
555    const HAIKU: &str = "anthropic/claude-haiku-4-5";
556    const SONNET: &str = "anthropic/claude-sonnet-5";
557    const OPUS: &str = "anthropic/claude-opus-4-8";
558    const GPT: &str = "openai/gpt-5.5";
559
560    fn resp(model: &str, text: &str) -> ModelResponse {
561        ModelResponse {
562            model: model.to_owned(),
563            text: text.to_owned(),
564            in_tokens: 1000,
565            out_tokens: 500,
566            raw: Value::Null,
567        }
568    }
569
570    fn base_request() -> ModelRequest {
571        ModelRequest {
572            model: String::new(),
573            system: None,
574            messages: vec![crate::provider::ChatMessage {
575                role: "user".into(),
576                content: "hi".into(),
577            }],
578            max_tokens: 256,
579            tools: Value::Null,
580        }
581    }
582
583    /// Build a registry where each provider id answers a per-model outcome map.
584    fn registry(
585        outcomes: Vec<(&str, &str, Result<ModelResponse, ProviderError>)>,
586    ) -> ProviderRegistry {
587        let mut by_provider: HashMap<
588            String,
589            HashMap<String, Result<ModelResponse, ProviderError>>,
590        > = HashMap::new();
591        for (provider, model, out) in outcomes {
592            by_provider
593                .entry(provider.to_owned())
594                .or_default()
595                .insert(model.to_owned(), out);
596        }
597        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
598        for (pid, outs) in by_provider {
599            map.insert(pid.clone(), Arc::new(MockProvider::new(pid, outs)));
600        }
601        ProviderRegistry::from_map(map)
602    }
603
604    #[allow(clippy::too_many_arguments)]
605    fn ctx<'a>(
606        ladder: &'a [String],
607        gates: &'a [Box<dyn Gate>],
608        req: &'a ModelRequest,
609        providers: &'a ProviderRegistry,
610        auth: &'a Auth,
611        prices: &'a PriceTable,
612        budget: Option<f64>,
613        health: &'a GateHealthRegistry,
614    ) -> EnforceCtx<'a> {
615        EnforceCtx {
616            ladder,
617            gates,
618            health,
619            base_request: req,
620            providers,
621            auth,
622            prices,
623            budget_per_request_usd: budget,
624            max_rungs: 3,
625            speculation: 0,
626            serve_threshold: None,
627            features: Features::new(firstpass_core::TaskKind::CodeEdit),
628            tenant_id: "acme".into(),
629            session_id: "sess-1".into(),
630            prompt_hash: "deadbeef".into(),
631            api: "anthropic.messages".into(),
632            policy_id: "static-ladder@v0".into(),
633        }
634    }
635
636    #[tokio::test]
637    async fn serve_first_pass_no_escalation_with_savings() {
638        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
639        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
640        let req = base_request();
641        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, r#"{"ok":1}"#)))]);
642        let (auth, prices) = (Auth::default(), PriceTable::defaults());
643        let health = GateHealthRegistry::new();
644        let (out, trace) = route_enforce(ctx(
645            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
646        ))
647        .await;
648
649        match out {
650            EngineOutcome::Served(r) => assert_eq!(r.model, HAIKU),
651            EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
652        }
653        assert_eq!(trace.attempts.len(), 1);
654        assert_eq!(trace.final_.escalations, 0);
655        assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
656        assert_eq!(trace.final_.served_rung, Some(0));
657        assert!(
658            trace.final_.savings_usd > 0.0,
659            "top-rung baseline should exceed haiku cost"
660        );
661    }
662
663    #[tokio::test]
664    async fn escalate_on_gate_fail() {
665        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
666        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
667        let req = base_request();
668        // Haiku returns empty (fails non-empty); Sonnet returns text (passes).
669        let providers = registry(vec![
670            ("anthropic", HAIKU, Ok(resp(HAIKU, "   "))),
671            ("anthropic", SONNET, Ok(resp(SONNET, "real answer"))),
672        ]);
673        let (auth, prices) = (Auth::default(), PriceTable::defaults());
674        let health = GateHealthRegistry::new();
675        let (out, trace) = route_enforce(ctx(
676            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
677        ))
678        .await;
679
680        assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
681        assert_eq!(trace.attempts.len(), 2);
682        assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
683        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
684        assert_eq!(trace.final_.escalations, 1);
685        assert_eq!(trace.final_.served_rung, Some(1));
686    }
687
688    #[tokio::test]
689    async fn cross_provider_failover_on_transport_error() {
690        // Rung 0 is anthropic (transport error), rung 1 is openai (succeeds).
691        let ladder = vec![HAIKU.to_owned(), GPT.to_owned()];
692        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
693        let req = base_request();
694        let providers = registry(vec![
695            (
696                "anthropic",
697                HAIKU,
698                Err(ProviderError::Transport("connection refused".into())),
699            ),
700            ("openai", GPT, Ok(resp(GPT, "answer from openai"))),
701        ]);
702        let (auth, prices) = (Auth::default(), PriceTable::defaults());
703        let health = GateHealthRegistry::new();
704        let (out, trace) = route_enforce(ctx(
705            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
706        ))
707        .await;
708
709        assert!(matches!(out, EngineOutcome::Served(r) if r.model == GPT));
710        assert_eq!(trace.attempts[0].verdict, Verdict::Abstain);
711        assert_eq!(
712            trace.attempts[0].gates[0].reason.as_deref(),
713            Some(reason::PROVIDER_ERROR)
714        );
715        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
716        assert_eq!(trace.final_.served_rung, Some(1));
717    }
718
719    #[tokio::test]
720    async fn budget_cap_stops_escalation() {
721        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
722        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
723        let req = base_request();
724        // All fail the gate (empty), so it would climb — but a tiny budget cuts it short.
725        let providers = registry(vec![
726            ("anthropic", HAIKU, Ok(resp(HAIKU, ""))),
727            ("anthropic", SONNET, Ok(resp(SONNET, ""))),
728            ("anthropic", OPUS, Ok(resp(OPUS, ""))),
729        ]);
730        let (auth, prices) = (Auth::default(), PriceTable::defaults());
731        let health = GateHealthRegistry::new();
732        let (_out, trace) = route_enforce(ctx(
733            &ladder,
734            &gates,
735            &req,
736            &providers,
737            &auth,
738            &prices,
739            Some(0.0),
740            &health,
741        ))
742        .await;
743        assert!(
744            trace.attempts.len() < 3,
745            "budget should cut escalation short, got {}",
746            trace.attempts.len()
747        );
748    }
749
750    #[tokio::test]
751    async fn all_fail_serves_best_attempt() {
752        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
753        let gates: Vec<Box<dyn Gate>> = vec![Box::new(JsonValidGate)]; // demand JSON
754        let req = base_request();
755        let providers = registry(vec![
756            ("anthropic", HAIKU, Ok(resp(HAIKU, "not json"))),
757            ("anthropic", SONNET, Ok(resp(SONNET, "still not json"))),
758        ]);
759        let (auth, prices) = (Auth::default(), PriceTable::defaults());
760        let health = GateHealthRegistry::new();
761        let (out, trace) = route_enforce(ctx(
762            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
763        ))
764        .await;
765
766        assert!(
767            matches!(out, EngineOutcome::Served(r) if r.model == SONNET),
768            "serves highest attempt"
769        );
770        assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
771        assert_eq!(trace.final_.served_rung, Some(1));
772    }
773
774    #[tokio::test]
775    async fn hard_4xx_does_not_escalate() {
776        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
777        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
778        let req = base_request();
779        let providers = registry(vec![
780            (
781                "anthropic",
782                HAIKU,
783                Err(ProviderError::Http {
784                    status: 400,
785                    body: "bad request".into(),
786                }),
787            ),
788            ("anthropic", SONNET, Ok(resp(SONNET, "would have worked"))),
789        ]);
790        let (auth, prices) = (Auth::default(), PriceTable::defaults());
791        let health = GateHealthRegistry::new();
792        let (out, trace) = route_enforce(ctx(
793            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
794        ))
795        .await;
796
797        assert!(
798            matches!(out, EngineOutcome::Failed(_)),
799            "4xx is a hard error, not failover"
800        );
801        assert_eq!(
802            trace.attempts.len(),
803            1,
804            "must not escalate past a client error"
805        );
806        assert_eq!(trace.final_.served_from, ServedFrom::Error);
807    }
808
809    #[tokio::test]
810    async fn counterfactual_and_savings_math() {
811        let ladder = vec![HAIKU.to_owned(), OPUS.to_owned()];
812        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
813        let req = base_request();
814        let served = resp(HAIKU, "answer");
815        let (in_t, out_t) = (served.in_tokens, served.out_tokens);
816        let providers = registry(vec![("anthropic", HAIKU, Ok(served))]);
817        let (auth, prices) = (Auth::default(), PriceTable::defaults());
818        let health = GateHealthRegistry::new();
819        let (_out, trace) = route_enforce(ctx(
820            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
821        ))
822        .await;
823
824        let expected_baseline = prices.cost_usd(OPUS, in_t, out_t).unwrap();
825        assert!((trace.final_.counterfactual_baseline_usd - expected_baseline).abs() < 1e-12);
826        let expected_savings = expected_baseline - trace.final_.total_cost_usd;
827        assert!((trace.final_.savings_usd - expected_savings).abs() < 1e-12);
828        assert!(trace.final_.savings_usd > 0.0);
829    }
830
831    #[tokio::test]
832    async fn produced_trace_is_chain_verifiable() {
833        let ladder = vec![HAIKU.to_owned()];
834        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
835        let req = base_request();
836        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "ok")))]);
837        let (auth, prices) = (Auth::default(), PriceTable::defaults());
838        let health = GateHealthRegistry::new();
839        let (_out, trace) = route_enforce(ctx(
840            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
841        ))
842        .await;
843
844        // A single trace with the genesis prev_hash must form a valid 1-long chain.
845        assert!(firstpass_core::verify_chain(std::slice::from_ref(&trace), GENESIS_HASH).is_ok());
846        // And it must round-trip through JSON (wire/audit contract).
847        let json = serde_json::to_string(&trace).unwrap();
848        let _back: Trace = serde_json::from_str(&json).unwrap();
849    }
850
851    #[tokio::test]
852    async fn auto_disabled_gate_is_skipped_by_the_engine() {
853        // An empty candidate would FAIL the non-empty gate — but once that gate is auto-disabled
854        // (over its error budget), the engine skips it, so rung 0 serves with no gate verdict.
855        let ladder = vec![HAIKU.to_owned()];
856        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
857        let req = base_request();
858        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "")))]); // empty text
859        let (auth, prices) = (Auth::default(), PriceTable::defaults());
860
861        // Drive the "non-empty" budget over threshold so it is disabled before the run.
862        let health = GateHealthRegistry::new().with_budget("non-empty", 4, 0.5);
863        for _ in 0..4 {
864            health.record("non-empty", true);
865        }
866        assert!(
867            !health.enabled("non-empty"),
868            "precondition: gate is auto-disabled"
869        );
870
871        let (out, trace) = route_enforce(ctx(
872            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
873        ))
874        .await;
875        assert!(matches!(out, EngineOutcome::Served(_)));
876        assert_eq!(trace.final_.served_rung, Some(0));
877        assert!(
878            trace.attempts[0].gates.is_empty(),
879            "disabled gate must be skipped, not run"
880        );
881    }
882
883    /// Like [`registry`], but every model is served by one `anthropic` mock, and its shared call
884    /// log is returned so a test can see which rungs `complete()` actually fired.
885    fn counted_registry(
886        outcomes: Vec<(&str, Result<ModelResponse, ProviderError>)>,
887    ) -> (
888        ProviderRegistry,
889        std::sync::Arc<std::sync::Mutex<Vec<String>>>,
890    ) {
891        let mut outs = HashMap::new();
892        for (model, out) in outcomes {
893            outs.insert(model.to_owned(), out);
894        }
895        let mock = MockProvider::new("anthropic", outs);
896        let log = mock.call_log();
897        let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
898        map.insert("anthropic".to_owned(), Arc::new(mock));
899        (ProviderRegistry::from_map(map), log)
900    }
901
902    #[tokio::test]
903    async fn speculation_prefetches_next_rung_but_serves_identically() {
904        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
905        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
906        let req = base_request();
907        let (auth, prices) = (Auth::default(), PriceTable::defaults());
908
909        // Serial baseline: rung 0 passes → rung 1 is never even called.
910        let (providers, log) = counted_registry(vec![
911            (HAIKU, Ok(resp(HAIKU, "answer"))),
912            (SONNET, Ok(resp(SONNET, "other"))),
913        ]);
914        let health = GateHealthRegistry::new();
915        let (serial_out, serial_trace) = route_enforce(ctx(
916            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
917        ))
918        .await;
919        assert_eq!(
920            *log.lock().unwrap(),
921            vec![HAIKU.to_owned()],
922            "serial must not touch rung 1 when rung 0 passes"
923        );
924
925        // Speculative (k=1): rung 1 fires concurrently, but rung 0 still serves.
926        let (providers, log) = counted_registry(vec![
927            (HAIKU, Ok(resp(HAIKU, "answer"))),
928            (SONNET, Ok(resp(SONNET, "other"))),
929        ]);
930        let health = GateHealthRegistry::new();
931        let mut c = ctx(
932            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
933        );
934        c.speculation = 1;
935        let (spec_out, spec_trace) = route_enforce(c).await;
936
937        assert!(
938            log.lock().unwrap().contains(&SONNET.to_owned()),
939            "speculation must fire rung 1 ahead: {:?}",
940            *log.lock().unwrap()
941        );
942
943        // Served result is byte-identical to serial (same rung, same bytes).
944        let (a, b) = match (serial_out, spec_out) {
945            (EngineOutcome::Served(a), EngineOutcome::Served(b)) => (a, b),
946            _ => panic!("both variants must serve"),
947        };
948        assert_eq!(
949            (a.model, a.text, a.out_tokens),
950            (b.model, b.text, b.out_tokens)
951        );
952        assert_eq!(spec_trace.final_.served_rung, Some(0));
953        assert_eq!(spec_trace.attempts.len(), 1, "only rung 0 is gated");
954        // Honest waste: the completed speculative rung's cost is recorded in the total.
955        assert!(
956            spec_trace.final_.total_cost_usd > serial_trace.final_.total_cost_usd,
957            "speculative waste must show in total cost: spec={} serial={}",
958            spec_trace.final_.total_cost_usd,
959            serial_trace.final_.total_cost_usd
960        );
961    }
962
963    #[tokio::test]
964    async fn speculation_preserves_escalation_result() {
965        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
966        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
967        let req = base_request();
968        let (auth, prices) = (Auth::default(), PriceTable::defaults());
969        // Rung 0 empty (fails non-empty), rung 1 real (passes) — prefetched concurrently.
970        let (providers, _log) = counted_registry(vec![
971            (HAIKU, Ok(resp(HAIKU, ""))),
972            (SONNET, Ok(resp(SONNET, "real answer"))),
973        ]);
974        let health = GateHealthRegistry::new();
975        let mut c = ctx(
976            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
977        );
978        c.speculation = 2; // window wider than the ladder must clamp, not panic
979        let (out, trace) = route_enforce(c).await;
980        match out {
981            EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
982            EngineOutcome::Failed(e) => panic!("expected served, got {e}"),
983        }
984        assert_eq!(trace.final_.served_rung, Some(1));
985        assert_eq!(trace.attempts.len(), 2);
986        assert_eq!(trace.final_.escalations, 1);
987        assert_eq!(trace.attempts[0].verdict, Verdict::Fail);
988        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
989    }
990
991    /// Latency A/B: p50/p95/p99 of serial vs speculative escalation over many requests. Every request
992    /// escalates (rung 0 fails the gate) and each rung costs ~DELAY ms; serial pays both rungs
993    /// sequentially while speculation prefetches rung 1 during rung 0's gate. Run with `--nocapture`
994    /// to see the distribution. Offline (mock delays) but the exact shape a live serial-vs-spec A/B
995    /// produces — swap the mock for a live provider for real-provider numbers.
996    #[tokio::test]
997    async fn latency_ab_speculative_beats_serial_p95() {
998        const DELAY: u64 = 40;
999        const N: usize = 30;
1000        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1001        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1002        let req = base_request();
1003        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1004
1005        fn pctl(sorted: &[u64], p: f64) -> u64 {
1006            let i = (((sorted.len() - 1) as f64) * p).round() as usize;
1007            sorted[i]
1008        }
1009
1010        let mut serial = Vec::with_capacity(N);
1011        let mut spec = Vec::with_capacity(N);
1012        for run in 0..(2 * N) {
1013            let speculation = u32::from(run >= N); // first N serial, next N speculative
1014            let mut outs: HashMap<String, Result<ModelResponse, ProviderError>> = HashMap::new();
1015            outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); // empty → fails gate → escalate
1016            outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "ok")));
1017            let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1018            map.insert(
1019                "anthropic".to_owned(),
1020                Arc::new(MockProvider::new("anthropic", outs).with_delay(DELAY)),
1021            );
1022            let providers = ProviderRegistry::from_map(map);
1023            let health = GateHealthRegistry::new();
1024            let mut c = ctx(
1025                &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1026            );
1027            c.speculation = speculation;
1028            let start = std::time::Instant::now();
1029            let _ = route_enforce(c).await;
1030            let ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX);
1031            if speculation == 0 {
1032                serial.push(ms);
1033            } else {
1034                spec.push(ms);
1035            }
1036        }
1037        serial.sort_unstable();
1038        spec.sort_unstable();
1039        println!(
1040            "latency A/B (per-rung {DELAY}ms, escalate every request):\n  serial     p50={} p95={} p99={}\n  spec(k=1)  p50={} p95={} p99={}",
1041            pctl(&serial, 0.5),
1042            pctl(&serial, 0.95),
1043            pctl(&serial, 0.99),
1044            pctl(&spec, 0.5),
1045            pctl(&spec, 0.95),
1046            pctl(&spec, 0.99),
1047        );
1048        // Speculation runs rung 0 + rung 1 concurrently → ~1 rung of latency vs ~2 serial.
1049        assert!(
1050            pctl(&spec, 0.95) * 4 < pctl(&serial, 0.95) * 3,
1051            "spec p95 {}ms should beat serial p95 {}ms by >25%",
1052            pctl(&spec, 0.95),
1053            pctl(&serial, 0.95)
1054        );
1055    }
1056
1057    #[tokio::test]
1058    async fn speculation_never_fires_past_max_rungs() {
1059        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned(), OPUS.to_owned()];
1060        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1061        let req = base_request();
1062        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1063        // All fail the gate → best-attempt fallback serves the highest reached rung.
1064        let (providers, log) = counted_registry(vec![
1065            (HAIKU, Ok(resp(HAIKU, ""))),
1066            (SONNET, Ok(resp(SONNET, ""))),
1067            (OPUS, Ok(resp(OPUS, ""))),
1068        ]);
1069        let health = GateHealthRegistry::new();
1070        let mut c = ctx(
1071            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1072        );
1073        c.max_rungs = 2;
1074        c.speculation = 5; // huge window, but the ceiling is 2 rungs
1075        let (out, trace) = route_enforce(c).await;
1076
1077        assert!(
1078            !log.lock().unwrap().contains(&OPUS.to_owned()),
1079            "must not fire beyond max_rungs: {:?}",
1080            *log.lock().unwrap()
1081        );
1082        assert_eq!(trace.attempts.len(), 2);
1083        assert_eq!(trace.final_.served_from, ServedFrom::BestAttempt);
1084        assert_eq!(trace.final_.served_rung, Some(1));
1085        match out {
1086            EngineOutcome::Served(r) => assert_eq!(r.model, SONNET),
1087            EngineOutcome::Failed(e) => panic!("expected best-attempt served, got {e}"),
1088        }
1089    }
1090
1091    #[tokio::test]
1092    async fn speculation_cuts_wall_clock_vs_serial() {
1093        // The latency payoff, verified offline: rung 0 fails the gate, so serial pays rung 0 + rung
1094        // 1 latency *sequentially*; speculation fires both concurrently and finishes in ~one rung's
1095        // time. Timing-based, but the margin (a full 80ms rung) dwarfs scheduler jitter. This proves
1096        // the overlap mechanism — absolute live p95 still needs a real-provider run.
1097        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1098        let gates: Vec<Box<dyn Gate>> = vec![Box::new(NonEmptyGate)];
1099        let req = base_request();
1100        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1101
1102        let build = || {
1103            let mut outs = HashMap::new();
1104            outs.insert(HAIKU.to_owned(), Ok(resp(HAIKU, ""))); // fails non-empty
1105            outs.insert(SONNET.to_owned(), Ok(resp(SONNET, "real answer"))); // passes
1106            let mock = MockProvider::new("anthropic", outs).with_delay(80);
1107            let mut map: HashMap<String, Arc<dyn Provider>> = HashMap::new();
1108            map.insert("anthropic".to_owned(), Arc::new(mock));
1109            ProviderRegistry::from_map(map)
1110        };
1111
1112        let providers = build();
1113        let health = GateHealthRegistry::new();
1114        let t = std::time::Instant::now();
1115        let _ = route_enforce(ctx(
1116            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1117        ))
1118        .await;
1119        let serial = t.elapsed();
1120
1121        let providers = build();
1122        let health = GateHealthRegistry::new();
1123        let mut c = ctx(
1124            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1125        );
1126        c.speculation = 1;
1127        let t = std::time::Instant::now();
1128        let _ = route_enforce(c).await;
1129        let spec = t.elapsed();
1130
1131        assert!(
1132            spec < serial * 3 / 4,
1133            "speculation must overlap rung latencies: serial={serial:?} spec={spec:?}"
1134        );
1135    }
1136
1137    /// A gate that always passes but scores by parsing the candidate text as `f64` — lets a test
1138    /// drive an exact aggregate score without depending on a real gate's scoring internals.
1139    #[derive(Debug)]
1140    struct ScoreGate;
1141
1142    #[async_trait::async_trait]
1143    impl Gate for ScoreGate {
1144        fn id(&self) -> &str {
1145            "score"
1146        }
1147
1148        async fn evaluate(&self, _req: &ModelRequest, resp: &ModelResponse) -> GateResult {
1149            let score = resp.text.trim().parse::<f64>().unwrap_or(0.0);
1150            GateResult {
1151                gate_id: self.id().to_owned(),
1152                verdict: Verdict::Pass,
1153                score: Some(firstpass_core::Score::clamped(score)),
1154                cost_usd: 0.0,
1155                ms: 0,
1156                reason: None,
1157                evidence_ref: None,
1158            }
1159        }
1160    }
1161
1162    #[tokio::test]
1163    async fn serve_threshold_escalates_past_low_scoring_rung() {
1164        // Both rungs pass ScoreGate's verdict; only score decides. Haiku scores 0.5 (< 0.8, must
1165        // escalate even though it "passed"); Sonnet scores 0.9 (>= 0.8, serves).
1166        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
1167        let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1168        let req = base_request();
1169        let providers = registry(vec![
1170            ("anthropic", HAIKU, Ok(resp(HAIKU, "0.5"))),
1171            ("anthropic", SONNET, Ok(resp(SONNET, "0.9"))),
1172        ]);
1173        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1174        let health = GateHealthRegistry::new();
1175        let mut c = ctx(
1176            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1177        );
1178        c.serve_threshold = Some(0.8);
1179        let (out, trace) = route_enforce(c).await;
1180
1181        assert!(matches!(out, EngineOutcome::Served(r) if r.model == SONNET));
1182        assert_eq!(trace.attempts.len(), 2);
1183        // Both rungs actually passed the gate's verdict — proving escalation was score-driven.
1184        assert_eq!(trace.attempts[0].verdict, Verdict::Pass);
1185        assert_eq!(trace.attempts[1].verdict, Verdict::Pass);
1186        assert_eq!(trace.final_.escalations, 1);
1187        assert_eq!(trace.final_.served_rung, Some(1));
1188        assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1189    }
1190
1191    #[tokio::test]
1192    async fn serve_threshold_does_not_serve_a_pass_below_threshold() {
1193        // A single-rung ladder: the gate passes it, but its score (0.3) is below the 0.8
1194        // threshold, so it must NOT serve as a normal pass — it can only be reached via the
1195        // best-attempt fallback once the ladder is exhausted.
1196        let ladder = vec![HAIKU.to_owned()];
1197        let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1198        let req = base_request();
1199        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.3")))]);
1200        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1201        let health = GateHealthRegistry::new();
1202        let mut c = ctx(
1203            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1204        );
1205        c.serve_threshold = Some(0.8);
1206        let (out, trace) = route_enforce(c).await;
1207
1208        assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1209        assert_eq!(
1210            trace.attempts[0].verdict,
1211            Verdict::Pass,
1212            "gate verdict was Pass"
1213        );
1214        assert_eq!(
1215            trace.final_.served_from,
1216            ServedFrom::BestAttempt,
1217            "score below threshold must fall back, not serve as a normal pass"
1218        );
1219    }
1220
1221    #[tokio::test]
1222    async fn serve_threshold_none_serves_on_verdict_regardless_of_score() {
1223        // Same low-scoring rung as above, but with no threshold configured: today's rule (verdict
1224        // alone) must serve it as a normal pass.
1225        let ladder = vec![HAIKU.to_owned()];
1226        let gates: Vec<Box<dyn Gate>> = vec![Box::new(ScoreGate)];
1227        let req = base_request();
1228        let providers = registry(vec![("anthropic", HAIKU, Ok(resp(HAIKU, "0.1")))]);
1229        let (auth, prices) = (Auth::default(), PriceTable::defaults());
1230        let health = GateHealthRegistry::new();
1231        let c = ctx(
1232            &ladder, &gates, &req, &providers, &auth, &prices, None, &health,
1233        );
1234        assert_eq!(c.serve_threshold, None);
1235        let (out, trace) = route_enforce(c).await;
1236
1237        assert!(matches!(out, EngineOutcome::Served(r) if r.model == HAIKU));
1238        assert_eq!(trace.final_.served_from, ServedFrom::Attempt);
1239    }
1240}