Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Friday's cluster ran 160 batch jobs against a weekly-lag forecast of 132. That leftover became a reviewable alert. The forecasts, and the job-risk scores that decide which jobs get SLA review, can still fail after the endpoint looks healthy: inputs break now, labels arrive after the promised window, and a newer model can look better offline while costing more after promotion.
Monitoring splits those questions onto separate clocks. Can this request be scored safely? Has traffic changed? Did eventual outcomes stay good enough? Should a candidate replace the current release?

s-103 is only 15 minutes old and still blocks because queue_backlog is missing. s-104 is 180 minutes old. The right bars are hours from noon for SLA labels: five predictions are already due, and p-006 at +1h stays out of the quality cohort.Start with a served release and feature traces
Stay with that serving cluster. Now score each live job for SLA-miss risk. High scores enter review. Each request carries two model inputs:
| Feature | Meaning | Why it can fail |
|---|---|---|
queue_backlog | jobs waiting in the runner pool | feed can become missing or use wrong units |
hours_since_last_heartbeat | age of latest scheduler heartbeat | heartbeat pipeline can become stale |
Store a feature trace with each prediction. A feature trace records the values, timestamps, and feature-contract versions that produced one score. It lets an engineer reproduce the decision after traffic, code, or source data changes.
Build six incoming requests against deployed release job-risk-v3. Two rows contain failures that should be caught before delayed SLA labels exist.
1from datetime import datetime, timedelta, timezone
2from hashlib import sha256
3from json import dumps
4from math import log
5
6now = datetime(2026, 2, 4, 12, 0, tzinfo=timezone.utc)
7deployed = {
8 "model": "job-risk-v3",
9 "feature_contract": "features-v3",
10 "threshold": 0.50,
11}
12requests = [
13 {"job_id": "s-100", "runner_pool": "fc-a", "queue_backlog": 18, "hours_since_last_heartbeat": 2, "feature_as_of": now - timedelta(minutes=10), "risk_score": 0.18},
14 {"job_id": "s-101", "runner_pool": "fc-a", "queue_backlog": 27, "hours_since_last_heartbeat": 8, "feature_as_of": now - timedelta(minutes=20), "risk_score": 0.44},
15 {"job_id": "s-102", "runner_pool": "fc-b", "queue_backlog": 35, "hours_since_last_heartbeat": 20, "feature_as_of": now - timedelta(minutes=25), "risk_score": 0.76},
16 {"job_id": "s-103", "runner_pool": "fc-b", "queue_backlog": None, "hours_since_last_heartbeat": 26, "feature_as_of": now - timedelta(minutes=15), "risk_score": None},
17 {"job_id": "s-104", "runner_pool": "fc-a", "queue_backlog": 22, "hours_since_last_heartbeat": 6, "feature_as_of": now - timedelta(minutes=180), "risk_score": 0.52},
18 {"job_id": "s-105", "runner_pool": "fc-b", "queue_backlog": 40, "hours_since_last_heartbeat": 30, "feature_as_of": now - timedelta(minutes=5), "risk_score": 0.91},
19]
20feature_traces = [
21 {
22 **request,
23 "model": deployed["model"],
24 "feature_contract": deployed["feature_contract"],
25 }
26 for request in requests
27]
28
29print("deployed:", deployed["model"])
30print("feature contract:", deployed["feature_contract"])
31print("feature traces:", len(feature_traces))
32print("label status: pending")1deployed: job-risk-v3
2feature contract: features-v3
3feature traces: 6
4label status: pendingAt request time, you don't know which jobs will miss their SLA. You do know whether required inputs exist and whether their timestamps are fresh enough to trust. Each trace binds those values to the model and feature-contract versions that consumed them.
Block invalid inputs before waiting for labels
Define two request-time invariants:
- Required features can't be missing.
- Feature snapshots can't be older than 60 minutes. That bound is a feature freshness contract: age is prediction time minus the snapshot's
feature_as_of, and a typed non-null value can still be unusable if that gap is too large.
An invariant is a condition that has to stay true for the system to operate safely. Check each trace and block unsafe rows instead of sending a plausible-looking score downstream.
1required_features = ["queue_backlog", "hours_since_last_heartbeat"]
2max_feature_age_minutes = 60
3
4def request_health(row):
5 missing = [
6 name
7 for name in required_features
8 if row[name] is None
9 ]
10 feature_age = int((now - row["feature_as_of"]).total_seconds() / 60)
11 reasons = []
12 if missing:
13 reasons.append("missing=" + ",".join(missing))
14 if feature_age > max_feature_age_minutes:
15 reasons.append(f"stale={feature_age}m")
16 return reasons
17
18healthy_requests = []
19for row in feature_traces:
20 reasons = request_health(row)
21 if reasons:
22 print(row["job_id"], "-> block", ";".join(reasons))
23 else:
24 healthy_requests.append(row)
25 print(row["job_id"], "-> score")
26
27print("scored:", len(healthy_requests))
28print("blocked:", len(feature_traces) - len(healthy_requests))1s-100 -> score
2s-101 -> score
3s-102 -> score
4s-103 -> block missing=queue_backlog
5s-104 -> block stale=180m
6s-105 -> score
7scored: 4
8blocked: 2Job s-103 lacks backlog data even though its snapshot is only 15 minutes old. Job s-104 carries a three-hour-old snapshot. A label-based accuracy report would discover either unsafe path too late.
Sculley et al. treat unstable data dependencies, input-data tests, and live monitoring as first-class production concerns, including prediction-bias checks and alerts from upstream feature producers.[1] The practical point is that models depend on surrounding data systems, not model code alone.
Why block s-104 before its SLA label arrives?
Answer
The three-hour-old feature snapshot violates a request-time freshness contract. A future label may measure outcome quality, but it can't make an unsafe current input trustworthy.
Measure drift without calling it failure
Healthy rows can still look different from training traffic. Data drift means an input distribution changed over time. Klaise et al. treat those distribution checks as a production signal when labels lag, and they distinguish covariate shift ( moves) from label shift ( moves).[2] A shift is a reason to inspect. It isn't proof that accuracy fell.
Compare the historical and current distributions for hours_since_last_heartbeat:
| Bucket | Training window | Current window | Change |
|---|---|---|---|
0-4h | 50% | 30% | -20 points |
4-12h | 30% | 25% | -5 points |
12-24h | 15% | 25% | +10 points |
24h+ | 5% | 20% | +15 points |

0-4h; live mass has moved into 24h+. Population Stability Index (PSI) 0.37 is a one-number summary of that transfer, not a verdict that v3 is inaccurate.Older heartbeats are more common now. A scheduler outage could cause that shift, but so could a holiday or runner-pool mix change.
Compress the four bucket pairs into one diagnostic with Population Stability Index (PSI):
Here is the historical share for bucket , and is its current share. This exercise uses non-zero shares in every bucket so the logarithm is defined. Real monitoring code must choose an explicit policy for empty buckets, such as a small floor, before taking the log.
For continuous numerical inputs without predefined bins, two-sample tests like the Kolmogorov-Smirnov (KS) test (measuring the maximum divergence between empirical cumulative distribution functions, ) or Wasserstein distance detect shifts directly. When features are binned or categorical, PSI and Chi-square () tests provide standard discrete summaries.
Work the largest term by hand. The 24h+ bucket moved from to :
The other three terms add about , so PSI is about . Credit-scoring practice often treats PSI below as small and as large. Treat those bands as a local starting point, not a retraining law. Confirm the sum in code and apply a local investigation threshold of 0.20.
1bucket_names = ["0-4h", "4-12h", "12-24h", "24h+"]
2reference_counts = [50, 30, 15, 5]
3current_counts = [30, 25, 25, 20]
4
5def normalize(counts):
6 total = sum(counts)
7 return [count / total for count in counts]
8
9def population_stability_index(reference, current):
10 return sum(
11 (actual - expected) * log(actual / expected)
12 for expected, actual in zip(reference, current)
13 )
14
15reference = normalize(reference_counts)
16current = normalize(current_counts)
17psi = population_stability_index(reference, current)
18investigate_at = 0.20
19
20for name, before, after in zip(bucket_names, reference, current):
21 print(f"{name}: reference={before:.2f} current={after:.2f} delta={after - before:+.2f}")
22print("PSI:", round(psi, 3))
23print("local action:", "inspect shift" if psi >= investigate_at else "continue")10-4h: reference=0.50 current=0.30 delta=-0.20
24-12h: reference=0.30 current=0.25 delta=-0.05
312-24h: reference=0.15 current=0.25 delta=+0.10
424h+: reference=0.05 current=0.20 delta=+0.15
5PSI: 0.37
6local action: inspect shiftPSI is a compact diagnostic, not an accuracy verdict. The 0.20 cutoff is a local policy calibrated on stable windows. This result calls for inspection; it doesn't prove release v3 is inaccurate or that retraining is the first fix.
Why shouldn't the 0.37 PSI result automatically start retraining?
Answer
PSI only reports a distribution change. Inspect source-data health, business context, and delayed outcomes first. Retraining on corrupted inputs can produce a newer broken model.
Data drift isn't concept drift
Data drift (covariate shift) is a change in the input distribution . Feature PSI measures that class of change. Concept drift is a change in the relationship : the same feature values start mapping to different outcomes. Label / prior shift is a change in that may or may not accompany either of the others.
| Change | What moved | Early evidence | First response |
|---|---|---|---|
| data drift | (feature mix) | feature PSI, freshness, schema | inspect sources and traffic mix |
| concept drift | (label relationship) | delayed quality, slice calibration, residual patterns after inputs look valid | freeze clean snapshot and retrain candidate when inputs and parity are healthy |
| label / prior shift | base rate | outcome rate in mature cohorts | recheck decision threshold and cost policy before blaming the model |
Delayed quality metrics and slice calibration are concept-drift evidence: they compare scores to outcomes after the label window closes. Feature PSI alone can't prove concept drift. Don't retrain on corrupt or skewed inputs either; fix data path and parity first, then use mature delayed quality to decide whether the learned relationship still holds.
Score distributions are a useful early signal between feature PSI and delayed labels. If risk scores shift sharply while feature mix and parity look stable, inspect serving config, threshold policy, and recent transform deploys before outcomes mature. A score histogram isn't an accuracy verdict; it only shortens the time to notice that something changed.
Reproduce training-serving skew
Training-serving skew means production computes an input differently from training. Suppose training divided backlog count by staffed runner-pool capacity, while a serving change divides by a fixed 100.
Probe the same raw row through both transformations.
1probe = {"queue_backlog": 20, "staffed_capacity": 50}
2
3def training_transform(row):
4 return round(row["queue_backlog"] / row["staffed_capacity"], 3)
5
6def buggy_serving_transform(row):
7 return round(row["queue_backlog"] / 100, 3)
8
9training_value = training_transform(probe)
10serving_value = buggy_serving_transform(probe)
11print("training backlog ratio:", training_value)
12print("serving backlog ratio:", serving_value)
13print("parity:", training_value == serving_value)1training backlog ratio: 0.4
2serving backlog ratio: 0.2
3parity: FalseThe same runner pool becomes 0.4 in training and 0.2 online. A model retrained on the existing training function won't repair that serving bug. Fix the shared transformation contract and keep a parity test in the release gate.
Google Cloud's MLOps guidance includes unit tests for feature-engineering logic, prediction-service tests that call the API with expected inputs, data validation, and a check that the candidate meets predictive-performance targets before deployment.[3]
Join delayed labels back to stored predictions
Request-time checks move fast because they don't need outcomes. Accuracy moves slower. An SLA-miss label may arrive only after the promised job window closes.
Store model ID, score, job ID, decision context, and the time when each outcome becomes mature. Later, join outcomes by immutable prediction ID. Compute quality on cohorts whose outcome windows have closed, and report label coverage inside those cohorts. Labels that happen to arrive early can be a biased subset; a missing label in a mature cohort is a coverage gap, not an on-schedule outcome.
1predictions = [
2 {"prediction_id": "p-001", "job_id": "s-200", "risk_score": 0.15, "priority_job": False, "label_due_at": now - timedelta(hours=5)},
3 {"prediction_id": "p-002", "job_id": "s-201", "risk_score": 0.32, "priority_job": False, "label_due_at": now - timedelta(hours=4)},
4 {"prediction_id": "p-003", "job_id": "s-202", "risk_score": 0.68, "priority_job": True, "label_due_at": now - timedelta(hours=3)},
5 {"prediction_id": "p-004", "job_id": "s-203", "risk_score": 0.78, "priority_job": False, "label_due_at": now - timedelta(hours=2)},
6 {"prediction_id": "p-005", "job_id": "s-204", "risk_score": 0.91, "priority_job": True, "label_due_at": now - timedelta(hours=1)},
7 {"prediction_id": "p-006", "job_id": "s-205", "risk_score": 0.84, "priority_job": True, "label_due_at": now + timedelta(hours=1)},
8]
9decision_policy = "missed-sla-threshold-050-v1"
10predictions = [
11 {
12 **prediction,
13 "model": deployed["model"],
14 "feature_contract": deployed["feature_contract"],
15 "decision_policy": decision_policy,
16 "decision_threshold": deployed["threshold"],
17 }
18 for prediction in predictions
19]
20labels = {
21 "p-001": False,
22 "p-002": False,
23 "p-003": True,
24 "p-004": False,
25 "p-005": True,
26}
27
28matured = [prediction for prediction in predictions if prediction["label_due_at"] <= now]
29immature = [prediction for prediction in predictions if prediction["label_due_at"] > now]
30matured_labeled = []
31missing_matured_labels = []
32for prediction in matured:
33 prediction_id = prediction["prediction_id"]
34 if prediction_id in labels:
35 matured_labeled.append({**prediction, "missed_sla": labels[prediction_id]})
36 else:
37 missing_matured_labels.append(prediction_id)
38
39label_coverage = len(matured_labeled) / len(matured) if matured else 0.0
40
41print("stored predictions:", len(predictions))
42print("matured predictions:", len(matured))
43print("joined matured labels:", len(matured_labeled))
44print(f"matured label coverage: {label_coverage:.3f}")
45print("missing matured labels:", missing_matured_labels)
46print("immature predictions:", [row["prediction_id"] for row in immature])1stored predictions: 6
2matured predictions: 5
3joined matured labels: 5
4matured label coverage: 1.000
5missing matured labels: []
6immature predictions: ['p-006']Prediction p-006 is excluded because its SLA outcome window hasn't closed. That's the right-hand clock in the opening figure: five bars sit before noon, and +1h stays out of the cohort. Label arrival doesn't define cohort maturity. The five-row mature cohort has 100% label coverage. If a mature row were still unlabeled, the report would expose the coverage gap and withhold the quality decision rather than assume the observed subset is representative.
Why exclude p-006 from this quality cohort instead of counting it as an on-schedule job?
Answer
Its outcome window hasn't closed, so the prediction isn't mature. Replacing that unknown outcome with a negative label biases the report. Separately, missing labels for predictions that are already mature must reduce reported coverage and can block quality decisions.
Evaluate decisions and calibration
A binary decision uses deployed threshold 0.50: scores at or above the threshold enter job-SLA review. Require complete label coverage for this mature lab cohort, then compute precision and recall, priority-job misses, and a small review-cost receipt.
1threshold = deployed["threshold"]
2
3if missing_matured_labels:
4 raise RuntimeError("quality metrics unavailable: mature cohort has missing labels")
5
6def predicted_missed_sla(row):
7 return row["risk_score"] >= threshold
8
9true_positives = sum(predicted_missed_sla(row) and row["missed_sla"] for row in matured_labeled)
10false_positives = sum(predicted_missed_sla(row) and not row["missed_sla"] for row in matured_labeled)
11false_negatives = sum(not predicted_missed_sla(row) and row["missed_sla"] for row in matured_labeled)
12
13def rate_or_none(numerator, denominator):
14 return round(numerator / denominator, 3) if denominator else None
15
16precision = rate_or_none(true_positives, true_positives + false_positives)
17recall = rate_or_none(true_positives, true_positives + false_negatives)
18missed_priority_job = sum(
19 row["priority_job"] and row["missed_sla"] and not predicted_missed_sla(row)
20 for row in matured_labeled
21)
22review_cost = false_positives * 2 + false_negatives * 10
23
24print("precision:", precision)
25print("recall:", recall)
26print("missed priority jobs:", missed_priority_job)
27print("review cost:", review_cost)1precision: 0.667
2recall: 1.0
3missed priority jobs: 0
4review cost: 2The local cost policy assigns 2 units to an unnecessary review and 10 to a missed SLA. It's an example decision policy, not a universal business value. If a denominator is zero, report None: missing evidence isn't the same as measured failure.
Scores also need a calibration check. Guo et al. showed that modern neural classifiers are often miscalibrated: a score near 0.80 doesn't have to mean an 80% chance unless outcomes from adequately covered mature cohorts support that reading.[4] The same habit applies to this risk model. Compare average score with observed job-SLA rate inside two tiny buckets.
1score_buckets = [
2 ("low", [row for row in matured_labeled if row["risk_score"] < 0.50]),
3 ("high", [row for row in matured_labeled if row["risk_score"] >= 0.50]),
4]
5
6for name, rows_in_bucket in score_buckets:
7 if not rows_in_bucket:
8 print(f"{name}: n=0 average_score=None observed_missed_sla_rate=None")
9 continue
10 average_score = sum(row["risk_score"] for row in rows_in_bucket) / len(rows_in_bucket)
11 observed_rate = sum(row["missed_sla"] for row in rows_in_bucket) / len(rows_in_bucket)
12 print(
13 f"{name}: n={len(rows_in_bucket)} "
14 f"average_score={average_score:.3f} "
15 f"observed_missed_sla_rate={observed_rate:.3f}"
16 )1low: n=2 average_score=0.235 observed_missed_sla_rate=0.000
2high: n=3 average_score=0.790 observed_missed_sla_rate=0.667Five labels with full mature-cohort coverage are enough to explain the receipt, not enough to approve probability quality. An empty bucket should report None instead of pretending evidence exists. A production report needs larger mature windows, explicit overall and slice coverage, and slices such as runner pool, service tier, geography, and forecast age.
Triage repair, inspection, and retraining separately
Drift, skew, and delayed quality degradation can appear together. They don't imply the same action:
| Evidence | First action |
|---|---|
| missing or stale online features | repair data path |
| parity probe fails | repair training-serving contract |
| distribution shifts | inspect source health and business context |
| delayed decision cost regresses after inputs are valid | train frozen-snapshot candidate |
Encode that order in a function so a dirty window can't skip repair and jump straight to training. The first scenario has broken data and parity. The second has clean inputs plus delayed quality regression.
1def triage(schema_failures, stale_features, parity_ok, psi, cost_regressed):
2 actions = []
3 if schema_failures or stale_features:
4 actions.append("repair online data path")
5 if not parity_ok:
6 actions.append("repair training-serving parity")
7 if psi >= investigate_at:
8 actions.append("inspect distribution shift")
9 if not schema_failures and not stale_features and parity_ok and cost_regressed:
10 actions.append("train frozen-snapshot candidate")
11 return actions
12
13print("dirty window:", triage(1, 1, False, psi, True))
14print("clean changed window:", triage(0, 0, True, psi, True))1dirty window: ['repair online data path', 'repair training-serving parity', 'inspect distribution shift']
2clean changed window: ['inspect distribution shift', 'train frozen-snapshot candidate']Continuous training doesn't mean every alert immediately overwrites production. Google Cloud documents on-demand, scheduled, new-data, performance-degradation, and feature-distribution triggers, plus metadata that includes a pointer to the previous trained model.[3] Their trigger list labels a feature-distribution change as concept drift. In this chapter that's still data drift until delayed outcomes show that moved.

Freeze an immutable candidate bundle
When clean evidence justifies retraining, freeze the snapshot, training pipeline, feature contract, decision policy, evaluation policy, and previous model pointer. The resulting candidate bundle is an immutable release proposal. Promotion moves an alias; it doesn't rewrite the bundle.
Create a deterministic bundle ID from the candidate configuration.
1candidate_config = {
2 "model": "job-risk-v4",
3 "snapshot": "labels-through-2026-02-03",
4 "training_pipeline": "job-risk-train-v4",
5 "feature_contract": "features-v3",
6 "decision_policy": decision_policy,
7 "threshold": 0.50,
8 "evaluation_policy": "job-risk-eval-v1",
9 "previous_model": deployed["model"],
10}
11payload = dumps(candidate_config, sort_keys=True, separators=(",", ":"))
12bundle_id = "job-risk-v4-" + sha256(payload.encode()).hexdigest()[:10]
13candidate_bundle = {**candidate_config, "bundle_id": bundle_id}
14
15print("candidate bundle:", candidate_bundle["bundle_id"])
16print("rollback pointer:", candidate_bundle["previous_model"])1candidate bundle: job-risk-v4-edde9c2176
2rollback pointer: job-risk-v3Google Cloud's MLOps guidance recommends recording pipeline versions, arguments, produced artifacts, evaluation metrics, and a pointer to the previous trained model for rollback or comparison.[3]
Gate offline metrics and canary traffic
Compare candidate v4 with current v3 before changing live traffic. These thresholds are explicit local policies:
1current_metrics = {
2 "recall": 0.80,
3 "priority_misses": 1,
4 "review_cost": 6,
5 "p95_latency_ms": 40,
6}
7candidate_metrics = {
8 "recall": 0.90,
9 "priority_misses": 0,
10 "review_cost": 4,
11 "p95_latency_ms": 42,
12}
13evaluation_cohort = {
14 "maturity_cutoff": "2026-02-03T12:00:00Z",
15 "matured_predictions": 1000,
16 "joined_labels": 1000,
17}
18evaluation_cohort["label_coverage"] = (
19 evaluation_cohort["joined_labels"] / evaluation_cohort["matured_predictions"]
20)
21offline_evaluation = {
22 "policy": candidate_bundle["evaluation_policy"],
23 "label_snapshot": candidate_bundle["snapshot"],
24 "cohort": evaluation_cohort,
25 "current": current_metrics,
26 "candidate": candidate_metrics,
27}
28offline_gates = {
29 "mature_label_coverage": evaluation_cohort["label_coverage"] == 1.0,
30 "recall": candidate_metrics["recall"] >= current_metrics["recall"],
31 "priority_misses": candidate_metrics["priority_misses"] == 0,
32 "review_cost": candidate_metrics["review_cost"] <= current_metrics["review_cost"],
33 "latency_budget": candidate_metrics["p95_latency_ms"] <= 50,
34}
35
36for name, passed in offline_gates.items():
37 print(f"{name}: {passed}")
38print("offline gate:", all(offline_gates.values()))1mature_label_coverage: True
2recall: True
3priority_misses: True
4review_cost: True
5latency_budget: True
6offline gate: TrueOffline evidence is necessary but incomplete. Google Cloud's same MLOps page puts a newly trained model through online validation in a canary or A/B setup before it serves full traffic.[3] Two live patterns sit between a frozen bundle and a full alias move:
- Shadow evaluation scores the candidate on production-shaped requests and logs the result. Users still see
v3. You learn about errors, latency, and disagreement without givingv4authority. - A canary rollout sends a limited slice of real traffic to the candidate. Those scores are user-visible, so blast radius is the canary fraction.
Gate the canary on fast signals: service errors, latency, request count, and immediate review-queue pressure. Those checks can stop a bad rollout quickly. They don't replace delayed-label quality.
1canary_metrics = {
2 "requests": 500,
3 "error_rate": 0.002,
4 "p95_latency_ms": 44,
5 "review_queue_load_ratio": 0.72,
6}
7canary_gates = {
8 "enough_requests": canary_metrics["requests"] >= 500,
9 "error_rate": canary_metrics["error_rate"] <= 0.005,
10 "p95_latency_ms": canary_metrics["p95_latency_ms"] <= 50,
11 "review_queue_load_ratio": canary_metrics["review_queue_load_ratio"] <= 0.80,
12}
13canary_evaluation = {
14 "policy": "job-risk-canary-v1",
15 "metrics": canary_metrics,
16 "checks": canary_gates,
17}
18
19for name, passed in canary_gates.items():
20 print(f"{name}: {passed}")
21print("canary gate:", all(canary_gates.values()))1enough_requests: True
2error_rate: True
3p95_latency_ms: True
4review_queue_load_ratio: True
5canary gate: TrueArgo Rollouts documents canary steps, metric analysis, unsuccessful-analysis aborts, and post-promotion analysis that can switch traffic back to a previous stable release.[5] The exact deployment tool may differ, but promotion needs measured conditions and a rollback path.
Promote an alias, then rehearse rollback
Keep immutable bundle IDs separate from a movable production alias. If both gate sets pass, move the alias from v3 to the candidate. Then simulate a slower delayed-label guardrail arriving after promotion. The cost threshold is evaluated only after the declared mature cohort reaches full label coverage; an incomplete cohort would report insufficient evidence rather than treat early labels as representative.
1alias = {"production": deployed["model"]}
2rollback_pointer = alias["production"]
3
4if all(offline_gates.values()) and all(canary_gates.values()):
5 alias["production"] = candidate_bundle["bundle_id"]
6
7print("production alias:", alias["production"])
8print("rollback pointer:", rollback_pointer)
9
10post_promotion = {
11 "matured_predictions": 500,
12 "joined_labels": 500,
13 "delayed_review_cost": 8,
14}
15post_promotion["label_coverage"] = (
16 post_promotion["joined_labels"] / post_promotion["matured_predictions"]
17)
18rollback_limit = 5
19rollback_reason = None
20if (
21 post_promotion["label_coverage"] == 1.0
22 and post_promotion["delayed_review_cost"] > rollback_limit
23):
24 rollback_reason = (
25 f"delayed review cost {post_promotion['delayed_review_cost']} "
26 f"> {rollback_limit}"
27 )
28 alias["production"] = rollback_pointer
29
30print(f"post-promotion label coverage: {post_promotion['label_coverage']:.3f}")
31print("delayed review cost:", post_promotion["delayed_review_cost"])
32print("final alias:", alias["production"])
33
34receipt = {
35 "candidate_bundle": candidate_bundle,
36 "previous": rollback_pointer,
37 "offline_evaluation": {**offline_evaluation, "checks": offline_gates},
38 "canary_evaluation": canary_evaluation,
39 "post_promotion": {**post_promotion, "rollback_limit": rollback_limit},
40 "rollback_reason": rollback_reason,
41 "release_action": "rolled_back" if rollback_reason else "keep_candidate",
42 "final_alias": alias["production"],
43}
44receipt_json = dumps(receipt, sort_keys=True, separators=(",", ":"))
45print("release action:", receipt["release_action"])
46print("receipt sha256:", sha256(receipt_json.encode()).hexdigest()[:12])1production alias: job-risk-v4-edde9c2176
2rollback pointer: job-risk-v3
3post-promotion label coverage: 1.000
4delayed review cost: 8
5final alias: job-risk-v3
6release action: rolled_back
7receipt sha256: 54de7af4a622The rehearsal promotes v4, waits for a fully labeled 500-row mature cohort, observes delayed review cost 8 above local limit 5, and restores alias v3. Its receipt binds candidate lineage, offline evidence, canary evidence, delayed-label coverage, and the derived rollback reason. A rollback drill is stronger than a diagram: it proves the pointer and policy work together.
When monitoring breaks
| Symptom | Cause | Fix |
|---|---|---|
| Accuracy report arrives after days of bad inputs | only label metrics are monitored | add schema and freshness alarms |
| Every drift alert starts retraining | drift is confused with failure | inspect data path, context, and delayed outcomes |
| Feature PSI is quiet but delayed cost rises | concept drift () without covariate shift | use mature quality and calibration; retrain only on clean inputs |
| New training run repeats wrong scores | serving transform differs from training | add parity probes before retraining |
| Report looks good while labels are still arriving | metrics use whichever labels arrived first | define mature cohorts, report label coverage, and block or qualify incomplete windows |
| New model can't be rolled back cleanly | bundle and alias aren't versioned separately | publish immutable candidate plus rollback pointer |
| Canary passes but delayed cost later rises | only fast rollout metrics were checked | keep post-promotion guardrails and rehearse rollback |