1use std::sync::atomic::{AtomicU64, Ordering};
30use thiserror::Error;
31
32#[derive(Debug, Error)]
36pub enum BudgetError {
37 #[error("invalid budget: {reason}")]
39 InvalidBudget { reason: String },
40
41 #[error("epsilon budget exceeded: need {epsilon_needed}, remaining {epsilon_remaining}")]
43 BudgetExceeded {
44 epsilon_needed: f64,
45 epsilon_remaining: f64,
46 },
47
48 #[error("delta budget exceeded: need {delta_needed}, remaining {delta_remaining}")]
50 DeltaExceeded {
51 delta_needed: f64,
52 delta_remaining: f64,
53 },
54}
55
56#[derive(Debug, Clone)]
60pub struct BudgetSnapshot {
61 pub epsilon_total: f64,
63 pub delta_total: f64,
65 pub epsilon_spent: f64,
67 pub delta_spent: f64,
69 pub epsilon_remaining: f64,
71 pub delta_remaining: f64,
73 pub round_count: u64,
75 pub is_exhausted: bool,
77}
78
79pub struct PrivacyBudget {
87 epsilon_total: f64,
89 delta_total: f64,
91 epsilon_spent: AtomicU64,
93 delta_spent: AtomicU64,
95 round_count: AtomicU64,
97}
98
99impl PrivacyBudget {
100 pub fn new(epsilon_total: f64, delta_total: f64) -> Result<Self, BudgetError> {
108 if epsilon_total <= 0.0 {
109 return Err(BudgetError::InvalidBudget {
110 reason: format!("epsilon_total must be positive, got {epsilon_total}"),
111 });
112 }
113 if delta_total <= 0.0 {
114 return Err(BudgetError::InvalidBudget {
115 reason: format!("delta_total must be positive, got {delta_total}"),
116 });
117 }
118 if delta_total >= 1.0 {
119 return Err(BudgetError::InvalidBudget {
120 reason: format!("delta_total must be < 1.0, got {delta_total}"),
121 });
122 }
123 Ok(Self {
124 epsilon_total,
125 delta_total,
126 epsilon_spent: AtomicU64::new(0f64.to_bits()),
127 delta_spent: AtomicU64::new(0f64.to_bits()),
128 round_count: AtomicU64::new(0),
129 })
130 }
131
132 #[inline]
134 pub fn epsilon_total(&self) -> f64 {
135 self.epsilon_total
136 }
137
138 #[inline]
140 pub fn delta_total(&self) -> f64 {
141 self.delta_total
142 }
143
144 #[inline]
146 pub fn epsilon_remaining(&self) -> f64 {
147 let spent = f64::from_bits(self.epsilon_spent.load(Ordering::SeqCst));
148 self.epsilon_total - spent
149 }
150
151 #[inline]
153 pub fn delta_remaining(&self) -> f64 {
154 let spent = f64::from_bits(self.delta_spent.load(Ordering::SeqCst));
155 self.delta_total - spent
156 }
157
158 #[inline]
160 pub fn is_exhausted(&self) -> bool {
161 self.epsilon_remaining() <= 0.0 || self.delta_remaining() <= 0.0
162 }
163
164 pub fn consume(&self, epsilon: f64, delta: f64) -> Result<(), BudgetError> {
174 let eps_result =
176 self.epsilon_spent
177 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current_bits| {
178 let current = f64::from_bits(current_bits);
179 let new_val = current + epsilon;
180 if new_val > self.epsilon_total {
181 None } else {
183 Some(new_val.to_bits())
184 }
185 });
186
187 if eps_result.is_err() {
188 let remaining = self.epsilon_remaining();
189 return Err(BudgetError::BudgetExceeded {
190 epsilon_needed: epsilon,
191 epsilon_remaining: remaining,
192 });
193 }
194
195 let delta_result =
197 self.delta_spent
198 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current_bits| {
199 let current = f64::from_bits(current_bits);
200 let new_val = current + delta;
201 if new_val > self.delta_total {
202 None
203 } else {
204 Some(new_val.to_bits())
205 }
206 });
207
208 if delta_result.is_err() {
209 self.epsilon_spent
211 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current_bits| {
212 let current = f64::from_bits(current_bits);
213 Some((current - epsilon).to_bits())
214 })
215 .ok(); let remaining = self.delta_remaining();
218 return Err(BudgetError::DeltaExceeded {
219 delta_needed: delta,
220 delta_remaining: remaining,
221 });
222 }
223
224 Ok(())
225 }
226
227 pub fn increment_round(&self) -> u64 {
229 self.round_count.fetch_add(1, Ordering::SeqCst) + 1
230 }
231
232 pub fn snapshot(&self) -> BudgetSnapshot {
234 let epsilon_spent = f64::from_bits(self.epsilon_spent.load(Ordering::SeqCst));
235 let delta_spent = f64::from_bits(self.delta_spent.load(Ordering::SeqCst));
236 let epsilon_remaining = self.epsilon_total - epsilon_spent;
237 let delta_remaining = self.delta_total - delta_spent;
238 let round_count = self.round_count.load(Ordering::SeqCst);
239 BudgetSnapshot {
240 epsilon_total: self.epsilon_total,
241 delta_total: self.delta_total,
242 epsilon_spent,
243 delta_spent,
244 epsilon_remaining,
245 delta_remaining,
246 round_count,
247 is_exhausted: epsilon_remaining <= 0.0 || delta_remaining <= 0.0,
248 }
249 }
250}
251
252impl std::fmt::Debug for PrivacyBudget {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 let snap = self.snapshot();
255 f.debug_struct("PrivacyBudget")
256 .field("epsilon_total", &snap.epsilon_total)
257 .field("epsilon_spent", &snap.epsilon_spent)
258 .field("delta_total", &snap.delta_total)
259 .field("delta_spent", &snap.delta_spent)
260 .field("round_count", &snap.round_count)
261 .finish()
262 }
263}
264
265#[derive(Debug, Clone)]
272pub struct RenyiAccountant {
273 alpha: f64,
275 rdp_epsilon: f64,
277 rounds: u64,
279}
280
281impl RenyiAccountant {
282 pub fn new(alpha: f64) -> Self {
284 Self {
285 alpha,
286 rdp_epsilon: 0.0,
287 rounds: 0,
288 }
289 }
290
291 #[inline]
293 pub fn alpha(&self) -> f64 {
294 self.alpha
295 }
296
297 #[inline]
299 pub fn rdp_epsilon(&self) -> f64 {
300 self.rdp_epsilon
301 }
302
303 #[inline]
305 pub fn rounds(&self) -> u64 {
306 self.rounds
307 }
308
309 pub fn record_gaussian_mechanism(
319 &mut self,
320 noise_multiplier: f64,
321 sensitivity: f64,
322 _sample_rate: f64,
323 ) {
324 let increment = self.alpha / (2.0 * noise_multiplier.powi(2) * sensitivity.powi(2));
325 self.rdp_epsilon += increment;
326 self.rounds += 1;
327 }
328
329 pub fn to_dp(&self, delta: f64) -> (f64, f64) {
338 let rdp = self.rdp_epsilon;
339 let epsilon = rdp + (rdp.ln() / delta.ln()).abs().sqrt();
340 (epsilon, delta)
341 }
342
343 pub fn reset(&mut self) {
345 self.rdp_epsilon = 0.0;
346 self.rounds = 0;
347 }
348}
349
350impl Default for RenyiAccountant {
351 fn default() -> Self {
352 Self::new(10.0)
353 }
354}
355
356#[derive(Debug)]
363pub struct RoundGuard {
364 pub round_id: u64,
366}
367
368#[derive(Debug)]
375pub struct PerRoundBudget {
376 budget: PrivacyBudget,
378 max_epsilon_per_round: f64,
380 max_delta_per_round: f64,
382}
383
384impl PerRoundBudget {
385 pub fn new(
391 epsilon_total: f64,
392 delta_total: f64,
393 max_epsilon_per_round: f64,
394 max_delta_per_round: f64,
395 ) -> Result<Self, BudgetError> {
396 if max_epsilon_per_round <= 0.0 {
397 return Err(BudgetError::InvalidBudget {
398 reason: format!(
399 "max_epsilon_per_round must be positive, got {max_epsilon_per_round}"
400 ),
401 });
402 }
403 if max_delta_per_round <= 0.0 {
404 return Err(BudgetError::InvalidBudget {
405 reason: format!("max_delta_per_round must be positive, got {max_delta_per_round}"),
406 });
407 }
408 let budget = PrivacyBudget::new(epsilon_total, delta_total)?;
409 Ok(Self {
410 budget,
411 max_epsilon_per_round,
412 max_delta_per_round,
413 })
414 }
415
416 #[inline]
418 pub fn budget(&self) -> &PrivacyBudget {
419 &self.budget
420 }
421
422 #[inline]
424 pub fn max_epsilon_per_round(&self) -> f64 {
425 self.max_epsilon_per_round
426 }
427
428 #[inline]
430 pub fn max_delta_per_round(&self) -> f64 {
431 self.max_delta_per_round
432 }
433
434 pub fn begin_round(&self) -> Result<RoundGuard, BudgetError> {
443 let eps_rem = self.budget.epsilon_remaining();
444 if eps_rem < self.max_epsilon_per_round {
445 return Err(BudgetError::BudgetExceeded {
446 epsilon_needed: self.max_epsilon_per_round,
447 epsilon_remaining: eps_rem,
448 });
449 }
450 let delta_rem = self.budget.delta_remaining();
451 if delta_rem < self.max_delta_per_round {
452 return Err(BudgetError::DeltaExceeded {
453 delta_needed: self.max_delta_per_round,
454 delta_remaining: delta_rem,
455 });
456 }
457 let round_id = self.budget.increment_round();
458 Ok(RoundGuard { round_id })
459 }
460
461 pub fn commit_round(
468 &self,
469 _guard: RoundGuard,
470 epsilon_used: f64,
471 delta_used: f64,
472 ) -> Result<(), BudgetError> {
473 if epsilon_used > self.max_epsilon_per_round {
474 return Err(BudgetError::BudgetExceeded {
475 epsilon_needed: epsilon_used,
476 epsilon_remaining: self.max_epsilon_per_round,
477 });
478 }
479 if delta_used > self.max_delta_per_round {
480 return Err(BudgetError::DeltaExceeded {
481 delta_needed: delta_used,
482 delta_remaining: self.max_delta_per_round,
483 });
484 }
485 self.budget.consume(epsilon_used, delta_used)
486 }
487}
488
489#[cfg(test)]
492mod tests {
493 use super::*;
494
495 #[test]
497 fn test_valid_budget_construction() {
498 let b = PrivacyBudget::new(10.0, 1e-5).expect("test: should succeed");
499 assert_eq!(b.epsilon_total(), 10.0);
500 assert_eq!(b.delta_total(), 1e-5);
501 assert_eq!(b.epsilon_remaining(), 10.0);
502 assert_eq!(b.delta_remaining(), 1e-5);
503 assert!(!b.is_exhausted());
504 }
505
506 #[test]
508 fn test_invalid_budget_negative_epsilon() {
509 let err = PrivacyBudget::new(-1.0, 1e-5).unwrap_err();
510 assert!(matches!(err, BudgetError::InvalidBudget { .. }));
511 }
512
513 #[test]
515 fn test_invalid_budget_zero_epsilon() {
516 let err = PrivacyBudget::new(0.0, 1e-5).unwrap_err();
517 assert!(matches!(err, BudgetError::InvalidBudget { .. }));
518 }
519
520 #[test]
522 fn test_invalid_budget_delta_ge_one() {
523 let err = PrivacyBudget::new(10.0, 1.0).unwrap_err();
524 assert!(matches!(err, BudgetError::InvalidBudget { .. }));
525 let err2 = PrivacyBudget::new(10.0, 2.0).unwrap_err();
526 assert!(matches!(err2, BudgetError::InvalidBudget { .. }));
527 }
528
529 #[test]
531 fn test_invalid_budget_negative_delta() {
532 let err = PrivacyBudget::new(10.0, -1e-5).unwrap_err();
533 assert!(matches!(err, BudgetError::InvalidBudget { .. }));
534 }
535
536 #[test]
538 fn test_consume_within_budget() {
539 let b = PrivacyBudget::new(10.0, 1e-5).expect("test: should succeed");
540 b.consume(1.0, 1e-6).expect("test: should succeed");
541 let snap = b.snapshot();
542 assert!((snap.epsilon_spent - 1.0).abs() < 1e-12);
543 assert!((snap.delta_spent - 1e-6).abs() < 1e-18);
544 assert!((snap.epsilon_remaining - 9.0).abs() < 1e-12);
545 }
546
547 #[test]
549 fn test_consume_exceeds_epsilon() {
550 let b = PrivacyBudget::new(1.0, 1e-5).expect("test: should succeed");
551 let err = b.consume(2.0, 1e-6).unwrap_err();
552 assert!(matches!(err, BudgetError::BudgetExceeded { .. }));
553 assert_eq!(b.epsilon_remaining(), 1.0);
555 }
556
557 #[test]
559 fn test_consume_exceeds_delta() {
560 let b = PrivacyBudget::new(10.0, 1e-5).expect("test: should succeed");
561 let err = b.consume(1.0, 1.0).unwrap_err();
563 assert!(matches!(err, BudgetError::DeltaExceeded { .. }));
564 assert!((b.epsilon_remaining() - 10.0).abs() < 1e-10);
566 }
567
568 #[test]
570 fn test_is_exhausted_epsilon() {
571 let b = PrivacyBudget::new(1.0, 1e-5).expect("test: should succeed");
572 b.consume(1.0, 1e-6).expect("test: should succeed");
573 assert!(b.is_exhausted());
574 }
575
576 #[test]
578 fn test_snapshot_consistency() {
579 let b = PrivacyBudget::new(10.0, 1e-4).expect("test: should succeed");
580 b.consume(3.0, 2e-5).expect("test: should succeed");
581 let snap = b.snapshot();
582 assert!((snap.epsilon_spent + snap.epsilon_remaining - snap.epsilon_total).abs() < 1e-10);
583 assert!((snap.delta_spent + snap.delta_remaining - snap.delta_total).abs() < 1e-20);
584 assert_eq!(snap.is_exhausted, b.is_exhausted());
585 }
586
587 #[test]
589 fn test_renyi_accumulates() {
590 let mut acc = RenyiAccountant::new(10.0);
591 assert_eq!(acc.rdp_epsilon(), 0.0);
592 acc.record_gaussian_mechanism(1.1, 1.0, 0.01);
593 assert!(acc.rdp_epsilon() > 0.0);
594 let before = acc.rdp_epsilon();
595 acc.record_gaussian_mechanism(1.1, 1.0, 0.01);
596 assert!(acc.rdp_epsilon() > before);
597 assert_eq!(acc.rounds(), 2);
598 }
599
600 #[test]
602 fn test_renyi_to_dp_positive() {
603 let mut acc = RenyiAccountant::new(10.0);
604 acc.record_gaussian_mechanism(1.1, 1.0, 0.01);
605 let (eps, delta) = acc.to_dp(1e-5);
606 assert!(eps > 0.0, "epsilon must be positive, got {eps}");
607 assert!((delta - 1e-5).abs() < 1e-15);
608 }
609
610 #[test]
612 fn test_renyi_reset() {
613 let mut acc = RenyiAccountant::new(10.0);
614 acc.record_gaussian_mechanism(1.1, 1.0, 0.01);
615 acc.record_gaussian_mechanism(1.1, 1.0, 0.01);
616 assert!(acc.rdp_epsilon() > 0.0);
617 acc.reset();
618 assert_eq!(acc.rdp_epsilon(), 0.0);
619 assert_eq!(acc.rounds(), 0);
620 }
621
622 #[test]
624 fn test_per_round_budget_limit() {
625 let prb = PerRoundBudget::new(10.0, 1e-4, 1.0, 1e-5).expect("test: should succeed");
626 let guard = prb.begin_round().expect("test: should succeed");
628 let err = prb.commit_round(guard, 2.0, 1e-6).unwrap_err();
629 assert!(matches!(err, BudgetError::BudgetExceeded { .. }));
630 }
631
632 #[test]
634 fn test_per_round_cumulative_consumption() {
635 let prb = PerRoundBudget::new(3.0, 1e-4, 1.0, 1e-5).expect("test: should succeed");
636 let g1 = prb.begin_round().expect("test: should succeed");
638 prb.commit_round(g1, 1.0, 1e-6)
639 .expect("test: should succeed");
640 let g2 = prb.begin_round().expect("test: should succeed");
642 prb.commit_round(g2, 1.0, 1e-6)
643 .expect("test: should succeed");
644 let g3 = prb.begin_round().expect("test: should succeed");
646 prb.commit_round(g3, 1.0, 1e-6)
647 .expect("test: should succeed");
648 let snap = prb.budget().snapshot();
650 assert!((snap.epsilon_spent - 3.0).abs() < 1e-10);
651 assert!(snap.is_exhausted);
652 }
653
654 #[test]
656 fn test_begin_round_insufficient_budget() {
657 let prb = PerRoundBudget::new(1.5, 1e-4, 1.0, 1e-5).expect("test: should succeed");
658 let g = prb.begin_round().expect("test: should succeed");
659 prb.commit_round(g, 1.0, 1e-6)
660 .expect("test: should succeed");
661 let err = prb.begin_round().unwrap_err();
663 assert!(matches!(err, BudgetError::BudgetExceeded { .. }));
664 }
665
666 #[test]
668 fn test_snapshot_round_count() {
669 let prb = PerRoundBudget::new(10.0, 1e-4, 1.0, 1e-5).expect("test: should succeed");
670 let g1 = prb.begin_round().expect("test: should succeed");
671 assert_eq!(g1.round_id, 1);
672 prb.commit_round(g1, 0.5, 1e-6)
673 .expect("test: should succeed");
674 let g2 = prb.begin_round().expect("test: should succeed");
675 assert_eq!(g2.round_id, 2);
676 prb.commit_round(g2, 0.5, 1e-6)
677 .expect("test: should succeed");
678 let snap = prb.budget().snapshot();
679 assert_eq!(snap.round_count, 2);
680 }
681}