Skip to content

feat(gen-shacl): translate class-level boolean expressions to SHACL logical constraints - #29

Open
jdsika wants to merge 4 commits into
mainfrom
feat/shaclgen-class-boolean-expressions
Open

jdsika wants to merge 4 commits into
mainfrom
feat/shaclgen-class-boolean-expressions

Conversation

@jdsika

@jdsika jdsika commented Sep 24, 2026 •

Copy link
Copy Markdown

Summary

Fixes # — no issue covers the class-level operators. Related: linkml#2400 reports the same gap for the slot-level all_of / none_of / exactly_one_of, which this PR does not change.

gen-shacl silently drops class-level any_of, all_of, exactly_one_of and none_of. A class that says "a code or a name is required", or "one of two complete profiles", generates a shape that accepts every instance, and nothing is logged. The metamodel itself contains one: UnitOfMeasure in units.yaml.

The metamodel already declares the target: these four slots carry exact_mappings to sh:or, sh:and, sh:xone and sh:not. SHACL Core defines those components with the same semantics (SHACL §4.6). This PR emits them on the class's sh:NodeShape:

LinkML SHACL
any_of sh:or over the member shapes
all_of sh:and over the member shapes
exactly_one_of sh:xone over the member shapes
none_of one sh:not per member (the values of a single-parameter component are separate, conjunctive constraints, §2.1.1)

Each member becomes an anonymous node shape: is_a gives sh:class, and nested expressions recurse. Each slot condition becomes an sh:property on the path of the slot as induced for the class, so slot_usage and attributes resolve exactly as in the class's own property shapes. Conditions translate as follows:

Condition field SHACL
required, value_presence, cardinalities sh:minCount / sh:maxCount
minimum_value / maximum_value sh:minInclusive / sh:maxInclusive
equals_number both bounds, so 5 also matches 5.0
pattern sh:pattern
equals_string / equals_string_in sh:in; on an enum slot, the permissible values as _add_enum renders them (the meaning IRI where there is one)
range the same class, type or enum constraint as a slot's range

SHACL allows sh:minInclusive, sh:maxInclusive, sh:in and sh:pattern at most once per shape (§4). A condition can need one twice, for example minimum_value next to equals_number, equals_string next to equals_string_in, or pattern next to a range type's own pattern. In that case the second value goes into an sh:and member of the property shape, where it applies to the same values, and the shapes graph stays well-formed.

The range dispatch and the sh:path computation of the slot loop move into _add_range and _slot_path, so conditions reuse them.

GeodeticReferenceSystem:
  slots: [code, name]
  any_of:
    - slot_conditions: {code: {required: true}}
    - slot_conditions: {name: {required: true}}
ex:GeodeticReferenceSystem a sh:NodeShape ;
    sh:or ( [ sh:property [ sh:path ex:code ; sh:minCount 1 ] ]
            [ sh:property [ sh:path ex:name ; sh:minCount 1 ] ] ) ; ...

Presence. A slot condition constrains the values that are present, so it holds when the slot is absent unless required: true, value_presence: PRESENT or a minimum cardinality of at least 1 says otherwise. Inside none_of, a condition that constrains values requires the slot. Without that, none_of: [{slot_conditions: {s: {equals_string: A}}}] would reject every instance without s. The exception is a condition that decides presence itself, through required, value_presence, or a maximum or exact cardinality of 0. Adding minimum_cardinality: 0 or a maximum cardinality of at least 1 to such a condition never turns an accepted absent slot into a rejected one. The JSON Schema generator requires the slot for the same reason in a class's own none_of, for every condition that sets neither required nor value_presence.

Untranslatable input is skipped, not approximated. A member's fields are checked against the metamodel: AnonymousClassExpression has exactly six semantic fields, and everything else is CommonMetadata. A slot condition's fields are checked against the table above. Some input cannot be translated:

  • a field the table does not cover, such as has_member or a slot-level any_of inside a condition
  • a condition on a name that is not a slot
  • a condition on the identifier slot, which is the node's IRI, not an arc
  • equals_string on a slot whose range does not hold strings

An operator with such a member is skipped as a whole and logged as a warning that names the reason, because dropping a single member would change what any_of / exactly_one_of admit.

How was this tested?

  • tests/linkml/test_generators/test_shaclgen.py gains 56 cases:

    • structure per operator
    • pyshacl end-to-end for all four operators and for a two-profile any_of
    • every slot-condition field
    • the condition path under slot_usage, attributes and a slot name containing a space
    • presence inside and outside none_of, including a nested none_of, cardinality-only conditions, and monotonicity under an added cardinality
    • conditions that need a single-value parameter twice
    • equals_string on an enum with meaning
    • equals_number across numeric datatypes
    • nesting and is_a in both naming modes
    • reaching subclass instances through sh:targetClass
    • member metadata
    • each skip-with-warning path
    • schemas without class expressions staying unchanged

    Every end-to-end case runs pyshacl with meta_shacl=True, so an ill-formed shapes graph fails the test. With the new call disabled, 35 of the 56 fail. The rest are cases that conform either way.

  • tests/linkml/test_compliance/test_boolean_slot_compliance.py: test_class_any_of and test_class_any_of_with_required now run for SHACL through the validator's ShaclValidationPlugin, instead of being skipped as INCOMPLETE. That is 63 cases: all four operators, nested and not nested. With the feature disabled, 35 fail.

    • A row stays INCOMPLETE for SHACL only where an integer in a string slot is its sole violation. Instances reach the shapes through python dataclasses, which coerce 5 to "5", the reason test_core_compliance already gives for OWL/SHACL/ShEx. Rows that also violate the operator are checked.
  • Refactor is output-preserving. Every schema under tests/ and examples/ (373 schemas) was generated in default mode and in native-names-with-suffix mode, and compared against main's generator with the new call disabled: 746 comparisons, 0 differences, 0 new errors. With the call enabled, only meta.yaml and units.yaml change; UnitOfMeasure gains its sh:or.

  • Full suite locally, run as CI runs it (pytest tests/linkml/ --ignore=tests/linkml/test_notebooks -m "not kroki" -n 8): 12333 passed, 2029 skipped, 12 xfailed, 0 failed.

  • tox -e lint passes.

Areas of uncertainty

  • Skips are logged at WARNING, whereas an unsupported rules pattern is logged at DEBUG. An operator that is not translated leaves a class unconstrained, so I chose the louder level. Happy to align.

  • Divergence from jsonschemagen in three corners:

    • jsonschemagen requires the slot only in a class's own none_of. A none_of nested in another operator does not get it there, so all_of: [{none_of: [X]}] rejects an instance that none_of: [X] accepts. Here the rule applies at any depth, so the two read the same.
    • jsonschemagen also requires the slot for a condition without a value constraint ({s: {}} or cardinality-only) inside none_of. Here such a condition is taken literally: none_of: [{s: {maximum_cardinality: 0}}] means "s is present", not "s is absent".
    • jsonschemagen does not translate cardinalities or range inside slot conditions. Here they are translated.

    The first looks like a jsonschemagen inconsistency rather than intended semantics. I'm happy to open a separate issue for it.

  • Subclasses. The constraint sits on the declaring class's shape only. SchemaView.induced_class does not inherit class expressions, and jsonschemagen emits them only for the declaring class. The constraint reaches instances of subclasses the SHACL way, through sh:targetClass and rdfs:subClassOf in the data graph (§2.1.3.2). The sh:class from a member's is_a works the same way, as it does for a slot's range. ShaclValidationPlugin puts no class hierarchy in the data graph, so under it an instance of a subclass satisfies neither. Restating the constraint on every subclass shape would avoid that, but the violations would then be reported twice where the hierarchy is present.

  • equals_number is sh:minInclusive n ; sh:maxInclusive n inside conditions, while the slot loop uses sh:hasValue n. sh:hasValue compares terms (5 ≠ 5.0) and fails for an absent slot. I left the slot loop alone.

  • Shared class_uri. In default naming mode, classes sharing a class_uri share one shape, so their expressions are conjoined on it, as their property shapes already are.

  • sh:closed counts only the shape's direct sh:property paths. A condition on a slot the class does not have is therefore still rejected by a closed shape, like additionalProperties: false in JSON Schema.

  • Out of scope:

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 self-assigned this Sep 24, 2026
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch 2 times, most recently from a29acc0 to 022e25c Compare September 24, 2026 11:55
…ogical constraints

gen-shacl dropped class-level any_of, all_of, exactly_one_of and none_of
without a trace, so a class stating "a code or a name is required" or "one
of two complete profiles" generated shapes that accepted everything.

The metamodel maps these operators to sh:or, sh:and, sh:xone and sh:not
(their exact_mappings), and SHACL Core defines them with the same
semantics (SHACL 4.6). They are now emitted on the class's NodeShape:

- any_of / all_of / exactly_one_of: sh:or / sh:and / sh:xone over a list
  of anonymous member shapes
- none_of: one sh:not per member; the values of sh:not are separate
  constraints that all apply (SHACL 2.1.1)
- a member's is_a gives sh:class, nested expressions recurse, and each
  slot condition gives an sh:property on the path of the slot as induced
  for the class, so slot_usage and attributes resolve as in the slot loop
- conditions translate required, value_presence, the cardinalities,
  minimum/maximum_value, pattern, equals_string(_in) (as the enum renders
  its permissible values on an enum slot), equals_number (as an inclusive
  bound on both sides, so 5 matches 5.0) and range
- a parameter SHACL allows once per shape (sh:minInclusive, sh:in,
  sh:pattern, ...) that one condition needs twice keeps its first value
  and moves the second into an sh:and member, so the shapes graph stays
  well-formed
- a condition holds vacuously for an absent slot unless required,
  value_presence or a minimum cardinality of at least 1 says otherwise;
  inside none_of, at any depth, a condition that constrains values
  requires the slot, so that the negation does not reject absent slots,
  unless it decides presence itself (required, value_presence, or a
  maximum or exact cardinality of 0). The JSON Schema generator requires
  the slot in a class's own none_of for every condition that sets neither
  required nor value_presence
- an operator whose members use anything else (has_member, slot-level
  boolean expressions inside a condition, a name that is not a slot, the
  identifier slot, equals_string on a non-string range, ...) is skipped
  as a whole with a warning, because dropping a member would change what
  the operator admits
- the slot loop's range dispatch and sh:path computation move into
  _add_range and _slot_path so that slot conditions reuse them; the
  output for schemas without class expressions is unchanged

The compliance tests test_class_any_of and test_class_any_of_with_required
now run for SHACL through the validator's SHACL plugin instead of being
skipped as incomplete. Rows stay incomplete only where an integer in a
string slot is the sole violation: instances reach the shapes through
python dataclasses, which coerce the value.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
dependabot Bot and others added 3 commits September 24, 2026 09:59
…nkml#3855)

* fix(schemaview): keep track of which schema requested each import

* fix(schemaview): keep URL import keys intact in the closure
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