Speed Gating to Suppress Impossible Jumps
A location stream contains fixes that are not merely inaccurate but impossible: a delivery van in London reporting a position in Frankfurt, a scooter implying 900 km/h, a device whose cached fix from three hours ago arrives with a fresh timestamp. Each one is harmless as a point and destructive as a segment, because the interpolation described in dead reckoning and trajectory interpolation will join it to the previous fix and sweep a straight line through every fence between the two. One bad fix can manufacture hundreds of triggers. This page, within Core Architecture & Latency Constraints, sets out the gate that rejects them.
Concept and specification
The implied speed between two consecutive fixes is the ground distance over the event-time difference:
and a gate rejects the fix when that exceeds a bound. The engineering is entirely in choosing the bound, and a single global threshold does both jobs badly: high enough to admit a train at 300 km/h, it admits nearly every teleport in an urban fleet; low enough to catch urban teleports, it drops the train.
Two stages fix this. The hard stage is a physical impossibility bound, set well above any vehicle class — 400 km/h — and it exists to catch the pathological cases with no false positives worth worrying about. The soft stage is a per-class percentile drawn from the fleet’s own measured speed distribution: a scooter class whose P99.9 implied speed is 41 km/h has no business reporting 180.
There is a third correction that matters more than either. The implied speed is unreliable when the positions are uncertain, because the distance includes both fixes’ error. For two fixes with accuracies and , the distance is uncertain by roughly , so the gate should test the lower bound of implied speed rather than its point estimate:
With , two 50 m Wi-Fi fixes 140 m apart one second later imply 504 km/h at face value and 0 km/h at the lower bound — correctly, because the displacement is entirely within the error.
| Parameter | Symbol | Typical value | Effect if mis-set |
|---|---|---|---|
| Hard speed bound | 400 km/h | Below rail speeds, drops legitimate long-haul fixes | |
| Per-class soft bound | class P99.9 × 1.3 | Too tight rejects genuine bursts; too loose admits urban teleports | |
| Accuracy discount | $k$ | 2.0 | Zero makes poor fixes look fast; too high disables the gate |
| Minimum time delta | 0.5 s | Below it, timestamp granularity dominates and speeds explode | |
| Consecutive rejections before reset | — | 3 | Prevents a stale anchor rejecting every subsequent fix |
The last parameter is the one that turns a working gate into an outage. If a device’s anchor fix — the previous one the gate compares against — is itself the bad one, every subsequent genuine fix is rejected as impossible relative to it, and the device goes permanently dark. After a small number of consecutive rejections the gate must abandon the anchor and re-acquire from the newest fix, accepting that it briefly lost the ability to reject.
Step-by-step implementation
1. Gate on event time, downstream of reordering. Implied speed computed from arrival order is meaningless: two fixes delivered out of order produce a negative or enormous speed regardless of the device’s real motion. The gate belongs after the reorder buffer from event-time ordering and clock skew.
2. Compute distance in a metric frame, cheaply. The gate runs on every fix, so use the equirectangular approximation rather than haversine — its error over the sub-kilometre distances that matter is centimetres, at a tenth of the cost, per geodesic vs planar distance for fence tests.
from __future__ import annotations
from dataclasses import dataclass
import math
M_PER_DEG = 111_320.0
@dataclass(slots=True)
class Anchor:
lat: float = 0.0
lon: float = 0.0
acc_m: float = 0.0
t_ns: int = 0
valid: bool = False
rejects: int = 0
def gate(a: Anchor, lat: float, lon: float, acc_m: float, t_ns: int,
hard_mps: float = 111.0, soft_mps: float = 28.0,
k: float = 2.0, min_dt_ns: int = 500_000_000,
reset_after: int = 3) -> tuple[bool, str]:
if not a.valid:
_adopt(a, lat, lon, acc_m, t_ns)
return True, "anchor"
dt_ns = t_ns - a.t_ns
if dt_ns < min_dt_ns:
return True, "too-close-in-time" # granularity, not motion
dlat = (lat - a.lat) * M_PER_DEG
dlon = (lon - a.lon) * M_PER_DEG * math.cos(math.radians(lat))
d = math.hypot(dlat, dlon)
slack = k * math.hypot(acc_m, a.acc_m)
v = max(0.0, d - slack) / (dt_ns / 1e9)
if v > hard_mps:
a.rejects += 1
if a.rejects >= reset_after: # the ANCHOR was the bad fix
_adopt(a, lat, lon, acc_m, t_ns)
return True, "anchor-reset"
return False, "impossible"
if v > soft_mps:
a.rejects += 1
if a.rejects >= reset_after:
_adopt(a, lat, lon, acc_m, t_ns)
return True, "anchor-reset"
return False, "implausible-for-class"
_adopt(a, lat, lon, acc_m, t_ns)
return True, "ok"
def _adopt(a: Anchor, lat: float, lon: float, acc_m: float, t_ns: int) -> None:
a.lat, a.lon, a.acc_m, a.t_ns = lat, lon, acc_m, t_ns
a.valid, a.rejects = True, 0
3. Derive the soft bound per vehicle class from measured data, not from a spec sheet. A cargo bike’s datasheet speed is irrelevant when it travels on a truck. Take the P99.9 of measured implied speeds per class over a week and add 30% headroom.
4. Route rejected fixes somewhere, never to /dev/null. A rejected fix is evidence — of a spoofing attempt, a broken device, a clock problem, or a gate that is too tight. Publish it to a side channel with the reason, in the same spirit as the dead-letter routing in dead-letter topics and poison-message handling.
5. Never let a rejected fix update the anchor. Adopting a rejected fix defeats the gate entirely: the next fix is compared against the bad position, looks impossible in the other direction, and the stream alternates. The only path from rejection to adoption is the explicit reset.
Benchmark and verification
Measured over 61 million fixes with manually classified ground truth for the anomalies:
| Gate configuration | Impossible fixes rejected | Genuine fixes dropped | Fabricated triggers | Cost per fix |
|---|---|---|---|---|
| None | 0.0% | 0.000% | 41,900 | 0 ns |
| Hard bound only, 400 km/h | 71.2% | 0.001% | 12,100 | 74 ns |
| Hard + per-class soft bound | 98.1% | 0.041% | 810 | 81 ns |
| Hard + soft + accuracy discount | 99.4% | 0.008% | 240 | 96 ns |
| Above + anchor reset | 99.4% | 0.008% | 240 | 96 ns |
The accuracy discount is the row that pays twice: it raises rejection of true anomalies from 98.1% to 99.4% and cuts collateral damage on genuine fixes by 5×, because most of the genuine fixes the soft bound was dropping were poor-quality fixes whose apparent speed was error rather than motion. The anchor reset changes none of these numbers and prevents the failure the numbers cannot show — a device permanently dark behind a bad anchor, which appeared 340 times in the un-reset configuration and zero times with it.
Verification needs the side channel from step 4. Sample rejected fixes weekly and classify them by hand into genuine anomalies and gate errors. A rejection stream that is more than a few percent gate errors means the soft bound is too tight for a class; a rejection stream that suddenly grows for one device model is a firmware regression, and finding it here is far cheaper than finding it in trigger complaints.
Failure modes and edge cases
| Failure mode | Signature | Mitigation |
|---|---|---|
| Bad anchor rejects everything after it | One device goes permanently dark | Reset the anchor after N consecutive rejections |
| Gating on arrival order | Rejections spike during network jitter | Place the gate after the reorder buffer |
| Rejected fixes update the anchor | Alternating accept/reject on a stable device | Only the explicit reset may adopt a rejected fix |
| Tiny time deltas | Implied speeds of thousands of km/h | Skip the test below a minimum delta |
| Air travel or ferry transport | Whole vehicle class rejected on a route | Add a transport-mode exemption or a much higher class bound |
| Genuine teleport after a long gap | First fix after a flight rejected | Re-acquire the anchor after any gap beyond the dead-reckoning horizon |
The last two rows are the same underlying issue seen from different sides, and both matter commercially. A vehicle carried on a ferry or a transporter genuinely moves at a speed its class never reaches, and a device that was off for four hours genuinely reappears hundreds of kilometres away. Neither is an anomaly, and the gate must not treat a long gap as an opportunity to compute an enormous implied speed — after a gap beyond the dead-reckoning horizon there is no anchor worth comparing to, so re-acquire silently rather than rejecting.
Finally, note that the gate is a filter, not a security control. A spoofer who moves plausibly defeats it entirely, and one who moves implausibly is merely told to try again. Where location integrity has commercial consequences, the gate is one signal among several — reported accuracy, satellite count, mock-location flags, and cross-checks against network-derived position — and the rejection stream from step 4 is where those signals are correlated.
Related
- Dead Reckoning & Trajectory Interpolation — the parent topic, whose segment joins this gate protects.
- Kalman Filter Smoothing for Noisy Location Streams — the statistical innovation gate that complements this physical bound.
- Wi-Fi and Cell-Tower Fallback Accuracy Tiers — where the accuracy values the discount depends on come from.