A consumer’s pipeline broke on the new release. Every gate passed, the geometry is valid, the statistics are within tolerance, and a column that used to be an integer is now a float — or a categorical field grew a value nobody declared.
Part of Artifact Versioning & Lineage: this page is the class of change that is invisible to every quality gate and visible immediately to every consumer.
The gates a synthetic spatial pipeline runs — geometry validity, envelope containment, statistical parity, privacy accounting — all take the schema as given and check the values inside it. None of them compares this release’s shape to the last one’s, because none of them has the last one.
That leaves a category of change that passes everything and breaks consumers on contact:
A column appears or disappears. Additive changes break consumers doing strict column checks; removals break everyone.
A dtype widens or narrows.int32 becomes int64 because a value overflowed, or a nullable integer becomes a float because a null appeared. Both are silent, both change the on-disk representation, and both break a consumer with a fixed reader schema.
A categorical gains a value. The generator learned a new land-use class, or a fallback introduced an unknown. A consumer with an enum mapping now has an unmapped value, and depending on their code that is either an exception or a silently dropped row.
Nullability changes. A column that was never null now is, because a fallback rule started firing. Nothing about the values is wrong; every consumer that assumed non-null is.
Units or CRS change inside an unchanged column name. The nastiest of the set, because the shape is identical and only the meaning moved.
Five drift classes against the gates that might catch them: the quality gates see none, and a shape comparison sees four of the five.
The fifth class is the one that stays invisible even to a schema check, and it is the reason a contract carries units and a CRS rather than only names and types.
import pandas as pd
previous = pd.DataFrame({"cell_id": pd.array([1,2,3], dtype="int32"),"land_use": pd.Categorical(["residential","retail","residential"]),"density":[12.4,88.1,9.0],})
current = pd.DataFrame({"cell_id": pd.array([1,2,3], dtype="int64"),# widened"land_use": pd.Categorical(["residential","retail","unknown"]),# new value"density":[12.4,88.1,None],# now nullable})for name, df in(("previous", previous),("current", current)):print(name,{c:str(df[c].dtype)for c in df.columns})
Every value in current is legitimate. The geometry gate has nothing to look at, the statistical gate sees a mean within tolerance, and the privacy ledger is unaffected. A consumer reading with a fixed Arrow schema fails on the first row.
from dataclasses import dataclass, asdict
@dataclass(frozen=True)classColumnShape:
name:str
dtype:str
nullable:bool
categories:tuple|None# for categoricals, the declared value set
unit:str|None# from the contract, not inferred
crs:str|None# for geometry columnsdefdescribe(df, contract:dict)->list[ColumnShape]:
out =[]for col insorted(df.columns):
cats =tuple(sorted(map(str, df[col].cat.categories))) \
ifstr(df[col].dtype)=="category"elseNone
out.append(ColumnShape(
name=col,
dtype=str(df[col].dtype),
nullable=bool(df[col].isna().any()),
categories=cats,
unit=contract["columns"].get(col,{}).get("unit"),
crs=contract["columns"].get(col,{}).get("crs"),))return out
Sorting the columns matters for the same reason it does everywhere else in this area: an unsorted description changes when nothing changed, and a description that changes spuriously is one nobody reads.
Note that unit and crs come from the contract rather than from the data. They cannot be inferred — a float column of distances looks identical in metres and feet — which is exactly why the fifth drift class is invisible without them.
The classification is the policy, and writing it down is most of the value. “Widening an integer is additive, becoming nullable is breaking” is a decision somebody has to make, and having it in code means it is made once rather than argued each time.
defrequired_bump(issues:list[tuple[str,str]])->str:
kinds ={kind for kind, _ in issues}return"major"if BREAKING in kinds else("minor"if ADDITIVE in kinds else"patch")defgate_promotion(prev_schema, cur_schema, declared_version:str, previous_version:str):
issues = diff_schemas(prev_schema, cur_schema)
need = required_bump(issues)
got = bump_kind(previous_version, declared_version)if got != need:raise SystemExit(f"schema requires a {need} bump, release declares {got}\n"+"\n".join(f" {k}: {m}"for k, m in issues if k != SAFE))
This is what makes the version number mean something. Without it, the semantic version is a label somebody types; with it, it is derived from the schema difference and a consumer reading “major” knows exactly what class of change to expect.
The compatibility policy as a table: every difference maps to a bump, and the bump is derived rather than declared.
deftest_schema_is_compatible_with_the_current_release(candidate, registry, contract):
current = registry.current(candidate["name"])
prev = load_schema(current["artifact_sha"])
cur = describe(candidate["frame"], contract)
issues =[i for i in diff_schemas(prev, cur)if i[0]!= SAFE]assert required_bump(issues)== bump_kind(current["version"], candidate["version"]), issues
deftest_contract_declares_units_for_every_numeric_column(contract, frame):
missing =[c for c in frame.columns
if pd.api.types.is_numeric_dtype(frame[c])andnot contract["columns"].get(c,{}).get("unit")]assertnot missing,f"no unit declared for {missing} — unit drift will be invisible"
The second test is the one that closes the fifth class. A numeric column with no declared unit is a column whose meaning can change without any detectable difference, and requiring the declaration is the only defence.
The first release has nothing to compare against. Record its schema and let the check pass with a note rather than skipping it silently, so the absence is visible in the promotion record rather than looking like a pass.
Only a description stored in the registry is available before the release exists — which is the only point at which failing the check costs nothing.
A column is renamed. The diff sees a removal and an addition, and reports a breaking change plus an additive one, which is correct but unhelpful. A rename map in the contract lets the check report it as a single rename and still classify it as breaking.
Categories that legitimately grow every release. Some vocabularies are open by nature. Declare the column as open in the contract, and check that new values match a declared pattern rather than a declared set — an unbounded enum is still checkable, just differently.
Nullability that flickers. A column is nullable in one release and not the next because the fallback happened not to fire. Derive nullability from the contract’s declaration rather than from the data, and check the data against it; otherwise the diff reports a breaking change every time the input happens to be complete.
A compatibility check that runs sometimes is a compatibility check that will be missing when it
matters, so it is worth making it fast enough to be unconditional.
The schema description is small — a few hundred bytes for a wide table — so storing one per
release costs nothing and the comparison is a dictionary diff. What costs is producing it, if
the implementation reads the whole artifact to find out whether a column is nullable or which
categories are present. On a large release that is a full scan.
Two things avoid it. Most columnar formats carry per-column statistics in their footer — null
counts, min and max, and for dictionary-encoded columns the dictionary itself — so the description
can be assembled from metadata without touching a data page. And where a statistic genuinely
requires the data, compute it during generation rather than afterwards: the generator already
visits every value, and accumulating a null count and a category set as it goes is free relative
to what it is already doing.
The result is a check that adds milliseconds to promotion rather than minutes, which is the
difference between a gate that runs on every release and one somebody disables during a busy
week and forgets to re-enable.
There is a second reason to compute the description during generation rather than from the
artifact: it is then a declaration rather than an observation. A description derived from the
data says what happened to be there this time; one produced by the generator from the contract
says what was supposed to be there, and the difference between the two is itself worth asserting.