Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
At 09:00 on May 1, 2026 the scheduler asks a blunt question: will training job J-204 miss its SLA? The scoring service doesn't read the raw event log. It needs one frozen row of measurements that already existed at that moment.
A versioned dataset with honest splits still stops one step short of that row. Schema checks keep columns typed. Grouped splits keep related records from leaking into evaluation. Those checks don't freeze what each number meant at prediction time.
Those numbers are features. hours_since_last_heartbeat compresses many scheduler pings into one input. Inventing a column is cheap. Making the same column mean the same thing in a training snapshot and in a live request is the actual work.
Start with a prediction timestamp
At that 09:00 ask, only some fields on the job record are legal inputs. Heartbeats after the timestamp aren't available to the prediction service, so they can't appear in the training row either.
| Candidate field | Known at prediction time? | Use as feature? |
|---|---|---|
| runner pool | yes | yes, categorical |
| queued minutes | yes | yes, numeric |
| hours since most recent heartbeat | yes | yes, numeric |
| cluster queue backlog | yes | yes, numeric |
| finished timestamp | no | no, it defines the eventual label |
| post-SLA escalation | no | no, it leaks the outcome |
The label may be computed later as missed_sla = 1. A feature must be computed from history ending at the prediction timestamp. If a training row contains the post-SLA escalation, feature leakage rewards the model for reading the answer.
That leak isn't subtle in a warehouse dump that already has the later escalation bit:
1rows = [
2 {"missed_sla": 1, "escalation": True, "queue_backlog": 18},
3 {"missed_sla": 0, "escalation": False, "queue_backlog": 3},
4 {"missed_sla": 1, "escalation": True, "queue_backlog": 9},
5 {"missed_sla": 0, "escalation": False, "queue_backlog": 22},
6]
7
8def accuracy(predict):
9 hits = sum(predict(row) == row["missed_sla"] for row in rows)
10 return hits / len(rows)
11
12print("escalation rule:", accuracy(lambda row: int(row["escalation"])))
13print("backlog > 10:", accuracy(lambda row: int(row["queue_backlog"] > 10)))1escalation rule: 1.0
2backlog > 10: 0.5On this four-row dump, predicting a miss exactly when the row was escalated is perfect. queue_backlog > 10 is a coin flip. At 09:00 the escalation field doesn't exist yet, so the perfect rule can't run. The backlog number can.
Sculley et al. treat data dependencies, hidden feedback loops, undeclared consumers, and configuration as ML-specific technical debt.[1] Feature definitions sit in that graph. When the time boundary is implicit, an impressive offline score doesn't survive serving.
Work the cutoff by hand before writing a transform. Prediction time is 09:00. Heartbeats exist at 01:00, 08:30, and 12:00. Only the first two are visible. The latest visible ping is 08:30, so heartbeat age is 30 minutes, which is 0.5 hours. The noon ping would make the age negative. Don't cap a future event to zero. Drop it.
![Job J-204 cutoff at 09:00: heartbeats at 01:00 and 08:30 stay in the known panel and compress to a 0.5-hour heartbeat age, while the 12:00 heartbeat, May 3 finished_at, and post-SLA escalation sit in the future panel outside the emitted row [a100-pool, 42.0, 0.5, 18.0, 0, 0].](/cdn/content-image/foundations/feature-engineering-production-ml/illustrations/_generated/chapter_flow_dark.png?v=6be3e1d19f4c)
hb_age_h is hours since the 08:30 ping (0.5). Short headers map to queued_minutes, hours_since_last_heartbeat, and queue_backlog_missing. The noon heartbeat, May 3 finish, and post-SLA escalation stay out of the emitted row.
The shared contract is the only reason the training snapshot and the live request can claim to mean the same thing. Labels join later. They never travel with the model input.
Why must a feature definition begin with a prediction timestamp?
Answer
The timestamp defines which source facts were available when the prediction was made. Without it, a training row can silently include future outcomes or later updates that live serving couldn't access.
Write the missing-value and category rules
A cutoff still isn't a model input. Each surviving field needs a type, a missing rule, and a reason to exist. That's the feature contract for this job-SLA scorer:
| Feature | Type | Missing rule | Why it can help |
|---|---|---|---|
queued_minutes | numeric | reject if absent; allow 0 | longer queue waits expose more scheduling risk |
hours_since_last_heartbeat | numeric | cap at 180 | stale heartbeats signal job risk |
queue_backlog | numeric | use measured queue only | queue pressure affects scheduling and preemption |
runner_pool | categorical | reject if absent; map unseen to other | runner pools have different networks |
priority_job | boolean | default false only when source guarantees it | priority changes SLA and retry policy |
A missing value is a product decision. Filling missing queue_backlog with zero says "unknown congestion means no congestion," which is rarely defensible. Store an extra queue_backlog_missing indicator, or stop scoring until the feed recovers.
Categorical values need a policy too. If a new runner pool appears after training, the online encoder can't invent a new model column. An other bucket stays stable while a new model candidate is evaluated. Missing runner-pool data is different: it may signal a broken source feed, so don't silently fold it into other.
The 180-hour heartbeat cap is part of the fitted contract, not a live knob. Sculley et al. call this CACE, Changing Anything Changes Everything: raising the cap at deploy time without retraining means every weight that reads heartbeat age is reading a different number.[1]
Keep only events at or before 09:00
Make the prediction timestamp explicit, then drop later events before aggregating. Keep heartbeats at or before 09:00. Treat a heartbeat as visible as soon as it occurs. The next lesson adds a separate ingestion timestamp so a replay can't use an earlier heartbeat that arrived late.
1from datetime import datetime
2
3prediction_time = datetime.fromisoformat("2026-05-01T09:00:00")
4heartbeats = [
5 datetime.fromisoformat("2026-05-01T01:00:00"),
6 datetime.fromisoformat("2026-05-01T08:30:00"),
7 datetime.fromisoformat("2026-05-01T12:00:00"),
8]
9
10visible_heartbeats = [heartbeat for heartbeat in heartbeats if heartbeat <= prediction_time]
11print("visible heartbeats:", len(visible_heartbeats))
12print("latest visible:", max(visible_heartbeats).isoformat())
13print("heartbeat age hours:", (prediction_time - max(visible_heartbeats)).total_seconds() / 3600)1visible heartbeats: 2
2latest visible: 2026-05-01T08:30:00
3heartbeat age hours: 0.5Filtering source history isn't enough by itself. The row contract must also enforce output keys, types, missing-value meaning, fitted category mappings, and numeric bounds:
queue_backlog=0means a measured empty queue; missing backlog emitsqueue_backlog_missing=1.- an unseen but valid runner pool maps to fitted category
other; an absent pool is rejected. - heartbeat age is capped at the fitted 180-hour limit, while a future heartbeat is rejected.
- an output allowlist blocks future-only keys such as
finished_at.
Source filtering proves values were available at the cutoff. The allowlist proves only declared keys leave the transform. Neither check replaces offline/online parity.
Assemble the contract in code
The source record may contain post-job-SLA fields because labels need them later. The returned model row may contain only allowlisted feature keys.
1from datetime import datetime
2from math import isfinite
3
4allowed_feature_keys = {
5 "runner_pool",
6 "queued_minutes",
7 "hours_since_last_heartbeat",
8 "queue_backlog",
9 "queue_backlog_missing",
10 "priority_job",
11}
12known_runner_pools = {"a100-pool", "h100-pool"}
13future_only_keys = {"finished_at", "post_sla_escalation"}
14
15def make_features(row, prediction_time):
16 required_keys = {"queued_minutes", "last_heartbeat_at", "priority_job"}
17 missing_keys = sorted(required_keys - row.keys())
18 if missing_keys:
19 raise ValueError(f"missing required fields: {missing_keys}")
20
21 queued_minutes = float(row["queued_minutes"])
22 if not isfinite(queued_minutes) or queued_minutes < 0:
23 raise ValueError("queued_minutes must be finite and nonnegative")
24
25 runner_pool = row.get("runner_pool")
26 if not isinstance(runner_pool, str) or not runner_pool.strip():
27 raise ValueError("runner_pool is missing")
28 runner_pool = runner_pool.strip().casefold()
29
30 if not isinstance(row["priority_job"], bool):
31 raise ValueError("priority_job must be boolean")
32
33 last_heartbeat = datetime.fromisoformat(row["last_heartbeat_at"])
34 if last_heartbeat > prediction_time:
35 raise ValueError("last_heartbeat_at is after prediction_time")
36 heartbeat_age = min((prediction_time - last_heartbeat).total_seconds() / 3600, 180.0)
37
38 queue_backlog = row.get("queue_backlog")
39 if queue_backlog is not None:
40 queue_backlog = float(queue_backlog)
41 if not isfinite(queue_backlog) or queue_backlog < 0:
42 raise ValueError("queue_backlog must be finite and nonnegative")
43 features = {
44 "runner_pool": runner_pool if runner_pool in known_runner_pools else "other",
45 "queued_minutes": queued_minutes,
46 "hours_since_last_heartbeat": heartbeat_age,
47 "queue_backlog": 0.0 if queue_backlog is None else queue_backlog,
48 "queue_backlog_missing": int(queue_backlog is None),
49 "priority_job": int(row["priority_job"]),
50 }
51
52 blocked_fields = sorted(features.keys() - allowed_feature_keys)
53 if blocked_fields:
54 raise ValueError(f"unexpected feature fields: {blocked_fields}")
55 return features
56
57prediction_time = datetime.fromisoformat("2026-05-01T09:00:00")
58job = {
59 "job_id": "J-204",
60 "runner_pool": "a100-pool",
61 "queued_minutes": 42,
62 "queue_backlog": 18,
63 "priority_job": False,
64 "last_heartbeat_at": "2026-05-01T08:30:00",
65 "finished_at": "2026-05-03T14:00:00",
66 "post_sla_escalation": True,
67}
68
69features = make_features(job, prediction_time)
70print(features)
71print("future fields leaked:", sorted(features.keys() & future_only_keys))1{'runner_pool': 'a100-pool', 'queued_minutes': 42.0, 'hours_since_last_heartbeat': 0.5, 'queue_backlog': 18.0, 'queue_backlog_missing': 0, 'priority_job': 0}
2future fields leaked: []finished_at and post_sla_escalation stay on the source record for a later label join. They never appear in features. Probe the highest-risk branches next: missing backlog, unseen pool, absent pool, future heartbeat, and a negative queue time.
1missing_backlog = make_features({**job, "queue_backlog": None}, prediction_time)
2assert missing_backlog["queue_backlog"] == 0.0
3assert missing_backlog["queue_backlog_missing"] == 11unseen_pool = make_features({**job, "runner_pool": "l4-pool"}, prediction_time)
2assert unseen_pool["runner_pool"] == "other"1try:
2 make_features({**job, "runner_pool": None}, prediction_time)
3except ValueError as error:
4 assert str(error) == "runner_pool is missing"
5else:
6 raise AssertionError("missing runner_pool was accepted")1try:
2 make_features({**job, "last_heartbeat_at": "2026-05-01T12:00:00"}, prediction_time)
3except ValueError as error:
4 assert str(error) == "last_heartbeat_at is after prediction_time"
5else:
6 raise AssertionError("future heartbeat was accepted")1zero_queue = make_features({**job, "queued_minutes": 0}, prediction_time)
2assert zero_queue["queued_minutes"] == 0.01try:
2 make_features({**job, "queued_minutes": -1}, prediction_time)
3except ValueError as error:
4 assert str(error) == "queued_minutes must be finite and nonnegative"
5else:
6 raise AssertionError("negative queued_minutes was accepted")A model still needs a fixed vector order and fitted categorical mapping. Version both beside the model artifact. For J-204 at 09:00 that vector is the row the figure emitted:
1runner_pool_code = {"a100-pool": 0, "h100-pool": 1, "other": 2}
2feature_order = (
3 "runner_pool_code",
4 "queued_minutes",
5 "hours_since_last_heartbeat",
6 "queue_backlog",
7 "queue_backlog_missing",
8 "priority_job",
9)
10features = {
11 "runner_pool": "a100-pool",
12 "queued_minutes": 42.0,
13 "hours_since_last_heartbeat": 0.5,
14 "queue_backlog": 18.0,
15 "queue_backlog_missing": 0,
16 "priority_job": 0,
17}
18
19vector = [
20 runner_pool_code[features["runner_pool"]],
21 features["queued_minutes"],
22 features["hours_since_last_heartbeat"],
23 features["queue_backlog"],
24 features["queue_backlog_missing"],
25 features["priority_job"],
26]
27print("order:", feature_order)
28print("vector:", vector)1order: ('runner_pool_code', 'queued_minutes', 'hours_since_last_heartbeat', 'queue_backlog', 'queue_backlog_missing', 'priority_job')
2vector: [0, 42.0, 0.5, 18.0, 0, 0]Same names in a different order, or a regenerated runner_pool_code map, would feed the trained weights a different story. The package has to carry the fitted mapping, the 180-hour cap, and this order with the weights.
Why keep both queue_backlog=0 and queue_backlog_missing=1 when the source value is absent?
Answer
Measured zero and unknown congestion aren't the same fact. The indicator preserves that distinction instead of teaching the model that a broken feed means an empty queue.
Changing one fitted number changes the vector
The 180-hour heartbeat cap is part of the trained package. Sculley et al. call the failure CACE: Changing Anything Changes Everything.[1] Job J-188 last heartbeated 200 hours before scoring. Training clipped that age to 180.0. A later deploy that "just" raises the cap to 360 hours writes 200.0 into the same slot. Every weight that reads heartbeat age now sees a number the fit never produced.

180.0 because that was the fitted cap. A live raise to 360 writes 200.0 into the same slot, so the model is no longer reading the number it was trained on.1def heartbeat_age_hours(raw_hours, cap_hours):
2 return min(raw_hours, cap_hours)
3
4raw_age = 200.0
5training_vector = heartbeat_age_hours(raw_age, 180.0)
6live_vector = heartbeat_age_hours(raw_age, 360.0)
7print("raw hours:", raw_age)
8print("training hb_age_h:", training_vector)
9print("live hb_age_h after cap change:", live_vector)
10print("same slot, different meaning:", training_vector != live_vector)1raw hours: 200.0
2training hb_age_h: 180.0
3live hb_age_h after cap change: 200.0
4same slot, different meaning: Truepandas and scikit-learn still have to ship that same cap. OrdinalEncoder can own the runner-pool codes only if other is a fitted category, not something you invent at serve time.
1import pandas as pd
2from sklearn.preprocessing import OrdinalEncoder
3
4rows = pd.DataFrame(
5 [
6 {"runner_pool": "a100-pool", "queued_minutes": 42.0, "raw_heartbeat_hours": 0.5},
7 {"runner_pool": "l4-pool", "queued_minutes": 18.0, "raw_heartbeat_hours": 200.0},
8 ]
9)
10rows["hb_age_h"] = rows["raw_heartbeat_hours"].map(lambda hours: heartbeat_age_hours(hours, 180.0))
11
12mapped_pools = rows["runner_pool"].map(
13 lambda pool: pool if pool in {"a100-pool", "h100-pool"} else "other"
14)
15encoder = OrdinalEncoder(categories=[["a100-pool", "h100-pool", "other"]])
16rows["runner_pool_code"] = encoder.fit_transform(mapped_pools.to_frame())
17
18print(rows[["runner_pool", "runner_pool_code", "hb_age_h"]].to_string(index=False))
19print("unknown pools share the fitted other code:", set(rows.loc[rows["runner_pool"].eq("l4-pool"), "runner_pool_code"]) == {2.0})1runner_pool runner_pool_code hb_age_h
2 a100-pool 0.0 0.5
3 l4-pool 2.0 180.0
4unknown pools share the fitted other code: TrueThe pandas table didn't invent a new meaning. l4-pool still becomes fitted other (2.0), and the 200-hour heartbeat still clips to 180.0. Ship the encoder categories and the cap with the weights. Don't regenerate them from today's runner catalog.
A serving change raises the heartbeat-age cap from 180 hours to 360 hours and keeps the same model file. What broke?
Answer
The trained weights interpret heartbeat age under the fitted 180-hour cap. A 200-hour heartbeat that used to arrive as 180.0 now arrives as 200.0, so the same slot has a new meaning.
Compare the offline row with the live row
An offline notebook might compute queue backlog by scanning a completed daily table. The service might read an hourly cache. Even when both columns are named queue_backlog, differences in freshness or aggregation can change predictions. That failure is training-serving skew.
A feature store is one way to ship the same contract to both paths: historical rows for training, low-latency lookups for scoring. Feast's historical retrieval does a point-in-time join: it scans backward from each entity timestamp up to the feature view's TTL, then joins the latest eligible feature row.[2] Its online store keeps only the latest values per entity key, not the historical series.[3] A model release must name the feature definition and snapshot that produced its score. The store doesn't invent the cutoff, missing policy, or vector order. It executes them.
Sample offline and online paths on the same entities before promoting a model. Compare both a matching row and a stale-cache failure.
1def mismatches(offline, online):
2 keys = offline.keys() | online.keys()
3 return {key: (offline.get(key), online.get(key)) for key in keys if offline.get(key) != online.get(key)}
4
5offline = {"runner_pool": "a100-pool", "queue_backlog": 18.0, "priority_job": 0}
6online_rows = {
7 "matching": {"runner_pool": "a100-pool", "queue_backlog": 18.0, "priority_job": 0},
8 "stale cache": {"runner_pool": "a100-pool", "queue_backlog": 0.0, "priority_job": 0},
9}
10
11for name, online in online_rows.items():
12 differences = mismatches(offline, online)
13 print(name, "mismatches:", differences, "release allowed:", not differences)1matching mismatches: {} release allowed: True
2stale cache mismatches: {'queue_backlog': (18.0, 0.0)} release allowed: FalseMatching names and types aren't enough. The 18.0 vs 0.0 backlog pair is a release blocker even though both dictionaries look well-formed.
Watch the contract before watching accuracy:
| Production check | Failure it catches | Action |
|---|---|---|
| null rate by feature | upstream feed disappeared | fail closed or fallback |
| unseen-category rate | runner-pool catalog changed | collect labels and retrain |
| freshness lag | online values are stale | pause promotions |
| offline/online parity sample | transformations disagree | repair feature path |
Parity strategy also depends on where the feature is computed:
| Feature kind | Production path | Parity strategy |
|---|---|---|
| request-time | computed on the serving path from the live request and request-local context | log raw request fields and recompute the training transform offline |
| precomputed store | written to an online feature store ahead of the request | dual-write or dual-read the store path, plus point-in-time replay for training rows |
Request-path fields need log-and-recompute. Precomputed store fields need dual-write (or dual-read) plus PIT replay. Both still need a shared transformation contract. Only the evidence path differs.
Freshness failures need an explicit product policy. This one tries normal scoring, fallback, then abstention. The 15 / 45 minute cutoffs are a local product policy, not a universal constant. Other lessons may choose different numbers for the same idea. Version the thresholds with the serving path.
1def scoring_policy(freshness_lag_minutes):
2 if freshness_lag_minutes <= 15:
3 return "normal scoring"
4 if freshness_lag_minutes <= 45:
5 return "fallback model"
6 return "abstain and alert"
7
8for lag in [8, 30, 90]:
9 print(f"{lag} minutes -> {scoring_policy(lag)}")18 minutes -> normal scoring
230 minutes -> fallback model
390 minutes -> abstain and alertThe 09:00 row is now a contract: cutoff, types, missing rules, fitted maps, vector order, and a parity sample. It still assumes every heartbeat is visible as soon as it occurs. Real pipelines have a second clock.
Offline and online transforms return different queue_backlog values for the same entity and cutoff. What should the release check do?
Answer
Block release and inspect freshness, aggregation, and feature-definition versions. Matching column names don't establish training-serving parity.