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.

Proposed upstream description (2026-10-30), not yet applied to linkml#4028; it is copied there after review.

Merge order of this series: linkml#3716 → linkml#4168 → linkml#3993 → linkml#4144 → linkml#4171 → linkml#3989 → linkml#3990 → linkml#4156 → linkml#4157 → linkml#4158 → linkml#4028 → linkml#4169 → linkml#4170 → linkml#3446. A stack is rebased when its base lands.

Read first: generators/common/class_expression.py (150 lines, the shared semantics), then shaclgen.py (430) and jsonschemagen.py (174); the rest is tests, snapshots and docs.

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.

  • is_a: a member's is_a is read through class membership: it adds nothing for a class every instance belongs to, sh:class / a $ref for a class some instances belong to through a descendant, and a shape nothing conforms to for any other class. A linkml:Any class stays undetermined.

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.

Areas of uncertainty

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

@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 4 times, most recently from edcb032 to e6a40da Compare October 23, 2026 10:56
…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 <carlo.van-driesten@vdl.digital>
@jdsika
jdsika force-pushed the feat/shaclgen-class-boolean-expressions branch from e6a40da to 878d548 Compare October 30, 2026 07: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