Skip to content

feat(gen-shacl): add compositional fallback rule converters (M1-M5)#20

Open
jdsika wants to merge 1 commit into
feat/shaclgen-presence-implies-value-stackedfrom
feat/shaclgen-compositional-rule-fallback
Open

feat(gen-shacl): add compositional fallback rule converters (M1-M5)#20
jdsika wants to merge 1 commit into
feat/shaclgen-presence-implies-value-stackedfrom
feat/shaclgen-compositional-rule-fallback

Conversation

@jdsika

@jdsika jdsika commented Jul 10, 2026

Copy link
Copy Markdown

Summary

Adds a compositional fallback to the rules → SHACL-SPARQL converter for
rule-operator combinations outside the three named patterns (boolean guard,
presence-implies-value, exclusive value). The fallback is tried only after
the named patterns, so their output is unchanged. It translates a conjunction
of precondition slot conditions plus a single postcondition into one
SELECT $this violation query. The safety contract is skip, never
mis-translate
: any unsupported operator makes the converter return None
and the rule is logged at DEBUG and skipped.

Supported combinations

ID Pattern Violation SPARQL
M1 conditional-required: equals_string / value_presence: PRESENT precondition → required: true precondition holds ∧ FILTER NOT EXISTS on the target slot
M2 conditional-absent: → value_presence: ABSENT precondition holds ∧ the forbidden slot is present
M3 numeric threshold preconditions: minimum_value / maximum_value (inclusive, per the LinkML metamodel) on the trigger slot numeric FILTER bound, combinable with any supported postcondition
M4 nested precondition: one hop into an inlined child via range_expression.slot_conditions (e.g. sun_position.elevation <= 0) binds the child node, applies inner conditions via the shared _member_conditions emitter
M5 has_member list-membership: a multivalued slot must contain a member matching a nested range_expression FILTER NOT EXISTS over the members

M4/M5 introduce _member_conditions (shared inner-condition emitter) and
_resolve_member_enum_ref, which resolves inner enum values against the
container slot's range class, so slot_usage-specialised enums pick the
correct permissible values.

Stack position

Base branch: feat/shaclgen-presence-implies-value-stacked — only the
fallback delta shows in the diff.

main
└─ feat/shaclgen-rules-sparql                      #11 (green)
   └─ feat/shaclgen-presence-implies-value-stacked   (PR: presence-implies-value)
      └─ feat/shaclgen-compositional-rule-fallback  ← this PR
         └─ fix/shaclgen-rule-converter-hardening     (follow-up)

Provenance

Squashed, content-identical, from the five M1–M5 commits on
feat/shacl-rule-converters (#18: 7566e30c, fac681e0, f8ea709d,
25f8cd28, 47769772), where the full CI matrix is green. Original
authorship preserved.

Areas of uncertainty

Skipped (never mis-translated), documented as follow-up candidates:

  • equals → equals postconditions ("if A=x then B=y") are not yet composed.
  • A precondition mixing a range_expression with scalar operators resolves
    via the nested branch only.
  • Multiple postconditions (more than one slot condition) are skipped; a rule's
    postconditions are a conjunction, so the single-postcondition case covers
    the modeled cross-parameter rules.
  • First-match operator dispatch on a single condition (e.g.
    {minimum_value, maximum_value} keeping only one bound) is a known defect
    fixed in the stacked follow-up fix/shaclgen-rule-converter-hardening,
    kept separate so the defect and its regression tests stay reviewable.

How was this tested?

  • tests/linkml/test_generators/test_shaclgen.py on this branch:
    100 passed (base suites + 16 new tests; per converter: structural triple
    assertions, prepareQuery syntax validation, pyshacl end-to-end with
    advanced=True — conforming instances pass, crafted violations fail).
  • 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.

@jdsika

jdsika commented Jul 10, 2026

Copy link
Copy Markdown
Author

Adversarial audit findings (fallback converters, audited at stack tip incl. the hardening PR)

Findings demonstrated with pyshacl end-to-end probes against the stack tip; all apply equally to the consolidated branch of #18.

B1 — real bug: M4/M5 inner-slot paths resolve against the OUTER class.
_member_conditions calls self._slot_uri(sv, inner_name, cls) with the container class, but the inner slot lives on the container slot's range class (the neighboring _resolve_member_enum_ref gets this right for enums; the path resolution does not). Two demonstrated failure modes:

  • False negative: child class overrides the inner slot's slot_uri via slot_usagesh:path on the child shape uses the overridden IRI, the SPARQL body queries the base IRI → the constraint silently never fires.
  • False positive: the inner slot name also exists on the outer class with a different slot_usage URI → the outer induced slot wins, the member pattern queries a predicate no member has, FILTER NOT EXISTS is vacuously true → conforming data flagged.
    Related: _resolve_member_enum_ref uses sv.get_slot(container_slot_name) (non-induced), so a slot_usage range-narrowing of the container slot on the outer class resolves enum values against the wrong enum.
    Fix direction: resolve inner_name (and the container range) against the container slot's induced range class, mirroring what _resolve_member_enum_ref does for enums.

B2 — real bug: recognized+unrecognized operator mixes are silently under-translated → false positives (contract violation).
The hardening PR fixed combining of recognized scalar operators, but a condition mixing a recognized with an unrecognized operator still translates with the unrecognized conjunct dropped: {equals_string: fog, pattern: "^f.*"} emits only the equals filter; expression-level any_of/all_of/none_of on pre/postconditions are ignored entirely (demonstrated: dropping an any_of precondition conjunct widened the trigger and pyshacl flagged a conforming instance). Postcondition side drops conjuncts too ({required: true, pattern: ...} → only required).
Fix direction: enumerate the set fields on each condition/expression and return None (skip) if anything outside the supported set is present.

B3 — real bug (artifact-poisoning edge): _sparql_number renders non-numeric bounds raw.
minimum_value/maximum_value have metamodel range Anything (the docstring's "LinkML parses as int or float" is incorrect). Demonstrated: minimum_value: "abc"FILTER ( ?pre0 >= abc )pyshacl raises ParseException on every validation run against the generated shapes graph (one bad bound poisons validation of all data); a YAML date 2020-01-01 parses as the arithmetic expression 2020−01−01 = 2018 → constraint silently never fires; .nan/.inf also unparseable.
Fix direction: gate on isinstance(value, (int, float)) and not isinstance(value, bool) (covers the extended_* runtime subclasses) and propagate a skip otherwise.

B4 — edge case: elseconditions silently dropped.
bidirectional/open_world rules get explicit logger.warnings, but a rule with elseconditions is translated forward-only with no signal at all (the rule isn't skipped, so the DEBUG skip log doesn't fire). Demonstrated: a node failing only the else branch conforms.
Fix direction: warn (or skip) when elseconditions is set, consistent with the neighboring warnings.

Verified clean (attacked, held up): M2 conjunction semantics for multivalued slots (correct existential violation, deduplicated reports); M3 numeric promotion for well-typed data; M5 zero-member semantics (flagged, as "must contain a member" requires — though no test locks this in: suggest adding a zero-member violating instance); variable namespaces (?pre{i}/?post/?mem) are collision-free; deactivated/bidirectional handled before the fallback; pure value_presence: ABSENT preconditions are correctly rejected (skip).

Suggest addressing B1–B3 as a follow-up commit on this stack before upstreaming; the probe scripts are reusable as regression-test seeds.

@jdsika

jdsika commented Jul 11, 2026

Copy link
Copy Markdown
Author

The substantive audit findings above are resolved in #22 (fix/shaclgen-rule-converter-audit-findings, stacked on this series) — one commit, 19 regression tests, 18 of which fail on the pre-fix source. See the finding→fix table in the #22 description.

Add a compositional fallback in _rule_to_sparql for rule-operator
combinations outside the three named patterns (boolean guard,
presence-implies-value, exclusive value). Tried only after the named
patterns, so their output is unchanged. The fallback translates a
conjunction of precondition slot conditions plus a single postcondition
into one SELECT $this violation query; any unsupported operator makes
the converter return None -- skip, never mis-translate.

Supported combinations:

- M1 conditional-required: equals_string / value_presence: PRESENT
  precondition + required: true postcondition (violation = FILTER NOT
  EXISTS on the target slot).
- M2 conditional-absent: value_presence: ABSENT postcondition
  (violation = the forbidden slot is present).
- M3 numeric threshold preconditions: minimum_value / maximum_value
  inclusive bounds on the trigger slot, rendered via _sparql_number.
- M4 nested precondition: one hop into an inlined child object via
  range_expression.slot_conditions (e.g. sun_position.elevation <= 0);
  adds _member_conditions (shared inner-condition emitter) and
  _resolve_member_enum_ref, which resolves inner enum values against
  the container slot's range class (handles slot_usage-specialised
  enums).
- M5 has_member list-membership: a multivalued slot must contain a
  member matching a nested range_expression (violation = FILTER NOT
  EXISTS over the members); reuses _member_conditions.

Tests per converter: structural triple assertions, prepareQuery syntax
validation, and pyshacl end-to-end (conforming instances pass, crafted
violations fail) with advanced=True.

Squashed from the five M1-M5 commits on feat/shacl-rule-converters
(7566e30, fac681e, f8ea709, 25f8cd2, 4776977).

Signed-off-by: Carlo van Driesten <carlo.van-driesten@vdl.digital>
@jdsika jdsika force-pushed the feat/shaclgen-presence-implies-value-stacked branch from 2e56a36 to 8999ad6 Compare July 11, 2026 10:38
@jdsika jdsika force-pushed the feat/shaclgen-compositional-rule-fallback branch from 8442eb8 to 1ea1ea2 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.

2 participants