use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use serde_json::Value;
use super::FuzzyStore;
#[derive(Debug, Clone, Deserialize)]
pub struct Fixture {
pub devices: Vec<DeviceGroup>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeviceGroup {
#[serde(rename = "deviceId")]
pub device_id: String,
pub observations: Vec<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct EvalReport {
pub total_observations: usize,
pub total_devices: usize,
pub revisits: usize,
pub stable_links: usize,
pub collisions: usize,
pub minted_ids: usize,
}
impl EvalReport {
#[allow(clippy::cast_precision_loss)] pub fn stability_rate(&self) -> f64 {
if self.revisits == 0 {
return 1.0;
}
self.stable_links as f64 / self.revisits as f64
}
#[allow(clippy::cast_precision_loss)] pub fn collision_rate(&self) -> f64 {
if self.total_observations == 0 {
return 0.0;
}
self.collisions as f64 / self.total_observations as f64
}
}
impl Fixture {
pub fn from_json(json: &str) -> serde_json::Result<Self> {
serde_json::from_str(json)
}
pub fn synthetic() -> serde_json::Result<Self> {
Self::from_json(SYNTHETIC_FIXTURE)
}
}
const SYNTHETIC_FIXTURE: &str = include_str!("../../fixtures/eval/synthetic.json");
pub fn evaluate(fixture: &Fixture) -> EvalReport {
let store = FuzzyStore::new();
let mut owner: HashMap<String, String> = HashMap::new();
let mut seen_devices: HashSet<String> = HashSet::new();
let mut report = EvalReport {
total_observations: 0,
total_devices: fixture.devices.len(),
revisits: 0,
stable_links: 0,
collisions: 0,
minted_ids: 0,
};
let mut now_ms = 1_000u64;
for (device_id, components) in interleave(fixture) {
let outcome = store.identify(components, now_ms);
now_ms += 1_000;
report.total_observations += 1;
let is_revisit = !seen_devices.insert(device_id.clone());
if is_revisit {
report.revisits += 1;
}
match owner.get(&outcome.visitor_id) {
None => {
owner.insert(outcome.visitor_id.clone(), device_id.clone());
report.minted_ids += 1;
}
Some(owning_device) if owning_device == device_id => {
if is_revisit {
report.stable_links += 1;
}
}
Some(_) => {
report.collisions += 1;
}
}
}
report
}
fn interleave(fixture: &Fixture) -> Vec<(&String, &Value)> {
let max_visits = fixture
.devices
.iter()
.map(|d| d.observations.len())
.max()
.unwrap_or(0);
let mut order = Vec::new();
for round in 0..max_visits {
for device in &fixture.devices {
if let Some(obs) = device.observations.get(round) {
order.push((&device.device_id, obs));
}
}
}
order
}
#[cfg(test)]
mod tests {
use super::{Fixture, evaluate};
#[test]
fn synthetic_fixture_parses() {
let fixture = Fixture::synthetic().unwrap();
assert!(
fixture.devices.len() >= 3,
"need several distinct devices to measure collisions"
);
assert!(
fixture.devices.iter().any(|d| d.observations.len() >= 2),
"need at least one multi-visit device to measure stability"
);
}
#[test]
fn synthetic_eval_is_directionally_correct() {
let report = evaluate(&Fixture::synthetic().unwrap());
assert!(report.revisits > 0, "fixture must exercise revisits");
assert!(
report.stability_rate() >= 0.9,
"same-device revisits should re-link: {report:?}",
);
assert!(
report.collision_rate() <= 0.1,
"distinct devices should not merge: {report:?}",
);
assert_eq!(
report.minted_ids, report.total_devices,
"each device should own exactly one visitorId: {report:?}",
);
}
#[test]
fn no_revisits_yields_neutral_stability() {
let json = r#"{
"devices": [
{ "deviceId": "d1", "observations": [ { "webgl": "A", "platform": "Linux" } ] },
{ "deviceId": "d2", "observations": [ { "webgl": "B", "platform": "Windows" } ] }
]
}"#;
let report = evaluate(&Fixture::from_json(json).unwrap());
assert_eq!(report.revisits, 0);
assert!((report.stability_rate() - 1.0).abs() < f64::EPSILON);
assert_eq!(report.collisions, 0);
}
}