Skip to content

fix: run filter-feature intake before the filter enters the collection - #909

Merged
TomKaltofen merged 2 commits into
mainfrom
fix/filter-feature-intake-before-collection
Jul 31, 2026
Merged

fix: run filter-feature intake before the filter enters the collection#909
TomKaltofen merged 2 commits into
mainfrom
fix/filter-feature-intake-before-collection

Conversation

@TKaltofen

Copy link
Copy Markdown
Collaborator

Closes #904.

The defect

Engine._add_filter_feature stored a matched SingleFilter into GlobalFilter.collection and only then ran the filter feature through feature intake. The comment above that call claimed the intake call "must remain an identity no-op (no rebind) to keep the SingleFilter set hash stable". It is not.

GlobalFilter.unify_options copies the host's effective options into the filter feature only for keys the filter feature does not already hold, so a key the filter feature declares explicitly as None reaches intake unfilled. options_with_defaults then treats a present-but-None value as absent and fills the declared default, rebinding the options of a Feature that is already inside a set. GlobalFilter.add_filter accepts a Feature, so this is reachable through the public API.

Two failure modes, because Options.__hash__ covers group only while Feature.__eq__ compares group and context:

declared key rebind consequence
group (context=False) yes SingleFilter.__hash__ shifts, the set stops finding its own member
context (context=True) yes hash holds, Feature.__eq__ breaks

Observable symptoms, both reproduced against the pre-fix ordering:

  • Silent: a host feature reached by two consumers stores the same filter twice, and ExecutionPlan.add_single_filters_to_feature_set hands both to the FeatureSet.
  • Hard: add a second host of the same feature group reached once, and the two collection entries have unequal lengths, so the plan aborts the run with ValueError: ... has different filters for different features.

The symmetric two-host case stays silent because CPython's set.__eq__ compares using the stored entry hashes, so two equally stale sets still compare equal, while set.__contains__ recomputes.

The fix

Swap the two calls so the filter feature's options are final before the SingleFilter is stored, and replace the stale comment. Every other mutation of the filter feature's hash-relevant fields (options via unify_options, domain, compute_frameworks, name) already happened before the insert; this closes the last one.

The effective options are unchanged: the filter feature still computes with the materialized default, which a guard test pins.

Tests

New module tests/test_core/test_filter/test_filter_feature_explicit_none_intake.py, 8 tests. Four discriminate (they fail against the pre-fix ordering), four are guards that pin behavior a wrong fix would break, notably that an allow_explicit_none=True explicit None is still honored and never filled. Each docstring says which it is.

Full tox gate green: 7543 passed, 170 skipped, ruff, mypy --strict, licenses, bandit.

A matched filter feature was stored in GlobalFilter.collection and only then
run through feature intake. When the filter feature declares a defaulted
PROPERTY_MAPPING key explicitly as None, unify_options leaves that None in
place and options_with_defaults fills the declared default, so intake rebound
the options of a Feature already inside a set. SingleFilter's hash covers that
feature, so the stored entry became unfindable in its own set and a host
feature reached by two consumers stored the same filter twice, handing both to
the FeatureSet.

Swap the two calls so the options are final before the filter is stored, and
replace the comment that asserted the intake call was an identity no-op.
…ring

Two hosts of one feature group, one reached twice and one once, made the
pre-fix ordering abort the run: the collection entries had unequal lengths and
ExecutionPlan.add_single_filters_to_feature_set rejected them. The symmetric
cases could not reach that, because set equality compares stored hashes and two
equally stale sets still compare equal, while set membership recomputes.

Also state which tests are guards and which fail pre-fix, and reword the engine
comment: a group fill shifts SingleFilter's hash, a context fill only its
equality, since Options hashes group alone.
@TKaltofen
TKaltofen requested a review from TomKaltofen as a code owner July 30, 2026 18:16
@TomKaltofen
TomKaltofen merged commit d771b75 into main Jul 31, 2026
18 checks passed
@TomKaltofen
TomKaltofen deleted the fix/filter-feature-intake-before-collection branch July 31, 2026 07:02
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.

engine.py's filter-feature intake call is not the identity no-op its comment claims

2 participants