On-Device Budget Management for LDP Clients
Under local differential privacy the server cannot enforce the budget, because the loss is incurred the moment the handset randomises. Everything that keeps a per-report from compounding into an unbounded one — memoisation, a distinct-value cap, epoch rotation — is client code, running on a device you do not control, with no server-side check that can detect its absence. That makes the client ledger the highest-risk component in an LDP deployment and the one most often shipped as an afterthought. This guide implements it, under local differential privacy for mobile clients in Differential Privacy for Geospatial Data; the server-side counterpart is privacy budget management.
Parameter Configuration and Calibration
epoch— the window over which the cap applies. A day is the common choice because it aligns with the natural cycle of movement, and because a subject’s daily pattern is what most threat models protect. Longer epochs give stronger guarantees and stale data; shorter ones invite the averaging attack that memoisation exists to prevent.max_distinct_values— the cap. The per-epoch guarantee is , so this parameter is the guarantee. A cap of 5 at per value gives a defensible daily ; a cap of 50 does not.memo_store— persistent, per-epoch, and confidential. The memoised randomised outputs must survive app restarts within an epoch, or the client silently re-randomises and the guarantee degrades. They must also be readable only by the app: a memo table is a record of which cells the device visited, which is exactly the data LDP is protecting.epoch_rotation— deterministic and jitter-free. Rotate on a fixed schedule that does not depend on device activity. An epoch boundary triggered by “first report after midnight local time” leaks the time zone and the device’s wake pattern.
| Setting | Weak configuration | Defensible configuration |
|---|---|---|
| Epoch | per report | 24 h, fixed UTC boundary |
| Cap on distinct cells | none | 5–10 per epoch |
| Memoisation | in-memory only | persisted, encrypted, per-epoch |
| Behaviour at cap | stop sending | stop sending, on schedule, no signal |
| Per-epoch ε | unbounded | cap × per-value ε, logged |
Reference Implementation
from __future__ import annotations
import hashlib
import hmac
from dataclasses import dataclass, field
from typing import Callable, Mapping
class BudgetExhausted(RuntimeError):
"""Raised when the client has already reported its cap of distinct values."""
@dataclass
class ClientLedger:
"""The on-device budget: memoised outputs plus a distinct-value cap.
The ledger is authoritative — there is no server-side equivalent, because under
local differential privacy the loss happens before anything is transmitted.
"""
epsilon_per_value: float
max_distinct_values: int
epoch_id: str
device_key: bytes
_memo: dict[int, int] = field(default_factory=dict)
def spent(self) -> float:
"""Epoch privacy loss so far: one epsilon per DISTINCT value reported."""
return self.epsilon_per_value * len(self._memo)
def remaining(self) -> int:
return self.max_distinct_values - len(self._memo)
def rotate(self, new_epoch_id: str) -> None:
"""Start a new epoch. Memoised outputs must NOT survive the boundary.
Carrying memos across epochs would keep the guarantee intact but would make
the released value linkable across an unbounded horizon; dropping them costs
budget in the new epoch, which is the intended trade.
"""
if new_epoch_id == self.epoch_id:
return
self.epoch_id = new_epoch_id
self._memo.clear()
def report(self, cell: int, randomise: Callable[[int], int]) -> int:
"""Return the value to transmit for `cell`, memoising the randomisation.
Repeat reports of an already-seen cell return the SAME randomised output and
cost nothing: the adversary sees no new randomness, so no new information.
A previously unseen cell costs one unit of the cap.
"""
if cell in self._memo:
return self._memo[cell]
if self.remaining() <= 0:
raise BudgetExhausted(
f"epoch {self.epoch_id}: cap of {self.max_distinct_values} distinct "
"cells reached; the client must stop reporting until rotation"
)
value = randomise(cell)
self._memo[cell] = value
return value
def memo_digest(self) -> str:
"""A keyed digest of the memo table, for local integrity checks only.
Never transmit this: the digest is derived from the set of visited cells and
would be a linkable identifier across reports.
"""
payload = ",".join(f"{k}:{v}" for k, v in sorted(self._memo.items()))
return hmac.new(self.device_key, payload.encode(), hashlib.blake2b).hexdigest()
Validation Checkpoint
def _validate() -> None:
calls = {"n": 0}
def randomise(cell: int) -> int:
calls["n"] += 1
return (cell * 7919 + calls["n"]) % 64 # a stand-in for the real mechanism
ledger = ClientLedger(
epsilon_per_value=1.0, max_distinct_values=3,
epoch_id="2026-08-11", device_key=b"test-key",
)
# 1. Repeat reports of the same cell must be byte-identical and free.
first = ledger.report(12, randomise)
again = ledger.report(12, randomise)
assert first == again
assert calls["n"] == 1, "a memoised value must not be re-randomised"
assert ledger.spent() == 1.0
# 2. Distinct cells consume the cap.
ledger.report(30, randomise)
ledger.report(44, randomise)
assert ledger.remaining() == 0
assert ledger.spent() == 3.0
# 3. Reporting a new cell past the cap must raise, not silently randomise.
try:
ledger.report(51, randomise)
except BudgetExhausted:
pass
else:
raise AssertionError("the cap must be enforced, not advisory")
# 4. A memoised cell is still reportable after the cap — it costs nothing.
assert ledger.report(12, randomise) == first
# 5. Rotation clears memos and restores the cap.
ledger.rotate("2026-08-12")
assert ledger.remaining() == 3
fresh = ledger.report(12, randomise)
assert fresh != first or calls["n"] == 4, "a new epoch must re-randomise"
# 6. Rotating to the same epoch id must be a no-op, not a reset.
ledger.rotate("2026-08-12")
assert ledger.remaining() == 2
print("on-device LDP ledger: all assertions passed")
_validate()
Assertions 1 and 4 together define the property that makes a realistic reporting cadence possible: the client may report as often as it likes, but only distinct locations cost budget. Assertion 3 is the one that must never be relaxed under product pressure — an advisory cap is not a cap.
Incident Response and Edge Cases
- The device hits the cap by mid-morning. Its movement pattern is more varied than the cap assumed. Do not raise the cap silently; either accept reduced coverage for such devices, or reduce the reporting resolution so that a day’s movement spans fewer distinct cells. Coarsening the domain is almost always the better trade, because it improves the estimator’s variance too.
- The app is reinstalled and the memo table is lost. The client re-randomises previously reported cells, and the guarantee for those values is now the composition of two draws. Treat reinstall as an epoch boundary and accept the loss, but track the reinstall rate — a high rate means the effective epoch is much shorter than the configured one.
- Clock skew moves the epoch boundary. Devices rotating at different times is harmless; a device rotating twice in one real day because its clock jumped is not. Anchor rotation to a monotonic counter plus a server-provided epoch id, and refuse to rotate backwards.
- The memo store is readable by other apps. Then the visited-cell set is exposed to any process on the device, which is a worse disclosure than the one LDP prevents. Use the platform keystore, and never write the memo table to shared storage or to a crash report.
- Product asks to report continuously “since it is already private”. The mechanism protects a value, not a schedule. Continuous reporting of memoised values is genuinely free in budget terms, but the timing of reports still discloses activity. Batch on a fixed cadence regardless of movement.
Treat the ledger module as a security-critical component in its own right: small, dependency-free, separately versioned, and covered by the property tests above rather than by integration tests that happen to exercise it. It is the only place in a local-differential-privacy deployment where the guarantee is actually enforced.
Frequently Asked Questions
Why can't the server enforce the LDP budget?
Because the privacy loss occurs when the device randomises, which is before anything is transmitted. The server sees only perturbed values and cannot tell whether they were freshly randomised or memoised, nor how many distinct values a device has reported. Enforcement is client-side or it does not exist.
What happens when a client reaches its cap?
It stops sending new distinct values until the epoch rotates. It should continue its normal transmission schedule with memoised values, because a device that goes silent at the moment it enters an unusual area has signalled exactly the thing the mechanism was hiding.
Should memoised outputs survive an epoch boundary?
No. Carrying them forward keeps the per-value guarantee but makes the released value linkable indefinitely, so a device becomes trackable by its stable randomised outputs. Clearing at the boundary costs fresh budget in the new epoch, which is the intended price of unlinkability.
How do I audit that the client actually does all this?
Keep the randomiser and ledger in a small, separately versioned module; publish its source or a reproducible build; add platform attestation where available; and test the properties above in CI. None of this is proof against a modified client, and the honest framing is that LDP protects against a curious server, not a compromised device.
Related
- Local Differential Privacy for Mobile Clients — the parent design.
- Randomized Response for Grid-Cell Reporting — the randomiser this ledger wraps.
- Privacy Budget Management — the server-side ledger and its accounting.
- Privacy Budget Exhaustion — Detection and Recovery — the same failure, handled centrally.
Up one level: Local Differential Privacy for Mobile Clients · Section: Differential Privacy for Geospatial Data.