What Could the Model Have Known at That Moment?
Build one feature row per historical decision using event time, availability time and explicit label boundaries, without leaking later evidence into the inputs.
17 sections · 4 questions · 1 coding challenge
The arrow keys move between sections too.
Training Data Needed a Memory of What Was Available
BOTIE
That distinction explains our late upload. Filtering event time alone cannot exclude data that arrived after the decision. This lesson expresses both boundaries directly in SQL so their meaning remains visible regardless of the feature-store framework used later.
Sources for this history
Yesterday’s Prediction Has Been Given Tomorrow’s Evidence
Botie is preparing training rows for a model that will help prioritise pump inspections. Each row represents a decision made at noon. A temperature was measured at 11:00, but the device did not upload it until 13:00. Today’s database contains that measurement. The person making the noon decision did not have it.
If we include the late upload in the noon feature, the training example describes a better-informed system than the one we can actually deploy. We need to reconstruct the information available at each decision, not simply gather every record whose event time looks old enough.
The records are invented maintenance examples. The workshop exposes record operations; the SQL worksheet executes a real query in an isolated SQLite database.
Step 1 of 1
Read the noon decision from the evidence it actually had
D1 concerns P7 at January 2, 12:00 UTC, with a 24-hour lookback.
| Event time | Available time | Value | Use for D1? |
|---|---|---|---|
| Jan 1, 12:00 | Jan 1, 12:05 | 10 | Yes, lower boundary included |
| Jan 2, 10:00 | Jan 2, 10:05 | 30 | Yes |
| Jan 2, 11:00 | Jan 2, 13:00 | 90 | No, uploaded too late |
| Jan 2, 12:00 | Jan 2, 12:00 | 100 | No, event cutoff excluded |
Jan 1, 12:00
- Available time
- Jan 1, 12:05
- Value
- 10
- Use for D1?
- Yes, lower boundary included
Jan 2, 10:00
- Available time
- Jan 2, 10:05
- Value
- 30
- Use for D1?
- Yes
Jan 2, 11:00
- Available time
- Jan 2, 13:00
- Value
- 90
- Use for D1?
- No, uploaded too late
Jan 2, 12:00
- Available time
- Jan 2, 12:00
- Value
- 100
- Use for D1?
- No, event cutoff excluded
The decision row defines the information boundary
The evidence filter enforces both clocks, and the final left joins preserve decisions with no eligible evidence.
Reconstruct the observations available at each decision
Match events to decisions by asset, enforce both time boundaries, then summarise and reattach the results to every decision.
BOTIE
Decision IDs with eligible means and counts
[
{
"decision_id": "D1",
"feature_mean": 20,
"n": 2
},
{
"decision_id": "D2",
"feature_mean": null,
"n": 0
}
]Follow the operations
1. Decision identities and UTC cutoffs
[ { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00" }, { "decision_id": "D2", "asset_id": "P8", "cutoff": "2026-01-02 12:00:00" } ]2. Measurements with event and availability times
[ { "asset_id": "P7", "event_at": "2026-01-01 12:00:00", "available_at": "2026-01-01 12:05:00", "value": 10 }, { "asset_id": "P7", "event_at": "2026-01-02 10:00:00", "available_at": "2026-01-02 10:05:00", "value": 30 }, { "asset_id": "P7", "event_at": "2026-01-02 11:00:00", "available_at": "2026-01-02 13:00:00", "value": 90 }, { "asset_id": "P7", "event_at": "2026-01-02 12:00:00", "available_at": "2026-01-02 12:00:00", "value": 100 } ]3. Match rows on a key
[ { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "e_event_at": "2026-01-01 12:00:00", "e_available_at": "2026-01-01 12:05:00", "e_value": 10 }, { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "e_event_at": "2026-01-02 10:00:00", "e_available_at": "2026-01-02 10:05:00", "e_value": 30 }, { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "e_event_at": "2026-01-02 11:00:00", "e_available_at": "2026-01-02 13:00:00", "e_value": 90 }, { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "e_event_at": "2026-01-02 12:00:00", "e_available_at": "2026-01-02 12:00:00", "e_value": 100 }, { "decision_id": "D2", "asset_id": "P8", "cutoff": "2026-01-02 12:00:00", "e_event_at": null, "e_available_at": null, "e_value": null } ]4. Lookback hours
24
5. Keep history available at the decision
[ { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "e_event_at": "2026-01-01 12:00:00", "e_available_at": "2026-01-01 12:05:00", "e_value": 10 }, { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "e_event_at": "2026-01-02 10:00:00", "e_available_at": "2026-01-02 10:05:00", "e_value": 30 } ]6. Summarise each group
[ { "decision_id": "D1", "feature_mean": 20 } ]7. Match rows on a key
[ { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "feature_mean": 20 }, { "decision_id": "D2", "asset_id": "P8", "cutoff": "2026-01-02 12:00:00", "feature_mean": null } ]8. Summarise each group
[ { "decision_id": "D1", "n": 2 } ]9. Match rows on a key
[ { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "feature_mean": 20, "n": 2 }, { "decision_id": "D2", "asset_id": "P8", "cutoff": "2026-01-02 12:00:00", "feature_mean": null, "n": null } ]10. Set a constant
0
11. Replace a missing result deliberately
[ { "decision_id": "D1", "asset_id": "P7", "cutoff": "2026-01-02 12:00:00", "feature_mean": 20, "n": 2 }, { "decision_id": "D2", "asset_id": "P8", "cutoff": "2026-01-02 12:00:00", "feature_mean": null, "n": 0 } ]12. Choose table columns
[ { "decision_id": "D1", "feature_mean": 20, "n": 2 }, { "decision_id": "D2", "feature_mean": null, "n": 0 } ]13. Order rows by named fields
[ { "decision_id": "D1", "feature_mean": 20, "n": 2 }, { "decision_id": "D2", "feature_mean": null, "n": 0 } ]14. Result
[ { "decision_id": "D1", "feature_mean": 20, "n": 2 }, { "decision_id": "D2", "feature_mean": null, "n": 0 } ]
The fixture has immutable nonmissing numeric values and unique decision IDs. Revision selection, label creation and model evaluation require additional rules taught in the lesson.
1. Start with a table of decisions
A training row needs a stable identity and a stated prediction time. Here decision_id identifies the row, asset_id identifies the pump and cutoff is the instant at which we imagine making the prediction. The same pump can have several decision rows at different times.
Do not group only by asset_id when the task has repeated decisions. That would merge information boundaries that must remain separate.
One row per decision is the grain of the feature table, even when many events contribute to it.
2. Record when an event happened and when it became usable
event_at describes when the measurement occurred. available_at describes when that value became usable by the decision system. Ingestion, validation or publication can make availability later than the physical measurement.
These timestamps require trustworthy pipeline semantics. A warehouse load time is not automatically the moment a production service could read the value. If availability was never recorded, a query cannot reliably recover it from wishful assumptions.
The feature contract needs the availability of the actual value used, including later corrections.
3. Apply both boundaries in the join
The lookback includes events at the start of the preceding 24 hours and excludes events at the cutoff itself. The availability condition allows values usable no later than the cutoff. These inclusivity choices are part of this lesson’s contract.
All event conditions belong in the LEFT JOIN matching rule. That preserves decisions with no eligible event. AVG then remains NULL for those decisions, while COUNT of a real event ID is zero.
Event time prevents future observations. Availability time prevents later-arriving past observations.
Calculate the mean before adding its coverage count
The value 90 is older by event time but newer by availability time, so it cannot contribute.
| decision_id | asset_id | cutoff |
|---|---|---|
| D1 | P7 | 2026-01-02 12:00:00 |
| D2 | P8 | 2026-01-02 12:00:00 |
| event_id | asset_id | event_at | available_at | value |
|---|---|---|---|---|
| 1 | P7 | 2026-01-01 12:00:00 | 2026-01-01 12:05:00 | 10 |
| 2 | P7 | 2026-01-02 10:00:00 | 2026-01-02 10:05:00 | 30 |
| 3 | P7 | 2026-01-02 11:00:00 | 2026-01-02 13:00:00 | 90 |
| 4 | P7 | 2026-01-02 12:00:00 | 2026-01-02 12:00:00 | 100 |
Database setup for this example
CREATE TABLE decisions(decision_id TEXT PRIMARY KEY NOT NULL, asset_id TEXT NOT NULL, cutoff TEXT NOT NULL);
CREATE TABLE measurements(event_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, event_at TEXT NOT NULL, available_at TEXT NOT NULL, value REAL NOT NULL);
INSERT INTO decisions VALUES ('D1','P7','2026-01-02 12:00:00'),('D2','P8','2026-01-02 12:00:00');
INSERT INTO measurements VALUES
(1,'P7','2026-01-01 12:00:00','2026-01-01 12:05:00',10),
(2,'P7','2026-01-02 10:00:00','2026-01-02 10:05:00',30),
(3,'P7','2026-01-02 11:00:00','2026-01-02 13:00:00',90),
(4,'P7','2026-01-02 12:00:00','2026-01-02 12:00:00',100);SELECT d.decision_id, AVG(m.value) AS feature_mean
FROM decisions AS d LEFT JOIN measurements AS m
ON m.asset_id=d.asset_id
AND m.event_at >= datetime(d.cutoff, '-24 hours')
AND m.event_at < d.cutoff
AND m.available_at <= d.cutoff
GROUP BY d.decision_id ORDER BY d.decision_id;| decision_id | feature_mean |
|---|---|
| D1 | 20 |
| D2 | NULL |
D2 stays in the output with an unknown mean. The challenge adds a count so each mean’s evidence basis remains visible.
Check yourself
Questions on Parts 1 to 3
Question 1 of 2
Can it be used in the noon feature?
Use the information below.
A reading happened at 11:00, became available at 13:00, and the decision cutoff is 12:00.4. Inspect the eligible event rows before trusting their mean
When a feature value looks surprising, select the joined event IDs and timestamps before aggregating them. That reveals a late arrival or a boundary event directly. An aggregate alone hides which records were admitted.
The workshop exposes this relation as a separate component. Its summaries are then left-joined back to the decision list, because filtering joined event rows first can otherwise remove decisions with no remaining matches.
Preserve the intended decision population separately from the population of eligible events.
Show the two records supporting D1
This query inspects evidence membership instead of calculating a summary.
| decision_id | asset_id | cutoff |
|---|---|---|
| D1 | P7 | 2026-01-02 12:00:00 |
| D2 | P8 | 2026-01-02 12:00:00 |
| event_id | asset_id | event_at | available_at | value |
|---|---|---|---|---|
| 1 | P7 | 2026-01-01 12:00:00 | 2026-01-01 12:05:00 | 10 |
| 2 | P7 | 2026-01-02 10:00:00 | 2026-01-02 10:05:00 | 30 |
| 3 | P7 | 2026-01-02 11:00:00 | 2026-01-02 13:00:00 | 90 |
| 4 | P7 | 2026-01-02 12:00:00 | 2026-01-02 12:00:00 | 100 |
Database setup for this example
CREATE TABLE decisions(decision_id TEXT PRIMARY KEY NOT NULL, asset_id TEXT NOT NULL, cutoff TEXT NOT NULL);
CREATE TABLE measurements(event_id INTEGER PRIMARY KEY, asset_id TEXT NOT NULL, event_at TEXT NOT NULL, available_at TEXT NOT NULL, value REAL NOT NULL);
INSERT INTO decisions VALUES ('D1','P7','2026-01-02 12:00:00'),('D2','P8','2026-01-02 12:00:00');
INSERT INTO measurements VALUES
(1,'P7','2026-01-01 12:00:00','2026-01-01 12:05:00',10),
(2,'P7','2026-01-02 10:00:00','2026-01-02 10:05:00',30),
(3,'P7','2026-01-02 11:00:00','2026-01-02 13:00:00',90),
(4,'P7','2026-01-02 12:00:00','2026-01-02 12:00:00',100);SELECT d.decision_id, m.event_id, m.value
FROM decisions AS d JOIN measurements AS m ON m.asset_id=d.asset_id
AND m.event_at >= datetime(d.cutoff, '-24 hours')
AND m.event_at < d.cutoff AND m.available_at <= d.cutoff
ORDER BY d.decision_id, m.event_id;| decision_id | event_id | value |
|---|---|---|
| D1 | 1 | 10 |
| D1 | 2 | 30 |
The first event sits exactly on the lower boundary and is correctly included.
5. Keep the answer’s future window separate from the input window
A failure label might describe whether the pump failed in the next seven days. That future information is the target being learned, not an input available at noon. Its source records and join conditions should remain separate from the feature calculation.
A decision made two days before the dataset ends has not necessarily completed a seven-day outcome window. Treating its absent failure record as a negative label can introduce censoring errors. Define when labels have matured, how failures are observed and how incomplete follow-up is handled.
A clear label definition includes its horizon and observation coverage as well as the outcome event.
6. A correction needs its own historical version
Suppose an old value is corrected after the decision. If the original row was overwritten, filtering its old event time does not reconstruct what the model originally saw. Preserve versions or an as-of history with availability for each value revision.
Use a documented revision-selection rule when several versions of one logical event are eligible. Do not count those versions as independent physical measurements. Our small table deliberately contains one immutable row per event so that the timing rule can be studied first.
Point-in-time reconstruction depends on data history that the storage design actually retains.
7. Check joins for multiplication before calling the output a feature
A measurement joined to several maintenance notes can appear several times before averaging. Some averages may accidentally remain the same while counts and weighted summaries change. Aggregate each relation to the intended grain before combining independent one-to-many histories.
Check uniqueness of decision_id in the final result. Compare counts of eligible event identities as well as numerical summaries, and retain a versioned feature definition so a changed rule is not silently mixed into old training sets.
A time-correct join can still be wrong because of record multiplication.
8. Keep training and live feature rules aligned
The live service and historical query need compatible units, windows, missing-value handling and availability rules. A notebook calculation that uses a different cutoff or quietly fills missing means with zero will train a different input distribution.
Feast’s documented created-timestamp filter is one available mechanism when supported by the selected offline store and backed by a meaningful timestamp. Framework support does not decide the correct timestamp semantics or repair missing version history for us.
Verify the deployed feature values against independently reconstructed historical examples.
9. A time-respecting feature does not replace an evaluation split
This lesson prevents one source of leakage within each row. Training and evaluation still need a split suited to the deployment question, which may involve later time periods, held-out equipment or both. Reusing related outcomes across the split can still make results look better than they will be on new work.
Keep preprocessing fitted only on the appropriate training data. The SQL table is an input to that learning process, not evidence that the entire modelling workflow is free of leakage.
The database boundary and the model-evaluation boundary solve different problems.
10. Test the rows that almost belong
Move availability from 13:00 to exactly noon and predict that the 11:00 event becomes eligible. Move an event to exactly the decision cutoff and predict that it stays excluded. Add a second decision for the same pump and calculate its boundaries independently.
These changes test the information the model was allowed to have. They are more revealing than checking only whether the base mean happens to equal twenty.
A useful historical feature test states both what entered and why other plausible-looking evidence stayed out.
Check yourself
Questions on Parts 1 to 10
Question 1 of 2
What must be considered before assigning a negative label?
Use the information below.
The label is failure within seven days, but the dataset ends two days after a decision with no recorded failure.Try it with Botie
Build with Botie
Write and run SQL