Skip to content

feat: reindex rows by identifier membership with join and antijoin - #236

Merged
SkyeAv merged 8 commits into
mainfrom
anti-join-reindex
Oct 22, 2026
Merged

SkyeAv merged 8 commits into
mainfrom
anti-join-reindex

Conversation

@SkyeAv

@SkyeAv SkyeAv commented Oct 21, 2026

Copy link
Copy Markdown
Owner

feat: reindex rows by identifier membership with join and antijoin

Adds join and antijoin to the reindex comparison 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 one eq entry 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 obvious is_in implementation at every list size worth writing down.

Config surface

  • New operators: Comparisons.JOIN and Comparisons.ANTIJOIN (src/tablassert/enums.py), and Reindex.comparator widens to str | int | float | list[str] (src/tablassert/models.py).
  • Coded rejects: 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), and comparison-membership-member-not-a-string.
  • The last one is the reviewer-facing detail: comparator: [3, 4] is the natural spelling against an Int64 column whose string form is 3. Left to the field union it surfaced as a bare pydantic string_type error with no code and no docs URL, unlike the rest of the family, so a mode="before" validator now names the member and tells the user to quote it.
  • Scalar behavior is untouched: the six existing comparisons still refuse a list, regex stays an eq/ne feature, and regex: true on 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 runs how="semi" (keep) or how="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.
  • One plan-time dispatcher: 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)) and regex: true emits (lib.reindex_regex, (col, pattern, negate)); the dispatcher is shared with the regex work now on main (merged at 9fbb169).
  • Typed columns: a textual column joins straight on col. Anything else is compared in the form polars renders (3, 3.0, true, 2024-01-15, 1.50 for a scale-2 decimal, 2024-01-15 06:24:30.000000+00:00 for 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).
  • Refused dtypes: List, Array, Struct, Object, and Binary raise reindex-membership-column-dtype-unsupported rather than an opaque engine error or a filter that quietly matches nothing. Binary is 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.
  • Null semantics are join semantics: a null cell never matches, so join drops it while antijoin keeps it. That is the one place membership deliberately differs from ne, and it is what makes a deny-list section keep blank rows.
  • Caching: registered as a filter phase with cost 1 in src/tablassert/runcache.py, so membership sections digest and reuse like the other filters; tests/test_run_cache.py pins 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 eager collect, which is what a build actually pays). This box, polars 2.0.0-rc.2:

keys join antijoin is_in ~is_in
10 0.0035s 0.0052s 0.0037s 0.0038s
1,000 0.0031s 0.0043s 0.0038s 0.0050s
100,000 0.0170s 0.0227s 0.0253s 0.0275s
100,000 over Int64 (cast path) 0.0197s - - -
  • The claim is the ranking, not the seconds: the join ties the literal filter at ten keys and pulls about 1.5x ahead at 100k. Absolute numbers move with box load (a concurrent heavy job inflated every number by roughly 3x without changing the ranking).
  • No-regression line: the unchanged scalar op measures lt 0.0011s and ne 0.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.
  • Opt-in harness: all three tests skip unless TABLASSERT_BENCH=1, so default collection and CI never time anything.
  • Enforced invariant in the harness: the join path may cost at most 2x the literal filter's median (asserted from 1k keys up, where the signal exists). It is deliberately not asserted to be strictly faster, because at ten keys the two tie inside noise.

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 from explain() (no helper column on a textual column, one on an Int64 column), op compilation for scalar/membership/regex entries, and the end-to-end section build.
  • tests/test_models.py and tests/test_enums.py: the widened field, the membership validation family, and the new codes (also wired into tests/test_progress.py, which asserts each code surfaces with its docs URL through flatten_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 under join and under antijoin. 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

  • No zero-match warning. A list that matches nothing emits an empty section silently; catching it costs an extra pass over the frame on every build, which is a bad trade for a config typo.
  • Membership is pre-resolution. The list must be spelled exactly as the source column is written, so a CURIE list needs a CURIE column. Documented in both pages.
  • Deferred: file- or URL-backed identifier lists. A 40k-entry inline YAML list is the only shape today, and the op boundary (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: no file under rust/ changed on this branch, so cargo fmt --check, cargo clippy --all-targets -- -D warnings, and cargo test are unaffected (they were green at the merge commit).

Questions for the reviewer

  • Null semantics as shipped. antijoin keeps null cells because that is anti-join semantics and it is what a deny-list section wants (blank rows stay and surface as incomplete-edges QC findings instead of vanishing). If you would rather both operators drop nulls, that is a one-line change plus two test flips, but it makes antijoin silently lose rows.
  • Single PR, not a stack. The branch carries a merge of origin/main, so layering it into stacked PRs would mean rewriting that history; this is one review unit for a squash merge, matching #232 through #234 on the regex feature. Say the word if you want it decomposed.

SkyeAv and others added 8 commits October 21, 2026 14:45
`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.
@coderabbitai

coderabbitai Bot commented Oct 21, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 0f279768-ac6e-4b8f-aebc-1976807b1eff
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@SkyeAv
SkyeAv merged commit e7a0cd4 into main Oct 22, 2026
6 checks passed
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.
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