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 ε\varepsilon 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 εepoch=εreport×cap\varepsilon_\text{epoch} = \varepsilon_\text{report} \times \text{cap}, so this parameter is the guarantee. A cap of 5 at ε=1\varepsilon = 1 per value gives a defensible daily εepoch=5\varepsilon_\text{epoch} = 5; 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
Memoisation is what makes a realistic cadence affordable Two curves of a client's spent epsilon against the number of reports it sends in an epoch, on a logarithmic axis. Re-randomising every report grows without bound, reaching 10,000 after ten thousand reports. Memoising the randomised output and capping distinct values flattens at five, regardless of how often the client reports. Memoisation is what makes a realistic cadence affordable ε = 1.0 per distinct value, one-day epoch 1 10 100 1000 10000 0 5000 10000 reports sent in the epoch ε spent by the client re-randomising each report memoised, cap = 5 distinct
Without memoisation a device reporting every five minutes spends 288 ε a day. With it, the same device spends one ε per distinct cell and the cap does the rest.

Reference Implementation

python
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

python
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()
What repeated independent randomisation gives away A rising bar chart of an adversary's confidence in the true cell as the same value is independently randomised more times. One report leaves roughly a fifty percent posterior; sixteen independent randomisations push it above ninety-five percent, and beyond that the true value is effectively revealed. Memoisation prevents this by reusing one randomised output. What repeated independent randomisation gives away d = 16, ε = 2, same true cell reported repeatedly 0 50 100 % confidence 33 80 99 16× 99 64× 99 256×
Averaging is the attack memoisation exists to stop: 16 fresh randomisations of a home cell reveal it, whatever the per-report ε says.

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.
Why memoised outputs must not survive an epoch boundary A two-row comparison of epoch-rotation policies. Clearing memoised outputs at each boundary costs fresh budget in the new epoch but keeps releases unlinkable. Carrying them forward costs nothing after the first epoch and makes each device identifiable by its stable randomised outputs, which is a worse outcome than the budget it saves. Why memoised outputs must not survive an epoch boundary the cheaper option is the one that makes the device trackable per-epoch ε linkable across epochs? verdict Memos cleared at rotation cap × ε no recommended Memos carried across epochs 0 after the first yes — stable outputs trackable device
The cheap policy is the unsafe one. Clearing memos at rotation is what buys unlinkability, and paying fresh budget for it is the intended trade.

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.

Up one level: Local Differential Privacy for Mobile Clients · Section: Differential Privacy for Geospatial Data.