Skip to content

feat(delivery): FAO input-integrity invariants S0–S6 (#51) - #64

Merged
Polichinel merged 9 commits into
developmentfrom
sprint/fao-input-integrity
Jun 26, 2026
Merged

Polichinel merged 9 commits into
developmentfrom
sprint/fao-input-integrity

Conversation

@Polichinel

Copy link
Copy Markdown
Collaborator

Closes the FAO delivery input-integrity epic (#51): six representation-free guards that fail loud instead of shipping silent bad data to the partner.

Design

Each guard is a representation-free invariant in views_postprocessing/delivery/ (primitives only — no pandas), fed by the unfao/extraction.py pandas→primitives seam, and called by the manager (never inherited). This survives the eventual pandas→views-frames migration untouched (only the seam changes; C-40), and keeps the C-40 manager de-inheritance cleanly deferred.

Guards

Story Guard Concern
S1 / #53 complete-coverage contract + cell-count logging C-34
S2 / #52 observed-range clip — no fabricated zeros past last_valid_month_id C-26
S3 / #54 forecast-file identity — reject a decoy category=forecast upload C-25
S4 / #55 land_gaul exclusion manifest — GAUL-uncovered cells can't leak C-30
S5 / #56 structured delivery provenance on uploads C-15
S6 / #57 end-to-end suite through the manager seam —

Corrections surfaced during build

Residuals (tracked, not blockers)

  • C-25 (name/loa) & C-43 (enrichment equivalence) verify at the first live run — the guards fail loud, not silent, if a contract is off.
  • C-40 manager de-inheritance deferred (the one remaining input-integrity xfail).

Verification

ruff clean; 126 passed, 44 xfailed. The design-contract module is 5 passed / 1 xfail (the C-40-deferred inheritance contract).

Risk register updated throughout (C-15, C-25, C-30, C-43 + new C-43 entry). Story issues close on merge.

🤖 Generated with Claude Code

Polichinel and others added 9 commits June 26, 2026 09:29
…d-range guards (#51)

Introduces the representation-free delivery-integrity layer and wires the first two
guards into the FAO manager (epic #51, S0-S2).

- views_postprocessing/delivery/: partner-agnostic, pandas-free invariants — coverage
  (S1/C-34) and observed_range (S2/C-26). Called by managers, never inherited.
- unfao/extraction.py: the single pandas-aware seam feeding primitives to the invariants
  (swappable when the delivery representation leaves pandas, C-40).
- unfao/source_metadata.py: producer data-facts read straight from views-datafactory
  (last_valid_month_id), never via pipeline-core (D-07).
- unfao manager: _clip_observed_history (drop fabricated tail months > last_valid_month_id,
  historical frame only) and _check_coverage (enforce pinned-region cell counts, else log-skip).
- enrichment.py: "prediction frame" -> "input DataFrame" naming (frame conflation).
- unit + design-contract tests for each.

S3-S6 (#54-#57) remain. Reconciliation/ still present here (predates PR #63; deferred sync).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…curation

Accumulated risk-register edits that rode this branch:
- Reconciliation migration end-state (verified on origin/development): C-42 corrected
  (the false "vpp unused" claim; then release v1.7.0 -> repoint views-models#191/PR#202 ->
  delete #62/PR#63; pipeline-core chose Decision K, not C; mis-stated-state hazard resolved;
  residual ForecastReconciler coupling tracked by pipeline-core#198, no separate entry).
  C-37/C-38 relocate to views-frames with the code; C-38 dead-link to the deleted CIC repaired.
- D-07 resolved: data-facts come from the producer (datafactory/viewser), not pipeline-core.
- /review-rr curation + input-integrity entries (C-26, C-34).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings in the reconciliation retirement (PR #63 — deletes views_postprocessing/reconciliation/,
its tests/fixtures/scripts, the CIC) and poetry.lock (#59). The sprint does not use
reconciliation; accept the deletion. Keeps the input-integrity S0-S2 work intact.
…valence proof (Tier 2)

The ADR-011 enrichment swap (runtime mapper -> GAUL lookup enricher) went
live without the output-equivalence proof its own plan required (umbrella
#20: Stage 0 baseline + Stage 2 diff, "zero unexplained differences"). That
proof was never produced, and the old mapper + both diff scripts were since
deleted (eba1df8, PR #42), so it is now unrecoverable. Accepting option A
(a smoke-test delivery) verifies the path runs, not that it produces the
same/correct values; _validate only checks the 9 columns are non-null, not
correct, so a lookup/merge bug ships wrong-but-non-null geo metadata to FAO
silently. Tier 2 (not 1: the lookup sources from datafactory's authoritative
GAUL parquets, D-07); trigger is the go-global flip to land_gaul (64k cells).

Header 42/22/20 -> 43/23/20. Cross-refs C-03, C-22, C-23, C-30/32/34, C-39, D-08.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… guard (#54, C-25)

The FAO forecast is selected from the prediction store by category alone
(newest-wins) with no filter on ensemble name or loa, so a stray
category="forecast" upload (a test run, a second model, a backfill) would be
silently enriched and shipped to FAO (C-25). S3 adds a representation-free
guard that fails loud when the selected file's identity does not match the
configured ensemble.

Mirrors the S0-S2 pattern exactly: a primitives-only invariant in delivery/
(assert_forecast_identity), fed by the extraction seam (file_metadata
normalizes the datastore metadata record to a plain dict), called by the
manager. _read_forecast_data now resolves the file id, asserts identity
({name: model_name, loa: "pgm"}) before download, instead of the single
download_latest_file convenience call.

Residual tracked in C-25: the producer's uploaded name/loa contract is
verified against pipeline-core's code but not a live upload — confirmed at
the first real run; the guard fails loud (not silent) if the contract is off.

Tests: unit on dicts (match/mismatch/missing-key/label) + extraction
normalization. ruff clean; 101 passed, 44 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nifest (#55, C-30)

At the land_gaul switch, the GAUL-uncovered sub-Antarctic cells have no
country assignment — shipping them attributes FAO rows to a non-country (-1),
or crashes on delivery day (C-30, Tier 1). S4 pins the excluded cells as an
explicit frozen manifest and fails loud if any reach the delivery.

Mirrors S1-S3: a representation-free manifest + check in delivery/coverage.py
(EXCLUDED_GIDS_BY_REGION, excluded_for, assert_no_excluded_cells), called
region-gated by the manager's _check_coverage (a no-op for unpinned
africa_me_legacy, so its 5 ocean cells are unaffected). The 76 are disclosed
in docs/fao_excluded_cells.md.

Count correction (the frozen-list tripwire working as designed): deriving from
the live producer (datafactory v1.4.0) gives 64,742 complete + 76 excluded,
not the 64,736 + 82 the register/issue carried. Cause: datafactory #163
(ADR-043) supplemented 6 Azorean cells into land_gaul (82 - 6 = 76). vpp's own
built lookup already ships 64,742, so the stale pin would have false-positived
against our own artifact. Authoritative source = land - land_gaul (76), not
raw gaul0_code == -1 (82). EXPECTED_CELLS pin corrected; C-30 + #55 updated.

Tests (8 new): exclusion count == 76; known sub-Antarctic gids excluded;
Azorean cells NOT excluded; leaked cell raises naming the gid; unpinned region
no-op; plus a drift tripwire cross-checking the manifest against the
datafactory sibling when present. ruff clean; 109 passed, 44 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ovenance (#56, C-15)

Upload metadata carried only a free-text description, so FAO could not audit
what produced their data (C-15). S5 stamps each upload with structured,
sourced provenance.

Mirrors S1-S4: a representation-free delivery/provenance.py (build_provenance
-> dict) assembling lookup_version, region, expected/actual cell counts, and
unmapped_count from the enricher + S1 coverage + a new extraction.unmapped_cell_count
seam — nothing hardcoded. The manager's _delivery_description builds it per file
and both _save uploads carry it.

Carrier constraint (surfaced): pipeline-core's upload_data exposes no structured
field — only free-text description — so the dict is JSON-encoded into description
behind a human prefix for now. A dedicated metadata field is requested upstream
(pipeline-core #245); when it lands, only the manager's attach step changes (the
provenance shape is already representation-free). fill_count omitted until a
fabricated-value count exists (cf. C-26).

Tests (9 new): provenance dict fields/optionality/JSON-serializability;
unmapped_cell_count on mapped/null/missing-col frames. ruff clean; 117 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…gh the manager seam (#57)

Proves all five guards fire end-to-end on synthetic delivery frames. The manager
can't be instantiated (pipeline-core + Appwrite env, C-40), so — like
test_append_metadata.py / test_validation.py — each test replicates the manager's
own extraction->invariant chain and asserts the guard:

  S1 coverage          cells_of -> assert_complete_coverage (correct passes; under/over raise)
  S2 observed range    months_of -> fabricated_months -> drop_months_above (month > boundary not shipped as zero)
  S3 forecast identity file_metadata -> assert_forecast_identity (decoy rejected; match passes)
  S4 land_gaul         cells_of -> assert_no_excluded_cells (injected 62356 crashes loud)
  S5 provenance        build_provenance <- cells_of/unmapped_cell_count (description carries structured fields)

S4 uses the corrected 64,742 + 76 manifest (not the issue's stale 64,736 + 82).
The design-contract module is now 5 passed / 1 xfail — the only remaining
input-integrity xfail is the C-40-deferred manager de-inheritance. ruff clean;
126 passed, 44 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…#64)

- _clip_observed_history: degrade open consistently. The boundary fetch
  returned None when the attribute is absent (skip clip, warn) but RAISED on a
  transient datafactory network error (URLError/HTTPError/TimeoutError), which
  propagated through _read_historical_data (no try/except) and crashed the
  whole delivery. Wrap the fetch so both unresolved cases are treated alike.
- CIC: document the four new fail-loud guards (ForecastIdentityError,
  CoverageError on coverage/exclusion, observed-range clip, structured
  provenance) in §6, and the new invariant + e2e test modules in §10 — the
  contract doc had drifted from the manager's now-richer loudness contract.
- extraction.unmapped_cell_count: type the metadata_cols param (Sequence[str]).

ruff clean; 126 passed, 44 xfailed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant