Skip to content

[FEATURE]: Studio: rules that differ only in filter are treated as duplicates (and the second one silently stays in draft) #1576

Open

Description

@anasanchezss9

Is there an existing issue for this?

  • I have searched the existing issues

Problem statement

Found while building a regulatory data-quality demo with DQX Studio (studio-v0.1.0-rc.4; the code below is unchanged on main as of b645540).

Studio's duplicate detection ignores the filter of a check. Two rules that run the same check on the same column but on different row subsets are treated as the same rule. Example:

- criticality: error
  check:
    function: is_in_list
    arguments: {column: status, allowed: [OK, PENDING]}
  filter: error_code = 100

- criticality: error
  check:
    function: is_in_list
    arguments: {column: status, allowed: [OK, PENDING]}
  filter: error_code = 200

These are two different rules, and DQX core and the Studio Registry both treat them that way. Two Studio code paths do not:

1. Submit/publish guard. _check_no_duplicate_pending_or_approved compares only the inner check block (function + arguments), so the top-level filter is not part of the comparison:

def _check_no_duplicate_pending_or_approved(self, entry: RuleCatalogEntry) -> None:
"""Raise ValueError if another pending/approved rule with the same check exists."""
check_sig = json.dumps(entry.checks[0].get("check", {}) if entry.checks else {}, sort_keys=True)
siblings = self.list_rules_for_table(entry.table_fqn)
for sibling in siblings:
if sibling.rule_id == entry.rule_id:
continue
if sibling.status not in ("pending_approval", "approved"):
continue
sib_sig = json.dumps(sibling.checks[0].get("check", {}) if sibling.checks else {}, sort_keys=True)
if sib_sig == check_sig:
raise ValueError(
f"A duplicate rule with the same check already exists "
f"(rule_id={sibling.rule_id}, status={sibling.status}). "
f"Cannot submit a duplicate for approval."

When a monitored table is published, its checks are moved draft → pending_approval one at a time. The second check fails with ValueError, which is only logged and skipped. The check silently stays in draft, and the UI only receives a count of submitted checks:

def _transition_binding_checks(
monitored_tables_svc: MonitoredTableService,
rules_catalog: RulesCatalogService,
binding_id: str,
*,
from_status: str,
to_status: str,
user_email: str,
) -> int:
"""Move every materialized check of *binding_id* from *from_status* to *to_status*.
Reuses ``RulesCatalogService.set_status`` (the per-rule transition path)
so nothing about a check's audit trail differs from a hand-submitted one.
Per-row failures (e.g. a duplicate-pending guard) are logged and skipped
rather than aborting the whole binding — mirroring
``RulesCatalogService.set_status_by_table``'s resilience. Returns the
count of checks actually transitioned.
"""
count = 0
for rule_id, status in monitored_tables_svc.list_materialized_rule_statuses(binding_id):
if status != from_status:
continue
try:
rules_catalog.set_status(rule_id, to_status, user_email)
count += 1
except Exception: # one bad row must not abort the whole binding
logger.warning(
"Failed to transition materialized check %s (%s -> %s) for binding %s",
rule_id,
from_status,
to_status,
binding_id,
exc_info=True,
)
return count

2. Batch import with skip_duplicates. Reuse is keyed on shape_key, which hashes mode, polarity, body, slots and parameters, but not definition.filter:

def shape_key(mode: str, definition: RuleDefinition, polarity: str | None) -> str:
"""Hash of the rule's structure with slots renamed to their position."""
ordered = _ordered_slots(definition.slots)
positional = {s.name: f"__slot_{i}" for i, s in enumerate(ordered)}
body = _rename_placeholders(definition.body, positional) if mode in _RENAMEABLE_MODES else definition.body
payload = {
"mode": mode,
"polarity": polarity,
"body": body,
"slots": (
[positional[s.name] + ":" + s.cardinality for s in ordered]
if mode in _RENAMEABLE_MODES
else [s.name + ":" + s.cardinality for s in ordered]
),
"parameters": sorted(
({"name": p.name, "type": p.type, "value": p.value} for p in definition.parameters),
key=lambda p: p["name"],
),
}
return hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()

try:
key: str | None = None
if body.skip_duplicates:
key = shape_key(rule_in.mode, rule_in.definition, rule_in.polarity)
match = seen_in_batch.get(key) or existing_by_shape.get(key)
if match is None:
exact = svc.get_active_rule_by_fingerprint(
svc.compute_definition_fingerprint(rule_in.mode, rule_in.definition, rule_in.polarity)
)
if exact is not None and (
not body.generalize_slots or exact.source != "import" or _has_neutral_slots(exact)
):
match = RegistryRuleOut.from_domain(exact)
if match is not None:
renames = slot_renames_between(rule_in.definition, match.definition)
reused.append(
CreateRegistryRuleOut(
rule=match,
input_index=index,
slot_renames={k: v for k, v in renames.items() if k != v},
)
)
seen_in_batch[key] = match
continue

The second rule is reported as "reused" and replaced by the first, so it ends up with the first rule's filter (error_code = 100 instead of error_code = 200).

In contrast, the other identity functions include the filter:

Ignoring the rule name in the duplicate check is intentional and fine. As the _check_signature docstring explains, profiler-generated and manually created rules with the same check should match:

@classmethod
def _check_signature(cls, check: dict[str, Any]) -> str:
"""Produce a normalised, case-insensitive identity for a check.
Only the function name and semantically meaningful arguments are
included. Options like ``trim_strings``, ``name``, ``weight``, and
``criticality`` are excluded so that profiler-generated rules and
manually-created rules with the same function + column are treated as
duplicates.
"""
inner = check.get("check", check)
fn = inner.get("function", "")
raw_args = inner.get("arguments", {})

The problem is only the filter.

Proposed Solution

  1. Include the normalized filter in the duplicate comparison of _check_no_duplicate_pending_or_approved (and in _check_signature, which find_duplicates uses and which also ignores it). Keep ignoring name, criticality, etc.
  2. Include definition.filter in shape_key, so skip_duplicates only collapses rules that also share the same filter. This would match registry_fingerprint.
  3. When a check of a monitored table is not submitted/published (duplicate guard or any other per-row failure in _transition_binding_checks), return the skipped checks and the reason in the response and show them in the UI, instead of only logging a warning.

Suggested tests: two checks that differ only in filter both reach pending_approval on table submit; a batch import with skip_duplicates=true of two rules that differ only in filter creates two rules; two checks that differ only in name are still flagged as duplicates.

Additional Context

filter is part of what a rule checks: it selects the rows the check runs on. A common pattern in regulatory data-quality checks is to apply the same domain check to different record types selected by a code column.

Activity

  1. mwojtyczka commented on Oct 15, 2026

    @mwojtyczka
    Contributor

    @anasanchezss9 thank you for reporting this. Would you like to work on this?

  2. OGordon100 commented on Oct 19, 2026

    @OGordon100
    Contributor
  3. self-assigned this
    on Oct 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

DQX AppFeature/Bug related to the DQX AppbugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions