The full validation suite takes forty minutes. Nobody will wait forty minutes on a pull request, so the suite runs nightly, so defects are found a day after they are introduced by somebody who has moved on to something else.
Part of CI/CD Integration for Spatial Data: this page is about the split — what runs on every commit, what runs before a release, and how to make the first of those genuinely useful rather than a token.
Two distributions matter, and they are not the same shape.
Cost is heavily concentrated. In a typical spatial suite, the adversarial privacy checks and the full-extent generation account for most of the wall clock, and the manifest and structural checks account for almost none. That is the distribution most teams know about.
Value on a pull request is concentrated too, and in the opposite place. A pull request changes code; the defects it introduces are overwhelmingly structural and contractual — a CRS assertion removed, a schema field renamed, a geometry repair that now produces slivers. The expensive statistical and adversarial checks mostly detect data drift, which a code change rarely causes and which a nightly run catches perfectly well.
Cost and pull-request value across the gate set: the cheapest gates catch most of what a code change actually breaks.
That mismatch is the whole opportunity. A pull-request suite made of the cheap gates plus a small sample of the expensive ones catches the great majority of what a code change breaks, in a couple of minutes.
import time
from contextlib import contextmanager
@contextmanagerdeftimed(name:str, into:dict):
t = time.perf_counter()yield
into[name]= time.perf_counter()- t
defprofile_suite(gates:list, artifact)->dict:
timings ={}for gate in gates:with timed(gate.name, timings):
gate.run(artifact)
total =sum(timings.values())return{"total_s": total,"share":{k: v / total for k, v insorted(timings.items(),
key=lambda kv:-kv[1])}}
Run this before deciding anything. The intuition about which gate is slow is wrong more often than not — a check that feels heavy because it is conceptually complicated is frequently cheaper than a simple one that happens to read the whole artifact twice.
These read the manifest and nothing else. They catch a removed assertion, a renamed field and an unpinned contract — the exact failures a code change produces — and they cost nothing, so they can run before anything else and fail fast.
The expensive gates become affordable when run on a fixed, small, deterministically chosen extent:
python
import hashlib
defpr_sample(contract:dict, n_tiles:int=4)->list[tuple[int,int]]:"""A small, fixed set of tiles — same on every run, chosen to be representative.
Derived from the contract rather than the commit, so the sample does not move
between pull requests and two runs are comparable.
"""
tiles = contract["tile_index"]
key = contract["contract_version"].encode()
scored =sorted(tiles, key=lambda t: hashlib.blake2b(f"{t}".encode(), key=key, digest_size=8).digest())# one tile from each declared area type, so the sample is not all rural
by_type:dict={}for t in scored:
by_type.setdefault(tiles[t]["area_type"], t)returnsorted(by_type.values())[:n_tiles]
Two properties matter. The sample is fixed — derived from the contract, not from the commit or the clock — so two pull-request runs are comparable and a failure is reproducible. And it is stratified by area type, because an unstratified sample of four tiles is usually four rural tiles, and rural tiles pass everything.
Everything else: the full extent, the adversarial privacy checks, the perceptual and duplicate scans over the whole artifact. These run on the release branch and on a schedule, and their job is to catch data drift rather than code defects.
The three tiers against a five-minute pull-request budget: tier one and a four-tile tier two fit inside it with room to spare.
The other half of the budget goes on producing something to validate. Regenerating a sample extent on every pull request is usually the single largest line item, and it is avoidable:
python
deffixture_key(contract:dict, generator_version:str, tiles:list)->str:"""A fixture is reusable while the contract, the generator and the tiles are unchanged."""
h = hashlib.blake2b(digest_size=16)for part in(contract["contract_version"], generator_version,repr(sorted(tiles))):
h.update(part.encode())return h.hexdigest()
Keying on the generator version means the fixture is invalidated exactly when the code that produces it changes — which is the correct behaviour, and is also why the cache hits on the many pull requests that change validation code, documentation or configuration rather than the generator itself.
Caching the result rather than the fixture is the tempting shortcut and it is wrong: it means a pull request that changes only a validation threshold will report the previous verdict.
import pytest
PR_BUDGET_S =300deftest_pr_suite_fits_the_budget(pr_gates, fixture):
profile = profile_suite(pr_gates, fixture)assert profile["total_s"]< PR_BUDGET_S,(f"pull-request suite takes {profile['total_s']:.0f}s; "f"slowest: {list(profile['share'])[:3]}")deftest_tier_one_fails_fast(pr_gates, broken_manifest):"""A manifest defect must fail before any generation happens."""
t = time.perf_counter()with pytest.raises(ValidationError):
run_suite(pr_gates, broken_manifest)assert time.perf_counter()- t <2.0deftest_sample_is_stable_across_runs(contract):assert pr_sample(contract)== pr_sample(contract)
Asserting the budget in the suite is what stops it drifting back. A suite with no budget test grows by thirty seconds a quarter and is back at forty minutes within two years, at which point somebody moves it to nightly again and the cycle repeats.
The sample passes and the full extent fails. Expected occasionally, and it is not a failure of the approach — it is the tier-three run doing its job. What matters is whether the failure class is one the sample could have caught: if it is, the stratification needs another axis.
Measured over 4,000 simulated samples: a stratified sample of four tiles detects roughly what a uniform sample of twelve does.
A pull request that changes the contract. The fixture key changes, so the cache misses and the run is slow. That is correct: a contract change is exactly when the expensive validation is worth paying for.
Flaky expensive gates. A gate that fails intermittently on the full extent will fail intermittently on the sample too, and on a pull request that is far more damaging because it blocks somebody. Fix the flake or move the gate to tier three; a gate people learn to re-run is a gate that is no longer read.
Everything already fits. Then the split is not needed and adding it is complexity for nothing. Profile first; the answer is sometimes that the suite is fine and the perception of slowness came from a single pathological gate.
Splitting a suite moves gates out of the pull request, and it is worth being deliberate about
where they land, because “not on the pull request” is not a destination.
The release branch gets the full suite, and it runs before promotion rather than after merge.
That is the important detail: a gate that runs after merge finds defects in code that is already
on the main branch, which means the fix is a second change rather than an amendment to the first.
The nightly schedule gets the gates whose job is detecting data drift rather than code
defects — the distributional comparisons against a reference, the duplicate scans over the full
artifact, the adversarial checks. Their failure mode is gradual, so daily is frequent enough, and
running them on a schedule rather than on a trigger means their cost is predictable.
Nothing gets dropped. The temptation, once a gate is out of the pull request, is to notice
that it has not failed in months and quietly remove it. The gates that have not failed in months
are usually the ones holding a property everybody now takes for granted, which is exactly the
property that will regress the moment the gate is gone.
One more piece is worth adding at the same time: a weekly report of which gates fired, in
which tier, over the previous week. It costs nothing to produce and it is the only thing that
makes the split reviewable — a tier-three gate that fires regularly is one that belongs in tier
two, and a tier-two gate that has never fired is one worth questioning.