S4 (#185) — retire the _validate replica; test the gate where it fires - #200
Merged
Merged
Conversation
…te where it fires tests/test_validation.py defined its own validate_dataframe() and ran 43 parametrised cases against it. Forty-three tests, zero production code — a closed loop that could not have failed if the real gate were deleted outright. Its header claimed "the logic tested matches unfao.py:_validate() exactly". False since #149, which stopped _validate null-gating entirely; its docstring now reads "Neither payload is null-gated here." It also carried REQUIRED_METADATA_COLS as a nine-element literal — the hand copy of the contract that C-70 was resolved to make single-source. Replaced by 14 tests of contract/historical.assert_metadata_complete, the code that actually gates a delivery, parametrised over the IMPORTED METADATA_COLS so a column added to gaul_schema.COLUMNS is gated automatically. Plus source-scan pins that the gate has not drifted back into _validate and that the manager still calls it. Mutation-tested: narrowing the gate to a single column fails 9 of the 14. The old suite passed that same mutation untouched. The behaviour C-01 fixed is preserved in full; it is simply asserted against the function that performs it. C-03 closes. Residual 1 (the replica) is gone. Residual 2 (the enrich->validate end-to-end path) is RELOCATED to #18 by number, per the convention that a relocation is not complete until the destination exists and is cited. Every leg is covered — enrichment, artifact build, reader parity, invariants on primitives, the wire e2e, and now the null-gate. What remains is the manager ORCHESTRATING them, which needs pipeline-core and a production-like Appwrite environment that þing-02 D2 forbids. That gap already had two trackers; a third here was noise. Three `assert False` campaign probes retired because the findings they assert are discharged: 3.2's "validation uses replica not real code", 4.3's "filter_cols change not detected", and 4.3's "enrichment test checks only 5 of 9" — the last verified before removal, since test_enrichment.py asserts list(lookup.columns) == METADATA_COLS, exact equality on all nine, then iterates the full set twice more. C-36's own resolution predicted this: "the pure-assert False probes don't test the live condition, so a fixed finding won't auto-flip." A discharged probe must be retired by hand or it permanently asserts a defect that no longer exists. Each removal leaves a dated note; the modules stay, because the campaign record is the point. Review caught the story closing C-03 while the CIC still cited it as open, next to a now-false "replica tests" — the exact drift class, in the contract document. Corrected to name #18 and þing-02 D2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Aug 2, 2026
Polichinel
added a commit
that referenced
this pull request
Aug 3, 2026
…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>
Polichinel
added a commit
that referenced
this pull request
Aug 3, 2026
…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>
Polichinel
added a commit
that referenced
this pull request
Aug 3, 2026
…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>
Polichinel
added a commit
that referenced
this pull request
Aug 3, 2026
…merge (#210) * feat(enrichment): S4 (#89) — numpy/pyarrow keyed gather, drop the pandas 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> * fix(enrichment): S4 review findings — refuse an empty lookup, and say 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> * fix(enrichment): S4 review round 1 — a declared conversion, and the guards 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> * fix(enrichment): S4 review round 2 — bound the conversion, stop parsing, 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> * fix(enrichment): S4 review round 3 — the float bound was off by one ULP, 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> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #185. Closes register C-03. Part of epic #181.
43 tests that could not fail
tests/test_validation.pydefined its ownvalidate_dataframe()twelve lines above the tests that exercised it. Forty-three parametrised cases against zero production code. Ifassert_metadata_completehad been deleted outright, every one would still have passed.Its header said:
False since #149, which stopped
_validatenull-gating entirely. Its docstring now reads "Neither payload is null-gated here" — it asserts the read resolved and calls_check_coverage. The gate moved tocontract/historicaland fires at artifact-build time.And
REQUIRED_METADATA_COLSwas a nine-element literal — the hand copy of the contract C-70 was resolved to eliminate.What replaces it
14 tests of
historical.assert_metadata_complete, parametrised over the importedMETADATA_COLS, plus source-scan pins that the gate has not drifted back into_validateand that the manager still invokes it.The numbers are the argument:
The behaviour C-01 fixed — a null in a required GAUL column stops the delivery — is preserved in full. It is simply asserted against the function that performs it.
C-03, closed honestly
Residual 1 (the replica) is gone.
Residual 2 (enrich→validate end-to-end) is relocated to #18 by number, per the convention added this week: a relocation is not complete until the destination exists and is cited by number.
Every leg is covered — enrichment (16), artifact build (7), reader parity (3), invariants on primitives (8), the wire e2e (6), the null-gate (14). What remains is the manager orchestrating them, needing views-pipeline-core and a production-like Appwrite environment that þing-02 D2 forbids. That gap already had two trackers (#18 and an
xfail(strict)probe); a third here was noise, not signal.Three discharged probes retired
assert Falseprobes asserting findings that are now fixed:C-36's own resolution predicted this residual: "the pure-
assert Falseprobes don't test the live condition, so a fixed finding won't auto-flip — inherent to marker-style tests." So a discharged probe cannot surface itself; it has to be retired by hand, or it permanently asserts a defect that no longer exists. S4 sets the first precedent: remove the probe, leave a datedDISCHARGEDnote in the module docstring. The modules stay — the campaign record is the point.Scope extension, stated plainly rather than buried. The third probe is about
test_enrichment.py, not the_validatereplica, so it is beyond this story's stated boundary. I verified it false before removing:test_enrichment.pyassertslist(lookup.columns) == METADATA_COLS— exact equality on all nine — then iterates the full set twice more for presence and nulls. The four columns it said had "no schema drift protection" are covered three times over. Leaving a known-falseassert Falsebeside one just retired seemed worse than the small overreach; Claim 4.3 now holds no live probe.Review findings — both addressed
F1 (warning) — this story closes C-03, and the CIC still said "source-scan and replica tests" and cited "(C-03)" as the open tracker for the end-to-end gap. The story closing a concern was leaving the contract document asserting it open — the #154/#158 class, in the epic built to end it. Corrected: "seam tests", no replica, and the residual now points at #18 with þing-02 D2 named.
F2 (suggestion) — C-43's
See alsodescribed C-03 in the present tense as an open gap. Annotated with its resolution and the relocation target.Verification
Register: 74 concerns, 21 open, 53 resolved.
🤖 Generated with Claude Code