use crate::check::discrepancy::{
ConsistencySummary, DeviceDiscrepancies, Discrepancy, DiscrepancyKind, FileDiscrepancies,
Severity,
};
use crate::check::preprocess::ProofCheckFile;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Consistency {
pub verified: bool,
pub file_discrepancies: Vec<FileDiscrepancies>,
pub device_discrepancies: Vec<DeviceDiscrepancies>,
pub summary: ConsistencySummary,
}
pub fn check_consistency(proof_files: &[ProofCheckFile]) -> Consistency {
#[allow(unused_mut)]
let mut file_discrepancies: Vec<FileDiscrepancies> = proof_files
.iter()
.filter_map(|file| {
let json = file.json.as_ref()?;
let mut flags = Vec::new();
check_missing_field(
json.location.latitude.as_ref(),
"location.latitude",
&mut flags,
);
check_missing_field(
json.location.longitude.as_ref(),
"location.longitude",
&mut flags,
);
check_missing_field(json.location.time.as_ref(), "location.time", &mut flags);
check_missing_field(
json.location.accuracy.as_ref(),
"location.accuracy",
&mut flags,
);
check_missing_field(
json.location.altitude.as_ref(),
"location.altitude",
&mut flags,
);
check_missing_field(
json.location.bearing.as_ref(),
"location.bearing",
&mut flags,
);
check_missing_field(json.location.speed.as_ref(), "location.speed", &mut flags);
check_missing_field(
json.location.provider.as_ref(),
"location.provider",
&mut flags,
);
check_missing_field(
json.device.manufacturer.as_ref(),
"device.manufacturer",
&mut flags,
);
check_missing_field(json.device.model.as_ref(), "device.model", &mut flags);
check_missing_field(
json.device.screen_size.as_ref(),
"device.screenSize",
&mut flags,
);
check_missing_field(json.device.language.as_ref(), "device.language", &mut flags);
check_missing_field(json.device.locale.as_ref(), "device.locale", &mut flags);
check_missing_field(json.network.ipv4.as_ref(), "network.ipv4", &mut flags);
check_missing_field(json.network.ipv6.as_ref(), "network.ipv6", &mut flags);
check_missing_field(
json.network.network_name.as_ref(),
"network.networkName",
&mut flags,
);
check_missing_field(
json.network.network_type.as_ref(),
"network.networkType",
&mut flags,
);
check_missing_field(
json.network.cell_info.as_ref(),
"network.cellInfo",
&mut flags,
);
if let Some(exif) = &file.integrity.exif {
if let Some(pm_manufacturer) = &json.device.manufacturer {
if let Some(exif_make) = exif.get("Make") {
let exif_val = exif_make.as_str().unwrap_or("");
if !values_match_loose(exif_val, pm_manufacturer) {
flags.push(Discrepancy {
field: "device.manufacturer".to_string(),
severity: Severity::Error,
kind: DiscrepancyKind::Mismatch,
message: format!(
"EXIF Make '{}' differs from ProofMode manufacturer '{}'",
exif_val, pm_manufacturer
),
});
}
}
}
if let Some(pm_model) = &json.device.model {
if let Some(exif_model) = exif.get("Model") {
let exif_val = exif_model.as_str().unwrap_or("");
if !values_match_loose(exif_val, pm_model) {
flags.push(Discrepancy {
field: "device.model".to_string(),
severity: Severity::Error,
kind: DiscrepancyKind::Mismatch,
message: format!(
"EXIF Model '{}' differs from ProofMode model '{}'",
exif_val, pm_model
),
});
}
}
}
compare_exif_timestamp(exif, json, &mut flags);
compare_exif_gps(exif, json, &mut flags);
}
let flag_count = flags.len();
Some(FileDiscrepancies {
file_name: file.name.clone(),
flags,
flag_count,
})
})
.collect();
#[allow(unused_mut)]
let mut device_discrepancies = check_device_consistency(proof_files);
#[cfg(feature = "polars")]
{
use crate::check::analytics;
let outlier_map = analytics::detect_location_outliers(proof_files);
for fd in &mut file_discrepancies {
if let Some(outliers) = outlier_map.get(&fd.file_name) {
fd.flags.extend(outliers.clone());
fd.flag_count = fd.flags.len();
}
}
let network_map = analytics::detect_network_anomalies(proof_files);
for (device_id, flags) in network_map {
if let Some(existing) = device_discrepancies
.iter_mut()
.find(|d| d.device_id == device_id)
{
existing.flags.extend(flags);
} else {
let file_count = proof_files
.iter()
.filter(|f| {
f.json
.as_ref()
.and_then(|j| j.device.id.as_deref())
.unwrap_or("unknown")
== device_id
})
.count();
device_discrepancies.push(DeviceDiscrepancies {
device_id,
file_count,
flags,
});
}
}
}
let total_files = file_discrepancies.len();
let files_with_flags = file_discrepancies
.iter()
.filter(|f| f.flag_count > 0)
.count();
let total_flags: usize = file_discrepancies
.iter()
.map(|f| f.flag_count)
.sum::<usize>()
+ device_discrepancies
.iter()
.map(|d| d.flags.len())
.sum::<usize>();
let verified = total_flags == 0;
Consistency {
verified,
file_discrepancies,
device_discrepancies,
summary: ConsistencySummary {
total_files,
files_with_flags,
total_flags,
},
}
}
fn check_missing_field<T>(value: Option<&T>, field_name: &str, flags: &mut Vec<Discrepancy>) {
if value.is_none() {
flags.push(Discrepancy {
field: field_name.to_string(),
severity: Severity::Warning,
kind: DiscrepancyKind::MissingData,
message: format!("{} is missing", field_name),
});
}
}
fn values_match_loose(a: &str, b: &str) -> bool {
let a_lower = a.trim().to_lowercase();
let b_lower = b.trim().to_lowercase();
a_lower == b_lower || a_lower.contains(&b_lower) || b_lower.contains(&a_lower)
}
fn compare_exif_timestamp(
exif: &serde_json::Map<String, serde_json::Value>,
json: &crate::check::translate::ProofModeJSON,
flags: &mut Vec<Discrepancy>,
) {
let exif_datetime = exif
.get("DateTimeOriginal")
.or_else(|| exif.get("DateTime"))
.and_then(|v| v.as_str());
let pm_datetime = json
.file
.created_at
.as_deref()
.or(json.file.modified_at.as_deref());
if let (Some(exif_dt), Some(pm_dt)) = (exif_datetime, pm_datetime) {
let exif_clean = exif_dt.replace([':', '-', 'T', ' '], "");
let pm_clean = pm_dt.replace([':', '-', 'T', ' '], "");
let exif_date = &exif_clean[..exif_clean.len().min(8)];
let pm_date = &pm_clean[..pm_clean.len().min(8)];
if exif_date != pm_date {
flags.push(Discrepancy {
field: "file.createdAt".to_string(),
severity: Severity::Error,
kind: DiscrepancyKind::Mismatch,
message: format!(
"EXIF timestamp '{}' differs from ProofMode timestamp '{}'",
exif_dt, pm_dt
),
});
}
}
}
fn compare_exif_gps(
exif: &serde_json::Map<String, serde_json::Value>,
json: &crate::check::translate::ProofModeJSON,
flags: &mut Vec<Discrepancy>,
) {
const GPS_TOLERANCE: f64 = 0.001;
let exif_lat = exif
.get("GPSLatitude")
.and_then(|v| v.as_str())
.and_then(parse_exif_gps_coord);
let exif_lon = exif
.get("GPSLongitude")
.and_then(|v| v.as_str())
.and_then(parse_exif_gps_coord);
let exif_lat = exif_lat.map(|lat| {
let lat_ref = exif
.get("GPSLatitudeRef")
.and_then(|v| v.as_str())
.unwrap_or("N");
if lat_ref.starts_with('S') {
-lat
} else {
lat
}
});
let exif_lon = exif_lon.map(|lon| {
let lon_ref = exif
.get("GPSLongitudeRef")
.and_then(|v| v.as_str())
.unwrap_or("E");
if lon_ref.starts_with('W') {
-lon
} else {
lon
}
});
if let (Some(exif_lat), Some(pm_lat)) = (exif_lat, json.location.latitude) {
if (exif_lat - pm_lat).abs() > GPS_TOLERANCE {
flags.push(Discrepancy {
field: "location.latitude".to_string(),
severity: Severity::Error,
kind: DiscrepancyKind::Mismatch,
message: format!(
"EXIF latitude {} differs from ProofMode latitude {} (tolerance: {})",
exif_lat, pm_lat, GPS_TOLERANCE
),
});
}
}
if let (Some(exif_lon), Some(pm_lon)) = (exif_lon, json.location.longitude) {
if (exif_lon - pm_lon).abs() > GPS_TOLERANCE {
flags.push(Discrepancy {
field: "location.longitude".to_string(),
severity: Severity::Error,
kind: DiscrepancyKind::Mismatch,
message: format!(
"EXIF longitude {} differs from ProofMode longitude {} (tolerance: {})",
exif_lon, pm_lon, GPS_TOLERANCE
),
});
}
}
}
fn parse_exif_gps_coord(s: &str) -> Option<f64> {
if let Ok(v) = s.parse::<f64>() {
return Some(v);
}
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() >= 6 {
let deg = parts[0].parse::<f64>().ok()?;
let min = parts[2].parse::<f64>().ok()?;
let sec = parts[4].parse::<f64>().ok()?;
return Some(deg + min / 60.0 + sec / 3600.0);
}
None
}
fn check_device_consistency(proof_files: &[ProofCheckFile]) -> Vec<DeviceDiscrepancies> {
let mut device_groups: HashMap<String, Vec<&ProofCheckFile>> = HashMap::new();
for file in proof_files {
if let Some(json) = &file.json {
let device_id = json
.device
.id
.clone()
.unwrap_or_else(|| "unknown".to_string());
device_groups.entry(device_id).or_default().push(file);
}
}
device_groups
.into_iter()
.filter_map(|(device_id, files)| {
if files.len() < 2 {
return None;
}
let mut flags = Vec::new();
check_static_attribute_consistency(&files, "device.manufacturer", &mut flags, |f| {
f.json.as_ref().and_then(|j| j.device.manufacturer.clone())
});
check_static_attribute_consistency(&files, "device.model", &mut flags, |f| {
f.json.as_ref().and_then(|j| j.device.model.clone())
});
check_static_attribute_consistency(&files, "device.screenSize", &mut flags, |f| {
f.json.as_ref().and_then(|j| j.device.screen_size.clone())
});
check_static_attribute_consistency(&files, "device.language", &mut flags, |f| {
f.json.as_ref().and_then(|j| j.device.language.clone())
});
check_static_attribute_consistency(&files, "device.locale", &mut flags, |f| {
f.json.as_ref().and_then(|j| j.device.locale.clone())
});
if flags.is_empty() {
return None;
}
Some(DeviceDiscrepancies {
device_id,
file_count: files.len(),
flags,
})
})
.collect()
}
fn check_static_attribute_consistency(
files: &[&ProofCheckFile],
field_name: &str,
flags: &mut Vec<Discrepancy>,
extractor: impl Fn(&ProofCheckFile) -> Option<String>,
) {
let values: Vec<String> = files.iter().filter_map(|f| extractor(f)).collect();
if values.is_empty() {
return;
}
let mut unique_values: Vec<String> = values.clone();
unique_values.sort();
unique_values.dedup();
if unique_values.len() > 1 {
flags.push(Discrepancy {
field: field_name.to_string(),
severity: Severity::Warning,
kind: DiscrepancyKind::Outlier,
message: format!(
"{} has {} distinct values across {} files from same device: [{}]",
field_name,
unique_values.len(),
values.len(),
unique_values.join(", ")
),
});
}
}