Inspiration
India has 20 transit feeds in the Mobility Database, the catalogue Google Maps, Transit and OpenTripPlanner draw from. Nine are still active. France has 1,127, of which 859 are active.
That is one active catalogued feed per 161 million Indians, against one per 79,359 in France, a 2,031-fold gap. Reproducible in thirty seconds:
curl -sO https://files.mobilitydatabase.org/feeds_v2.csv
python3 -c "
import csv,collections
r=[x for x in csv.DictReader(open('feeds_v2.csv')) if x['location.country_code']=='IN']
print(len(r), collections.Counter(x['status'] for x in r))"
# 20 Counter({'active': 9, 'inactive': 8, 'deprecated': 3})
The reason is not technology. Indian timetables exist as photocopies, board notices and painted boards not CSVs. Kochi Metro, India's first GTFS agency, is now deprecated with a dead DNS name. Delhi's DTC and Maharashtra's MSRTC both
- India has zero GTFS-Realtime feeds.
A feed is not a one-time artifact. It is a build that has to keep passing.
What it does
HEADWAY reads a photographed, scanned or PDF timetable and produces a standards-valid GTFS feed or refuses, loudly, with the reason attached.
photo / PDF → two INDEPENDENT vision reads (blind to each other)
→ grid binding (NO MODEL — geometry, from bounding boxes)
→ disagreement gate (NO MODEL — disagreement WITHHOLDS a claim)
→ geocoding (NO MODEL — OSM, audited by the printed km)
→ composer (NO MODEL — schedule arithmetic isn't language)
→ publish gate (MobilityData gtfs-validator — someone else's binary)
One model stage. Five deterministic stages. That asymmetry is the design.
The result that matters
Run on page 1 of Assam State Transport Corporation's real Guwahati division
timetable, and compared against the obvious non-agentic baseline pdftotext
plus regular expressions through the same composer, geocoder and validator:
| trips | stop_times | the service that runs off the page | validator | |
|---|---|---|---|---|
| Baseline (text layer + regex) | 3 | 23 | published | ERROR=0 WARNING=0 |
| HEADWAY | 2 | 17 | withheld | ERROR=0 WARNING=0 |
Both feeds pass every conformance check. One of them is false.
Page 1 holds three services. The third, "Guwahati to Bihpuria", runs off the
bottom of the page its last visible row still has a departure time, so the
bus does not terminate there. The baseline publishes a 409 km coach service as
though it ends at a village halfway along. gtfs-validator cannot see it; it
checks conformance, not whether the bus goes where the feed says.
HEADWAY withholds it, and says why.
Other refusals on the same page. Two stops Laluk and Jagiroad got no coordinates, because OpenStreetMap has no place node for either. The best match for "Jagiroad" is Jagiroad Hardware Stores in Guwahati, 60 km away. Both were refused and both are named on screen. A missing stop is visible to a rider; a stop in the wrong town is not.
Fidelity. 25 of 25 rows transcribed exactly, scored against the PDF's embedded text layer which is extracted but never shown to the reader, so it is an independent oracle rather than a hint.
Reproducible. Three consecutive live runs produced the byte-identical feed
70224a64…, across two models whose raw output differs on every run.
The whole division, not one page
python3 scripts/run_multipage.py --pages 1-10
1,185 claims read across 10 pages by two models, independently
→ 4 of 5 page-seam joins accepted · the fifth refused and named
→ 40 of 50 stop names located · 10 REFUSED rather than guessed
→ 34 trips · 40 stops · 38 routes · 188 stop_times
→ gtfs-validator 8.0.1: ERROR=0
A 369 km coach service does not fit on one sheet of A4, so services run off the bottom of a page and continue under no heading on the next. A join is treated as a claim and checked four ways the printed row number must continue, the km column must increase, time must not run backwards, and the joined run must actually terminate. All four or the join is refused and the service stays withheld.
That is a complete published operator timetable turned into a validated feed.
And on an artifact with no text layer at all
That page has a text layer, so the fair objection is that the baseline could compete. Most Indian timetables cannot they are photocopies. So we took it away: same page, degraded the way a copier degrades one (skew, blur, toner speckle, contrast loss, JPEG artifacts), both approaches re-run.
| degradation | baseline rows | HEADWAY fidelity | confident-wrong |
|---|---|---|---|
| photocopy of a photocopy | 0 | 70/70 (100%) | 0 |
| phone photo of a bad copy | 0 | 65/70 (93%) | 4 → service withheld |
The baseline scores zero for a structural reason: a JPEG has no text layer, so there is nothing to parse.
The harsher level is where this got interesting. It first produced 18 confidently wrong departure times with zero abstentions a feed that would have validated perfectly clean. Every value had been read correctly; two degrees of page skew displaced the rightmost column by nearly a full row height and the binder shifted the whole column by one. Three passes to fix it, and the third was finding that the fix had been silently disabled by a column-clustering bug.
Where it still mis-binds, it does not publish. The mis-binding puts a departure on the terminus row and a completed run ends with an arrival and no departure. The structural rule fires and withholds the whole service. At the point the geometry fails, the system loses coverage, not correctness.
How we built it
Google ADK 2.8 with gemini-3.7-flash as the primary reader and
gemini-3.5-flash-lite as an independent second opinion, on Vertex AI.
The load-bearing decisions:
- The reader holds zero tools and zero write permissions. It receives bytes and returns typed claims. A prompt injection in a scanned document has no reachable action the worst it can do is propose a claim the deterministic Composer then refuses.
- No model writes a CSV byte. The Composer is an ADK
BaseAgentwith nomodelfield at all. Referential integrity and id uniqueness hold by construction. - Row and column are geometry, not language. The model is asked only what a cell says and where it is; clustering its bounding boxes recovers the grid. Asking the model for the binding measurably degraded it enumeration collapsed from 20 cells to 1 as fields were added.
- The timetable audits the geocoder. Road distance is never shorter than a straight line, so a straight line longer than the printed km between two stops proves a coordinate is wrong. 15 segments checked, tightest margin 6.5 km. This is the only check in the stack that catches a stop placed in the wrong town the failure no conformance validator can see.
- A hallucination cannot reach the outside world, enforced by IAM rather
than by a prompt:
sa-readercan call Gemini and cannot write any bucket;sa-publishercan write the feed bucket and cannot call Vertex at all. Proven with a real 403.
Challenges we ran into
The first end-to-end run against a real page found nine defects, five of which were invisible in unit tests and two of which were silently corrupting output:
- The pipeline could not be constructed at all. ADK 2.8 rejects a schema in
generate_content_config.response_schemaand requiresLlmAgent(output_schema=...). The unit tests built every piece but never theSequentialAgent. - Withholding didn't survive serialisation.
as_dictwroteretractedandfrom_dictsdropped it, so every withheld claim came back alive at the next stage the truncated service was composed anyway. - The two readers use different bounding-box conventions, and both flip
between runs. One emits
[x0,y0,x1,y1]normalised 0..1; the other[ymin,xmin,ymax,xmax]scaled 0..1000. Read wrong, the second reader binds to the wrong cells while the gate cheerfully reports "0 disagreements". - Coordinate drift manufactured disagreements. The readers place the same stop up to two row-heights apart down a page. Matching cells to coordinate bands turned that into 8–12 phantom escalations per run. Rows are now the stop column in rank order, immune to drift.
- A misread departure became a 24-hour dwell, valid GTFS and validator-clean, saying the bus parks overnight at Khanapara.
Accomplishments we're proud of
Finding out and publishing that the naive baseline also scores ERROR=0. It would have been easy to show only our own green checkmark. The interesting result is that a green checkmark is not evidence, and we can now demonstrate exactly what it misses.
And building a system that caught its own worst failure. The skew bug produced exactly the error class the project exists to prevent, and an invariant written for a completely different reason page truncation is what stopped it reaching a feed. That is what defence in depth is supposed to look like, and we only know it works because we went looking for the failure.
What we learned
Zero ERROR notices proves conformance, not correctness. A completely false timetable validates cleanly. That reframed the whole project: the deliverable is not a feed that passes, it is a feed whose refusals you can inspect.
What's next: and a working preview of it
The claims-with-provenance engine generalizes beyond timetables, and the
deployment already carries the proof: /audit points the same stack at a
specimen purchase bill. One model stage reads the cells with bounding boxes,
geometry binds the rows, Gemini with Google Search grounding checks every
billed rate against live web prices, and deterministic checks no model find all three planted defects: a line that does not multiply, an item billed
at 3.5x the market ceiling, a duplicate line. Rs. 79,200 of exposure on a bill
whose grand total is internally consistent a totals audit passes it.
Conformance is not correctness, on a timetable or a purchase order; that is
the error class this engine exists to catch.
Beyond that: the other eight ASTC divisions (the pipeline handles them unchanged), and a genuine photocopy our degradation is synthetic, and synthetic is not the same as real.
Honest limits
- The photocopy degradation is synthetic. It is a fair test of the no-text-layer claim and of skew robustness; it is not a real photocopy, and a real one remains untested.
- The underlying artifacts are rendered the fixture is drawn and the ASTC PDF is a Word document printed to PDF. Neither is a scan.
- On a page with a clean text layer, the baseline ties us on transcription. Our advantage is the refusals, and working where no text layer exists.
- Two of fourteen stops on the demonstrated page have no coordinates at all. The feed is correct about the twelve it publishes and silent about the two it cannot place.
- ASTC's operating days are assumed daily; the PDF does not state them. This is declared in the operator profile and printed as an assumption on every run.
- Generated feeds are labelled unofficial development feeds in
feed_info.txtand none has been submitted to any catalogue.
Built with
python · google-adk 2.8 · gemini-3.7-flash · gemini-3.5-flash-lite ·
vertex-ai · cloud-run · gtfs · mobilitydata/gtfs-validator 8.0.1 ·
openstreetmap / nominatim · poppler
Try it out
- Repo: https://github.com/Munshi-PremChand/headway (tag
submission-v2) make test— 192 testsmake pipeline— the full run against the live ASTC PDFpython3 scripts/baseline_textlayer.py— the baseline, for comparison/auditon the live URL — the same engine pointed at a purchase bill, with web-grounded price checks (scripts/build_audit_data.pyrebuilds it)
Built With
- cloud-run
- fastapi
- gemini
- google-adk
- gtfs
- openstreetmap
- python
- vertex-ai
Log in or sign up for Devpost to join the conversation.