Pinning Map Versions for Reproducible Fleet Matching
Six months after a delivery, a customer disputes the distance on an invoice. The matched trace says 41.2 kilometres. Somebody re-runs the matcher today and gets 39.8, because a one-way system changed in March and the router now takes a shorter path that did not exist at the time. Both numbers are correct, and without a pinned map version there is no way to demonstrate that.
This is the case for treating the routing graph as a versioned input rather than as infrastructure. It costs one column on the output table and a retention policy, and it converts an unanswerable question into a reproducible one. It completes the versioning work started in map data and graph preparation.
Compatibility and Configuration Requirements
| Requirement | Value | Notes |
|---|---|---|
| Version key | Four components, hashed together | Extract checksum, OSM timestamp, overlay revision, profile revision |
| Output column | graph_version on every matched row |
A job log is not a substitute; logs rotate |
| Retention | ≥ the dispute window | Weekly promotions for a year is manageable in cold storage |
| Archived artefacts | Clipped PBF and built graph | Upstream extracts rotate within weeks |
| Re-match path | A code path that loads a named graph | If re-matching only works against “current”, pinning buys nothing |
Defining the Version
A version identifier is only useful if two graphs that behave differently cannot share one. Four components are needed, and dropping any of them creates a collision that will eventually matter.
from __future__ import annotations
import hashlib
import json
import pathlib
def graph_version(graph_dir: pathlib.Path) -> str:
"""Stable identifier for everything that can change a matched result."""
parts = {
"extract_sha256": (graph_dir / "SOURCE_SHA256").read_text().strip(),
"osm_timestamp": (graph_dir / "OSM_TIMESTAMP").read_text().strip(),
"overlay_rev": (graph_dir / "OVERLAY_REV").read_text().strip(),
"profile_rev": (graph_dir / "PROFILE_REV").read_text().strip(),
}
canonical = json.dumps(parts, sort_keys=True, separators=(",", ":"))
digest = hashlib.sha256(canonical.encode()).hexdigest()[:12]
(graph_dir / "GRAPH_VERSION").write_text(digest)
(graph_dir / "GRAPH_VERSION.json").write_text(canonical)
return digest
Writing both the digest and the components matters. The digest is what goes on every output row — twelve characters, cheap to store and to index. The JSON is what somebody reads six months later when they need to know what that digest meant, and it is the part that turns an opaque key into an explanation.
Stamping and Retaining
The stamp goes on the output, not in the log:
ALTER TABLE matched_traces
ADD COLUMN graph_version char(12) NOT NULL,
ADD COLUMN matcher_version text NOT NULL;
CREATE INDEX ON matched_traces (graph_version);
Two versions, not one. The graph version covers the map; the matcher version covers the code and its parameters. When a re-run produces a different answer, those two columns are what tell you whether the map moved or the code did — and that distinction is the first question anybody asks.
Retention should be driven by the dispute window rather than by disk anxiety. Archive the clipped PBF alongside the built graph: upstream providers rotate their extracts within weeks, so a graph you can load but not rebuild is only half a record, and a checksum referring to a file nobody has is no record at all.
Execution and Tuning Guidelines
Make the re-match path first-class. Reproducibility that requires somebody to reconstruct a container by hand is reproducibility in theory. A single command — matcher name, graph version, trace identifier — is what makes it real.
def rematch(trace_id: str, graph_version: str) -> dict:
graph = resolve_graph(graph_version) # from archive if not on local disk
matcher = load_matcher(graph, matcher_version=lookup_matcher(trace_id))
return matcher.match(load_trace(trace_id))
Assert that the re-match reproduces the original. Run it as a scheduled sample: pick a hundred traces a week, re-match them against their recorded versions, and assert the output is identical. That check catches archive corruption, a graph that was silently rebuilt, and a matcher whose behaviour depends on something not captured in either version — all of which are invisible until somebody needs the guarantee.
Do not version the sample data. It is tempting to freeze a set of traces alongside the graph. The traces are already immutable; what changes is the map and the code, and those are what the version key covers.
Expect the version column to be low-cardinality and index it anyway. Weekly promotions mean about fifty distinct values a year. That makes it cheap to index and extremely useful for the query nobody anticipates: “show me everything matched against the graph we have just discovered was broken.”
Common Pitfalls Specific to This Technique
Versioning the graph but not the profile. The profile is where truck weight rules, speed assumptions and access handling live, and a change to it can move matched routes more than a month of OSM edits. A version key without the profile revision is a key with a hole in it.
Storing the version on the batch rather than the row. Batches get re-run, partially reprocessed and backfilled. A batch-level stamp becomes wrong the first time half a batch is redone, and the error is undetectable afterwards.
Archiving the graph without the extract. A built graph can be loaded but not inspected, and it cannot be rebuilt if the engine version changes. Keep the clipped PBF; it compresses well and it is the only artefact from which everything else can be regenerated.
Answering a Dispute End to End
The value of pinning shows up in one workflow, so it is worth walking through it completely.
A customer questions the distance on an invoice from six months ago. The steps are:
- Find the trace and its versions. One query returns the trip, its
graph_versionand itsmatcher_version. If either is missing, the rest of this list is not available. - Resolve the graph. The version identifier maps to an archived directory, restored from cold
storage if it is no longer on local disk. The
GRAPH_VERSION.jsonbeside it explains what the digest means in ordinary terms — which extract, which OSM date, which overlay and profile. - Re-match. The same matcher version, the same graph, the same trace. The output should be byte-identical to what was invoiced.
- Re-match against today. The difference between the two is the answer to the customer’s question, and it is attributable: “the route we invoiced used Kerkstraat, which became one-way in March; the current map routes around it and is 1.4 km shorter.”
- Record the outcome. Both results, both versions, and the explanation, attached to the dispute.
That fourth step is the one that turns a defensive conversation into a factual one. Without pinning, the honest answer is that the pipeline cannot reproduce its own output, which is a considerably worse position than a 1.4-kilometre discrepancy.
Cost of the guarantee
The whole apparatus is one indexed column, a second column for the matcher, and an archive that grows by a few gigabytes a week. The archive compresses well — a clipped PBF is already compact and cold storage is cheap — and it can be lifecycle-managed down to whatever the dispute window actually requires.
The operational cost is closer to zero than most teams expect, and it is paid entirely up front. The alternative is paid at the worst possible moment, by whoever is on the call.
Related
- Map data and graph preparation for fleet routing — parent topic
- Scheduling OSM extract refreshes for routing engines — the job that mints each new version
- Detecting map-matching drift after an OSM update — measuring what changed between two versions
- Storing and querying matched trajectories — where the version column lives
- Routing Engine Integration for Fleet Telematics — parent section