use serde_json::Value;
use sha2::{Digest, Sha256};
use super::{
FuzzyStore, classify,
component::{Hash32, Stored, jaccard},
record::FingerprintRecord,
velocity::{self, VelocityBand},
};
const T_HI: f64 = 12.0;
const T_LO: f64 = 8.0;
const TAU: f64 = 0.5;
const COLLISION_GAP: f64 = 2.0;
const SET_U: f64 = 0.05;
const U_FLOOR: f64 = 1e-4;
const U_CEIL: f64 = 0.9999;
const ALPHA: f64 = 20.0;
const M_FLOOR: f64 = 1e-4;
const M_CEIL: f64 = 0.9999;
const CONF_SCALE: f64 = 4.0;
const VELOCITY_PENALTY: f64 = 0.3;
const FULL_COMPONENTS: f64 = 8.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Match,
Review,
NewDevice,
}
impl Decision {
pub fn as_str(self) -> &'static str {
match self {
Decision::Match => "match",
Decision::Review => "review",
Decision::NewDevice => "new_device",
}
}
}
#[derive(Debug, Clone)]
pub struct MatchOutcome {
pub visitor_id: String,
pub is_new_device: bool,
pub confidence: f64,
pub decision: Decision,
pub score: Option<f64>,
pub compared_components: usize,
pub collision_risk: bool,
pub new_device_velocity: VelocityBand,
pub new_device_velocity_ja4: VelocityBand,
}
struct Scored {
score: f64,
compared: usize,
}
impl FuzzyStore {
pub fn identify(&self, components: &Value, now_ms: u64) -> MatchOutcome {
self.identify_with_signals(components, now_ms, None, None)
}
pub fn identify_with_ip(
&self,
components: &Value,
now_ms: u64,
client_ip: Option<&str>,
) -> MatchOutcome {
self.identify_with_signals(components, now_ms, client_ip, None)
}
pub fn identify_with_signals(
&self,
components: &Value,
now_ms: u64,
client_ip: Option<&str>,
ja4: Option<&str>,
) -> MatchOutcome {
let _guard = self
.identify_lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut outcome = self.evaluate(components);
match outcome.decision {
Decision::Match => {
self.record_agreement(&outcome.visitor_id, components);
self.observe(&outcome.visitor_id, components, now_ms);
}
Decision::NewDevice => {
self.observe(&outcome.visitor_id, components, now_ms);
if let Some(ip) = client_ip {
let now_secs = now_ms / 1000;
self.velocity.record(ip, now_secs);
let n = self.velocity.count(ip, now_secs, velocity::WINDOW);
outcome.new_device_velocity = VelocityBand::classify(n);
if outcome.new_device_velocity == VelocityBand::High {
outcome.confidence =
(outcome.confidence - VELOCITY_PENALTY).clamp(0.0, 1.0);
}
}
if let Some(class) = ja4.and_then(crate::signals::ja4_class) {
let now_secs = now_ms / 1000;
let key = format!("ja4:{class}");
self.velocity.record(&key, now_secs);
let n = self.velocity.count(&key, now_secs, velocity::WINDOW);
outcome.new_device_velocity_ja4 = VelocityBand::classify_ja4(n);
}
}
Decision::Review => {}
}
outcome
}
pub fn score(&self, components: &Value) -> MatchOutcome {
self.evaluate(components)
}
fn evaluate(&self, components: &Value) -> MatchOutcome {
let probe = self.stored_map(components);
let mut scored: Vec<(String, Scored)> = self
.candidates(components)
.into_iter()
.map(|id| {
let record = self.record(&id);
let s = record.map_or(
Scored {
score: f64::NEG_INFINITY,
compared: 0,
},
|r| self.score_candidate(&probe, &r),
);
(id, s)
})
.collect();
scored.sort_by(|a, b| b.1.score.total_cmp(&a.1.score));
let best = scored.first();
let second_score = scored.get(1).map(|(_, s)| s.score);
let best_score = best.map(|(_, s)| s.score);
let compared = best.map_or(0, |(_, s)| s.compared);
let decision = match best_score {
Some(s) if s >= T_HI => Decision::Match,
Some(s) if s >= T_LO => Decision::Review,
_ => Decision::NewDevice,
};
let confidence = confidence(decision, best_score, second_score, compared);
match (decision, best) {
(Decision::Match, Some((id, top))) => {
let collision_risk =
second_score.is_some_and(|s2| s2 >= T_HI && (top.score - s2) < COLLISION_GAP);
MatchOutcome {
visitor_id: id.clone(),
is_new_device: false,
confidence,
decision,
score: best_score,
compared_components: compared,
collision_risk,
new_device_velocity: VelocityBand::Low,
new_device_velocity_ja4: VelocityBand::Low,
}
}
(Decision::Review, Some((id, _))) => MatchOutcome {
visitor_id: id.clone(),
is_new_device: false,
confidence,
decision,
score: best_score,
compared_components: compared,
collision_risk: false,
new_device_velocity: VelocityBand::Low,
new_device_velocity_ja4: VelocityBand::Low,
},
_ => {
let id = derive_visitor_id(components);
MatchOutcome {
visitor_id: id,
is_new_device: true,
confidence,
decision: Decision::NewDevice,
score: best_score,
compared_components: compared,
collision_risk: false,
new_device_velocity: VelocityBand::Low,
new_device_velocity_ja4: VelocityBand::Low,
}
}
}
}
fn record_agreement(&self, visitor_id: &str, components: &Value) {
let Some(template) = self.record(visitor_id) else {
return;
};
let probe = self.stored_map(components);
for (name, probe_value) in &probe {
let Some(template_value) = template.components.get(name) else {
continue; };
let Some((sim, _u)) = self.compare(probe_value, template_value) else {
continue; };
self.agreement.record(name, sim >= TAU);
}
}
fn score_candidate(&self, probe: &ProbeMap, template: &FingerprintRecord) -> Scored {
let mut score = 0.0;
let mut compared = 0;
for (name, probe_value) in probe {
let Some(template_value) = template.components.get(name) else {
continue; };
let Some((sim, u)) = self.compare(probe_value, template_value) else {
continue; };
let m = self.m_estimate(name, classify(name).stability.m_prior());
score += agreement_weight(m, u, sim);
compared += 1;
}
Scored { score, compared }
}
fn compare(&self, probe: &Stored, template: &Stored) -> Option<(f64, f64)> {
match (probe, template) {
(Stored::Category(a), Stored::Category(b)) => {
let sim = f64::from(u8::from(a == b));
Some((sim, self.u_estimate(*a)))
}
(Stored::Numeric(a), Stored::Numeric(b)) => {
let sim = f64::from(u8::from(a == b));
Some((sim, self.u_estimate(self.salt.hash(&a.to_string()))))
}
(Stored::Set(a), Stored::Set(b)) => {
let j = jaccard(a, b);
let sim = if j >= TAU { 1.0 } else { j };
Some((sim, SET_U))
}
_ => None,
}
}
#[allow(clippy::cast_precision_loss)] fn u_estimate(&self, value: Hash32) -> f64 {
let hits = self.frequency.count(value) as f64;
let total = self.frequency.total() as f64;
((hits + 0.5) / (total + 1.0)).clamp(U_FLOOR, U_CEIL)
}
#[allow(clippy::cast_precision_loss)] fn m_estimate(&self, name: &str, prior: f64) -> f64 {
let (agree, total) = self.agreement.stats(name);
if total == 0 {
return prior; }
let est = (agree as f64 + ALPHA * prior) / (total as f64 + ALPHA);
est.clamp(M_FLOOR, M_CEIL)
}
}
type ProbeMap = std::collections::BTreeMap<String, Stored>;
fn agreement_weight(m: f64, u: f64, sim: f64) -> f64 {
let agree = (m / u).log2();
let disagree = ((1.0 - m) / (1.0 - u)).log2();
sim * agree + (1.0 - sim) * disagree
}
#[allow(clippy::cast_precision_loss)] fn confidence(
decision: Decision,
best_score: Option<f64>,
second_score: Option<f64>,
compared: usize,
) -> f64 {
let completeness = (compared as f64 / FULL_COMPONENTS).min(1.0);
let completeness_scale = 0.5 + 0.5 * completeness;
let base = match decision {
Decision::Match => {
let s = best_score.unwrap_or(T_HI);
let margin = logistic((s - T_HI) / CONF_SCALE);
let gap = second_score.map_or(1.0, |s2| logistic((s - s2) / CONF_SCALE));
0.5 + 0.5 * margin.min(gap)
}
Decision::Review => {
let s = best_score.unwrap_or(T_LO);
let position = ((s - T_LO) / (T_HI - T_LO)).clamp(0.0, 1.0);
0.25 + 0.25 * position
}
Decision::NewDevice => {
let closeness = best_score.map_or(0.0, |s| logistic((s - T_LO) / CONF_SCALE));
0.5 + 0.5 * (1.0 - closeness)
}
};
(base * completeness_scale).clamp(0.0, 1.0)
}
fn logistic(x: f64) -> f64 {
1.0 / (1.0 + (-x).exp())
}
fn derive_visitor_id(components: &Value) -> String {
let canonical = serde_json::to_vec(components).unwrap_or_default();
hex::encode(Sha256::digest(&canonical))
}
#[cfg(test)]
mod tests {
use super::{Decision, T_LO};
use crate::fuzzy::FuzzyStore;
use serde_json::{Value, json};
fn full_probe() -> Value {
json!({
"webgl": "ANGLE (Intel)",
"platform": "Linux x86_64",
"timezone": "Asia/Shanghai",
"audio": "124.04",
"cpu_cores": 8,
"device_memory": 8,
"fonts": ["Arial", "Helvetica", "Courier", "Times", "Verdana"],
"user_agent": "Chrome/120",
})
}
#[test]
fn b_shared_block_but_distinct_device_is_not_merged() {
let store = FuzzyStore::new();
let first = store.identify(&full_probe(), 1_000);
let other = json!({
"webgl": "Mali-G78",
"platform": "Linux armv8",
"timezone": "America/New_York",
"audio": "124.04",
"cpu_cores": 8,
"device_memory": 8,
"fonts": ["Roboto", "Noto", "Droid", "Ubuntu"],
"user_agent": "Firefox/119",
});
assert!(store.candidates(&other).contains(&first.visitor_id));
let second = store.identify(&other, 2_000);
assert_eq!(second.decision, Decision::NewDevice);
assert!(second.is_new_device);
assert_ne!(second.visitor_id, first.visitor_id);
}
#[test]
fn c_low_entropy_only_overlap_does_not_merge() {
let store = FuzzyStore::new();
let low = json!({
"platform": "Linux x86_64",
"timezone": "Asia/Shanghai",
"languages": "en-US",
});
for i in 0..30 {
store.observe(&format!("bg{i}"), &low, 1_000);
}
assert!(!store.candidates(&low).is_empty());
let out = store.identify(&low, 2_000);
assert_eq!(out.decision, Decision::NewDevice);
assert!(out.is_new_device);
assert!(out.score.unwrap() < T_LO, "score was {:?}", out.score);
}
#[test]
fn review_band_hit_does_not_drift_the_template() {
let store = FuzzyStore::new();
let seed = store.identify(&full_probe(), 1_000);
let id = seed.visitor_id.clone();
let before = store.record(&id).unwrap();
assert_eq!(before.last_seen, 1_000);
let review_probe = json!({
"webgl": "ANGLE (Intel)",
"platform": "Linux x86_64",
"timezone": "Asia/Shanghai",
"cpu_cores": 8,
});
let review = store.identify(&review_probe, 5_000);
assert_eq!(review.decision, Decision::Review);
assert_eq!(review.visitor_id, id);
let after = store.record(&id).unwrap();
assert_eq!(
after.last_seen, before.last_seen,
"review must not drift last_seen"
);
assert_eq!(after.observation_count, before.observation_count);
assert_eq!(after.components, before.components);
let confirm = store.identify(&full_probe(), 9_000);
assert_eq!(confirm.decision, Decision::Match);
assert_eq!(store.record(&id).unwrap().last_seen, 9_000);
}
#[test]
fn concurrent_identify_stays_consistent() {
use std::{
collections::HashSet,
sync::{Arc, Mutex},
thread,
};
const THREADS: usize = 8;
const ITERS: u64 = 40;
let devices: Arc<Vec<Value>> = Arc::new(
(0..8)
.map(|i| {
json!({
"webgl": format!("ANGLE (Vendor {i})"),
"platform": format!("Platform-{i}"),
"timezone": format!("Zone/{i}"),
"audio": format!("12{i}.5"),
"cpu_cores": 4 + i,
"device_memory": 4 + i,
"fonts": [
format!("F{i}a"), format!("F{i}b"), format!("F{i}c"),
format!("F{i}d"), format!("F{i}e"),
],
"user_agent": format!("Agent/{i}"),
})
})
.collect(),
);
let store = Arc::new(FuzzyStore::new());
let seen: Arc<Mutex<HashSet<(usize, String)>>> = Arc::new(Mutex::new(HashSet::new()));
let handles: Vec<_> = (0..THREADS)
.map(|t| {
let store = Arc::clone(&store);
let devices = Arc::clone(&devices);
let seen = Arc::clone(&seen);
thread::spawn(move || {
let mut local = HashSet::new();
for i in 0..ITERS {
for (d, probe) in devices.iter().enumerate() {
let now = 1_000 + (t as u64) * 1_000_000 + i * 1_000 + d as u64;
let out = store.identify(probe, now);
assert!(
matches!(out.decision, Decision::Match | Decision::NewDevice),
"unexpected {:?} for device {d}",
out.decision,
);
local.insert((d, out.visitor_id));
}
}
seen.lock().unwrap().extend(local);
})
})
.collect();
for h in handles {
h.join().expect("identify thread panicked");
}
let seen = Arc::into_inner(seen).unwrap().into_inner().unwrap();
assert_eq!(seen.len(), devices.len());
for (d, id) in &seen {
let record = store.record(id).expect("device record present");
assert_eq!(
record.observation_count,
THREADS as u64 * ITERS,
"device {d} observation_count",
);
}
for (d, probe) in devices.iter().enumerate() {
let out = store.identify(probe, 9_000_000);
assert_eq!(out.decision, Decision::Match, "re-identify device {d}");
assert!(seen.contains(&(d, out.visitor_id)));
}
}
#[test]
fn cold_start_matches_on_priors_then_records_agreement() {
let store = FuzzyStore::new();
assert_eq!(store.agreement.stats("webgl"), (0, 0));
let first = store.identify(&full_probe(), 1_000);
assert!(first.is_new_device); assert_eq!(store.agreement.stats("webgl"), (0, 0));
let second = store.identify(&full_probe(), 2_000);
assert_eq!(second.decision, Decision::Match);
assert_eq!(second.visitor_id, first.visitor_id);
assert_eq!(store.agreement.stats("webgl"), (1, 1));
}
#[test]
fn repeated_same_device_matches_lift_m_estimate() {
use crate::fuzzy::classify;
let store = FuzzyStore::new();
let prior = classify("webgl").stability.m_prior();
store.identify(&full_probe(), 1_000);
for i in 0..10 {
let out = store.identify(&full_probe(), 2_000 + i);
assert_eq!(out.decision, Decision::Match);
}
assert_eq!(store.agreement.stats("webgl"), (10, 10));
let est = store.m_estimate("webgl", prior);
assert!(est > prior, "m_estimate {est} should exceed prior {prior}");
assert!(est < super::M_CEIL);
assert!((store.m_estimate("plugins", 0.8) - 0.8).abs() < f64::EPSILON);
}
#[test]
fn m_estimate_climbs_monotonically_across_same_device_revisits() {
use crate::fuzzy::classify;
let store = FuzzyStore::new();
let prior = classify("webgl").stability.m_prior();
store.identify(&full_probe(), 1_000);
let mut prev = store.m_estimate("webgl", prior);
assert!(
(prev - prior).abs() < f64::EPSILON,
"cold start returns the prior exactly"
);
for i in 0..12 {
let out = store.identify(&full_probe(), 2_000 + i);
assert_eq!(out.decision, Decision::Match);
let est = store.m_estimate("webgl", prior);
assert!(
est > prev,
"estimate must rise each revisit: {prev} -> {est}"
);
assert!(
est <= super::M_CEIL,
"estimate stays within the ceiling clamp"
);
assert!(est < 1.0);
prev = est;
}
assert!(
prev > prior,
"after revisits the estimate sits above the cold prior {prior}"
);
}
#[test]
fn score_stays_finite_when_m_is_driven_to_the_clamp() {
let store = FuzzyStore::new();
store.identify(&full_probe(), 1_000);
for name in [
"webgl",
"platform",
"timezone",
"audio",
"cpu_cores",
"device_memory",
"fonts",
"user_agent",
] {
for _ in 0..20_000 {
store.agreement.record(name, true);
}
assert!(
(store.m_estimate(name, 0.95) - super::M_CEIL).abs() < 1e-9,
"{name} m_estimate should clamp to M_CEIL"
);
}
assert!(super::agreement_weight(super::M_CEIL, super::U_FLOOR, 1.0).is_finite());
assert!(super::agreement_weight(super::M_CEIL, super::U_CEIL, 0.0).is_finite());
let out = store.identify(&full_probe(), 2_000);
assert_eq!(out.decision, Decision::Match);
let matched_score = out.score.expect("a matched candidate has a score");
assert!(matched_score.is_finite(), "score was {matched_score}");
}
#[test]
fn churning_component_m_estimate_falls_below_its_prior() {
use crate::fuzzy::classify;
let store = FuzzyStore::new();
let ua_prior = classify("user_agent").stability.m_prior();
store.identify(&full_probe(), 1_000);
for i in 0..12 {
let mut probe = full_probe();
probe["user_agent"] = json!(format!("Chrome/{}", 200 + i));
let out = store.identify(&probe, 2_000 + i);
assert_eq!(
out.decision,
Decision::Match,
"stable components must carry the match while UA churns"
);
}
let (agree, total) = store.agreement.stats("user_agent");
assert_eq!(agree, 0, "user_agent never agreed");
assert!(
total >= 12,
"every revisit recorded a same-device comparison"
);
let est = store.m_estimate("user_agent", ua_prior);
assert!(
est < ua_prior,
"churning estimate {est} should fall below prior {ua_prior}"
);
}
fn distinct_probe(i: u64) -> Value {
json!({
"webgl": format!("ANGLE (Vendor {i})"),
"platform": format!("Platform-{i}"),
"timezone": format!("Zone/{i}"),
"audio": format!("12{i}.5"),
"cpu_cores": 4 + i,
"device_memory": 4 + i,
"fonts": [
format!("F{i}a"), format!("F{i}b"), format!("F{i}c"),
format!("F{i}d"), format!("F{i}e"),
],
"user_agent": format!("Agent/{i}"),
})
}
#[test]
fn burst_of_new_devices_from_one_ip_crosses_to_high_and_downgrades() {
use super::velocity::HIGH;
let store = FuzzyStore::new();
let ip = "203.0.113.9";
let mut last = None;
for i in 0..HIGH {
let probe = distinct_probe(i);
let out = store.identify_with_ip(&probe, 1_000_000 + i * 1_000, Some(ip));
assert_eq!(out.decision, Decision::NewDevice, "device {i}");
assert!(out.is_new_device);
last = Some(out);
}
let last = last.unwrap();
assert_eq!(last.new_device_velocity, super::VelocityBand::High);
assert!(
(last.confidence - (0.5 - super::VELOCITY_PENALTY)).abs() < 1e-9,
"confidence was {}",
last.confidence,
);
}
#[test]
fn new_devices_spread_beyond_the_window_stay_low() {
use super::velocity::{HIGH, WINDOW};
let store = FuzzyStore::new();
let ip = "203.0.113.10";
let step_ms = (WINDOW + 10) * 1_000;
let mut last = None;
for i in 0..HIGH {
let probe = distinct_probe(i);
let out = store.identify_with_ip(&probe, 1_000_000 + i * step_ms, Some(ip));
assert_eq!(out.decision, Decision::NewDevice, "device {i}");
last = Some(out);
}
let last = last.unwrap();
assert_eq!(last.new_device_velocity, super::VelocityBand::Low);
assert!(
(last.confidence - 0.5).abs() < 1e-9,
"no downgrade expected"
);
}
const BROWSER_JA4: &str = "t13d1516h2_8daaf6152771_02713d6af862";
#[test]
fn burst_sharing_one_ja4_class_rises_but_band_only_never_downgrades() {
use super::velocity::{JA4_HIGH, JA4_MEDIUM};
let store = FuzzyStore::new();
let mut at_medium = None;
let mut last = None;
for i in 0..JA4_HIGH {
let probe = distinct_probe(i);
let out =
store.identify_with_signals(&probe, 1_000_000 + i * 1_000, None, Some(BROWSER_JA4));
assert_eq!(out.decision, Decision::NewDevice, "device {i}");
assert_eq!(out.new_device_velocity, super::VelocityBand::Low);
if i + 1 == JA4_MEDIUM {
at_medium = Some(out.clone());
}
last = Some(out);
}
assert_eq!(
at_medium.unwrap().new_device_velocity_ja4,
super::VelocityBand::Medium
);
let last = last.unwrap();
assert_eq!(last.new_device_velocity_ja4, super::VelocityBand::High);
assert!(
(last.confidence - 0.5).abs() < 1e-9,
"lone High JA4 band must not downgrade confidence; was {}",
last.confidence,
);
}
#[test]
fn absent_or_malformed_ja4_keeps_the_class_band_low_and_neutral() {
let store = FuzzyStore::new();
let out =
store.identify_with_signals(&distinct_probe(1), 1_000_000, Some("198.51.100.7"), None);
assert_eq!(out.new_device_velocity_ja4, super::VelocityBand::Low);
let out2 =
store.identify_with_signals(&distinct_probe(2), 1_000_100, None, Some("garbage"));
assert_eq!(out2.new_device_velocity_ja4, super::VelocityBand::Low);
assert!((out2.confidence - 0.5).abs() < 1e-9);
}
}