use std::collections::HashMap;
use firstpass_core::{Features, PriceTable, TaskKind, Verdict};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ContextBucket {
pub task_kind: TaskKind,
pub prompt_bucket_coarse: u32,
}
impl ContextBucket {
#[must_use]
pub fn from_features(f: &Features) -> Self {
Self {
task_kind: f.task_kind,
prompt_bucket_coarse: f.prompt_token_bucket / 2,
}
}
}
#[derive(Debug, Default, Clone)]
struct ArmCounts {
pass: f64,
fail: f64,
}
impl ArmCounts {
fn n(&self) -> f64 {
self.pass + self.fail
}
}
#[derive(Debug)]
pub struct StartRungBandit {
exploration: f64,
min_observations: usize,
algorithm: Algorithm,
discount: f64,
rng: u64,
data: HashMap<ContextBucket, HashMap<u32, ArmCounts>>,
}
const PROPENSITY_SAMPLES: usize = 64;
fn argmin_expected_cost(
ladder: &[String],
prices: &PriceTable,
mut p_pass: impl FnMut(u32) -> f64,
) -> u32 {
const NOMINAL_IN: u64 = 1_000;
const NOMINAL_OUT: u64 = 500;
let mut best_s = 0u32;
let mut best_cost = f64::MAX;
for s in 0..ladder.len() {
let mut expected_cost = 0.0_f64;
let mut p_reach = 1.0_f64;
for (r, model) in ladder.iter().enumerate().skip(s) {
let price = prices
.get(model)
.map(|p| p.cost(NOMINAL_IN, NOMINAL_OUT))
.unwrap_or(0.0);
expected_cost += p_reach * price;
p_reach *= 1.0 - p_pass(r as u32);
if p_reach < 1e-10 {
break;
}
}
if expected_cost < best_cost {
best_cost = expected_cost;
best_s = s as u32;
}
}
best_s
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Algorithm {
#[default]
Ucb1,
Thompson,
}
impl StartRungBandit {
#[must_use]
pub fn new(min_observations: usize, exploration: f64) -> Self {
Self::with_algorithm(min_observations, exploration, Algorithm::Ucb1, 1.0, 0x9E37)
}
#[must_use]
pub fn with_algorithm(
min_observations: usize,
exploration: f64,
algorithm: Algorithm,
discount: f64,
seed: u64,
) -> Self {
Self {
exploration,
min_observations,
algorithm,
discount: discount.clamp(f64::MIN_POSITIVE, 1.0),
rng: seed.max(1),
data: HashMap::new(),
}
}
fn discount_context(&mut self, ctx: &ContextBucket) {
if self.discount >= 1.0 {
return;
}
if let Some(arms) = self.data.get_mut(ctx) {
for c in arms.values_mut() {
c.pass *= self.discount;
c.fail *= self.discount;
}
}
}
fn next_u01(&mut self) -> f64 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
let v = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
(v >> 11) as f64 / (1u64 << 53) as f64
}
fn next_normal(&mut self) -> f64 {
let u1 = self.next_u01().max(f64::MIN_POSITIVE);
let u2 = self.next_u01();
(-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos()
}
fn next_gamma(&mut self, shape: f64) -> f64 {
debug_assert!(shape >= 1.0, "Beta(+1 prior) keeps shapes >= 1");
let d = shape - 1.0 / 3.0;
let c = 1.0 / (9.0 * d).sqrt();
loop {
let x = self.next_normal();
let v = (1.0 + c * x).powi(3);
if v <= 0.0 {
continue;
}
let u = self.next_u01();
if u < 1.0 - 0.0331 * x.powi(4) || u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
return d * v;
}
}
}
fn thompson_pass(&mut self, ctx: &ContextBucket, rung: u32) -> f64 {
let (a, b) = self
.data
.get(ctx)
.and_then(|arms| arms.get(&rung))
.map_or((1.0, 1.0), |c| (c.pass + 1.0, c.fail + 1.0));
let x = self.next_gamma(a);
let y = self.next_gamma(b);
x / (x + y)
}
#[must_use]
pub fn pass_estimate(&self, ctx: &ContextBucket, rung: u32) -> Option<f64> {
let arms = self.data.get(ctx)?;
let total: f64 = arms.values().map(ArmCounts::n).sum();
if total < self.min_observations as f64 {
return None;
}
let c = arms.get(&rung)?;
if c.n() == 0.0 {
return None;
}
Some((c.pass + 1.0) / (c.n() + 2.0))
}
pub fn observe(&mut self, ctx: &ContextBucket, rung: u32, verdict: Verdict) {
match verdict {
Verdict::Abstain => {} Verdict::Pass => {
self.discount_context(ctx);
self.data
.entry(ctx.clone())
.or_default()
.entry(rung)
.or_default()
.pass += 1.0;
}
Verdict::Fail => {
self.discount_context(ctx);
self.data
.entry(ctx.clone())
.or_default()
.entry(rung)
.or_default()
.fail += 1.0;
}
}
}
fn ucb_pass(&self, ctx: &ContextBucket, rung: u32, ln_n: f64) -> f64 {
let Some(arms) = self.data.get(ctx) else {
return 1.0; };
let Some(counts) = arms.get(&rung) else {
return 1.0; };
let n = counts.n();
if n == 0.0 {
return 1.0;
}
let p_hat = counts.pass / n;
(p_hat + self.exploration * (ln_n / n).sqrt()).clamp(0.0, 1.0)
}
#[must_use]
pub fn choose_start(&self, ctx: &ContextBucket, ladder: &[String], prices: &PriceTable) -> u32 {
let top = ladder.len();
if top == 0 {
return 0;
}
let n_total: f64 = self
.data
.get(ctx)
.map(|arms| arms.values().map(ArmCounts::n).sum())
.unwrap_or(0.0);
if n_total < self.min_observations as f64 {
return 0;
}
let ln_n = n_total.ln();
argmin_expected_cost(ladder, prices, |r| self.ucb_pass(ctx, r, ln_n))
}
#[must_use]
pub fn choose_start_with_propensity(
&mut self,
ctx: &ContextBucket,
ladder: &[String],
prices: &PriceTable,
) -> (u32, Option<f64>) {
if self.algorithm == Algorithm::Ucb1 {
return (self.choose_start(ctx, ladder, prices), None);
}
let top = ladder.len();
if top == 0 {
return (0, None);
}
let n_total: f64 = self
.data
.get(ctx)
.map(|arms| arms.values().map(ArmCounts::n).sum())
.unwrap_or(0.0);
if n_total < self.min_observations as f64 {
return (0, None);
}
let draw = |this: &mut Self| {
let samples: Vec<f64> = (0..top)
.map(|r| this.thompson_pass(ctx, r as u32))
.collect();
argmin_expected_cost(ladder, prices, |r| samples[r as usize])
};
let choice = draw(self);
let matches = (0..PROPENSITY_SAMPLES)
.filter(|_| draw(self) == choice)
.count();
let p = (matches as f64 / PROPENSITY_SAMPLES as f64)
.max(1.0 / (2.0 * PROPENSITY_SAMPLES as f64));
(choice, Some(p))
}
pub fn feed_trace_attempts(
&mut self,
ctx: &ContextBucket,
attempts: &[firstpass_core::Attempt],
) {
for attempt in attempts {
self.observe(ctx, attempt.rung, attempt.verdict);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use firstpass_core::{
Attempt, Features, FinalOutcome, GENESIS_HASH, Mode, PolicyRef, RequestInfo, ServedFrom,
TaskKind, Trace,
};
fn ctx_code() -> ContextBucket {
ContextBucket {
task_kind: TaskKind::CodeEdit,
prompt_bucket_coarse: 3,
}
}
fn ctx_chat() -> ContextBucket {
ContextBucket {
task_kind: TaskKind::Chat,
prompt_bucket_coarse: 3,
}
}
const HAIKU: &str = "anthropic/claude-haiku-4-5";
const SONNET: &str = "anthropic/claude-sonnet-5";
#[test]
fn cold_start_returns_rung_0() {
let b = StartRungBandit::new(50, 1.0);
let prices = PriceTable::defaults();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
assert_eq!(b.choose_start(&ctx_code(), &ladder, &prices), 0);
}
#[test]
fn empty_ladder_returns_rung_0() {
let b = StartRungBandit::new(50, 1.0);
let prices = PriceTable::defaults();
assert_eq!(b.choose_start(&ctx_code(), &[], &prices), 0);
}
#[test]
fn after_enough_rung0_fails_rung1_passes_picks_rung1_for_that_context() {
let mut b = StartRungBandit::new(50, 1.0);
let prices = PriceTable::defaults();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
let code = ctx_code();
for _ in 0..60 {
b.observe(&code, 0, Verdict::Fail);
b.observe(&code, 1, Verdict::Pass);
}
assert_eq!(
b.choose_start(&code, &ladder, &prices),
1,
"should skip rung 0 after observing it always fails"
);
let chat = ctx_chat();
assert_eq!(
b.choose_start(&chat, &ladder, &prices),
0,
"different context must be independent (cold start)"
);
}
#[test]
fn abstain_verdicts_are_not_counted() {
let mut b = StartRungBandit::new(2, 1.0); let code = ctx_code();
for _ in 0..100 {
b.observe(&code, 0, Verdict::Abstain);
}
let prices = PriceTable::defaults();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
assert_eq!(b.choose_start(&code, &ladder, &prices), 0);
}
#[test]
fn single_rung_ladder_always_returns_0() {
let mut b = StartRungBandit::new(1, 1.0);
let code = ctx_code();
b.observe(&code, 0, Verdict::Fail);
b.observe(&code, 0, Verdict::Pass);
let prices = PriceTable::defaults();
let ladder = vec![HAIKU.to_owned()];
assert_eq!(b.choose_start(&code, &ladder, &prices), 0);
}
fn stub_attempt(rung: u32, verdict: Verdict) -> Attempt {
Attempt {
rung,
model: HAIKU.to_owned(),
provider: "anthropic".to_owned(),
in_tokens: 1000,
out_tokens: 500,
cost_usd: 0.001,
latency_ms: 10,
gates: vec![],
verdict,
}
}
#[test]
fn from_features_derives_bucket_correctly() {
let mut f = Features::new(TaskKind::CodeEdit);
f.prompt_token_bucket = 7; let b = ContextBucket::from_features(&f);
assert_eq!(b.task_kind, TaskKind::CodeEdit);
assert_eq!(b.prompt_bucket_coarse, 3);
}
#[test]
fn feed_trace_attempts_populates_counts() {
let mut bandit = StartRungBandit::new(2, 1.0);
let ctx = ctx_code();
let attempts = vec![
stub_attempt(0, Verdict::Fail),
stub_attempt(1, Verdict::Pass),
];
bandit.feed_trace_attempts(&ctx, &attempts);
let prices = PriceTable::defaults();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
let _ = bandit.choose_start(&ctx, &ladder, &prices);
}
#[test]
fn zero_pass_rate_rung_is_not_optimistic() {
let mut b = StartRungBandit::new(10, 0.0); let ctx = ctx_code();
for _ in 0..20 {
b.observe(&ctx, 0, Verdict::Fail);
b.observe(&ctx, 1, Verdict::Pass);
}
let prices = PriceTable::defaults();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
assert_eq!(b.choose_start(&ctx, &ladder, &prices), 1);
}
fn trace_with_features_and_attempts(features: Features, attempts: Vec<Attempt>) -> Trace {
let mut trace = Trace {
trace_id: uuid::Uuid::now_v7(),
prev_hash: GENESIS_HASH.to_owned(),
tenant_id: "test-tenant".to_owned(),
session_id: "sess-warm".to_owned(),
ts: jiff::Timestamp::now(),
mode: Mode::Enforce,
policy: PolicyRef {
id: "test@v0".to_owned(),
explore: false,
propensity: None,
},
request: RequestInfo {
api: "anthropic.messages".to_owned(),
prompt_hash: "deadbeef".to_owned(),
features,
},
attempts,
deferred: vec![],
final_: FinalOutcome {
served_rung: Some(0),
served_from: ServedFrom::Attempt,
total_cost_usd: 0.001,
gate_cost_usd: 0.0,
total_latency_ms: 10,
escalations: 0,
counterfactual_baseline_usd: 0.001,
savings_usd: 0.0,
},
};
trace.recompute_savings();
trace
}
#[tokio::test]
async fn warm_start_from_trace_store_replays_counts() {
let db = std::env::temp_dir().join(format!("bandit-warmstart-{}.db", uuid::Uuid::now_v7()));
let (tx, writer) = crate::store::open(&db).unwrap();
let mut f = Features::new(TaskKind::CodeEdit);
f.prompt_token_bucket = 7;
for _ in 0..60 {
let attempts = vec![
stub_attempt(0, Verdict::Fail),
stub_attempt(1, Verdict::Pass),
];
tx.try_send(trace_with_features_and_attempts(f.clone(), attempts))
.unwrap();
}
drop(tx);
writer.await.unwrap();
let mut bandit = StartRungBandit::new(50, 0.0); let stored = crate::store::load_all_traces(&db).unwrap();
assert_eq!(stored.len(), 60, "all traces must be stored");
for trace in &stored {
let ctx = ContextBucket::from_features(&trace.request.features);
bandit.feed_trace_attempts(&ctx, &trace.attempts);
}
let prices = PriceTable::defaults();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
let ctx = ContextBucket::from_features(&f);
assert_eq!(
bandit.choose_start(&ctx, &ladder, &prices),
1,
"warm-started bandit must prefer rung 1 after 60 fail/pass pairs"
);
let mut f2 = Features::new(TaskKind::Chat);
f2.prompt_token_bucket = 7;
let ctx2 = ContextBucket::from_features(&f2);
assert_eq!(
bandit.choose_start(&ctx2, &ladder, &prices),
0,
"different context must be independent"
);
let _ = std::fs::remove_file(&db);
}
#[test]
fn beta_sampler_mean_matches_posterior_mean() {
let mut b = StartRungBandit::with_algorithm(0, 1.0, Algorithm::Thompson, 1.0, 0xDEADBEEF);
let ctx = ctx_code();
for _ in 0..7 {
b.observe(&ctx, 0, Verdict::Pass);
}
b.observe(&ctx, 0, Verdict::Fail);
let mean: f64 = (0..4000).map(|_| b.thompson_pass(&ctx, 0)).sum::<f64>() / 4000.0;
assert!(
(mean - 0.8).abs() < 0.03,
"Beta(8,2) sample mean should be ~0.8, got {mean}"
);
let mean_u: f64 = (0..4000).map(|_| b.thompson_pass(&ctx, 5)).sum::<f64>() / 4000.0;
assert!(
(mean_u - 0.5).abs() < 0.03,
"uniform prior mean ~0.5, got {mean_u}"
);
}
#[test]
fn thompson_converges_to_skipping_a_hopeless_cheap_rung() {
let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 42);
let ctx = ctx_code();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
let prices = PriceTable::defaults();
for _ in 0..80 {
b.observe(&ctx, 0, Verdict::Fail);
b.observe(&ctx, 1, Verdict::Pass);
}
let picks_rung1 = (0..100)
.filter(|_| b.choose_start_with_propensity(&ctx, &ladder, &prices).0 == 1)
.count();
assert!(
picks_rung1 >= 90,
"TS should overwhelmingly skip the hopeless cheap rung, picked rung 1 {picks_rung1}/100"
);
}
#[test]
fn discounting_adapts_after_a_distribution_flip() {
let ctx = ctx_code();
let feed = |b: &mut StartRungBandit| {
for _ in 0..200 {
b.observe(&ctx, 0, Verdict::Fail);
}
for _ in 0..40 {
b.observe(&ctx, 0, Verdict::Pass);
}
};
let mut discounted = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 0.95, 7);
let mut undiscounted =
StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 7);
feed(&mut discounted);
feed(&mut undiscounted);
let p_disc = discounted.pass_estimate(&ctx, 0).unwrap();
let p_flat = undiscounted.pass_estimate(&ctx, 0).unwrap();
assert!(
p_disc > 0.7 && p_flat < 0.25,
"discounted tracks the flip (got {p_disc:.2}), undiscounted lags (got {p_flat:.2})"
);
}
#[test]
fn thompson_propensity_matches_empirical_selection_frequency() {
let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 99);
let ctx = ctx_code();
let ladder = vec![HAIKU.to_owned(), SONNET.to_owned()];
let prices = PriceTable::defaults();
for _ in 0..20 {
b.observe(&ctx, 0, Verdict::Pass);
b.observe(&ctx, 0, Verdict::Fail);
b.observe(&ctx, 1, Verdict::Pass);
}
let mut freq = std::collections::HashMap::new();
let mut props: Vec<(u32, f64)> = Vec::new();
for _ in 0..400 {
let (c, p) = b.choose_start_with_propensity(&ctx, &ladder, &prices);
*freq.entry(c).or_insert(0u32) += 1;
props.push((c, p.expect("thompson always logs a propensity")));
}
for (arm, count) in freq {
let empirical = f64::from(count) / 400.0;
let mean_logged: f64 = {
let logged: Vec<f64> = props
.iter()
.filter(|(c, _)| *c == arm)
.map(|(_, p)| *p)
.collect();
logged.iter().sum::<f64>() / logged.len() as f64
};
assert!(
(empirical - mean_logged).abs() < 0.15,
"arm {arm}: empirical {empirical:.2} vs logged propensity {mean_logged:.2}"
);
}
}
#[test]
fn pass_estimate_cold_and_warm() {
let mut b = StartRungBandit::with_algorithm(10, 1.0, Algorithm::Thompson, 1.0, 5);
let ctx = ctx_code();
assert!(
b.pass_estimate(&ctx, 0).is_none(),
"cold context: no estimate"
);
for _ in 0..12 {
b.observe(&ctx, 0, Verdict::Pass);
}
let p = b.pass_estimate(&ctx, 0).expect("warm");
assert!(p > 0.85, "12/12 passes: posterior mean high, got {p}");
assert!(
b.pass_estimate(&ctx, 3).is_none(),
"unobserved arm: no estimate"
);
}
}