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