Skip to content

feat(gen-shacl): translate presence-implies-value rules to SHACL-SPARQL - #19

Open
jdsika wants to merge 1 commit into
mainfrom
feat/shaclgen-presence-implies-value-stacked
Open

jdsika wants to merge 1 commit into
mainfrom
feat/shaclgen-presence-implies-value-stacked

Conversation

@jdsika

@jdsika jdsika commented Jul 10, 2026 •

Copy link
Copy Markdown

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

Summary

Translates the rule pattern "if slot A is present, slot B must hold one of these values" into a SHACL-SPARQL constraint (SHACL §5).

rules:
  - preconditions:  {slot_conditions: {signature: {value_presence: PRESENT}}}
    postconditions: {slot_conditions: {status: {equals_string: Published}}}

With this rule, a Document that has a signature violates the constraint when its status is missing or not Published. The existing boolean guard is the case equals_string: "true" on a boolean slot.

Semantics

  • equals_string / equals_string_in are translated on string-valued slots (enums, xsd:string types) and on xsd:boolean slots. Booleans accept the lexical forms true / false / 1 / 0 and compare by value. Rules on other ranges are skipped with a warning, because their RDF values are typed literals or IRIs that equal no string.
  • Comparison: values are compared with =, which is how SPARQL defines IN (§17.4.1.9). So "x" also matches "x"^^xsd:string, the same term in RDF 1.1; rdflib's IN uses term identity and misses it. The comparison is wrapped in COALESCE, so strict engines report incomparable values instead of dropping them (§17.4.1.7).
  • open_world: true: the target may be omitted.
  • Inheritance: rules apply to subclass shapes too. The metamodel says rules apply to "all members of this class", and jsonschemagen does the same since Traverse class ancestors looking for rules to apply in JSON Schema output linkml/linkml#1805.
  • Untranslatable rules: a rule that can't be translated exactly is skipped with one warning stating why; it is never partially translated.
  • Results name the property and the offending value: sh:resultPath and sh:value (SHACL §5.3.2).

Changes to the existing patterns (linkml#3451)

  • Exclusive value:
    • maximum_cardinality > 1 never fired, because HAVING can't see SELECT aliases (§18.2.4.2). Fixed.
    • It now also accepts has_member: {equals_string: V}. A bare equals_string on a multivalued slot keeps its "one of the values" reading, now with a warning (question 3 in the comments).
  • Boolean guard: now applies only to xsd:boolean ranges, compares by value, and honours open_world.
  • Warnings: skipped rules are logged as warnings instead of at DEBUG.

Testing

test_shaclgen.py and test_shacl_validation_plugin.py cover each pattern end to end with pyshacl. Cross-generator tests validate the same instances with the generated JSON Schema and, through the generated JSON-LD context, with the generated SHACL; the verdicts must agree. This covers xsd:string-coerced data, subclasses and open_world.

Limitations

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 (PIV converter, audited at stack tip incl. the hardening PR)

Two substantive findings, both empirically demonstrated with pyshacl end-to-end probes and cross-checked against LinkML's reference rule semantics (gen-json-schema if/then realization):

A1 — real bug: greedy dispatch drops extra pre/postcondition operators → false positives.
_rule_to_sparql dispatches to presence-implies-value whenever the precondition has value_presence: PRESENT and the postcondition has equals_string/equals_string_in — without requiring these to be the only operators set. A precondition {value_presence: PRESENT, minimum_value: 100} loses the threshold: data with temp 50 (precondition unsatisfied, rule vacuously satisfied — LinkML's own JSON-Schema realization accepts it) is flagged as violating. Dual: extra postcondition operators are dropped too (equals_string alongside equals_string_in — the _in list silently wins). This widens/narrows the rule instead of skipping — a mis-translation, not a safe skip.
Fix direction: dispatch only when the pre/post conditions set exactly the pattern's operators; otherwise fall through (the fallback PR's _scalar_filters already does this accounting per-operator — the named patterns need the same exhaustiveness check).

A2 — edge case: boolean-guard shadows PIV for equals_string: "true" on non-boolean slots.
The boolean-guard branch keeps dispatch priority but never checks that the target slot's range is boolean. A rule "if opt present, status (range string) must equal "true"" is hijacked into a boolean comparison; both status "true" (conforming) and status "false" are flagged. Pre-existing in the framework, but this PR codifies the priority order, and PIV is the handler that would translate this rule correctly.
Fix direction: gate the boolean-guard branch on the induced post-slot range being boolean.

Verified clean (attacked, held up): absent-target semantics (violation via !BOUND, matches the JSON-Schema required realization); multivalued-target ∀ semantics (matches items:{const}); multivalued guards; mixed meaning/no-meaning equals_string_in sets (matches rdflib_dumper's IRI-vs-literal convention exactly).

Test gaps (cosmetic): test_presence_implies_value_no_meaning_falls_back_to_literal's "<Manual>" not in query assertion is vacuous (an erroneous emission would be a full IRI, never matching that string); no tests for combined operators (would have caught A1) or equals_string: "true" on a non-boolean slot (would have caught A2).

These findings equally apply to the consolidated branch of #18. Suggest addressing A1/A2 as a follow-up commit on this stack before upstreaming.

@jdsika

jdsika commented Jul 11, 2026 •

Copy link
Copy Markdown
Author

Status (head 1e506c2, mirrored as linkml#3989): both findings are fixed in this PR's single commit. #22 was closed unmerged, and its fixes were folded into that commit.

  • A1: each named pattern requires exactly its operators. Anything else falls through to the composed translation (feat(gen-shacl): add a compositional fallback for rule-to-SPARQL conversion linkml/linkml#3990) or is skipped with a warning. Tests: test_rule_extra_condition_operator_skipped, test_rule_expression_level_operator_skipped.
  • A2: the boolean case applies only to xsd:boolean ranges and compares by value. Tests: test_rule_equals_true_on_string_slot_uses_piv, test_rule_boolean_target_compared_by_value.

@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-presence-implies-value-stacked branch from 8999ad6 to e161ce7 Compare September 11, 2026 12:53
@jdsika
jdsika changed the base branch from feat/shaclgen-rules-sparql to main September 11, 2026 12:53
@jdsika jdsika changed the title feat(gen-shacl): add presence-implies-value rule pattern (stacked on #11) feat(gen-shacl): translate presence-implies-value rules to SHACL-SPARQL Sep 11, 2026
@jdsika jdsika closed this Sep 11, 2026
@jdsika jdsika reopened this Sep 11, 2026
@jdsika

jdsika commented Sep 11, 2026

Copy link
Copy Markdown
Author

Mirrored upstream as linkml#3989.

@jdsika
jdsika force-pushed the feat/shaclgen-presence-implies-value-stacked branch 5 times, most recently from 1bf1dc9 to 66276ea Compare October 6, 2026 07:44
The rules-to-SHACL-SPARQL converter recognised two named patterns.  This
adds the presence-implies-value pattern: a precondition asserting
`value_presence: PRESENT` on one slot, and a postcondition constraining
another slot with `equals_string` or `equals_string_in`.  It reads as
"if the guard slot is present, the target slot must be present and hold
one of the allowed values", and generalises the boolean guard to
arbitrary enum values.  The boolean guard becomes the case
`equals_string: "true"` of this pattern on an xsd:boolean-typed flag.

`equals_string` / `equals_string_in` compare strings.  The metamodel
defines them for slots of range string; enums and types with datatype
xsd:string are treated alike, and a slot without a range takes the
schema's default_range.  On an xsd:boolean-typed slot each value must be
a lexical form of xsd:boolean (XML Schema 1.1 Part 2 section 3.3.2.2:
true, false, 1, 0) and denotes that boolean.  A rule applying them to
any other range is skipped with a warning: its RDF values are typed
literals or IRIs that equal no string.  Built-in type names resolve
without importing linkml:types, as in the main slot loop; the built-in
name of `curie` was misspelt there and is corrected.

Values are compared with `=`, spelled out as the disjunction SPARQL 1.1
section 17.4.1.9 defines `IN` to be, so the RDF 1.1-identical plain and
xsd:string literal forms both match and booleans compare by value;
rdflib evaluates `IN` and triple-pattern constants by term identity.
COALESCE turns the type error that RDFterm-equal raises for
incomparable literals into false, so a spec-conformant engine reports
such a value instead of dropping the row.  Queries are DISTINCT and
project ?path (and ?value where there is one), so each result names the
property and the offending value.  A value containing a backslash
followed by "u" or "U" is split with CONCAT, since codepoint escapes are
replaced before a query is parsed.

The exclusive-value pattern accepts the precondition
`has_member: {equals_string: V}`, which states "V is one of the values".
A bare `equals_string: V` is read the same way, as before, now with a
warning on a multivalued slot: the specification applies a slot
constraint to all members of a collection.  The pattern gets the same
range check and value comparison, and its maximum_cardinality > 1 form
tests HAVING on the COUNT aggregate: expressions projected by SELECT are
not visible to HAVING (SPARQL 1.1 section 18.2.4.2), so
`HAVING (?count > N)` never held and the constraint never fired.

A rule applies to all members of its class, so a class shape now
carries the rules of its ancestors and mixins, as the JSON Schema
generator applies them.  Each rule is translated in the inheriting
class's context, where slot_usage may refine the slots it references.
Classes sharing a class_uri share one shape, which carries each rule
once.  With `open_world: true` "the postconditions may be omitted in
instance data", so an absent target is no violation.

A rule whose conditions carry any operator beyond what a pattern
translates, or name an unknown or identifier slot, is skipped rather
than partially translated.  Each problem with a rule is logged once as a
warning naming the declaring class, the rule's position and the class
shapes it affects.  The metadata fields the operator accounting ignores
are derived from the metamodel.  Slot resolution goes through induced
slots, so slot_usage overrides, slot_uri overrides and alias-form keys
resolve to the IRI sh:path emits.

Behaviour changes for existing schemas: the boolean guard rejects a
string "true" on an xsd:boolean flag and skips a flag whose type has
another datatype IRI (main compared with str()); exclusive-value rules
with maximum_cardinality > 1 now fire; inherited rules are now enforced
on subclass shapes; problems with rules are logged at WARNING instead of
DEBUG; rule results carry sh:resultPath and sh:value; sh:message follows
the rule's in_language or default_language.

Co-authored-by: jdsika <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the feat/shaclgen-presence-implies-value-stacked branch from 66276ea to 1e506c2 Compare October 6, 2026 13:17
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