Privacy Budget Management
A production spatial-analytics platform serving hundreds of tenants emits thousands of differentially private releases a day — heatmaps, choropleth counts, trajectory statistics — and every one of them leaks a sliver of information about the people underneath the pixels. Privacy budget management is the operational discipline that keeps that cumulative leakage bounded: a live ledger that debits an cost from a per-subject, per-tenant, per-query-type account on each release, composes those costs correctly across the whole pipeline, and refuses the release that would push an account past its cap. Where the differential privacy for geospatial data mechanisms decide how much noise a single query gets, the budget ledger is their operational counterpart — it decides whether the query runs at all, and it is the component auditors ask about first.
This guide sits in the Core Fundamentals & Architecture for Spatial Privacy reference. It covers running the ledger as a service: choosing the privacy unit and ledger keys, standing up a Rényi differential privacy (RDP) accountant, debiting each release and converting the accumulated RDP curve to a single figure, enforcing hard caps with pre-emptive alerting, and re-allocating budget when spatial re-scoring reclassifies a dataset. What happens after an account hits zero — degradation, coarsening, and cohort rotation — is the separate concern of the child guide on privacy budget exhaustion — detection and recovery; this page keeps the meter honest so exhaustion is the rare, orderly event it should be.
Key concept. The privacy budget is not a per-query knob; it is a cumulative quantity that only ever grows within a window. Correct accounting composes the Rényi divergence of every release additively, converts the accumulated curve to exactly once, and enforces the cap on the prospective spend — the value the release would reach — never after the fact.
Prerequisites and Accounting Environment
The ledger is arithmetic, not cryptography, so the reference implementation needs only Python’s standard library plus numpy for vectorising the RDP curve over a grid of Rényi orders. Everything below runs against real DP releases produced by the spatial noise mechanisms — Gaussian and Laplace primarily — so the ledger consumes a mechanism specification (a noise multiplier or an ), not raw data. Three environmental invariants must hold before the meter can be trusted:
- A declared privacy unit. Composition bounds mean nothing until you fix what neighbouring datasets differ by: one event, one data subject over a window, or one tenant. The unit determines both the per-release sensitivity and the account the debit lands in, and it is the single most common source of a ledger that under-counts leakage.
- RDP as the accumulation currency. Basic and advanced composition on pairs are convenient to explain but loose to accumulate. Real accounting composes in Rényi space — where composition is exact addition — and converts to only at read time. We track the RDP curve on a fixed grid of orders , exactly as production accountants (TensorFlow Privacy, Opacus) do.
- A cap wired to a concrete control. The ceiling is not a taste decision. It descends from the compliance framework mapping — GDPR Article 25 data-minimisation expressed as a maximum cumulative per data subject, for instance — and from the risk tier assigned by the spatial sensitivity scoring models. A dense urban catchment where one nightly dwell point is highly identifying warrants a tighter cap than a sparse rural fleet.
Throughout, is fixed at the release target (commonly or smaller than the reciprocal of the population) and is the quantity the ledger tracks and bounds.
Step 1: Define the Privacy Unit and Ledger Keys
The ledger key is the compound identity an cost is charged against. Charging everything to one global account is safe but useless — it exhausts in minutes and blocks the platform. Charging per query with no memory is the classic composition bug: an adversary issues many overlapping spatial queries, differences the answers, and peels the noise straight back off. The defensible middle is a compound key across four axes — data subject, tenant, query type, and temporal window — so that the meter isolates the leakage that actually composes against one person while letting independent subjects and independent epochs spend independently.
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class LedgerKey:
"""Compound identity a privacy cost is debited against.
`subject` is the privacy unit — a data-subject id for user-level DP, or a
tenant id when the guarantee is at the organisation boundary. `query_type`
separates release families whose sensitivity differs (a snapshot heatmap
vs. a trajectory statistic). `window` scopes composition to one epoch so
budget can be released on a schedule instead of monotonically depleting.
"""
subject: str # e.g. "subject:9f2a" or "tenant:acme"
query_type: str # e.g. "heatmap" | "trajectory" | "aggregate"
window: str # e.g. ISO week "2026-W28"
def key_for(subject_id: str, query_type: str, window: str) -> LedgerKey:
"""Normalise inputs so equivalent releases collapse onto one account.
Casing and whitespace differences must never fork a subject into two
accounts — that would silently halve the effective composition bound.
"""
return LedgerKey(
subject=subject_id.strip().lower(),
query_type=query_type.strip().lower(),
window=window.strip(),
)
The choice of subject is the privacy guarantee. If the unit is the data subject, neighbouring datasets differ by all of one person’s contributions and the cap bounds what any single individual can leak across every release combined. If the unit is the tenant, the guarantee protects the organisation but says nothing about individuals within it — appropriate for B2B mobility aggregates, dangerous for patient data. Fixing this before the first debit is non-negotiable; the differential privacy for geospatial data section covers how the unit interacts with sensitivity, and it is the assumption every number below inherits.
Step 2: Instantiate an RDP Accountant
A mechanism is -RDP if the Rényi divergence of order between its outputs on neighbouring datasets is at most . Two facts make RDP the right accumulation currency. First, composition is addition: releasing mechanisms yields -RDP at every order. Second, the common spatial mechanisms have closed-form RDP curves — the Gaussian mechanism with noise multiplier (the ratio of noise standard deviation to sensitivity) is -RDP for all , which is equivalent to -zCDP with . We track the accumulated curve on a fixed grid of orders and let each mechanism contribute its own curve.
import math
from dataclasses import dataclass
from typing import Tuple
import numpy as np
# Standard accounting grid: fractional low orders sharpen the tight-delta
# regime, integer orders cover the bulk, a few large orders catch high delta.
DEFAULT_ORDERS: np.ndarray = np.concatenate([
np.array([1.25, 1.5, 1.75]),
np.arange(2.0, 64.0),
np.array([128.0, 256.0]),
])
@dataclass(frozen=True)
class GaussianMechanism:
"""Gaussian release. noise_multiplier = noise_std / L2-sensitivity."""
noise_multiplier: float
def rdp(self, orders: np.ndarray) -> np.ndarray:
if self.noise_multiplier <= 0:
raise ValueError("noise_multiplier must be positive")
# (alpha, alpha / (2 sigma^2))-RDP <=> rho-zCDP with rho = 1/(2 sigma^2)
return orders / (2.0 * self.noise_multiplier ** 2)
@dataclass(frozen=True)
class PureDPMechanism:
"""Pure eps0-DP release (e.g. geometric counts) via eps0-DP => (eps0^2/2)-zCDP."""
epsilon0: float
def rdp(self, orders: np.ndarray) -> np.ndarray:
return (self.epsilon0 ** 2 / 2.0) * orders
@dataclass(frozen=True)
class LaplaceMechanism:
"""Exact Renyi divergence of the Laplace mechanism (Mironov 2017).
epsilon0 = sensitivity / scale. Tighter than the pure-DP zCDP bound above,
which matters when a spatial release leans on many Laplace count queries.
"""
epsilon0: float
def rdp(self, orders: np.ndarray) -> np.ndarray:
a, e = orders, self.epsilon0
term = ((a / (2 * a - 1)) * np.exp((a - 1) * e)
+ ((a - 1) / (2 * a - 1)) * np.exp(-a * e))
return np.log(term) / (a - 1.0)
class RDPAccountant:
"""Accumulates an RDP curve additively and converts to (eps, delta)."""
def __init__(self, orders: np.ndarray = DEFAULT_ORDERS) -> None:
self.orders = np.asarray(orders, dtype=float)
self._rdp = np.zeros_like(self.orders)
def compose(self, mechanism, count: int = 1) -> None:
"""Debit `count` identical releases — pure additive composition."""
self._rdp = self._rdp + count * mechanism.rdp(self.orders)
@property
def rdp(self) -> np.ndarray:
return self._rdp.copy()
def get_epsilon(self, delta: float) -> Tuple[float, float]:
"""Convert the accumulated curve to (eps, best_order) at a fixed delta.
Mironov's RDP->DP bound: eps = rdp(alpha) + ln(1/delta)/(alpha - 1),
minimised over the order grid. Reporting the minimising order is a
useful audit signal — it should track the tightness of the release.
"""
if not (0.0 < delta < 1.0):
raise ValueError("delta must be in (0, 1)")
conv = self._rdp + math.log(1.0 / delta) / (self.orders - 1.0)
idx = int(np.argmin(conv))
return float(conv[idx]), float(self.orders[idx])
The three mechanism classes make the accounting currency-agnostic: a spatial dashboard that mixes a Gaussian heatmap with a handful of Laplace count queries composes them on the same grid, and the get_epsilon conversion happens once over the summed curve rather than being pessimistically re-applied per query. This is exactly why RDP beats naïve bookkeeping — the conversion loss is paid a single time, not times.
Step 3: Debit a Release and Convert RDP to Epsilon-Delta
The ledger wraps one RDPAccountant per key and exposes the operational surface: spend() to debit a release, remaining() to report headroom, and a prospective-spend check so the cap is enforced before the debit is ever committed. Converting the accumulated curve to is where the composition story pays off: because the whole history lives as one RDP curve, the current spend for a key is a single get_epsilon call, no matter how many releases it aggregates.
from dataclasses import dataclass, field
from typing import Dict, List, Tuple
@dataclass
class BudgetCap:
epsilon: float # hard ceiling on cumulative eps per key
delta: float # fixed conversion delta (e.g. 1e-5)
warn_fraction: float = 0.8 # emit a warning once this fraction is reached
@dataclass
class SpendReceipt:
key: LedgerKey
epsilon: float # cumulative eps for the key AFTER this debit
remaining: float # cap.epsilon - epsilon
fraction: float # epsilon / cap.epsilon
warned: bool # crossed warn_fraction on this release
class BudgetExhausted(RuntimeError):
"""Raised when a release would push a key past its cap. Fail closed."""
class PrivacyBudgetLedger:
"""A live cumulative-epsilon ledger keyed by (subject, query_type, window)."""
def __init__(self, cap: BudgetCap,
orders: np.ndarray = DEFAULT_ORDERS) -> None:
self.cap = cap
self.orders = np.asarray(orders, dtype=float)
self._acc: Dict[LedgerKey, RDPAccountant] = {}
self.history: List[Tuple[LedgerKey, np.ndarray]] = []
self.warnings: List[SpendReceipt] = []
def _accountant(self, key: LedgerKey) -> RDPAccountant:
if key not in self._acc:
self._acc[key] = RDPAccountant(self.orders)
return self._acc[key]
def epsilon_of(self, key: LedgerKey) -> float:
"""Cumulative eps for a key at the configured delta (0 if untouched)."""
if key not in self._acc:
return 0.0
return self._acc[key].get_epsilon(self.cap.delta)[0]
def remaining(self, key: LedgerKey) -> float:
"""Headroom before the cap. Clamped at 0 — never report negative budget."""
return max(0.0, self.cap.epsilon - self.epsilon_of(key))
def _prospective_epsilon(self, key: LedgerKey, mechanism,
count: int) -> float:
"""The eps this key WOULD reach if the release were committed."""
trial = RDPAccountant(self.orders)
if key in self._acc:
trial._rdp = self._acc[key].rdp # copy of the committed curve
trial.compose(mechanism, count)
return trial.get_epsilon(self.cap.delta)[0]
epsilon_of reads the meter; remaining reports headroom clamped at zero so a downstream planner never sees a negative budget and mis-schedules a release. The _prospective_epsilon helper is the linchpin of correct enforcement — it computes the post-release figure on a copy of the committed curve, so a rejected release leaves the account untouched. That separation is what lets the next step enforce the cap without ever committing a debit it then has to unwind.
Step 4: Enforce Caps and Emit Pre-Exhaustion Warnings
A cap enforced after a debit is a cap that has already been breached. The spend() method therefore evaluates the prospective spend first and raises BudgetExhausted before touching the accountant — failing closed, exactly as the parent architecture requires: an unavailable answer is acceptable, an unprotected answer is a breach. Warnings fire at a configurable fraction of the cap (warn_fraction, defaulting to 0.8) so operators can coarsen resolution or provision a fresh cohort before the meter blocks, rather than reacting to a refused query.
def would_exceed(self, key: LedgerKey, mechanism, count: int = 1) -> bool:
"""True if committing this release would breach the cap."""
return self._prospective_epsilon(key, mechanism, count) \
> self.cap.epsilon + 1e-12
def spend(self, key: LedgerKey, mechanism, count: int = 1) -> SpendReceipt:
"""Debit a release, enforcing the cap pre-emptively.
The prospective check runs BEFORE the accountant is mutated, so a
refused release never corrupts the committed curve. Only after the
cap clears is the RDP debit made permanent.
"""
prospective = self._prospective_epsilon(key, mechanism, count)
if prospective > self.cap.epsilon + 1e-12:
raise BudgetExhausted(
f"release on {key} would spend eps={prospective:.4f} "
f"> cap {self.cap.epsilon:.4f}")
acc = self._accountant(key)
acc.compose(mechanism, count) # commit the debit
self.history.append((key, mechanism.rdp(self.orders) * count))
eps = acc.get_epsilon(self.cap.delta)[0]
fraction = eps / self.cap.epsilon
warned = fraction >= self.cap.warn_fraction
receipt = SpendReceipt(key, eps, self.cap.epsilon - eps,
fraction, warned)
if warned:
self.warnings.append(receipt) # pre-exhaustion alert
return receipt
A driver that spends until the meter blocks shows the intended behaviour — steady debits, a warning as the account crosses 80%, then a hard refusal:
def drive_until_blocked() -> None:
ledger = PrivacyBudgetLedger(BudgetCap(epsilon=3.0, delta=1e-5))
key = key_for("subject:9f2a", "heatmap", "2026-W28")
release = GaussianMechanism(noise_multiplier=4.0)
while not ledger.would_exceed(key, release):
r = ledger.spend(key, release)
flag = " <-- WARN" if r.warned else ""
print(f"eps={r.epsilon:5.3f} remaining={r.remaining:5.3f}{flag}")
print("cap reached; further releases refused")
if __name__ == "__main__":
drive_until_blocked()
The warning fraction is where operational alerting hooks in: push each SpendReceipt with warned=True to the on-call channel, and page when remaining() for a high-traffic tenant falls below the cost of a single scheduled release. Detecting and recovering from the exhaustion that follows — degradation, coarsening, cohort rotation — is the province of the child guide on privacy budget exhaustion — detection and recovery.
Step 5: Handle Window and Epoch Resets
A monotonic ledger is a ledger that eventually blocks everything. Real deployments release budget on a schedule — a fresh allowance per temporal window (an ISO week, a billing month) — which is sound only because the window axis is baked into the key: releases in different epochs are charged to different accounts, so resetting one epoch cannot retroactively unprotect another. The reset is a targeted drop of every key in the closed window; it must never touch a live epoch.
The subtler case is spatial re-scoring. A dataset first classified as a snapshot (a single-pass heatmap) becomes a trajectory the moment its temporal frequency rises, and a trajectory is categorically harder to protect — its per-release sensitivity is larger, so the same nominal query costs more . When the sensitivity scoring re-tiers a subject mid-window, the budget already spent under the snapshot key must carry to the trajectory key rather than resetting the meter, or re-classification becomes a free budget refill an adversary could trigger deliberately.
def reset(self, window: str) -> int:
"""Release budget for one closed epoch. Returns the number of keys freed.
Only keys whose `window` matches are dropped; every other epoch's
cumulative spend is preserved, so a reset can never unprotect a subject
in a still-open window.
"""
drop = [k for k in self._acc if k.window == window]
for k in drop:
del self._acc[k]
return len(drop)
def rescope(self, old: LedgerKey, new: LedgerKey) -> None:
"""Carry spend when spatial re-scoring reclassifies a release family.
A snapshot (heatmap) that becomes a trajectory must not reset its meter.
The already-committed RDP curve is transferred onto the new key, whose
future releases will be debited at the higher trajectory sensitivity.
"""
acc = self._acc.pop(old, None)
if acc is not None:
target = self._accountant(new)
target._rdp = target.rdp + acc.rdp # accumulated leakage carries over
rescope transfers the accumulated RDP curve wholesale, so the subject’s cumulative is unchanged at the instant of re-classification; only the cost of subsequent releases rises, because the new query_type carries the trajectory mechanism’s larger sensitivity. This is the budget re-allocation the architecture calls for when a tile’s temporal frequency crosses the snapshot-to-trajectory boundary — the same re-scoring trigger described in the spatial sensitivity scoring models.
Step 6: Validate the Composition Math
The one failure mode that voids the entire guarantee is composition that silently under-counts. The harness below asserts the invariants that must hold in CI: RDP composes additively, the grid conversion is tight to — and never beats — the closed-form zCDP bound, RDP accounting is never looser than basic composition, the cap is enforced monotonically with a warning before it blocks, and re-scoring carries spend rather than resetting it.
def zcdp_to_dp(rho: float, delta: float) -> float:
"""Closed-form zCDP -> (eps, delta): eps = rho + 2*sqrt(rho*ln(1/delta))."""
return rho + 2.0 * math.sqrt(rho * math.log(1.0 / delta))
def basic_composition(eps: float, k: int) -> float:
"""Naive linear composition of k identical (eps, delta) releases."""
return k * eps
def advanced_composition(eps: float, delta_prime: float, k: int) -> float:
"""Dwork-Rothblum-Vadhan advanced composition (for the eps' comparison)."""
return (math.sqrt(2 * k * math.log(1.0 / delta_prime)) * eps
+ k * eps * (math.exp(eps) - 1.0))
def _validate() -> None:
orders, delta = DEFAULT_ORDERS, 1e-5
# (a) RDP composition is exact addition.
single = GaussianMechanism(noise_multiplier=4.0)
acc = RDPAccountant(orders)
k = 20
for _ in range(k):
acc.compose(single)
assert np.allclose(acc.rdp, k * single.rdp(orders)), "RDP must add up"
# (b) Grid conversion is tight to, and never beats, the zCDP closed form.
rho = 1.0 / (2.0 * single.noise_multiplier ** 2) # per-release zCDP
eps_grid, _ = acc.get_epsilon(delta)
eps_zcdp = zcdp_to_dp(k * rho, delta)
assert eps_grid + 1e-9 >= eps_zcdp, "grid cannot beat the continuous optimum"
assert eps_grid - eps_zcdp < 0.10 * eps_zcdp, "grid must stay tight"
# (c) RDP accounting is never looser than basic composition.
eps_single = zcdp_to_dp(rho, delta)
assert eps_grid <= basic_composition(eps_single, k) + 1e-9
# Advanced composition is tighter than basic only when per-release eps is
# small; here eps_single is large, so basic is the tighter (eps, delta) bound.
assert basic_composition(eps_single, k) <= advanced_composition(
eps_single, delta, k)
# (d) Exact Laplace RDP is finite and positive across the grid.
lap = LaplaceMechanism(epsilon0=0.5)
assert np.all(np.isfinite(lap.rdp(orders))) and np.all(lap.rdp(orders) > 0)
# (e) The ledger enforces the cap monotonically and warns before blocking.
ledger = PrivacyBudgetLedger(BudgetCap(epsilon=3.0, delta=delta,
warn_fraction=0.8))
key = key_for("subject:9f2a", "heatmap", "2026-W28")
last, warned_once = 0.0, False
while not ledger.would_exceed(key, single):
r = ledger.spend(key, single)
assert r.epsilon >= last - 1e-12, "cumulative eps must be non-decreasing"
last, warned_once = r.epsilon, warned_once or r.warned
assert ledger.epsilon_of(key) <= 3.0 + 1e-9, "cap must never be breached"
assert warned_once, "must warn before the cap blocks"
# (f) A release past the cap is refused, not silently allowed.
try:
ledger.spend(key, single)
except BudgetExhausted:
pass
else:
raise AssertionError("exhausted budget must raise")
# (g) Re-scoring carries spend; a window reset frees only that epoch.
l2 = PrivacyBudgetLedger(BudgetCap(epsilon=5.0, delta=delta))
snap = key_for("subject:c71b", "heatmap", "2026-W28")
l2.spend(snap, single)
spent = l2.epsilon_of(snap)
traj = key_for("subject:c71b", "trajectory", "2026-W28")
l2.rescope(snap, traj)
assert abs(l2.epsilon_of(traj) - spent) < 1e-9, "re-scoring must carry spend"
assert l2.epsilon_of(snap) == 0.0
assert l2.reset("2026-W28") == 1 and l2.epsilon_of(traj) == 0.0
print("all privacy-budget-ledger invariants hold")
if __name__ == "__main__":
_validate()
Invariant (b) is the one worth internalising: the discrete order grid can only ever report an at or above the true continuous optimum, so a grid conversion that comes in below the closed-form zCDP figure is a bug in the accountant, not a tighter bound. Invariant © quantifies why the whole exercise is worth it — for these parameters RDP composition reports a cumulative several times smaller than naïve linear composition of the same releases, which is the difference between a ledger that lasts a week and one that blocks by lunchtime.
Threat Model Considerations
The budget ledger closes an information-theoretic channel — composition leakage — so its threats are about tricking the accountant rather than breaking cryptography:
- Composition / query-correlation attacks. The canonical DP attack: issue many overlapping spatial queries, difference the answers, and average away the noise. Correct per-subject accounting is the only defence — if two correlated releases are charged to separate accounts (because a key was mis-normalised, or the privacy unit was set to event rather than subject), the attack succeeds even though every individual query was “properly” noised.
- Budget-timing / side-channel probing. An adversary who can observe whether a query is refused learns the account is near its cap, which itself leaks information about how much a subject has been queried. Return a uniform “unavailable” response for both cap-blocks and genuine errors, and avoid response-latency differences between the prospective-check path and the debit path.
- Re-scoring reset abuse. If re-classification reset the meter, an adversary who can influence temporal frequency (spoofing extra pings to tip a snapshot into a trajectory) could trigger free budget refills. The
rescopecarry-over closes this — spend is monotonic across a re-tier. - Account-splitting via key forks. Casing, whitespace, or transliteration differences that fork one subject into two accounts halve the effective composition bound. Canonicalise keys at the boundary (as
key_fordoes) and treat a sudden rise in distinct-subject cardinality as an alert. - Delta shopping. Reporting at an adversary-chosen understates the real spend. Fix to the release target up front and never let a caller vary it per query.
- Windowing that outpaces correlation. Resetting the window faster than the real-world correlation horizon (resetting weekly when a subject’s home is stable across months) lets cumulative leakage exceed the nominal cap over the correlated period. Size the window to the sensitivity horizon, not to a convenient billing cycle.
Validation & Compliance Checklist
Wire each control into CI and the compliance dashboard with a measurable pass criterion rather than a code review:
- Additive composition — PASS if composing identical releases equals the single-release RDP curve elementwise, for a -iteration property test. This is the core correctness invariant; fail the build on any deviation.
- Conversion soundness — PASS if
get_epsilonnever returns a value below the closed-form zCDP bound for a pure Gaussian sequence, and stays within 10% of it. A conversion that “beats” the continuous optimum is under-counting. - Cap enforcement — PASS if no key’s cumulative ever exceeds
cap.epsilon, and every over-cap release raisesBudgetExhaustedrather than returning a result. Fail closed, always. - Pre-exhaustion alerting — PASS if a
warned=Truereceipt is emitted at or beforewarn_fractionof the cap on every account that reaches it, and the warning precedes the first block. - Monotonic re-scoring — PASS if
rescopeleaves cumulative unchanged at the instant of re-classification and raises the per-release cost thereafter. Assert that no re-tier ever lowers a subject’s spend. - Window isolation — PASS if
reset(window)frees exactly the keys in that epoch and leaves every other window’s spend intact. Tie the cap value and window length to a clause in the compliance framework mapping so each is defensible in an audit.
The _validate() harness in Step 6 exercises invariants 1–6 directly and is safe to run in CI.
Failure Modes & Remediation
Budget accounting fails quietly — it does not crash, it under-counts, and the leak surfaces only when someone re-identifies a subject. The high-frequency production failures:
- Per-query accounting instead of cumulative. Each release is individually noised but nothing tracks the running total, so composition leakage is unbounded. Detection: assert every release path routes through
spend()and that no code path produces a DP release without a debit. Recovery: gate the release API behind the ledger so an un-debited release is structurally impossible. - Wrong privacy unit. The unit is set to event-level while the guarantee is claimed at subject-level; the cap bounds far less than advertised. Detection: reconcile the claimed guarantee in the compliance mapping against the
subjectaxis actually used. Recovery: re-key to the correct unit and recompute historical spend before issuing further releases. - Naïve composition. Summing per release blocks the platform far too early and tempts operators to raise the cap to compensate — inflating real risk. Detection: compare the deployed accountant’s cumulative against the RDP harness for the same release sequence. Recovery: switch accumulation to RDP and convert once at read time.
- Silent drift. A caller varies per query and the reported no longer reflects the real spend. Detection: assert a single fixed across all
get_epsiloncalls. Recovery: pin to the release target inBudgetCapand reject per-call overrides. - Re-scoring that resets the meter. A snapshot-to-trajectory re-tier drops the old account, refilling budget. Detection: the invariant-5 CI check. Recovery: route every re-classification through
rescopeso spend carries. - Cap breach masked by a race. Two concurrent releases both pass the prospective check, then both commit, jointly overshooting the cap. Detection: reconcile committed against the cap after each debit. Recovery: serialise
spend()per key (a per-key lock or single-writer actor) so the prospective check and the commit are atomic.
Frequently Asked Questions
Why compose in Rényi space instead of adding up epsilon per query?
Because RDP composition is exact addition of the Rényi curve, and the lossy conversion to a final (epsilon, delta) is paid exactly once over the summed curve rather than once per release. Adding (epsilon, delta) pairs directly (basic composition) is correct but drastically loose — for a sequence of Gaussian releases it can report a cumulative epsilon several times larger than the RDP bound, which blocks the platform far sooner than the true privacy loss requires.
What is the right ledger key for a multi-tenant spatial platform?
A compound key over data subject, tenant, query type, and temporal window. The subject axis is the privacy unit and determines the guarantee; the tenant axis isolates one customer's spend from another's; the query-type axis separates release families whose sensitivity differs (a snapshot heatmap versus a trajectory statistic); and the window axis scopes composition to an epoch so budget can be released on a schedule. Charging everything to one global account exhausts immediately, and charging per query with no memory reopens composition attacks.
How should the cap change when a snapshot dataset becomes a trajectory?
The cap itself does not change, but the per-release cost does. A trajectory has larger sensitivity than a snapshot, so the same nominal query debits more epsilon. Critically, the budget already spent under the snapshot key must carry over to the trajectory key rather than resetting — otherwise re-classification becomes a free budget refill an adversary could trigger by inflating temporal frequency. Transfer the accumulated RDP curve on re-scoring and let only future releases cost more.
When should the ledger warn versus block?
Warn at a configurable fraction of the cap — 0.8 is a sensible default — so operators can coarsen release resolution or provision a fresh cohort before the meter blocks. Block, by raising an exhaustion error, only when the prospective spend of a release would exceed the cap, and enforce that check before the debit is committed so a refused release never corrupts the account. The block must fail closed: an unavailable answer is acceptable, an unprotected one is a breach.
Does clamping or post-processing a released value cost extra budget?
No. Any function applied to a differentially private release without re-touching the raw data is post-processing and consumes zero additional budget — clamping negative counts to zero, smoothing a heatmap, or rounding coordinates are all free. Budget is debited only when the raw data is queried through a mechanism. This is why the ledger charges at the mechanism call and never at downstream transforms.
Related
This guide is part of the Core Fundamentals & Architecture for Spatial Privacy reference — start there for how scoring, routing, budgeting, and audit fit into one pipeline.
- Privacy Budget Exhaustion — Detection and Recovery — the child guide on what to do once an account hits its cap: degradation, coarsening, and cohort rotation.
- Differential Privacy for Geospatial Data — the mechanisms whose costs this ledger accounts for.
- Spatial Noise Mechanisms — how the Gaussian and Laplace releases the ledger debits are calibrated.
- Compliance Framework Mapping — where the cap and window length come from.
- Spatial Sensitivity Scoring Models — the risk tier that sets the cap and triggers snapshot-to-trajectory re-scoring.