Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A deployed model isn't done when its endpoint responds. A system that scores job-SLA risk, ranks alerts, or forecasts runner-pool load can still fail after it reaches live traffic: inputs break now, labels arrive days later, and a new model can regress under real usage.
Monitoring answers separate questions on separate clocks: can this request be scored safely, has traffic changed, did eventual outcomes remain good enough, and should a candidate replace the current release?

4/6 safe rows and expose parity and drift problems before labels arrive. Later quality uses 5/5 label coverage in the mature cohort, and delayed review cost 8 > 5 proves the rollback pointer restores v3.Start with a served release and feature traces
Use a job-SLA model as the running example. Each job 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 and 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.
An invariant is a condition that must 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. Job s-104 carries a three-hour-old snapshot. A label-based accuracy report would discover the unsafe scoring path too late.
Sculley et al. describe unstable data dependencies, input-data testing, and live monitoring as central production ML concerns.[1] Their point is practical: 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. It's a reason to inspect, not proof that accuracy fell.[2]
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 |
Older heartbeats are more common now. A scheduler outage could cause that shift, but so could a holiday or runner-pool mix change.
This lab summarizes the bucket changes 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.
Calculate PSI 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.370 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 and JS measure 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/JS, 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, prediction-service tests with expected inputs, data validation, and predictive-performance validation 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. 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. A score near 0.80 shouldn't be interpreted as an 80% risk unless outcomes from adequately covered mature cohorts support that use.[4] 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. 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 scheduled, new-data, performance-degradation, and distribution-change triggers for retraining pipelines, with metadata and model-validation stages around those pipelines.[3]

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. A canary sends limited live traffic to a candidate before full promotion. Gate fast signals such as service errors, latency, request count, and immediate review-queue pressure. Those signals can stop a bad rollout quickly, but 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 |