What It Takes to Publish One Race Page

By RunPact TeamAugust 19, 2026

We published race pages for 33 ultras — finish-time distributions, aid station cutoff pressure, segment-by-segment field pace. This post is the other half: where that data comes from, what has to be true before we'll publish it, and the races we've thrown away.

The second list turns out to be the more interesting one.

Rule Zero: The Course GPX Comes First

Every race page and every model row needs two things — per-runner checkpoint splits, and a GPX trace of the course. Splits without a course are inert: no distance can be attached to a checkpoint, no elevation can be attached to a segment, no flythrough can be built, no page can be rendered. The rows just sit there looking like progress.

We learned this the expensive way. We ingested the Stranda Fjord Trail Race 48K — a full field, clean splits from the timing provider — and then went looking for the course file. There wasn't one we could resolve. The result was the only race directory in the repository that the model trainer skipped entirely, that no page could be built from, and that read as finished work while being completely inert.

We deleted it. The ingester and the notes stayed, along with the exact command to regenerate it the day a 2025 GPX turns up.

The rule is now: confirm the GPX exists before ingesting anything. It sounds obvious. It is obvious. It is also the single thing most likely to be skipped when a results table is sitting right there and looks easy.

Three Providers, Three Different Problems

There is no standard for publishing race results. Every timing company has its own format, and each one lies to you in a different way.

livetrail.net covers a large share of French and Spanish trail racing — Templiers, EcoTrail Paris, SaintéLyon, Penyagolosa, Serre Chevalier, Kullamannen. It serves per-runner passage times through an XML endpoint, which is the good news. The bad news is wave starts (times need the runner's own start subtracted), pagination, and checkpoint records with no distance attached, so distances have to be recovered by projecting each checkpoint's coordinates onto the course GPX.

B4SPORT times a large share of Polish racing — Łemkowyna, the Lądek-Zdrój races, Rzeźnik. It's markedly cheaper to work with: the whole field arrives in one server-rendered table, times are already elapsed, and checkpoint distances sit right in the column headers. No wave-start arithmetic, no pagination, no GPX projection.

EQ Timing covers Scandinavia — Sätila Trail's Lygnernslingan (four editions), Tromsø Skyrace — through a JSON API keyed by event and stage IDs.

Then there's what doesn't have an API at all: Western States publishes spreadsheets, Hardrock publishes split times as HH:MM rather than HH:MM:SS, and both required their own handling.

How a Checkpoint Gets Its Distance

A split time is useless without knowing where it was recorded. Three sources, tried in order:

  1. The distance the provider published, if it published one. B4SPORT does this.
  2. The official cutoff chart, joined positionally against the checkpoint list and guarded by a distance tolerance so a mismatched join fails loudly instead of quietly assigning the wrong kilometre.
  3. Projection onto the course GPX — take the checkpoint's coordinates, find the nearest point along the route, guarded for monotonicity so an out-and-back course can't place checkpoint 8 before checkpoint 7.

Only after all three fail does the checkpoint get dropped.

The Guard Rails Exist Because Everything Goes Wrong Once

Every validation in the ingest pipeline is there because something specific went wrong. A partial list:

Truncated results grids. The B4SPORT ingester asserts that the number of finishers it parsed matches the timekeeper's own published "Ukończyło" count. If a grid is truncated — a pagination bug, a bad fetch — the ingest fails rather than silently writing a partial field and reporting a fictional finish rate.

Disqualified runners. B4SPORT keeps real split times for DQ'd runners behind a " DQ" suffix. Handled naively, that either corrupts the time parser or inflates the finisher count. It's now treated like a DNF, and disqualifications are counted separately so the reconciliation guard can stay strict rather than being loosened to accommodate them.

Start-reference bugs. Penyagolosa was excluded from publication for months because our parsed times ran roughly 44 minutes fast — a gun-time versus wave-start reference problem. When we cross-checked our parsed winning times against the winning times livetrail publishes directly, we found the fix had only ever reached one of the two courses: CSP matched at 10:41:10, while MIM was still 41 minutes fast — 4:34 against livetrail's 5:15:09. Re-ingesting MIM with the current code reproduced 5:15:09 exactly and moved its median finish from 9:10 to 10:23. The stored file had simply gone stale after the fix.

That's worth sitting with. The bug wasn't in the code any more. It was in a data file the fix never got re-run against — and the only way to catch it was to check our numbers against a number the timekeeper had published independently.

The Races We Won't Publish

Grand Trail des Templiers 80K. Livetrail lists 2,643 rows but carries a finish time for only 2,417 of them. We could publish it — the median and the distribution would be roughly right — but the finish rate would be wrong, and finish rate is one of the first numbers anyone reads. It stays out. Re-ingesting it confirmed the gap rather than closing it.

Kullamannen 20K. This one is our favourite failure. Validating a proposed elevation-smoothing change across every course file we ship, we found this GPX carried 10,582 points over 22 km of sea-level coast — 461 points per kilometre, against a corpus norm nearer 30. The entire relief of the course is 26.8 m. Summing the sub-metre GPS jitter across all those points produces 1,970 m of climbing against the organiser's 96 m. A 20× error. The model had been training on a flat coastal 20K as if it were an alpine course.

It can't be repaired: decimating the trace to 25 points per kilometre still yields 326 m, because there is no elevation signal in there to recover — only noise, at a finer grain. We deleted it.

The smoothing change that surfaced it was rejected too, and the reason is a good illustration of why "just clean the data" is rarely a real answer. Across the 39 course files tested, raw elevation figures land within 15% of the organiser's number on 30 of them. Apply a ±5 point smoothing window and that drops to 21. Apply ±12 and it drops to 15. Smoothing rescues the dense traces (Templiers goes from +25% error to +2%) by wrecking the sparse ones (EcoTrail 45K goes from −15% to −42%). The error tracks point density, so no single window serves both, and nothing global should be applied. Better to know the shape of the error than to hide it under an average.

Feeding the Model

The same corpus trains RunPact's finish-time model — currently 262,260 split-level training samples. Publishing race pages and improving predictions are the same work, and the corpus growth this summer went straight into both.

The subtle part is the evaluation split. If two editions of the same race land on opposite sides of a cross-validation fold, the model gets to memorise the course and reports an accuracy it doesn't have. So folds are grouped by course, not shuffled by row.

That turned out to be not quite enough. The Łemkowyna 48 is literally the final 47.7 km of the Łemkowyna 150 — same ground, same climbs, different start line. Grouping by course ID puts them in different folds, so training on one and validating on the other leaks the exact terrain the model is supposedly being tested on. But each distance still needs its own course ID to resolve its own GPX file. The fix is a separate course_group_id that overrides the evaluation grouping while leaving GPX resolution alone, declared on all five Łemkowyna courses and available as a flag for the next nested-distance event.

The honest headline metric from that setup is a cross-validated MAE of about 74 seconds per kilometre. It is a less flattering number than the one we'd get from a shuffled split, which is precisely why it's the one we quote. We corrected the predictor's accuracy copy this summer for exactly this reason — the page had been claiming a validation R² measured the easy way, which was not a claim about held-out races at all.

Why Any of This Matters to a Runner

None of the above is visible on a race page. What's visible is a number, and the entire question is whether you can trust it.

Our answer is a methodology block at the bottom of every page listing where the numbers came from and what they exclude, an explicit "finishers only" label on everything split-derived, suppression rather than approximation wherever the source data is thin — livetrail's "age" is a category floor, not an age, so no livetrail race shows a median age at all — and a list of races we've declined to publish.

That last one is the real signal. Anyone can publish 33 pages. Publishing 33 and being able to name the ones you threw out, and why, is the part that should make the 33 believable.

Browse the race pages →