1use std::collections::HashMap;
43
44use firstpass_core::{Features, PriceTable, TaskKind, Verdict};
45
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
56pub struct ContextBucket {
57 pub task_kind: TaskKind,
59 pub prompt_bucket_coarse: u32,
61}
62
63impl ContextBucket {
64 #[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#[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#[derive(Debug)]
93pub struct StartRungBandit {
94 exploration: f64,
96 min_observations: usize,
98 algorithm: Algorithm,
101 discount: f64,
105 rng: u64,
107 data: HashMap<ContextBucket, HashMap<u32, ArmCounts>>,
109}
110
111const PROPENSITY_SAMPLES: usize = 64;
114
115fn argmin_expected_cost(
119 ladder: &[String],
120 prices: &PriceTable,
121 mut p_pass: impl FnMut(u32) -> f64,
122) -> u32 {
123 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 #[default]
157 Ucb1,
158 Thompson,
162}
163
164impl StartRungBandit {
165 #[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 #[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 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 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 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 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 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 #[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 pub fn observe(&mut self, ctx: &ContextBucket, rung: u32, verdict: Verdict) {
274 match verdict {
275 Verdict::Abstain => {} 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 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; };
304 let Some(counts) = arms.get(&rung) else {
305 return 1.0; };
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 #[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 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 #[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 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 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 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 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 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 assert_eq!(
461 b.choose_start(&code, &ladder, &prices),
462 1,
463 "should skip rung 0 after observing it always fails"
464 );
465
466 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); let code = ctx_code();
479 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 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 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; 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 let prices = PriceTable::defaults();
537 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
538 let _ = bandit.choose_start(&ctx, &ladder, &prices);
542 }
543
544 #[test]
548 fn zero_pass_rate_rung_is_not_optimistic() {
549 let mut b = StartRungBandit::new(10, 0.0); 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 assert_eq!(b.choose_start(&ctx, &ladder, &prices), 1);
563 }
564
565 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 #[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 let mut f = Features::new(TaskKind::CodeEdit);
613 f.prompt_token_bucket = 7; 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 let mut bandit = StartRungBandit::new(50, 0.0); 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 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 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 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 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 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 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 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 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 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}