use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
use std::sync::RwLock;
use crate::solver::{CaptchaType, SolveMethod};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Arm {
pub captcha_type: String,
pub solver_method: String,
pub alpha: f64,
pub beta: f64,
}
impl Arm {
pub fn new(captcha_type: String, solver_method: String) -> Self {
Self {
captcha_type,
solver_method,
alpha: 1.0,
beta: 1.0,
}
}
pub fn posterior_mean(&self) -> f64 {
self.alpha / (self.alpha + self.beta)
}
pub fn posterior_variance(&self) -> f64 {
let a = self.alpha;
let b = self.beta;
let denom = (a + b).powi(2) * (a + b + 1.0);
(a * b) / denom
}
pub fn observe(&mut self, succeeded: bool) {
if succeeded {
self.alpha += 1.0;
} else {
self.beta += 1.0;
}
}
}
pub struct Bandit {
arms: RwLock<HashMap<(String, String), Arm>>,
decay: f64,
}
impl Bandit {
pub fn new() -> Self {
Self {
arms: RwLock::new(HashMap::new()),
decay: 1.0,
}
}
pub fn with_decay(mut self, decay: f64) -> Self {
self.decay = decay.clamp(0.5, 1.0);
self
}
pub fn choose(
&self,
captcha_type: &CaptchaType,
candidates: &[SolveMethod],
) -> Option<SolveMethod> {
if candidates.is_empty() {
return None;
}
let arms = self.arms.read().unwrap_or_else(|e| e.into_inner());
let ct = render_captcha_type(captcha_type);
let mut rng = rand::thread_rng();
let mut best: Option<(f64, SolveMethod)> = None;
for cand in candidates {
let sm = render_solve_method(cand);
let arm = arms
.get(&(ct.clone(), sm.clone()))
.cloned()
.unwrap_or_else(|| Arm::new(ct.clone(), sm.clone()));
let theta = sample_beta(arm.alpha, arm.beta, &mut rng);
if best.as_ref().map(|(b, _)| theta > *b).unwrap_or(true) {
best = Some((theta, cand.clone()));
}
}
best.map(|(_, m)| m)
}
pub fn observe(
&self,
captcha_type: &CaptchaType,
solver_method: &SolveMethod,
succeeded: bool,
) {
let ct = render_captcha_type(captcha_type);
let sm = render_solve_method(solver_method);
let mut arms = self.arms.write().unwrap_or_else(|e| e.into_inner());
if self.decay < 1.0 {
for arm in arms.values_mut() {
if arm.alpha + arm.beta > 2.0 {
arm.alpha = ((arm.alpha - 1.0) * self.decay) + 1.0;
arm.beta = ((arm.beta - 1.0) * self.decay) + 1.0;
}
}
}
let arm = arms
.entry((ct.clone(), sm.clone()))
.or_insert_with(|| Arm::new(ct, sm));
arm.observe(succeeded);
}
pub fn snapshot(&self) -> Vec<Arm> {
let arms = self.arms.read().unwrap_or_else(|e| e.into_inner());
arms.values().cloned().collect()
}
pub fn save_to(&self, path: &Path) -> std::io::Result<()> {
let snap = self.snapshot();
let s = serde_json::to_string(&snap)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(path, s)
}
pub fn load_from(&self, path: &Path) -> std::io::Result<usize> {
let body = std::fs::read_to_string(path)?;
let snap: Vec<Arm> = serde_json::from_str(&body)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let count = snap.len();
let mut arms = self.arms.write().unwrap_or_else(|e| e.into_inner());
for arm in snap {
arms.insert((arm.captcha_type.clone(), arm.solver_method.clone()), arm);
}
Ok(count)
}
pub fn len(&self) -> usize {
self.arms.read().unwrap_or_else(|e| e.into_inner()).len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl Default for Bandit {
fn default() -> Self {
Self::new()
}
}
fn sample_beta(alpha: f64, beta: f64, rng: &mut impl Rng) -> f64 {
let g1 = sample_gamma(alpha, rng);
let g2 = sample_gamma(beta, rng);
if g1 + g2 == 0.0 {
return 0.5;
}
g1 / (g1 + g2)
}
fn sample_gamma(shape: f64, rng: &mut impl Rng) -> f64 {
if shape < 1.0 {
let g = sample_gamma(shape + 1.0, rng);
let u: f64 = rng.gen();
return g * u.powf(1.0 / shape);
}
let d = shape - 1.0 / 3.0;
let c = 1.0 / (9.0 * d).sqrt();
loop {
let mut x;
let mut v;
loop {
x = sample_standard_normal(rng);
v = 1.0 + c * x;
if v > 0.0 {
break;
}
}
v = v * v * v;
let u: f64 = rng.gen();
if u < 1.0 - 0.0331 * (x * x * x * x) {
return d * v;
}
if u.ln() < 0.5 * x * x + d * (1.0 - v + v.ln()) {
return d * v;
}
}
}
fn sample_standard_normal(rng: &mut impl Rng) -> f64 {
let u1: f64 = rng.gen::<f64>().max(1e-12);
let u2: f64 = rng.gen();
let r = (-2.0 * u1.ln()).sqrt();
let theta = 2.0 * std::f64::consts::PI * u2;
r * theta.cos()
}
fn render_captcha_type(c: &CaptchaType) -> String {
match c {
CaptchaType::Custom(name) => {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(name.as_bytes());
let d = h.finalize();
format!("Custom:{:02x}{:02x}{:02x}{:02x}", d[0], d[1], d[2], d[3])
}
other => format!("{other:?}"),
}
}
fn render_solve_method(m: &SolveMethod) -> String {
format!("{m:?}")
}
#[cfg(test)]
mod tests {
use super::*;
use rand::SeedableRng;
fn methods() -> Vec<SolveMethod> {
vec![
SolveMethod::BehavioralBypass,
SolveMethod::VisionLLM,
SolveMethod::AudioBypass,
SolveMethod::CrowdSourced,
SolveMethod::AutoPass,
]
}
#[test]
fn arm_new_starts_with_uniform_prior() {
let arm = Arm::new("X".into(), "Y".into());
assert_eq!(arm.alpha, 1.0);
assert_eq!(arm.beta, 1.0);
assert!((arm.posterior_mean() - 0.5).abs() < 0.001);
}
#[test]
fn arm_observe_success_increments_alpha() {
let mut arm = Arm::new("X".into(), "Y".into());
arm.observe(true);
assert_eq!(arm.alpha, 2.0);
assert_eq!(arm.beta, 1.0);
}
#[test]
fn arm_observe_failure_increments_beta() {
let mut arm = Arm::new("X".into(), "Y".into());
arm.observe(false);
assert_eq!(arm.alpha, 1.0);
assert_eq!(arm.beta, 2.0);
}
#[test]
fn arm_posterior_mean_tracks_success_rate() {
let mut arm = Arm::new("X".into(), "Y".into());
for _ in 0..9 {
arm.observe(true);
}
arm.observe(false);
let mean = arm.posterior_mean();
assert!((mean - 10.0 / 12.0).abs() < 0.001, "got {mean}");
}
#[test]
fn choose_returns_none_on_empty_candidates() {
let b = Bandit::new();
let r = b.choose(&CaptchaType::CloudflareTurnstile, &[]);
assert!(r.is_none());
}
#[test]
fn choose_returns_some_when_candidates_present() {
let b = Bandit::new();
let r = b.choose(&CaptchaType::CloudflareTurnstile, &methods());
assert!(r.is_some());
}
#[test]
fn observe_increments_arm_count() {
let b = Bandit::new();
b.observe(
&CaptchaType::CloudflareTurnstile,
&SolveMethod::AutoPass,
true,
);
assert_eq!(b.len(), 1);
b.observe(
&CaptchaType::CloudflareTurnstile,
&SolveMethod::VisionLLM,
false,
);
assert_eq!(b.len(), 2);
}
#[test]
fn save_and_load_round_trips_arms() {
let b = Bandit::new();
for _ in 0..5 {
b.observe(
&CaptchaType::CloudflareTurnstile,
&SolveMethod::AutoPass,
true,
);
}
for _ in 0..2 {
b.observe(
&CaptchaType::CloudflareTurnstile,
&SolveMethod::VisionLLM,
false,
);
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bandit.json");
b.save_to(&path).unwrap();
let b2 = Bandit::new();
let loaded = b2.load_from(&path).unwrap();
assert_eq!(loaded, 2);
assert_eq!(b2.len(), 2);
}
#[test]
fn sample_beta_in_unit_interval() {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
for _ in 0..1000 {
let x = sample_beta(2.0, 3.0, &mut rng);
assert!((0.0..=1.0).contains(&x), "sample {x} out of [0,1]");
}
}
#[test]
fn sample_beta_mean_matches_alpha_over_alpha_plus_beta() {
let mut rng = rand::rngs::StdRng::seed_from_u64(1234);
let (alpha, beta) = (8.0, 2.0);
let mut sum = 0.0;
let n = 5000;
for _ in 0..n {
sum += sample_beta(alpha, beta, &mut rng);
}
let mean = sum / (n as f64);
let expected = alpha / (alpha + beta);
assert!(
(mean - expected).abs() < 0.02,
"empirical mean {mean} != expected {expected}"
);
}
#[test]
fn convergence_thompson_concentrates_on_best_arm() {
let true_p = [0.9, 0.6, 0.4, 0.2, 0.1];
let methods_local = vec![
SolveMethod::AutoPass,
SolveMethod::BehavioralBypass,
SolveMethod::VisionLLM,
SolveMethod::AudioBypass,
SolveMethod::ThirdPartyService,
];
let b = Bandit::new();
let ct = CaptchaType::CloudflareTurnstile;
let mut rng = rand::rngs::StdRng::seed_from_u64(0xC0DE);
let mut counts = [0usize; 5];
for _ in 0..1000 {
let chosen = b.choose(&ct, &methods_local).unwrap();
let idx = methods_local.iter().position(|m| m == &chosen).unwrap();
counts[idx] += 1;
let succeeded: bool = rng.gen_bool(true_p[idx]);
b.observe(&ct, &chosen, succeeded);
}
let best_pulls = counts[0];
let total = counts.iter().sum::<usize>();
let frac = (best_pulls as f64) / (total as f64);
assert!(
frac >= 0.55,
"Thompson didn't concentrate; arm0 got {best_pulls}/{total} = {frac:.3}"
);
}
#[test]
fn convergence_regret_bounded() {
let true_p = [0.9, 0.5, 0.3, 0.1, 0.05];
let methods_local = vec![
SolveMethod::AutoPass,
SolveMethod::BehavioralBypass,
SolveMethod::VisionLLM,
SolveMethod::AudioBypass,
SolveMethod::ThirdPartyService,
];
let b = Bandit::new();
let ct = CaptchaType::CloudflareTurnstile;
let mut rng = rand::rngs::StdRng::seed_from_u64(0xC0FFEE);
let mut bandit_reward = 0;
for _ in 0..1000 {
let chosen = b.choose(&ct, &methods_local).unwrap();
let idx = methods_local.iter().position(|m| m == &chosen).unwrap();
let succeeded: bool = rng.gen_bool(true_p[idx]);
if succeeded {
bandit_reward += 1;
}
b.observe(&ct, &chosen, succeeded);
}
let oracle_expected_reward = 1000.0 * true_p[0]; let ratio = (bandit_reward as f64) / oracle_expected_reward;
assert!(
ratio >= 0.65,
"bandit reward {bandit_reward} fell below 65% of oracle {oracle_expected_reward}"
);
}
#[test]
fn scale_50k_rounds_thompson_competitive() {
let true_p = [0.85, 0.6, 0.4, 0.2, 0.1];
let methods_local = vec![
SolveMethod::AutoPass,
SolveMethod::BehavioralBypass,
SolveMethod::VisionLLM,
SolveMethod::AudioBypass,
SolveMethod::ThirdPartyService,
];
let b = Bandit::new();
let ct = CaptchaType::CloudflareTurnstile;
let mut rng = rand::rngs::StdRng::seed_from_u64(0xDEAD_BEEF);
let mut bandit_reward = 0;
for _ in 0..50_000 {
let chosen = b.choose(&ct, &methods_local).unwrap();
let idx = methods_local.iter().position(|m| m == &chosen).unwrap();
let succeeded: bool = rng.gen_bool(true_p[idx]);
if succeeded {
bandit_reward += 1;
}
b.observe(&ct, &chosen, succeeded);
}
let oracle_expected = 50_000.0 * true_p[0];
let ratio = (bandit_reward as f64) / oracle_expected;
assert!(
ratio >= 0.75,
"50k rounds: bandit reward {} below 75% of oracle {}; ratio={:.3}",
bandit_reward,
oracle_expected,
ratio
);
}
#[test]
fn drift_decay_helps_recovery_after_arm_change() {
let methods_local = vec![
SolveMethod::AutoPass,
SolveMethod::BehavioralBypass,
SolveMethod::VisionLLM,
];
let mut rng = rand::rngs::StdRng::seed_from_u64(0x1234_5678);
let b_static = Bandit::new();
let ct = CaptchaType::CloudflareTurnstile;
let mut switch_recovery_static = 0;
for round in 0..10_000 {
let chosen = b_static.choose(&ct, &methods_local).unwrap();
let idx = methods_local.iter().position(|m| m == &chosen).unwrap();
let p = if round < 5_000 {
[0.9, 0.4, 0.1][idx]
} else {
[0.1, 0.4, 0.9][idx]
};
let succeeded: bool = rng.gen_bool(p);
if round >= 5_000 && chosen == SolveMethod::VisionLLM && succeeded {
switch_recovery_static += 1;
}
b_static.observe(&ct, &chosen, succeeded);
}
let b_decay = Bandit::new().with_decay(0.995);
let mut switch_recovery_decay = 0;
for round in 0..10_000 {
let chosen = b_decay.choose(&ct, &methods_local).unwrap();
let idx = methods_local.iter().position(|m| m == &chosen).unwrap();
let p = if round < 5_000 {
[0.9, 0.4, 0.1][idx]
} else {
[0.1, 0.4, 0.9][idx]
};
let succeeded: bool = rng.gen_bool(p);
if round >= 5_000 && chosen == SolveMethod::VisionLLM && succeeded {
switch_recovery_decay += 1;
}
b_decay.observe(&ct, &chosen, succeeded);
}
assert!(
switch_recovery_decay >= switch_recovery_static,
"decay variant ({switch_recovery_decay}) didn't help vs static ({switch_recovery_static})"
);
}
proptest::proptest! {
#![proptest_config(proptest::test_runner::Config {
cases: 10_000, .. proptest::test_runner::Config::default()
})]
#[test]
fn prop_choose_returns_one_of_candidates(
n in 1usize..6,
) {
let b = Bandit::new();
let cand: Vec<SolveMethod> = methods().into_iter().take(n).collect();
let r = b.choose(&CaptchaType::CloudflareTurnstile, &cand).unwrap();
assert!(cand.contains(&r));
}
#[test]
fn prop_observe_alpha_beta_strictly_monotonic(
successes in 0u32..200,
failures in 0u32..200,
) {
let b = Bandit::new();
let ct = CaptchaType::CloudflareTurnstile;
let sm = SolveMethod::AutoPass;
for _ in 0..successes {
b.observe(&ct, &sm, true);
}
for _ in 0..failures {
b.observe(&ct, &sm, false);
}
let snap = b.snapshot();
let arm = snap.iter().find(|a| a.solver_method == "AutoPass");
if successes > 0 || failures > 0 {
let arm = arm.unwrap();
assert!(arm.alpha >= 1.0);
assert!(arm.beta >= 1.0);
assert!((arm.alpha - (1.0 + successes as f64)).abs() < 0.001);
assert!((arm.beta - (1.0 + failures as f64)).abs() < 0.001);
}
}
#[test]
fn prop_sample_beta_in_unit_interval(
alpha in 0.1f64..100.0,
beta in 0.1f64..100.0,
) {
let mut rng = rand::thread_rng();
for _ in 0..10 {
let x = sample_beta(alpha, beta, &mut rng);
assert!((0.0..=1.0).contains(&x));
}
}
#[test]
fn prop_save_load_idempotent(
successes in 0u32..50,
failures in 0u32..50,
) {
let b = Bandit::new();
let ct = CaptchaType::CloudflareTurnstile;
let sm = SolveMethod::AutoPass;
for _ in 0..successes {
b.observe(&ct, &sm, true);
}
for _ in 0..failures {
b.observe(&ct, &sm, false);
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bandit.json");
b.save_to(&path).unwrap();
let b2 = Bandit::new();
b2.load_from(&path).unwrap();
assert_eq!(b.len(), b2.len());
}
}
}