use std::fmt;
use std::sync::atomic::{AtomicU64, Ordering};
use serde::Serialize;
static FSQLITE_DP_QUERIES_TOTAL: AtomicU64 = AtomicU64::new(0);
static FSQLITE_DP_EPSILON_SPENT_MICROS: AtomicU64 = AtomicU64::new(0);
static FSQLITE_DP_BUDGET_EXHAUSTED_TOTAL: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct DpMetrics {
pub fsqlite_dp_queries_total: u64,
pub fsqlite_dp_epsilon_spent_micros: u64,
pub fsqlite_dp_budget_exhausted_total: u64,
}
#[must_use]
pub fn dp_metrics() -> DpMetrics {
DpMetrics {
fsqlite_dp_queries_total: FSQLITE_DP_QUERIES_TOTAL.load(Ordering::Relaxed),
fsqlite_dp_epsilon_spent_micros: FSQLITE_DP_EPSILON_SPENT_MICROS.load(Ordering::Relaxed),
fsqlite_dp_budget_exhausted_total: FSQLITE_DP_BUDGET_EXHAUSTED_TOTAL
.load(Ordering::Relaxed),
}
}
pub fn reset_dp_metrics() {
FSQLITE_DP_QUERIES_TOTAL.store(0, Ordering::Relaxed);
FSQLITE_DP_EPSILON_SPENT_MICROS.store(0, Ordering::Relaxed);
FSQLITE_DP_BUDGET_EXHAUSTED_TOTAL.store(0, Ordering::Relaxed);
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn record_dp_query(epsilon: f64) {
FSQLITE_DP_QUERIES_TOTAL.fetch_add(1, Ordering::Relaxed);
let micros = (epsilon * 1_000_000.0) as u64;
FSQLITE_DP_EPSILON_SPENT_MICROS.fetch_add(micros, Ordering::Relaxed);
}
fn record_budget_exhausted() {
FSQLITE_DP_BUDGET_EXHAUSTED_TOTAL.fetch_add(1, Ordering::Relaxed);
}
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn next_f64(&mut self) -> f64 {
loop {
let u = (self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64);
if u > 0.0 && u < 1.0 {
return u;
}
}
}
fn sample_laplace(&mut self, b: f64) -> f64 {
let u = self.next_f64() - 0.5;
-b * u.signum() * 2.0f64.mul_add(-u.abs(), 1.0).ln()
}
fn sample_standard_normal(&mut self) -> f64 {
let u1 = self.next_f64();
let u2 = self.next_f64();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub enum NoiseMechanism {
Laplace,
Gaussian {
delta: f64,
},
}
impl fmt::Display for NoiseMechanism {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Laplace => write!(f, "Laplace"),
Self::Gaussian { delta } => write!(f, "Gaussian(δ={delta:.2e})"),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub enum DpError {
BudgetExhausted { requested: f64, remaining: f64 },
InvalidParameter(String),
}
impl fmt::Display for DpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BudgetExhausted {
requested,
remaining,
} => write!(
f,
"DP budget exhausted: requested ε={requested:.4}, remaining ε={remaining:.4}"
),
Self::InvalidParameter(msg) => write!(f, "DP invalid parameter: {msg}"),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PrivacyBudget {
total_epsilon: f64,
spent_epsilon: f64,
queries_charged: u64,
}
impl PrivacyBudget {
pub fn new(total_epsilon: f64) -> Result<Self, DpError> {
if total_epsilon <= 0.0 || !total_epsilon.is_finite() {
return Err(DpError::InvalidParameter(format!(
"total_epsilon must be positive and finite, got {total_epsilon}"
)));
}
Ok(Self {
total_epsilon,
spent_epsilon: 0.0,
queries_charged: 0,
})
}
#[must_use]
pub fn remaining(&self) -> f64 {
(self.total_epsilon - self.spent_epsilon).max(0.0)
}
#[must_use]
pub fn total(&self) -> f64 {
self.total_epsilon
}
#[must_use]
pub fn spent(&self) -> f64 {
self.spent_epsilon
}
#[must_use]
pub fn queries_charged(&self) -> u64 {
self.queries_charged
}
#[must_use]
pub fn can_spend(&self, epsilon: f64) -> bool {
epsilon > 0.0 && self.spent_epsilon + epsilon <= self.total_epsilon
}
fn charge(&mut self, epsilon: f64) -> Result<(), DpError> {
if epsilon <= 0.0 || !epsilon.is_finite() {
return Err(DpError::InvalidParameter(format!(
"epsilon must be positive and finite, got {epsilon}"
)));
}
if self.spent_epsilon + epsilon > self.total_epsilon {
record_budget_exhausted();
return Err(DpError::BudgetExhausted {
requested: epsilon,
remaining: self.remaining(),
});
}
self.spent_epsilon += epsilon;
self.queries_charged += 1;
Ok(())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct DpQueryResult {
pub noisy_value: f64,
pub epsilon_spent: f64,
pub mechanism: NoiseMechanism,
pub sensitivity: f64,
pub noise_scale: f64,
}
pub struct DpEngine {
budget: PrivacyBudget,
rng: SplitMix64,
}
#[allow(clippy::missing_fields_in_debug)]
impl fmt::Debug for DpEngine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DpEngine")
.field("budget", &self.budget)
.finish_non_exhaustive()
}
}
impl DpEngine {
pub fn new(total_epsilon: f64, seed: u64) -> Result<Self, DpError> {
let budget = PrivacyBudget::new(total_epsilon)?;
tracing::info!(
target: "fsqlite.dp",
total_epsilon,
seed,
"dp_engine_created"
);
Ok(Self {
budget,
rng: SplitMix64::new(seed),
})
}
#[must_use]
pub fn budget(&self) -> &PrivacyBudget {
&self.budget
}
pub fn laplace(
&mut self,
true_value: f64,
sensitivity: f64,
epsilon: f64,
) -> Result<DpQueryResult, DpError> {
self.validate_params(sensitivity, epsilon)?;
self.budget.charge(epsilon)?;
let b = sensitivity / epsilon;
let noise = self.rng.sample_laplace(b);
let noisy_value = true_value + noise;
record_dp_query(epsilon);
tracing::info!(
target: "fsqlite.dp",
epsilon_spent = epsilon,
mechanism = "Laplace",
sensitivity,
noise_scale = b,
"dp_query"
);
Ok(DpQueryResult {
noisy_value,
epsilon_spent: epsilon,
mechanism: NoiseMechanism::Laplace,
sensitivity,
noise_scale: b,
})
}
pub fn gaussian(
&mut self,
true_value: f64,
sensitivity: f64,
epsilon: f64,
delta: f64,
) -> Result<DpQueryResult, DpError> {
self.validate_params(sensitivity, epsilon)?;
if delta <= 0.0 || delta >= 1.0 || !delta.is_finite() {
return Err(DpError::InvalidParameter(format!(
"delta must be in (0, 1), got {delta}"
)));
}
self.budget.charge(epsilon)?;
let sigma = sensitivity * (2.0 * (1.25_f64 / delta).ln()).sqrt() / epsilon;
let noise = self.rng.sample_standard_normal() * sigma;
let noisy_value = true_value + noise;
record_dp_query(epsilon);
tracing::info!(
target: "fsqlite.dp",
epsilon_spent = epsilon,
mechanism = "Gaussian",
sensitivity,
noise_scale = sigma,
delta,
"dp_query"
);
Ok(DpQueryResult {
noisy_value,
epsilon_spent: epsilon,
mechanism: NoiseMechanism::Gaussian { delta },
sensitivity,
noise_scale: sigma,
})
}
#[allow(clippy::unused_self)]
fn validate_params(&self, sensitivity: f64, epsilon: f64) -> Result<(), DpError> {
if sensitivity <= 0.0 || !sensitivity.is_finite() {
return Err(DpError::InvalidParameter(format!(
"sensitivity must be positive and finite, got {sensitivity}"
)));
}
if epsilon <= 0.0 || !epsilon.is_finite() {
return Err(DpError::InvalidParameter(format!(
"epsilon must be positive and finite, got {epsilon}"
)));
}
Ok(())
}
}
pub mod sensitivity {
pub const COUNT: f64 = 1.0;
pub const fn sum(max_contribution: f64) -> f64 {
max_contribution
}
pub fn avg(max_contribution: f64, n: usize) -> f64 {
if n == 0 {
return 0.0;
}
2.0 * max_contribution / n as f64
}
}
#[cfg(test)]
#[allow(clippy::float_cmp)]
mod tests {
use super::*;
#[test]
fn test_budget_creation() {
let b = PrivacyBudget::new(1.0).unwrap();
assert_eq!(b.total(), 1.0);
assert_eq!(b.remaining(), 1.0);
assert_eq!(b.spent(), 0.0);
assert_eq!(b.queries_charged(), 0);
assert!(PrivacyBudget::new(0.0).is_err());
assert!(PrivacyBudget::new(-1.0).is_err());
assert!(PrivacyBudget::new(f64::NAN).is_err());
assert!(PrivacyBudget::new(f64::INFINITY).is_err());
println!("[PASS] budget creation: valid and invalid cases");
}
#[test]
fn test_budget_charging() {
let mut b = PrivacyBudget::new(1.0).unwrap();
b.charge(0.3).unwrap();
assert!((b.spent() - 0.3).abs() < 1e-10);
assert!((b.remaining() - 0.7).abs() < 1e-10);
assert_eq!(b.queries_charged(), 1);
b.charge(0.5).unwrap();
assert!((b.spent() - 0.8).abs() < 1e-10);
assert_eq!(b.queries_charged(), 2);
let err = b.charge(0.5).unwrap_err();
assert!(matches!(err, DpError::BudgetExhausted { .. }));
println!("[PASS] budget charging: sequential composition enforced");
}
#[test]
fn test_budget_can_spend() {
let mut b = PrivacyBudget::new(1.0).unwrap();
assert!(b.can_spend(0.5));
assert!(b.can_spend(1.0));
assert!(!b.can_spend(1.1));
b.charge(0.6).unwrap();
assert!(b.can_spend(0.4));
assert!(!b.can_spend(0.5));
println!("[PASS] can_spend: correct pre-check");
}
#[test]
fn test_laplace_mechanism() {
let mut engine = DpEngine::new(10.0, 42).unwrap();
let result = engine.laplace(100.0, 1.0, 1.0).unwrap();
assert!(
(result.noisy_value - 100.0).abs() < 50.0,
"Laplace noise too extreme: {}",
result.noisy_value
);
assert_eq!(result.epsilon_spent, 1.0);
assert_eq!(result.mechanism, NoiseMechanism::Laplace);
assert_eq!(result.sensitivity, 1.0);
assert_eq!(result.noise_scale, 1.0);
println!(
"[PASS] Laplace mechanism: noisy_value={:.2}, noise={:.2}",
result.noisy_value,
result.noisy_value - 100.0
);
}
#[test]
fn test_gaussian_mechanism() {
let mut engine = DpEngine::new(10.0, 42).unwrap();
let result = engine.gaussian(100.0, 1.0, 1.0, 1e-5).unwrap();
assert!(
(result.noisy_value - 100.0).abs() < 50.0,
"Gaussian noise too extreme: {}",
result.noisy_value
);
assert_eq!(result.epsilon_spent, 1.0);
assert!(matches!(result.mechanism, NoiseMechanism::Gaussian { .. }));
assert!(result.noise_scale > 0.0);
println!(
"[PASS] Gaussian mechanism: noisy_value={:.2}, sigma={:.4}",
result.noisy_value, result.noise_scale
);
}
#[test]
fn test_budget_exhaustion() {
let mut engine = DpEngine::new(1.0, 42).unwrap();
engine.laplace(50.0, 1.0, 0.5).unwrap();
engine.laplace(50.0, 1.0, 0.3).unwrap();
let err = engine.laplace(50.0, 1.0, 0.5).unwrap_err();
assert!(matches!(err, DpError::BudgetExhausted { .. }));
engine.laplace(50.0, 1.0, 0.2).unwrap();
println!("[PASS] budget exhaustion: enforced at limit");
}
#[test]
fn test_laplace_noise_distribution() {
let mut rng = SplitMix64::new(12345);
let n = 10_000;
let mut sum = 0.0;
let mut sum_sq = 0.0;
for _ in 0..n {
let x = rng.sample_laplace(1.0);
sum += x;
sum_sq += x * x;
}
let mean = sum / f64::from(n);
let variance = sum_sq / f64::from(n) - mean * mean;
assert!(mean.abs() < 0.1, "Laplace mean should be ~0, got {mean:.4}");
assert!(
(variance - 2.0).abs() < 0.3,
"Laplace variance should be ~2, got {variance:.4}"
);
println!("[PASS] Laplace distribution: mean={mean:.4}, var={variance:.4}");
}
#[test]
fn test_gaussian_noise_distribution() {
let mut rng = SplitMix64::new(54321);
let n = 10_000;
let mut sum = 0.0;
let mut sum_sq = 0.0;
for _ in 0..n {
let x = rng.sample_standard_normal();
sum += x;
sum_sq += x * x;
}
let mean = sum / f64::from(n);
let variance = sum_sq / f64::from(n) - mean * mean;
assert!(
mean.abs() < 0.1,
"Gaussian mean should be ~0, got {mean:.4}"
);
assert!(
(variance - 1.0).abs() < 0.15,
"Gaussian variance should be ~1, got {variance:.4}"
);
println!("[PASS] Gaussian distribution: mean={mean:.4}, var={variance:.4}");
}
#[test]
fn test_sensitivity_helpers() {
assert_eq!(sensitivity::COUNT, 1.0);
assert_eq!(sensitivity::sum(100.0), 100.0);
let avg_sens = sensitivity::avg(100.0, 1000);
assert!(
(avg_sens - 0.2).abs() < 1e-10,
"avg sensitivity = 2*100/1000 = 0.2"
);
assert_eq!(sensitivity::avg(100.0, 0), 0.0);
println!("[PASS] sensitivity helpers: count, sum, avg");
}
#[test]
fn test_metrics_integration() {
let before = dp_metrics();
let mut engine = DpEngine::new(5.0, 42).unwrap();
engine.laplace(100.0, 1.0, 0.5).unwrap();
engine.gaussian(100.0, 1.0, 0.3, 1e-5).unwrap();
let after = dp_metrics();
let queries_delta = after.fsqlite_dp_queries_total - before.fsqlite_dp_queries_total;
let epsilon_delta =
after.fsqlite_dp_epsilon_spent_micros - before.fsqlite_dp_epsilon_spent_micros;
assert!(
queries_delta >= 2,
"at least 2 queries, got {queries_delta}"
);
assert!(
epsilon_delta >= 800_000,
"at least 0.8ε in micros, got {epsilon_delta}"
);
let json = serde_json::to_string(&after).unwrap();
assert!(json.contains("fsqlite_dp_queries_total"));
assert!(json.contains("fsqlite_dp_epsilon_spent_micros"));
println!("[PASS] metrics: queries_delta={queries_delta} epsilon_delta={epsilon_delta}");
}
#[test]
fn test_deterministic_noise() {
let mut e1 = DpEngine::new(10.0, 42).unwrap();
let mut e2 = DpEngine::new(10.0, 42).unwrap();
let r1 = e1.laplace(100.0, 1.0, 1.0).unwrap();
let r2 = e2.laplace(100.0, 1.0, 1.0).unwrap();
assert!(
(r1.noisy_value - r2.noisy_value).abs() < 1e-10,
"same seed should produce same noise"
);
println!(
"[PASS] deterministic noise: both engines produced {:.6}",
r1.noisy_value
);
}
#[test]
fn test_invalid_parameters() {
let mut engine = DpEngine::new(10.0, 42).unwrap();
assert!(engine.laplace(100.0, 0.0, 1.0).is_err());
assert!(engine.laplace(100.0, -1.0, 1.0).is_err());
assert!(engine.laplace(100.0, 1.0, 0.0).is_err());
assert!(engine.laplace(100.0, 1.0, -1.0).is_err());
assert!(engine.gaussian(100.0, 1.0, 1.0, 0.0).is_err());
assert!(engine.gaussian(100.0, 1.0, 1.0, 1.0).is_err());
assert!(engine.gaussian(100.0, 1.0, 1.0, -0.1).is_err());
println!("[PASS] invalid parameters: all rejected");
}
}