Skip to content

feat(gen-shacl): add a compositional fallback for rule-to-SPARQL conversion - #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 a compositional fallback for rule-to-SPARQL conversion#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

The named-pattern converters only recognise whole-rule shapes, so a rule one
operator away from a known pattern produced no constraint at all — silently.
This adds a compositional fallback, tried only after every named pattern has
declined, that builds the query from the operators actually present rather than
from a fixed template.

Preconditions become a conjunction of graph patterns and FILTERs; the single
postcondition becomes its negation. Together they select focus nodes that
satisfy every precondition while violating the postcondition — exactly the
SHACL-SPARQL violation contract of
SHACL §5.3.1, with
$this pre-bound to the focus node.

Because the named patterns are tried first, their output is unchanged.

What it covers

Pattern Trigger
Conditional required postcondition required: true
Conditional absent postcondition value_presence: ABSENT
Numeric threshold precondition minimum_value / maximum_value
Nested precondition one hop into an inlined child via range_expression.slot_conditions
List membership has_member

...in any combination the operators allow. Operator support is declared per
operator, and the builder returns None — skipping the rule — as soon as it
meets one it does not handle, so an unsupported combination is never partially
translated.

Soundness of interpolated values

minimum_value / maximum_value have metamodel range Anything, so YAML
strings, dates, booleans and .nan / .inf reach the generator unchanged.
Interpolating them raw was unsound in two distinct ways:

  • "abc" produced unparsable SPARQL, which poisons the entire shapes graph
    at validation time — not just the offending rule.
  • A date such as 2020-01-01 parsed as the arithmetic expression
    2020-01-01 = 2018 and so silently never fired.

Only int and finite float are rendered now; anything else skips the rule.
String literals are escaped rather than interpolated, and a slot carrying both
minimum_value and maximum_value yields both bounds instead of only the
first.

Nested and member slots resolve against the range class of their container,
so an inner slot is no longer shadowed by a same-named slot on the outer class,
and a range narrowed through slot_usage resolves its enum permissible values
from the narrowed range.

Stack position

main
└─ #19  presence-implies-value
   └─ #20  compositional fallback (M1–M5)   ← this PR
      └─ #23  documentation

Important

Depends on #19. This PR is based on feat/shaclgen-presence-implies-value-stacked
and reuses the shared helpers introduced there (exact-operator detection,
induced-slot resolution, slot-URI resolution, enum meaning resolution).
Review and merge #19 first; the diff shown here is the fallback delta only.

Testing

pytest tests/linkml/test_generators/test_shaclgen.py134 passed
(105 from #19 plus 29 here), with the full generator suite green
(1774 passed, 52 skipped, 3 xfailed). Each supported combination has SPARQL
syntax validation plus a pyshacl end-to-end conforming / violating round-trip,
and the unsound-bound cases are tested to skip while leaving the shapes graph
parseable.

Review notes

Re-cut from an earlier five-PR stack; the corrections that were previously
separate follow-up PRs are folded into the feature they correct.

@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.

@jdsika jdsika self-assigned this Jul 11, 2026
@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
…ersion

The named-pattern converters only recognise whole-rule shapes, so a rule
one operator away from a known pattern produced no constraint at all.
This adds a compositional fallback, tried only after every named pattern
has declined, that builds the query from the operators present rather
than from a fixed template.

Preconditions become a conjunction of graph patterns and FILTERs;
the single postcondition becomes its negation.  Together they select
focus nodes satisfying every precondition while violating the
postcondition, which is exactly the SHACL-SPARQL violation contract of
SHACL 5.3.1, with $this pre-bound to the focus node.  Operator support
is declared per operator, and the builder returns None -- skipping the
rule -- as soon as it meets one it does not handle, so an unsupported
combination is never partially translated.

This covers conditional-required and conditional-absent postconditions,
numeric threshold preconditions, nested-object preconditions and
has_member list membership, in any combination the operators allow.

Numeric bounds are validated before interpolation.  minimum_value and
maximum_value have metamodel range Anything, so YAML strings, dates and
.nan / .inf reach the generator unchanged; interpolating them raw
either produced unparsable SPARQL that poisons the whole shapes graph
at validation time, or -- for a date such as 2020-01-01 -- parsed as an
arithmetic expression that silently never fires.  Only int and finite
float are rendered; anything else skips the rule.  String literals are
escaped rather than interpolated, and a slot carrying both
minimum_value and maximum_value now yields both bounds instead of only
the first.

Nested and member slots resolve against the range class of their
container, so an inner slot is no longer shadowed by a same-named slot
on the outer class, and a range narrowed through slot_usage resolves
its enum permissible values from the narrowed range.

Co-authored-by: jdsika <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the feat/shaclgen-presence-implies-value-stacked branch from 8999ad6 to e161ce7 Compare September 11, 2026 12:53
@jdsika
jdsika force-pushed the feat/shaclgen-compositional-rule-fallback branch from 1ea1ea2 to 59544b4 Compare September 11, 2026 12:53
@jdsika jdsika changed the title feat(gen-shacl): add compositional fallback rule converters (M1-M5) feat(gen-shacl): add a compositional fallback for rule-to-SPARQL conversion Sep 11, 2026
@jdsika

jdsika commented Sep 11, 2026

Copy link
Copy Markdown
Author

Mirrored upstream as linkml#3990.

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