Skip to content

fix(gen-shacl): enforce operator exactness, nested-slot parity, numeric bounds#22

Open
jdsika wants to merge 1 commit into
fix/shaclgen-rule-converter-hardeningfrom
fix/shaclgen-rule-converter-audit-findings
Open

fix(gen-shacl): enforce operator exactness, nested-slot parity, numeric bounds#22
jdsika wants to merge 1 commit into
fix/shaclgen-rule-converter-hardeningfrom
fix/shaclgen-rule-converter-audit-findings

Conversation

@jdsika

@jdsika jdsika commented Jul 11, 2026

Copy link
Copy Markdown

Summary

Resolves every substantive finding from the adversarial audit of the
rule-converter stack (see the audit comments on #19, #20, #21). One commit,
21 regression tests; 20 fail on the pre-fix source (the 21st locks in
already-correct zero-member has_member semantics that had no coverage).

Audit finding Fix
A1/B2/C2 — greedy operator dispatch (conditions mixing recognized + unrecognized operators, expression-level any_of/all_of/none_of/exactly_one_of, co-set equals_string + equals_string_in, value_presence: ABSENT combined with a bound — all partially translated, dropping conjuncts → demonstrated false positives) New _set_operator_fields enumerates the constraint-bearing fields actually set on a condition / class expression (metadata excluded; scalars never judged by truthiness, so minimum_value: 0 still counts). Named patterns and the fallback dispatch only when their conditions set exactly the operators they translate; everything else is skipped.
A2 — boolean-guard shadows PIV (equals_string: "true" on a string-range slot hijacked into a boolean comparison, flagging conforming data) Boolean guard dispatches only when the target slot's induced range is boolean; otherwise the rule flows to presence-implies-value, which compares the string.
B1/C1 — inner slots resolved against the outer class (member-class slot_usage slot_uri overrides diverged from sh:path → constraints silently never fired; name collisions with outer-class overrides made FILTER NOT EXISTS vacuously true → false positives) _member_conditions resolves the container slot in the outer class's induced context, requires its range to be a class, and resolves inner slot URIs and enum values against that range class. Container slot_usage range-narrowing now picks the right enum too (_resolve_member_enum_ref subsumed by passing the range class to _resolve_enum_value_ref).
B3/C3 — non-numeric threshold bounds ("abc" → unparsable sh:select poisoning the whole shapes graph; YAML date → arithmetic 2020-01-01 = 2018, silently never firing; .nan, booleans) _sparql_number returns None for anything but int / finite float (bool excluded) and callers skip the rule. Docstring corrected (the metamodel range is Anything, not int/float).
B4 — elseconditions silently dropped Rules with an else branch log a warning that only the forward direction is enforced, consistent with the bidirectional/open_world warnings.
C4 — alias-form / unknown rule keys (a key my_slot for a slot named my slot, or a typo'd key, fabricated a default_prefix predicate no shape uses → silently vacuous constraint, or an always-firing has_member) New _rule_slot resolves rule keys via the class's induced slots, then the underscored alias form, then the base slot; _slot_uri returns None for unresolvable names and every converter skips the rule.
Test gaps (M5 zero-member untested; vacuous "<Manual>" not in query assertion) Zero-member has_member end-to-end test added; assertion now checks the full IRI form.

Areas of uncertainty

All substantive audit findings, including C4 (alias-form / unknown rule keys),
are addressed here. \b/\f escaping was verified a non-issue (both are
legal raw characters in SPARQL STRING_LITERAL2).

Stack position

main
└─ feat/shaclgen-rules-sparql                      #11 (green)
   └─ feat/shaclgen-presence-implies-value-stacked   #19
      └─ feat/shaclgen-compositional-rule-fallback   #20
         └─ fix/shaclgen-rule-converter-hardening    #21
            └─ fix/shaclgen-rule-converter-audit-findings ← this PR

When upstreaming, fold each fix into the submission that introduces the
affected code (exactness + boolean gate → framework/PIV; nested parity +
numeric gate → fallback). These fixes also apply verbatim to the consolidated
branch of #18 (cherry-pick b9287ac1).

How was this tested?

  • tests/linkml/test_generators/test_shaclgen.py: 127 passed (106 base
    • 21 new; per finding: structural skip assertions, generated-query IRI
      assertions, and pyshacl end-to-end with advanced=True).
  • Negative control: with this branch's tests against the pre-fix
    generator source, 20 of the 21 new tests fail — the tests bite.
  • ruff check and ruff format --check clean on both changed files.

Checklist

  • My code follows the contributor guidelines
  • I have added tests that prove my fix/feature works
  • Existing tests pass locally with my changes

AI Assistance

If you used AI tools while preparing this PR, you are still the author and responsible for understanding, verifying, and defending your submission. Please engage with reviewers personally rather than through your agent during feedback and revisions. See our AI Covenant for details.

…ic bounds

Resolve the adversarial-audit findings on the rule-converter stack. Every
fix carries regression tests; 18 of the 19 new tests fail on the pre-fix
source (the 19th locks in already-correct zero-member has_member
semantics that had no coverage).

- Operator exactness (audit A1/B2/C2): named patterns and the
  compositional fallback now require their conditions to set EXACTLY the
  operators they translate. A new _set_operator_fields helper enumerates
  the constraint-bearing fields actually set on a condition or class
  expression (metadata excluded; scalars never judged by truthiness, so
  minimum_value: 0 still counts). Previously a condition mixing a
  recognized operator with an unrecognized one (equals_string + pattern),
  expression-level any_of/all_of/none_of/exactly_one_of, co-set
  equals_string + equals_string_in, or value_presence: ABSENT combined
  with a bound was partially translated, silently dropping conjuncts and
  producing demonstrated false positives. All such rules are now skipped
  (skip, never mis-translate).

- Boolean-guard range gate (audit A2): the boolean-guard pattern only
  dispatches when the target slot's induced range is boolean. An
  equals_string "true" postcondition on a string-range slot previously
  hijacked the boolean comparison and flagged conforming data; it now
  dispatches to presence-implies-value, which compares the string.

- Nested inner-slot parity (audit B1/C1): _member_conditions resolves the
  container slot in the induced context of the outer class, requires its
  range to be a class, and resolves inner slot URIs and enum values
  against that range class. Previously inner slots resolved against the
  OUTER class: a slot_usage slot_uri override on the member class made
  sh:path and the SPARQL body diverge (constraint silently never fired),
  and an inner slot name colliding with an outer slot_usage override
  queried a predicate members never carry, making has_member's FILTER NOT
  EXISTS vacuously true (false positives on conforming data). The
  _resolve_member_enum_ref special case is subsumed by passing the range
  class to _resolve_enum_value_ref.

- Numeric bound gate (audit B3/C3): _sparql_number returns None for
  anything but int / finite float (bool excluded), and callers skip the
  rule. The metamodel range of minimum_value/maximum_value is Anything:
  a string bound produced unparsable SPARQL that poisoned the entire
  shapes graph at validation time, and a YAML date parsed as arithmetic
  (2020-01-01 = 2018), silently never firing. Docstring corrected.

- elseconditions warning (audit B4): rules with an else branch now log a
  warning that only the forward direction is enforced, consistent with
  the bidirectional/open_world warnings.

- Test hygiene: the vacuous "<Manual> not in query" assertion now checks
  the full IRI form; new zero-member has_member end-to-end test.

Signed-off-by: Carlo van Driesten <carlo.van-driesten@vdl.digital>

- Alias-form and unknown rule keys (audit C4): _rule_slot resolves a
  rule condition key via the class's induced slots, then the underscored
  alias form (my_slot for a slot named "my slot"), then the base slot.
  _slot_uri returns None for names that resolve to no slot and every
  converter skips the rule -- previously an unknown or alias-form key
  fabricated a default_prefix predicate no shape uses, silently emitting
  a vacuous constraint (or an always-firing has_member).
@jdsika jdsika force-pushed the fix/shaclgen-rule-converter-hardening branch from 15c4d0d to 4b2ef26 Compare July 11, 2026 10:38
@jdsika jdsika force-pushed the fix/shaclgen-rule-converter-audit-findings branch from d0a7316 to b9287ac Compare July 11, 2026 10:38
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