diff --git a/docs/CICs/UNFAOPostProcessorManager.md b/docs/CICs/UNFAOPostProcessorManager.md index 76a7cda..42d31c1 100644 --- a/docs/CICs/UNFAOPostProcessorManager.md +++ b/docs/CICs/UNFAOPostProcessorManager.md @@ -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) diff --git a/tests/test_selection_guard.py b/tests/test_selection_guard.py new file mode 100644 index 0000000..05da56a --- /dev/null +++ b/tests/test_selection_guard.py @@ -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 diff --git a/views_postprocessing/unfao/managers/unfao.py b/views_postprocessing/unfao/managers/unfao.py index c012f25..194d245 100644 --- a/views_postprocessing/unfao/managers/unfao.py +++ b/views_postprocessing/unfao/managers/unfao.py @@ -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__( @@ -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