Versioning Network Extracts for Reproducible Catchments

This page solves one exact task: constructing a build identifier that captures everything which can change a route, attaching it to every artifact computed from the graph, and using it to answer questions about why numbers moved.

Without it, a catchment is a polygon with a date. With it, a catchment is a polygon with a provenance — and the difference shows up the first time a real-estate committee asks why a site that scored 0.81 last quarter scores 0.78 today.

Prerequisites

  • A build pipeline producing graphs at versioned locations, per preparing OSM network extracts for routing.
  • Artifact storage that can carry metadata — a column in the contour table, a field in the GeoParquet schema, a tag on the cache key.
  • Python packages: hashlib from the standard library is genuinely all that is required; pandas for the comparison queries.

Configuration and execution parameters

Component of the identifier Example Why it belongs
Source extract date 2026-08-01 The map’s own vintage
Source checksum (short) 9f3c1a Distinguishes two files with one date
Profile name car_retail A different profile is a different network
Profile file hash 4b1c Catches an edited speed table
Clip geometry hash 7a2e Catches a widened buffer or a new market
Engine version v5.27 Import behaviour changes between releases
Preprocessing mode ch Contraction hierarchies versus other modes

The rule that makes this work: anything that can change a returned duration goes into the identifier, and nothing else does. Adding the build host or the wall-clock time produces a new version for every build even when nothing changed, which destroys the property that makes the identifier useful — that two identical builds are recognisably identical.

Six inputs, one identifier, one question answered The source extract, its checksum, the profile file, the clip geometry, the engine version and the preprocessing mode all hash into a single build identifier. The build host, the wall-clock time and the operator do not, because none of them can change a duration. In the hash: everything that can move a duration. Nothing else. hashed in source extract date source file checksum profile file contents clip geometry engine version · mode build id 2026-08-01_car_4b1c stamped onto every contour every cache key every reachable-population figure every scored candidate row every committee pack deliberately excluded build host · wall-clock time · operator · job id — none changes a route, and each would make two identical builds look different The identifier is short enough to read in a log line and specific enough to reproduce a build from.

Annotated implementation

The identifier is a hash over the things that matter, with a human-readable prefix so it can be recognised at a glance.

python
from __future__ import annotations

import hashlib
import json
from pathlib import Path


def _file_hash(path: Path, chunk: int = 1 << 20) -> str:
    h = hashlib.sha256()
    with path.open("rb") as fh:
        while block := fh.read(chunk):
            h.update(block)
    return h.hexdigest()


def build_identifier(extract_path: Path, extract_date: str, profile_path: Path,
                     clip_geojson: Path, engine_version: str,
                     mode: str = "ch") -> tuple[str, dict]:
    """Deterministic: the same inputs always produce the same identifier, and
    a change to any of them produces a different one."""
    components = {
        "extract_date": extract_date,
        "extract_sha256": _file_hash(extract_path),
        "profile_sha256": _file_hash(profile_path),
        "clip_sha256": _file_hash(clip_geojson),
        "engine_version": engine_version,
        "mode": mode,
    }
    digest = hashlib.sha256(
        json.dumps(components, sort_keys=True).encode()).hexdigest()

    profile_name = profile_path.stem
    build_id = f"{extract_date}_{profile_name}_{digest[:8]}"
    # The manifest is what makes the id reproducible rather than merely unique.
    return build_id, {"build_id": build_id, **components}


def write_manifest(build_dir: Path, manifest: dict) -> Path:
    out = build_dir / "BUILD_MANIFEST.json"
    out.write_text(json.dumps(manifest, indent=2, sort_keys=True))
    return out

The manifest matters as much as the identifier. A hash tells you two builds differ; the manifest tells you which component differs, which turns “the catchments moved” into “the profile changed and the map did not” in the time it takes to diff two small files.

Failure modes and debugging

A version that changes on every build. Usually caused by including a timestamp, a temporary path or an unsorted dictionary in the hash. The symptom is a cache that never hits and a comparison that always reports change. Sorting keys before hashing, as above, removes the commonest cause.

A version that does not change when it should. More dangerous. If the profile is hashed by filename rather than contents, an edited speed table produces the same identifier and two genuinely different graphs become indistinguishable. Hash contents, always.

Artifacts that lose the stamp. A contour written with its build id, aggregated into a reachable-population figure that drops it, then fed into a score that has no idea which graph it came from. The stamp has to propagate through every aggregation, which means the aggregation code has to carry it deliberately — usually as a column that is grouped by rather than dropped.

Two versions in one result set. A batch that spans a promotion, as covered in the refresh pipeline. The stamp makes it visible after the fact; a lease held during the batch prevents it in the first place, and both are worth having.

From "why did this change?" to an answer in three queries A site scored 0.81 in the June run and 0.78 in the August run. Comparing the stamped versions shows the weight version identical, the demographic vintage identical and the graph build different — then the build manifests show the profile unchanged and the extract three months newer, which points at a specific road change. Three comparisons, and the answer is a road, not a mystery stamped version June run August run same? weight version w-2026-03 w-2026-03 yes demographic vintage acs-2024-5yr acs-2024-5yr yes graph build 2026-05-01_car_4b1c 2026-08-01_car_4b1c no The profile hash is identical in both identifiers, so the profile did not change — only the map did. The route probe set then names the market, and the contour diff names the road. Total elapsed time: a few minutes. Without the stamps the same question is a week of reconstruction and usually ends in "we think the network was refreshed at some point".

Verification

  • Hash the same inputs twice and confirm identical identifiers. If they differ, something non-deterministic is in the hash.
  • Change one input at a time and confirm the identifier changes each time — particularly the profile contents, which is the one most often hashed incorrectly.
  • Query artifacts by version. Every contour, cache entry and scored row should be filterable by build id; if any stage cannot answer “which build produced this?”, the stamp is not propagating.
  • Reproduce an old contour. Take a stored contour, read its build id, restore that build from retention, re-run the request and compare geometry. This is the end-to-end proof, and it is worth doing once per quarter rather than assuming.
Where the stamp survives, and where it gets dropped The contour table, the cache key and the scored candidate table all carry the graph build id. The reachable-population aggregate and the committee pack originally dropped it, which broke the chain at exactly the point where the question is asked — both were fixed by carrying it as a grouping column. The chain is only as good as its weakest aggregation stage carries the build id? how contour table yes a column contour cache yes part of the key reachable-population aggregate was dropped now a group-by key committee pack was dropped now on the cover Both breaks were in aggregations, which is where stamps go to die: a group-by that omits the column silently averages across versions and produces a figure belonging to no build at all.

Frequently Asked Questions

Should the version be a hash or a readable name?

Both, joined: a readable prefix that a person can recognise and a hash suffix that guarantees uniqueness. A pure hash is unambiguous and unreadable, so it ends up copied incorrectly into tickets; a pure name is readable and collides the first time two builds share a date. The combination costs nothing and is what people actually paste into a message.

How does this interact with the demographic and weight versions?

They are peers, not a hierarchy. A scored candidate row should carry the graph build, the demographic vintage and the weight version as three independent fields, because a change to any one of them can move the score and only naming all three tells you which. The temptation to collapse them into a single “pipeline version” removes exactly the information the fields exist to provide.

Is it worth versioning when only one graph is ever in use?

Yes, and more so than when several are. A single-graph setup is precisely where the version is invisible and where a refresh is most likely to be mistaken for something else. The stamp costs one column and one hash; the alternative is a quarterly conversation about whether a number moved because of the model, the data or the map, with no way to settle it.

What should happen when a build cannot be reproduced?

Record it as a finding rather than working around it. Non-reproducibility means something outside the identifier is influencing the graph — an unpinned engine version, a profile pulling in an external file, a source that changed under a stable name — and each of those is a small fix that restores a property worth having. Reproducibility that holds most of the time is not reproducibility; it is a coincidence with good habits.

How long should old builds be kept?

Three for operations and one per quarter indefinitely. The operational retention exists so a rollback is a pointer change; the quarterly archive exists so a catchment computed eighteen months ago can be reproduced exactly, which is the question that arrives from an auditor or from a committee reviewing a store that has now opened and traded. Archived graphs compress well and cost little, and the alternative — being unable to reproduce a decision the business made — is the kind of gap that is only ever noticed at the worst moment.

Does the same discipline apply to the demographic and competitor layers?

Exactly the same, and for the same reason. Every input whose change can move a score needs a version that travels with the output: the survey vintage, the competitor extract date, the parcel reference build. The graph is the one people forget because it feels like infrastructure rather than data, but a catchment is as much a function of the road network as a reachable-population figure is of the census — and both deserve to be traceable.

What is the minimum viable version of all this?

A single string on every artifact, formed from the extract date and a hash of the profile. That alone answers most questions and takes an afternoon; the manifest, the clip hash and the engine version can follow. What is not worth doing is waiting until the full scheme is designed, because the value arrives with the first stamped artifact and compounds from there.

Who reads the build identifier in practice?

Three groups, and each needs a different presentation of it. The pipeline reads it as a cache key and a filter. An analyst reads it when two runs disagree, and wants the manifest diff rather than the hash. A committee reads it once, on the cover of a pack, as evidence that the numbers came from a dated, named set of inputs rather than from a model somebody ran. Designing for all three costs nothing beyond putting the identifier where each of them will look.

← Back to Preparing OSM Network Extracts for Routing