Blog

Matching repeated runs without a segment database

Posted 2026-07-21

One of the requests I*ve gotten from beta-testers is basically "Strava segments". This is a popular feature of Strava, after a run you're shown how that run compared to previous times on the same ground, as well as how you stack up against others. Strava does this by maintaining a global catalog of named segments with leaderboards. That means an authoring UI, a server, user accounts, and a lot of product I am not interested in building. But at it's core, a segmented runs feature doesn't need any of the fancy social stuff or user tracking. All it needs is a way to identify repeated runs, and a way to compare them.

If you have already run a loop a few times, the app already has the required data. A “segment” can just be ground that a runner has covered more than once, in the same direction.

That sounds simple until you try it on real GPS. Most of my own test route is an out-and-back. Without a direction check, the outbound leg matches the return leg and you compare two different efforts: grade reversed, fatigue different. Get the clustering wrong and a noisy first recording becomes the template forever, and clean later runs miss the gate by a hair. I prototyped the geometry in Python against exported sessions, then ported it into the production Kotlin path. The first version of the segmented runs feature is available in the beta now, and more is coming soon.

What the field data said

To make the prototype I measured three runs of the same ~6.9 km route, recorded at about 1 Hz. Nearest-point distance between traversals: median 1.5–1.8 m, p90 3.6–4.3 m. About 99% of points land within 10 m of the sibling run. Reported GPS accuracy on those days was median 3.1–4.5 m. So a 25 m match tolerance is generous relative to noise, and still below the gap you usually get between parallel streets.

The same data showed why direction matters. On that out-and-back, 94% of points revisit ground covered earlier in the same run, almost always with a bearing delta near 180°. Ungated matching happily glues out-leg to back-leg.

Method, roughly fifty lines: project both traces into a local flat frame, hash run A onto a 25 m grid, look up each of run B’s points in the 3×3 neighborhood. That is still the core of the algorithm that is used in the first version of the feature.

Fingerprints

Raw GPS is a bad key. Two recordings of the same course can yield different numbers of points. Time indices do not neccesarily line up. So at save (and again after trim or GPS clean) each session gets a fingerprint: one point per 10 m of gated arclength.

“Gated” matters. Distance for the fingerprint goes through the same pause and glitch rules the rest of the app uses, so a traffic-light stop does not invent meters and a GPS jump does not stretch the route. Chords longer than 50 m break the fingerprint entirely. A long dropout is unknown ground; interpolating across it would draw a corridor through places you never ran. Moving time still accumulates across the break. Indoor runs and anything under 500 m get no fingerprint.

Each resampled point stores lat/lon, bearing (from neighboring resampled points, stabler than raw GPS bearing), and cumulative moving time. A 7 km run is on the order of 11–17 KB. Arclength itself is Haversine per step, not a shared flat frame: sessions can be hundreds of kilometers apart, and an equirectangular frame inflated lengths by about 4% in early tests. Flat frames are only used per comparison, anchored at the reference trace.

Same route means mutual coverage

Coverage of A onto B is the fraction of A’s fingerprint points that find a B point within 25 m with bearing within 60°. Mutual coverage is the minimum of the two directions. Same route if mutual coverage is at least 0.80.

On the field set, true repeats sat at 0.983–0.996. Unrelated nearby runs topped out around 0.43–0.59. Sweeping tolerance (15/25/35 m) and bearing (45/60/90°) left the same gap: same-route mins around 0.97–0.99, cross-route maxes around 0.51–0.59. The 0.80 gate sits in a wide dead zone, which is comforting. Exact constants are not fragile.

Two relations fall out of the directional numbers and are worth keeping separate:

Shared ground across different routes

Not every useful comparison is a full loop. Two different routes can share a riverside stretch. A partial recording of your usual loop should still get a time on the part it actually covered.

For pairs that fail the route test but pass the bbox filter, the matcher projects A onto B and takes maximal contiguous matched spans, bridging gaps up to 50 m and keeping spans of at least 400 m. Contiguity has to hold on both sides: A can skip a dropout break so adjacent A indices sit on distant ground; the B-side index jump catches that and splits the span. Times come from the projected endpoints, not a second nearest-point lookup (out-and-backs reverse endpoints otherwise). This runs on demand when you open session detail. Nothing is persisted yet.

On the degraded twin recording from the prototype set, that produced one shared stretch of 3.3 km: 25:26 vs 24:20. The app shows the same numbers after import.

What you see in the app

After a save, the session detail can show a route card: run N of this route, rank, best and previous times, and a one-line note like “lost 65 s on km 4, gained 23 s on km 5.” Partials get a short context line without a ranking. Shared stretches appear below as length plus both moving times (top three by length).

Session detail showing a 6.87 km route card: Run 4 of this route, 3rd fastest, 25 seconds behind best, with this run / best / previous times and a per-km delta note.
Session detail after a fourth traversal of the same loop: rank, best and previous times, and where the seconds went relative to the best.

Matching also feeds the post-run AI debrief, but only as a precomputed fact: rank, best, previous, today’s delta. The model is not asked to invent course comparisons. That is the same pattern as the rest of the coaching stack (arithmetic in code, language in the model). Live mid-run ghost pacing is still ahead.

Future work