feat(gen-shacl): translate LinkML rules to SHACL-SPARQL constraints#18
feat(gen-shacl): translate LinkML rules to SHACL-SPARQL constraints#18rmessaou wants to merge 15 commits into
Conversation
Add a --deterministic / --no-deterministic CLI flag (default off) to OWL, SHACL, JSON-LD Context, and JSON-LD generators that produces diff-stable output using Weisfeiler-Lehman structural hashing on top of the RDFC-1.0 canonicalization from upstream (linkml#3407). Three-phase hybrid pipeline (when --deterministic is set): 1. RDFC-1.0 canonicalization (upstream) produces sequential _:c14nN IDs 2. Weisfeiler-Lehman structural hashing replaces sequential IDs with content-based _:b<sha256> hashes that remain stable when unrelated triples are added/removed 3. rdflib re-serialization recovers idiomatic Turtle (inline blank nodes, collection syntax, filtered prefixes, preserved xsd:string) Without --deterministic, upstream's always-on RDFC-1.0 canonicalization is used directly (via canonicalize_rdf_graph). Additional features gated behind --deterministic: - Expression sorting (any_of/all_of/none_of/exactly_one_of) in owlgen - Collection sorting (sh:in, sh:ignoredProperties) in shaclgen - Permissible value sorting in owlgen and shaclgen - JSON-LD deterministic key ordering (deterministic_json) - JSON-LD context structured ordering (jsonldcontextgen) Rebased on top of upstream linkml#3407 (pyoxigraph RDFC-1.0). Refs: linkml#1847, linkml#3407 Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
rdflib's Turtle serializer always emits a trailing double newline. Normalize to single newline in deterministic_turtle() and the rdflib fallback path in canonicalize_rdf_graph() for consistent file endings. Note: CLI print() still adds a newline after serialize()'s trailing newline. Callers capturing stdout should strip trailing blank lines (e.g. via sed). Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
… names Add an opt-in --normalize-prefixes flag to OWL, SHACL, and JSON-LD Context generators that normalises non-standard prefix aliases to well-known names from a static prefix map (derived from rdflib 7.x defaults, cross-checked against prefix.cc consensus). Key design decisions: - Static frozen map (MappingProxyType) instead of runtime Graph().namespaces() lookup eliminates rdflib version dependency - Both http://schema.org/ and https://schema.org/ map to 'schema' - Shared normalize_graph_prefixes() helper used by OWL and SHACL - Two-phase graph normalisation: Phase 1 normalises schema-declared prefixes, Phase 2 cleans up runtime-injected bindings - Collision detection: skip with warning when standard prefix name is already user-declared for a different namespace - Phase 2 guard prevents overwriting HTTPS bindings with HTTP variants The flag defaults to off, preserving existing behaviour. Tests cover OWL, SHACL, and context generators with sdo->schema, dce->dc, http/https edge case, custom prefix preservation, flag-off backward compatibility, cross-generator consistency, prefix collision detection, schema1 regression prevention, Phase 2 HTTPS guard, empty schema edge case, and static map integrity. Signed-off-by: jdsika <carlo.van-driesten@bmw.de> Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
Implement SHACL-SPARQL constraint generation for the boolean-guard pattern commonly used in conditional validation rules. When a LinkML class has rules: blocks with preconditions (value_presence: PRESENT) and postconditions (equals_string: true), the generator now emits sh:SPARQLConstraint nodes on the corresponding sh:NodeShape. Features: - New _add_rules() method translates recognised rule patterns to SPARQL - Boolean-guard pattern: if value present then flag must be true - Rule description mapped to sh:message on the constraint - Deactivated rules are skipped - Warnings emitted for bidirectional/open_world rule flags - New --emit-rules/--no-emit-rules CLI flag (default: enabled) - Full URI references in SPARQL (no PREFIX declarations needed) The generated SPARQL follows W3C SHACL Section 5 and uses the pre-bound \ variable per Section 5.3.1. Constraints are validated by pyshacl with advanced=True. Refs: linkml#2464 Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
Python truthiness (`if s.maximum_cardinality:`) treats 0 as falsy, so `maximum_cardinality: 0`, `minimum_cardinality: 0` and `exact_cardinality: 0` emitted no constraint at all. `maximum_cardinality: 0` (SHACL `sh:maxCount 0`, "property must not appear") is the idiomatic way to suppress an inherited slot on a subclass via slot_usage, and owlgen already emits `owl:maxCardinality 0` for it -- so the SHACL and OWL output silently diverged. Use explicit `is not None` checks for minimum_cardinality, maximum_cardinality and exact_cardinality, matching the pattern already used in owlgen.py and docgen.py. Precedence: an explicit minimum_cardinality wins over the `required` fallback in the elif cascade, consistent with owlgen.py (which uses the same `if minimum_cardinality is not None ... elif required` order), so `required: true` + `minimum_cardinality: 0` yields `sh:minCount 0`. That combination is a schema-authoring contradiction (the metamodel documents minimum_cardinality as a multivalued-slot count); the explicit, more specific constraint is emitted. Tests cover maximum_cardinality: 0, exact_cardinality: 0, minimum_cardinality: 0, and the required + minimum_cardinality: 0 precedence case. Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
The SHACL generator translated any_of branches by dispatching
solely on `any.range` (class, type, enum, or simple datatype).
If a branch specified `pattern:` — either alone or combined
with a range — the constraint was silently dropped, producing
an empty blank node `[ ]` (trivially satisfied) instead of the
intended `[ sh:pattern "..." ]`.
This is a problem for schemas that use pattern alternatives in
`any_of`, such as the SPDX license field where valid values are
either members of a fixed enum (SPDX identifiers), IRIs, or
custom identifiers matching the LicenseRef- pattern defined in
SPDX Specification v2.3 Annex D (ABNF: license-ref =
["DocumentRef-"(idstring)":"]"LicenseRef-"(idstring)).
The fix adds a single check after the range dispatch:
if any.pattern:
g.add((range_list[-1], SH.pattern, Literal(any.pattern)))
This correctly handles:
- Pattern-only branches (no range): node gets only sh:pattern
- Range + pattern branches: node gets both sh:datatype and sh:pattern
- Range-only branches (no pattern): unchanged behaviour
The test suite now includes a dedicated schema exercising all
three cases, with assertions on both the generated RDF triples
and pyshacl validation of conforming/non-conforming data.
Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
Expose the existing JsonSchemaGenerator.include_null field on the gen-json-schema CLI. --no-include-null forbids explicit JSON null in optional slots so optionality is expressed only via absence from required (JSON Schema Validation 6.5.3), keeping the bare value type (6.1.1) -- needed for strict parity with reference schemas that forbid null. Default unchanged (include_null=True). Tested at the CLI surface via CliRunner over scalar, multivalued, and required slots; the standards rationale lives in the include_null field docstring. Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
…nstraints For an inlined-as-dict slot whose range class has an identifier/key slot, render the key slot's string-applicable constraints onto JSON Schema propertyNames (draft-06+) instead of dropping them. In the inlined-dict form the mapping key is the identifier value, so the key slot's constraints constrain the keys. JSON object keys are always strings, so only pattern, enum (equals_string_in) and a string const (equals_string) are emitted; numeric minimum/maximum, numeric const (equals_number) and allOf are excluded -- a numeric const would otherwise reject every key. structured_pattern is honored when materialize_patterns is enabled, consistent with value patterns. Backward compatible: emitted only when a string-applicable key constraint applies. Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
Generalise the boolean-guard SHACL-SPARQL pattern to enum-valued targets. A rule whose precondition is `value_presence: PRESENT` on a value slot and whose postcondition is `equals_string` / `equals_string_in` on a target slot now emits an `sh:sparql` constraint requiring the target slot to be present and hold one of the allowed values. Each allowed value resolves to its enum `meaning` IRI, with a string-literal fallback. Motivating case (aiSim environment): - "if texture_sky_color is set, sky_model must be TextureSky" - "if overcast_sky_illuminance is set, sky_model must be OvercastSky or MeasuredOvercastSky" The existing boolean-guard (`equals_string: "true"`) and exclusive-value patterns are unchanged; boolean guard keeps priority over the new branch. Adds focused unit tests (enum IRI vs. literal fallback, single value vs. set membership, message emission, SPARQL syntax) plus pyshacl end-to-end validation for the new pattern.
Add a compositional fallback in _rule_to_sparql that handles operator combinations outside the three named patterns. First operator: equals_string precondition + required postcondition, emitting a SHACL-SPARQL constraint whose SELECT flags focus nodes that satisfy the precondition but lack the required slot (FILTER NOT EXISTS). Tests: structural, SPARQL syntax (prepareQuery) and pyshacl end-to-end.
Handle a value_presence: ABSENT postcondition in the compositional fallback: when the precondition holds, the target slot must not be present. Violation SPARQL matches focus nodes where the precondition holds and the forbidden slot is present. Tests: structural, SPARQL syntax and pyshacl end-to-end.
Support minimum_value / maximum_value in rule preconditions: emit a numeric SPARQL FILTER (inclusive bound) on the trigger slot, combinable with any supported postcondition. Adds _sparql_number to render the bound. Tests: structural, SPARQL syntax and pyshacl end-to-end.
Support a precondition that reaches 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). Tests: structural, SPARQL syntax and pyshacl end-to-end.
Support a has_member postcondition with a nested range_expression: the
violation is a FILTER NOT EXISTS over the multivalued slot's members,
requiring at least one member that matches the inner conditions (e.g.
the light-group list must contain {group: Vehicle, type: front_fog_light}).
Reuses _member_conditions so inner enum values resolve against the member
class.
Tests: structural, SPARQL syntax and pyshacl end-to-end.
…and SPARQL escaping
Review hardening for the SHACL-SPARQL rule converters. Three defects, each
with a regression test that fails before this change:
- Induced-slot parity: _slot_uri and _resolve_enum_value_ref resolved the
*base* slot, so a slot_usage override of slot_uri (or a narrowed enum range)
made the generated SPARQL query a property/enum the data never uses while
sh:path used the induced IRI. The constraint then silently never fired
(false negative). Both now resolve the induced slot for the class, matching
the sh:path logic in the main slot loop.
- Combined operators: a single precondition / member condition dispatched on
the first matching operator, so {minimum_value: X, maximum_value: Y} dropped
the lower bound and under-constrained the trigger (false positives). A shared
_scalar_filters helper now emits every recognised operator, and still returns
None -- skip, never mis-translate -- when none is recognised.
- SPARQL string escaping: an equals_string / permissible-value name containing
a double quote, backslash or newline produced invalid, unparseable SPARQL.
New _sparql_string_literal escapes per SPARQL 1.1 section 19.7.
Tests: 6 new regression tests (structural, prepareQuery syntax, and pyshacl
end-to-end) covering all three defects; full shaclgen suite green (111).
Signed-off-by: Carlo van Driesten <carlo.van-driesten@vdl.digital>
Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
c6415c7 to
e263def
Compare
|
The new work on this branch (relative to the already-open atomic PRs #1/#4/#11/#12/#13/#15/#16) has been extracted into a reviewable stacked series, per the one-feature-one-PR convention: Commit mapping verified by patch-id: Two follow-ups surfaced by the audit (details in the #19/#20/#21 audit comments — all findings reproduce on this branch's tip):
|
|
Follow-up: the audit findings are fixed in #22; cherry-pick |
|
Closing as superseded: |
Summary
Adds a rules → SHACL-SPARQL converter to the SHACL generator: LinkML
rules:blocks (cross-parameter / conditional validation) are now translatedinto
sh:SPARQLConstraint(sh:sparql) nodes on the correspondingsh:NodeShape, gated behind a new--emit-rules / --no-emit-rulesCLI flag(default on). Every generated query pre-binds
$thisperW3C SHACL §5.3.1
and is validated by pyshacl with
advanced=True.Rule patterns supported
Three named patterns (matched first, output unchanged):
value_presence: PRESENT→equals_string: "true"truevalue_presence: PRESENT→equals_string/equals_string_inequals_string→maximum_cardinality: N(same slot)…plus a compositional fallback for combinations outside the named patterns
(tried only after them, so it never changes named-pattern output):
required(violation =
FILTER NOT EXISTS).value_presence: ABSENT.minimum_value/maximum_valuebound on the trigger slot.
range_expression.slot_conditions(e.g.sun_position.elevation <= 0).matching a nested
range_expression(violation =FILTER NOT EXISTSoverthe members).
Unrecognised patterns are logged at
DEBUGand skipped — the converternever emits a mis-translated constraint.
deactivatedrules are skipped;bidirectional/open_worldrules warn (forward direction emitted).Supporting generator changes carried on this branch
--deterministicflag: diff-stable output via Weisfeiler-Lehman structuralhashing layered on the upstream RDFC-1.0 canonicalization (OWL / SHACL /
JSON-LD / JSON-LD-context), plus deterministic collection / expression /
permissible-value ordering; trailing-newline normalization.
--normalize-prefixesflag: map non-standard prefix aliases to well-knownnames from a static, version-independent map (OWL / SHACL / context).
shaclgen: emitsh:minCount/maxCount 0for zero cardinality(
is not Nonechecks, matching owlgen); emitsh:patternforpatternconstraints inside
any_of.gen-json-schema:--include-null / --no-include-null; emitpropertyNamesfrom an inlined-dict key slot's string-applicable constraints.
Review-hardening pass
A final adversarial review commit fixes three latent defects in the rule
converters, each with a regression test that fails before the fix:
_slot_uriand_resolve_enum_value_refresolved the base slot, so aslot_usageoverride of
slot_uri(or a narrowed enumrange) made the SPARQL bodyquery a property / enum IRI the data never uses, while
sh:pathused theinduced IRI. The constraint then silently never fired. Both now resolve the
induced slot for the class, matching the
sh:pathlogic in the main slotloop.
precondition or member condition dispatched on the first matching operator,
so a bounded range
{minimum_value: X, maximum_value: Y}dropped the lowerbound. A shared
_scalar_filtershelper now emits every recognisedoperator (and still returns
None— skip, never mis-translate — when none isrecognised).
equals_stringor permissible-value name containing a
",\, or newline producedunparseable SPARQL. New
_sparql_string_literalescapes perSPARQL 1.1 §19.7.
How was this tested?
prepareQuerysyntaxvalidation, and pyshacl end-to-end (conforming instances pass, crafted
violations fail) with
advanced=True.below-threshold case that proves the lower bound is enforced, and an
end-to-end proof that a
slot_usage-overridden constraint actually fires).Each was confirmed to fail on the pre-fix source.
test_shaclgen.pysuite green (111 passed); the PR's other touchedtest files (
test_deterministic_output,test_normalize_prefixes,test_jsonschemagen,test_jsonldcontextgen,test_gen_json_schema) green(281 passed, 2 skipped, 7 xfailed, 261 subtests).
ruff checkandruff format --checkclean on the changed files.Areas of uncertainty
Known, deliberately-scoped limitations (candidates for follow-ups, not
regressions):
?flag != true(anxsd:boolean); a flag modeled as anxsd:string"true"yields a SPARQLtype-error under three-valued logic. The modeled use case uses boolean flags.
precondition + equals postcondition) is not yet handled by the compositional
fallback and is skipped (logged at DEBUG), not mis-translated.
condition mixing a
range_expressionwith scalar operators still resolves viathe nested branch only.
Checklist
AI Assistance
AI tooling assisted with the final adversarial-review pass (defect discovery
and the accompanying regression tests). The findings were reproduced and the
changes verified against the pyshacl end-to-end suite by the author, who
remains responsible for the submission and will engage with reviewers
personally. See our AI Covenant for details.