use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::sync::{Arc, RwLock};
use super::{CaptchaType, SolveMethod};
const EMA_BOOTSTRAP_N: u32 = 10;
const EMA_ALPHA: f32 = 0.2;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MethodStat {
pub success_rate: f32,
pub avg_solve_time: u64,
pub sample_count: u32,
}
impl MethodStat {
fn record(&mut self, success: bool, solve_time_ms: u64) {
self.sample_count = self.sample_count.saturating_add(1);
let alpha = if self.sample_count <= EMA_BOOTSTRAP_N {
1.0 / self.sample_count as f32
} else {
EMA_ALPHA
};
let observation = if success { 1.0 } else { 0.0 };
self.success_rate = self.success_rate * (1.0 - alpha) + observation * alpha;
let prev = self.avg_solve_time as f32;
let next = prev * (1.0 - alpha) + solve_time_ms as f32 * alpha;
self.avg_solve_time = next.round() as u64;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CaptchaPattern {
pub domain: String,
pub captcha_type: CaptchaType,
pub best_method: SolveMethod,
pub success_rate: f32,
pub avg_solve_time: u64,
pub sample_count: u32,
#[serde(default)]
pub methods: BTreeMap<String, MethodStat>,
}
impl CaptchaPattern {
pub fn new(domain: impl Into<String>, captcha_type: CaptchaType, method: SolveMethod) -> Self {
Self {
domain: domain.into(),
captcha_type,
best_method: method,
success_rate: 0.0,
avg_solve_time: 0,
sample_count: 0,
methods: BTreeMap::new(),
}
}
pub fn record(&mut self, success: bool, solve_time_ms: u64, method: SolveMethod) {
self.sample_count = self.sample_count.saturating_add(1);
let key = method_key(&method);
self.methods
.entry(key.clone())
.or_default()
.record(success, solve_time_ms);
let (best_key, best_stat) = self
.methods
.iter()
.max_by(|(_, a), (_, b)| {
a.success_rate
.partial_cmp(&b.success_rate)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| b.avg_solve_time.cmp(&a.avg_solve_time))
})
.map(|(k, s)| (k.clone(), s.clone()))
.unwrap_or_else(|| (key, MethodStat::default()));
self.best_method = method_from_key(&best_key);
self.success_rate = best_stat.success_rate;
self.avg_solve_time = best_stat.avg_solve_time;
}
}
fn method_key(m: &SolveMethod) -> String {
serde_json::to_value(m)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| format!("{:?}", m))
}
fn method_from_key(key: &str) -> SolveMethod {
serde_json::from_value(serde_json::Value::String(key.to_string()))
.unwrap_or(SolveMethod::CrowdSourced)
}
#[derive(Debug, Clone, Default)]
pub struct PatternStore {
inner: Arc<RwLock<HashMap<String, CaptchaPattern>>>,
}
impl PatternStore {
fn key(domain: &str, ct: &CaptchaType) -> String {
format!("{}:{}", domain, ct)
}
pub fn record(
&self,
domain: &str,
captcha_type: &CaptchaType,
success: bool,
time_ms: u64,
method: SolveMethod,
) {
let key = Self::key(domain, captcha_type);
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
map.entry(key)
.and_modify(|p| p.record(success, time_ms, method.clone()))
.or_insert_with(|| {
let mut p = CaptchaPattern::new(domain, captcha_type.clone(), method.clone());
p.record(success, time_ms, method);
p
});
}
pub fn best_method(&self, domain: &str, captcha_type: &CaptchaType) -> Option<SolveMethod> {
let key = Self::key(domain, captcha_type);
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
map.get(&key).map(|p| p.best_method.clone())
}
pub fn all_patterns(&self) -> Vec<CaptchaPattern> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect()
}
pub fn save_to_path(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
let path = path.as_ref();
let patterns = self.all_patterns();
let json = serde_json::to_vec_pretty(&patterns)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn load_from_path(path: impl AsRef<std::path::Path>) -> std::io::Result<Self> {
let bytes = std::fs::read(path.as_ref())?;
let patterns: Vec<CaptchaPattern> = serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut map = HashMap::with_capacity(patterns.len());
for pat in patterns {
let key = Self::key(&pat.domain, &pat.captcha_type);
map.insert(key, pat);
}
Ok(Self {
inner: Arc::new(RwLock::new(map)),
})
}
pub fn merge(&self, other: &PatternStore) {
let mut self_map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let other_map = other.inner.read().unwrap_or_else(|e| e.into_inner());
for (key, other_pat) in other_map.iter() {
self_map
.entry(key.clone())
.and_modify(|existing| {
if other_pat.sample_count > existing.sample_count {
*existing = other_pat.clone();
}
})
.or_insert_with(|| other_pat.clone());
}
}
pub fn load_or_default(path: impl AsRef<std::path::Path>) -> Self {
Self::load_from_path(path).unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pattern_record_tracks_per_method_winner() {
let mut p = CaptchaPattern::new(
"example.com",
CaptchaType::CloudflareTurnstile,
SolveMethod::BehavioralBypass,
);
p.record(true, 2000, SolveMethod::BehavioralBypass);
p.record(false, 3000, SolveMethod::VisionLLM);
p.record(true, 1000, SolveMethod::BehavioralBypass);
assert_eq!(p.sample_count, 3);
assert_eq!(p.best_method, SolveMethod::BehavioralBypass);
assert!((p.success_rate - 1.0).abs() < 0.01);
assert_eq!(p.avg_solve_time, 1500);
assert_eq!(p.methods.len(), 2);
let beh = p
.methods
.iter()
.find(|(k, _)| k.as_str() == "behavioral_bypass")
.map(|(_, v)| v)
.expect("behavioral stat");
assert_eq!(beh.sample_count, 2);
assert!((beh.success_rate - 1.0).abs() < 0.01);
let vlm = p
.methods
.iter()
.find(|(k, _)| k.as_str() == "vision_llm")
.map(|(_, v)| v)
.expect("vision stat");
assert_eq!(vlm.sample_count, 1);
assert!(vlm.success_rate.abs() < 0.01);
}
#[test]
fn ema_recency_bias_lets_best_method_flip_within_ten_samples() {
let mut p = CaptchaPattern::new(
"shifty.test",
CaptchaType::CloudflareTurnstile,
SolveMethod::BehavioralBypass,
);
for _ in 0..50 {
p.record(true, 800, SolveMethod::BehavioralBypass);
}
assert_eq!(p.best_method, SolveMethod::BehavioralBypass);
for _ in 0..10 {
p.record(false, 1200, SolveMethod::BehavioralBypass);
p.record(true, 1500, SolveMethod::VisionLLM);
}
assert_eq!(
p.best_method,
SolveMethod::VisionLLM,
"EMA must let a freshly-winning method overtake within ~10 samples; \
behavioral_rate={}, vision_rate={}",
p.methods
.get("behavioral_bypass")
.map(|s| s.success_rate)
.unwrap_or(0.0),
p.methods
.get("vision_llm")
.map(|s| s.success_rate)
.unwrap_or(0.0),
);
}
#[test]
fn ema_bootstrap_phase_uses_cumulative_mean() {
let mut p = CaptchaPattern::new("x.test", CaptchaType::HCaptcha, SolveMethod::VisionLLM);
p.record(true, 1000, SolveMethod::VisionLLM);
p.record(true, 2000, SolveMethod::VisionLLM);
p.record(false, 3000, SolveMethod::VisionLLM);
let stat = p.methods.get("vision_llm").unwrap();
assert!((stat.success_rate - 2.0 / 3.0).abs() < 0.01);
assert_eq!(stat.avg_solve_time, 2000);
}
#[test]
fn pattern_store_record_and_lookup() {
let store = PatternStore::default();
store.record(
"example.com",
&CaptchaType::RecaptchaV2,
true,
1500,
SolveMethod::AudioBypass,
);
let method = store.best_method("example.com", &CaptchaType::RecaptchaV2);
assert_eq!(method, Some(SolveMethod::AudioBypass));
}
#[test]
fn pattern_store_unknown_returns_none() {
let store = PatternStore::default();
assert!(store
.best_method("unknown.com", &CaptchaType::Slider)
.is_none());
}
#[test]
fn save_then_load_round_trips_recorded_patterns() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("patterns.json");
let original = PatternStore::default();
original.record(
"alpha.test",
&CaptchaType::HCaptcha,
true,
1234,
SolveMethod::VisionLLM,
);
original.record(
"beta.test",
&CaptchaType::CloudflareTurnstile,
true,
500,
SolveMethod::BehavioralBypass,
);
original.save_to_path(&path).expect("save");
let loaded = PatternStore::load_from_path(&path).expect("load");
assert_eq!(
loaded.best_method("alpha.test", &CaptchaType::HCaptcha),
Some(SolveMethod::VisionLLM),
);
assert_eq!(
loaded.best_method("beta.test", &CaptchaType::CloudflareTurnstile),
Some(SolveMethod::BehavioralBypass),
);
assert_eq!(loaded.all_patterns().len(), 2);
}
#[test]
fn save_to_path_is_atomic_against_concurrent_reads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("patterns.json");
let store = PatternStore::default();
store.record(
"x.test",
&CaptchaType::HCaptcha,
true,
1000,
SolveMethod::VisionLLM,
);
store.save_to_path(&path).unwrap();
assert!(path.exists());
assert!(
!path.with_extension("tmp").exists(),
"temp file should be renamed away"
);
}
#[test]
fn load_from_path_errors_on_missing_file() {
let res = PatternStore::load_from_path("/definitely/no/such/file.json");
assert!(res.is_err());
}
#[test]
fn load_or_default_returns_empty_when_file_missing() {
let store = PatternStore::load_or_default("/definitely/no/such/file.json");
assert!(store.all_patterns().is_empty());
}
#[test]
fn merge_keeps_higher_sample_count_winner() {
let a = PatternStore::default();
a.record(
"shared.test",
&CaptchaType::HCaptcha,
true,
1000,
SolveMethod::AudioBypass,
);
let b = PatternStore::default();
for _ in 0..5 {
b.record(
"shared.test",
&CaptchaType::HCaptcha,
true,
500,
SolveMethod::VisionLLM,
);
}
a.merge(&b);
assert_eq!(
a.best_method("shared.test", &CaptchaType::HCaptcha),
Some(SolveMethod::VisionLLM),
);
}
#[test]
fn merge_keeps_existing_when_self_has_more_samples() {
let a = PatternStore::default();
for _ in 0..10 {
a.record(
"shared.test",
&CaptchaType::HCaptcha,
true,
500,
SolveMethod::VisionLLM,
);
}
let b = PatternStore::default();
b.record(
"shared.test",
&CaptchaType::HCaptcha,
true,
800,
SolveMethod::AudioBypass,
);
a.merge(&b);
assert_eq!(
a.best_method("shared.test", &CaptchaType::HCaptcha),
Some(SolveMethod::VisionLLM),
);
}
#[test]
fn merge_inserts_disjoint_keys() {
let a = PatternStore::default();
a.record(
"a.test",
&CaptchaType::HCaptcha,
true,
100,
SolveMethod::VisionLLM,
);
let b = PatternStore::default();
b.record(
"b.test",
&CaptchaType::HCaptcha,
true,
100,
SolveMethod::AudioBypass,
);
a.merge(&b);
assert_eq!(a.all_patterns().len(), 2);
}
#[test]
fn pattern_store_all_patterns() {
let store = PatternStore::default();
store.record(
"a.com",
&CaptchaType::HCaptcha,
true,
1000,
SolveMethod::VisionLLM,
);
store.record(
"b.com",
&CaptchaType::RecaptchaV2,
false,
2000,
SolveMethod::AudioBypass,
);
assert_eq!(store.all_patterns().len(), 2);
}
}