w-Event Privacy for Streaming Trajectories

A vehicle emitting a location fix every second never stops producing data, so the finite privacy budget that protects a single snapshot is exhausted almost immediately if you spend a fixed ε\varepsilon at every timestamp. w-event privacy solves the infinite-stream problem with one structural rule: guarantee ε\varepsilon-differential privacy over any sliding window of w consecutive timestamps rather than over the whole unbounded trace. This page is the budget-allocation deep-dive inside the parent guide on differential privacy for spatial trajectories, which lives under the Differential Privacy for Geospatial Data section. The concern here is narrow and mechanical: how a scheduler decides, at each incoming fix, how much of the window budget to spend versus reuse the previous release — the exact allocation logic that makes the sliding-window guarantee hold while keeping a parked-then-moving subject’s released trajectory usable. Everything below assumes the per-release noise is drawn by a calibrated spatial noise mechanism and that the cumulative spend is reconciled against the accounting discipline in privacy budget management.

w-event budget allocation — a width-w window slides over an infinite stream while per-step ε spends stay summed under ε_total An infinite horizontal timeline of location fixes runs left to right, ending in an arrow toward infinity. A translucent window box of width w encloses five consecutive timestamps labelled i through i plus four. Above each enclosed timestamp is a vertical bar whose height is the ε spent there: the timestamps at i and i plus three are teal "publish" bars that spend ε_pub on a fresh noisy release, while i plus one, i plus two, and i plus four are short coral "reuse" markers that spend zero by re-emitting the previous release. A caption notes that reuse steps recover budget as old spends age out of the window. On the right, a vertical violet gauge shows the running sum of ε spent inside the current window filling only partway up to a dashed cap line labelled ε_total, demonstrating that the total ε spent in any width-w window stays at or below ε_total. A legend at the bottom maps teal to publish and spend ε_pub, coral to reuse and spend zero, and violet to the window budget sum. w-event budget allocation — a width-w window slides, per-step ε spends stay summed ≤ ε_total window of width w = [i, i+w−1] window slides one step per fix t → ∞ i−1 i i+1 i+2 i+3 i+4 i+5 publish spend ε_pub reuse ε = 0 reuse ε = 0 publish spend ε_pub reuse ε = 0 ε_total (cap) Σ ε in window headroom publish → spend ε_pub reuse → spend 0, reuse last release Σ ε across any width-w window ≤ ε_total

Parameter Configuration and Calibration

The single invariant every w-event scheme must preserve is that the sum of ε\varepsilon spent across any w consecutive timestamps is at most εtotal\varepsilon_{\text{total}}. Formally, for a spend sequence ε1,ε2,\varepsilon_1, \varepsilon_2, \dots over the stream, the mechanism satisfies w-event εtotal\varepsilon_{\text{total}}-privacy iff k=tw+1tεkεtotal\sum_{k=t-w+1}^{t} \varepsilon_k \le \varepsilon_{\text{total}} for every tt. Because that constraint slides, spending heavily now silently constrains what you can spend for the next w1w-1 steps — which is exactly why naive per-step allocation collapses. The knobs below decide how that fixed window budget is spread over a bursty movement pattern.

Knob Symbol Typical value Rationale / privacy consequence
Window width w 60–300 The number of consecutive fixes protected as one indistinguishable window. At 1 Hz, w = 120 protects any rolling 2-minute segment. Larger w hides longer sub-trajectories but starves each step of budget.
Window budget epsilon_total 0.5–2.0 The total ε\varepsilon any width-w window may spend. This is the release-level guarantee; tie it to a data-subject ceiling in the budget accountant.
Allocation strategy distribution uniform gives every step εtotal/w\varepsilon_{\text{total}}/w; distribution (Budget Distribution) spends half the residual per publish and reserves the rest; absorption (Budget Absorption) pre-allocates εtotal/w\varepsilon_{\text{total}}/w per step and lets skipped steps donate budget to a later publish.
Publication sensitivity Δf\Delta f 1.0–100 Query sensitivity of the released statistic — a count, a grid histogram, or a coordinate displacement in metres. Sets the Laplace scale b=Δf/εpubb = \Delta f / \varepsilon_{\text{pub}} of each fresh release.
Dissimilarity metric dissimilarity movement-dependent The measured distance between the fresh statistic and the last release (e.g. mean point displacement). Compared against the would-be noise scale to decide publish vs reuse.

The strategy choice is the substance. Uniform allocation is trivially safe — w steps each spending εtotal/w\varepsilon_{\text{total}}/w sum to exactly εtotal\varepsilon_{\text{total}} — but wasteful: a parked vehicle burns the same budget standing still as it does cornering, so every release is drowned in noise scaled to a tiny εtotal/w\varepsilon_{\text{total}}/w. Adaptive schemes exploit the fact that trajectories are mostly stationary. When the new fix barely differs from the last release, publishing a fresh noisy value is strictly worse than reusing the previous one — the noise you would add exceeds the real change. So the mechanism approximates (re-emits the last release, spends nothing) and banks the budget for the moment the subject actually moves. Budget Distribution dissipates the residual geometrically (εpub=εrm/2\varepsilon_{\text{pub}} = \varepsilon_{\text{rm}}/2) so it never fully commits; Budget Absorption keeps a uniform per-step allotment but lets a burst of movement absorb the budget that quiet neighbours left on the table, then nullifies future steps to keep the window sum capped.

The publish-versus-approximate decision is a direct comparison: publish only when dissimilarity > sensitivity / epsilon_pub. The right side is the expected error of a fresh release; the left is the real change. Releasing beats reusing precisely when the subject moved more than the noise would blur.

Reference Implementation

WEventBudgetAllocator is a single self-contained scheduler. It keeps a rolling record of the ε\varepsilon spent at the most recent w1w-1 timestamps, computes the residual budget the current step may spend without breaching any window that contains it, and applies the publish-versus-reuse test. It performs no noise sampling itself — sensitivity and the returned noise_scale are the contract with the downstream Laplace stage — so the class is the pure allocation policy, easy to test in isolation.

python
from __future__ import annotations

from collections import deque
from dataclasses import dataclass
from typing import Deque, List, Literal

Strategy = Literal["uniform", "distribution"]


@dataclass(frozen=True)
class Release:
    """Outcome of one timestamp's budget request."""
    timestamp: int
    published: bool        # True = fresh noisy release; False = reuse previous
    epsilon_spent: float   # 0.0 on a reuse step
    noise_scale: float     # Laplace b = sensitivity / epsilon_spent (inf on reuse)


class WEventBudgetAllocator:
    """Allocate an epsilon budget across an infinite location stream so the
    total epsilon spent in ANY sliding window of w consecutive timestamps
    never exceeds epsilon_total -- the defining invariant of w-event privacy.

    A rolling deque holds the epsilon spent at the most recent w-1 committed
    timestamps. At each new timestamp the residual budget is epsilon_total
    minus that rolling sum, so committing a publication can never push any
    window over the cap. Skipping a publication (reuse) spends zero and lets
    the residual recover as old spends age out of the window -- this is what
    buys budget for the moments a subject actually moves.
    """

    def __init__(
        self,
        epsilon_total: float,
        w: int,
        sensitivity: float = 1.0,
        strategy: Strategy = "distribution",
    ) -> None:
        if epsilon_total <= 0:
            raise ValueError("epsilon_total must be positive")
        if w < 1:
            raise ValueError("w (window width) must be >= 1")
        self.epsilon_total: float = epsilon_total
        self.w: int = w
        self.sensitivity: float = sensitivity
        self.strategy: Strategy = strategy
        # spends of the most recent (w-1) committed timestamps
        self._window: Deque[float] = deque(maxlen=max(w - 1, 1))
        self._t: int = 0

    def _residual(self) -> float:
        """Budget still spendable now without breaching the cap on any window
        that includes the current timestamp."""
        # For w == 1 the window is empty and every step gets the full budget.
        spent = sum(self._window) if self.w > 1 else 0.0
        return self.epsilon_total - spent

    def request(self, dissimilarity: float) -> Release:
        """Decide publish-vs-reuse for the current timestamp.

        `dissimilarity` is the already-computed distance between the fresh
        statistic and the last released one -- e.g. mean displacement of the
        trajectory point in metres. We publish only when a fresh release would
        be MORE accurate than reusing the stale one: when the real movement
        exceeds the noise we would have to add under the residual budget.
        Otherwise we reuse and spend nothing, banking budget for later motion.
        """
        self._t += 1
        residual = self._residual()

        # Hard floor: no budget left in this window -> must reuse.
        if residual <= 1e-12:
            return self._commit(published=False, epsilon=0.0)

        # Candidate publication budget under the chosen strategy.
        if self.strategy == "uniform":
            eps_pub = min(self.epsilon_total / self.w, residual)
        else:  # Budget Distribution: spend half the residual, reserve the rest
            eps_pub = residual / 2.0

        # Expected error of a fresh release == Laplace scale b = sensitivity / eps.
        noise_scale = self.sensitivity / eps_pub
        # Publish only if the subject moved more than the noise would blur;
        # a reuse leaks nothing new, so approximating stale data is "free".
        if dissimilarity > noise_scale:
            return self._commit(True, eps_pub, noise_scale)
        return self._commit(published=False, epsilon=0.0)

    def _commit(self, published: bool, epsilon: float,
                noise_scale: float = float("inf")) -> Release:
        """Record the spend in the rolling window and advance the clock."""
        if self.w > 1:
            self._window.append(epsilon)
        return Release(self._t, published, epsilon, noise_scale)

Each publish under distribution spends at most half the residual, so a fresh window sum can only ever move from ss to s+(εtotals)/2s + (\varepsilon_{\text{total}} - s)/2, which stays strictly below εtotal\varepsilon_{\text{total}}; uniform caps each spend at εtotal/w\varepsilon_{\text{total}}/w. In both cases the cap holds by construction — but “by construction” is exactly the kind of claim that rots when someone edits the residual math, so it must be pinned by an executable test.

Validation Checkpoint

The harness below simulates a subject that alternates long stationary dwells with bursts of movement, runs the allocator over the stream, and then brute-forces the invariant: for every contiguous window of width w, the summed spend must not exceed εtotal\varepsilon_{\text{total}}. A misconfigured residual would let a window quietly overspend and void the privacy claim without raising, so this check belongs in CI.

python
import numpy as np


def _validate() -> None:
    rng = np.random.default_rng(42)
    w, eps_total = 20, 1.0
    alloc = WEventBudgetAllocator(eps_total, w=w, sensitivity=50.0,
                                  strategy="distribution")

    # 500 fixes: alternating "parked" phases (dissimilarity ~ 0) and
    # "driving" phases (large displacement) -- the realistic bursty pattern.
    releases: List[Release] = []
    for t in range(500):
        driving = (t // 25) % 2 == 1
        dis = float(rng.uniform(200, 400)) if driving else float(rng.uniform(0, 5))
        releases.append(alloc.request(dis))
    spends = [r.epsilon_spent for r in releases]

    # 1. THE invariant: every sliding window of width w spends <= eps_total.
    for start in range(len(spends) - w + 1):
        window_sum = sum(spends[start:start + w])
        assert window_sum <= eps_total + 1e-9, (
            f"window [{start}, {start + w}) spent {window_sum} > {eps_total}"
        )

    # 2. Semantics: published steps cost >0; reuse steps cost exactly 0.
    assert all(r.epsilon_spent > 0.0 for r in releases if r.published)
    assert all(r.epsilon_spent == 0.0 for r in releases if not r.published)

    # 3. Adaptivity: the scheme publishes during motion, holds during dwell.
    assert any(r.published for r in releases), "allocator never published"
    assert any(not r.published for r in releases), "allocator never reused"

    # 4. Uniform strategy respects the cap under a worst case of constant motion.
    uni = WEventBudgetAllocator(eps_total, w=w, sensitivity=50.0,
                                strategy="uniform")
    uni_spends = [uni.request(1e9).epsilon_spent for _ in range(200)]
    for start in range(len(uni_spends) - w + 1):
        assert sum(uni_spends[start:start + w]) <= eps_total + 1e-9

    print("all w-event budget invariants hold")


if __name__ == "__main__":
    _validate()

The brute-force sweep is deliberately not clever: it re-derives the window sums from the raw spend log rather than trusting the allocator’s internal accounting, so a bug in _residual cannot hide behind the same bug in the check. Pair this with the exhaustion detection in privacy budget management to alert before a window pins to zero residual and silently degrades to constant reuse.

Incident Response and Edge Cases

  • Adversarial dissimilarity oscillation. An attacker who can nudge the raw signal so dissimilarity sits just above noise_scale on alternating steps forces maximal publication and drives every window to the cap, collapsing utility to pure noise. Detection: monitor the publish rate per window; a sustained rate near w/2w/2 under distribution is anomalous for real mobility. Remediation: compute the dissimilarity itself under a small reserved sub-budget (so the decision is private too), or add hysteresis — require dissimilarity to exceed noise_scale by a margin before publishing.
  • Window pinned to zero residual. After a movement burst spends most of the budget, the next w1w-1 steps may have near-zero residual and be forced to reuse even if the subject keeps moving, producing a stale, laggy released trajectory. Detection: residual stays below a floor across a full window. Remediation: raise epsilon_total, shorten w, or switch to Budget Absorption so quiet steps pre-fund the burst instead of the burst starving its successors.
  • Reuse of a coordinate that is itself sensitive. Reuse spends no new budget, but it re-publishes a value already released — fine for privacy, yet if the first release under-noised the point, every reuse propagates that error. Detection: audit that the initial noise_scale of each published segment matches Δf/εpub\Delta f / \varepsilon_{\text{pub}}. Remediation: never let a downstream stage re-noise a reused value (it would spend budget for no new information); fix calibration at the publish step via the spatial noise mechanisms layer.
  • Restart amnesia across process boundaries. If the allocator restarts and forgets its rolling window, the first w-1 steps after restart treat the full budget as free and can overspend a window that straddles the restart. Detection: reconcile spend logs across the restart boundary. Remediation: persist the deque of recent spends (or the timestamped spend log) durably and rehydrate it before serving the first post-restart fix.

Frequently Asked Questions

How is w-event privacy different from event-level and user-level DP?

Event-level DP protects a single timestamp (w = 1) and leaks the trajectory as a whole; user-level DP protects the entire infinite stream at once, which forces so much noise that streaming releases become useless. w-event privacy is the middle ground: it protects any window of w consecutive events with a fixed budget, so recent activity is hidden while the mechanism stays viable over an unbounded stream. Choose w to match the length of the sub-trajectory you actually need to conceal.

Why does reusing the previous release cost zero privacy budget?

A reuse re-emits a value that was already released under its own epsilon spend, so it reveals nothing the adversary did not already have. Differential privacy is closed under post-processing, and re-publishing an existing output is post-processing. Only a fresh query against the live data touches the raw stream and therefore consumes budget. That asymmetry is exactly what adaptive schemes exploit: stationary stretches are protected for free, banking budget for genuine movement.

When should I pick Budget Distribution over Budget Absorption?

Budget Distribution spends half the residual per publish and never fully commits, so it degrades gracefully and suits streams with unpredictable, frequent motion where you want to always retain some spendable budget. Budget Absorption pre-allocates a uniform per-step share and lets a movement burst absorb the budget quiet neighbours left unspent, which is stronger when motion arrives in concentrated bursts separated by long dwells. Both preserve the window cap; the difference is how they time the spend against the movement profile.

How do I set w for a 1 Hz GPS trajectory?

Pick w from the length of the segment you must protect, not from a round number. At 1 Hz, w = 120 makes any rolling two-minute stretch epsilon-total-indistinguishable, which is enough to hide a turn, a stop, or a short detour. Larger w hides longer patterns but leaves each step a smaller share of the budget, so if releases look too noisy, shorten w or raise epsilon_total rather than fighting the allocator.

Up: Differential Privacy for Spatial Trajectories · Differential Privacy for Geospatial Data