Skip to content

S4 — enrichment.py: numpy/pyarrow keyed gather (drop pandas merge) #89

Closed

Description

@Polichinel

Epic: #85 · S4 · [UNILATERAL — low priority]

Background

GaulLookupEnricher is OWN-CHOICE pandas: it loads the GAUL lookup via pd.read_parquet (enrichment.py:48) and attaches metadata with base.merge(left_on=pg_id_col, right_index=True, how="left") (enrichment.py:117-119). This is a keyed metadata-attach join, not frame algebra. It does not block samples (it attaches geo columns to whatever rows exist), so this is low priority.

Work

  • Replace pd.read_parquet(lookup) + .merge with a pandas-free keyed gather: load the lookup once into a priogrid_gid → row numpy/dict structure (via pyarrow.parquet, already imported for _read_version), then gather metadata by cell id, producing NaN/sentinel for misses to preserve the fail-loud null behaviour.
  • Explicitly do NOT push this into views-frames — PredictionFrame carries values + a SpatioTemporalIndex, not arbitrary GAUL columns; modelling a 9-column geographic attach as a frame op would distort the value-object contract. The target is plain numpy/arrow keyed gather.

Acceptance criteria

  • Enricher no longer imports/uses pandas; output is column-for-column identical to the merge path, including NaN positions for unmapped cells.
  • The country_iso_a3.isna() warning path fires identically.

Parity / validation

tests/test_enrichment.py + tests/test_append_metadata.py as the oracle: feed identical inputs to old merge vs new gather, assert equality incl. NaN positions and dtypes.

Dependencies

Independent of S1–S3 (can land any time). Keep gaul_schema.py (the 9-column contract) as the single source of truth; coordinate with S5.

Files

views_postprocessing/unfao/enrichment.py, tests/test_enrichment.py, tests/test_append_metadata.py.

Activity

  1. Polichinel commented on Jul 27, 2026

    @Polichinel
    CollaboratorAuthor

    Scope update (2026-07-27): the historical half of this issue is delivered by #126/PR #129 — geography attaches via a pyarrow searchsorted gather from the ADR-011 lookup (unfao/historical.py), no pandas merge. Remaining scope here: the legacy forecast path's enrichment.py merge only, which is slated for deletion with that path post-run-0 — at which point this issue likely closes as moot rather than implemented. Re-assess after run 0.

    🤖 Generated with Claude Code

  2. added a commit that references this issue on Aug 1, 2026
  3. Polichinel commented on Aug 2, 2026

    @Polichinel
    CollaboratorAuthor

    Stale paths — re-read against the tree before starting.

    Epic #148's #153 moved this module: unfao/enrichment.py → views_postprocessing/contract/enrichment.py.

    And _read_version, which this issue cites as "already imported for" the pyarrow dependency, moved out of the enricher entirely in #152 — it lives in contract/gaul_lookup.py as the public version(), and epic #181's S5 (#186) rewrites it again. GaulLookupEnricher.lookup_version now delegates there.

    The substance of this story is unaffected — the pandas merge at what is now contract/enrichment.py is still a keyed metadata-attach join. Only the coordinates changed.

  4. added 3 commits that reference this issue on Aug 3, 2026
  5. Polichinel commented on Aug 3, 2026

    @Polichinel
    CollaboratorAuthor

    Landed in #210.

    The enricher's lookup side is pandas-free: arrow read, one take sort, np.searchsorted gather. This unblocks #90 — the old path did pd.read_parquet + right_index=True, which depends on the artifact carrying pandas index metadata that a pyarrow-native builder will not write. Doing #90 first would have broken the merge silently.

    Two full /code-review max passes, eleven findings, all fixed. The ones worth remembering:

    • a NaN gid crashed the very line whose job is to report the problem gracefully — a regression against development, found because the parity proof used check_dtype=False and reset_index, which hide exactly what a merge→assign rewrite changes;
    • a non-integral float truncated silently, so 54220.000000001 matched real cell 54220. The pandas merge returns null there and even warns;
    • the float upper bound was off by one ULP — 2**63-1 is odd and unrepresentable in float64, so comparing against it rounds up and admitted exactly what it excluded;
    • timedelta64 slipped through isinstance(..., np.integer), which is True for it and False for datetime64;
    • the test I wrote to prove the AST guard bites was itself a replica — the defect S4 of epic [EPIC] Every claim checkable: clear the unblocked debt before the views-pipeline-core 3.0.0 bump #181 retired from test_validation.py two days earlier, committed again in the PR boasting about catching its cousin;
    • and fixing that by routing through the public API lost the proof, because publicly "refused" and "accepted but wrapped to an absent id" both produce null.

    39 tests, every guard mutation-proven. Registered C-75 (this class has no production caller and now holds a second copy of the delivery path's gather) and C-76 (the builder writes an empty lookup without complaint).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    implementationCode implementation workstoryA single reviewable unit of an epic

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions