From 1fd598fc89c9df46aae4a6df88717507db3c3596 Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Fri, 7 Aug 2026 07:57:15 +0000 Subject: [PATCH 1/2] fix: key the dropped-filter ledger on the declaring filter (#1074) GlobalFilter.dropped_filters keyed on (feature group, filter feature name), so two filters declared over one column collapsed into one entry and which fact survived rode set iteration order. The key now carries the declaring filter's uuid, mirroring the probes ledger, and the two report-dedupe sets follow it. _nearest_miss no longer withholds a reason when two filters share a column, and the unmatched-filter warning emits its messages sorted, since the two now differ only in their suffix and filters is a set. --- docs/docs/in_depth/feature-group-matching.md | 2 +- docs/docs/in_depth/filter_data.md | 14 +- mloda/core/filter/global_filter.py | 86 ++++++------ .../test_filter_elimination_reasons.py | 128 ++++++++++++++++-- .../test_filter_matcher_containment.py | 13 +- .../test_filter_matcher_rejection_taxonomy.py | 7 +- .../test_filter_matcher_truthiness.py | 7 +- 7 files changed, 185 insertions(+), 72 deletions(-) diff --git a/docs/docs/in_depth/feature-group-matching.md b/docs/docs/in_depth/feature-group-matching.md index 5b8c565e..4983aa59 100644 --- a/docs/docs/in_depth/feature-group-matching.md +++ b/docs/docs/in_depth/feature-group-matching.md @@ -31,7 +31,7 @@ Filter matching contains the same way: a raise is a non-match for that probe, li The probe runs per feature, but a matched filter attaches to the whole `FeatureSet`, so a non-match for one feature does not suppress a filter a sibling matched. See [Filter scope](filter_data.md#filter-scope-is-the-featureset). -Every caller reads the return by truthiness: any falsy value is a non-match, any truthy value a match. Filter matching additionally reports a falsy value that is not `False` once per FeatureGroup and filter feature. +Every caller reads the return by truthiness: any falsy value is a non-match, any truthy value a match. Filter matching additionally reports a falsy value that is not `False` once per FeatureGroup and declared filter. The options view depends on the caller: feature resolution passes declared (pre-default) options, while filter matching runs after intake and passes the resolved feature's effective (post-default) options merged onto the filter feature's own. Matching logic that reads option values can see different values on the two paths. See [Applying declared defaults](property-mapping.md#applying-declared-defaults). diff --git a/docs/docs/in_depth/filter_data.md b/docs/docs/in_depth/filter_data.md index e906a399..7cefc655 100644 --- a/docs/docs/in_depth/filter_data.md +++ b/docs/docs/in_depth/filter_data.md @@ -211,7 +211,7 @@ filters for row elimination. The return is read for truthiness, so any falsy value is a non-match, exactly like `False`: a hook that falls off the end of a branch and returns `None` attaches no filter. A falsy value that is not -`False` is reported once per FeatureGroup and filter feature, so the detached filter is visible; +`False` is reported once per FeatureGroup and declared filter, so the detached filter is visible; return `True` explicitly to keep it. Matched filters are attached to the `FeatureSet` before `calculate_feature()` is @@ -252,10 +252,11 @@ value whose truthiness test raises, that filter is a non-match for that probe, l return, and the drop is recorded in `GlobalFilter.dropped_filters`. A typed decline the matcher records lands in the same ledger, at DEBUG. A framework-owned raise still aborts. -`GlobalFilter.dropped_filters` maps (FeatureGroup, filter feature name) to the gate that dropped the -filter and that gate's reason, for the current engine setup only. A plain `False` is an ordinary -non-match and records nothing. A matcher defect takes the key from a stored near-miss; otherwise the -deepest gate the filter reached keeps it, and two facts at one depth leave the first one in place. +`GlobalFilter.dropped_filters` maps (FeatureGroup, filter feature name, filter uuid) to the gate that +dropped the filter and that gate's reason, for the current engine setup only. A plain `False` is an +ordinary non-match and records nothing. A matcher defect takes the key from a stored near-miss; +otherwise the deepest gate the filter reached keeps it, and two facts at one depth leave the first one +in place. A filter that matches no FeatureGroup at all is reported once after setup, with its nearest miss appended when one was recorded: across FeatureGroups the deepest gate wins, and a matcher defect @@ -269,8 +270,7 @@ The parenthesized label, `(scope)` here, names the gate in the vocabulary of the found" error's [near-miss bullets](troubleshooting/feature-group-resolution-errors.md#the-eliminated-candidates-block). -Two filters declared on one column name share the ledger key, so neither report can quote a fact and -both stay the bare sentence. +Two filters declared on one column name each record their own fact and get their own nearest miss. ### Filter scope is the `FeatureSet` diff --git a/mloda/core/filter/global_filter.py b/mloda/core/filter/global_filter.py index c1007337..9ec9fdda 100644 --- a/mloda/core/filter/global_filter.py +++ b/mloda/core/filter/global_filter.py @@ -44,6 +44,10 @@ } +# One declared filter against one feature group: the name alone collapses two filters on one column. +_LedgerKey = tuple[type[FeatureGroup], str, UUID] + + def _copy_feature_leaf(value: Any) -> Any: """Detach a Feature leaf from the host; any other leaf stays shared by reference.""" return copy(value) if isinstance(value, Feature) else value @@ -59,8 +63,8 @@ def __init__(self) -> None: names and the uuid to the used single filter. This is used to track which features are associated with which filters for a specific feature group. This can be used to check after the fact if a feature is a filter feature for a specific feature group e.g. for debugging, logging or quality checks. - 3. `dropped_filters`: maps (feature group, filter feature name) to the `Elimination` naming the gate that - dropped it and why; a matcher defect outranks a stored near-miss, otherwise the deepest gate reached wins. + 3. `dropped_filters`: maps (feature group, filter feature name, filter uuid) to the `Elimination` naming the + gate that dropped it and why; a matcher defect outranks a stored near-miss, else the deepest gate wins. 4. `probes`: maps (feature group, feature name, feature uuid) to the filters that probe matched, empty included. 5. `matched_filter_uuids`: uuids of filters that cleared every gate at least once this setup. @@ -69,14 +73,14 @@ def __init__(self) -> None: """ self.filters: set[SingleFilter] = set() self.collection: dict[tuple[type[FeatureGroup], FeatureName], set[SingleFilter]] = {} - self.dropped_filters: dict[tuple[type[FeatureGroup], str], Elimination] = {} + self.dropped_filters: dict[_LedgerKey, Elimination] = {} self.probes: dict[tuple[type[FeatureGroup], FeatureName, UUID], set[SingleFilter]] = {} self.matched_filter_uuids: set[UUID] = set() self._warned_divergences: set[str] = set() # Own state, not dropped_filters: a falsy non-bool is an ordinary non-match, never a recorded drop. - self._reported_falsy_matches: set[tuple[type[FeatureGroup], str]] = set() + self._reported_falsy_matches: set[_LedgerKey] = set() # WARNING dedupe for defect drops; the ledger itself no longer decides first-ness. - self._warned_drops: set[tuple[type[FeatureGroup], str]] = set() + self._warned_drops: set[_LedgerKey] = set() def reset_match_tracking(self) -> None: """Every match report is scoped to one engine setup, so a later setup names only what it consulted. @@ -171,26 +175,25 @@ def identify_matched_filters( # We are making a deepcopy so that, we do not change the original filter. _filter = deepcopy(filter) _filter.filter_feature.options = self.unify_options(feat.options, _filter.filter_feature.options) - filter_name = str(_filter.filter_feature.name) # criteria records its own drops: only it can tell a defect from a decline from a plain non-match. if not self.criteria(feature_group, _filter, data_access_collection): continue if self.domain(_filter, feat.domain, feature_group) is False: self._record_near_miss( - feature_group, filter_name, "domain", self._domain_reason(_filter, feat, feature_group) + feature_group, _filter, "domain", self._domain_reason(_filter, feat, feature_group) ) continue if self.feature_group_scope(_filter, feature_group) is False: - self._record_near_miss(feature_group, filter_name, "scope", "outside the requested feature group scope") + self._record_near_miss(feature_group, _filter, "scope", "outside the requested feature group scope") continue supported = self.capability(_filter, feat, feature_group) if supported is not None and not supported: - self._record_near_miss(feature_group, filter_name, "capability", self._capability_reason(_filter, feat)) + self._record_near_miss(feature_group, _filter, "capability", self._capability_reason(_filter, feat)) continue if self.compute_framework(_filter, feat, supported) is False: self._record_near_miss( - feature_group, filter_name, "framework_pin", self._framework_pin_reason(_filter, feat) + feature_group, _filter, "framework_pin", self._framework_pin_reason(_filter, feat) ) continue # we don't check links, because this is not necessary as this is covered by the feature and feature group before @@ -203,27 +206,22 @@ def identify_matched_filters( def warn_on_unmatched_filters(self) -> None: """Warn once per filter that matched no feature group this setup, naming its nearest miss.""" - for filter in sorted(self.filters, key=lambda f: f.name): + messages: list[str] = [] + for filter in self.filters: if filter.uuid in self.matched_filter_uuids: continue message = f"Filter feature '{filter.name}' matched no feature group." - # SingleFilter.name reads through to filter_feature.name, which is what the recorders key on. - nearest = self._nearest_miss(filter.name) + nearest = self._nearest_miss(filter.uuid) if nearest is not None: message += f" Nearest miss: {nearest}" + messages.append(message) + # `filters` is a set and two filters can share a name, so only the rendered message orders them stably. + for message in sorted(messages): logger.warning(message) - def _nearest_miss(self, filter_feature_name: str) -> str | None: + def _nearest_miss(self, filter_uuid: UUID) -> str | None: """The captured fact of the deepest gate this filter reached, as the shared near-miss bullet.""" - # The ledger keys on the name, so a name two declared filters share makes every fact under it - # unattributable to either of them. - if sum(1 for filter in self.filters if filter.name == filter_feature_name) > 1: - return None - captured = [ - (feature_group, elimination) - for (feature_group, name), elimination in self.dropped_filters.items() - if name == filter_feature_name - ] + captured = [(key[0], elimination) for key, elimination in self.dropped_filters.items() if key[2] == filter_uuid] if not captured: return None feature_group, elimination = min(captured, key=self._nearest_miss_key) @@ -327,22 +325,27 @@ def criteria( ) if probe.matcher_error is not None: reason = contained_raise_reason(probe.matcher_error) - self._record_dropped_filter(feature_group, str(filter.filter_feature.name), reason) + self._record_dropped_filter(feature_group, filter, reason) return False # value_rejection is excluded: its returned is the containment's synthetic None, not the hook's. if probe.value_rejection is None and not probe.matched and not isinstance(probe.returned, bool): - self._report_falsy_match(feature_group, str(filter.filter_feature.name), probe.returned) + self._report_falsy_match(feature_group, filter, probe.returned) if probe.rejection is not None: - self._record_rejected_filter(feature_group, str(filter.filter_feature.name), probe.rejection) + self._record_rejected_filter(feature_group, filter, probe.rejection) return False return probe.matched + @staticmethod + def _ledger_key(feature_group: type[FeatureGroup], filter: SingleFilter) -> _LedgerKey: + """The declaring filter's own key; the per-match deepcopy carries the declaration's uuid.""" + return feature_group, filter.name, filter.uuid + def _record_near_miss( - self, feature_group: type[FeatureGroup], filter_feature_name: str, stage: EliminationStage, reason: str + self, feature_group: type[FeatureGroup], filter: SingleFilter, stage: EliminationStage, reason: str ) -> None: """Record the gate one filter lost at against one feature group; the deepest gate reached keeps the key, matching how `_nearest_miss` reads the ledger back.""" - key = (feature_group, filter_feature_name) + key = self._ledger_key(feature_group, filter) stored = self.dropped_filters.get(key) # A stored defect stays pinned to its key, and equal depth keeps the first fact recorded. if stored is None or ( @@ -351,41 +354,40 @@ def _record_near_miss( self.dropped_filters[key] = Elimination(stage=stage, reason=reason) def _record_rejected_filter( - self, feature_group: type[FeatureGroup], filter_feature_name: str, rejection: MatchRejection + self, feature_group: type[FeatureGroup], filter: SingleFilter, rejection: MatchRejection ) -> None: """Record a typed decline in the same ledger at DEBUG: a deliberate rejection is a near-miss, not a defect.""" - self._record_near_miss( - feature_group, filter_feature_name, rejection_elimination_stage(rejection.stage), rejection.reason - ) + self._record_near_miss(feature_group, filter, rejection_elimination_stage(rejection.stage), rejection.reason) logger.debug( "%s rejected filter feature '%s': %s; dropping that filter for this feature group.", # A plugin-owned read past the hook call's containment, so it degrades instead of escaping the seam. safe_field(lambda: feature_group.get_class_name(), ""), - filter_feature_name, + filter.name, rejection.reason, ) - def _record_dropped_filter(self, feature_group: type[FeatureGroup], filter_feature_name: str, reason: str) -> None: - """Record the drop: defect drops warn once per key and take the key from a stored near-miss.""" - key = (feature_group, filter_feature_name) + def _record_dropped_filter(self, feature_group: type[FeatureGroup], filter: SingleFilter, reason: str) -> None: + """Record the drop over a stored near-miss but never over another defect; `_warned_drops` picks the level.""" + key = self._ledger_key(feature_group, filter) + stored = self.dropped_filters.get(key) + if stored is None or stored.stage != "matcher_error": + self.dropped_filters[key] = Elimination(stage="matcher_error", reason=reason) first = key not in self._warned_drops self._warned_drops.add(key) - if first: - self.dropped_filters[key] = Elimination(stage="matcher_error", reason=reason) logger.log( logging.WARNING if first else logging.DEBUG, "%s %s while matching filter feature '%s'; dropping that filter for this feature group.", feature_group.get_class_name(), reason, - filter_feature_name, + filter.name, ) - def _report_falsy_match(self, feature_group: type[FeatureGroup], filter_feature_name: str, returned: Any) -> None: + def _report_falsy_match(self, feature_group: type[FeatureGroup], filter: SingleFilter, returned: Any) -> None: """Report the detached filter: WARNING on a key's first report, DEBUG after, like `_record_dropped_filter`. Both fields are plugin-owned reads and this runs past the hook call's containment, so each degrades alone. """ - key = (feature_group, filter_feature_name) + key = self._ledger_key(feature_group, filter) first = key not in self._reported_falsy_matches self._reported_falsy_matches.add(key) logger.log( @@ -395,7 +397,7 @@ def _report_falsy_match(self, feature_group: type[FeatureGroup], filter_feature_ safe_field(lambda: feature_group.get_class_name(), ""), # The type name only: the value's own __repr__ is plugin code and must not run here. safe_field(lambda: type(returned).__name__, ""), - filter_feature_name, + filter.name, ) def domain(self, filter: SingleFilter, feature_domain: None | Domain, feature_group: type[FeatureGroup]) -> bool: diff --git a/tests/test_core/test_filter/test_filter_elimination_reasons.py b/tests/test_core/test_filter/test_filter_elimination_reasons.py index 6a1ea6e3..69befb2d 100644 --- a/tests/test_core/test_filter/test_filter_elimination_reasons.py +++ b/tests/test_core/test_filter/test_filter_elimination_reasons.py @@ -9,6 +9,7 @@ import gc import logging from collections.abc import Callable, Sequence +from copy import deepcopy from dataclasses import dataclass from functools import partial from typing import Any, ClassVar, TypeVar, cast, get_args @@ -81,6 +82,14 @@ # The canonical seam's own wording over the one framework the filter would ride. CAPABILITY_REASON = f"supports_compute_framework rejected {[PythonDictFramework.__name__]}" +# The pin gate's own wording over the filter's declared pin and the framework the host resolved to. +PIN_REASON = ( + f"pinned compute framework '{PandasDataFrame.__name__}' " + f"is not the feature's resolved '{PythonDictFramework.__name__}'" +) + +ABSENT_UUID = "" # what a ledger key carrying no filter identity reads as + # (recorded free-form hint, the elimination stage it maps onto). STAGE_HINT_TABLE: tuple[tuple[str, EliminationStage], ...] = ( (INPUT_DATA_STAGE, INPUT_DATA_ELIMINATION_STAGE), @@ -147,6 +156,16 @@ def _ledger_rows(global_filter: GlobalFilter) -> tuple[tuple[str, str, str, str] return tuple(sorted(rows)) +def _ledger_keys(global_filter: GlobalFilter) -> tuple[tuple[str, str, str], ...]: + """(group class name, filter feature name, filter uuid) per ledger key, sorted. Holds no class.""" + keys: list[tuple[str, str, str]] = [] + # cast: the key's arity is what this pins, so it must be read without its declared shape. + for key in cast(dict[tuple[Any, ...], Any], global_filter.dropped_filters): + uuid_part = str(key[2]) if len(key) > 2 else ABSENT_UUID + keys.append((str(key[0].get_class_name()), str(key[1]), uuid_part)) + return tuple(sorted(keys)) + + def _filter_feature( domain: str | None = None, scope: str | None = None, @@ -472,6 +491,8 @@ class _LedgerSnapshot: # Set when reading a stored fact raised, which is how a bare-string ledger reports itself. ledger_error: str | None rows: tuple[tuple[str, str, str, str], ...] + keys: tuple[tuple[str, str, str], ...] + declared_uuids: tuple[str, ...] warnings: tuple[str, ...] debugs: tuple[str, ...] unmatched: tuple[str, ...] @@ -524,6 +545,8 @@ def _run_setup( fact_types=tuple(sorted(type(fact).__name__ for fact in global_filter.dropped_filters.values())), ledger_error=ledger_error, rows=rows or (), + keys=_ledger_keys(global_filter), + declared_uuids=tuple(sorted(str(single.uuid) for single in global_filter.filters)), warnings=warnings, debugs=_messages(caplog, logging.DEBUG), unmatched=tuple(message for message in warnings if UNMATCHED_PHRASE in message), @@ -589,10 +612,14 @@ def _drive_setups( gc.collect() -def _drive_shared_name(caplog: pytest.LogCaptureFixture, filter_features: Sequence[Feature]) -> _LedgerSnapshot: - """Drive filters that all declare one name against a single plain probe whose host loses the pin.""" +def _drive_shared_name( + caplog: pytest.LogCaptureFixture, + filter_features: Sequence[Feature | str], + makes: Sequence[_Factory] = (_make_plain_fg,), +) -> _LedgerSnapshot: + """Drive filters that all declare one name against a single probe whose host loses the pin.""" return _drive( - [_make_plain_fg], + makes, caplog, filter_features=filter_features, make_hosts=(partial(_host_feature, pin=PythonDictFramework),), @@ -600,6 +627,18 @@ def _drive_shared_name(caplog: pytest.LogCaptureFixture, filter_features: Sequen ) +def _losing_pair_messages() -> tuple[str, ...]: + """The two unmatched messages the losing pair must render, sorted as the warning emits them.""" + from mloda.core.prepare.resolution_failure_renderer import near_miss_text + + return tuple( + sorted( + f"{BARE_MESSAGE} {NEAREST_MISS_PHRASE}{near_miss_text(PLAIN_CLASS_NAME, stage, reason)}" + for stage, reason in ((SCOPE_STAGE, SCOPE_REASON), (FRAMEWORK_PIN_STAGE, PIN_REASON)) + ) + ) + + @dataclass(frozen=True) class _CanonicalSnapshot: """Plain-data readout of one evaluate() pass. Holds no class and no Elimination object.""" @@ -1062,21 +1101,44 @@ def test_a_falsy_non_bool_is_reported_again_in_a_new_setup(self, caplog: pytest. ) -class TestTwoFiltersOnOneNameAttributeNothing: - """The ledger keys on the filter feature NAME, so a shared name cannot attribute a fact to one filter.""" +class TestTwoFiltersOnOneNameAreAttributedSeparately: + """The ledger keys on the declaring filter's uuid, so a shared name still attributes each fact to its filter.""" - def test_both_warnings_are_the_bare_sentence(self, caplog: pytest.LogCaptureFixture) -> None: - """One filter loses at scope and the other at the pin; neither message may quote the other's fact.""" + def test_the_ledger_key_carries_the_declaring_filters_uuid(self, caplog: pytest.LogCaptureFixture) -> None: snapshot = _drive_shared_name(caplog, _losing_pair()) assert snapshot.escaped is None, f"nothing may cross identify_matched_filters: {snapshot.escaped}" + assert len(snapshot.declared_uuids) == 2, f"two declared filters, got: {snapshot.declared_uuids}" + assert ABSENT_UUID not in {key[2] for key in snapshot.keys}, ( + f"the key must name the filter that lost, got: {snapshot.keys}" + ) + assert tuple(key[2] for key in snapshot.keys) == snapshot.declared_uuids, ( + f"one key per declared filter, each under its own uuid, got: {snapshot.keys}" + ) + + def test_each_filter_records_its_own_fact(self, caplog: pytest.LogCaptureFixture) -> None: + """One filter loses at scope and the other at the pin, against one feature group: two facts, not one.""" + snapshot = _drive_shared_name(caplog, _losing_pair()) + + assert snapshot.escaped is None, f"nothing may cross identify_matched_filters: {snapshot.escaped}" + assert snapshot.ledger_error is None, f"the stored fact must be readable: {snapshot.ledger_error}" assert snapshot.names == (), f"neither filter may attach, got: {snapshot.names}" - assert snapshot.unmatched == (BARE_MESSAGE, BARE_MESSAGE), ( - f"an unattributable fact must not be quoted, got: {snapshot.unmatched}" + assert len(snapshot.rows) == 2, f"one fact per declared filter, got: {snapshot.rows}" + assert {row[2] for row in snapshot.rows} == {SCOPE_STAGE, FRAMEWORK_PIN_STAGE}, ( + f"each filter must keep the gate it lost at, got: {snapshot.rows}" + ) + assert {(row[0], row[1]) for row in snapshot.rows} == {(PLAIN_CLASS_NAME, FILTER_FEATURE)}, ( + f"both facts stay under the group and the shared name, got: {snapshot.rows}" + ) + + def test_each_warning_names_its_own_nearest_miss(self, caplog: pytest.LogCaptureFixture) -> None: + snapshot = _drive_shared_name(caplog, _losing_pair()) + + assert tuple(sorted(snapshot.unmatched)) == _losing_pair_messages(), ( + f"each filter must be told its own nearest miss, got: {snapshot.unmatched}" ) def test_one_filter_on_that_name_still_names_its_nearest_miss(self, caplog: pytest.LogCaptureFixture) -> None: - """The guard covers the ambiguous case only: a single filter still gets its suffix.""" from mloda.core.prepare.resolution_failure_renderer import near_miss_text snapshot = _drive_shared_name(caplog, (_filter_feature(scope=MISSING_SCOPE),)) @@ -1085,10 +1147,52 @@ def test_one_filter_on_that_name_still_names_its_nearest_miss(self, caplog: pyte assert snapshot.unmatched == (expected,), f"one filter on the name keeps its suffix, got: {snapshot.unmatched}" def test_the_pair_renders_the_same_way_on_every_run(self, caplog: pytest.LogCaptureFixture) -> None: - """Which of the two facts the ledger keeps rides set iteration order; the messages must not.""" + """`filters` is a set, so an unsorted emission would reorder the two messages between runs.""" runs = tuple(_drive_shared_name(caplog, _losing_pair()).unmatched for _ in range(REPEAT_RUNS)) - assert set(runs) == {(BARE_MESSAGE, BARE_MESSAGE)}, f"the runs diverged: {sorted(set(runs))}" + assert len(set(runs)) == 1, f"the emitted order rides set iteration order: {sorted(set(runs))}" + assert set(runs) == {_losing_pair_messages()}, f"the runs must emit both messages sorted: {sorted(set(runs))}" + + +class TestTheReportLedgersAreKeyedPerDeclaration: + """The report dedupe follows the fact it guards, so a shared name never mutes the second declaration.""" + + def test_two_defects_on_one_name_record_and_warn_twice(self, caplog: pytest.LogCaptureFixture) -> None: + snapshot = _drive_shared_name(caplog, (FILTER_FEATURE, FILTER_FEATURE), makes=(_make_matcher_error_fg,)) + + assert snapshot.escaped is None, f"nothing may cross identify_matched_filters: {snapshot.escaped}" + assert snapshot.ledger_error is None, f"the stored fact must be readable: {snapshot.ledger_error}" + assert len(snapshot.rows) == 2, f"one defect fact per declaration, got: {snapshot.rows}" + assert {row[2] for row in snapshot.rows} == {MATCHER_ERROR_STAGE}, ( + f"both declarations lost to the same defect, got: {snapshot.rows}" + ) + assert len(_carrying(snapshot.warnings, DROP_REPORT_FRAGMENT)) == 2, ( + f"each dropped declaration must be reported, got: {snapshot.warnings}" + ) + assert _carrying(snapshot.debugs, DROP_REPORT_FRAGMENT) == (), ( + f"the second declaration must not be downgraded to DEBUG, got: {snapshot.debugs}" + ) + + def test_two_falsy_returns_on_one_name_are_each_reported(self, caplog: pytest.LogCaptureFixture) -> None: + snapshot = _drive_shared_name(caplog, (FILTER_FEATURE, FILTER_FEATURE), makes=(_make_falsy_decline_fg,)) + + assert snapshot.escaped is None, f"nothing may cross identify_matched_filters: {snapshot.escaped}" + assert snapshot.rows == (), f"a falsy non-bool is a non-match, never a fact, got: {snapshot.rows}" + assert len(_carrying(snapshot.warnings, FALSY_REPORT_FRAGMENT)) == 2, ( + f"each detached declaration must be reported, got: {snapshot.warnings}" + ) + assert _carrying(snapshot.debugs, FALSY_REPORT_FRAGMENT) == (), ( + f"the second declaration must not be downgraded to DEBUG, got: {snapshot.debugs}" + ) + + +class TestFilterIdentitySurvivesThePerMatchDeepcopy: + """Every gate records against a per-match deepcopy, so the copy must carry the declaration's uuid.""" + + def test_the_deepcopy_keeps_the_declarations_uuid(self) -> None: + declared = SingleFilter(_filter_feature(), FilterType.EQUAL, {"value": 1}) + + assert deepcopy(declared).uuid == declared.uuid, "the per-match copy must keep the declaration's identity" class TestTheDeepestFactKeepsTheKey: diff --git a/tests/test_core/test_filter/test_filter_matcher_containment.py b/tests/test_core/test_filter/test_filter_matcher_containment.py index 4bca9d01..110828c8 100644 --- a/tests/test_core/test_filter/test_filter_matcher_containment.py +++ b/tests/test_core/test_filter/test_filter_matcher_containment.py @@ -10,6 +10,7 @@ import gc import logging from collections.abc import Callable +from copy import deepcopy from dataclasses import dataclass from functools import partial from typing import Any, Optional, TypeVar, cast @@ -274,6 +275,8 @@ def _drive_criteria( caplog.clear() fg = make_fg() global_filter = GlobalFilter() + # The engine probes a per-match deepcopy of one declaration, so all `calls` share one ledger key. + declared = _single(filter_feature_name, options) ledger: Optional[dict[Any, Any]] = None items: list[tuple[Any, Any]] = [] try: @@ -281,9 +284,7 @@ def _drive_criteria( escaped: Optional[str] = None with caplog.at_level(logging.DEBUG, logger=GF_LOGGER_NAME): for _ in range(calls): - value, failure = _capture_type_name( - partial(global_filter.criteria, fg, _single(filter_feature_name, options), None) - ) + value, failure = _capture_type_name(partial(global_filter.criteria, fg, deepcopy(declared), None)) results.append(value) if failure is not None: escaped = failure @@ -294,7 +295,7 @@ def _drive_criteria( results=tuple(results), escaped=escaped, has_ledger=ledger is not None, - keyed_by_group_and_filter=ledger is not None and (fg, filter_feature_name) in ledger, + keyed_by_group_and_filter=ledger is not None and (fg, filter_feature_name, declared.uuid) in ledger, entries=tuple((str(key[0].get_class_name()), str(key[1])) for key, _ in items), reasons=tuple(str(_reason_of(recorded)) for _, recorded in items), reason_types=tuple(type(_reason_of(recorded)).__name__ for _, recorded in items), @@ -302,7 +303,7 @@ def _drive_criteria( debugs=_messages(caplog, logging.DEBUG), ) finally: - del fg, global_filter, ledger, items + del fg, global_filter, declared, ledger, items gc.collect() @@ -317,7 +318,7 @@ def test_fresh_global_filter_records_no_drops(self) -> None: assert ledger == {}, f"a fresh GlobalFilter has dropped nothing, got: {ledger!r}" def test_contained_raise_records_group_filter_and_reason(self, caplog: pytest.LogCaptureFixture) -> None: - """One entry, keyed by (feature group, filter feature name), carrying the WARNING's own reason text.""" + """One entry, keyed by (feature group, filter feature name, filter uuid), carrying the WARNING's reason.""" snapshot = _drive_criteria(_make_raising_filter_matcher_fg, FILTER_FEATURE_RAISING, caplog) assert snapshot.escaped is None, f"the raise must not cross GlobalFilter.criteria: {snapshot.escaped}" diff --git a/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py b/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py index d16509e0..d678e5f6 100644 --- a/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py +++ b/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py @@ -9,6 +9,7 @@ import gc import logging from collections.abc import Callable +from copy import deepcopy from dataclasses import dataclass, is_dataclass from functools import partial from typing import Any, ClassVar, TypeVar @@ -493,13 +494,15 @@ def _drive_criteria(make: _RtxFactory, caplog: pytest.LogCaptureFixture, calls: caplog.clear() fg, read_window = make() global_filter = GlobalFilter() + # The engine probes a per-match deepcopy of one declaration, so all `calls` share one ledger key. + declared = _single(FILTER_FEATURE) items: list[tuple[Any, Any]] = [] try: value: Any = None escaped: str | None = None with caplog.at_level(logging.DEBUG, logger=GF_LOGGER_NAME): for _ in range(calls): - value, call_escaped = _capture(partial(global_filter.criteria, fg, _single(FILTER_FEATURE), None)) + value, call_escaped = _capture(partial(global_filter.criteria, fg, deepcopy(declared), None)) escaped = escaped or call_escaped items = sorted(global_filter.dropped_filters.items(), key=lambda item: str(item[0])) return _RtxCriteriaSnapshot( @@ -513,7 +516,7 @@ def _drive_criteria(make: _RtxFactory, caplog: pytest.LogCaptureFixture, calls: window_active=read_window(), ) finally: - del fg, read_window, global_filter, items + del fg, read_window, global_filter, declared, items gc.collect() diff --git a/tests/test_core/test_filter/test_filter_matcher_truthiness.py b/tests/test_core/test_filter/test_filter_matcher_truthiness.py index e3db24ce..27925212 100644 --- a/tests/test_core/test_filter/test_filter_matcher_truthiness.py +++ b/tests/test_core/test_filter/test_filter_matcher_truthiness.py @@ -15,6 +15,7 @@ import gc import logging from collections.abc import Callable +from copy import deepcopy from dataclasses import dataclass from functools import partial from typing import Any, Optional, TypeVar @@ -201,12 +202,14 @@ def _drive_criteria(returned: Any, caplog: pytest.LogCaptureFixture, calls: int caplog.clear() fg = _make_non_bool_matcher_fg(returned) global_filter = GlobalFilter() + # The engine probes a per-match deepcopy of one declaration, so all `calls` share one ledger key. + declared = _single(FILTER_FEATURE) try: value: Any = None escaped: Optional[str] = None with caplog.at_level(logging.DEBUG, logger=GF_LOGGER_NAME): for _ in range(calls): - value, escaped = _capture(partial(global_filter.criteria, fg, _single(FILTER_FEATURE), None)) + value, escaped = _capture(partial(global_filter.criteria, fg, deepcopy(declared), None)) return _CriteriaSnapshot( is_false=value is False, is_true=value is True, @@ -218,7 +221,7 @@ def _drive_criteria(returned: Any, caplog: pytest.LogCaptureFixture, calls: int debugs=_messages(caplog, logging.DEBUG), ) finally: - del fg, global_filter + del fg, global_filter, declared gc.collect() From f2eb62c3ab053a2117ce05fb9dc52f7d50a55144 Mon Sep 17 00:00:00 2001 From: Tom Kaltofen Date: Fri, 7 Aug 2026 08:28:40 +0000 Subject: [PATCH 2/2] fix: dedupe the filter drop reports per column, not per declaration The report lines render from the feature group, the reason and the filter feature name alone, so two declarations on one column produce byte-identical warnings. The dedupe goes back to the column key while the ledger keeps its per-declaration fact, which it now records unconditionally. The unmatched warning sorts on the filter feature name first and uses the rendered message only as the tie-break, so a name that prefixes another keeps its old position. criteria() states that the filter it is handed must be a declared filter or a per-match copy, since the ledger keys on its uuid, and the docs name SingleFilter.uuid as the join back to the declaration. --- docs/docs/in_depth/filter_data.md | 4 ++ mloda/core/filter/global_filter.py | 36 +++++++++---- .../test_filter_elimination_reasons.py | 54 ++++++++++++++----- .../test_filter_matcher_containment.py | 2 +- .../test_filter_matcher_rejection_taxonomy.py | 8 +-- .../test_filter_matcher_truthiness.py | 2 +- 6 files changed, 75 insertions(+), 31 deletions(-) diff --git a/docs/docs/in_depth/filter_data.md b/docs/docs/in_depth/filter_data.md index 7cefc655..d289fa28 100644 --- a/docs/docs/in_depth/filter_data.md +++ b/docs/docs/in_depth/filter_data.md @@ -258,6 +258,10 @@ ordinary non-match and records nothing. A matcher defect takes the key from a st otherwise the deepest gate the filter reached keeps it, and two facts at one depth leave the first one in place. +The uuid is `SingleFilter.uuid` of the declaration in `GlobalFilter.filters`, which is what joins a +recorded fact back to the filter that lost. The key previously carried no uuid, so code unpacking it +as `for (feature_group, name), elimination in ...` must now unpack three parts. + A filter that matches no FeatureGroup at all is reported once after setup, with its nearest miss appended when one was recorded: across FeatureGroups the deepest gate wins, and a matcher defect ranks last. diff --git a/mloda/core/filter/global_filter.py b/mloda/core/filter/global_filter.py index 9ec9fdda..f272be5a 100644 --- a/mloda/core/filter/global_filter.py +++ b/mloda/core/filter/global_filter.py @@ -47,6 +47,11 @@ # One declared filter against one feature group: the name alone collapses two filters on one column. _LedgerKey = tuple[type[FeatureGroup], str, UUID] +# Both reports render from (feature group, reason, filter name) only, so two declarations on one column produce +# byte-identical lines and repeating one adds no signal. Nothing is lost: the ledger records per declaration +# unconditionally and no longer derives its write from the report dedupe. +_ReportKey = tuple[type[FeatureGroup], str] + def _copy_feature_leaf(value: Any) -> Any: """Detach a Feature leaf from the host; any other leaf stays shared by reference.""" @@ -77,10 +82,10 @@ def __init__(self) -> None: self.probes: dict[tuple[type[FeatureGroup], FeatureName, UUID], set[SingleFilter]] = {} self.matched_filter_uuids: set[UUID] = set() self._warned_divergences: set[str] = set() - # Own state, not dropped_filters: a falsy non-bool is an ordinary non-match, never a recorded drop. - self._reported_falsy_matches: set[_LedgerKey] = set() - # WARNING dedupe for defect drops; the ledger itself no longer decides first-ness. - self._warned_drops: set[_LedgerKey] = set() + # Per column, not per declaration: own state, and a falsy non-bool is an ordinary non-match, never a drop. + self._reported_falsy_matches: set[_ReportKey] = set() + # Per column, not per declaration: a WARNING dedupe; the ledger itself no longer decides first-ness. + self._warned_drops: set[_ReportKey] = set() def reset_match_tracking(self) -> None: """Every match report is scoped to one engine setup, so a later setup names only what it consulted. @@ -206,7 +211,7 @@ def identify_matched_filters( def warn_on_unmatched_filters(self) -> None: """Warn once per filter that matched no feature group this setup, naming its nearest miss.""" - messages: list[str] = [] + messages: list[tuple[str, str]] = [] for filter in self.filters: if filter.uuid in self.matched_filter_uuids: continue @@ -214,9 +219,10 @@ def warn_on_unmatched_filters(self) -> None: nearest = self._nearest_miss(filter.uuid) if nearest is not None: message += f" Nearest miss: {nearest}" - messages.append(message) - # `filters` is a set and two filters can share a name, so only the rendered message orders them stably. - for message in sorted(messages): + messages.append((filter.name, message)) + # `filters` is a set, so the emission is sorted. The name stays primary; the rendered message only breaks + # the tie, which is what makes two filters sharing one name deterministic. + for _, message in sorted(messages): logger.warning(message) def _nearest_miss(self, filter_uuid: UUID) -> str | None: @@ -316,6 +322,8 @@ def criteria( gates under the probe's window. Mark-or-contain policy: see call_match_hook. + `filter` must be one of this GlobalFilter's declared filters or a per-match copy of one: the drop ledger + keys on its uuid. """ probe = probe_match_criteria( feature_group, @@ -340,6 +348,11 @@ def _ledger_key(feature_group: type[FeatureGroup], filter: SingleFilter) -> _Led """The declaring filter's own key; the per-match deepcopy carries the declaration's uuid.""" return feature_group, filter.name, filter.uuid + @staticmethod + def _report_key(feature_group: type[FeatureGroup], filter: SingleFilter) -> _ReportKey: + """The column's key, uuid-free: what a report line can tell apart, unlike `_ledger_key`.""" + return feature_group, filter.name + def _record_near_miss( self, feature_group: type[FeatureGroup], filter: SingleFilter, stage: EliminationStage, reason: str ) -> None: @@ -372,8 +385,9 @@ def _record_dropped_filter(self, feature_group: type[FeatureGroup], filter: Sing stored = self.dropped_filters.get(key) if stored is None or stored.stage != "matcher_error": self.dropped_filters[key] = Elimination(stage="matcher_error", reason=reason) - first = key not in self._warned_drops - self._warned_drops.add(key) + report_key = self._report_key(feature_group, filter) + first = report_key not in self._warned_drops + self._warned_drops.add(report_key) logger.log( logging.WARNING if first else logging.DEBUG, "%s %s while matching filter feature '%s'; dropping that filter for this feature group.", @@ -387,7 +401,7 @@ def _report_falsy_match(self, feature_group: type[FeatureGroup], filter: SingleF Both fields are plugin-owned reads and this runs past the hook call's containment, so each degrades alone. """ - key = self._ledger_key(feature_group, filter) + key = self._report_key(feature_group, filter) first = key not in self._reported_falsy_matches self._reported_falsy_matches.add(key) logger.log( diff --git a/tests/test_core/test_filter/test_filter_elimination_reasons.py b/tests/test_core/test_filter/test_filter_elimination_reasons.py index 69befb2d..15ef6cfb 100644 --- a/tests/test_core/test_filter/test_filter_elimination_reasons.py +++ b/tests/test_core/test_filter/test_filter_elimination_reasons.py @@ -57,6 +57,9 @@ DROP_REPORT_FRAGMENT = "dropping that filter" # the drop report's own wording, which no unmatched warning carries BARE_MESSAGE = f"Filter feature '{FILTER_FEATURE}' matched no feature group." +ORDER_FIRST_FEATURE = "fer_order_feat" # sorts before its twin by name +ORDER_SECOND_FEATURE = "fer_order_feat two" # its space beats the other's closing quote, so its message sorts first + RUNTIME_MESSAGE = "fer_runtime_boom" RUNTIME_TYPE_NAME = "RuntimeError" DEFECT_MESSAGE = "fer_defect_after_decline" @@ -137,6 +140,11 @@ def _carrying(messages: Sequence[str], fragment: str) -> tuple[str, ...]: return tuple(message for message in messages if fragment in message) +def _bare_message(filter_feature_name: str) -> str: + """BARE_MESSAGE for another filter feature name, so the sentence is spelled once.""" + return BARE_MESSAGE.replace(FILTER_FEATURE, filter_feature_name) + + def _stage_reason(stage: str) -> str: """The reason text the stage-recording probe stores for one recorded hint.""" return f"fer_stage_reason_{stage}" @@ -1105,6 +1113,7 @@ class TestTwoFiltersOnOneNameAreAttributedSeparately: """The ledger keys on the declaring filter's uuid, so a shared name still attributes each fact to its filter.""" def test_the_ledger_key_carries_the_declaring_filters_uuid(self, caplog: pytest.LogCaptureFixture) -> None: + """The ABSENT_UUID assert guards the arity: with the cast in `_ledger_keys`, a key losing its uuid fails here.""" snapshot = _drive_shared_name(caplog, _losing_pair()) assert snapshot.escaped is None, f"nothing may cross identify_matched_filters: {snapshot.escaped}" @@ -1154,10 +1163,29 @@ def test_the_pair_renders_the_same_way_on_every_run(self, caplog: pytest.LogCapt assert set(runs) == {_losing_pair_messages()}, f"the runs must emit both messages sorted: {sorted(set(runs))}" -class TestTheReportLedgersAreKeyedPerDeclaration: - """The report dedupe follows the fact it guards, so a shared name never mutes the second declaration.""" +class TestTheUnmatchedWarningsAreOrderedByFilterFeatureName: + """The name orders the warnings; the rendered message only breaks the tie between filters sharing a name.""" + + def test_the_name_orders_the_warnings_not_the_rendered_message(self, caplog: pytest.LogCaptureFixture) -> None: + """Two names whose messages sort the other way round, so an order taken from the message shows up here.""" + expected = (_bare_message(ORDER_FIRST_FEATURE), _bare_message(ORDER_SECOND_FEATURE)) + assert tuple(sorted(expected)) != expected, "the messages must sort the other way, else this pins nothing" - def test_two_defects_on_one_name_record_and_warn_twice(self, caplog: pytest.LogCaptureFixture) -> None: + snapshot = _drive( + [_make_plain_fg], + caplog, + filter_features=(ORDER_FIRST_FEATURE, ORDER_SECOND_FEATURE), + warn_unmatched=True, + ) + + assert snapshot.unmatched == expected, f"the filter feature name must order the warnings: {snapshot.unmatched}" + + +class TestTheReportsDedupePerColumnNotPerDeclaration: + """Both reports render from the group, the reason and the name alone, so a second declaration adds no line.""" + + def test_two_defects_on_one_name_record_twice_and_report_once(self, caplog: pytest.LogCaptureFixture) -> None: + """The ledger keeps its fact per declaration; the byte-identical repeat of the report falls to DEBUG.""" snapshot = _drive_shared_name(caplog, (FILTER_FEATURE, FILTER_FEATURE), makes=(_make_matcher_error_fg,)) assert snapshot.escaped is None, f"nothing may cross identify_matched_filters: {snapshot.escaped}" @@ -1166,23 +1194,21 @@ def test_two_defects_on_one_name_record_and_warn_twice(self, caplog: pytest.LogC assert {row[2] for row in snapshot.rows} == {MATCHER_ERROR_STAGE}, ( f"both declarations lost to the same defect, got: {snapshot.rows}" ) - assert len(_carrying(snapshot.warnings, DROP_REPORT_FRAGMENT)) == 2, ( - f"each dropped declaration must be reported, got: {snapshot.warnings}" - ) - assert _carrying(snapshot.debugs, DROP_REPORT_FRAGMENT) == (), ( - f"the second declaration must not be downgraded to DEBUG, got: {snapshot.debugs}" + reported = _carrying(snapshot.warnings, DROP_REPORT_FRAGMENT) + assert len(reported) == 1, f"the column's drop must be reported once, got: {snapshot.warnings}" + assert _carrying(snapshot.debugs, DROP_REPORT_FRAGMENT) == reported, ( + f"the repeat is that same line and belongs at DEBUG, got: {snapshot.debugs}" ) - def test_two_falsy_returns_on_one_name_are_each_reported(self, caplog: pytest.LogCaptureFixture) -> None: + def test_two_falsy_returns_on_one_name_are_reported_once(self, caplog: pytest.LogCaptureFixture) -> None: snapshot = _drive_shared_name(caplog, (FILTER_FEATURE, FILTER_FEATURE), makes=(_make_falsy_decline_fg,)) assert snapshot.escaped is None, f"nothing may cross identify_matched_filters: {snapshot.escaped}" assert snapshot.rows == (), f"a falsy non-bool is a non-match, never a fact, got: {snapshot.rows}" - assert len(_carrying(snapshot.warnings, FALSY_REPORT_FRAGMENT)) == 2, ( - f"each detached declaration must be reported, got: {snapshot.warnings}" - ) - assert _carrying(snapshot.debugs, FALSY_REPORT_FRAGMENT) == (), ( - f"the second declaration must not be downgraded to DEBUG, got: {snapshot.debugs}" + reported = _carrying(snapshot.warnings, FALSY_REPORT_FRAGMENT) + assert len(reported) == 1, f"the column's detached filter must be reported once, got: {snapshot.warnings}" + assert _carrying(snapshot.debugs, FALSY_REPORT_FRAGMENT) == reported, ( + f"the repeat is that same line and belongs at DEBUG, got: {snapshot.debugs}" ) diff --git a/tests/test_core/test_filter/test_filter_matcher_containment.py b/tests/test_core/test_filter/test_filter_matcher_containment.py index 110828c8..31128c68 100644 --- a/tests/test_core/test_filter/test_filter_matcher_containment.py +++ b/tests/test_core/test_filter/test_filter_matcher_containment.py @@ -324,7 +324,7 @@ def test_contained_raise_records_group_filter_and_reason(self, caplog: pytest.Lo assert snapshot.escaped is None, f"the raise must not cross GlobalFilter.criteria: {snapshot.escaped}" assert snapshot.has_ledger, "GlobalFilter must expose a dropped_filters ledger" assert snapshot.entries == ((UNIT_CLASS_NAME, FILTER_FEATURE_RAISING),), ( - f"exactly one drop, keyed by group and filter feature, got: {snapshot.entries}" + f"exactly one drop, whose key names the group and the filter feature, got: {snapshot.entries}" ) assert snapshot.keyed_by_group_and_filter, "the key must be the group CLASS, not its name or a stand-in" assert snapshot.reason_types == ("str",), ( diff --git a/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py b/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py index d678e5f6..bd1d2e07 100644 --- a/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py +++ b/tests/test_core/test_filter/test_filter_matcher_rejection_taxonomy.py @@ -619,7 +619,7 @@ def test_criteria_contains_it_and_stores_exactly_the_rejection_text(self, caplog assert snapshot.escaped is None, f"the raise must not cross GlobalFilter.criteria: {snapshot.escaped}" assert snapshot.is_false, "a rejected value is a non-match for that filter" assert snapshot.entries == ((VALUE_REJECTION_CLASS_NAME, FILTER_FEATURE),), ( - f"exactly one drop, keyed by group and filter feature, got: {snapshot.entries}" + f"exactly one drop, whose key names the group and the filter feature, got: {snapshot.entries}" ) assert snapshot.reasons == (VALUE_REJECT_MESSAGE,), ( f"the drop must hold exactly str(exc), no 'raised' prefix, got: {snapshot.reasons}" @@ -689,7 +689,7 @@ def test_the_first_recorded_reason_outranks_a_later_value_rejection(self, caplog assert snapshot.escaped is None, f"the raise must not cross GlobalFilter.criteria: {snapshot.escaped}" assert snapshot.is_false, "the rejection is still a non-match for that filter" assert snapshot.entries == ((RECORD_THEN_VALUE_CLASS_NAME, FILTER_FEATURE),), ( - f"exactly one drop, keyed by group and filter feature, got: {snapshot.entries}" + f"exactly one drop, whose key names the group and the filter feature, got: {snapshot.entries}" ) assert snapshot.reasons == (REASON_A,), ( f"the FIRST recorded reason wins the drop, not the raise and not a wrapper, got: {snapshot.reasons}" @@ -705,7 +705,7 @@ def test_a_matcher_error_outranks_the_recorded_reason_and_still_warns( assert snapshot.is_false, "a raising hook is a non-match for that filter" assert snapshot.window_active, "the seam must give the matcher an active rejection window" assert snapshot.entries == ((RECORD_THEN_ERROR_CLASS_NAME, FILTER_FEATURE),), ( - f"exactly one drop, keyed by group and filter feature, got: {snapshot.entries}" + f"exactly one drop, whose key names the group and the filter feature, got: {snapshot.entries}" ) reason = snapshot.reasons[0] assert RUNTIME_TYPE_NAME in reason, f"the reason must name the exception type: {reason}" @@ -786,7 +786,7 @@ def test_the_default_hooks_owned_veto_gate_sees_the_filter_seams_window( assert snapshot.escaped is None, f"nothing may cross GlobalFilter.criteria: {snapshot.escaped}" assert snapshot.is_false, "the owned veto must gate the default hook's name rules" assert snapshot.entries == ((OWNED_VETO_CLASS_NAME, FILTER_FEATURE),), ( - f"the veto is a typed drop, keyed by group and filter feature, got: {snapshot.entries}" + f"the veto is a typed drop, whose key names the group and the filter feature, got: {snapshot.entries}" ) assert snapshot.reasons == (OWNED_REASON,), f"the drop must hold the veto's reason, got: {snapshot.reasons}" assert snapshot.warnings == (), f"an owned veto is a verdict, not a defect, got: {snapshot.warnings}" diff --git a/tests/test_core/test_filter/test_filter_matcher_truthiness.py b/tests/test_core/test_filter/test_filter_matcher_truthiness.py index 27925212..37626fd9 100644 --- a/tests/test_core/test_filter/test_filter_matcher_truthiness.py +++ b/tests/test_core/test_filter/test_filter_matcher_truthiness.py @@ -512,7 +512,7 @@ def test_criteria_contains_it_and_records_the_drop(self, caplog: pytest.LogCaptu assert snapshot.escaped is None, f"the raise must not cross GlobalFilter.criteria: {snapshot.escaped}" assert snapshot.is_false, f"an unreadable return is a non-match for that filter, got: {snapshot.shown}" assert snapshot.entries == ((PROBE_CLASS_NAME, FILTER_FEATURE),), ( - f"exactly one drop, keyed by group and filter feature, got: {snapshot.entries}" + f"exactly one drop, whose key names the group and the filter feature, got: {snapshot.entries}" ) assert len(snapshot.warnings) == 1, f"exactly one WARNING must report the drop, got: {snapshot.warnings}" message = snapshot.warnings[0]