Reproducibility is not a property a generation pipeline has; it is a property a specific run has, and it survives only if the things that determined it were written down while the run was happening. This page is part of Synthetic Spatial Data Architecture & Fundamentals, and it covers the sub-problem every other page in that area quietly depends on: given a release and its manifest, can you produce that release again, on a different machine, a year later, byte for byte — and can you prove it before anybody asks?
The answer is almost never “yes, because we set a seed.” A seed fixes one source of variation among five or six, and the other sources are precisely the ones that differ between the machine a pipeline was written on and the machine it eventually runs on. What makes a run reproducible is a derivation discipline: a single recorded root, a rule that turns it into every downstream stream, and a manifest that pins everything the derivation cannot control.
The failure this page prevents is the one that arrives as a question rather than an error. Somebody asks why a model trained on last quarter’s synthetic release behaves differently from one trained on this quarter’s, and the honest answer turns out to be that nobody knows, because the older release cannot be regenerated to compare.
Reproducibility is a conjunction. Every one of the following must hold, and the run is non-reproducible if any single one fails:
The random stream is derived, not shared. A single global generator consumed by several stages, or by several parallel workers, produces values that depend on the order in which those consumers happened to run. That order is a property of the scheduler, not of the pipeline.
The toolchain is pinned to resolved versions, not requested ones.geopandas>=0.14 is a request. What ran was one specific version, and a patch release of GEOS can change which of two coincident vertices survives a repair.
The container is pinned by digest, not by tag. A tag is a mutable pointer. The image that python:3.11-slim resolves to today is not the image it resolved to six months ago.
Reductions are ordered. Floating-point addition is not associative, so summing partial results in completion order produces a different total on every run. The per-partition outputs can be byte-identical while the aggregate statistics are not.
Every input asset is content-addressed. A network extract, a covariate raster, a boundary file: each one is an input, and “the current version” is not an identifier.
Measured over 20,000 simulated re-runs: reproducibility survives only when every source is closed, and the two most commonly overlooked ones dominate the loss.
The chart makes the shape of the problem concrete. Because the conditions compose multiplicatively, partial discipline buys very little: a pipeline that pins its dependencies and its container but derives its seeds from the wall clock reproduces almost nothing, and a pipeline that does everything except order its reductions reproduces its geometry and not its statistics — which is the worse of the two failures, because it looks like success.
Nothing here needs a library you do not already have; what it needs is a place to put the record.
# requirements.txt — pin the majors, let patch float only if you also record what resolved
geopandas==0.14.4
shapely==2.0.4
pyproj==3.6.1
numpy==1.26.4
Two environment facts matter beyond the package list. PYTHONHASHSEED must be set to a fixed value, because Python’s string hashing is randomised per process by default and any code path that iterates a set or a dict of string keys inherits that randomisation. And the thread count of the numerical libraries must be pinned, because some linear-algebra and compression paths produce different results at different thread counts:
Recording these is as important as setting them. A run whose manifest does not say what the thread count was cannot be reproduced by somebody who does not already know.
The central pattern is a tree. One root seed is recorded in the manifest; every stream any stage or partition uses is derived from it by hashing the root together with a stable name for that consumer. Nothing draws from a shared mutable generator, and nothing derives its stream from a counter.
python
import hashlib
import numpy as np
defderive(root:int,*parts:object)-> np.random.Generator:"""Derive an independent generator from the root seed and a stable path.
The path components must be properties of the *thing* being generated — a stage
name, a region key, a tile's row and column — and never properties of the
schedule, such as a worker id or a position in a work queue.
"""
key ="/".join(str(p)for p in parts).encode()
digest = hashlib.blake2b(key, digest_size=8, key=root.to_bytes(8,"big")).digest()return np.random.default_rng(int.from_bytes(digest,"big"))
root_seed =20260811
sampler = derive(root_seed,"stage","point-sampler")
tile_rng = derive(root_seed,"stage","point-sampler","tile",12,47)
The distinction in that docstring is the whole idea, and it is the one most implementations get wrong. Deriving a per-worker seed from a worker index gives every worker an independent stream, which looks correct and is not: re-running the job with a different number of workers, or with the same number in a different order, assigns different streams to the same work. Deriving from the tile’s own row and column gives the same tile the same stream forever, regardless of which worker picks it up, how many workers there are, or what order the scheduler dispatches in.
Three derivation strategies against the four re-runs that matter — only a content-addressed path survives all of them.
A second property falls out of the tree for free: partition-level reproducibility. Because a tile’s stream depends only on the root and the tile’s identity, a single tile can be regenerated in isolation and compared against the released one. That turns “the release does not reproduce” from a whole-pipeline investigation into a bisection over tiles, which is the difference between an afternoon and a week.
Resolution happens once, at the top of the run, and its output is an input to everything below.
python
import json
import subprocess
import sys
defresolve_environment()->dict:"""Capture what actually resolved, not what was requested."""
freeze = subprocess.run([sys.executable,"-m","pip","freeze","--disable-pip-version-check"],
capture_output=True, text=True, check=True,).stdout.splitlines()import pyproj
import shapely
return{"python": sys.version.split()[0],"packages":sorted(line for line in freeze if line andnot line.startswith("-e ")),"proj": pyproj.proj_version_str,"geos": shapely.geos_version_string,"hash_seed": os.environ.get("PYTHONHASHSEED"),"threads":{k: os.environ.get(k)for k in("OMP_NUM_THREADS","OPENBLAS_NUM_THREADS","MKL_NUM_THREADS")},}
pyproj.proj_version_str and shapely.geos_version_string are worth calling out: the Python package version does not determine the PROJ or GEOS version, and those are the libraries whose behaviour actually changes the geometry. A wheel built against a different PROJ produces different datum-shift results from the same code.
from pathlib import Path
defasset_digest(path: Path, chunk:int=1<<20)->str:
h = hashlib.blake2b(digest_size=16)with path.open("rb")as fh:while block := fh.read(chunk):
h.update(block)return h.hexdigest()
inputs ={str(p.relative_to(asset_root)): asset_digest(p)for p insorted(asset_root.rglob("*"))if p.is_file()}
Sorting matters. An unsorted rglob yields filesystem order, which differs between machines and produces a different manifest for identical inputs — a manifest that changes when nothing changed is a manifest nobody trusts, and an untrusted manifest is quickly ignored.
defmerge_partitions(results:dict[tuple[int,int],dict])->dict:"""Combine per-tile results in a fixed order, so the aggregate is deterministic.
`results` is keyed by tile identity, never by completion order.
"""
total_features =0
area_sum =0.0for key insorted(results):# the fixed order lives here
total_features += results[key]["features"]
area_sum += results[key]["area"]return{"features": total_features,"area": area_sum}
The sort is the entire fix, and it costs nothing. What it buys is that the aggregate statistics — the ones a validation gate compares against a tolerance — no longer depend on which worker finished first.
Note what is not in the manifest: a timestamp, a hostname, a run id. Those belong in the run log, not in the reproducibility record, because including them means two identical runs produce different manifests and the manifest stops being comparable.
What belongs in the reproducibility record, what belongs in the run log, and the specific question each field is the only one able to answer.
The test for reproducibility is a re-run, and it belongs in CI rather than in somebody’s memory.
python
deftest_run_is_reproducible(tmp_path):"""Generate twice from the same manifest and compare artifact hashes."""
manifest = json.loads(Path("fixtures/manifest.json").read_text())
first = generate(manifest, tmp_path /"a")
second = generate(manifest, tmp_path /"b")assert asset_digest(first)== asset_digest(second),("identical manifests produced different artifacts")deftest_single_tile_reproduces_in_isolation(tmp_path):"""A tile regenerated alone must match the same tile from the full run."""
manifest = json.loads(Path("fixtures/manifest.json").read_text())
full = generate(manifest, tmp_path /"full")
one = generate_tile(manifest, row=12, col=47, out=tmp_path /"one")assert extract_tile(full,12,47)== one.read_bytes()
The second test is the one that catches derivation mistakes, and it is the one teams skip. A pipeline can pass the first test — two full runs agreeing — while deriving its seeds from a worker index, provided both runs happen to use the same worker count. The isolation test fails immediately in that case, because a single-tile run has one worker.
A third check is worth running weekly rather than per-commit, because it is slow and it catches something the others cannot:
python
deftest_reproduces_in_a_clean_container():"""Re-run the pinned digest from a cold cache and compare."""
ref = json.loads(Path("fixtures/manifest.json").read_text())
out = docker_run(ref["environment"]["image_digest"],"generate","--manifest","-")assert out["artifact"]["sha"]== ref["artifact"]["sha"]
The argument for this work is usually made as insurance, and insurance is a weak argument for
engineering effort because the payout is hypothetical. Three of the returns are not hypothetical
at all, and they arrive long before any audit does.
The first is debugging speed. A partition that can be regenerated in isolation turns “the
release is wrong somewhere” into a bisection over partitions, and a bisection over ten thousand
partitions is fourteen comparisons. Teams without partition-level reproducibility do not perform
that bisection more slowly; they perform a different, worse investigation, in which the whole
pipeline is re-run with print statements added.
The second is safe refactoring. A generator whose output is byte-reproducible has a free
regression test: run the previous release’s manifest through the new code and compare hashes. A
change that was meant to be a pure refactor and was not is caught in one command rather than by a
consumer three weeks later. Without reproducibility that test does not exist, and the practical
consequence is that generators calcify — nobody wants to touch code whose behaviour cannot be
compared before and after.
The third is honest incident response. When a downstream team reports that something changed,
the first question is whether the data changed or their pipeline did. With reproducible releases
that question is settled by comparing two hashes. Without them it becomes a negotiation, and
negotiations between teams about which of them broke something are expensive in a way that has
nothing to do with engineering.
The derivation tree costs a hash per partition, which is nothing next to generating the partition. The two real costs lie elsewhere.
The first is that a fixed reduction order rules out the cheapest parallel reduction, which combines results as they arrive. In practice this matters far less than it sounds: the reduction is over per-partition summaries, not over the data, so it is thousands of small values rather than millions of large ones, and the sort is invisible in the profile.
The second is that content-addressing every input costs a full read of every asset. For a large raster collection that is real time. Two mitigations work: hash at ingest and store the digest alongside the asset, so the pipeline reads a recorded value rather than recomputing it; and for assets that are themselves produced by a pipeline, take the digest from their manifest rather than the file. Both preserve the property that matters, which is that the digest identifies the bytes rather than the path.
The run reproduces on one machine and not another. Almost always an unpinned native library. Compare proj_version_str and geos_version_string between the two environments before looking at anything else; the Python package versions will usually match while the native ones do not.
The per-tile output matches and the aggregate does not. An unordered reduction. Sort the partition keys before combining, and check for a set or dict iteration in the merge path.
The run reproduces today and not next week. A floating tag or an unpinned patch version. Pin the container by digest and record the resolved package list rather than the requirement list.
A single tile does not reproduce in isolation. The seed derivation is keyed on something schedule-dependent — a worker index, an enumeration counter, a chunk position in a queue. Re-key it on the tile’s own coordinates.
The manifest differs between two identical runs. Something non-deterministic is being recorded: a timestamp, a hostname, an unsorted file listing. Move it to the run log.
Everything is pinned and output still differs. Check PYTHONHASHSEED and the thread-count variables. Both change results silently and neither shows up in a package list.
Only for a single-threaded, single-stage script with no parallelism, which is not what a generation pipeline is. As soon as two stages or two workers draw from the same generator, the values each receives depend on the order they ran in, and that order is not part of your program. Derive per-stage and per-partition streams from the root instead.
Should the seed be random or fixed?
Random at the start of a new release, fixed thereafter. Drawing the root from the operating system’s entropy source means successive releases are genuinely independent samples rather than the same sample; recording it immediately means that release can be reproduced. What must never happen is deriving the root from the clock and not recording it, which produces a release that is independent and unreproducible at once.
Do I need this if the output is not bit-exact anyway?
Bit-exactness is the only version of reproducibility that can be checked cheaply. “Statistically equivalent” requires choosing a statistic, a tolerance and a test, all of which are arguable, and none of which will settle a dispute about whether two releases differ. Aim for byte-identical, and where a genuinely non-deterministic component makes that impossible — some GPU kernels, for instance — isolate that component and record its output as an input to the rest.
How does this interact with the privacy budget?
Regenerating a release from its manifest does not spend additional budget, because it produces the same artifact rather than a new observation of the population — but only if it genuinely reproduces. A “regeneration” that draws fresh noise is a second release and must be accounted as one, which is another reason the reproduction test matters. See diagnosing epsilon budget exhaustion across releases.