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

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

Mirror of linkml#3990, same head. Review there; this PR tracks the fork stack.

Summary

When no named pattern from linkml#3989 matches a rule, this PR composes the rule's SHACL-SPARQL constraint from the operators it uses. Each precondition becomes filters on $this. The single postcondition becomes the union of the ways to violate it, with the offending value bound to ?value (SHACL §5.3.1, §5.3.2).

rules:
  - preconditions:  {slot_conditions: {level: {minimum_value: 10, maximum_value: 20}}}
    postconditions: {slot_conditions: {note: {equals_string_in: [low, high]}}}

With this rule, a Thing whose level is between 10 and 20 violates the constraint when its note is missing, or for each note value other than low or high.

Supported operators (in any condition, including nested ones): value_presence, required, equals_string, equals_string_in, minimum_value, maximum_value, range_expression and has_member.

Anything else is skipped with a warning, as is a rule with empty pre- or postconditions or with several postcondition slots. A rule is never partially translated.

Semantics

These follow jsonschemagen's if / then.

Differences from jsonschemagen

Limitations

  • One postcondition slot only. Rules with several are skipped (gen-shacl rules: translate rules with several postcondition slots #47).
  • Not supported: pattern, equals_number, cardinalities, and slot-level any_of / none_of.
  • Ill-typed literals: rdflib's isNumeric returns true for literals such as "abc"^^xsd:integer, so they are still compared. The property shape reports them anyway.

Testing

  • Agreement with JSON Schema: 61 rule and instance pairs, plus open-world, equivalent-guard and conjunction cases. Each instance is validated by the generated JSON Schema and, through the generated JSON-LD context, by the generated SHACL. The verdicts must agree, for the declaring class and for a subclass.
  • Explicit pyshacl expectations where jsonschemagen differs or has no counterpart:
    • has_member;
    • several ABSENT conditions;
    • references;
    • sh:value;
    • booleans;
    • all 16 numeric datatypes, with non-numeric types and values;
    • every skip reason.
  • Mutation testing: 41 mutants of the new code, all killed.

Upstream stack: linkml#3989 → linkml#3990 → linkml#3991 (docs).
Fork stack: #19 → #20 → #23 (docs) and #35 (tests).

@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_usage → sh: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

Status (head c89faac, mirrored as linkml#3990): all four findings are fixed in this PR's single commit. #21 and #22 were closed unmerged, and their fixes were folded into that commit.

  • B1: inner slots resolve on the container slot's range class. Test: test_compose_inner_slot_resolved_on_range_class.
  • B2: each condition may use only supported operators, and expression-level operators skip the rule. Tests: test_compose_untranslatable_rule_skipped, test_rule_expression_level_operator_skipped.
  • B3: bounds must be finite numbers on a numeric range. Tests: test_compose_non_numeric_bound_skipped, test_compose_bounds_only_on_numeric_ranges.
  • B4: elseconditions now triggers a warning. Test: test_rule_problems_reported_per_rule_and_per_problem.

Two "verified clean" statements above no longer describe the code:

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

@jdsika
jdsika force-pushed the feat/shaclgen-presence-implies-value-stacked branch from e161ce7 to 7622d70 Compare October 2, 2026 11:15
@jdsika
jdsika force-pushed the feat/shaclgen-compositional-rule-fallback branch from 59544b4 to 1b1c775 Compare October 2, 2026 11:16
@jdsika
jdsika force-pushed the feat/shaclgen-presence-implies-value-stacked branch from 7622d70 to bae4a43 Compare October 2, 2026 11:19
@jdsika
jdsika force-pushed the feat/shaclgen-compositional-rule-fallback branch from 1b1c775 to d035e8c Compare October 2, 2026 11:22
@jdsika
jdsika force-pushed the feat/shaclgen-presence-implies-value-stacked branch 4 times, most recently from 66276ea to 1e506c2 Compare October 6, 2026 13:17
…ersion

A rule that no named pattern matches is now composed from the operators
it uses: each precondition becomes SPARQL filters on $this, and the
single postcondition slot the union of the ways to violate it, binding
the offending value to ?value (SHACL sections 5.3.1 and 5.3.2).

A condition may use value_presence, required, equals_string,
equals_string_in, minimum_value, maximum_value, a range_expression on
the slot's class (nested to any depth) and has_member.  The semantics
follow the JSON Schema generator's if/then:

- presence: value_presence decides, then required, then a default: a
  precondition requires its slot, a postcondition unless the rule is
  open_world, an inner condition of a nested expression never;
- value operators and range_expression apply to every value of the slot
  (05validation.md: "all members"), has_member to some value;
- equals_string(_in) only on string, enum and boolean slots (the named
  patterns' range check), bounds only on the numeric datatypes of SPARQL
  1.1 section 17.1, guarded with isNumeric, and only finite numbers.

Any other operator, a rule with empty pre- or postconditions or several
postcondition slots, skips the rule with one warning, so a rule is never
partially translated.  A rule skipped part-way no longer reports the
notes of the abandoned translation.

Co-authored-by: jdsika <carlo.van-driesten@vdl.digital>
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