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