Skip to main content

firstpass_proxy/
bandit.rs

1//! UCB1 start-rung bandit: predict-to-start, verify-to-serve.
2//!
3//! # Science
4//!
5//! Every request currently starts the escalation ladder at rung 0 (cheapest model). Many
6//! contexts (task kind × prompt-size bucket) have a learned empirical pass rate per rung: if
7//! rung 0 almost never passes for a given context, always starting there wastes money. This
8//! module learns a per-context start rung that minimises expected cost while the enforce gate
9//! still verifies the chosen rung's output before serving.
10//!
11//! **The invariant (predict-to-start, verify-to-serve):** the bandit only controls where the
12//! ladder *starts*. Gating, escalation, budget, and speculation are untouched. If the predicted
13//! start rung's output fails the gate, the ladder continues upward as normal — no downward
14//! retry. Prediction errors can cost money (latency, slightly higher expected cost when the
15//! estimate is wrong) but can never cause a wrong answer to be served.
16//!
17//! # Algorithm
18//!
19//! UCB1 (Auer et al. 2002) applied to cost-sensitive start-rung selection. For each candidate
20//! start s, the expected cost is:
21//!
22//! ```text
23//! E[s] = Σ_{r=s}^{top-1}  price_r · P(reach r | start s)
24//! P(reach r | start s) = Π_{q=s}^{r-1} (1 − p̂_q)
25//! p̂_q = clamp(successes_q / n_q  +  c · √(ln N / n_q),  0, 1)
26//! ```
27//!
28//! where `N` is the total gate-verdict observations in this context, `n_q` is the observations
29//! at rung `q`, and `c` is the exploration constant (default 1.0). Prices are evaluated at
30//! nominal fixed tokens (1 000 in / 500 out) — relative prices are what matters for start-rung
31//! comparison. Tie → lower s (conservative).
32//!
33//! **Cold-start safety:** if the context has fewer than `min_observations` total gate verdicts,
34//! the bandit returns rung 0 (byte-identical to today's behavior). Abstain verdicts are not
35//! counted (only clear Pass/Fail gate outcomes are informative).
36//!
37//! # State
38//!
39//! ponytail: in-memory `Mutex`, per-process. Survives restarts via warm-start replay of the
40//! trace store at startup. No persistence of its own — the trace store is the durable record.
41
42use std::collections::HashMap;
43
44use firstpass_core::{Features, PriceTable, TaskKind, Verdict};
45
46/// The context bucket that keys the bandit's per-arm statistics.
47///
48/// Coarsened to keep the arm count dense: `prompt_bucket_coarse = prompt_token_bucket / 2`
49/// halves the bucket resolution (floor(log2(n)) → floor(log2(n)/2) in effect), trading
50/// granularity for faster warm-up.
51///
52/// ponytail: the ladder identity is not part of the key — in practice a given (TaskKind,
53/// prompt size) routes to the same ladder via the first-match routing rule. Would need to be
54/// included if operators run overlapping routes for the same context.
55#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56pub struct ContextBucket {
57    /// Coarse task classification from the request feature vector.
58    pub task_kind: TaskKind,
59    /// `features.prompt_token_bucket / 2` — halved for denser arms.
60    pub prompt_bucket_coarse: u32,
61}
62
63impl ContextBucket {
64    /// Derive a context bucket from a request's feature vector.
65    #[must_use]
66    pub fn from_features(f: &Features) -> Self {
67        Self {
68            task_kind: f.task_kind,
69            prompt_bucket_coarse: f.prompt_token_bucket / 2,
70        }
71    }
72}
73
74/// Per-(context, rung) gate-verdict counts. Abstain is excluded (see module doc).
75#[derive(Debug, Default, Clone)]
76struct ArmCounts {
77    pass: f64,
78    fail: f64,
79}
80
81impl ArmCounts {
82    fn n(&self) -> f64 {
83        self.pass + self.fail
84    }
85}
86
87/// Online UCB1 bandit for start-rung selection.
88///
89/// Keyed by [`ContextBucket`]; per context, tracks per-rung pass/fail counts of gate verdicts.
90/// Thread-safety comes from the caller wrapping this in `Arc<std::sync::Mutex<_>>` (mirroring
91/// `AdaptiveConformal` — both are per-process, in-memory, low-contention).
92#[derive(Debug)]
93pub struct StartRungBandit {
94    /// UCB1 exploration constant `c` ≥ 0 (used by [`Algorithm::Ucb1`]).
95    exploration: f64,
96    /// Cold-start threshold: contexts with fewer total observations return rung 0.
97    min_observations: usize,
98    /// Selection algorithm: deterministic UCB1 (auditable) or Thompson sampling (stochastic,
99    /// native propensities, better under non-stationarity with `discount < 1`).
100    algorithm: Algorithm,
101    /// Per-observation multiplicative decay of a context's counts, in `(0, 1]`. `1.0` = no
102    /// forgetting (stationary). Applied on every observe, so a context that stops matching
103    /// reality fades at rate `discount^n` (discounted Thompson sampling).
104    discount: f64,
105    /// xorshift64* PRNG state for Thompson draws (seeded once; deterministic in tests).
106    rng: u64,
107    /// context → (rung index → counts).
108    data: HashMap<ContextBucket, HashMap<u32, ArmCounts>>,
109}
110
111/// Start-rung selection algorithm.
112/// Monte-Carlo repetitions for the Thompson propensity estimate.
113const PROPENSITY_SAMPLES: usize = 64;
114
115/// The expected-cost argmin over candidate start rungs, shared by UCB1 and Thompson: walk the
116/// ladder from each candidate start, accumulating `P(reach r) · price(r)` with
117/// `P(reach r+1) = P(reach r) · (1 − p_pass(r))`. Ties prefer the lower start (conservative).
118fn argmin_expected_cost(
119    ladder: &[String],
120    prices: &PriceTable,
121    mut p_pass: impl FnMut(u32) -> f64,
122) -> u32 {
123    // ponytail: nominal 1 000 in / 500 out — relative prices are what matters for start-rung
124    // selection; absolute spend is immaterial here.
125    const NOMINAL_IN: u64 = 1_000;
126    const NOMINAL_OUT: u64 = 500;
127
128    let mut best_s = 0u32;
129    let mut best_cost = f64::MAX;
130    for s in 0..ladder.len() {
131        let mut expected_cost = 0.0_f64;
132        let mut p_reach = 1.0_f64;
133        for (r, model) in ladder.iter().enumerate().skip(s) {
134            let price = prices
135                .get(model)
136                .map(|p| p.cost(NOMINAL_IN, NOMINAL_OUT))
137                .unwrap_or(0.0);
138            expected_cost += p_reach * price;
139            p_reach *= 1.0 - p_pass(r as u32);
140            if p_reach < 1e-10 {
141                break;
142            }
143        }
144        if expected_cost < best_cost {
145            best_cost = expected_cost;
146            best_s = s as u32;
147        }
148    }
149    best_s
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum Algorithm {
154    /// Deterministic optimism (Auer et al. 2002): auditable, but propensities are degenerate
155    /// (0/1) — off-policy estimates then need the epsilon-greedy overlay.
156    #[default]
157    Ucb1,
158    /// Thompson sampling over Beta posteriors of gate-pass: stochastic by nature, so every
159    /// decision carries a non-degenerate propensity (Monte-Carlo estimated), and `discount`
160    /// handles model churn (Chapelle & Li 2011; discounted TS for non-stationarity).
161    Thompson,
162}
163
164impl StartRungBandit {
165    /// Create a new UCB1 bandit with the given cold-start and exploration settings.
166    #[must_use]
167    pub fn new(min_observations: usize, exploration: f64) -> Self {
168        Self::with_algorithm(min_observations, exploration, Algorithm::Ucb1, 1.0, 0x9E37)
169    }
170
171    /// Create a bandit with an explicit algorithm, discount, and PRNG seed (seed matters only
172    /// for [`Algorithm::Thompson`]; pass anything non-zero).
173    #[must_use]
174    pub fn with_algorithm(
175        min_observations: usize,
176        exploration: f64,
177        algorithm: Algorithm,
178        discount: f64,
179        seed: u64,
180    ) -> Self {
181        Self {
182            exploration,
183            min_observations,
184            algorithm,
185            discount: discount.clamp(f64::MIN_POSITIVE, 1.0),
186            rng: seed.max(1),
187            data: HashMap::new(),
188        }
189    }
190
191    /// Apply one step of multiplicative forgetting to every arm in `ctx` (no-op at 1.0).
192    fn discount_context(&mut self, ctx: &ContextBucket) {
193        if self.discount >= 1.0 {
194            return;
195        }
196        if let Some(arms) = self.data.get_mut(ctx) {
197            for c in arms.values_mut() {
198                c.pass *= self.discount;
199                c.fail *= self.discount;
200            }
201        }
202    }
203
204    /// Next xorshift64* draw as a uniform in `[0, 1)`.
205    fn next_u01(&mut self) -> f64 {
206        let mut x = self.rng;
207        x ^= x >> 12;
208        x ^= x << 25;
209        x ^= x >> 27;
210        self.rng = x;
211        let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
212        (v >> 11) as f64 / (1u64 << 53) as f64
213    }
214
215    /// Standard normal via Box–Muller on the internal PRNG.
216    fn next_normal(&mut self) -> f64 {
217        let u1 = self.next_u01().max(f64::MIN_POSITIVE);
218        let u2 = self.next_u01();
219        (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
220    }
221
222    /// Gamma(shape ≥ 1, scale 1) sample — Marsaglia–Tsang squeeze method.
223    fn next_gamma(&mut self, shape: f64) -> f64 {
224        debug_assert!(shape >= 1.0, "Beta(+1 prior) keeps shapes >= 1");
225        let d = shape - 1.0 / 3.0;
226        let c = 1.0 / (9.0 * d).sqrt();
227        loop {
228            let x = self.next_normal();
229            let v = (1.0 + c * x).powi(3);
230            if v <= 0.0 {
231                continue;
232            }
233            let u = self.next_u01();
234            if u < 1.0 - 0.0331 * x.powi(4) || u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
235                return d * v;
236            }
237        }
238    }
239
240    /// One Beta(pass+1, fail+1) posterior draw of the gate-pass probability for `(ctx, rung)`.
241    /// Unobserved arms draw from the uniform prior Beta(1, 1).
242    fn thompson_pass(&mut self, ctx: &ContextBucket, rung: u32) -> f64 {
243        let (a, b) = self
244            .data
245            .get(ctx)
246            .and_then(|arms| arms.get(&rung))
247            .map_or((1.0, 1.0), |c| (c.pass + 1.0, c.fail + 1.0));
248        let x = self.next_gamma(a);
249        let y = self.next_gamma(b);
250        x / (x + y)
251    }
252
253    /// Posterior-mean gate-pass estimate for `(ctx, rung)` — `None` when the context is cold
254    /// (below `min_observations`) or the arm is unobserved. Deterministic; used by the
255    /// speculative-deferral band, never for serving decisions.
256    #[must_use]
257    pub fn pass_estimate(&self, ctx: &ContextBucket, rung: u32) -> Option<f64> {
258        let arms = self.data.get(ctx)?;
259        let total: f64 = arms.values().map(ArmCounts::n).sum();
260        if total < self.min_observations as f64 {
261            return None;
262        }
263        let c = arms.get(&rung)?;
264        if c.n() == 0.0 {
265            return None;
266        }
267        Some((c.pass + 1.0) / (c.n() + 2.0))
268    }
269
270    /// Record a gate verdict for `(context, rung)`.
271    ///
272    /// `Abstain` is ignored — only clear Pass/Fail outcomes are informative for cost modelling.
273    pub fn observe(&mut self, ctx: &ContextBucket, rung: u32, verdict: Verdict) {
274        match verdict {
275            Verdict::Abstain => {} // not counted
276            Verdict::Pass => {
277                self.discount_context(ctx);
278                self.data
279                    .entry(ctx.clone())
280                    .or_default()
281                    .entry(rung)
282                    .or_default()
283                    .pass += 1.0;
284            }
285            Verdict::Fail => {
286                self.discount_context(ctx);
287                self.data
288                    .entry(ctx.clone())
289                    .or_default()
290                    .entry(rung)
291                    .or_default()
292                    .fail += 1.0;
293            }
294        }
295    }
296
297    /// UCB1-optimistic pass-rate estimate for `rung` in `ctx`.
298    ///
299    /// Returns 1.0 for unobserved rungs (optimistic exploration, UCB → ∞, clamped).
300    fn ucb_pass(&self, ctx: &ContextBucket, rung: u32, ln_n: f64) -> f64 {
301        let Some(arms) = self.data.get(ctx) else {
302            return 1.0; // context unseen: fully optimistic
303        };
304        let Some(counts) = arms.get(&rung) else {
305            return 1.0; // rung unobserved for this context: fully optimistic
306        };
307        let n = counts.n();
308        if n == 0.0 {
309            return 1.0;
310        }
311        let p_hat = counts.pass / n;
312        (p_hat + self.exploration * (ln_n / n).sqrt()).clamp(0.0, 1.0)
313    }
314
315    /// Choose the start rung that minimises expected cost for this context and ladder.
316    ///
317    /// Returns `0` in all cold-start cases:
318    /// - bandit is cold (total observations < `min_observations` for this context)
319    /// - `ladder` is empty
320    ///
321    /// Never returns a value ≥ `ladder.len()`.
322    #[must_use]
323    pub fn choose_start(&self, ctx: &ContextBucket, ladder: &[String], prices: &PriceTable) -> u32 {
324        let top = ladder.len();
325        if top == 0 {
326            return 0;
327        }
328
329        // Cold-start guard: total gate observations in this context.
330        let n_total: f64 = self
331            .data
332            .get(ctx)
333            .map(|arms| arms.values().map(ArmCounts::n).sum())
334            .unwrap_or(0.0);
335        if n_total < self.min_observations as f64 {
336            return 0;
337        }
338
339        let ln_n = n_total.ln();
340        argmin_expected_cost(ladder, prices, |r| self.ucb_pass(ctx, r, ln_n))
341    }
342
343    /// Choose the start rung and its selection propensity.
344    ///
345    /// - [`Algorithm::Ucb1`]: deterministic — returns `(choice, None)`; the epsilon-greedy
346    ///   overlay (if configured) supplies the logged propensity, exactly as before.
347    /// - [`Algorithm::Thompson`]: one posterior draw per rung drives the same expected-cost
348    ///   argmin as UCB1 (the decision), and the propensity is Monte-Carlo estimated by
349    ///   re-running the draw `PROPENSITY_SAMPLES` times — the standard estimator for TS
350    ///   selection probabilities (exact computation is analytically intractable). Cold-start
351    ///   contexts return `(0, None)` (deterministic, no propensity to log).
352    #[must_use]
353    pub fn choose_start_with_propensity(
354        &mut self,
355        ctx: &ContextBucket,
356        ladder: &[String],
357        prices: &PriceTable,
358    ) -> (u32, Option<f64>) {
359        if self.algorithm == Algorithm::Ucb1 {
360            return (self.choose_start(ctx, ladder, prices), None);
361        }
362        let top = ladder.len();
363        if top == 0 {
364            return (0, None);
365        }
366        let n_total: f64 = self
367            .data
368            .get(ctx)
369            .map(|arms| arms.values().map(ArmCounts::n).sum())
370            .unwrap_or(0.0);
371        if n_total < self.min_observations as f64 {
372            return (0, None);
373        }
374
375        let draw = |this: &mut Self| {
376            // One full posterior draw per rung, then the shared cost argmin.
377            let samples: Vec<f64> = (0..top)
378                .map(|r| this.thompson_pass(ctx, r as u32))
379                .collect();
380            argmin_expected_cost(ladder, prices, |r| samples[r as usize])
381        };
382
383        let choice = draw(self);
384        let matches = (0..PROPENSITY_SAMPLES)
385            .filter(|_| draw(self) == choice)
386            .count();
387        // Clamp away 0: the MC estimate can miss a genuinely-possible arm in finite samples,
388        // and a zero propensity would blow up IPS. 1/(2·M) is the standard floor.
389        let p = (matches as f64 / PROPENSITY_SAMPLES as f64)
390            .max(1.0 / (2.0 * PROPENSITY_SAMPLES as f64));
391        (choice, Some(p))
392    }
393
394    /// Feed all attempts from a stored trace into this bandit — used for warm-start at startup.
395    pub fn feed_trace_attempts(
396        &mut self,
397        ctx: &ContextBucket,
398        attempts: &[firstpass_core::Attempt],
399    ) {
400        for attempt in attempts {
401            self.observe(ctx, attempt.rung, attempt.verdict);
402        }
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use firstpass_core::{
410        Attempt, Features, FinalOutcome, GENESIS_HASH, Mode, PolicyRef, RequestInfo, ServedFrom,
411        TaskKind, Trace,
412    };
413
414    fn ctx_code() -> ContextBucket {
415        ContextBucket {
416            task_kind: TaskKind::CodeEdit,
417            prompt_bucket_coarse: 3,
418        }
419    }
420
421    fn ctx_chat() -> ContextBucket {
422        ContextBucket {
423            task_kind: TaskKind::Chat,
424            prompt_bucket_coarse: 3,
425        }
426    }
427
428    const HAIKU: &str = "anthropic/claude-haiku-4-5";
429    const SONNET: &str = "anthropic/claude-sonnet-5";
430
431    #[test]
432    fn cold_start_returns_rung_0() {
433        let b = StartRungBandit::new(50, 1.0);
434        let prices = PriceTable::defaults();
435        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
436        // No observations at all → cold start.
437        assert_eq!(b.choose_start(&ctx_code(), &ladder, &prices), 0);
438    }
439
440    #[test]
441    fn empty_ladder_returns_rung_0() {
442        let b = StartRungBandit::new(50, 1.0);
443        let prices = PriceTable::defaults();
444        assert_eq!(b.choose_start(&ctx_code(), &[], &prices), 0);
445    }
446
447    #[test]
448    fn after_enough_rung0_fails_rung1_passes_picks_rung1_for_that_context() {
449        let mut b = StartRungBandit::new(50, 1.0);
450        let prices = PriceTable::defaults();
451        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
452
453        // Feed 60 rung-0 fails + 60 rung-1 passes for ctx_code → bandit should skip rung 0.
454        let code = ctx_code();
455        for _ in 0..60 {
456            b.observe(&code, 0, Verdict::Fail);
457            b.observe(&code, 1, Verdict::Pass);
458        }
459        // Expected cost E[0] = price_haiku + (1 - p̂_0) * price_sonnet  >> E[1] = price_sonnet.
460        assert_eq!(
461            b.choose_start(&code, &ladder, &prices),
462            1,
463            "should skip rung 0 after observing it always fails"
464        );
465
466        // A DIFFERENT context with no data stays cold (returns 0), not influenced by code's data.
467        let chat = ctx_chat();
468        assert_eq!(
469            b.choose_start(&chat, &ladder, &prices),
470            0,
471            "different context must be independent (cold start)"
472        );
473    }
474
475    #[test]
476    fn abstain_verdicts_are_not_counted() {
477        let mut b = StartRungBandit::new(2, 1.0); // tiny min_obs to isolate the logic
478        let code = ctx_code();
479        // Only feed abstains — they must not push us past cold start.
480        for _ in 0..100 {
481            b.observe(&code, 0, Verdict::Abstain);
482        }
483        let prices = PriceTable::defaults();
484        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
485        // N = 0 (abstains excluded) < min_obs=2 → rung 0.
486        assert_eq!(b.choose_start(&code, &ladder, &prices), 0);
487    }
488
489    #[test]
490    fn single_rung_ladder_always_returns_0() {
491        let mut b = StartRungBandit::new(1, 1.0);
492        let code = ctx_code();
493        b.observe(&code, 0, Verdict::Fail);
494        b.observe(&code, 0, Verdict::Pass);
495        let prices = PriceTable::defaults();
496        let ladder = vec![HAIKU.to_owned()];
497        assert_eq!(b.choose_start(&code, &ladder, &prices), 0);
498    }
499
500    /// Build a minimal attempt with a given rung and verdict (for warm-start tests).
501    fn stub_attempt(rung: u32, verdict: Verdict) -> Attempt {
502        Attempt {
503            rung,
504            model: HAIKU.to_owned(),
505            provider: "anthropic".to_owned(),
506            in_tokens: 1000,
507            out_tokens: 500,
508            cost_usd: 0.001,
509            latency_ms: 10,
510            gates: vec![],
511            verdict,
512        }
513    }
514
515    #[test]
516    fn from_features_derives_bucket_correctly() {
517        let mut f = Features::new(TaskKind::CodeEdit);
518        f.prompt_token_bucket = 7; // coarse = 7/2 = 3
519        let b = ContextBucket::from_features(&f);
520        assert_eq!(b.task_kind, TaskKind::CodeEdit);
521        assert_eq!(b.prompt_bucket_coarse, 3);
522    }
523
524    #[test]
525    fn feed_trace_attempts_populates_counts() {
526        let mut bandit = StartRungBandit::new(2, 1.0);
527        let ctx = ctx_code();
528        let attempts = vec![
529            stub_attempt(0, Verdict::Fail),
530            stub_attempt(1, Verdict::Pass),
531        ];
532        bandit.feed_trace_attempts(&ctx, &attempts);
533
534        // Verify internally: 1 fail at rung 0, 1 pass at rung 1 → total N=2 ≥ min_obs=2.
535        // With min_obs=2 and only rung-0 failing, the bandit should prefer rung 1.
536        let prices = PriceTable::defaults();
537        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
538        // N=2, p̂_0 = 0/1 + sqrt(ln2/1) ≈ 0.83, p̂_1 = 1/1 + ... ≥ 1.0
539        // E[0] = price_haiku + (1-0.83)*price_sonnet; E[1] = price_sonnet.
540        // Whether bandit picks 0 or 1 depends on exact math — just verify it doesn't panic.
541        let _ = bandit.choose_start(&ctx, &ladder, &prices);
542    }
543
544    // ---- Guarantee invariant: bandit on + start rung fails → escalates, never serves failing output.
545    // (Integration with the router is tested in router.rs; here we prove the math doesn't
546    // synthesise a "free pass" when an observed rung has 0% pass rate.)
547    #[test]
548    fn zero_pass_rate_rung_is_not_optimistic() {
549        // After many fail observations at rung 0 and pass observations at rung 1,
550        // choosing E[s] should prefer s=1 even with c=0 (no exploration bonus).
551        let mut b = StartRungBandit::new(10, 0.0); // c=0: pure exploitation
552        let ctx = ctx_code();
553        for _ in 0..20 {
554            b.observe(&ctx, 0, Verdict::Fail);
555            b.observe(&ctx, 1, Verdict::Pass);
556        }
557        let prices = PriceTable::defaults();
558        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
559        // p̂_0 = 0 (c=0), p̂_1 = 1.0
560        // E[0] = price_haiku + 1.0*price_sonnet;  E[1] = price_sonnet
561        // E[0] > E[1] → choose start=1.
562        assert_eq!(b.choose_start(&ctx, &ladder, &prices), 1);
563    }
564
565    // ---- Warm-start from disk --------------------------------------------------------
566
567    /// Helper: build a minimal enforce-mode trace with one attempt at rung 0 (pass) and one
568    /// at rung 1 (fail), carrying the given Features so ContextBucket::from_features works.
569    fn trace_with_features_and_attempts(features: Features, attempts: Vec<Attempt>) -> Trace {
570        let mut trace = Trace {
571            trace_id: uuid::Uuid::now_v7(),
572            prev_hash: GENESIS_HASH.to_owned(),
573            tenant_id: "test-tenant".to_owned(),
574            session_id: "sess-warm".to_owned(),
575            ts: jiff::Timestamp::now(),
576            mode: Mode::Enforce,
577            policy: PolicyRef {
578                id: "test@v0".to_owned(),
579                explore: false,
580                propensity: None,
581            },
582            request: RequestInfo {
583                api: "anthropic.messages".to_owned(),
584                prompt_hash: "deadbeef".to_owned(),
585                features,
586            },
587            attempts,
588            deferred: vec![],
589            final_: FinalOutcome {
590                served_rung: Some(0),
591                served_from: ServedFrom::Attempt,
592                total_cost_usd: 0.001,
593                gate_cost_usd: 0.0,
594                total_latency_ms: 10,
595                escalations: 0,
596                counterfactual_baseline_usd: 0.001,
597                savings_usd: 0.0,
598            },
599        };
600        trace.recompute_savings();
601        trace
602    }
603
604    /// Warm-start test: write traces to a temp store, replay them into a fresh bandit,
605    /// assert the learned counts drive the expected start-rung choice.
606    #[tokio::test]
607    async fn warm_start_from_trace_store_replays_counts() {
608        let db = std::env::temp_dir().join(format!("bandit-warmstart-{}.db", uuid::Uuid::now_v7()));
609        let (tx, writer) = crate::store::open(&db).unwrap();
610
611        // Build features that land in ctx_code (TaskKind::CodeEdit, bucket_coarse=3).
612        let mut f = Features::new(TaskKind::CodeEdit);
613        f.prompt_token_bucket = 7; // coarse = 7/2 = 3
614
615        // 60 traces: rung 0 always fails, rung 1 always passes.
616        for _ in 0..60 {
617            let attempts = vec![
618                stub_attempt(0, Verdict::Fail),
619                stub_attempt(1, Verdict::Pass),
620            ];
621            tx.try_send(trace_with_features_and_attempts(f.clone(), attempts))
622                .unwrap();
623        }
624        drop(tx);
625        writer.await.unwrap();
626
627        // Replay the stored traces into a fresh bandit (mirrors run.rs warm-start logic).
628        let mut bandit = StartRungBandit::new(50, 0.0); // c=0: pure exploitation
629        let stored = crate::store::load_all_traces(&db).unwrap();
630        assert_eq!(stored.len(), 60, "all traces must be stored");
631        for trace in &stored {
632            let ctx = ContextBucket::from_features(&trace.request.features);
633            bandit.feed_trace_attempts(&ctx, &trace.attempts);
634        }
635
636        // After warm-start: 60 rung-0 fails + 60 rung-1 passes → bandit prefers rung 1.
637        let prices = PriceTable::defaults();
638        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
639        let ctx = ContextBucket::from_features(&f);
640        assert_eq!(
641            bandit.choose_start(&ctx, &ladder, &prices),
642            1,
643            "warm-started bandit must prefer rung 1 after 60 fail/pass pairs"
644        );
645
646        // A context with different features sees no data — cold start, returns 0.
647        let mut f2 = Features::new(TaskKind::Chat);
648        f2.prompt_token_bucket = 7;
649        let ctx2 = ContextBucket::from_features(&f2);
650        assert_eq!(
651            bandit.choose_start(&ctx2, &ladder, &prices),
652            0,
653            "different context must be independent"
654        );
655
656        let _ = std::fs::remove_file(&db);
657    }
658
659    #[test]
660    fn beta_sampler_mean_matches_posterior_mean() {
661        let mut b = StartRungBandit::with_algorithm(0, 1.0, Algorithm::Thompson, 1.0, 0xDEADBEEF);
662        // Beta(8, 2): mean 0.8. 4000 draws give a tight empirical mean.
663        let ctx = ctx_code();
664        for _ in 0..7 {
665            b.observe(&ctx, 0, Verdict::Pass);
666        }
667        b.observe(&ctx, 0, Verdict::Fail);
668        // counts: pass 7, fail 1 → Beta(8, 2), mean 0.8.
669        let mean: f64 = (0..4000).map(|_| b.thompson_pass(&ctx, 0)).sum::<f64>() / 4000.0;
670        assert!(
671            (mean - 0.8).abs() < 0.03,
672            "Beta(8,2) sample mean should be ~0.8, got {mean}"
673        );
674        // Unobserved arm = Beta(1,1) uniform, mean 0.5.
675        let mean_u: f64 = (0..4000).map(|_| b.thompson_pass(&ctx, 5)).sum::<f64>() / 4000.0;
676        assert!(
677            (mean_u - 0.5).abs() < 0.03,
678            "uniform prior mean ~0.5, got {mean_u}"
679        );
680    }
681
682    #[test]
683    fn thompson_converges_to_skipping_a_hopeless_cheap_rung() {
684        let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 42);
685        let ctx = ctx_code();
686        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
687        let prices = PriceTable::defaults();
688        // Rung 0 always fails, rung 1 always passes → starting at 1 is cheaper in expectation.
689        for _ in 0..80 {
690            b.observe(&ctx, 0, Verdict::Fail);
691            b.observe(&ctx, 1, Verdict::Pass);
692        }
693        let picks_rung1 = (0..100)
694            .filter(|_| b.choose_start_with_propensity(&ctx, &ladder, &prices).0 == 1)
695            .count();
696        assert!(
697            picks_rung1 >= 90,
698            "TS should overwhelmingly skip the hopeless cheap rung, picked rung 1 {picks_rung1}/100"
699        );
700    }
701
702    #[test]
703    fn discounting_adapts_after_a_distribution_flip() {
704        let ctx = ctx_code();
705        // History: rung 0 failed 200 times. Then the world flips: 40 recent passes.
706        let feed = |b: &mut StartRungBandit| {
707            for _ in 0..200 {
708                b.observe(&ctx, 0, Verdict::Fail);
709            }
710            for _ in 0..40 {
711                b.observe(&ctx, 0, Verdict::Pass);
712            }
713        };
714        let mut discounted = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 0.95, 7);
715        let mut undiscounted =
716            StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 7);
717        feed(&mut discounted);
718        feed(&mut undiscounted);
719        let p_disc = discounted.pass_estimate(&ctx, 0).unwrap();
720        let p_flat = undiscounted.pass_estimate(&ctx, 0).unwrap();
721        assert!(
722            p_disc > 0.7 && p_flat < 0.25,
723            "discounted tracks the flip (got {p_disc:.2}), undiscounted lags (got {p_flat:.2})"
724        );
725    }
726
727    #[test]
728    fn thompson_propensity_matches_empirical_selection_frequency() {
729        let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 99);
730        let ctx = ctx_code();
731        let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
732        let prices = PriceTable::defaults();
733        // Ambiguous data: rung 0 passes ~half the time → genuinely stochastic selection.
734        for _ in 0..20 {
735            b.observe(&ctx, 0, Verdict::Pass);
736            b.observe(&ctx, 0, Verdict::Fail);
737            b.observe(&ctx, 1, Verdict::Pass);
738        }
739        // Empirical frequency of each choice over many decisions vs the logged propensity.
740        let mut freq = std::collections::HashMap::new();
741        let mut props: Vec<(u32, f64)> = Vec::new();
742        for _ in 0..400 {
743            let (c, p) = b.choose_start_with_propensity(&ctx, &ladder, &prices);
744            *freq.entry(c).or_insert(0u32) += 1;
745            props.push((c, p.expect("thompson always logs a propensity")));
746        }
747        for (arm, count) in freq {
748            let empirical = f64::from(count) / 400.0;
749            let mean_logged: f64 = {
750                let logged: Vec<f64> = props
751                    .iter()
752                    .filter(|(c, _)| *c == arm)
753                    .map(|(_, p)| *p)
754                    .collect();
755                logged.iter().sum::<f64>() / logged.len() as f64
756            };
757            assert!(
758                (empirical - mean_logged).abs() < 0.15,
759                "arm {arm}: empirical {empirical:.2} vs logged propensity {mean_logged:.2}"
760            );
761        }
762    }
763
764    #[test]
765    fn pass_estimate_cold_and_warm() {
766        let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 5);
767        let ctx = ctx_code();
768        assert!(
769            b.pass_estimate(&ctx, 0).is_none(),
770            "cold context: no estimate"
771        );
772        for _ in 0..12 {
773            b.observe(&ctx, 0, Verdict::Pass);
774        }
775        let p = b.pass_estimate(&ctx, 0).expect("warm");
776        assert!(p > 0.85, "12/12 passes: posterior mean high, got {p}");
777        assert!(
778            b.pass_estimate(&ctx, 3).is_none(),
779            "unobserved arm: no estimate"
780        );
781    }
782}