Tracking Compliance Trend Over Reporting Periods
Snapshot the metric set at the close of every reporting period, store the snapshots as rows rather than recomputing history from current data, and report the change alongside the level — a compliance figure with no direction tells a reader whether the catalogue is good, and never whether the programme is working.
Compliance dashboards overwhelmingly show current state. That answers the question a manager asks once and the question a team asks never, because what a team needs to know is whether the last quarter of effort moved anything. Trend requires snapshots, and snapshots require deciding in advance what to freeze. This guide sits under compliance dashboards for spatial catalogs, within Spatial Data Audit Reporting & Compliance Governance.
Automated Python Implementation
#!/usr/bin/env python3
"""Snapshot compliance metrics per period and report the trend."""
import datetime
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS metric_snapshot (
period TEXT NOT NULL, -- '2026-Q1'
closed_on TEXT NOT NULL,
metric TEXT NOT NULL, -- 'licence_recorded'
scope TEXT NOT NULL, -- 'catalogue' or a team name
numerator INTEGER NOT NULL,
denominator INTEGER NOT NULL,
rule_version TEXT NOT NULL,
PRIMARY KEY (period, metric, scope)
)
"""
def snapshot(con, period, metrics, rule_version, closed_on=None):
"""Freeze the metric set for a closed period. Idempotent by primary key."""
closed_on = closed_on or datetime.date.today().isoformat()
con.execute(SCHEMA)
for (metric, scope), (numerator, denominator) in sorted(metrics.items()):
con.execute(
"INSERT OR REPLACE INTO metric_snapshot"
" (period, closed_on, metric, scope, numerator, denominator, rule_version)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(period, closed_on, metric, scope, numerator, denominator, rule_version),
)
con.commit()
def series(con, metric, scope="catalogue"):
rows = con.execute(
"SELECT period, numerator, denominator, rule_version FROM metric_snapshot"
" WHERE metric = ? AND scope = ? ORDER BY period",
(metric, scope),
).fetchall()
return [
{"period": period, "rate": numerator / denominator if denominator else None,
"numerator": numerator, "denominator": denominator, "rule_version": version}
for period, numerator, denominator, version in rows
]
def trend(points):
"""Change against the previous period, with the caveats that matter."""
if len(points) < 2:
return {"direction": "insufficient history", "points": len(points)}
current, previous = points[-1], points[-2]
delta = (current["rate"] or 0) - (previous["rate"] or 0)
return {
"current_rate": current["rate"],
"previous_rate": previous["rate"],
"delta": delta,
"direction": "improving" if delta > 0.005
else "declining" if delta < -0.005 else "flat",
"denominator_changed": current["denominator"] != previous["denominator"],
"rules_changed": current["rule_version"] != previous["rule_version"],
}
The two boolean flags in trend are the whole reason this is worth doing carefully.
denominator_changed says whether the catalogue grew. A compliance rate that fell from 74% to 68% because two hundred undocumented legacy datasets were ingested is a different story from one that fell because existing datasets decayed, and the rate alone cannot distinguish them.
rules_changed says whether the measurement moved rather than the catalogue. Adding a lint rule lowers every subsequent rate, and a chart that shows the drop without noting the rule change reports a regression that did not happen. Stamping the rule version on the snapshot makes the discontinuity explicit and lets a reader see exactly where the definition changed.
Validation and Pipeline Integration
def test_snapshot_is_idempotent(tmp_path):
con = sqlite3.connect(":memory:")
metrics = {("licence_recorded", "catalogue"): (300, 400)}
snapshot(con, "2026-Q1", metrics, "v3")
snapshot(con, "2026-Q1", metrics, "v3")
assert con.execute("SELECT count(*) FROM metric_snapshot").fetchone()[0] == 1
def test_rule_change_is_visible_in_the_trend():
points = [
{"period": "2026-Q1", "rate": 0.74, "denominator": 400, "rule_version": "v3"},
{"period": "2026-Q2", "rate": 0.68, "denominator": 400, "rule_version": "v4"},
]
assert trend(points)["rules_changed"] is True
def test_growth_is_distinguished_from_decay():
points = [
{"period": "2026-Q1", "rate": 0.74, "denominator": 400, "rule_version": "v3"},
{"period": "2026-Q2", "rate": 0.68, "denominator": 600, "rule_version": "v3"},
]
assert trend(points)["denominator_changed"] is True
Take the snapshot in the same job that closes the period, immediately after the final results are written, and never recompute a closed period from current data. Recomputation is tempting — it looks like a correction — and it silently rewrites what was reported, which means a figure quoted in a past report can no longer be reproduced from the system that produced it.
Choosing the Period and Sticking to It
The reporting period should be the one the organisation already uses for something else — a quarter, a release cycle, a governance meeting — because a period nobody else recognises produces reports nobody reads.
Two properties matter more than the length.
It must be fixed. Comparing a six-week period against a thirteen-week one produces a change that is partly an artefact of the window. Where an organisation genuinely changes cadence, the honest treatment is to start a new series rather than to splice the old one onto it.
It must close cleanly. A period whose cut-off is ambiguous — results arriving after the close but describing work inside it — yields snapshots that differ depending on when they were taken. Defining the cut-off as an instant, and treating anything arriving after it as belonging to the next period regardless of when the work happened, is arbitrary and reproducible, which is the correct trade for a reporting boundary.
The temptation to report monthly is worth resisting unless the underlying data moves monthly. A metric computed over a catalogue that changes twice a quarter produces eleven months of noise and one month of signal, and the noise trains readers to ignore the series. Quarterly is a good default for catalogue-level compliance and matches the cadence at which remediation work actually completes.
Presenting a Trend Without Overclaiming
Two or three snapshots do not establish a direction, and a chart drawn through them implies one. The presentation should be as careful as the measurement.
Say how many periods there are. A series of three labelled as a trend invites a reader to extrapolate from noise. Stating the count, and refusing to describe a direction below four points, costs nothing and prevents the most common misreading.
Mark the discontinuities on the chart. A vertical rule where the rule version changed, annotated with what changed, converts a confusing step into an explained one. Without it, the first question at every review is about that step, and the answer has to be reconstructed each time.
Show the count alongside the rate. A rate of 72% over 598 datasets and the same rate over 40 are different claims, and a chart of rates alone presents them identically. Putting the denominator in the axis label is enough.
The failure this guards against is not statistical sophistication but ordinary over-reading: a programme reporting three quarters of improvement, drawing a line, and committing to a target the line implies. The measurement supports a statement about what happened and not about what will.
Long-Term Compliance Best Practices
- Snapshot at close, never recompute. A recomputed period silently rewrites what was reported.
- Store the numerator and denominator, not the rate. A rate cannot be recombined across scopes; counts can.
- Stamp the rule version on every snapshot. It is the only way to distinguish a measurement change from a real one.
- Report direction alongside level. A level answers whether the catalogue is good; direction answers whether the programme is working.
- Keep a snapshot even when nothing changed. A gap in the series is indistinguishable from a period nobody measured.
Related
- Compliance Dashboards for Spatial Catalogs — the metric definitions this series snapshots
- Aggregating Compliance Metrics Across a Dataset Inventory — computing the numbers that get frozen
- Automated Compliance Report Generation — the period report the snapshot feeds
- Packaging an Evidence Bundle for an External Auditor — the same period boundary, used as an evidence scope