Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CICs/UNFAOPostProcessorManager.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Assumptions that are not met **must cause failure**, not fallback behavior. **Kn
- **Null values in required metadata columns:** Raises `ValueError` with null count and affected column name (C-01 resolved — validation active)
- **Dataset initialization failure:** Raises `ValueError` in `_save()` if datasets are None
- **Appwrite upload failure:** Propagates exception from `DatastoreModule`
- **Wrong forecast file selected:** Raises `ForecastIdentityError` in `_read_forecast_data()` if the newest `category="forecast"` file's identity (name/loa) does not match the configured ensemble (S3/C-25 — a stray upload cannot be silently shipped)
- **Wrong forecast file selected:** Raises `ForecastIdentityError` in `_read_forecast_data()` if the selected file's identity (name/loa) does not match the configured ensemble (S3/C-25 — a stray upload cannot be silently shipped). Selection pins `{category="forecast", type="ensemble"}` (`LEGACY_FORECAST_FILTERS`) — the ADR-013 §11.4 transition guard: contract artifacts (`sampled_forecast_*`) are unselectable by this legacy reader
- **Region coverage mismatch:** Raises `CoverageError` in `_check_coverage()` (called from `_validate()`) if a pinned region's delivered cell count is wrong (S1/C-34) or a GAUL-uncovered excluded cell leaks into the delivery (S4/C-30)
- **Fabricated historical tail:** `_clip_observed_history()` drops months beyond the producer's `last_valid_month_id` so unobserved zero-padding is not shipped as observed history (S2/C-26); **degrades open** (skips the clip with a WARNING) if the boundary cannot be resolved
- **Upload provenance:** every upload's `description` carries structured provenance (lookup version, region, expected/actual cell counts, unmapped count) via `_delivery_description()` (S5/C-15)
Expand Down
41 changes: 41 additions & 0 deletions tests/test_selection_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"""Golden-string tests for the Hop-A legacy transition guard (ADR-013 §11.4).

The manager cannot be imported in every test environment (it pulls the full
pipeline-core framework), so — like the design-contract tests — these pin the guard
at source level: the legacy selection filters must pin BOTH `category` and the legacy
`type`, so pipeline-core#269's contract uploads (`sampled_forecast_*`) can never be
selected by the deployed legacy reader.
"""

import re
from pathlib import Path

_SRC = (
Path(__file__).resolve().parent.parent
/ "views_postprocessing" / "unfao" / "managers" / "unfao.py"
).read_text()


def test_legacy_forecast_filters_pin_category_and_type():
# The golden string (ADR-013 §3.3 spirit): identity of the guard is this exact dict.
assert 'LEGACY_FORECAST_FILTERS = {"category": "forecast", "type": "ensemble"}' in _SRC


def test_selection_uses_the_guarded_filters():
# The store selection must go through the guarded constant — no inline
# category-only filter dict may remain on the selection call.
assert "get_latest_file_id(filters=LEGACY_FORECAST_FILTERS)" in _SRC
assert not re.search(
r"get_latest_file_id\(filters=\{[^}]*\}\)", _SRC
), "inline filter dict on the selection call — must use LEGACY_FORECAST_FILTERS"


def test_guard_type_is_disjoint_from_contract_vocabulary():
# The pinned legacy type must never be one of the contract's types.
assert '"type": "ensemble"' in _SRC
for contract_type in (
"sampled_forecast_shard",
"sampled_forecast_manifest",
"sampled_forecast_sidecar",
):
assert f'"type": "{contract_type}"' not in _SRC
21 changes: 16 additions & 5 deletions views_postprocessing/unfao/managers/unfao.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@

logger = logging.getLogger(__name__)

# Hop-A legacy selection filters (ADR-013 §11.4 transition guard). Declared, not
# inferred: the legacy production forecast is uploaded by pipeline-core's ensemble run
# with type="ensemble"; the contract's sampled_forecast_* types are disjoint by design,
# so pinning the legacy type here makes contract artifacts unselectable by this reader.
# Golden-string-tested (tests/test_selection_guard.py); change only with ADR-013.
LEGACY_FORECAST_FILTERS = {"category": "forecast", "type": "ensemble"}


class UNFAOPostProcessorManager(PostprocessorManager, ForecastingModelManager):
def __init__(
Expand Down Expand Up @@ -104,13 +111,17 @@ def _read_forecast_data(self):
try:
prediction_store_manager = DatastoreModule(appwrite_file_manager_config=appwrite_config)

# The store is filtered by category alone (newest-wins), so resolve the file,
# then verify its identity before delivering it (S3/C-25): a stray upload with
# category="forecast" must not be silently shipped as the configured ensemble.
file_id = prediction_store_manager.get_latest_file_id(filters={"category": "forecast"})
# Resolve the newest legacy forecast, then verify its identity before
# delivering it (S3/C-25): a stray upload must not be silently shipped as
# the configured ensemble. The `type` pin is the Hop-A legacy transition
# guard (ADR-013 §11.4): legacy ensemble forecasts carry type="ensemble"
# (pipeline-core EnsemblePathManager._target), disjoint from the contract's
# sampled_forecast_* vocabulary — so pipeline-core#269's shard/manifest
# uploads can never be selected by this legacy reader.
file_id = prediction_store_manager.get_latest_file_id(filters=LEGACY_FORECAST_FILTERS)
if file_id is None:
raise FileNotFoundError(
"No forecast file found in the prediction store (category='forecast')."
f"No forecast file found in the prediction store (filters={LEGACY_FORECAST_FILTERS})."
)
selected = extraction.file_metadata(prediction_store_manager.get_file_metadata(file_id))
# Identity contract: the producer's FileMetadata `name`/`loa` written on upload
Expand Down
Loading