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 mode_profile: None,
582 },
583 request: RequestInfo {
584 api: "anthropic.messages".to_owned(),
585 prompt_hash: "deadbeef".to_owned(),
586 features,
587 },
588 attempts,
589 deferred: vec![],
590 final_: FinalOutcome {
591 served_rung: Some(0),
592 served_from: ServedFrom::Attempt,
593 total_cost_usd: 0.001,
594 gate_cost_usd: 0.0,
595 total_latency_ms: 10,
596 escalations: 0,
597 counterfactual_baseline_usd: 0.001,
598 savings_usd: 0.0,
599 },
600 probe: None,
601 rollout: None,
602 shadow: None,
603 route_ix: None,
604 predicted_pass: None,
605 elastic: None,
606 };
607 trace.recompute_savings();
608 trace
609 }
610
611 #[tokio::test]
614 async fn warm_start_from_trace_store_replays_counts() {
615 let db = std::env::temp_dir().join(format!("bandit-warmstart-{}.db", uuid::Uuid::now_v7()));
616 let (tx, writer) = crate::store::open(&db).unwrap();
617
618 let mut f = Features::new(TaskKind::CodeEdit);
620 f.prompt_token_bucket = 7; for _ in 0..60 {
624 let attempts = vec![
625 stub_attempt(0, Verdict::Fail),
626 stub_attempt(1, Verdict::Pass),
627 ];
628 tx.try_send(trace_with_features_and_attempts(f.clone(), attempts))
629 .unwrap();
630 }
631 drop(tx);
632 writer.await.unwrap();
633
634 let mut bandit = StartRungBandit::new(50, 0.0); let stored = crate::store::load_all_traces(&db).unwrap();
637 assert_eq!(stored.len(), 60, "all traces must be stored");
638 for trace in &stored {
639 let ctx = ContextBucket::from_features(&trace.request.features);
640 bandit.feed_trace_attempts(&ctx, &trace.attempts);
641 }
642
643 let prices = PriceTable::defaults();
645 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
646 let ctx = ContextBucket::from_features(&f);
647 assert_eq!(
648 bandit.choose_start(&ctx, &ladder, &prices),
649 1,
650 "warm-started bandit must prefer rung 1 after 60 fail/pass pairs"
651 );
652
653 let mut f2 = Features::new(TaskKind::Chat);
655 f2.prompt_token_bucket = 7;
656 let ctx2 = ContextBucket::from_features(&f2);
657 assert_eq!(
658 bandit.choose_start(&ctx2, &ladder, &prices),
659 0,
660 "different context must be independent"
661 );
662
663 let _ = std::fs::remove_file(&db);
664 }
665
666 #[test]
667 fn beta_sampler_mean_matches_posterior_mean() {
668 let mut b = StartRungBandit::with_algorithm(0, 1.0, Algorithm::Thompson, 1.0, 0xDEADBEEF);
669 let ctx = ctx_code();
671 for _ in 0..7 {
672 b.observe(&ctx, 0, Verdict::Pass);
673 }
674 b.observe(&ctx, 0, Verdict::Fail);
675 let mean: f64 = (0..4000).map(|_| b.thompson_pass(&ctx, 0)).sum::<f64>() / 4000.0;
677 assert!(
678 (mean - 0.8).abs() < 0.03,
679 "Beta(8,2) sample mean should be ~0.8, got {mean}"
680 );
681 let mean_u: f64 = (0..4000).map(|_| b.thompson_pass(&ctx, 5)).sum::<f64>() / 4000.0;
683 assert!(
684 (mean_u - 0.5).abs() < 0.03,
685 "uniform prior mean ~0.5, got {mean_u}"
686 );
687 }
688
689 #[test]
690 fn thompson_converges_to_skipping_a_hopeless_cheap_rung() {
691 let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 42);
692 let ctx = ctx_code();
693 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
694 let prices = PriceTable::defaults();
695 for _ in 0..80 {
697 b.observe(&ctx, 0, Verdict::Fail);
698 b.observe(&ctx, 1, Verdict::Pass);
699 }
700 let picks_rung1 = (0..100)
701 .filter(|_| b.choose_start_with_propensity(&ctx, &ladder, &prices).0 == 1)
702 .count();
703 assert!(
704 picks_rung1 >= 90,
705 "TS should overwhelmingly skip the hopeless cheap rung, picked rung 1 {picks_rung1}/100"
706 );
707 }
708
709 #[test]
710 fn discounting_adapts_after_a_distribution_flip() {
711 let ctx = ctx_code();
712 let feed = |b: &mut StartRungBandit| {
714 for _ in 0..200 {
715 b.observe(&ctx, 0, Verdict::Fail);
716 }
717 for _ in 0..40 {
718 b.observe(&ctx, 0, Verdict::Pass);
719 }
720 };
721 let mut discounted = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 0.95, 7);
722 let mut undiscounted =
723 StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 7);
724 feed(&mut discounted);
725 feed(&mut undiscounted);
726 let p_disc = discounted.pass_estimate(&ctx, 0).unwrap();
727 let p_flat = undiscounted.pass_estimate(&ctx, 0).unwrap();
728 assert!(
729 p_disc > 0.7 && p_flat < 0.25,
730 "discounted tracks the flip (got {p_disc:.2}), undiscounted lags (got {p_flat:.2})"
731 );
732 }
733
734 #[test]
735 fn thompson_propensity_matches_empirical_selection_frequency() {
736 let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 99);
737 let ctx = ctx_code();
738 let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
739 let prices = PriceTable::defaults();
740 for _ in 0..20 {
742 b.observe(&ctx, 0, Verdict::Pass);
743 b.observe(&ctx, 0, Verdict::Fail);
744 b.observe(&ctx, 1, Verdict::Pass);
745 }
746 let mut freq = std::collections::HashMap::new();
748 let mut props: Vec<(u32, f64)> = Vec::new();
749 for _ in 0..400 {
750 let (c, p) = b.choose_start_with_propensity(&ctx, &ladder, &prices);
751 *freq.entry(c).or_insert(0u32) += 1;
752 props.push((c, p.expect("thompson always logs a propensity")));
753 }
754 for (arm, count) in freq {
755 let empirical = f64::from(count) / 400.0;
756 let mean_logged: f64 = {
757 let logged: Vec<f64> = props
758 .iter()
759 .filter(|(c, _)| *c == arm)
760 .map(|(_, p)| *p)
761 .collect();
762 logged.iter().sum::<f64>() / logged.len() as f64
763 };
764 assert!(
765 (empirical - mean_logged).abs() < 0.15,
766 "arm {arm}: empirical {empirical:.2} vs logged propensity {mean_logged:.2}"
767 );
768 }
769 }
770
771 #[test]
772 fn pass_estimate_cold_and_warm() {
773 let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 5);
774 let ctx = ctx_code();
775 assert!(
776 b.pass_estimate(&ctx, 0).is_none(),
777 "cold context: no estimate"
778 );
779 for _ in 0..12 {
780 b.observe(&ctx, 0, Verdict::Pass);
781 }
782 let p = b.pass_estimate(&ctx, 0).expect("warm");
783 assert!(p > 0.85, "12/12 passes: posterior mean high, got {p}");
784 assert!(
785 b.pass_estimate(&ctx, 3).is_none(),
786 "unobserved arm: no estimate"
787 );
788 }
789}