This document is the detailed companion to THREAT-MODEL.md T1 — Deanonymization via precise
coordinates or timing. The
threat model states the risk; this note works one specific attack all the way through — the
re-identification of a reporter from a rare hazard_type on a low-traffic segment — and then maps
each mitigation to the code and parameter that implements it, with the rationale for why each parameter
is set where it is. It also corrects a documentation error: earlier prose in src/nearmiss/README.md
described the public dataset as "jittered." The pipeline does not jitter. Privacy comes from
snap-to-segment aggregation and hard withholding, not from perturbing coordinates. That distinction
matters to anyone reasoning about the privacy guarantee, so it is spelled out below.
Most of the privacy model is about the location quasi-identifier: a precise coordinate or a per-report
timestamp. Those are handled bluntly — they are simply never published (see T1). The subtler leak is a
low-cardinality attribute attached to a low-count place. A hazard_type drawn from a small closed
vocabulary is, on a busy street, harmless: "12 close-pass reports on 3rd St" describes a place, not a
person. On a street that sees one incident, the same attribute becomes a near-unique tag, and an
adversary who holds one more fact — who rides that street — can turn a place back into a person.
The adversary this note targets is deliberately ordinary. Not a data broker, not a nation-state — a neighbor, a coworker, or an ex who already knows two things the published dataset does not contain:
- A person's route. They know that a specific individual bikes down a specific low-traffic segment — the quiet residential block the person leaves from every morning, the side street past a particular workplace.
- A rare event on it. They either witnessed, were told about, or can plausibly guess a specific kind of near miss the person had there — a "dooring," a wrong-way driver, a specific unusual hazard type — the sort of thing a person mentions once and a listener remembers.
This adversary has no special access and no technical sophistication. Their entire capability is side knowledge plus the public file. They are exactly the "curious neighbor" and "harasser" named in the THREAT-MODEL actor list, and they are the reason aggregation alone is not enough.
The attack is a join. The adversary takes three things and intersects them:
- a rare
hazard_type(a value that appears once or a handful of times in the whole file), - a low-count segment (a block with only a few reports, so the aggregate barely masks anyone), and
- their side knowledge (this person rides here; this is the sort of thing that happened to them).
If a thinly-populated segment publishes a hazard_breakdown that reveals {"dooring": 1} on the block
the adversary already associates with their target, the breakdown has done the adversary's work: it
confirms that someone reported that specific event on that specific block, and the adversary's side
knowledge supplies the name. Aggregation to the street segment did not stop this, because the leak was
never the coordinate — it was the rare attribute in a thin cell. The report_count alone is a
weaker version of the same attack: a segment showing a count of 1 on a block tied to one known rider
singles that rider out even without the hazard type.
So there are two doors to close: thin cells must not publish at all, and even cells that do publish must not carry a rare-attribute breakdown that is itself thin.
The published file is built by src/nearmiss/publish.py from the analysis in
src/nearmiss/stats/__init__.py, governed by two thresholds in src/nearmiss/config.py. Both doors
above map to one of those thresholds.
- Where:
config.py:42(min_publish_n: int = 3). Enforced byassert_published_cleaninpublish.py:99-116, which raises aPrivacyErrorrather than emit a violating feature. The upstream decision is made instats/__init__.pywhere a segment is markedpublishable = not (0 < count < config.min_publish_n). - What it does: any segment with a non-zero report count below
min_publish_nis withheld entirely — no geometry, no count, no breakdown, no metadata, no brief line. A published cell can therefore only mean "zero reports" or "at least three reports." It can never mean one or two. - Why the floor is 3, not 1 or 2: the value the adversary wants to confirm is "exactly one person reported here." A floor of 1 publishes single-reporter cells and hands that confirmation over directly. A floor of 2 still publishes two-reporter cells, where either reporter, plus the adversary's knowledge that the other rider exists, re-identifies both. Three is the smallest floor at which a published cell provides no individual with certainty about any other individual — the minimum non-trivial k-anonymity guarantee (k = 3). It is the floor, chosen as small as the guarantee allows, precisely because of its cost below.
- What it costs: withholding is not free. Low-traffic streets — often exactly the residential blocks where a single scary near miss matters most to the person who lives there — fall below the floor and disappear from the published map entirely. The dataset systematically under-covers quiet streets. That suppressed coverage is the price of the guarantee, and it is stated here and in the data card so the gap is not mistaken for "no incidents here." Lowering the floor would recover that coverage only by trading away the k-anonymity guarantee, which is not an acceptable trade.
- Where:
config.py:41(small_n: int = 5). Applied instats/__init__.py:breakdown = dict(a.hazard_breakdown) if (a and count >= config.small_n) else {}. Belowsmall_nthehazard_breakdownis emitted as{}and the feature is flaggedlow_sample. - What it does: even for a segment that clears the publication floor, the per-hazard-type breakdown
is suppressed until the segment carries at least
small_nreports. A rarehazard_typetherefore cannot appear as a{"dooring": 1}-style entry in a thin cell — the exact join the attack depends on. - Why 5 is higher than the k-anonymity floor of 3: the two thresholds guard different things and a
breakdown needs a higher floor than a raw count.
min_publish_nprotects a single aggregate number: three reports is enough to keep any one of them from being singled out as a count. Ahazard_breakdown, though, partitions that count across hazard types, and each partition cell is its own miniature aggregate that must independently resist singling-out. A segment with exactly three reports that clearsmin_publish_ncould still break down into{"dooring": 1, "close_pass": 1, "wrong_way": 1}— three cells of size one, each as re-identifying as an un-withheld single-report segment. Requiringsmall_n > min_publish_ngives the partition headroom so its individual cells are not trivially thin, and 5 is the chosen margin: enough that a breakdown reflects a genuine local pattern rather than one memorable incident, while still low enough to publish breakdowns on moderately-reported streets. The breakdown floor is deliberately the more conservative of the two.
The pipeline does not jitter, perturb, fuzz, or add noise to coordinates. Any documentation that
says otherwise is wrong; the misleading "jittered" wording in src/nearmiss/README.md is corrected in
this change. Spatial privacy is achieved by two mechanisms, neither of which is perturbation:
- Snap-to-segment aggregation (
snap_max_m = 25).config.py:38setssnap_max_m: float = 25.0. The pipeline snaps each report to the nearest public street segment within 25 m and then aggregates. Sub-segment precision is discarded, not scrambled: the published geometry is the real public street centerline — public infrastructure that describes a place, not a perturbed version of a person's location. There is nothing to reverse, because the fine coordinate was thrown away rather than moved. Jitter, by contrast, retains a perturbed coordinate that can be attacked statistically over repeat reports; snap-and-discard leaves no such coordinate. - The coordinate-leak assertion.
publish.pyrounds published geometry vertices and never emits a per-report coordinate, andassert_published_clean(publish.py:99-116) checks every published vertex against the set of raw report points (rounded to 6 decimals) and raisesPrivacyErrorif any published vertex coincides with a raw report location.assert_metadata_cleanapplies the same check (at 5 decimals) to the metadata sidecar. This is a hard, tested tripwire: publication fails closed rather than leak a point.
Framing this as "no jitter" is not a weaker claim — it is a stronger and more honest one. A jitter model
invites the question "how much noise, and can it be averaged out?" The snap-and-withhold model answers a
different, cleaner question: the precise coordinate is never in the published artifact at all, in any
form. See schema/dataset.schema.md ("No jitter, no published
coordinate") for the same statement at the schema level.
These mitigations bound the ordinary-adversary attack above; they do not eliminate every re-identification path. Consistent with the THREAT-MODEL "Stops at" text for T1 and its Residual-risk section:
- Repeat-visitor / linkage. A contributor who files many reports clustered near one origin still leaks a pattern. Each report may sit in a well-populated, un-suppressed cell, yet the set of segments a single contributor touches can be linked across the dataset by an adversary with side knowledge, re-identifying the person even though no single cell violates a floor. Per-cell k-anonymity does not compose into whole-trajectory anonymity. Contributor-facing guidance therefore advises reporting sparingly near home, and the data card states this plainly so consent is informed.
- Confirming a known address. The thresholds stop an adversary from discovering that a specific person reported a specific incident. They are weaker against an adversary who already holds a candidate address and is only confirming a hypothesis: if that block clears the floors, the published aggregate is consistent with their guess, and absence below a floor is itself weak information ("fewer than three reports here"). The model reduces the adversary's certainty; it does not always drive it to zero.
- Rare-combination residue above the floors.
small_nsuppresses thin breakdowns, but a hazard type that is genuinely rare city-wide can still be locally unusual on a segment that clearssmall_n. Suppressing rare-attribute combinations more aggressively (extending thesmall_nbreakdown rule) is tracked as intake-and-abuse item B7; this document is the "document the re-identification model" half of that item.
None of the above is claimed as solved. They are the acknowledged edges of a model that is honest about where it stops.
docs/THREAT-MODEL.md— T1 and Residual risk; this note is its detailed companion.docs/DATA-CARD.md— the published dataset's privacy section and parameters.docs/INTAKE-AND-ABUSE.md— item B7 (privacy hardening for rare combinations).schema/dataset.schema.md— the "no jitter, no published coordinate" guarantee at the schema level.src/nearmiss/config.py,src/nearmiss/publish.py,src/nearmiss/stats/__init__.py— the code and parameters this model maps to.