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