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.

Four things that change a matched result The extract checksum, the OSM data timestamp, the overlay revision and the profile revision combine into a single graph version digest. Each one can change independently, and each can change a matched route without any of the others moving. Drop any one of these and two different graphs can share a version extract checksum a truncated re-download OSM data timestamp a new one-way system overlay revision a bridge added by ops profile revision a changed truck weight rule sha256 → 12 chars graph_version every matched row one indexed column

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.”

One trip, three graphs, three defensible distances A single delivery trip re-matched against the graph in force at the time and against two later graphs. The distance falls from 41.2 to 39.8 kilometres because a one-way system changed in March and again in June. All three numbers are correct for their map, and only the pinned version identifies which one was reported. Trip 88-4102, matched against three graph versions a41c9d2b8e07 41.2 km in force at the time 7bd0e41f3ca6 40.5 km after the March change c92f5a0b16de 39.8 km current graph Without the top row's version recorded, the invoice and the re-run disagree and neither can be shown to be right. With it, the disagreement becomes a fact about the road network rather than a fault in the pipeline.

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:

  1. Find the trace and its versions. One query returns the trip, its graph_version and its matcher_version. If either is missing, the rest of this list is not available.
  2. 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.json beside it explains what the digest means in ordinary terms — which extract, which OSM date, which overlay and profile.
  3. Re-match. The same matcher version, the same graph, the same trace. The output should be byte-identical to what was invoiced.
  4. 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.”
  5. 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.

From dispute to attributable answer Five steps: look up the trace and its versions, resolve the archived graph, re-match to reproduce the invoiced figure, re-match against the current graph, and record the difference with its cause. Only the first step is impossible without a pinned version, and it makes all the others unreachable. Five steps, each depending on the one before 1 · look up trace + versions 2 · resolve archived graph 3 · re-match reproduce 41.2 km 4 · compare against today 5 · record the cause "Kerkstraat became one-way in March" Without step one the chain never starts, and the honest answer becomes "we cannot reproduce that number". Step three is also a scheduled check: sample a hundred traces a week and assert the re-match is identical.