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

  1. 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 BaseAgent with no model field 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-reader can call Gemini and cannot write any bucket; sa-publisher can 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_schema and requires LlmAgent(output_schema=...). The unit tests built every piece but never the SequentialAgent.
  • Withholding didn't survive serialisation. as_dict wrote retracted and from_dicts dropped 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.txt and 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 tests
  • make pipeline — the full run against the live ASTC PDF
  • python3 scripts/baseline_textlayer.py — the baseline, for comparison
  • /audit on the live URL — the same engine pointed at a purchase bill, with web-grounded price checks (scripts/build_audit_data.py rebuilds it)

Built With

Share this project:

Updates

Submission history