Skip to content

feat(generators): translate class-level boolean expressions alike to SHACL and JSON Schema - #29

Open
jdsika wants to merge 1 commit into
mainfrom
feat/shaclgen-class-boolean-expressions
Open

jdsika wants to merge 1 commit into
mainfrom
feat/shaclgen-class-boolean-expressions

Conversation

@jdsika

@jdsika jdsika commented Sep 24, 2026 •

Copy link
Copy Markdown

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

2026-10-23, third push (adversarial review): an is_a member is read through class membership: nothing for a class every instance belongs to, sh:class / a $ref only for one that some instances belong to through a descendant, a shape nothing conforms to for any other. Before, a class inherited its parent's any_of over its own subclasses as sh:class and $ref to itself: gen-json-schema recursed without end (RecursionError), and gen-shacl rejected a node typed only as a subclass. Tests test_class_expression_is_a_inherited_by_the_classes_it_names and ..._of_a_class_no_instance_belongs_to in both generators; the compliance test test_class_boolean_with_expressions now runs for SHACL except for the schemas whose harness data references untyped nodes. A linkml:Any class stays undetermined, since its values may be instances of any class (found by the compliance test test_range_expression_booleans on the fork's range_expression PR). The exactly_one_of reading is spelled out under Semantics. test_shaclgen.py + test_jsonschemagen.py + test_boolean_slot_compliance.py + test_validation_context.py + doctests: 4025 passed, 631 skipped, 261 subtests passed.

2026-10-23, second push: a condition on a slot inlined as a dict now counts and constrains the values of the dict (minProperties / maxProperties); before, it demanded an array and rejected valid data. Found while reviewing #30, which builds on this one. test_class_expression_condition_on_a_slot_inlined_as_dict; test_jsonschemagen.py + test_boolean_slot_compliance.py + test_validation_context.py green locally.

2026-10-23: rebased onto main (a1593d16c). Conflicts with linkml#3485 (34b78f497): in shaclgen.py it moved the slot-level sh:pattern out of the range block this PR folds into _add_range(), resolved by keeping the move and the helper; in test_shaclgen.py its tests and this PR's were appended at the same position, both kept. No behaviour change. test_shaclgen.py + test_jsonschemagen.py + test_boolean_slot_compliance.py + test_validation_context.py: 3972 passed, 261 subtests passed.

2026-10-14: rebased onto main (bd102062b); no code change.

Summary

Class-level any_of, all_of, exactly_one_of and none_of now mean the same in gen-shacl and gen-json-schema.

  • gen-shacl used to drop them silently, so a class stating "a code or a name is required" got shapes that accepted everything.
  • gen-json-schema translated them only for the declaring class, and read absent slots differently depending on nesting.
GeodeticReferenceSystem:
  slots: [code, name]
  any_of:
    - slot_conditions: {code: {required: true}}
    - slot_conditions: {name: {required: true}}

This becomes sh:or ( [sh:property [sh:path ex:code ; sh:minCount 1]] … ) and JSON Schema anyOf, on this class and on every subclass.

Semantics

The semantics live in one shared module, linkml.generators.common.class_expression, so the generators can't drift apart.

  • Inheritance: a class carries the expressions of its ancestors and mixins, translated in its own context, so slot_usage applies.

  • Absent slots: the spec doesn't define what a slot condition means for an absent slot (Specification: define what a slot condition means for an absent slot in class-level boolean expressions #49). This PR reads it as an SQL CHECK constraint reads null (ISO/IEC 9075 §4.10.2: "satisfied if and only if the specified search condition is not false"):

    • a condition that doesn't decide whether its slot may be absent is unknown;
    • an instance is invalid only when an expression is definitely false.

    The deciding conditions are value_presence: PRESENT / ABSENT, required: true, a minimum or exact cardinality ≥ 1, and a maximum or exact cardinality of 0.

  • exactly_one_of: holds when exactly one member is definitely true, whatever the others are. This is what sh:xone and oneOf compute over the members' "definitely true" forms. It is stricter than a Kleene reading, which would leave {} and an instance meeting no member unknown, and so valid; the compliance suite's exactly_one_of cases decide neither way, because their members use required.

Expression {} {label: A} {label: B}
any_of: [label = A] valid valid invalid
none_of: [label = A] valid invalid valid
exactly_one_of: [label = A, note = B] invalid valid invalid

Why this reading:

  • It composes. all_of: [none_of: [X]] means none_of: [X], and none_of: [none_of: [X]] means X.
  • It keeps the compliance suite's expectations that all-absent instances are valid.
  • It fixes gen-json-schema's exactly_one_of, which rejected {label: A} and accepted {label: B}.

The vacuous reading (JSON Schema properties, SHACL Core) and the existential one (XPath, ShEx) each contradict the compliance suite.

Rules keep their closed-world reading (linkml#3989, linkml#3990).

Translation

  • gen-shacl:
    • emits sh:or / sh:and / sh:xone / sh:not over anonymous member shapes. These are the operators' exact_mappings (SHACL §4.6).
    • Each expression is emitted in its "not false" form; under sh:not, in its "definitely true" form, where a non-deciding condition adds sh:minCount 1.
    • An is_a member names a class every instance of the shape's class belongs to (nothing is emitted), one that only some belong to through a descendant (sh:class, or sh:node with native names), or one that none belongs to (a shape nothing conforms to). An instance belongs to its class and that class's ancestors, by is_a and by mixins; a class that may also be another is declared as a subclass with that mixin.
    • exactly_one_of becomes sh:xone over the "definitely true" member forms.
    • A single-valued parameter needed twice goes into sh:and (§2.1.1).
  • gen-json-schema:
  • Untranslatable members (for example has_member or a slot-level boolean inside a condition): gen-shacl skips the operator whole, with one warning naming the affected shapes.

Testing

  • Out-of-tree check during development: 15,222 fuzzed instances over 360 random class expressions, nested two levels deep, over string, integer, enum and multivalued slots. gen-shacl and gen-json-schema agreed on every one, both matched an independent three-valued model, and every shapes graph passed meta-SHACL. That harness is not part of this PR; the reproducible evidence is below.
  • test_shaclgen.py:
    • a semantics table where SHACL = JSON Schema = expected (39 cases);
    • inheritance: subclasses, mixins, slot_usage, shared class_uri, warnings;
    • skip paths and mapping structure.
  • test_jsonschemagen.py: inheritance, several ABSENT slots, conditions on induced slots.
  • Mutation testing: 23 mutants, 21 killed; the other 2 are equivalent.
  • Compliance: test_class_any_of, test_class_any_of_with_required and test_class_boolean_with_expressions now run for SHACL; the last skips the schemas whose harness data writes a reference as an untyped node, which the slot's sh:class rejects.

Behaviour changes in gen-json-schema

owlgen is unchanged. OWL is open-world and can't express closed-world presence.

Closes #48 (class expressions not applied to subclasses in gen-json-schema).

Follow-ups: #49 (spec: absent-slot semantics) and #50 (spec: two-operand none_of is NOR, not XOR).

Fork stack: #29 → #30 → #34.

@jdsika jdsika self-assigned this Sep 24, 2026
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch 3 times, most recently from 022e25c to 3904d7d Compare September 24, 2026 12:04
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch 2 times, most recently from d90a2ab to 760eb0d Compare October 2, 2026 11:22
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch from 760eb0d to 4a9d475 Compare October 7, 2026 12:05
@jdsika jdsika changed the title feat(gen-shacl): translate class-level boolean expressions to SHACL logical constraints feat(generators): translate class-level boolean expressions alike to SHACL and JSON Schema Oct 7, 2026
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch 2 times, most recently from 4a9d475 to 48e9234 Compare October 14, 2026 05:29
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch 3 times, most recently from 78870fa to edcb032 Compare October 23, 2026 10:48
…SHACL and JSON Schema

gen-shacl dropped class-level any_of, all_of, exactly_one_of and none_of,
so a class stating "a code or a name is required" generated shapes that
accepted everything.  gen-json-schema translated them, but only for the
declaring class, and read an absent slot differently depending on where
an expression was nested.

Both generators now give class expressions one semantics, shared in
linkml.generators.common.class_expression:

- A class expression constrains every instance of its class, so a class
  carries the expressions of its ancestors and mixins, translated in its
  own context.
- The specification does not say what a slot condition means for an
  absent slot.  As an SQL CHECK constraint (ISO/IEC 9075) reads a
  condition on null, a condition that doesn't decide whether its slot
  may be absent is unknown for an absent slot.  An instance is invalid
  only when an expression is definitely false, and exactly_one_of holds
  when exactly one member is definitely true.  The operators compose,
  and gen-json-schema no longer rejects an instance meeting only the
  first member of an exactly_one_of while accepting one meeting none.

gen-shacl emits sh:or, sh:and, sh:xone and sh:not (the operators'
exact_mappings, SHACL 4.6) over anonymous member shapes, each in its
"not false" form or, under sh:not, its "definitely true" form, where
such a condition also has sh:minCount 1.  Conditions translate required,
value_presence, the cardinalities, minimum/maximum_value, pattern,
equals_string(_in), equals_number and range; a parameter SHACL allows
once per shape that one condition needs twice moves into an sh:and
member.  An operator whose members use anything else is skipped whole,
with one warning naming the shapes it is missing from.

gen-json-schema builds the same forms with anyOf, allOf, oneOf and not,
resolves conditions on the induced slot, and also enforces cardinalities
(minItems / maxItems, or minProperties / maxProperties for a slot inlined
as a dict, whose values are the values of the dict) and range in
class-level conditions.
value_presence: ABSENT on several slots of one expression now requires
each to be absent rather than not all of them present, in rules too.

An is_a member names a class that every instance of the class carrying
the expression belongs to (the class or an ancestor), that only some
belong to (through a descendant), or that none belongs to, since an
instance belongs to its class and that class's ancestors. Only the
second case checks the instance: sh:class in SHACL, and in JSON Schema,
which carries no type, a reference to the class's definition. Before,
a class inherited its parent's any_of over its own subclasses as sh:class
and $ref to itself, which recursed without end in JSON Schema and, in
SHACL, rejected a node typed only as a subclass.

The slot loop's range dispatch and sh:path computation move into
_add_range and _slot_iri; output for schemas without class expressions
is unchanged.  The compliance tests test_class_any_of and
test_class_any_of_with_required run for SHACL.

Signed-off-by: jdsika <[email protected]>
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch from edcb032 to e6a40da Compare October 23, 2026 10:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant