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.
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.](/projectImages/culprit_01_model_properties.jpg)
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.

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.

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.

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.

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.


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.

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.
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