1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! Persistent solver-decision LRU keyed by (vendor, fingerprint).
//!
//! Beyond `PatternStore` (per-domain): captchaforge increasingly
//! runs across many stealth profiles (JA3/JA4/Akamai-H2/UA). The
//! winning solver for a given vendor often depends on the
//! STACK fingerprint, not just the domain. This LRU lets the chain
//! short-circuit "for hCaptcha-on-Chrome131WinPQ, BehavioralBypass
//! won 12 of 14 attempts" → start there next time.
//!
//! Design contract:
//! - Bounded memory: `cap` entries; LRU eviction.
//! - Persisted to a single JSON file on `flush()`. Loaded eagerly
//! on `open()`.
//! - Atomic write via tmp-file + rename so a crash mid-flush
//! cannot corrupt the persisted state.
//! - Each entry carries a confidence score derived from sample
//! count + smoothed win rate. Below `min_confidence`, the chain
//! ignores the LRU and falls back to its normal ordering.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::RwLock;
use std::time::{SystemTime, UNIX_EPOCH};
/// One LRU entry, a (vendor, fingerprint-hash) → preferred solver
/// outcome.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FingerprintEntry {
pub vendor: String,
/// Stable hash of the stealth profile / TLS fingerprint
/// (e.g. SHA-256 of `<JA4>|<UA>|<lang>`). Keep opaque, only
/// equality matters.
pub fingerprint: String,
/// Solver name that historically wins for this pair.
pub best_solver: String,
/// Win count.
pub wins: u32,
/// Total attempt count.
pub attempts: u32,
/// Last-access unix-epoch seconds. Drives LRU eviction.
pub last_seen_unix: i64,
}
impl FingerprintEntry {
/// Smoothed win rate. Returns 0.0 for zero-attempt entries.
pub fn win_rate(&self) -> f32 {
if self.attempts == 0 {
0.0
} else {
self.wins as f32 / self.attempts as f32
}
}
/// Confidence: 0..1, monotone in attempts (more samples → more
/// confident) and in win_rate. 5 attempts is the floor for any
/// meaningful confidence.
pub fn confidence(&self) -> f32 {
if self.attempts < 5 {
return 0.0;
}
// Beta-distribution-style estimator: posterior mean shrinks
// win_rate toward 0.5 with a (2, 2) prior. Stable for low N.
let alpha = (self.wins as f32) + 2.0;
let beta = (self.attempts as f32 - self.wins as f32) + 2.0;
alpha / (alpha + beta)
}
}
/// LRU-bounded persistent store of (vendor, fingerprint) → best_solver.
pub struct FingerprintLru {
inner: RwLock<HashMap<String, FingerprintEntry>>,
path: Option<PathBuf>,
cap: usize,
min_confidence: f32,
}
impl FingerprintLru {
fn key(vendor: &str, fingerprint: &str) -> String {
format!("{vendor}|{fingerprint}")
}
/// New in-memory LRU with the given capacity.
pub fn new(cap: usize) -> Self {
Self {
inner: RwLock::new(HashMap::new()),
path: None,
cap: cap.max(1),
min_confidence: 0.6,
}
}
/// Open the LRU at the given path. Loads existing state on
/// success; missing file is NOT an error (returns an empty
/// LRU bound to that path so the next `flush()` creates it).
pub fn open(path: impl AsRef<Path>, cap: usize) -> anyhow::Result<Self> {
let path = path.as_ref().to_path_buf();
let inner = if path.is_file() {
let body = std::fs::read_to_string(&path)?;
let entries: Vec<FingerprintEntry> = serde_json::from_str(&body).unwrap_or_default();
let mut map = HashMap::new();
for e in entries {
let k = Self::key(&e.vendor, &e.fingerprint);
map.insert(k, e);
}
map
} else {
HashMap::new()
};
Ok(Self {
inner: RwLock::new(inner),
path: Some(path),
cap: cap.max(1),
min_confidence: 0.6,
})
}
/// Override the minimum confidence threshold. Below this, the
/// chain treats the LRU entry as "no signal".
pub fn with_min_confidence(mut self, threshold: f32) -> Self {
self.min_confidence = threshold.clamp(0.0, 1.0);
self
}
/// Record an attempt outcome.
pub fn record(&self, vendor: &str, fingerprint: &str, solver: &str, success: bool) {
let k = Self::key(vendor, fingerprint);
let mut map = self.inner.write().unwrap_or_else(|e| e.into_inner());
let now = now_unix();
let entry = map.entry(k).or_insert_with(|| FingerprintEntry {
vendor: vendor.into(),
fingerprint: fingerprint.into(),
best_solver: solver.into(),
wins: 0,
attempts: 0,
last_seen_unix: now,
});
entry.attempts = entry.attempts.saturating_add(1);
if success {
entry.wins = entry.wins.saturating_add(1);
// Sticky: only replace `best_solver` when this solver
// has 60%+ of attempts (otherwise a single one-off win
// shouldn't kick out a higher-evidence incumbent).
if entry.best_solver != solver
&& success
&& entry.attempts >= 3
&& entry.wins as f32 / entry.attempts as f32 >= 0.6
{
entry.best_solver = solver.into();
}
}
entry.last_seen_unix = now;
self.maybe_evict(&mut map);
}
fn maybe_evict(&self, map: &mut HashMap<String, FingerprintEntry>) {
if map.len() <= self.cap {
return;
}
// Evict the oldest `last_seen_unix`. O(n) scan; n bounded by cap.
let to_drop: Vec<String> = {
let mut entries: Vec<(&String, &FingerprintEntry)> = map.iter().collect();
entries.sort_by_key(|(_, e)| e.last_seen_unix);
entries
.iter()
.take(map.len() - self.cap)
.map(|(k, _)| (*k).clone())
.collect()
};
for k in to_drop {
map.remove(&k);
}
}
/// Look up the best solver for a (vendor, fingerprint) pair if
/// the LRU has high-confidence evidence. Returns `None` when the
/// entry is missing or below `min_confidence`.
pub fn best_solver(&self, vendor: &str, fingerprint: &str) -> Option<String> {
let map = self.inner.read().unwrap_or_else(|e| e.into_inner());
let entry = map.get(&Self::key(vendor, fingerprint))?;
if entry.confidence() >= self.min_confidence {
Some(entry.best_solver.clone())
} else {
None
}
}
/// Snapshot the current entries (for diagnostics, /metrics, …).
pub fn entries(&self) -> Vec<FingerprintEntry> {
self.inner
.read()
.unwrap_or_else(|e| e.into_inner())
.values()
.cloned()
.collect()
}
/// Atomically persist to disk. Best-effort: a flush failure logs
/// at debug and does NOT propagate (better to lose a sample
/// than to abort a solve over a transient disk error).
pub fn flush(&self) -> anyhow::Result<()> {
let Some(path) = self.path.as_ref() else {
return Ok(());
};
let entries = self.entries();
let json = serde_json::to_vec_pretty(&entries)?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
}
impl Default for FingerprintLru {
fn default() -> Self {
Self::new(1024)
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_then_lookup_yields_best_solver_once_confident() {
let lru = FingerprintLru::new(16).with_min_confidence(0.6);
for _ in 0..10 {
lru.record("hcaptcha", "fp_chrome131_win", "BehavioralBypass", true);
}
let pick = lru.best_solver("hcaptcha", "fp_chrome131_win");
assert_eq!(pick.as_deref(), Some("BehavioralBypass"));
}
#[test]
fn low_evidence_returns_none() {
let lru = FingerprintLru::new(16);
for _ in 0..2 {
lru.record("turnstile", "fp_x", "VlmCaptchaSolver", true);
}
// Only 2 attempts (below the 5-attempt floor).
assert!(lru.best_solver("turnstile", "fp_x").is_none());
}
#[test]
fn lru_evicts_oldest_when_over_cap() {
let lru = FingerprintLru::new(2);
lru.record("v1", "fp_a", "S", true);
std::thread::sleep(std::time::Duration::from_millis(1_100));
lru.record("v2", "fp_b", "S", true);
std::thread::sleep(std::time::Duration::from_millis(1_100));
lru.record("v3", "fp_c", "S", true);
let entries = lru.entries();
assert_eq!(entries.len(), 2);
let vendors: std::collections::HashSet<_> =
entries.iter().map(|e| e.vendor.as_str()).collect();
assert!(
!vendors.contains("v1"),
"v1 should have been evicted as oldest"
);
}
#[test]
fn round_trip_through_disk() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("fp_lru.json");
{
let lru = FingerprintLru::open(&path, 16).unwrap();
for _ in 0..8 {
lru.record("recaptcha-v2", "fp_safari", "TurnstileInteractive", true);
}
lru.flush().unwrap();
}
let lru2 = FingerprintLru::open(&path, 16).unwrap();
assert_eq!(
lru2.best_solver("recaptcha-v2", "fp_safari").as_deref(),
Some("TurnstileInteractive")
);
}
#[test]
fn win_rate_and_confidence_monotone() {
let mut e = FingerprintEntry {
vendor: "v".into(),
fingerprint: "f".into(),
best_solver: "s".into(),
wins: 0,
attempts: 0,
last_seen_unix: 0,
};
// Zero attempts: confidence 0.
assert_eq!(e.confidence(), 0.0);
e.attempts = 4;
e.wins = 4;
assert_eq!(e.confidence(), 0.0, "below 5-attempt floor");
e.attempts = 100;
e.wins = 100;
let c_high = e.confidence();
e.wins = 50;
let c_mid = e.confidence();
assert!(c_high > c_mid);
}
}