The bug, first
The offline number goes up when the model gets worse
Here is the whole post in one control. You want to predict “buys in the next seven days”. The label window opens on the first of September. The question is only ever this: at what moment did you read the features?
↳ Same profile, same label, same model. Only the moment the features were read changes.
That is the failure the rest of this post is arranged around. Everything before it exists so the fix is even possible, and everything after it exists so the score reaches an audience without going stale.
Traits
A profile is a query, not a document
Post 2 left you with one canonical profile id per connected component. The traits layer does not store facts about that profile; it derives them from the event log on a schedule, writes the result into the warehouse, and stamps a version. Nothing mutates. The document version of this loses on four counts.
- Concurrent writers corrupt silently. The order consumer and the session consumer both touch the same profile; one read-modify-write clobbers the other and no error is raised anywhere.
- A document has no history. “What was days-since-last-order on the twelfth of March” is unanswerable — which, as the interactive above shows, makes correct model training impossible.
- Replays double-count. A consumer restart replays an order, and an incrementing lifetime counter is now permanently wrong with no way to detect it.
- Recomputation is the fix for everything. A derived trait table can be rebuilt from events after a bug. A mutated document cannot be un-mutated.
Sessionization comes first, because a session is the unit almost every behavioural trait counts. The rule is thirty minutes of inactivity, computed with a window function over each profile’s ordered event stream rather than trusting the session id the client supplied.
The roll-up is then a plain aggregate over that view plus orders. It runs on a cadence, computes every trait from scratch for the profiles that had activity, and appends.
The feature store
One definition, two paths
Training reads history in batch. Scoring reads one profile in milliseconds. Those are genuinely different queries — and the moment you write them as two hand-maintained SQL strings, they start to drift. Someone fixes a null-handling bug in the online path, or changes a lookback from thirty days to twenty-eight, and only one side gets it. Nothing errors. The model just quietly gets worse over a quarter.
- A feature is an object, not a string. It owns a name, a type, and one expression; the offline and online queries are both compiled from it.
- The compiler is the only place SQL is written. No feature-specific SQL lives anywhere else, so there is nowhere for a divergent edit to hide.
- Parity is a test, not a convention. Sample profiles, run both paths, assert equality.
- Drift is otherwise invisible. There is no exception, no alarm and no log line — only a slow decline in a number nobody watches daily.
That test is the entire discipline in one file. It fails loudly the day someone edits one path, which is the only moment the failure is cheap to fix.
Point-in-time
Compute the feature as of the moment you would have had to
Now the mechanics behind the interactive at the top. You take a label window and build the training set from the trait table as it is today. Lifetime revenue already contains the purchase in the label window. Recency is measured from it. You have handed the model the answer inside the question.
The offline score comes back beautiful. Everyone is delighted. Online lift is zero, because at scoring time the model sees a profile before the purchase, and every feature it learned to lean on is in a completely different regime. The model learned “recently bought” and you asked it “will buy”.
- The leak is in the features, not the label. The label window is correct; the features are read from after it started.
- Every feature row carries the timestamp it was valid at. That is what the computed-at column is for, and why traits are appended rather than updated.
- Training joins on that timestamp, never on today. Each label row picks the most recent trait row at or before its own boundary.
- An as-of join is the right tool. The warehouse does this natively and efficiently; the hand-rolled subquery version is slower and easier to get subtly wrong.
The history table is the same roll-up written without replacement — one row per profile per run, kept for a retention window. It costs storage. It is the only thing that makes a correct training set possible, so it is not optional.
There is a second, subtler leak in the same neighbourhood, and the as-of join does not fix it. If your feature window and your label window overlap even by a day, you have reintroduced the same problem in miniature, and the offline metric moves just enough to look like a real improvement. Worse is survivorship in the sampled population: if you build the training set from profiles that exist in the trait table because they converted, your negatives are not a sample of non-buyers — they are a sample of people who nearly bought. The fix for both is the same as the fix for the first: define the population and the feature cutoff from the boundary alone, and let never-converted profiles into the negative class.
Scoring
A score is only useful if the audience can see it
The model output is not a file in a bucket. It lands back beside the traits, with the time it was scored and the model version next to it, so a marketer can build an audience on it and an investigation six weeks later can ask which model produced a given membership.
- Write back as a version bump. Same key, higher version, replacement at merge time.
- The model version is not decoration. Without it a bad audience cannot be traced to a bad model, and a rollback is guesswork.
- The scored-at timestamp gates staleness. Audiences filter on it; a score nobody refreshed is a score nobody should target on.
- Thresholds live in the audience, not the model. The model emits a probability; the business decides where the line is.
Teaching-grade reference implementation, not a production customer data platform. It reproduces the ideas and the streaming/warehouse integration shape; bring your own data and destination credentials. Destination adapters run against a local mock by default. MIT-licensed. View the repo →
Explain it back
Reveal a model answer
Almost certainly point-in-time leakage: the training set read the trait table as of build time, so features already contained the purchases in the label window. The model learned “bought recently”, which is unavailable at scoring time, so its ranking is close to noise in production — and nothing goes red because leakage is a correctness bug, not an availability one.
Confirm it by rebuilding the training set with an as-of join against the trait history at the label boundary and retraining: if the score collapses from 0.91 to the high sixties, you have your answer in an hour. Also check for feature and label windows that overlap, and for negatives sampled only from converters. CI catches it two ways: the offline/online parity test on the feature registry, and a leakage assertion that fails the build if any feature row used in training was computed after its own boundary.
Bonus consequence — every audience built on that score has been polluting your incrementality reads too, so post 6’s holdout numbers for the last month are unusable.
Traits and scores are only safe to hold if consent is a join key the audience cannot compile without — and if a deletion actually reaches every destination you ever sent this person to.
Make the join structural →