use super::super::PrivacyBudget;
use crate::error::{OptimError, Result};
use chrono::Utc;
use scirs2_core::numeric::Float;
use std::collections::{HashMap, VecDeque};
use std::fmt::Debug;
pub const DEFAULT_PARTICIPATION_WINDOW_ROUNDS: u64 = 100;
pub const DEFAULT_SUBJECT_EPSILON_BUDGET: f64 = 1.0;
#[derive(Debug, Clone)]
pub struct CrossDeviceConfig {
pub user_level_privacy: bool,
pub device_clustering: bool,
pub temporal_privacy: bool,
pub geographic_privacy: bool,
pub demographic_privacy: bool,
pub participation_window_rounds: u64,
pub subject_epsilon_budget: f64,
}
impl Default for CrossDeviceConfig {
fn default() -> Self {
Self {
user_level_privacy: false,
device_clustering: false,
temporal_privacy: false,
geographic_privacy: false,
demographic_privacy: false,
participation_window_rounds: DEFAULT_PARTICIPATION_WINDOW_ROUNDS,
subject_epsilon_budget: DEFAULT_SUBJECT_EPSILON_BUDGET,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct DeviceRegistration {
pub device_id: String,
pub user_id: String,
pub device_type: DeviceType,
pub location_cluster: String,
pub demographic_cohort: String,
}
#[derive(Debug, Clone)]
pub struct ParticipationRecord<T: Float + Debug + Send + Sync + 'static> {
pub round: u64,
pub epsilon_spent: f64,
pub update_l2_norm: Option<T>,
}
impl<T: Float + Debug + Send + Sync + 'static> ParticipationRecord<T> {
pub fn new(round: u64, epsilon_spent: f64) -> Self {
Self {
round,
epsilon_spent,
update_l2_norm: None,
}
}
pub fn with_update_norm(mut self, norm: T) -> Self {
self.update_l2_norm = Some(norm);
self
}
}
pub struct CrossDevicePrivacyManager<T: Float + Debug + Send + Sync + 'static> {
config: CrossDeviceConfig,
user_clusters: HashMap<String, Vec<String>>,
user_budgets: HashMap<String, PrivacyBudget>,
device_profiles: HashMap<String, DeviceProfile<T>>,
temporal_correlations: HashMap<String, Vec<TemporalEvent>>,
}
#[derive(Debug, Clone)]
pub struct DeviceProfile<T: Float + Debug + Send + Sync + 'static> {
pub device_id: String,
pub user_id: String,
pub device_type: DeviceType,
pub location_cluster: String,
pub demographic_cohort: String,
pub participation_frequency: f64,
pub participation_rounds: VecDeque<u64>,
pub total_participations: u64,
pub last_round: Option<u64>,
pub local_privacy_budget: PrivacyBudget,
pub sensitivity_estimate: T,
}
#[derive(Debug, Clone, Copy, Eq, Hash, PartialEq, Default)]
pub enum DeviceType {
#[default]
Mobile,
Desktop,
IoT,
Edge,
Server,
}
#[derive(Debug, Clone)]
pub struct TemporalEvent {
pub timestamp: u64,
pub round: u64,
pub event_type: TemporalEventType,
pub privacy_impact: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemporalEventType {
ClientParticipation,
ModelUpdate,
PrivacyBudgetConsumption,
AggregationEvent,
}
impl<T: Float + Debug + Send + Sync + 'static> CrossDevicePrivacyManager<T> {
pub fn try_new(config: CrossDeviceConfig) -> Result<Self> {
if config.participation_window_rounds == 0 {
return Err(OptimError::InvalidConfig(
"participation_window_rounds must be greater than zero".to_string(),
));
}
if !config.subject_epsilon_budget.is_finite() || config.subject_epsilon_budget <= 0.0 {
return Err(OptimError::InvalidConfig(format!(
"subject_epsilon_budget must be positive and finite, got {}",
config.subject_epsilon_budget
)));
}
Ok(Self {
config,
user_clusters: HashMap::new(),
user_budgets: HashMap::new(),
device_profiles: HashMap::new(),
temporal_correlations: HashMap::new(),
})
}
pub fn new(config: CrossDeviceConfig) -> Self {
let mut sanitized = config;
if sanitized.participation_window_rounds == 0 {
sanitized.participation_window_rounds = DEFAULT_PARTICIPATION_WINDOW_ROUNDS;
}
if !sanitized.subject_epsilon_budget.is_finite() || sanitized.subject_epsilon_budget <= 0.0
{
sanitized.subject_epsilon_budget = DEFAULT_SUBJECT_EPSILON_BUDGET;
}
Self {
config: sanitized,
user_clusters: HashMap::new(),
user_budgets: HashMap::new(),
device_profiles: HashMap::new(),
temporal_correlations: HashMap::new(),
}
}
pub fn register_device(&mut self, registration: DeviceRegistration) -> Result<()> {
if registration.device_id.is_empty() {
return Err(OptimError::InvalidParameter(
"device_id must not be empty".to_string(),
));
}
if registration.user_id.is_empty() {
return Err(OptimError::InvalidParameter(format!(
"device {} must declare a non-empty owning user_id; user-level privacy \
cannot be accounted for an unknown owner",
registration.device_id
)));
}
if self.config.geographic_privacy && registration.location_cluster.is_empty() {
return Err(OptimError::InvalidParameter(format!(
"geographic_privacy is enabled, so device {} must declare a location_cluster",
registration.device_id
)));
}
if self.config.demographic_privacy && registration.demographic_cohort.is_empty() {
return Err(OptimError::InvalidParameter(format!(
"demographic_privacy is enabled, so device {} must declare a demographic_cohort",
registration.device_id
)));
}
if let Some(existing) = self.device_profiles.get_mut(®istration.device_id) {
if existing.user_id != registration.user_id {
return Err(OptimError::InvalidParameter(format!(
"device {} is already owned by user {}; refusing to re-parent it to {}",
registration.device_id, existing.user_id, registration.user_id
)));
}
existing.device_type = registration.device_type;
existing.location_cluster = registration.location_cluster;
existing.demographic_cohort = registration.demographic_cohort;
return Ok(());
}
let profile = DeviceProfile {
device_id: registration.device_id.clone(),
user_id: registration.user_id.clone(),
device_type: registration.device_type,
location_cluster: registration.location_cluster,
demographic_cohort: registration.demographic_cohort,
participation_frequency: 0.0,
participation_rounds: VecDeque::new(),
total_participations: 0,
last_round: None,
local_privacy_budget: self.fresh_budget(),
sensitivity_estimate: T::zero(),
};
self.device_profiles
.insert(registration.device_id.clone(), profile);
let owned = self
.user_clusters
.entry(registration.user_id.clone())
.or_default();
if !owned.contains(®istration.device_id) {
owned.push(registration.device_id);
owned.sort();
}
if self.config.user_level_privacy {
let budget = self.fresh_budget();
self.user_budgets
.entry(registration.user_id)
.or_insert(budget);
}
Ok(())
}
pub fn record_participation(
&mut self,
device_id: &str,
record: ParticipationRecord<T>,
) -> Result<()> {
if !record.epsilon_spent.is_finite() || record.epsilon_spent < 0.0 {
return Err(OptimError::InvalidParameter(format!(
"epsilon_spent must be finite and non-negative, got {}",
record.epsilon_spent
)));
}
let (owner, last_round) = {
let profile = self.device_profiles.get(device_id).ok_or_else(|| {
OptimError::InvalidParameter(format!(
"device {device_id} is not registered; call register_device with its \
owning user_id before recording participation"
))
})?;
(profile.user_id.clone(), profile.last_round)
};
if let Some(previous) = last_round {
if record.round < previous {
return Err(OptimError::InvalidParameter(format!(
"round {} for device {device_id} is earlier than the last recorded round {}",
record.round, previous
)));
}
}
if self.config.user_level_privacy {
let remaining = self
.user_budgets
.get(&owner)
.map(|budget| budget.epsilon_remaining)
.unwrap_or(self.config.subject_epsilon_budget);
if record.epsilon_spent > remaining {
return Err(OptimError::PrivacyAccountingError(format!(
"user {owner} has {remaining} epsilon remaining; device {device_id} \
requested {}",
record.epsilon_spent
)));
}
} else {
let remaining = self
.device_profiles
.get(device_id)
.map(|profile| profile.local_privacy_budget.epsilon_remaining)
.unwrap_or(0.0);
if record.epsilon_spent > remaining {
return Err(OptimError::PrivacyAccountingError(format!(
"device {device_id} has {remaining} epsilon remaining; requested {}",
record.epsilon_spent
)));
}
}
let window = self.config.participation_window_rounds;
let oldest_in_window = record.round.saturating_sub(window.saturating_sub(1));
let profile = self.device_profiles.get_mut(device_id).ok_or_else(|| {
OptimError::InvalidState(format!("device {device_id} vanished during accounting"))
})?;
while profile
.participation_rounds
.front()
.is_some_and(|&r| r < oldest_in_window)
{
profile.participation_rounds.pop_front();
}
if profile.participation_rounds.back() != Some(&record.round) {
profile.participation_rounds.push_back(record.round);
}
profile.participation_frequency = profile.participation_rounds.len() as f64 / window as f64;
profile.total_participations = profile.total_participations.saturating_add(1);
profile.last_round = Some(record.round);
profile.local_privacy_budget.epsilon_consumed += record.epsilon_spent;
profile.local_privacy_budget.epsilon_remaining =
(profile.local_privacy_budget.epsilon_remaining - record.epsilon_spent).max(0.0);
profile.local_privacy_budget.steps_taken =
profile.local_privacy_budget.steps_taken.saturating_add(1);
if let Some(norm) = record.update_l2_norm {
if norm > profile.sensitivity_estimate {
profile.sensitivity_estimate = norm;
}
}
if self.config.user_level_privacy {
let fresh = self.fresh_budget();
let budget = self.user_budgets.entry(owner).or_insert(fresh);
budget.epsilon_consumed += record.epsilon_spent;
budget.epsilon_remaining = (budget.epsilon_remaining - record.epsilon_spent).max(0.0);
budget.steps_taken = budget.steps_taken.saturating_add(1);
}
if self.config.temporal_privacy {
self.temporal_correlations
.entry(device_id.to_string())
.or_default()
.push(TemporalEvent {
timestamp: Self::unix_now(),
round: record.round,
event_type: TemporalEventType::ClientParticipation,
privacy_impact: record.epsilon_spent,
});
}
Ok(())
}
pub fn get_device_profile(&self, device_id: &str) -> Option<&DeviceProfile<T>> {
self.device_profiles.get(device_id)
}
pub fn user_of_device(&self, device_id: &str) -> Option<&str> {
self.device_profiles
.get(device_id)
.map(|profile| profile.user_id.as_str())
}
pub fn get_temporal_correlations(&self, device_id: &str) -> Option<&Vec<TemporalEvent>> {
self.temporal_correlations.get(device_id)
}
pub fn create_user_cluster(&mut self, user_id: String, device_ids: Vec<String>) -> Result<()> {
for device_id in device_ids.iter() {
match self.device_profiles.get(device_id) {
None => {
return Err(OptimError::InvalidParameter(format!(
"device {device_id} is not registered, so it cannot be placed in the \
cluster of user {user_id}"
)));
}
Some(profile) if profile.user_id != user_id => {
return Err(OptimError::InvalidParameter(format!(
"device {device_id} is owned by user {}, not {user_id}",
profile.user_id
)));
}
Some(_) => {}
}
}
let mut sorted = device_ids;
sorted.sort();
sorted.dedup();
self.user_clusters.insert(user_id, sorted);
Ok(())
}
pub fn get_user_cluster(&self, user_id: &str) -> Option<&Vec<String>> {
self.user_clusters.get(user_id)
}
pub fn user_privacy_budget(&self, user_id: &str) -> Result<&PrivacyBudget> {
if !self.config.user_level_privacy {
return Err(OptimError::InvalidState(
"user_level_privacy is disabled; budgets are enforced per device, so there is \
no user-level budget to report"
.to_string(),
));
}
self.user_budgets.get(user_id).ok_or_else(|| {
OptimError::InvalidParameter(format!("user {user_id} has no registered device"))
})
}
pub fn devices_in_location_cluster(&self, location_cluster: &str) -> Result<Vec<String>> {
if !self.config.device_clustering {
return Err(OptimError::InvalidState(
"device_clustering is disabled; enable it to query location clusters".to_string(),
));
}
let mut members: Vec<String> = self
.device_profiles
.values()
.filter(|profile| profile.location_cluster == location_cluster)
.map(|profile| profile.device_id.clone())
.collect();
members.sort();
Ok(members)
}
pub fn is_user_level_privacy_enabled(&self) -> bool {
self.config.user_level_privacy
}
pub fn is_device_clustering_enabled(&self) -> bool {
self.config.device_clustering
}
pub fn is_temporal_privacy_enabled(&self) -> bool {
self.config.temporal_privacy
}
pub fn config(&self) -> &CrossDeviceConfig {
&self.config
}
pub fn device_count(&self) -> usize {
self.device_profiles.len()
}
pub fn user_count(&self) -> usize {
self.user_clusters.len()
}
pub fn get_participation_frequency(&self, device_id: &str) -> Option<f64> {
self.device_profiles
.get(device_id)
.map(|profile| profile.participation_frequency)
}
pub fn participation_frequency_at(&self, device_id: &str, round: u64) -> Option<f64> {
let profile = self.device_profiles.get(device_id)?;
let window = self.config.participation_window_rounds;
let oldest = round.saturating_sub(window.saturating_sub(1));
let in_window = profile
.participation_rounds
.iter()
.filter(|&&r| r >= oldest && r <= round)
.count();
Some(in_window as f64 / window as f64)
}
fn fresh_budget(&self) -> PrivacyBudget {
PrivacyBudget {
epsilon_consumed: 0.0,
epsilon_remaining: self.config.subject_epsilon_budget,
steps_taken: 0,
..PrivacyBudget::default()
}
}
fn unix_now() -> u64 {
let seconds = Utc::now().timestamp();
if seconds < 0 {
0
} else {
seconds as u64
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn registration(device_id: &str, user_id: &str) -> DeviceRegistration {
DeviceRegistration {
device_id: device_id.to_string(),
user_id: user_id.to_string(),
device_type: DeviceType::Mobile,
location_cluster: String::new(),
demographic_cohort: String::new(),
}
}
fn manager(config: CrossDeviceConfig) -> CrossDevicePrivacyManager<f64> {
CrossDevicePrivacyManager::<f64>::try_new(config).expect("valid config")
}
#[test]
fn test_cross_device_config() {
let config = CrossDeviceConfig::default();
assert!(!config.user_level_privacy);
assert!(!config.device_clustering);
assert!(!config.temporal_privacy);
assert_eq!(
config.participation_window_rounds,
DEFAULT_PARTICIPATION_WINDOW_ROUNDS
);
}
#[test]
fn test_device_profile_creation() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(DeviceRegistration {
device_id: "device_1".to_string(),
user_id: "user_1".to_string(),
device_type: DeviceType::Mobile,
location_cluster: "cluster_a".to_string(),
demographic_cohort: String::new(),
})
.expect("registration");
let profile = mgr.get_device_profile("device_1").expect("profile");
assert_eq!(profile.device_id, "device_1");
assert_eq!(profile.user_id, "user_1");
assert_eq!(profile.location_cluster, "cluster_a");
assert!(matches!(profile.device_type, DeviceType::Mobile));
assert_eq!(profile.sensitivity_estimate, 0.0);
}
#[test]
fn unregistered_device_participation_is_rejected_not_auto_mapped() {
let mut mgr = manager(CrossDeviceConfig::default());
let err = mgr
.record_participation("ghost", ParticipationRecord::new(1, 0.01))
.expect_err("unregistered device must be refused");
assert!(
format!("{err}").contains("not registered"),
"error should say the device is unregistered, got: {err}"
);
assert_eq!(mgr.device_count(), 0);
assert!(mgr.get_device_profile("ghost").is_none());
assert!(mgr.user_of_device("ghost").is_none());
}
#[test]
fn several_devices_can_share_one_user() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.register_device(registration("laptop", "alice"))
.expect("laptop");
mgr.register_device(registration("tablet", "bob"))
.expect("tablet");
assert_eq!(mgr.device_count(), 3);
assert_eq!(mgr.user_count(), 2);
assert_eq!(mgr.user_of_device("phone"), Some("alice"));
assert_eq!(mgr.user_of_device("laptop"), Some("alice"));
assert_eq!(mgr.user_of_device("tablet"), Some("bob"));
assert_eq!(
mgr.get_user_cluster("alice"),
Some(&vec!["laptop".to_string(), "phone".to_string()])
);
assert_eq!(
mgr.get_user_cluster("bob"),
Some(&vec!["tablet".to_string()])
);
}
#[test]
fn device_cannot_be_silently_reparented() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(registration("phone", "alice"))
.expect("phone");
let err = mgr
.register_device(registration("phone", "bob"))
.expect_err("re-parenting must fail");
assert!(format!("{err}").contains("already owned by user alice"));
assert_eq!(mgr.user_of_device("phone"), Some("alice"));
}
#[test]
fn empty_user_id_is_rejected() {
let mut mgr = manager(CrossDeviceConfig::default());
let err = mgr
.register_device(registration("phone", ""))
.expect_err("empty owner must fail");
assert!(format!("{err}").contains("non-empty owning user_id"));
}
#[test]
fn create_user_cluster_rejects_devices_it_does_not_own() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.register_device(registration("tablet", "bob"))
.expect("tablet");
assert!(mgr
.create_user_cluster("alice".to_string(), vec!["phone".to_string()])
.is_ok());
let err = mgr
.create_user_cluster("alice".to_string(), vec!["tablet".to_string()])
.expect_err("foreign device must be refused");
assert!(format!("{err}").contains("owned by user bob"));
let err = mgr
.create_user_cluster("alice".to_string(), vec!["nope".to_string()])
.expect_err("unknown device must be refused");
assert!(format!("{err}").contains("not registered"));
}
#[test]
fn geographic_and_demographic_privacy_require_their_labels() {
let mut mgr = manager(CrossDeviceConfig {
geographic_privacy: true,
..CrossDeviceConfig::default()
});
let err = mgr
.register_device(registration("phone", "alice"))
.expect_err("missing location cluster must fail");
assert!(format!("{err}").contains("location_cluster"));
let mut mgr = manager(CrossDeviceConfig {
demographic_privacy: true,
..CrossDeviceConfig::default()
});
let err = mgr
.register_device(registration("phone", "alice"))
.expect_err("missing cohort must fail");
assert!(format!("{err}").contains("demographic_cohort"));
}
#[test]
fn participation_frequency_is_a_bounded_windowed_rate() {
let window = 10_u64;
let mut mgr = manager(CrossDeviceConfig {
participation_window_rounds: window,
subject_epsilon_budget: 1000.0,
..CrossDeviceConfig::default()
});
mgr.register_device(registration("phone", "alice"))
.expect("phone");
for round in 1..=200_u64 {
mgr.record_participation("phone", ParticipationRecord::new(round, 0.001))
.expect("participation");
}
let frequency = mgr.get_participation_frequency("phone").expect("frequency");
assert!(
(frequency - 1.0).abs() < 1e-12,
"participating in every round must give rate 1.0, got {frequency}"
);
let profile = mgr.get_device_profile("phone").expect("profile");
assert_eq!(profile.total_participations, 200);
assert_eq!(profile.participation_rounds.len(), window as usize);
}
#[test]
fn participation_frequency_matches_the_fraction_of_rounds_in_the_window() {
let window = 10_u64;
let mut mgr = manager(CrossDeviceConfig {
participation_window_rounds: window,
subject_epsilon_budget: 100.0,
..CrossDeviceConfig::default()
});
mgr.register_device(registration("phone", "alice"))
.expect("phone");
for round in (1..=19_u64).step_by(2) {
mgr.record_participation("phone", ParticipationRecord::new(round, 0.01))
.expect("participation");
}
let frequency = mgr.get_participation_frequency("phone").expect("frequency");
assert!(
(frequency - 0.5).abs() < 1e-12,
"expected 5/10 = 0.5, got {frequency}"
);
}
#[test]
fn participation_frequency_decays_as_the_window_moves_past() {
let window = 5_u64;
let mut mgr = manager(CrossDeviceConfig {
participation_window_rounds: window,
..CrossDeviceConfig::default()
});
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.record_participation("phone", ParticipationRecord::new(1, 0.01))
.expect("participation");
assert_eq!(mgr.participation_frequency_at("phone", 1), Some(0.2));
assert_eq!(mgr.participation_frequency_at("phone", 5), Some(0.2));
assert_eq!(mgr.participation_frequency_at("phone", 10), Some(0.0));
}
#[test]
fn repeated_round_counts_once() {
let mut mgr = manager(CrossDeviceConfig {
participation_window_rounds: 10,
..CrossDeviceConfig::default()
});
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.record_participation("phone", ParticipationRecord::new(3, 0.01))
.expect("first");
mgr.record_participation("phone", ParticipationRecord::new(3, 0.01))
.expect("second");
let profile = mgr.get_device_profile("phone").expect("profile");
assert_eq!(profile.participation_rounds.len(), 1);
assert!((profile.participation_frequency - 0.1).abs() < 1e-12);
assert!((profile.local_privacy_budget.epsilon_consumed - 0.02).abs() < 1e-12);
}
#[test]
fn rounds_may_not_go_backwards() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.record_participation("phone", ParticipationRecord::new(10, 0.01))
.expect("round 10");
let err = mgr
.record_participation("phone", ParticipationRecord::new(9, 0.01))
.expect_err("going back must fail");
assert!(format!("{err}").contains("earlier than the last recorded round"));
}
#[test]
fn device_level_budget_is_enforced_per_device() {
let mut mgr = manager(CrossDeviceConfig {
subject_epsilon_budget: 0.05,
..CrossDeviceConfig::default()
});
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.register_device(registration("laptop", "alice"))
.expect("laptop");
mgr.record_participation("phone", ParticipationRecord::new(1, 0.04))
.expect("first spend fits");
let err = mgr
.record_participation("phone", ParticipationRecord::new(2, 0.04))
.expect_err("overspend must be refused");
assert!(format!("{err}").contains("epsilon remaining"));
let profile = mgr.get_device_profile("phone").expect("profile");
assert!((profile.local_privacy_budget.epsilon_consumed - 0.04).abs() < 1e-12);
assert_eq!(profile.last_round, Some(1));
mgr.record_participation("laptop", ParticipationRecord::new(2, 0.04))
.expect("sibling has its own budget");
}
#[test]
fn user_level_budget_is_shared_across_a_users_devices() {
let mut mgr = manager(CrossDeviceConfig {
user_level_privacy: true,
subject_epsilon_budget: 0.05,
..CrossDeviceConfig::default()
});
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.register_device(registration("laptop", "alice"))
.expect("laptop");
mgr.register_device(registration("tablet", "bob"))
.expect("tablet");
mgr.record_participation("phone", ParticipationRecord::new(1, 0.04))
.expect("phone spend");
let err = mgr
.record_participation("laptop", ParticipationRecord::new(2, 0.04))
.expect_err("shared budget must be enforced");
assert!(format!("{err}").contains("user alice"));
let alice = mgr.user_privacy_budget("alice").expect("alice budget");
assert!((alice.epsilon_consumed - 0.04).abs() < 1e-12);
assert!((alice.epsilon_remaining - 0.01).abs() < 1e-12);
mgr.record_participation("tablet", ParticipationRecord::new(2, 0.04))
.expect("bob spend");
let bob = mgr.user_privacy_budget("bob").expect("bob budget");
assert!((bob.epsilon_consumed - 0.04).abs() < 1e-12);
}
#[test]
fn user_budget_query_errors_when_user_level_privacy_is_off() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(registration("phone", "alice"))
.expect("phone");
let err = mgr
.user_privacy_budget("alice")
.expect_err("no user-level budget exists");
assert!(format!("{err}").contains("user_level_privacy is disabled"));
}
#[test]
fn sensitivity_estimate_tracks_the_largest_observed_norm() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.record_participation(
"phone",
ParticipationRecord::new(1, 0.01).with_update_norm(0.5),
)
.expect("round 1");
assert_eq!(
mgr.get_device_profile("phone")
.map(|p| p.sensitivity_estimate),
Some(0.5)
);
mgr.record_participation(
"phone",
ParticipationRecord::new(2, 0.01).with_update_norm(2.25),
)
.expect("round 2");
assert_eq!(
mgr.get_device_profile("phone")
.map(|p| p.sensitivity_estimate),
Some(2.25)
);
mgr.record_participation(
"phone",
ParticipationRecord::new(3, 0.01).with_update_norm(0.1),
)
.expect("round 3");
mgr.record_participation("phone", ParticipationRecord::new(4, 0.01))
.expect("round 4");
assert_eq!(
mgr.get_device_profile("phone")
.map(|p| p.sensitivity_estimate),
Some(2.25)
);
}
#[test]
fn temporal_events_record_the_real_epsilon_and_round() {
let mut mgr = manager(CrossDeviceConfig {
temporal_privacy: true,
..CrossDeviceConfig::default()
});
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.record_participation("phone", ParticipationRecord::new(1, 0.02))
.expect("round 1");
mgr.record_participation("phone", ParticipationRecord::new(7, 0.03))
.expect("round 7");
let events = mgr.get_temporal_correlations("phone").expect("events");
assert_eq!(events.len(), 2);
assert_eq!(events[0].round, 1);
assert_eq!(events[1].round, 7);
assert!((events[0].privacy_impact - 0.02).abs() < 1e-12);
assert!((events[1].privacy_impact - 0.03).abs() < 1e-12);
assert!(events[0].timestamp > 1_600_000_000);
assert!(events[1].timestamp >= events[0].timestamp);
}
#[test]
fn temporal_log_is_not_kept_when_temporal_privacy_is_off() {
let mut mgr = manager(CrossDeviceConfig::default());
mgr.register_device(registration("phone", "alice"))
.expect("phone");
mgr.record_participation("phone", ParticipationRecord::new(1, 0.02))
.expect("round 1");
assert!(mgr.get_temporal_correlations("phone").is_none());
}
#[test]
fn location_clusters_group_real_registrations() {
let mut mgr = manager(CrossDeviceConfig {
device_clustering: true,
geographic_privacy: true,
..CrossDeviceConfig::default()
});
for (device, user, cluster) in [
("phone", "alice", "eu-west"),
("laptop", "alice", "eu-west"),
("tablet", "bob", "us-east"),
] {
mgr.register_device(DeviceRegistration {
device_id: device.to_string(),
user_id: user.to_string(),
device_type: DeviceType::Mobile,
location_cluster: cluster.to_string(),
demographic_cohort: String::new(),
})
.expect("registration");
}
assert_eq!(
mgr.devices_in_location_cluster("eu-west")
.expect("cluster query"),
vec!["laptop".to_string(), "phone".to_string()]
);
assert_eq!(
mgr.devices_in_location_cluster("us-east")
.expect("cluster query"),
vec!["tablet".to_string()]
);
}
#[test]
fn location_cluster_query_errors_when_clustering_is_off() {
let mgr = manager(CrossDeviceConfig::default());
let err = mgr
.devices_in_location_cluster("eu-west")
.expect_err("clustering disabled");
assert!(format!("{err}").contains("device_clustering is disabled"));
}
#[test]
fn zero_window_is_rejected_by_try_new_and_sanitized_by_new() {
let bad = CrossDeviceConfig {
participation_window_rounds: 0,
..CrossDeviceConfig::default()
};
assert!(CrossDevicePrivacyManager::<f64>::try_new(bad.clone()).is_err());
let mgr = CrossDevicePrivacyManager::<f64>::new(bad);
assert_eq!(
mgr.config().participation_window_rounds,
DEFAULT_PARTICIPATION_WINDOW_ROUNDS
);
}
}