Replies: 3 comments
Seal backfill on production — findingsThe whole-catalog backfill was run against production on 2026-09-10. This is what it did
1. The run
Batch size 100, 36 batches running in parallel through Cloud Tasks. This only worked because the 2. What the six criteria producedEvery feed now holds all six criteria. Confirmed status after the run, with the count of
A criterion denies the seal by failing, or by passing while still serving probation. Three
Note that 3. 603 feeds lost the seal
1,675 holders before, 1,101 after — a 34% loss among holders. What was solely responsible, for each of the 603:
So a little over half the losses are attributable to one criterion alone, and the rest to 4. What the march did to the five existing criteriaOnly 115 confirmed-status changes across 17,795 pre-existing rows — 0.6%:
1,321 rows had their failure date reset to the window edge
These had an observed
How wrong the date is depends on the criterion. For
A feed that is official today was almost certainly always official; the flag is set by hand and 5. Two policy behaviours that will generate questionsGrace and probation are uneven across the sixBoth values live in
One bad day costs six months. Two feeds where the effect was examined day by day:
The 730-day guard is the largest single source of failure
6. The nightly job races the dataset pipeline
So ~400 UNKNOWNs a night against ~90 feeds with actually-absent data. The backfill reads 7. Related: the nightly job had been failingWorth recording because it was found during this work and is now fixed.
The 09-10 nightly run processed 309 of 3,559 feeds. It was the first run after Attribution, precisely: the calendar check came with Tonight's 04:00 run is the first since the index landed — worth confirming it completes. 8. Still open
FilesBaseline was captured before the run and is the only surviving record of the pre-backfill
Post-run snapshots were not exported — 6.7M rows. Query The per-feed case analysis behind §5, and the run-by-run record from the copy of production |
Seal of Reliability — backfill QA runsTesting Started as a Written 2026-09-09/10. Every feed id and count comes from 1. EnvironmentEverything below — the scan and the backfill runs — goes against the prod duplicate 2. The scan
set -a && source config/.env.qa.ro && set +a
PGPASSWORD="$POSTGRES_PASSWORD" psql -h localhost -p 9902 -U "$POSTGRES_USER" \
-d MobilityDatabaseProdDuplicate -f misc/AI/seal-backfill-qa/fresh_continuous_scan.sqlIt emits one row per dataset pair.
3. Candidate feedsTen cases over the last 365 days. Counts are dataset pairs. All of these are selected by
Caveats
4. Worked example: ntd-80188 (case 1)Before
(observed status; confirmed stayed The runcurl -s -X POST http://localhost:8080 -H "Content-Type: application/json" \
-d '{"task":"backfill_seal_of_reliability","payload":{"stable_feed_ids":["ntd-80188"],"dry_run":false,"days_back":365,"end_date":"2026-09-08","only_missing":false}}' \
| python3 -m json.tool
Resolves to 366 days, 2025-09-08 → 2026-09-08, six criteria, no Result
Recorded days are reused only for their
The third row has teeth: a re-march over a feed the nightly job already tracks replaces Cost: 370 s before the 5. Case 4: mdb-929 and tld-89 (run 2026-09-09)Both
|
| observed | span | days |
|---|---|---|
| pass | 2025-09-08 → 2026-01-05 | 120 |
| unknown | 2026-01-06 → 01-07 | 2 |
| pass | 2026-01-08 → 2026-05-08 | 121 |
| fail | 2026-05-09 → 2026-09-08 | 123 |
tld-89 is far choppier — five separate failing streaks, the first from 2025-09-12:
| observed | span | days |
|---|---|---|
| pass | 2025-09-08 → 09-11 | 4 |
| fail | 2025-09-12 → 10-30 | 49 |
| pass | 2025-10-31 → 12-13 | 44 |
| fail | 2025-12-14 → 2026-01-05 | 23 |
| unknown | 2026-01-06 | 1 |
| fail | 2026-01-07 → 03-12 | 65 |
| pass | 2026-03-13 → 05-07 | 56 |
| fail | 2026-05-08 → 2026-09-08 | 124 |
Outcome
| mdb-929 | tld-89 | |
|---|---|---|
has_seal before |
false | true |
has_seal after |
false | false — revoked |
fresh_continuous |
fail/fail, first fail 2026-05-09, probation 2026-09-09 | fail/fail, first fail 2026-05-08, probation 2026-09-09 |
compliant |
fail/fail, first fail 2025-09-08 (window edge) | pass/pass |
Three things to note:
tld-89lost its seal (seals_revoked: 1) purely becausefresh_continuouswas
added — its other five criteria all pass. That is the criterion doing its job, but it
means enablingfresh_continuouswill revoke seals in bulk.mdb-929'scompliantcold-started at the window edge, 2025-09-08, the same
artefact asofficialon ntd-80188. Its pre-run value was 2026-09-05, from the nightly
job — real history replaced by a reconstruction.- Both show
probation_start= 2026-09-09, the day afterend_date, because both are
still failing when the march ends. Probation only starts once a criterion recovers, so
this is the "still broken at handover" state, not a served probation.
6. All twenty feeds (run 2026-09-09)
One run, all 20 case feeds: criterion_rows_written: 120,
snapshot_rows_written: 38496, 39.5 s. after_allcases_*.csv holds the result.
Three feeds marched less than 366 days, as case 10 predicts: tdg-84156 and tdg-84170
got 7 days each, tdg-79014 and tdg-80415 273 (created 2025-12-10).
Seals: 6 held before, 2 after
| feed | case | before | after |
|---|---|---|---|
tld-61 |
1 | held | held |
mdb-1224 |
7 | held | held |
tld-89 |
4 | held | revoked (in the case 4 run) |
mdb-2202 |
6 | held | revoked |
tld-5863 |
6 | held | revoked |
mdb-740 |
7 | held | revoked |
Every revocation came from fresh_continuous alone. Adding this criterion to a catalog
where the nightly job has been granting seals on five criteria will revoke in bulk — 4 of
6 in this sample.
Observed days per feed
| feed | case | pass | fail | unknown | days |
|---|---|---|---|---|---|
ntd-80188 |
1 | 366 | 0 | 0 | 366 |
tld-61 |
1 | 366 | 0 | 0 | 366 |
mdb-2091 |
1 | 366 | 0 | 0 | 366 |
mdb-2149 |
2 | 0 | 366 | 0 | 366 |
tld-752 |
3 | 0 | 366 | 0 | 366 |
mdb-929 |
4 | 241 | 123 | 2 | 366 |
tld-89 |
4 | 104 | 261 | 1 | 366 |
mdb-1931 |
5 | 290 | 76 | 0 | 366 |
tld-7878 |
5,9 | 308 | 56 | 2 | 366 |
tld-5863 |
6 | 18 | 348 | 0 | 366 |
mdb-2202 |
6 | 18 | 348 | 0 | 366 |
mdb-1224 |
7 | 360 | 6 | 0 | 366 |
mdb-740 |
7 | 357 | 9 | 0 | 366 |
tdg-79014 |
7 | 269 | 3 | 1 | 273 |
mdb-2727 |
8 | 0 | 0 | 366 | 366 |
tld-4456 |
8 | 17 | 60 | 289 | 366 |
mdb-982 |
9 | 362 | 4 | 0 | 366 |
tdg-80415 |
9 | 272 | 0 | 1 | 273 |
tdg-84156 |
10 | 6 | 0 | 1 | 7 |
tdg-84170 |
10 | 6 | 0 | 1 | 7 |
Case 7 shows the 180-day probation clearly
All three case 7 feeds pass at end_date with a handful of failing days, yet only one
keeps the seal — the difference is when the failures happened:
| feed | failing days | last failure | probation ends | seal |
|---|---|---|---|---|
mdb-1224 |
6 | 2025-11-08 | 2026-05-08 — expired | held |
mdb-740 |
9 | 2026-05-29 | 2026-11-26 | revoked |
tdg-79014 |
3 | 2026-04-22 | 2026-10-20 | (had none to lose) |
mdb-740 fails 9 days out of 366 — 97.5% clean — and still cannot hold the seal, because
its last failure was inside the probation horizon. With no grace period, a single bad day
costs 180 days. That is the behaviour most likely to surprise people when the criterion
goes live.
Probation states at end_date
- Still failing —
probation_start= 2026-09-09, the day afterend_date:
mdb-2149,tld-752,mdb-929,tld-89. Not a served probation; an open failure. - Recovered, probation running:
mdb-1931(from 2026-04-15),tld-7878(08-05),
mdb-2202andtld-5863(08-22),mdb-740(05-30),tdg-79014(04-23),
mdb-982(09-03). - Clean — no probation:
ntd-80188,tld-61,mdb-2091,mdb-1224,tld-4456,
tdg-80415,tdg-84156,tdg-84170. mdb-2727isunknown/never_evaluated— 366 UNKNOWN days, so it never produced
a verdict at all. Case 8 exactly.
Cases 2 and 3 cold-start at the window edge
mdb-2149 and tld-752 fail every marched day, so first_observed_failure_at is
2025-09-08 — march_start, not a date anyone observed. Same artefact as official on
ntd-80188 (§4) and compliant on mdb-929 (§5). It is inherent to a cold start against a
feed that was already failing when the window opened.
7. Whole catalog (run 2026-09-09/10)
All 3,559 eligible feeds, batches of 200 through
run_full_backfill.sh. The first pass covered 2,988 feeds in 82 minutes before the SSH
tunnel dropped mid-INSERT; a resume finished the remaining 458 in 14. Feeds commit
individually and only_missing skips finished ones, so the interruption cost nothing but
time.
feeds marched 3,446 (+113 already complete)
snapshot rows 6,547,332
criterion rows 20,676
wall clock 97 min (1.69 s/feed)
Result files: after_full_seal.csv, after_full_criterion.csv. Snapshots were not
exported — 6.5M rows is ~600 MB of CSV; query the table instead.
601 feeds lost the seal
| transition | feeds |
|---|---|
| held → held | 1,063 |
| held → lost | 632 |
| none → gained | 31 |
| none → none | 1,833 |
1,695 holders before, 1,094 after — a net loss of 601, or 35% of all seal holders.
Which criteria block the 632 that lost it:
| blocking criterion | feeds |
|---|---|
fresh_continuous |
501 |
fresh_coverage |
155 |
compliant |
111 |
available |
15 |
fresh_continuous is the sole blocker for 375 of the 632 (59%). Its own verdicts
across the catalog: 2,614 pass (73.4%), 830 fail (23.3%), 92 never_evaluated — feeds
that were UNKNOWN on every marched day and so produced no verdict at all.
The other five criteria barely moved
The march recomputed all six, but the five that already existed came out almost identical
to what the nightly job had:
| criterion | unchanged | changed |
|---|---|---|
official |
3,559 | 0 |
stable |
3,559 | 0 |
available |
3,544 | 15 |
compliant |
3,519 | 40 |
fresh_coverage |
3,491 | 68 |
123 changed rows out of 17,795 — 0.7%. That is a strong signal the reconstruction is
faithful: official and stable reproduce exactly, and the three data-dependent criteria
differ only where the 04:00 race (§8) left the nightly job with an UNKNOWN. The largest
single flip is never_evaluated → pass (22 for fresh_coverage, 11 for compliant).
1,275 rows had first_observed_failure_at reset to the window edge
The cold start seen on ntd-80188 (§4) and mdb-929 (§5) is catalog-wide:
| criterion | rows whose first_observed_failure_at became 2025-09-08 |
|---|---|
official |
549 |
fresh_coverage |
358 |
compliant |
335 |
stable |
33 |
resume_from_snapshot would not have helped. It seeds from the latest snapshot
strictly before march_start (_seed_states), and snapshots in this database begin
2026-08-27 while march_start was 2025-09-08 — nothing qualifies, so every pair
cold-starts with or without the flag. The flag is for a re-march whose window starts
inside existing snapshot history; it cannot help a window that reaches back further than
the history does. A cold start is inherent to backfilling past your snapshots, and
first_observed_failure_at landing on the window edge is its visible cost.
How wrong the date is depends on the criterion. Taking official as the worked case,
its 549 rows split:
official |
rows | is march_start wrong? |
|---|---|---|
null |
526 | No, it is a floor. The flag was never set, so the feed was failing before the window opened too. The window simply cannot express an earlier date |
false |
23 | Yes. The flag was actively set to false, and all 23 have an official_updated_at inside the window, so the failure began later than march_start claims |
A feed that is official = true today was almost certainly always official — the flag is
set manually, so its date records when someone got to it, not when the feed became
official. Projecting today's value backwards is therefore right for the 2,924 passing
feeds, and they get no failure timestamp at all.
So for official the genuinely wrong rows are 23, not 549. The fix is narrow: when
official is false and official_updated_at falls inside the window, start the streak
there. Two things to settle first — whether that timestamp tracks the flag specifically or
just any bulk write to the row (the dates cluster suspiciously: 431 in one month, 497 in
another), and whether a feed can go true → false at all, because if it can, the march is
marking pre-flip days as failing when they should pass.
The same split has not been done for fresh_coverage, compliant and stable. Those
criteria do have reconstructible history, so their overwritten dates are more likely to be
genuinely wrong than official's are.
8. The nightly job races the dataset pipeline
compliant goes UNKNOWN when the closest dataset has no validation report;
fresh_coverage when it has no service_date_range_end. The nightly job runs at 04:00
UTC and ntd-80188 publishes around 01:00, so on 09-04 and 09-05 it saw a three-hour-old
dataset whose report and window had not been ingested yet. Both are present now, so today's
data cannot reproduce the UNKNOWN.
It is a race, not missing data:
- UNKNOWN is chronic and catalog-wide — 267–437 feeds/night for
compliant,
346–513 forfresh_coverage, every night in the range. - Yet of 2775 published active GTFS feeds, only 76 currently have a latest dataset with
no service window and 13 with no validation report.
~400 UNKNOWNs a night against ~90 feeds with actually-absent data.
Consequence. Reconstructed days come out PASS where the nightly job recorded UNKNOWN,
because the march reads today's complete data. The backfill is systematically more
optimistic than the nightly job on these two criteria — and not only outside the recorded
range: UNKNOWN days are excluded from _load_recorded_observations, so they are
reconstructed even inside it.
9. Two performance notes
The missing gtfsfile index — fixed. One feed over 366 days took 370 s, nearly all of
it in load_dataset_history; with the index it takes 2.27 s. Applied by hand to the
duplicate, shipped as liquibase/changes/feat_1831_idx.sql (issue #1831). Full write-up in
../issue-gtfsfile-missing-fk-index.md. The same subplan is what makes the §2 scan
unrunnable if you write the calendar check as a correlated EXISTS instead of a
pre-aggregated CTE.
The dataset history loads three times per batch. PreloadedHistory.load calls
load_history() once per evaluator, and compliant, fresh_coverage and
fresh_continuous all return load_dataset_history(...) with identical arguments — two
queries each, six executions where two would do. The class docstring claims criteria reading
the same table share one load; nothing implements it. Cheap now, worth fixing before the
fan-out runs at catalog size.
A killed psql client does not kill the query. The scan was stopped client-side and kept
running on the server for four hours, because the tunnel it arrived through had been
replaced and the server never saw the disconnect. Before blaming a slow run:
select pid, state, now()-query_start as age, left(regexp_replace(query,'\s+',' ','g'),120)
from pg_stat_activity
where datname='MobilityDatabaseProdDuplicate' and pid <> pg_backend_pid()
order by query_start;select pg_terminate_backend(<leader pid>) takes its parallel workers with it.
A worked example: how the backfill revoked a live seal
Before the backfillFrom the baseline export taken the morning of 09-10, before the run: The nightly job had seen After the backfillMarching the year reconstructed 143 failing days for The snapshot trailThe 09-10 row is the fingerprint of the transition: it is the pre-backfill nightly run,
Today's nightly observed Why this matters
Worth deciding deliberately whether a backfill should be allowed to revoke a seal at all, |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Seal of Reliability — backfill testing
Testing
backfill_seal_of_reliability(#1818, issue #1763): first against a copy of production, then against production itself. Both write-ups are below, as comments.Contents
Production run — findings — the real thing, 2026-09-10. All six criteria over 3,559 feeds in 5 minutes. What each criterion produced, why 603 feeds lost the seal, and the policy behaviours most likely to generate questions from data producers.
QA runs on the prod duplicate — where the detail lives. The
fresh_continuousscan and verdict distribution, ten candidate feed shapes, and the run-by-run record: one feed, two feeds, twenty, then the whole catalog. Also the nightly job's race with the dataset pipeline, and the missing index behind #1831.The short version
fresh_continuous, sole blocker for 349 of the 603Three things worth a decision, all detailed in the first comment: whether
fresh_continuoushaving no grace period is the intended trade-off, whether the 730-day coverage guard is firing on legitimate calendars, and whether the backfill should evaluate at midnight UTC given that datasets arrive between 00:00 and 03:00.All reactions