Repository navigation
feat: reindex rows by identifier membership with join and antijoin - #236
Merged
Merged
Conversation
`reindex` only supported scalar comparisons, so keeping or dropping a known set of identifiers meant one condition per identifier and was not expressible for real lists. `Comparisons` gains `join` (keep rows whose cell value is in the list) and `antijoin` (drop those rows), and `Reindex.comparator` widens to accept `list[str]` for exactly those two operators. Validators: `join`/`antijoin` require a non-empty list of strings (`comparison-membership-comparator-required`, `comparison-membership-empty`); the six scalar comparisons still refuse a list through the pre-existing codes, and their rules are unchanged. US-001 of the reindex membership build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
`lib.reindex_membership` implements the two new comparisons as polars semi/anti hash joins against a once-built, deduplicated key frame: the faster measured path at every tested size on the pinned polars (1M rows x 100k keys: 0.02-0.03s vs 0.036s for a literal-list filter) and the membership idiom `qc.py` already uses. `lib.reindex_op` compiles each `Reindex` entry to its op, so `Tcode._source_ops` emits membership for `join`/`antijoin` and byte-identical scalar op tuples (otherwise) - the run cache digests op tuples positionally, so the existing shapes are pinned by test. Semantics: exact string identity against the raw pre-resolution cell; null cells never match, so join drops them and antijoin keeps them (deliberately unlike `ne`); duplicate keys cannot multiply rows; source order and every column dtype survive (the cast to String happens in a throwaway join key that is dropped again); typed sources (parquet Int64) match string identifiers instead of failing on join-key dtypes; a missing column or an empty identifier list raises a coded error. Registration: `PHASE_OF` labels the op `filter` and `OP_COST` prices it 1, like the scalar filter ops. No existing op cost changed. US-002 of the reindex membership build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The identifier list is a semantic op argument, so a config that changes its keep-list must not be served the previous list's rows out of the store. These three tests pin that the list digests (it stays inside the supported `canonical` type set, no `runcache-unserializable-arg`), that a changed list, an added member, or a flip from join to antijoin each changes the digest, that a reordered list only costs a benign recompute, and that `lib.reindex_membership` is priced 1 like the scalar filter so the checkpoint floor does not move for configs that adopt it. US-003 of the reindex membership build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reproducible before/after numbers for the perf claim, shaped after
`tests/bench_merge_bench.py`: opt-in on `TABLASSERT_BENCH=1`, never collected by
the default suite, run with `-s -n 0` because the frames are session state.
Median of three collects over 1M string rows (this box, polars 2.0.0-rc.2):
keys join antijoin is_in ~is_in
10 0.0032s 0.0044s 0.0032s 0.0032s
1_000 0.0031s 0.0036s 0.0036s 0.0045s
100_000 0.0183s 0.0200s 0.0292s 0.0261s
Int64 column via the cast path, 100k keys: join 0.0203s
unchanged scalar control: lt 0.0011s, ne 0.0013s
So the shipped join ties the literal-list filter on tiny lists and wins by about
1.6x where a list actually matters, and the scalar op (untouched code) stays at
its own baseline. Correctness invariants are asserted at every size (join and
antijoin partition the corpus, and agree row-for-row with the filter they
replace); the timing ranking is asserted from 1k keys up with a 2x margin,
because absolute seconds move ~3x with box load while the ranking does not.
`lib.reindex_membership`'s docstring now cites these medians instead of the
first single-sample numbers.
US-004 of the reindex membership build.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Independent review of the membership op found three gaps this closes.
`maintain_order="left"` is now explicit on both joins. Polars documents the
default join order as unspecified ("might differ across Polars versions or even
between different runs") and the dependency is an open range, so relying on the
observed order meant a future 2.x could reshuffle a section's rows with no test
able to catch it. Verified free here (1M rows / 100k keys, five runs, median):
semi 5.1ms -> 4.6ms, anti 5.4ms -> 5.4ms, and the harness still reports join
0.017-0.021s vs the literal filter's 0.026s.
Nested cells now fail loudly: `List`/`Array`/`Struct`/`Object` columns raise
`reindex-membership-column-dtype-unsupported` naming the column instead of
surfacing polars' `InvalidOperationError` (list cells cannot be cast to String)
or a brace literal no identifier matches (structs). Matched by dtype *name*
because a module-level `pl.List` reference would resolve the lazy proxy and
import polars at `import tablassert.lib` - measured: `polars in sys.modules`
True with the tuple, False with the name set.
The cast branch derives its helper column name against the collected schema, so
a source column literally named `_reindex_membership_key` is no longer
overwritten and dropped. Unreachable through `Tcode` (`idx` renames every source
column to `column_<n>`), but the op is public.
Also documents the string forms an identifier list must be spelled against (Date
`2024-01-15`, Datetime with a space and microseconds, Decimal keeping scale,
Bool lowercase, Float64 in cast form), pins that a zero-match list is a silent
valid empty filter rather than an error, and completes the scalar op-shape
regression matrix with `le` and `gt`. Coverage added for the String fast path
schema, nulls on the cast branch, the reserved-name case, and shuffled source
order. 26 tests in `tests/test_reindex_membership.py` (was 18); full suite 1291
passed, 1 pre-existing env-gated skip; pyright 0 errors.
US-002 review follow-up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Table reference: the `comparison`/`comparator` rows now list the membership operators and the widened `List[String]` comparator, and a Membership Filtering subsection states what a config author needs: the test runs at the same stage as every other `reindex` condition on the raw pre-resolution cell, it is exact textual identity (so an identifier list must be spelled as polars renders the column, with the measured forms for Int64/Float64/Bool/Date/Datetime), duplicate identifiers are ignored, row order and column dtypes survive, nested cells are refused with `reindex-membership-column-dtype-unsupported`, null cells drop under `join` and survive `antijoin` (the one deliberate difference from `ne`), and a list that matches nothing is a valid empty filter rather than an error. Use-case gallery: a new "Identifier Allow-list and Deny-list" entry splits one table into a `join` section and an `antijoin` section over the same source. Also repairs the `[Taxon Filtering](#taxon-filtering)` anchor in the same page, which markdownlint rejects (the heading is `Taxonomic Filtering`); the repo's markdown-lint gate was failing on it. US-005 of the reindex membership build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…patcher main shipped `Reindex.regex` + `lib.reindex_regex` (#232/#233) over the same inline `reindex` comprehension in `_source_ops` that this branch replaced with `lib.reindex_op`. The merge folds the regex arm into that dispatcher, so a config entry compiles to exactly one of three families: - `join`/`antijoin` -> `reindex_membership` (new) - `regex: true` -> `reindex_regex` (main's arm, unchanged tuple: negate on `ne`) - the six scalar comparisons -> `reindex` (unchanged tuple shapes, so no run-cache digest moves for any pre-existing config) `Reindex` now carries the widened `comparator` (`String|Int|Float|List[String]`) and main's `regex` field. The validator gained one rule: `regex: true` on a membership entry is rejected with main's existing `comparison-regex-requires-eq-ne` code, because membership matches fixed identifiers by exact textual identity and main's rejection lived in the numeric arm that membership short-circuits past. `OP_COST`, `PHASE_OF`, the error-code list, the table reference and the changelog keep both features side by side. Verified after the merge: `uv run pytest` 1328 passed, 1 pre-existing env-gated skip; `uv run pyright` 0 errors; ruff check/format clean; `mkdocs build --strict` clean; `markdownlint-cli2` 0 issues; 29 membership tests including the new regex routing, the membership+regex rejection, and a mixed scalar/regex/membership section compile. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow-ups from the fresh-eyes review of the join/antijoin reindex branch. - `comparator: [3, 4]` on a membership entry (the natural spelling against an `Int64` column) surfaced as a bare pydantic `string_type` field error, with no stable code and no docs URL unlike the rest of the family. A `mode="before"` validator now names the offending member and raises `comparison-membership-member-not-a-string`, telling the user to quote it. - `Binary` cells join the refused dtypes: the cast to String succeeds only when the bytes happen to be valid UTF-8, so one list would match some rows and raise a `ComputeError` on the rest. `List`, `Array`, `Struct`, `Object` and `Binary` are now all covered by the refusal test. - The docs claimed `join`/`antijoin` over two different lists partition a file. They do not: a row in both lists is emitted by both sections. The example now uses one list under each operator, which does partition, and the bullet says explicitly that two different lists overlap by design. - The new example section had landed inside the regex section's **Key techniques** list, orphaning its bullets. They are back under their own heading, the enumeration names the membership pair, and the new section uses the file's **Key techniques** label. - `docs/configuration/table.md` showed two `reindex:` keys in one YAML block, where the last key silently wins. It is one list of two ANDed conditions now, plus the complementary-section pattern linked to `docs/examples.md`. - Cast forms documented: `06:24:30` for a time, `1.50` for a scale-2 decimal, and `2024-01-15 06:24:30.000000+00:00` for a timezone-aware datetime, which renders in UTC. - Pins: the String fast path is asserted by plan (no helper column on a textual column, one on an `Int64` column), the three config-time membership codes are added to the code-plus-docs-URL surfacing test, and the benchmark states the enforced invariant as a 2x ceiling on the median of three rather than a strict speed win.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configuration
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
SkyeAv
added a commit
that referenced
this pull request
Oct 22, 2026
Cut 22.2.0 and bump the package version in pyproject.toml, CITATION.cff, and uv.lock. Minor: the --qc study stage fails the build on self-referential edges via a new self-loop-edges assertion (#229); eq/ne reindex filters can match cells with a Polars regex behind a default-off regex key (#232, #233, #234); source.reindex can filter rows by identifier membership with join/antijoin semi/anti hash joins (#236). Docs: the Taxon Filtering link in the table config reference now matches its section (#228, #231). Changelog: - Versioned the Unreleased section as 22.2.0 - 2026-10-22. - Added PR links to the three Added entries (#229; #232, #233, #234; #236). - Added the missing Fixed entry for the Taxon Filtering docs link (#228, #231). Docs: none needed here; the shipped docs changes are covered by the changelog entries above. Testing: - make check -> ruff, ruff format, pyright, pytest, cargo test, cargo clippy -D warnings: all green.
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.
feat: reindex rows by identifier membership with join and antijoin
Adds
joinandantijointo thereindexcomparison set so one config entry keeps or drops rows by exact membership in a list of identifiers, backed by polars semi/anti hash joins. Before this, "keep exactly these 40k CURIEs" needed oneeqentry per identifier, and every one of those conditions was a separate full pass over the column; the new path replaces the whole list with one filter and beats the obviousis_inimplementation at every list size worth writing down.Config surface
Comparisons.JOINandComparisons.ANTIJOIN(src/tablassert/enums.py), andReindex.comparatorwidens tostr | int | float | list[str](src/tablassert/models.py).comparison-membership-comparator-required(scalar comparator on a membership entry),comparison-membership-empty(an empty list would silently drop or keep the whole table),reindex-membership-column-missing(entry targets a column the source does not have), andcomparison-membership-member-not-a-string.comparator: [3, 4]is the natural spelling against anInt64column whose string form is3. Left to the field union it surfaced as a bare pydanticstring_typeerror with no code and no docs URL, unlike the rest of the family, so amode="before"validator now names the member and tells the user to quote it.regexstays aneq/nefeature, andregex: trueon a membership entry is rejected (comparison-regex-requires-eq-ne).Runtime
lib.reindex_membership(df, col, keys, anti=False): deduplicates the list, builds a one-column key frame, and runshow="semi"(keep) orhow="anti"(drop).maintain_order="left"is explicit, not incidental: polars documents default join order as unspecified ("might differ across Polars versions or even between different runs") and the dependency is an open range, so a future 2.x must not be able to reshuffle a section's rows. Measured free (1M rows / 100k keys, median of five): semi 5.1ms -> 4.6ms, anti 5.4ms -> 5.4ms.lib.reindex_op(entry)picks the op for a config entry. Scalar entries still emit exactly the(lib.reindex, (col, op, comp, cast))tuples they always did, so existing run-cache digests do not move. Membership emits(lib.reindex_membership, (col, keys, anti))andregex: trueemits(lib.reindex_regex, (col, pattern, negate)); the dispatcher is shared with the regex work now onmain(merged at9fbb169).col. Anything else is compared in the form polars renders (3,3.0,true,2024-01-15,1.50for a scale-2 decimal,2024-01-15 06:24:30.000000+00:00for a timezone-aware datetime), through a throwaway helper key column that is dropped before the op returns and is renamed out of the way if the source already carries that name (_membership_key_column).List,Array,Struct,Object, andBinaryraisereindex-membership-column-dtype-unsupportedrather than an opaque engine error or a filter that quietly matches nothing.Binaryis refused because it casts only when the bytes happen to be valid UTF-8, so one list would match some rows and raise on the rest.joindrops it whileantijoinkeeps it. That is the one place membership deliberately differs fromne, and it is what makes a deny-list section keep blank rows.filterphase with cost 1 insrc/tablassert/runcache.py, so membership sections digest and reuse like the other filters;tests/test_run_cache.pypins that the identifier list is semantic (a changed list is a different cache entry) and that legacy scalar digests are unchanged.Performance
tests/bench_reindex_membership.py, 1M string rows, every number the median of three timed windows (each window includes the key-frame build and the eagercollect, which is what a build actually pays). This box, polars 2.0.0-rc.2:joinantijoinis_in~is_inInt64(cast path)lt0.0011s andne0.0015s here, matching what it measured before the branch. The diff changes only how an entry compiles at plan time, never what the scalar filter does per row.TABLASSERT_BENCH=1, so default collection and CI never time anything.Coverage
tests/test_reindex_membership.py(new): keep/drop shapes, source order under a shuffled corpus, schema and dtype preservation, the null asymmetry, duplicate keys, empty/missing/non-list comparators, every refused dtype, the helper-column collision, the String fast path asserted fromexplain()(no helper column on a textual column, one on anInt64column), op compilation for scalar/membership/regex entries, and the end-to-end section build.tests/test_models.pyandtests/test_enums.py: the widened field, the membership validation family, and the new codes (also wired intotests/test_progress.py, which asserts each code surfaces with its docs URL throughflatten_pydantic_error).Docs
docs/configuration/table.md: both operators in the field table, a Membership section (stage, textual matching, measured cast forms, refused dtypes, null rules, error codes), and an ANDed allow-list/deny-list example in one valid YAML block.docs/examples.md: "Identifier Allow-list and Deny-list", splitting one file into two sections with the same list underjoinand underantijoin. That is the shape that actually partitions the file; two different lists overlap, and a row in both is emitted twice.CHANGELOG.md: entry under Unreleased.Caveats
keys: list[str]) is where a loader would attach.Testing
uv run pytest -q --no-cov -p no:cacheprovider->1332 passed, 1 skipped, 2443 warnings in 21.69s(the skip is the env-gated corpus sweep,TABLASSERT_CONFIG_CORPUS).uv run pyright->0 errors, 0 warnings, 0 informations.uv run ruff check src tests->All checks passed!;uv run ruff format src tests docs-> clean.uv run pre-commit run --all-files-> ruff check, ruff format, markdownlint-cli2, cargo-fmt, uv-lock-check, html-validate all Passed.uv run mkdocs build --strict-> exit 0;npx -y markdownlint-cli2@0.23.3->Summary: 0 issues in 0 files.uv run pytest -n 0 -q --no-cov tests/test_docs_source_of_truth.py tests/test_docs_examples.py tests/test_docs_style.py->216 passed, 1 warning.TABLASSERT_BENCH=1 uv run pytest -s -n 0 -q --no-cov tests/bench_reindex_membership.py->3 passed in 3.88s, numbers above.rust/changed on this branch, socargo fmt --check,cargo clippy --all-targets -- -D warnings, andcargo testare unaffected (they were green at the merge commit).Questions for the reviewer
antijoinkeeps null cells because that is anti-join semantics and it is what a deny-list section wants (blank rows stay and surface asincomplete-edgesQC findings instead of vanishing). If you would rather both operators drop nulls, that is a one-line change plus two test flips, but it makesantijoinsilently lose rows.origin/main, so layering it into stacked PRs would mean rewriting that history; this is one review unit for a squash merge, matching#232through#234on the regex feature. Say the word if you want it decomposed.