Skip to content

feat(enrichment): S4 (#89) — numpy/pyarrow keyed gather, drop pandas merge - #210

Merged
Polichinel merged 5 commits into
developmentfrom
feat/s4-pyarrow-keyed-gather
Aug 3, 2026
Merged

Polichinel merged 5 commits into
developmentfrom
feat/s4-pyarrow-keyed-gather

Conversation

@Polichinel

Copy link
Copy Markdown
Collaborator

Closes #89 (epic #85, S4).

What

Rewrites GaulLookupEnricher's lookup side to be pandas-free: read the GAUL lookup with pyarrow, hold it as numpy arrays + a sorted key index, and attach metadata by a keyed searchsorted gather instead of a pandas merge. Absent gids gather to None, so the downstream null gate still fails loud. pandas drops to a TYPE_CHECKING-only import (it's in the enricher's interface — callers pass DataFrames — not its implementation). Unblocks S5 (#90)'s pyarrow-native builder.

Tests

  • test_enrichment: lookup fixture loads the artifact directly (the enricher no longer exposes a private pandas frame), tolerant of both the current pandas-index shape and S5's index-free one.
  • test_doc_accuracy: the pandas-importer guard now checks by AST (zero runtime importers + type-only in enrichment.py) — a regex couldn't distinguish a TYPE_CHECKING guard from a real import.
  • The change's own 31 tests pass.

Provenance / honesty

This was recovered from an uncommitted working tree on this (correctly-named) branch — one of the parallel sessions implemented it and never committed. Reviewed end-to-end before committing: complete, coherent, on-topic for #89.

⚠️ Pre-existing, NOT from this change

The repo currently has 5 failing tests in test_wire_shard.py / test_wire_sidecar.py (byte-parity + test_toolchain_is_pinned). Verified they fail on the clean branch without this change too — likely fallout from the views-frames 1.10.2 bump (#209) changing wire bytes, or a Python-version/toolchain drift. Flagging separately — not this PR's to fix, but someone should look.

Leaving the merge to whoever owns #89.

@Polichinel
Polichinel force-pushed the feat/s4-pyarrow-keyed-gather branch from d1f48c0 to 9065dd3 Compare August 3, 2026 00:16
Polichinel and others added 5 commits August 3, 2026 03:35
…das merge

Rewrite GaulLookupEnricher's lookup side to be pandas-free (epic #85):
- Read the 888 KB GAUL lookup with pyarrow; hold it as numpy arrays + a sorted
  key index. Attaching metadata is a keyed `searchsorted` gather, not a pandas
  merge — absent gids gather to None so the downstream null gate still fails
  loud. The artifact is no longer materialised as a pandas frame, and the join
  no longer needs it to carry pandas index metadata (which unblocks S5/#90's
  pyarrow-native builder).
- pandas becomes a TYPE_CHECKING-only import (it's in the enricher's interface —
  callers hand it DataFrames — not its implementation).
- test_enrichment: the `lookup` fixture loads the artifact directly (the
  enricher no longer exposes a private pandas frame), tolerant of both the
  current pandas-index shape and S5's index-free one.
- test_doc_accuracy: the pandas-importer guard now checks by AST, asserting
  ZERO runtime importers + type-only in enrichment.py (a regex couldn't tell a
  TYPE_CHECKING guard from a real import).

Recovered from an uncommitted working tree on this (correctly-named) branch —
complete, its 31 tests pass. (The repo's 5 wire shard/sidecar byte-parity +
toolchain-pin failures are pre-existing and unrelated to this change.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… what the dtype change costs

Four findings from the diff review, applied to the commit that preceded them.

An empty lookup raised IndexError from inside the gather. The guard read
`(len(self._keys) > 0) & (self._keys[clipped] == wanted)`, which LOOKS like a
guard and is not one: `&` evaluates both operands, so the index happened anyway.
Guard where the condition is knowable — __init__ now refuses an empty lookup with
a message about the lookup, rather than letting the delivery later complain about
missing metadata.

The parity proof passed check_dtype=False, check_categorical=False and compared
after reset_index — three flags that hide exactly what a merge-to-assign rewrite
is most likely to change. Re-checked without them. Two results worth having:

  - name columns went category -> object. Measured, not guessed: on a 200-row
    output, category is 1,795,191 bytes and object is 60,061, because a
    categorical carries the artifact's full 64,742-entry dictionary whatever the
    output size. Lighter for the frames this object actually sees. Recorded in the
    code beside the C-32 note it might otherwise be read as contradicting.

  - on EMPTY input the old merge replaced the input's RangeIndex with an
    object-dtype Index; the gather leaves it alone. The only behavioural
    difference found, and it is in the direction the CIC already claimed.

The CIC is corrected — fourth story running where it lagged the change, which is
the pattern ADR-014 §5 was written for, recurring in the first story after the ADR
landed. It now states the gather, the dtype change, the empty-lookup refusal, and
that pandas is interface-only.

Also dropped a dead alias and a duplicated row count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uards the claims needed

Six findings from /code-review max (five parallel reviewers), all reproduced before
fixing.

The blind `np.asarray(gids, dtype=np.int64)` was wrong in three ways, one of them a
regression against development:

  - a NaN gid became INT64_MIN with only a RuntimeWarning, and then the warning line
    itself raised `ValueError: cannot convert float NaN to integer` because it
    iterated the PRE-cast array. The path whose job is to report the problem
    gracefully was the path that crashed. A missing gid is ordinary data quality.
  - a non-integral float truncated SILENTLY: 54220.000000001 -> 54220, matching a
    real, different cell. Verified against the pandas merge this replaced — it
    returns null there, and even warns. A silent wrong match is worse than a crash.
  - pd.NA raised a bare TypeError with no log and no contract-shaped error.

Replaced by `_as_cell_ids`, which DECLARES what a cell id is and marks anything else
unusable rather than coercing it. Unusable ids gather to null exactly as unknown
gids do, so the downstream gate sees a hole rather than a guess. Verified case by
case against the old implementation: NaN, drifted float and object-with-None now
match it exactly, and pd.NA is strictly better (old crashed).

The lookup side had the same hole: a null key would take the same sentinel, collide
with an unusable query id, and report FOUND — handing back another cell's metadata.
That is the fabricated value this module's docstring forbids. __init__ now refuses
it.

The warning then named cell 0 for an unusable id — a cell nobody asked about. It now
names unknown cells and separately counts rows that carried no usable id, because an
id that is not an id has none to report.

The AST guard added in this same PR had the defect it was written to catch. It
walked the whole `if TYPE_CHECKING` node including `else:`, and substring-matched
the test, so `not TYPE_CHECKING` read as a guard. Both evasions reproduced, both now
caught, and pinned by a test rather than a comment — the `&`-not-short-circuiting
shape, committed twice in one pull request.

The CIC's guarantees were prose resting on a throwaway script. Committed: empty and
null-key refusals, the four unusable-id behaviours, row/order/index preservation
across five shapes plus a non-default index, and the object dtype. 29 tests.

ADR-012 updated — my own test docstring said "ADR-012 names the seam, so update it
rather than letting the claim drift", and the first draft did not. Three claims in
the CIC that PR #200 retired in the manager's CIC were still standing here.

Registered C-75: this class has no production caller and now holds a second copy of
the delivery path's keyed gather. Tier 3 — the verification path and the delivery
path implement the same algorithm twice, and the tests run through the copy that
does not ship.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng, prove the mask

Round 1 removed one silent coercion and introduced two more. Round 2 found them.

The object branch used `float(value)`, which PARSES. It accepted the string "54220"
as a cell id — where the pandas merge this replaces raised "You are trying to merge
on object and int64 column". A string gid column is a declaration error and the old
path said so; parsing it is inference, and it is the drifted-float defect pointing
the other way. It now accepts only values that already ARE integers. `bool` is
excluded for the same reason: True is not cell 1.

Neither branch bounded int64. `uint64` max wrapped to -1 and `1e30` to INT64_MIN,
both flagged VALID — an id nobody wrote, marked usable. Harmless only because real
gids are 1..259,200, which is luck of the key range rather than a property the code
enforced. Both branches now bound explicitly.

`& usable` in `_gather` was the one new guard that survived its own removal: unusable
ids substitute to 0, and 0 is not a real gid, so the key comparison already failed.
It looked like decoration. It is not, and the case is now committed: with a lookup
containing gid 0, a NaN row silently receives cell 0's geography without the mask.
That is the fabricated value the docstring forbids, produced from an input the caller
never wrote.

The out-of-range test I wrote in round 2 built every case with dtype=object, so all
of them entered the element-wise branch and the int and float bounds it claimed to
cover were never executed — removing them left the suite green. Each case now enters
the branch it tests. Mutation matrix after: dropping `& usable`, the float bound, the
int bound, or the isinstance check each fails the suite.

`np.isfinite` is the exception and is now documented as such rather than left to be
rediscovered: inf fails the upper bound, NaN fails `arr == rint(arr)`, so the check
is subsumed and defensive, not load-bearing. Saying so is cheaper than the next
person mutation-testing it and wondering.

37 tests in test_enrichment.py; 337 in the suite. Wire untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…LP, and my mutation proof was a replica

Second /code-review max pass. Three correctness defects and a vacuous test, all
reproduced before fixing.

The float upper bound admitted exactly what it was added to exclude. _INT64_MAX is
2**63-1, which is odd and NOT representable in float64, so comparing a float against
it promotes and rounds UP to 2**63. float(2**63) passed isfinite, integral and both
bounds, then wrapped to INT64_MIN and was flagged valid. Now bounded strictly below
2.0**63, which IS exact. _INT64_MIN needed no change: -2**63 is a power of two.

timedelta64 was accepted as a cell id, because isinstance(np.timedelta64(5,'ns'),
np.integer) is True — a numpy quirk, and datetime64 is not, so the hole was specific
to one type. A duration is not a cell id any more than True is cell 1.

The test I wrote to prove the AST guard bites was itself a replica. It defined a
local copy of the classifier and asserted against that; reintroducing the polarity
bug into the REAL guard left all 16 tests green. That is the defect S4 of epic #181
retired from test_validation.py two days ago — 43 tests against a function the file
defined itself — committed again by me, in the PR whose message boasts about
catching its cousin. The classifier is now one module-level function with two
callers, and both regressions now fail it.

And the fix for that had a sting: routing the out-of-range cases through the public
API, as the review recommended, LOST the proof. Publicly, "refused" and "accepted but
wrapped to an absent id" both produce null, so reverting the float bound left the
test green. The public assertion is the caller's guarantee; the mask assertion is the
mutation proof; both are needed and both are now present, with a comment saying why.

_gather returned four values, three of which existed only to build one log message.
The warning moved inside it, matching this file's own convention where __init__
co-locates detection and logging at every guard. It returns one dict.

My own error message credited the builder with refusing null keys. It does not: its
null check runs after the key becomes the index, and DataFrame.isna() does not
inspect an index. A null key is unreachable there only because an earlier
astype('int64') raises — protection by accident. Registered as C-76, along with the
empty-output case, which IS reachable and writes cells=0 without complaint.

Also: the pandas error message was misquoted, ADR-012 cited a path that does not
exist (unfao/wire/ -> contract/wire/), the CIC claimed eight input shapes where the
pinned class has six and named one it does not exercise, the artifact size was wrong
by 2% in four places, and a register quote was singular where C-40 is plural. C-75
gained the owner ADR-014 §4 requires and its first draft omitted while citing §4.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Polichinel
Polichinel force-pushed the feat/s4-pyarrow-keyed-gather branch from dfaceb5 to b406ad6 Compare August 3, 2026 01:35
@Polichinel
Polichinel merged commit 94977bd into development Aug 3, 2026
4 checks passed
@Polichinel
Polichinel deleted the feat/s4-pyarrow-keyed-gather branch August 3, 2026 01:37
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