Skip to content

fix: scope DUPLICATE_COUNT under --filter to the keys the window contains - #1619

Open
gkhnelbstn wants to merge 2 commits into
datacontract:mainfrom
gkhnelbstn:duplicate-check-ignores-row-filter
Open

gkhnelbstn wants to merge 2 commits into
datacontract:mainfrom
gkhnelbstn:duplicate-check-ignores-row-filter

Conversation

@gkhnelbstn

@gkhnelbstn gkhnelbstn commented Sep 12, 2026

Copy link
Copy Markdown

The bug

--filter narrows every check to the rows it selects. For uniqueness that answers "are today's rows unique among themselves", which misses a duplicate whose other half arrived outside the window.

We hit this on real data: a windowed field_unique passed every day for 45 days. The same contract read unfiltered against the same table reported duplicate_count(order_id) was 8 -- 8 duplicate primary keys on a column declared primaryKey: true and unique: true, invisible the whole time.

Filed as #1593.

The fix

Under --filter, DUPLICATE_COUNT reads every row of the table -- selected or not -- whose key occurs among the selected rows. A semi-join, as suggested in review:

SELECT key, COUNT(*) FROM t
WHERE EXISTS (SELECT 1 FROM t AS w WHERE <filter> AND w.key IS NOT DISTINCT FROM t.key)
GROUP BY key HAVING COUNT(*) > 1

That catches a key repeated within the window and one repeated across its edge, and skips keys the window does not contain, whose duplicates belong to another window. Without a filter nothing changes.

The change is in _run_model and a new _duplicate_scope() in datacontract/engines/ibis/ibis_check_execute.py, built with ibis's semi_join. On duckdb it compiles to:

FROM "data" AS "t1"
SEMI JOIN (SELECT DISTINCT "t0"."id" FROM "data" AS "t0" WHERE "t0"."batch" = 5) AS "t3"
  ON "t1"."id" IS NOT DISTINCT FROM "t3"."id"

The join is null-safe on purpose. GROUP BY puts NULL keys in one group, so the unfiltered check counts two NULL ids as a duplicate. A plain key IN (...) never matches NULL, so the same rows would pass when filtered. IS NOT DISTINCT FROM (ibis identical_to) keeps the two answers the same. Whether NULL keys should count as duplicates is a separate question; this only keeps filtered and unfiltered from disagreeing about it.

Two consequences, both keeping the check's behaviour from before this PR:

  • A percent threshold is of the filtered row count, as for every other filtered check. The count is of the window's keys, so the window's rows are the matching denominator.
  • A --filter that does not compile errors DUPLICATE_COUNT like every other check that reads rows. The check needs the window's keys to know which rows to read.

--include-failed-samples reads the same scoped rows as the count, so the samples and the number agree.

Testing

tests/test_row_filter_duplicate_count.py, against tests/fixtures/row-filter-duplicate/, whose batches hold: a key repeated across batches 1 and 2, a key repeated within batch 3, a batch-4 key that never repeats, and two NULL ids in batch 5.

case filter result
unfiltered -- failed
duplicate across the window edge batch = 1 failed
duplicate within the window batch = 3 failed
duplicate wholly outside the window batch = 4 passed
NULL key batch = 5 failed, as unfiltered
sibling row_count still narrows batch = 1 passed
filter that does not compile no_such_column = 1 error

Two of these pin the semantics rather than only the happy path. Replacing identical_to with == fails the NULL case; reading the whole table instead of the semi-join fails the wholly-outside case. Both mutations were run.

Locally against duckdb: the 7 tests pass, the 13 in test_test_row_filter.py pass, the 59 matching unique, duplicate or samples across the suite pass, and ruff check / ruff format --check are clean.

A uniqueness check answers "does this key repeat anywhere in the table".
--filter narrowing it to "anywhere in today's rows" answers a different,
weaker question, and it did so silently: a duplicate whose other half
loaded on an earlier day passed every day, filtered or not. Hit this on
real data -- field_unique passed for 45 days windowed; the same table read
unfiltered reported 16 duplicate primary keys across 3376 rows.

DUPLICATE_COUNT now reads unfiltered_t, the table handle kept before
_apply_row_filter narrows t, both for the count itself and for its
--include-failed-samples rows -- the two disagreeing would be worse than
neither existing. Its row_count denominator (for a percent threshold) is
the unfiltered count too, for the same reason. A --filter predicate that
fails to compile no longer errors DUPLICATE_COUNT specs either: they never
read the filtered table, so they do not need it to compile.

Every other check (row_count, missing_count, invalid_count, freshness,
custom SQL) is untouched -- unfiltered_t and t are the same object when
there is no --filter, so this costs nothing in the common case, and
unfiltered_row_count() short-circuits to model_row_count() rather than
issuing a second COUNT query.

tests/test_row_filter_duplicate_count.py, against a dedicated fixture
(id repeats once per two-value "batch" column): the duplicate is caught
unfiltered, still caught when a filter narrows to the batch that contains
only one of the two occurrences (the exact false-negative shape), a
sibling row_count check still honours the filter, and a filter predicate
that would not even compile does not block the duplicate check.

Verified locally against local/duckdb (Docker was not available for the
Postgres/SQL Server/etc. suites): the new test file passes, the existing
13 in test_test_row_filter.py are unaffected, and a broader run across
~2200 non-container tests shows no failure this touches -- the ones present
(duckdb/variables/metadata-only) reproduce identically on main, unrelated
to this change (Windows-path escaping in a regex/YAML string).

Fixes datacontract#1593.
Copilot AI lite review requested due to automatic review settings September 12, 2026 20:33

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@jschoedl

jschoedl commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Hi @gkhnelbstn. thank you for creating the issue and the PR!

Right now you check for duplicates in the whole table. But since a filter is set, it would be enough if we do the check (a) within the selected rows and (b) between selected and unselected rows - i.e., not within the unselected rows.

In other words, I'd propose to save costs by using a semi-join between the filtered rows and the whole table, conceptually like this:

SELECT key, COUNT(*) FROM my_table
WHERE key IN (SELECT DISTINCT key FROM my_table WHERE <filter>)
GROUP BY key HAVING COUNT(*) > 1

@gkhnelbstn gkhnelbstn changed the title fix: DUPLICATE_COUNT ignores --filter, always reads the whole table fix: scope DUPLICATE_COUNT under --filter to the keys the window contains Sep 18, 2026
@gkhnelbstn

gkhnelbstn commented Sep 18, 2026

Copy link
Copy Markdown
Author

Thanks @jschoedl, that is a better shape and it's done in ceb6f6e. Under --filter the check now reads the rows of the whole table whose key occurs among the selected rows, so it covers (a) and (b) and skips duplicates wholly outside the window. There's a test for each of the three, and the one for the wholly-outside case passes a window whose only key never repeats, even though the table has duplicates elsewhere.

One deliberate difference from the SQL you sketched: the join is IS NOT DISTINCT FROM (ibis identical_to), not IN. GROUP BY puts NULL keys in one group, so the unfiltered check counts two NULL ids as a duplicate. key IN (...) never matches NULL, so the same rows would pass when filtered. The null-safe join keeps the filtered and unfiltered answers the same. On duckdb it compiles to a SEMI JOIN ... ON a.id IS NOT DISTINCT FROM b.id.

If you'd rather NULL keys not count as duplicates at all, that's a reasonable call, but it would change the unfiltered check too, so I've left it out of this PR.

Two things went back to how they were before this PR, since the check now depends on the filter:

  • a percent threshold is of the filtered row count again, like every other filtered check;
  • a --filter that doesn't compile errors DUPLICATE_COUNT like the other row-reading checks, because the check needs the window's keys.

I've rewritten the PR description to match.

Reading the whole table caught a duplicate whose other half arrived outside
the window, but it also re-reported every duplicate the window never touched,
on every run, at the cost of grouping the entire table.

Under --filter the check now reads the rows of the whole table whose key
occurs among the filtered rows -- a semi-join, as suggested in review. That
finds a key repeated within the window and one repeated across its edge, and
skips keys the window does not contain.

The join is IS NOT DISTINCT FROM (ibis identical_to), not equality. GROUP BY
puts NULL keys in one group, so the unfiltered check counts two NULL ids as a
duplicate; a plain `key IN (...)` never matches NULL and would pass the same
rows filtered. The null-safe join keeps the two answers the same.

Two consequences, both returning to how the check behaved before this PR:

- A percent threshold is again of the filtered row count. The count is now of
  the window's keys, so the whole-table denominator no longer fits it.
- A --filter that does not compile errors DUPLICATE_COUNT like every other
  check that reads rows. The check needs the window's keys to know which rows
  to read.

Failed samples read the same scoped rows as the count.

Tests cover a duplicate within the window, across its edge, wholly outside it
(passes), and a NULL key. Replacing identical_to with == fails the NULL test;
reading the whole table fails the wholly-outside test.
@gkhnelbstn
gkhnelbstn force-pushed the duplicate-check-ignores-row-filter branch from f1f1fb3 to ceb6f6e Compare September 18, 2026 19:07
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.

3 participants