Skip to main content

ipfrs_tensorlogic/
privacy_budget.rs

1//! Differential privacy budget accounting for federated learning.
2//!
3//! This module provides:
4//! - [`PrivacyBudget`]: thread-safe epsilon/delta budget with atomic accounting
5//! - [`BudgetSnapshot`]: immutable point-in-time view of the budget
6//! - [`RenyiAccountant`]: Rényi DP composition tracker with Gaussian mechanism support
7//! - [`PerRoundBudget`]: per-round budget enforcement wrapping a [`PrivacyBudget`]
8//! - [`BudgetError`]: typed errors for all failure modes
9//!
10//! # Example
11//!
12//! ```rust
13//! use ipfrs_tensorlogic::privacy_budget::{PrivacyBudget, RenyiAccountant};
14//!
15//! // Create a total budget of ε=10.0, δ=1e-5
16//! let budget = PrivacyBudget::new(10.0, 1e-5).expect("example: should succeed in docs");
17//!
18//! // Spend some budget
19//! budget.consume(1.0, 1e-6).expect("example: should succeed in docs");
20//! assert!(!budget.is_exhausted());
21//!
22//! // Track Rényi DP composition
23//! let mut accountant = RenyiAccountant::new(10.0);
24//! accountant.record_gaussian_mechanism(1.1, 1.0, 0.01);
25//! let (eps, delta) = accountant.to_dp(1e-5);
26//! assert!(eps > 0.0);
27//! ```
28
29use std::sync::atomic::{AtomicU64, Ordering};
30use thiserror::Error;
31
32// ── BudgetError ────────────────────────────────────────────────────────────
33
34/// Errors produced by budget operations.
35#[derive(Debug, Error)]
36pub enum BudgetError {
37    /// Budget parameters were invalid at construction time.
38    #[error("invalid budget: {reason}")]
39    InvalidBudget { reason: String },
40
41    /// An operation would exceed the epsilon budget.
42    #[error("epsilon budget exceeded: need {epsilon_needed}, remaining {epsilon_remaining}")]
43    BudgetExceeded {
44        epsilon_needed: f64,
45        epsilon_remaining: f64,
46    },
47
48    /// An operation would exceed the delta budget.
49    #[error("delta budget exceeded: need {delta_needed}, remaining {delta_remaining}")]
50    DeltaExceeded {
51        delta_needed: f64,
52        delta_remaining: f64,
53    },
54}
55
56// ── BudgetSnapshot ─────────────────────────────────────────────────────────
57
58/// Immutable point-in-time snapshot of a [`PrivacyBudget`].
59#[derive(Debug, Clone)]
60pub struct BudgetSnapshot {
61    /// Total epsilon budget allocated.
62    pub epsilon_total: f64,
63    /// Total delta budget allocated.
64    pub delta_total: f64,
65    /// Epsilon consumed so far.
66    pub epsilon_spent: f64,
67    /// Delta consumed so far.
68    pub delta_spent: f64,
69    /// Epsilon remaining (= total − spent).
70    pub epsilon_remaining: f64,
71    /// Delta remaining (= total − spent).
72    pub delta_remaining: f64,
73    /// Number of rounds that have been committed.
74    pub round_count: u64,
75    /// Whether either epsilon or delta remaining is ≤ 0.
76    pub is_exhausted: bool,
77}
78
79// ── PrivacyBudget ──────────────────────────────────────────────────────────
80
81/// Thread-safe differential-privacy budget tracker.
82///
83/// Epsilon and delta consumption are tracked with `AtomicU64` using the
84/// `f64::to_bits` / `f64::from_bits` trick, so that the struct can be
85/// shared across threads without a `Mutex`.
86pub struct PrivacyBudget {
87    /// Hard upper bound on total epsilon expenditure.
88    epsilon_total: f64,
89    /// Hard upper bound on total delta expenditure.
90    delta_total: f64,
91    /// Atomically tracked epsilon spent (stored as `f64::to_bits`).
92    epsilon_spent: AtomicU64,
93    /// Atomically tracked delta spent (stored as `f64::to_bits`).
94    delta_spent: AtomicU64,
95    /// Number of committed rounds.
96    round_count: AtomicU64,
97}
98
99impl PrivacyBudget {
100    /// Create a new budget.
101    ///
102    /// # Errors
103    /// Returns [`BudgetError::InvalidBudget`] if:
104    /// - `epsilon_total` ≤ 0
105    /// - `delta_total` ≤ 0
106    /// - `delta_total` ≥ 1
107    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    /// Total epsilon budget (immutable).
133    #[inline]
134    pub fn epsilon_total(&self) -> f64 {
135        self.epsilon_total
136    }
137
138    /// Total delta budget (immutable).
139    #[inline]
140    pub fn delta_total(&self) -> f64 {
141        self.delta_total
142    }
143
144    /// Epsilon remaining (may be slightly negative under concurrent load).
145    #[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    /// Delta remaining.
152    #[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    /// Returns `true` if either epsilon or delta remaining is ≤ 0.
159    #[inline]
160    pub fn is_exhausted(&self) -> bool {
161        self.epsilon_remaining() <= 0.0 || self.delta_remaining() <= 0.0
162    }
163
164    /// Atomically consume `epsilon` and `delta` from the budget.
165    ///
166    /// Uses a spin-loop via `fetch_update` to ensure the combined check-then-add
167    /// is linearisable.  Both values are consumed together or not at all (epsilon
168    /// is rolled back on delta failure).
169    ///
170    /// # Errors
171    /// - [`BudgetError::BudgetExceeded`] if `epsilon` would push spent past the total.
172    /// - [`BudgetError::DeltaExceeded`] if `delta` would push spent past the total.
173    pub fn consume(&self, epsilon: f64, delta: f64) -> Result<(), BudgetError> {
174        // Atomic add for epsilon via fetch_update spin-loop.
175        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 // signal failure — do not update
182                    } 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        // Atomic add for delta via fetch_update spin-loop.
196        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            // Roll back the epsilon we already committed.
210            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(); // rollback is best-effort; error here would be a logic bug
216
217            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    /// Increment the round counter and return the new count.
228    pub fn increment_round(&self) -> u64 {
229        self.round_count.fetch_add(1, Ordering::SeqCst) + 1
230    }
231
232    /// Take an immutable snapshot of the current budget state.
233    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// ── RenyiAccountant ────────────────────────────────────────────────────────
266
267/// Rényi differential-privacy composition accountant.
268///
269/// Tracks accumulated Rényi DP epsilon across multiple mechanism applications
270/// and converts to (ε, δ)-DP via the standard Rényi-to-DP conversion.
271#[derive(Debug, Clone)]
272pub struct RenyiAccountant {
273    /// Rényi order α (must be > 1).
274    alpha: f64,
275    /// Accumulated Rényi epsilon.
276    rdp_epsilon: f64,
277    /// Number of mechanism applications recorded.
278    rounds: u64,
279}
280
281impl RenyiAccountant {
282    /// Create a new accountant with Rényi order `alpha` (default 10.0).
283    pub fn new(alpha: f64) -> Self {
284        Self {
285            alpha,
286            rdp_epsilon: 0.0,
287            rounds: 0,
288        }
289    }
290
291    /// Return the current Rényi order α.
292    #[inline]
293    pub fn alpha(&self) -> f64 {
294        self.alpha
295    }
296
297    /// Return the accumulated Rényi epsilon.
298    #[inline]
299    pub fn rdp_epsilon(&self) -> f64 {
300        self.rdp_epsilon
301    }
302
303    /// Number of mechanism applications recorded so far.
304    #[inline]
305    pub fn rounds(&self) -> u64 {
306        self.rounds
307    }
308
309    /// Record one application of the Gaussian mechanism.
310    ///
311    /// The Rényi epsilon for one step of the Gaussian mechanism with sensitivity
312    /// `sensitivity` and noise multiplier `noise_multiplier` at subsampling rate
313    /// `sample_rate` (not used in this simplified formula) is:
314    ///
315    /// ```text
316    /// rdp_ε += α / (2 · noise_multiplier² · sensitivity²)
317    /// ```
318    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    /// Convert accumulated Rényi DP to (ε, δ)-DP.
330    ///
331    /// Uses the simplified conversion:
332    /// ```text
333    /// ε = rdp_ε + √|ln(rdp_ε) / ln(δ)|
334    /// ```
335    ///
336    /// Returns `(epsilon, delta)`.
337    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    /// Reset the accountant to its initial state (keeps α).
344    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// ── RoundGuard ─────────────────────────────────────────────────────────────
357
358/// Token returned by [`PerRoundBudget::begin_round`].
359///
360/// Must be passed back to [`PerRoundBudget::commit_round`] to record actual
361/// consumption for the round.
362#[derive(Debug)]
363pub struct RoundGuard {
364    /// Monotonically increasing round identifier (1-based).
365    pub round_id: u64,
366}
367
368// ── PerRoundBudget ─────────────────────────────────────────────────────────
369
370/// Wraps a [`PrivacyBudget`] and enforces per-round epsilon/delta caps.
371///
372/// This prevents any single training round from consuming a disproportionate
373/// share of the total budget.
374#[derive(Debug)]
375pub struct PerRoundBudget {
376    /// Underlying total budget.
377    budget: PrivacyBudget,
378    /// Maximum epsilon that may be consumed in a single round.
379    max_epsilon_per_round: f64,
380    /// Maximum delta that may be consumed in a single round.
381    max_delta_per_round: f64,
382}
383
384impl PerRoundBudget {
385    /// Create a new per-round budget.
386    ///
387    /// # Errors
388    /// Returns [`BudgetError::InvalidBudget`] if the underlying [`PrivacyBudget`]
389    /// construction fails, or if per-round caps are non-positive.
390    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    /// Reference to the underlying total budget.
417    #[inline]
418    pub fn budget(&self) -> &PrivacyBudget {
419        &self.budget
420    }
421
422    /// Maximum epsilon allowed per round.
423    #[inline]
424    pub fn max_epsilon_per_round(&self) -> f64 {
425        self.max_epsilon_per_round
426    }
427
428    /// Maximum delta allowed per round.
429    #[inline]
430    pub fn max_delta_per_round(&self) -> f64 {
431        self.max_delta_per_round
432    }
433
434    /// Begin a new round.
435    ///
436    /// Checks that the total budget has at least `max_epsilon_per_round` and
437    /// `max_delta_per_round` remaining before issuing the guard.
438    ///
439    /// # Errors
440    /// - [`BudgetError::BudgetExceeded`] if epsilon remaining < per-round cap.
441    /// - [`BudgetError::DeltaExceeded`] if delta remaining < per-round cap.
442    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    /// Commit the round, consuming the actual `epsilon_used` / `delta_used`.
462    ///
463    /// # Errors
464    /// - [`BudgetError::BudgetExceeded`] if `epsilon_used` > `max_epsilon_per_round`.
465    /// - [`BudgetError::DeltaExceeded`] if `delta_used` > `max_delta_per_round`.
466    /// - Propagates errors from [`PrivacyBudget::consume`].
467    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// ── Tests ──────────────────────────────────────────────────────────────────
490
491#[cfg(test)]
492mod tests {
493    use super::*;
494
495    // 1. Valid budget construction
496    #[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    // 2. Invalid budget — negative epsilon
507    #[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    // 3. Invalid budget — zero epsilon
514    #[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    // 4. Invalid budget — delta >= 1
521    #[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    // 5. Invalid budget — negative delta
530    #[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    // 6. Consume within budget succeeds
537    #[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    // 7. Consume exceeding epsilon budget fails
548    #[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        // epsilon_spent should remain 0 (no partial write)
554        assert_eq!(b.epsilon_remaining(), 1.0);
555    }
556
557    // 8. Consume exceeding delta budget fails
558    #[test]
559    fn test_consume_exceeds_delta() {
560        let b = PrivacyBudget::new(10.0, 1e-5).expect("test: should succeed");
561        // epsilon fine, delta too large
562        let err = b.consume(1.0, 1.0).unwrap_err();
563        assert!(matches!(err, BudgetError::DeltaExceeded { .. }));
564        // epsilon rollback: spent should be 0
565        assert!((b.epsilon_remaining() - 10.0).abs() < 1e-10);
566    }
567
568    // 9. is_exhausted triggers after full epsilon consumption
569    #[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    // 10. Snapshot fields are consistent
577    #[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    // 11. RenyiAccountant accumulates rdp_epsilon
588    #[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    // 12. RenyiAccountant::to_dp returns positive values
601    #[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    // 13. RenyiAccountant::reset clears accumulated state
611    #[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    // 14. PerRoundBudget enforces per-round limit
623    #[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        // Trying to commit more than max_epsilon_per_round must fail
627        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    // 15. PerRoundBudget: multiple rounds consume cumulative budget
633    #[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        // Round 1
637        let g1 = prb.begin_round().expect("test: should succeed");
638        prb.commit_round(g1, 1.0, 1e-6)
639            .expect("test: should succeed");
640        // Round 2
641        let g2 = prb.begin_round().expect("test: should succeed");
642        prb.commit_round(g2, 1.0, 1e-6)
643            .expect("test: should succeed");
644        // Round 3
645        let g3 = prb.begin_round().expect("test: should succeed");
646        prb.commit_round(g3, 1.0, 1e-6)
647            .expect("test: should succeed");
648        // All epsilon consumed
649        let snap = prb.budget().snapshot();
650        assert!((snap.epsilon_spent - 3.0).abs() < 1e-10);
651        assert!(snap.is_exhausted);
652    }
653
654    // 16. begin_round fails when budget is too low for a full round
655    #[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        // Remaining epsilon (0.5) < max_epsilon_per_round (1.0)
662        let err = prb.begin_round().unwrap_err();
663        assert!(matches!(err, BudgetError::BudgetExceeded { .. }));
664    }
665
666    // 17. BudgetSnapshot round_count tracks rounds
667    #[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}