Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Warehouse teams need to forecast daily parcel volume before staffing and packing decisions are locked. A point forecast alone isn't enough: planners also need to know when actual demand falls outside an expected range and which evidence supports an alert.
This capstone ships a forecast and alert artifact. It doesn't automatically hire labor, move inventory, or page an operator on every miss. It creates a versioned expectation, detects unusually large forecast errors, and records evidence for a planner's human-in-the-loop decision.
Earlier, you built a same-weekday seasonal baseline and separated forecast errors from anomaly review. This capstone packages those mechanics into a release candidate with shadow evidence and a rollback pointer.

Choose the Series and Decision
Predict daily shipped parcels for each warehouse seven days ahead. Use an explicit planning contract:
| Field | Contract |
|---|---|
| entity | fulfillment center and shipping service tier |
| target | parcels shipped per calendar day |
| horizon | next seven days |
| decision | planner reviews capacity when forecast or alert requires it |
| baseline | same weekday from prior week |
| evaluation | MAE plus underforecast cost by high-volume slice |
Demand can change around promotions, holidays, seller campaigns, inventory shortages, and data outages. Those known drivers should appear as features only if they are scheduled and available before the forecast cutoff.
Hyndman and Athanasopoulos explain why forecast evaluation must use later observations and rolling forecasting origins rather than random splits.[1] For this project, each backtest run records its training cutoff, horizon, model version, and the actual values that arrived afterward.

Build a Reviewable Artifact
Your repository surface should look like:
1demand-forecast/
2 data/
3 warehouse_daily_counts.parquet
4 planned_events.json
5 split_manifest.json
6 forecasting/
7 seasonal_baseline.py
8 train_candidate.py
9 rolling_backtest.py
10 alerts/
11 forecast_error_policy.json
12 evaluate_alerts.py
13 reports/
14 backtest_metrics.json
15 alert_review.csv
16 tests/
17 test_future_rows_excluded.py
18 test_observation_join.py
19 test_alert_contract.pyThe candidate can be a tree model over lag features, rolling means, service tier, weekday, and known promotions. It must beat the seasonal baseline on later windows, especially where underforecasting is expensive. A candidate that marginally improves MAE but misses peak-volume days should remain blocked.
Freeze issued forecasts before outcomes arrive
The runnable receipt below starts after training. Seasonal baseline and candidate point forecasts are pre-filled fixtures so this lesson grades freeze, join, alert, and receipt contracts. Fitting lag features under a rolling origin is a separate training lab; don't claim the harness trained a tree.
It freezes two seven-day forecast windows issued one week apart. Each issued row stores the cutoff, target date, horizon, baseline, candidate, expected range, and known event context before the target day arrives.
Actual parcel counts belong in a separate append-only observation stream. The backtest joins each later observation to the immutable forecast it evaluates. A real pipeline should materialize the same boundary after every rolling-origin fold.
| Stream | Stored before replay | Arrival time |
|---|---|---|
| issued forecast | center, tier, issue date, target date, horizon, model outputs, range, known-event context | before target day |
| observation | forecast ID, observed date, actual parcel count | on or after target day |
| alert | joined forecast ID, range breach, policies, owner, resolution | after observation join |
1from dataclasses import dataclass, replace
2from datetime import date
3import json
4
5@dataclass(frozen=True)
6class IssuedForecast:
7 forecast_id: str
8 fold: str
9 issued_at: date
10 target_date: date
11 horizon_day: int
12 center: str
13 service_tier: str
14 baseline: int
15 candidate: int
16 lower: int
17 upper: int
18 latest_actual_at: date
19 scheduled_event: str | None = None
20 event_known_at: date | None = None
21
22@dataclass(frozen=True)
23class Observation:
24 forecast_id: str
25 observed_at: date
26 actual: int
27
28@dataclass(frozen=True)
29class BacktestRow:
30 issued: IssuedForecast
31 observation: Observation
32
33INTERVAL_HALF_WIDTH = 6
34INTERVAL_POLICY = "candidate-plus-minus-6-v1"
35LATEST_ACTUAL_BY_FOLD = {
36 "fold-1": date(2026, 1, 31),
37 "fold-2": date(2026, 2, 7),
38}
39
40def issue_forecast(
41 fold: str,
42 issued_at: date,
43 target_date: date,
44 center: str,
45 service_tier: str,
46 baseline: int,
47 candidate: int,
48 scheduled_event: str | None = None,
49 event_known_at: date | None = None,
50) -> IssuedForecast:
51 horizon_day = (target_date - issued_at).days
52 return IssuedForecast(
53 forecast_id=f"{center}:{service_tier}:{issued_at}:{target_date}",
54 fold=fold,
55 issued_at=issued_at,
56 target_date=target_date,
57 horizon_day=horizon_day,
58 center=center,
59 service_tier=service_tier,
60 baseline=baseline,
61 candidate=candidate,
62 lower=candidate - INTERVAL_HALF_WIDTH,
63 upper=candidate + INTERVAL_HALF_WIDTH,
64 latest_actual_at=LATEST_ACTUAL_BY_FOLD[fold],
65 scheduled_event=scheduled_event,
66 event_known_at=event_known_at,
67 )
68
69print("interval_policy:", INTERVAL_POLICY, "half_width:", INTERVAL_HALF_WIDTH)1interval_policy: candidate-plus-minus-6-v1 half_width: 61ISSUED_FORECASTS = [
2 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 2), "FC-A", "standard", 100, 103),
3 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 3), "FC-A", "standard", 112, 111),
4 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 4), "FC-A", "standard", 115, 117),
5 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 5), "FC-A", "standard", 118, 117),
6 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 6), "FC-A", "standard", 132, 140, "seller-campaign", date(2026, 1, 29)),
7 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 7), "FC-A", "standard", 82, 83),
8 issue_forecast("fold-1", date(2026, 2, 1), date(2026, 2, 8), "FC-A", "standard", 76, 77),
9 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 9), "FC-A", "standard", 104, 105),
10 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 10), "FC-A", "standard", 110, 112),
11 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 11), "FC-A", "standard", 119, 120),
12 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 12), "FC-A", "standard", 116, 119),
13 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 13), "FC-A", "standard", 160, 164, "seller-campaign", date(2026, 2, 5)),
14 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 14), "FC-A", "standard", 84, 85),
15 issue_forecast("fold-2", date(2026, 2, 8), date(2026, 2, 15), "FC-A", "standard", 78, 79),
16]
17
18ACTUALS = [104, 110, 119, 116, 160, 84, 78, 106, 113, 121, 118, 168, 86, 80]
19OBSERVATIONS = [
20 Observation(forecast.forecast_id, forecast.target_date, actual)
21 for forecast, actual in zip(ISSUED_FORECASTS, ACTUALS, strict=True)
22]
23issued_by_id = {row.forecast_id: row for row in ISSUED_FORECASTS}
24observations_by_forecast_id = {row.forecast_id: row for row in OBSERVATIONS}
25
26def lag_sources_precede_cutoff(forecasts: list[IssuedForecast]) -> bool:
27 return all(row.latest_actual_at < row.issued_at for row in forecasts)
28
29leaky_forecast = replace(
30 ISSUED_FORECASTS[0],
31 latest_actual_at=ISSUED_FORECASTS[0].target_date,
32)
33assert lag_sources_precede_cutoff(ISSUED_FORECASTS)
34assert not lag_sources_precede_cutoff([leaky_forecast])
35
36print("issued forecasts:", len(ISSUED_FORECASTS))
37print("later observations:", len(OBSERVATIONS))1ROWS = [
2 BacktestRow(forecast, observations_by_forecast_id[forecast.forecast_id])
3 for forecast in ISSUED_FORECASTS
4 if forecast.forecast_id in observations_by_forecast_id
5]
6
7folds = sorted({row.fold for row in ISSUED_FORECASTS})
8for fold in folds:
9 rows = [row for row in ISSUED_FORECASTS if row.fold == fold]
10 print(
11 f"{fold}: cutoff={rows[0].issued_at}",
12 f"window={rows[0].target_date}..{rows[-1].target_date}",
13 f"rows={len(rows)}",
14 )
15print("first forecast id:", ISSUED_FORECASTS[0].forecast_id)
16print("joined backtest rows:", len(ROWS))1fold-1: cutoff=2026-02-01 window=2026-02-02..2026-02-08 rows=7
2fold-2: cutoff=2026-02-08 window=2026-02-09..2026-02-15 rows=7
3first forecast id: FC-A:standard:2026-02-01:2026-02-02
4joined backtest rows: 14The fixture is intentionally compact: one fulfillment center and one service tier make every row inspectable. Its ID includes center, tier, issue date, and target date so additional series can't collide during replay. A production report should repeat the same contract by center, tier, horizon, and event slice.
Evaluate Forecasts and Alerts Separately
Mean absolute error (MAE) answers how far point forecasts miss on average. Capacity planning needs a second view because a large underforecast on a peak day can cost more than a small overforecast on a routine day. The next cell assigns a local cost of 3 units to each underforecast parcel on a day with at least 150 observed parcels.
An expected range answers a different question. It should contain a stated share of later observations when measured across enough held-out windows. Hyndman and Athanasopoulos describe prediction intervals as forecast ranges with a specified coverage probability and explain why distributional forecasts need their own accuracy measures.[1] The local candidate ± 6 policy only demonstrates release plumbing; it isn't a calibrated production interval.
1def mae(field: str) -> float:
2 return sum(
3 abs(row.observation.actual - getattr(row.issued, field))
4 for row in ROWS
5 ) / len(ROWS)
6
7def peak_underforecast_cost(field: str) -> int:
8 return sum(
9 3 * max(row.observation.actual - getattr(row.issued, field), 0)
10 for row in ROWS
11 if row.observation.actual >= 150
12 )
13
14def coverage() -> float:
15 return sum(
16 row.issued.lower <= row.observation.actual <= row.issued.upper
17 for row in ROWS
18 ) / len(ROWS)
19
20ALERT_POLICY = "outside-range-review-v1"
21ALERT_OWNER = "capacity-ops"
22CANDIDATE_FORECAST = "warehouse-demand-v1"
23PREVIOUS_FORECAST = "warehouse-demand-v0"
24
25forecast_metrics = {
26 "baseline_mae": round(mae("baseline"), 3),
27 "candidate_mae": round(mae("candidate"), 3),
28 "baseline_peak_underforecast_cost": peak_underforecast_cost("baseline"),
29 "candidate_peak_underforecast_cost": peak_underforecast_cost("candidate"),
30 "range_coverage": round(coverage(), 3),
31 "range_rows": len(ROWS),
32}
33
34print(json.dumps(forecast_metrics, indent=2))1new_alerts = [
2 {
3 "forecast_id": row.issued.forecast_id,
4 "forecast_version": CANDIDATE_FORECAST,
5 "interval_policy": INTERVAL_POLICY,
6 "policy_version": ALERT_POLICY,
7 "owner": ALERT_OWNER,
8 "issued_at": str(row.issued.issued_at),
9 "target_date": str(row.issued.target_date),
10 "horizon_day": row.issued.horizon_day,
11 "center": row.issued.center,
12 "service_tier": row.issued.service_tier,
13 "expected_range": [row.issued.lower, row.issued.upper],
14 "observed_at": str(row.observation.observed_at),
15 "observed": row.observation.actual,
16 "error": row.observation.actual - row.issued.candidate,
17 "scheduled_event": row.issued.scheduled_event,
18 "event_known_at": str(row.issued.event_known_at) if row.issued.event_known_at else None,
19 "resolution": None,
20 }
21 for row in ROWS
22 if not row.issued.lower <= row.observation.actual <= row.issued.upper
23]
24
25print("new_alert_count:", len(new_alerts))
26print(
27 "first_alert:",
28 {key: new_alerts[0][key] for key in ("forecast_id", "expected_range", "observed", "error", "scheduled_event", "owner")},
29)1new_alert_count: 1
2first_alert: {'forecast_id': 'FC-A:standard:2026-02-01:2026-02-06', 'expected_range': [134, 146], 'observed': 160, 'error': 20, 'scheduled_event': 'seller-campaign', 'owner': 'capacity-ops'}The candidate improves average error and peak-day cost, but one seller-campaign day still escapes its expected range. That row belongs in a planner queue. The queue preserves which immutable forecast was issued, what happened later, who owns review, and which interval and alert policies created the alert.
Publish a Shadow-Review Receipt
Forecast quality and alert usefulness require separate evidence. A narrow range can create an exhausting queue. A broad range can hide events a planner needed to see. Measure alert precision and recall on historical alerts after reviewers label whether each event required action. Keep the forecast and alert-policy versions on those review rows; otherwise a candidate could pass using evidence produced by a different model or threshold.
The final cell publishes one candidate receipt. It advances to planner shadow review, not production replacement. The previous forecast alias remains explicit so a later promotion process can roll back cleanly.
1# Bind at least one reviewed row to the same backtest breach the harness produced.
2backtest_breach_ids = {alert["forecast_id"] for alert in new_alerts}
3REVIEWED_ALERTS = [
4 {
5 "alert_id": "A-101",
6 "forecast_id": "FC-A:standard:2026-02-01:2026-02-06",
7 "forecast_version": CANDIDATE_FORECAST,
8 "policy_version": ALERT_POLICY,
9 "triggered": True,
10 "actionable": True,
11 },
12 {
13 "alert_id": "A-102",
14 "forecast_id": "FC-A:standard:2026-01-25:2026-01-30",
15 "forecast_version": CANDIDATE_FORECAST,
16 "policy_version": ALERT_POLICY,
17 "triggered": True,
18 "actionable": True,
19 },
20 {
21 "alert_id": "A-103",
22 "forecast_id": "FC-A:standard:2026-01-25:2026-01-28",
23 "forecast_version": CANDIDATE_FORECAST,
24 "policy_version": ALERT_POLICY,
25 "triggered": True,
26 "actionable": False,
27 },
28 {
29 "alert_id": "A-104",
30 "forecast_id": "FC-A:standard:2026-02-01:2026-02-03",
31 "forecast_version": CANDIDATE_FORECAST,
32 "policy_version": ALERT_POLICY,
33 "triggered": False,
34 "actionable": True,
35 },
36 {
37 "alert_id": "A-105",
38 "forecast_id": "FC-A:standard:2026-02-01:2026-02-04",
39 "forecast_version": CANDIDATE_FORECAST,
40 "policy_version": ALERT_POLICY,
41 "triggered": False,
42 "actionable": False,
43 },
44]
45
46true_positives = sum(row["triggered"] and row["actionable"] for row in REVIEWED_ALERTS)
47false_positives = sum(row["triggered"] and not row["actionable"] for row in REVIEWED_ALERTS)
48false_negatives = sum(not row["triggered"] and row["actionable"] for row in REVIEWED_ALERTS)
49
50def rate_or_none(numerator: int, denominator: int) -> float | None:
51 return round(numerator / denominator, 3) if denominator else None
52
53alert_review = {
54 "forecast_version": CANDIDATE_FORECAST,
55 "policy_version": ALERT_POLICY,
56 "precision": rate_or_none(true_positives, true_positives + false_positives),
57 "recall": rate_or_none(true_positives, true_positives + false_negatives),
58 "reviewed_rows": len(REVIEWED_ALERTS),
59}
60
61print("alert_review:", alert_review)1required_alert_fields = {
2 "forecast_id", "forecast_version", "interval_policy", "policy_version", "owner",
3 "issued_at", "target_date", "horizon_day", "center", "service_tier",
4 "expected_range", "observed_at", "observed", "error", "scheduled_event",
5 "event_known_at", "resolution",
6}
7release_gates = {
8 "issued_forecast_ids_unique": len(issued_by_id) == len(ISSUED_FORECASTS),
9 "observation_forecast_ids_unique": len(observations_by_forecast_id) == len(OBSERVATIONS),
10 "observations_join_issued_forecasts": all(
11 row.forecast_id in issued_by_id
12 for row in OBSERVATIONS
13 ),
14 "issued_forecasts_have_observations": len(ROWS) == len(ISSUED_FORECASTS),
15 "targets_after_cutoff": all(row.issued.issued_at < row.issued.target_date for row in ROWS),
16 "horizons_match_dates": all(
17 row.issued.horizon_day == (row.issued.target_date - row.issued.issued_at).days
18 for row in ROWS
19 ),
20 "horizons_within_seven_day_contract": all(
21 1 <= row.issued.horizon_day <= 7
22 for row in ROWS
23 ),
24 "observations_arrive_on_or_after_target": all(
25 row.observation.observed_at >= row.issued.target_date
26 for row in ROWS
27 ),
28 "scheduled_events_known_by_cutoff": all(
29 row.event_known_at is None or row.event_known_at <= row.issued_at
30 for row in ISSUED_FORECASTS
31 ),
32 "multiple_rolling_origins": len({row.issued_at for row in ISSUED_FORECASTS}) >= 2,
33 "candidate_beats_baseline_mae": forecast_metrics["candidate_mae"] < forecast_metrics["baseline_mae"],
34 "candidate_reduces_peak_underforecast_cost": (
35 forecast_metrics["candidate_peak_underforecast_cost"]
36 < forecast_metrics["baseline_peak_underforecast_cost"]
37 ),
38 # ±6 width is local plumbing, not a calibrated prediction interval.
39 "local_range_plumbing_coverage_at_least_0_85": forecast_metrics["range_coverage"] >= 0.85,
40 "shadow_alert_count_at_most_3": len(new_alerts) <= 3,
41 "alert_review_matches_candidate_policy": all(
42 row["forecast_version"] == CANDIDATE_FORECAST
43 and row["policy_version"] == ALERT_POLICY
44 for row in REVIEWED_ALERTS
45 ),
46 "alert_review_bound_to_backtest_breach": bool(
47 backtest_breach_ids
48 and any(row.get("forecast_id") in backtest_breach_ids for row in REVIEWED_ALERTS)
49 ),
50 "alert_precision_evidence_at_least_0_60": (
51 alert_review["precision"] is not None
52 and alert_review["precision"] >= 0.60
53 ),
54 "alert_recall_evidence_at_least_0_60": (
55 alert_review["recall"] is not None
56 and alert_review["recall"] >= 0.60
57 ),
58 "alert_rows_replayable": all(required_alert_fields <= row.keys() for row in new_alerts),
59 # Validate stored lag-source timestamps, not a date manufactured by the gate.
60 "lag_k_uses_only_pre_cutoff_actuals": lag_sources_precede_cutoff(ISSUED_FORECASTS),
61 "rollback_pointer_recorded": bool(PREVIOUS_FORECAST),
62}
63
64print("release_gates_pass:", all(release_gates.values()))1receipt = {
2 "candidate_forecast": CANDIDATE_FORECAST,
3 "previous_forecast": PREVIOUS_FORECAST,
4 "latest_rolling_origin": str(max(row.issued_at for row in ISSUED_FORECASTS)),
5 "interval_policy": INTERVAL_POLICY,
6 "alert_policy": ALERT_POLICY,
7 "owner": ALERT_OWNER,
8 "replay": {
9 "issued_forecast_rows": len(ISSUED_FORECASTS),
10 "joined_observation_rows": len(ROWS),
11 },
12 "backtest": forecast_metrics,
13 "alert_review": alert_review,
14 "release_gates": release_gates,
15 "candidate_decision": "candidate_for_planner_shadow_review" if all(release_gates.values()) else "hold",
16}
17
18print("candidate_decision:", receipt["candidate_decision"])1assert receipt["candidate_forecast"] == CANDIDATE_FORECAST
2assert receipt["previous_forecast"] == PREVIOUS_FORECAST
3assert receipt["backtest"]["candidate_mae"] < receipt["backtest"]["baseline_mae"]
4print("receipt keys:", sorted(receipt))1print("replay:", receipt["replay"])
2print("backtest:", receipt["backtest"])
3print("alert_review:", receipt["alert_review"])
4print("release_gates_pass:", all(receipt["release_gates"].values()))
5print("rollback:", receipt["previous_forecast"])
6print("candidate_decision:", receipt["candidate_decision"])1replay: {'issued_forecast_rows': 14, 'joined_observation_rows': 14}
2backtest: {'baseline_mae': 4.643, 'candidate_mae': 2.643, 'baseline_peak_underforecast_cost': 108, 'candidate_peak_underforecast_cost': 72, 'range_coverage': 0.929, 'range_rows': 14}
3alert_review: {'forecast_version': 'warehouse-demand-v1', 'policy_version': 'outside-range-review-v1', 'precision': 0.667, 'recall': 0.667, 'reviewed_rows': 5}
4release_gates_pass: True
5rollback: warehouse-demand-v0
6candidate_decision: candidate_for_planner_shadow_reviewcandidate_for_planner_shadow_review is intentionally narrower than launch approval. Frozen forecasts, later observation joins, and reviewed alerts say this bundle deserves planner observation beside current production forecasts. They don't prove that every center, tier, event slice, or future week will behave well. A reviewed-alert window with no triggered or actionable rows reports None, not an invented precision or recall score.
Plan Refresh and Monitoring
New outcomes arrive daily, but model replacement should happen on a scheduled or triggered review cycle. Store:
| Operational item | Required decision |
|---|---|
| daily observation join | append actual count, then join it to immutable issued forecast ID |
| weekly accuracy report | compare baseline, production, and shadow candidate by slice |
| range coverage report | measure later-window coverage by center, tier, and horizon |
| alert resolution review | classify actionable, expected, or data issue |
| retraining trigger | investigate sustained cost regression before fitting replacement |
| promotion gate | rerun rolling backtest, protected slices, shadow review, and rollback check |
Practice: break the forecast contract
Use the runnable examples as a release harness. Change one condition at a time, predict the failure, then rerun the examples.
- Change every
fold-2issue date from2026-02-08to2026-02-15. Which temporal gates fail? - Change
fold-1Friday candidate from140to132. Which cost gets worse even though only one row changed? - Set
INTERVAL_HALF_WIDTH = 0. Why can MAE stay unchanged while range and queue gates fail? - Mark
A-104as not actionable. Which alert metric improves, and which stays unchanged? - Set
PREVIOUS_FORECAST = "". Which executable gate fails? - Give first observation forecast ID
missing:standard:2026-02-01:2026-02-02. Which replay gate fails? - Set every reviewed alert's
triggeredandactionablevalues toFalse. Why do alert-evidence gates fail instead of crashing or passing? - Change every reviewed alert's
policy_versiontooutside-range-review-v0. Which provenance gate fails?
Practice answer sketches
Which gates fail when fold-2 is issued on 2026-02-15?
Answer
targets_after_cutoff fails because targets from February 9 through February 15 no longer occur after their issue cutoff. horizons_within_seven_day_contract fails too because those derived horizons are no longer between 1 and 7. That is hindsight, not a backtest.
What changes when the first Friday candidate falls from 140 to 132?
Answer
Candidate MAE rises by 8 / 14, but peak underforecast cost rises by 3 * 8 = 24. The slice metric makes capacity risk visible.
Why do zero-width expected ranges fail local range and queue gates without changing MAE?
Answer
MAE reads point forecasts only. Local coverage and queue volume read whether observations stay inside expected ranges. Different artifacts answer different questions.
What changes when A-104 becomes not actionable?
Answer
Recall rises from 2 / 3 to 2 / 2 = 1.0 because the former missed alert is no longer actionable. Precision stays 2 / 3 because triggered rows don't change.
Which gate fails when PREVIOUS_FORECAST becomes an empty string?
Answer
rollback_pointer_recorded fails. Shadow evidence may justify a later promotion, but promotion still needs a known rollback target.
Which gate fails when an observation points at forecast ID missing:standard:2026-02-01:2026-02-02?
Answer
observations_join_issued_forecasts fails. A later count can't become evaluation evidence unless it joins one immutable forecast issued before the target day.
What happens when reviewed history contains no triggered or actionable alerts?
Answer
Precision and recall become None, then both alert-evidence gates fail. No denominator means no evidence. It isn't a crash, measured success, or measured failure.
Which gate fails when reviewed alerts come from outside-range-review-v0?
Answer
alert_review_matches_candidate_policy fails. Precision and recall only support this release when every reviewed row was produced by the candidate forecast and outside-range-review-v1 policy under review.