Skip to main content
Jonathan Andrei
Back to all posts
Aug. 202613 min read

Culprit: A Stack Trace for Model Decay. $90,322 of Model Error Traced to One Column That Went from Max 6 to Max 7.

Freshness, volume, null-rate, and schema checks were all green. The model had been quietly wrong for six months. Culprit walked DataHub's ML lineage back to the column that did it, filed the incident into the graph with the dollars attached, wrote the fix, executed dbt build against the real warehouse to verify it, rejected its own first patch that would have deleted 87,693 rows, and only then opened the PR. $90,322 of attributable model error priced against a counterfactual control on 19.3M real NYC taxi records.

BuildWithDataHubHackathonDataHubMCPdbtModel ObservabilityML AgentsPython
I created this post and the Culprit project for Build with DataHub: The Agent Hackathon (Production ML Agents track). #BuildWithDataHubHackathon
The claim in one sentence: $90,322 of attributable model error in a single month, across 66,146 real NYC taxi trips, caused by one upstream column's maximum value changing from 6 to 7, while freshness, volume, null-rate, and schema checks all stayed green. That is $1.37 a trip, measured in SQL against 19.3M real records, net of a counterfactual control.

Model monitoring does root-cause analysis inside the model boundary. Data observability does it inside the data boundary. Both are good at that now, and I am not claiming otherwise. But the fact that decides this case lives on the boundary between them: which category values the deployed encoder was actually fitted on. That fact sits in the ML lineage, next to the training run, and it is what turns 'this feature moved' into 'this model was never taught this value, here is the retrain that baked it in, here is what it cost.'

DataHub's model page for nyc_fare_predictor showing the vendors_in_training_data property with value [1, 2, 6], captured before any drift occurred. This is the fact that decides the whole investigation.
The fact the whole diagnosis turns on, recorded in the graph before anything went wrong: the model was trained on vendors 1, 2 and 6. Nothing else knows this.

The real incident this is built on

The fault in this repository is not planted. It genuinely happened, in a public dataset, and you can verify it yourself. A new taxi vendor entered the NYC Taxi and Limousine Commission feed in December 2024. VendorID = 7 (Helix) started at 230 rows (0.006% of the feed), then compounded nearly 300x over six months. The fct_trip_features dbt model one-hot-encoded vendors with a hardcoded CASE over the three vendors that existed when it was written. That is ordinary, defensible dbt code. It is also the culprit: a vendor-7 trip asserts 'no vendor', a combination that appears nowhere in the training data.

The NYC TLC published Data Dictionary listing VendorIDs, with '7 = Helix' visible; the document is dated March 18, 2025 while vendor 7 first appeared in the feed in December 2024, meaning the data changed before the documentation caught up.
Primary source, so none of this has to be taken on trust. Vendor 7 exists. The feed changed in December 2024, the documentation dated March 2025.

There is a second defect stacked on top. Vendor 7 emits identical pickup and dropoff timestamps, so every one of its trips has a duration of exactly zero. The feature model guards against division by zero the way everyone does: `coalesce(trip_distance / nullif(trip_minutes / 60.0, 0), 0) as avg_speed_mph`. The null-safety guard is what hides the corruption. Without it, avg_speed_mph would go NULL and a null-rate monitor would fire. With it, the column stays clean and confidently reports 0 mph for 66,146 trips.

Culprit's output showing the semantic change detection: min stays at 1, max climbs from 6 to 7 in December 2024, null rate stays at 0%, dtype stays INTEGER, row volume stays normal. Every structural monitor stayed green through the entire six months.
One integer changed. That is the whole signal at the source. Every structural check freshness, volume, null-rate, dtype was green through the entire six months of quiet wrongness.

Measuring the damage, net of a counterfactual control

The honest part. A dollar figure is easy to inflate, so here is how this one is produced. Two models are trained with identical hyperparameters: production sees vendors {1, 2, 6}; control sees {1, 2, 6, 7}. Both score the full real 2025-06 month. On vendor 7, production has MAE $4.6060 and control has MAE $3.1674 — a naive gap of $1.4386 per trip. The obvious objection is that the control saw more data and fresher data, so some of its advantage is just that, not the encoding fix. Correct, and it is why the naive gap is not the headline.

The control's unearned advantage is directly measurable on segments with no encoding defect: $0.0731 per row across 3,840,878 unaffected rows. Subtracting gives a difference-in-differences: ($1.4386 − $0.0731) × 66,146 trips = $90,322.36. That is the cost of serving six months of trips from a vendor the deployed model was never fitted on, priced as mean absolute prediction error against a counterfactual control. It is not realised revenue loss, and it is not a figure the dbt patch alone recovers; a retrain on a window containing the vendor is needed for full repair. Culprit says so in the PR rather than implying the fix is complete.

Impact table showing per-vendor MAE comparison between production and control models, with vendor 7's $1.44 gap called out as the affected segment; also shows $1.37 per-trip difference-in-differences net of the control's unearned advantage on unaffected segments
The model is never asked to produce a number. Every dollar figure comes from SQL run against the real warehouse, then handed to the agent as a fact. This is the guardrail on the engine.

The recorded run: 13 turns, 28 tool calls, 152 seconds, $0.279

The agent is handed a model URN and one vague sentence ('upfront fare quotes have drifted, nobody knows why'). It reads the model's context from the graph, compares input behaviour across segments of live serving data looking for inputs that collapse to a constant or take impossible values, walks lineage backwards from the suspicious feature through the ML entities and into the dbt-derived column lineage to the raw source column, profiles that column over time to find the change in meaning, confirms that standard monitors would not have fired, quantifies the damage in dollars in SQL net of a control, and writes the finding back into DataHub. Nothing about taxis, vendors, or one-hot encoding appears in the system prompt or the tool catalogue.

Investigation trace showing the sequence of tool calls: search DataHub for the model URN, get_lineage on mlModel to walk into features, get_entities on the feature table, feature_drift_report on suspect columns, then dbt_column_lineage crossing into the dataset half of the graph, ending in measure_attributable_error returning the $90,322 figure
Recorded, not scripted. 28 tool calls in 13 turns. Four of the calls go through DataHub's own MCP server; the rest go through the deterministic SQL layer that returns numbers.

How this uses DataHub

  • Reads through DataHub's own MCP server. culprit/mcp_bridge.py launches mcp-server-datahub over stdio and exposes its tools to the agent (search, get_lineage, get_entities, list_schema_fields, get_dataset_queries, get_lineage_paths_between).
  • Contributes the ML half of the graph. No DataHub sample datapack ships ML entities, so pipeline/emit_ml_lineage.py emits 13 mlFeatures, an mlFeatureTable, an mlModelGroup, an mlModel, and a dataProcessInstance training run through the DataHub Python SDK.
  • Uses real, ingested lineage rather than hand-asserted lineage. The dataset half of the graph comes from DataHub's native dbt connector parsing real manifest.json and catalog.json build artifacts, which produces genuine column-level fineGrainedLineage.
  • Writes three artifacts back. culprit/writeback.py raises a DataHub Incident (CUSTOM / 'Semantic drift' / HIGH), saves the full investigation as a knowledge document via the MCP save_document tool, and annotates the offending source column so the next person or agent who opens it inherits the finding.
DataHub column-level lineage view for fct_trip_features showing the chain from raw.yellow_trips.vendor_id → stg_yellow_trips → fct_trip_features → the three is_vendor_* one-hot-encoded columns → the ML feature table → the model
The dataset half is DataHub's native dbt connector doing real column-level lineage. The ML half is the SDK emitting mlFeatures with the exact source column each derives from, which is what makes the walk from model back to raw column possible.
The DataHub incident that Culprit raised: typed CUSTOM / 'Semantic drift' / HIGH, filed on the raw.yellow_trips source dataset (because the MLModel GraphQL type has no incidents field so an incident on a model is invisible), with the affected model named in the body and the dollar figure attached
The incident is filed on the source dataset, not on the model — that is not a design choice, it is a DataHub v1.5 limitation I confirmed by probing a live instance, then corrected my own earlier wrong claim about it. Written up in docs/INCIDENT_PROBE_RESULTS.md.

Closing the loop: the generated fix, rejected then accepted

Culprit does not stop at the diagnosis. `culprit fix` locates the transformation at fault, writes a patch, then proves it by running dbt against the real warehouse before proposing anything. Three gates must pass: dbt build succeeds on the patched model, the affected rows now match a category, no other segment's row count changed. Only then does it open a PR. This is not decoration. The first attempt failed two of the three gates.

The model proposed `+ where vendor_id in (1, 2, 6)`. That does not encode the new vendor. It deletes all 87,693 of its rows (66,146 of them in the month we priced). It compiles cleanly and dbt build passes, so anything checking whether the patch looks reasonable would have shipped it, and the symptom would have vanished along with the data. The row-count gate caught it and refused to open the PR. The accepted patch added a catch-all bucket instead: `case when vendor_id not in (1, 2, 6) then 1 else 0 end as is_vendor_unknown` — better than the obvious hand-written fix because it will not break again on the next new vendor.

The gate output showing the rejected patch: dbt build PASS, affected rows now match a category FAIL, no other segment's row count changed FAIL, rows destroyed 87693, outcome REJECTED pull request not opened
This is what an execution-verified remediation gate looks like. The model's first patch compiled and made the symptom disappear. It also would have destroyed 87,693 rows. The gate is the reason the PR that did land was safe.

Design note: the model is the engine, SQL is the guardrail

The reasoning is the model's. Which features look wrong, which columns to walk back to, what a change in those values means, and whether a hypothesis survives are all decided by the agent. Provider-agnostic (OpenAI, Anthropic, or any OpenAI-compatible local server like Ollama). The deterministic layer exists for one narrow reason: the model is never asked to produce a number. Every dollar figure and row count is returned by SQL and handed to the agent as a fact. That is a guardrail on the engine, not a replacement for it, and it closes the obvious failure mode where a language model fabricates a plausible-looking financial impact.

Honest limitations

  • The feature-to-root-column mapping is a recorded property, not a derived traversal. `pipeline/emit_ml_lineage.py` writes root_columns onto each mlFeature so 'is_vendor_cmt → vendor_id' is read back in one call rather than traversed hop by hop. That matches what production feature stores (Feast, Tecton) publish, and DataHub separately ingests real column-level lineage for the same path, so the property is a cached answer rather than an invented one. But the honest verb is 'reads the model's declared root column out of DataHub' not 'derives it by traversal.'
  • Culprit currently detects semantic change in low-cardinality columns. Unit changes and backfill-driven leakage are described in the agent's method but only the new-categorical-value path is exercised end to end here.
  • The counterfactual control requires being able to retrain. Where retraining is expensive, the naive estimator is the fallback and it overstates.
  • The agent is not deterministic. Three runs were made against a checklist written before any of them; the committed run is the first that met all five criteria. Which of two damage routes gets reported (the unmapped encoding vs the trip-minutes collapse) is not fully reliable across runs. That is a real limitation, and no run was hand-edited to hide it.
The graph knows the model was trained on vendors 1, 2 and 6. The warehouse knows it is being asked to score vendor 7. No other system holds both of those facts. Culprit reads through DataHub's own MCP server to bring them together, prices the gap in SQL against 19.3M real records, executes the fix against the real warehouse before opening the PR, and rejects its own first patch when it would have destroyed 87,693 rows. Detection was never the hard part. Attribution is.
Related project

Culprit: A Stack Trace for Model Decay. Named the Column, Priced the Damage at $90,322/mo Across 66,146 Real NYC Taxi Trips, and Opened the PR.

View the project