Continuous Integration Checks for Cadastral Pipelines
A general-purpose CI configuration checks that the code compiles and the tests pass; a cadastral pipeline needs it to check several things that no linter knows about. This guide, part of testing and CI for coordinate transformation code, sets out the domain-specific gates worth adding — environment verification, refusal coverage, audit completeness and determinism — and the order to run them in so that a failure points at its cause rather than at everything downstream of it.
Ordering the Gates
The ordering principle is the same one that governs the pipeline itself: cheapest and most fundamental first, so that a single root cause produces a single failure rather than a cascade.
Figure — gates in causal order, so one root cause produces one failure.
- Environment verification. PROJ, EPSG database and grid checksums against the lock file. When this fails, everything numerical below it will fail too, and only this failure is informative.
- Static checks. Linting, typing and the pure-arithmetic unit tests, which need no data at all.
- Refusal tests. The guards — extent, sentinel, epoch, ensemble CRS, tolerance. Fast, and they exercise the code least likely to be exercised anywhere else.
- Golden vectors. Numerical agreement with independently sourced values.
- Property tests. Round trip, determinism, batch invariance, order independence.
- End-to-end batch. A small but realistic run, checked for row accounting, audit coverage and byte-for-byte reproducibility across two worker counts.
Gates one to five should complete in seconds. Gate six is minutes, and it belongs on merges rather than on every push.
The Determinism Gate
The check most specific to this domain, and the one most often missing, is that the same input produces byte-identical output under different execution shapes. It is the automated form of the invariant that batch transformation and automation treats as non-negotiable.
Figure — the same input, two execution shapes, one digest.
from __future__ import annotations
import hashlib
import json
from pathlib import Path
def digest_result(rows: list[dict]) -> str:
"""Canonical digest of a transformed batch.
Coordinates are formatted at a fixed precision with an explicit rounding mode
before hashing, so the digest describes the result rather than the float
formatting of whatever machine produced it.
"""
canonical = [
{
"id": r["id"],
"e": f"{r['easting']:.4f}",
"n": f"{r['northing']:.4f}",
"op": r["operation_id"],
}
for r in sorted(rows, key=lambda r: r["id"])
]
blob = json.dumps(canonical, separators=(",", ":"), sort_keys=True)
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def assert_worker_count_invariant(run, sample: Path) -> None:
"""The result must not depend on how the work was divided."""
one = digest_result(run(sample, workers=1))
many = digest_result(run(sample, workers=8))
assert one == many, (
"the transformed result depends on the worker count — look for rounding "
"applied per chunk, results collected in completion order, or a reduction "
"whose order varies"
)
def assert_rows_accounted_for(inputs: list[dict], outputs: list[dict],
rejected: list[dict]) -> None:
"""Every input row must appear exactly once in outputs or rejections."""
n_in, n_out, n_rej = len(inputs), len(outputs), len(rejected)
assert n_in == n_out + n_rej, (
f"{n_in} rows in, {n_out} out plus {n_rej} rejected — "
f"{n_in - n_out - n_rej} row(s) vanished"
)
ids_in = {r["id"] for r in inputs}
ids_seen = {r["id"] for r in outputs} | {r["id"] for r in rejected}
assert ids_in == ids_seen, "row identities do not match between input and output"
What Each Gate Catches
| Gate | Typical real failure it catches |
|---|---|
| Environment | A PROJ or grid upgrade that moved coordinates |
| Static | Ordinary defects, before any data is involved |
| Refusal | A guard that stopped guarding during a refactor |
| Golden vector | A silently changed coordinate operation |
| Property | An edge case at the boundary of the area of use |
| Row accounting | Exceptions swallowed per chunk, dropping rows |
| Determinism | Rounding per partition; completion-order collection |
| Audit coverage | Metadata written for some rows and not others |
Figure — how often each gate fires, from a year of a real pipeline’s builds.
Worked Example: a Minimal Job Definition
# Gates run in the order that makes a single root cause produce a single failure.
jobs:
verify:
steps:
- run: python -m pipeline.verify_environment proj.lock.json
- run: ruff check . && mypy pipeline
- run: pytest -m refusal -q
- run: pytest -m golden -q
- run: pytest -m property -q
endtoend:
needs: verify
steps:
- run: pytest -m endtoend -q # row accounting, determinism, audit coverage
The needs: verify line is doing real work: it stops the expensive end-to-end job from running at all when the environment has moved, which is both faster and clearer than letting it fail for a reason already reported above.
Validation Check
def assert_audit_covers_every_row(outputs: list[dict], audit: list[dict]) -> None:
"""An audit record that covers most of the rows covers none of the deliverable."""
missing = {r["id"] for r in outputs} - {a["id"] for a in audit}
assert not missing, (
f"{len(missing)} output row(s) have no audit record, e.g. "
f"{sorted(missing)[:3]} — the audit must be written by the same pass that "
f"writes the coordinates"
)
Common Mistakes
Running the end-to-end job first because it is the most realistic. It is also the slowest and the most derived: when the environment has changed, it fails for a reason five cheaper gates would have named in a second. Order by cost and by causality, not by realism.
Testing with grids absent and calling it a pass. Without the grid, the pipeline exercises its fallback path, and every numerical assertion is then testing the wrong operation. The environment gate exists to make that impossible, and it only works if it runs first and fails hard.
Determinism checked at one worker count. Running the same shape twice proves repeatability, not invariance. The failure this gate exists to catch — rounding applied per chunk — only appears when the chunk boundaries move, which means at least two different worker counts or partition sizes.
Keeping the Suite Honest Over Time
A CI suite decays in predictable ways, and three habits keep this kind of suite useful rather than ceremonial.
Watch for tests that have stopped asserting. A refactor that changes an exception type turns a pytest.raises(ValueError) into a test that never runs its body; a golden vector whose tolerance was widened after an upgrade passes on anything. Reviewing the tolerances and the refusal messages once a release keeps both honest.
Treat a flaky test as a defect in the pipeline, not in the test. In most software an intermittent failure is annoying; here it is a report of nondeterminism in something that has to be bitwise reproducible. The correct response is to find the source — threading, iteration order, an unpinned dependency — not to add a retry.
Re-baseline deliberately and record why. Every re-baseline is a statement that the numbers moved for a reason you have identified. A commit that changes both the lock file and the golden vectors, with a message naming the upgrade and the conclusion, is the entire audit trail for why a deliverable produced this month differs from one produced last month.
None of this is specific to geodesy, but the consequences are: in most projects a decayed test suite costs debugging time, and here it costs the ability to say what produced a coordinate that somebody is disputing.
Frequently Asked Questions
Should CI run against real production data?
A small, committed sample that exercises the interesting cases is better than a large extract of production: it is reproducible, it can be reasoned about, and it will not leak anything. Where production data has properties no synthetic sample reproduces — an unusual CRS, a region with sparse grid coverage — take a minimal excerpt and commit it deliberately, with a note about why those rows are there.
How do I keep the end-to-end job from becoming slow?
Bound it by design: a few thousand rows across the interesting cases, not a realistic volume. Its purpose is to test the shape of the pipeline — partitioning, ordering, accounting, audit assembly — and those are all exercised at small scale. Throughput belongs in a benchmark, run deliberately, not in a gate on every merge.
What should happen when the environment gate fails?
The build stops and someone decides. That is the entire point: the alternative is a silent re-baseline, which is the failure mode the gate exists to prevent. The decision is usually quick — a series refinement in the last digits is accepted and re-baselined, a centimetre shift in one region is investigated first.
Do these gates belong in a research or one-off project?
The environment record does, at minimum, because a result nobody can reproduce is worth less than it appears even in research. The rest scale with consequence: a one-off analysis needs golden vectors and determinism far less than a pipeline whose output is submitted to a land-records agency and defended years later.
How long should CI artefacts be kept?
Long enough to explain a deliverable — which means the environment record and the golden-vector output for any build that produced one should outlive the build logs by years, and belong beside the deliverable rather than in the CI system.