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