Inspiration
On August 19, 2026 the Illinois Tollway board approved the largest toll increase in state history. The common 75-cent passenger toll becomes $1.20 in January 2027, roughly a 60 percent jump. Commercial rates rise about 30 percent. Starting in 2029, tolls index to inflation automatically every two years, with no public hearings required. The increase funds a $26.5 billion, 15-year capital program and raises roughly a billion dollars a year.
The 2027 number is settled. What happens from 2029 onward depends on what that capital program actually costs.
There is a specific, mechanical gap in how that cost gets set. Public agencies are effectively required to award construction contracts to the lowest responsible bidder. At award time the agency knows what each bidder says the work will cost. It does not know what that bidder's past bids became. Construction contracts grow after award through change orders and extra work orders, and some contractors' awards grow far more than others' on comparable work. A contractor whose awards reliably grow nine percent on bridge work is not the low bidder on bridge work. The agency pays that premium invisibly, on every letting, at scale.
The Illinois Tollway publishes both halves of the answer and has never joined them:
- The Construction Contract Tracker exposes, per contract, the Award Amount and the Contract Authorized Amount (award plus every approved increase), alongside Earned to Date and the Prime Contractor. The gap between those two numbers is the overrun.
- The board resolutions, archived back to 2004 and posted under the Open Meetings Act, state the reason for every increase in plain language. A real example from February 2021: an increase of up to $699,000 on Contract I-19-4496 to provide for underground obstruction removals for three separate sewer installations.
One source has the number. The other has the reason. Nobody has ever read them as one dataset.
What it does
Track Record wakes on its own when new bids or board award items post, reads every bidder's contract history out of the agency's own records, and delivers a finished one-page award memo before the vote.
Per bidder, the memo shows the bid, the historical growth on comparable work, the adjusted cost, the reordered ranking, the recurring cause quoted verbatim from the resolutions, and every precedent cited by contract number and resolution number with a link to the source document.
Two behaviors define the product, and both were treated as non-negotiable through every refactor:
It never recommends. It supplies a missing variable. A human decides, and the interface says so in those exact words at the bottom of every memo. In public procurement that boundary is correct engineering, and it is a deliberate, visible design statement rather than a missing feature.
It refuses. Fewer than three comparable prior contracts returns the words "insufficient history," never a percentage. Growth computed from one or two contracts is noise. The threshold lives in config, is set to 3, and is never lowered to make a screen look fuller. The refusal, rendered calmly on screen next to bidders that do have history, is the most credible thing in the product.
Three screens, no more. Runs shows the watcher status and the memos it has delivered. Memo renders the sheet itself. Ledger shows the underlying corpus: what was awarded, what was authorized, and which contractors fall below the threshold, listed with an em dash and the word "insufficient" because their exclusion from the computation is itself information.
How I built it
Two protocol seams, defined on day one and never violated. A Repository protocol (SQLite and BigQuery both implement it) and a ModelProvider protocol (mock, Gemini API, and Vertex AI all implement it). config.py selects implementations from STORAGE=sqlite|bigquery and MODEL=mock|gemini|vertex. Application code imports the protocols and never a concrete class. This is what made the project buildable in stages: everything runs locally with zero credentials and zero network, then the same code runs on Google Cloud by changing two environment variables.
The metric is computed two ways and never blended. Dollar-weighted growth is the sum of authorized minus the sum of award, over the sum of award. Median growth is the median of per-contract growth. When one large contract dominates a vendor's history the two diverge, and that divergence is information the memo shows rather than hides. NULL and zero are kept strictly distinct in amount parsing, because conflating "unknown" with "zero" silently corrupts every percentage downstream.
Cause clustering is deterministic, not a model call. The recurring-reason categories come from keyword matching over the verbatim stated reason. A judge can read the keyword map and see exactly why a memo says what it says. Determinism where auditability matters, the model only where language understanding is actually required.
Gemini reads the resolutions as documents, not as text. PDFs go to the model natively as bytes with an enforced JSON array schema at temperature 0.1. Board resolutions are table-and-clause documents where layout carries meaning, so pre-extracting text throws away the part that disambiguates which number belongs to which contract.
The watcher is what makes it an agent. It loads the source listings, fingerprints them with a sha256 over the sorted document URLs and letting identifiers, compares against stored state, and acts on the difference: new resolution PDFs go to extraction, new lettings with identifiable bidders trigger a memo run. It logs a line for every run including the ones that found nothing. Locally it watches a fixture directory that the test suite mutates, which proves the trigger logic with no network at all. In production it runs as a Cloud Run job on a Cloud Scheduler heartbeat at 07:00 America/Chicago.
Stack: Python 3.11, Flask with server-rendered Jinja templates and no frontend build step, SQLite locally and BigQuery in the cloud behind the same interface, Gemini on Vertex AI, Google ADK wrapping the identical agent prompt and the identical two tools, Cloud Run for the web service and the watcher job, Cloud Scheduler for the heartbeat, Cloud Storage for the harvested PDF corpus, pytest for the suite.
Challenges I ran into
The corpus was thirteen times bigger than the spec assumed. The plan estimated 130 to 160 resolution PDFs on the board archive. Parsing the live archive found 2,032 PDF links. Classifying them by filename gave 608 minutes, 532 other documents, 227 notices, and 158 resolutions for 2015 through 2026. Resolutions are where increases are actually stated, and 158 is almost exactly what the spec estimated for the documents that matter. Reading all 2,032 would have cost roughly $40 and bought nothing. A free make survey step now shows the classification before anything spends money.
An honesty bug that would have been invisible. A memo created through POST /run was stored with is_sample=0 even when it was computed entirely from fictional seed rows, so its page would have rendered with no SAMPLE DATA badge. That is exactly the failure mode the whole design is built to prevent: fictional numbers presented as real. I found it by checking the stored row rather than the rendered page.
A silent patch failure caught only by not trusting myself. The form's money parsing was written, but the patch silently failed to apply, so float("22,000,000") was still reaching the agent. Nothing in the test output pointed at it. It surfaced only because I posted the form for real instead of trusting that the edit had landed.
A fresh clone could not pass its own test suite. The light install pulls Flask and pytest, but the Gemini and HTML-parsing tests import google-genai and bs4, so a brand new clone got 18 collection errors. Those tests now guard themselves the way the BigQuery and ADK tests already did: 106 passed and 10 skipped on a light install, 154 passed with everything installed.
BigQuery does not mirror SQLite for free. Three real defects surfaced while writing the second repository implementation against the same protocol: a TIMESTAMP column that SQLite was feeding ISO strings, query parameters typed by inferring from the value (a NULL award amount became a STRING parameter and would have broken COALESCE against a FLOAT64 column), and Liferay document URLs that end in a content hash rather than a filename, so downloaded files were being named 39f99992-...?version=1.0&t=1.
Reading the rules changed the architecture ranking. The qualifying Google Cloud services for this hackathon are Cloud Run, Cloud SQL, Firestore, GKE, and Pub/Sub. BigQuery, Cloud Storage, and Cloud Scheduler are all good engineering here and all in use, but Cloud Run is the piece that actually satisfies the requirement. That is now written down in a compliance document that maps every stated requirement to the specific evidence for it, so nothing gets cut for time that cannot be cut.
The build machine had no Python. No Node, no package manager, nothing. The runtime was bootstrapped from a CPython interpreter found in a tool cache into a project-local venv, with nothing outside the project directory modified.
Accomplishments I am proud of
The refusal path is the best-tested thing in the codebase. A vendor with two contracts produces sufficient: false from the tool, the words "insufficient history" in the memo JSON, absence from the adjusted ranking, an explicit note explaining that absence, and the declined block in the rendered HTML. It reads as a feature on screen, never as an error.
One command takes a stranger from clone to a working demo. ./setup.sh finds Python, builds the venv, installs, runs the tests, writes the memos, and says what to do next. It was tested against an actual fresh clone, which is how the 18-error bug above got found.
The interface is one coherent system rather than a bag of components: one typeface, three shadows, two radii, and a single accent color used at most once per visual region. Every numeral in the product is tabular. When I verified the final screen I checked the computed styles rather than trusting the diff, and every value mapped to an existing token with no new colors introduced.
What I learned
The most credible thing a data product can do is decline. A screen that says "2 comparable contracts found, 3 required" next to two bidders that do have history tells you more about whether to trust the other numbers than any confidence interval would.
Put determinism exactly where auditability matters and nowhere else. Cause clustering is keyword matching a judge can read. Document extraction is a model, because reading a table-and-clause PDF is a language problem. Mixing those up in either direction costs you either accuracy or explainability.
Verify the artifact, not the intention. Two of the worst bugs in this build, the silent money-parsing patch and the is_sample mislabel, were invisible in the diff and in the test output. They showed up only when I looked at what the running system actually produced.
What's next for Track Record
The method is not specific to the Illinois Tollway. Any agency that publishes award amounts, authorized amounts, and board minutes has the same latent dataset sitting in public view: IDOT, the CTA, Metra, and the equivalent authority in every other state. The ingestion layer is the only part that is agency-specific.
Beyond that: an alias map for contractor names that vary across documents, growth broken out by work type at finer resolution once the corpus supports it, and a backtest published as a standing honest error band rather than a one-time table, because the claim the product makes is that the signal existed at award time, not that prediction is precise.
Log in or sign up for Devpost to join the conversation.