Scoring Metadata Completeness in a CI Gate
This guide implements the scoring model and the blocking gate described in Metadata Quality Assurance & Record Scoring, and runs both in the ingestion pipeline. It sits within the Metadata Catalog Automation & Ingestion Workflows framework.
Prerequisites
- Records available in a normalised internal form after the crosswalk, per mapping ISO 19115 to Dublin Core for CSW.
- A CI runner able to execute the check against a harvest batch before it is published.
- Provenance on every record — at minimum the source identifier — so the report can be grouped.
- Agreement on the blocking rules with whoever will receive a rejection.
Separate the two outputs from the start
The check produces two things from one pass: a pass-or-fail verdict from a small set of blocking rules, and a score from everything else. Keeping them separate in the code is what keeps them separate in practice.
Step-by-step implementation
1. Express the rules as data, not as code branches
# rules.py — every rule is a small callable with a weight and a plain-language
# description. Blocking rules carry weight 0 and are listed separately.
from dataclasses import dataclass
from typing import Callable
@dataclass(frozen=True)
class Rule:
key: str
describe: str # written for a data custodian, not an engineer
weight: int # 0 for blocking rules
check: Callable[[dict], bool]
def has_extent(r):
b = r.get("extent") or {}
return all(isinstance(b.get(k), (int, float)) for k in ("minx", "miny", "maxx", "maxy")) \
and b["maxx"] > b["minx"] and b["maxy"] > b["miny"]
BLOCKING = [
Rule("identifier", "has a stable identifier we can update against", 0,
lambda r: bool(r.get("identifier"))),
Rule("extent_valid", "gives an area, so it can be found on a map", 0, has_extent),
Rule("crs_known", "uses a coordinate reference system this portal publishes", 0,
lambda r: r.get("crs") in PUBLISHED_CRS),
Rule("date_parseable", "has a publication date we can read", 0,
lambda r: r.get("published") is not None),
]
SCORED = [
Rule("keywords", "has at least three keywords", 12,
lambda r: len(r.get("keywords") or []) >= 3),
Rule("abstract_len", "describes the data in more than a sentence", 14,
lambda r: len((r.get("abstract") or "").split()) >= 40),
Rule("lineage", "says where the data came from", 10,
lambda r: bool(r.get("lineage"))),
Rule("temporal", "states the period the data covers", 10,
lambda r: bool(r.get("temporal_extent"))),
Rule("contact", "names somebody who can answer questions about it", 12,
lambda r: bool((r.get("contact") or {}).get("email"))),
Rule("licence", "states what may be done with it", 12,
lambda r: bool(r.get("licence"))),
Rule("distribution", "offers at least one way to get the data", 18,
lambda r: bool(r.get("distributions"))),
Rule("format", "says what format the data is in", 6,
lambda r: all(d.get("format") for d in (r.get("distributions") or []))),
Rule("title_specific", "has a title that distinguishes it from its neighbours", 6,
lambda r: len((r.get("title") or "")) >= 15),
]
2. Evaluate once, keep the breakdown
# score.py — the breakdown is stored, not just the total, because that is what
# makes a disputed score a conversation rather than an argument.
def evaluate(record: dict) -> dict:
blocked = [r.key for r in BLOCKING if not r.check(record)]
results = {r.key: bool(r.check(record)) for r in SCORED}
earned = sum(r.weight for r in SCORED if results[r.key])
possible = sum(r.weight for r in SCORED)
return {
"identifier": record.get("identifier"),
"source": record.get("provenance", {}).get("source_id"),
"blocked_by": blocked,
"score": round(100 * earned / possible),
"rules": results,
"model_version": MODEL_VERSION, # so a trend break is explicable
}
3. Run it in the pipeline and fail only on blocking rules
# gate.py — exits non-zero only when a blocking rule failed. The score is
# always reported and never decides the exit code.
import json, sys, collections
def main(batch_path: str) -> int:
results = [evaluate(r) for r in json.load(open(batch_path))]
blocked = [x for x in results if x["blocked_by"]]
by_source = collections.defaultdict(list)
for x in results:
by_source[x["source"]].append(x)
print(f"{len(results)} records, {len(blocked)} blocked")
for source, rows in sorted(by_source.items()):
avg = round(sum(x["score"] for x in rows) / len(rows))
print(f" {source:32s} n={len(rows):5d} score={avg:3d}")
json.dump(results, open("quality-report.json", "w"))
return 1 if blocked else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1]))
# .github/workflows/harvest-quality.yml
- name: Metadata quality gate
run: python3 gate.py harvest-batch.json
- name: Publish the report regardless of the verdict
if: always()
uses: actions/upload-artifact@v4
with: {name: quality-report, path: quality-report.json}
4. Turn the report into work, ordered by leverage
Tracking the trend without misreading it
A score is most useful as a series, and a series is easy to misread when the thing being measured changes underneath it. Three effects account for nearly every confusing movement.
Publish the scored count and the model version alongside every figure. It costs two extra columns and it removes most of the meetings that begin with somebody asking why the number moved.
Verification
# 1. A record missing an extent is blocked, and the reason is named
echo '[{"identifier":"x","crs":"EPSG:27700","published":"2026-01-01"}]' > one.json
python3 gate.py one.json; echo "exit=$?"
# expect: exit=1, and extent_valid listed in blocked_by
# 2. A thin but valid record is NOT blocked, and scores low
python3 - <<'EOF'
from score import evaluate
r = {"identifier":"y","crs":"EPSG:27700","published":"2026-01-01",
"extent":{"minx":-1.4,"miny":50.8,"maxx":-1.2,"maxy":51.0},
"title":"Parcels","abstract":"Parcels."}
out = evaluate(r); print(out["blocked_by"], out["score"])
EOF
# expect: [] and a low score — reported, not rejected
# 3. The breakdown is stored per record
jq '.[0].rules' quality-report.json
# expect: every scored rule with a boolean
# 4. The report groups by source
python3 gate.py harvest-batch.json | sed -n '2,10p'
# expect: one line per source with a record count and an average
# 5. The model version travels with the score
jq -r '.[0].model_version' quality-report.json
# expect: the current version — a trend break must be explicable
Troubleshooting matrix
| Symptom | Likely cause | Fix |
|---|---|---|
| A whole source is blocked at once | A blocking rule that is common upstream, not a data problem | Move it to the score; blocking rules must be rare |
| Scores dropped sharply with no content change | The model changed without a version bump | Version the model; annotate the break rather than explaining it later |
| Publishers dispute the score and nothing improves | Only the total is stored | Store and expose the per-rule breakdown |
| The gate is slow on large harvests | Rules re-parsing the record per check | Evaluate once into a normalised dict; keep rules pure functions |
| Records pass but users cannot find them | Extent validity checked as presence, not as usability | Check the ordering and plausibility of the bounds, not just the fields |
| The report is ignored | Grouped by record, and sent to a platform team | Group by source and rule; send each source its own |
| Blocked records vanish silently | The gate stops the batch without recording what was rejected | Persist rejected records and their reasons for triage |
FAQ
Should the gate fail the whole batch or only the affected records?
Only the affected records, with the rest published — otherwise one malformed record from one source stops an entire night’s harvest, and the pressure to weaken the rules becomes irresistible. Persist the rejected ones with their reasons so they can be triaged, and report the count per source.
What weight should each rule carry?
Something defensible rather than something precise. Weight by consequence for a user: a missing distribution link or extent costs more than a missing lineage statement, because the first two prevent use and the third merely reduces confidence. Write the reasoning down next to the weights so the next revision has something to argue with.
Should the score appear in the catalogue’s public interface?
A simplified version is useful — a small indicator that a record is thinly documented helps a user decide whether to rely on it and creates gentle pressure to improve. Publishing the raw number invites arguments about the model; naming what is missing is more useful and harder to dispute.
How do we avoid the score becoming a target?
By keeping it as a report rather than an objective, and by reviewing what it measures against what users need at least annually. A score that has become a target without being revisited is a good predictor of a catalogue where every abstract is exactly forty words long and none of them is useful.
Where should the rules themselves live?
In version control, next to the crosswalk they depend on, and reviewed like any other code. Rule changes have the same reach as mapping changes — one edit can move thousands of records — and a rule added to a running system without review is how a quality programme surprises its own operators.
Should the gate run on every harvest or only on new sources?
Every harvest, because records change and a source that was well-behaved last month can start emitting records without extents after a system upgrade on their side. The cost is negligible — the rules are pure functions over a parsed record — and running it only for new sources means the most common failure mode, a regression at an established source, is precisely the one nobody checks for.
Related
- Deduplicating Harvested Records by Fingerprint — the other half of aggregate quality.
- Detecting Broken Distribution Links in Catalog Records — the rule that must run on a schedule.
- Validating ISO 19115 Metadata Before Ingestion — schema validity, which this complements.
Up one level: Metadata Quality Assurance & Record Scoring.