Skip to content

chore: consolidated feature branch for ENVITED-X pipeline#14

Draft
jdsika wants to merge 17 commits into
mainfrom
feat/envited-x-pipeline
Draft

chore: consolidated feature branch for ENVITED-X pipeline#14
jdsika wants to merge 17 commits into
mainfrom
feat/envited-x-pipeline

Conversation

@jdsika

@jdsika jdsika commented May 7, 2026

Copy link
Copy Markdown

Summary

The single consolidated feature branch for the ENVITED-X pipeline. This
branch integrates every feature developed on this fork, each of which has its
own atomic, CI-green review PR. It is the branch downstream repositories pin
(ontology-management-base installs linkml from a SHA on this branch).

Based on fork main (mirroring linkml/linkml main at 7414ab98), it
carries, in order:

Commits Feature Review PR
0ec311cb, d286fbe5 --deterministic output (RDFC-1.0 + WL hashing + hybrid rdflib serialization), trailing-newline normalization #1
abed0245 --normalize-prefixes well-known prefix names #4
3f68eb8d rules → SHACL-SPARQL framework (boolean guard, exclusive value; --emit-rules) #11
985e6500 sh:minCount/maxCount 0 for zero cardinality #12
4f757fba sh:pattern inside any_of #13
442b5132 --include-null/--no-include-null (gen-json-schema) #15
ef7ad851 propertyNames from inlined-dict key constraints #16
43a45841 presence-implies-value rule pattern #19
a0a33fac compositional fallback rule converters (M1–M5) #20
48b7b4c0 rule-converter hardening (induced-slot parity, combined bounds, SPARQL escaping) #21
b7889d6e audit fixes (operator exactness, nested-slot parity, numeric bound gate, boolean-guard range gate, alias/unknown rule keys, elseconditions warning) #22
e78014be, cbeb443d, 57a4ecf7, 29933b5f, 6aa7702c generator documentation for all of the above #1/#4/#15/#16 docs commits, #23

Supersedes #18 (feat/shacl-rule-converters), whose rule-converter commits
are replaced here by the audited, hardened stack (#19#22 review lineage).

How was this tested?

  • Every constituent feature PR is independently CI-green (full matrix).
  • On this branch's tip: test_shaclgen.py 132 passed;
    test_jsonschemagen.py + test_deterministic_output.py +
    test_normalize_prefixes.py 109 passed, 2 skipped, 4 xfailed,
    261 subtests
    — the features are green in combination.
  • All commits GPG-signed and DCO-signed; original authorship preserved
    across cherry-picks.

Areas of uncertainty

  • This branch intentionally trails the atomic PRs; bump downstream pins
    (e.g. ontology-management-base pyproject.toml + submodule) to the new
    tip in a coordinated change. The previously pinned 97e73d0f remains
    reachable via feat/shaclgen-presence-implies-value-pipeline — do not
    delete that branch until the pin is bumped.
  • The --deterministic commit still carries linkml.utils.rdf_canonicalize
    although upstream main now ships linkml_runtime.utils.rdf_canonicalize
    (feat(generators): add --default-language flag for language-tagged literals linkml/linkml#3449); deduplicate when rebasing for upstream submission.

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 force-pushed the feat/envited-x-pipeline branch 9 times, most recently from a705c35 to 3d3a52a Compare May 12, 2026 16:35
@jdsika jdsika force-pushed the feat/envited-x-pipeline branch 3 times, most recently from cab84ad to b2b3dba Compare June 10, 2026 19:42
jdsika added 8 commits July 8, 2026 10:54
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>
rmessaou and others added 8 commits July 11, 2026 12:45
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.

(cherry picked from commit ea5cf57)
Add a compositional fallback in _rule_to_sparql for rule-operator
combinations outside the three named patterns (boolean guard,
presence-implies-value, exclusive value). Tried only after the named
patterns, so their output is unchanged. The fallback translates a
conjunction of precondition slot conditions plus a single postcondition
into one SELECT $this violation query; any unsupported operator makes
the converter return None -- skip, never mis-translate.

Supported combinations:

- M1 conditional-required: equals_string / value_presence: PRESENT
  precondition + required: true postcondition (violation = FILTER NOT
  EXISTS on the target slot).
- M2 conditional-absent: value_presence: ABSENT postcondition
  (violation = the forbidden slot is present).
- M3 numeric threshold preconditions: minimum_value / maximum_value
  inclusive bounds on the trigger slot, rendered via _sparql_number.
- M4 nested precondition: 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).
- M5 has_member list-membership: a multivalued slot must contain a
  member matching a nested range_expression (violation = FILTER NOT
  EXISTS over the members); reuses _member_conditions.

Tests per converter: structural triple assertions, prepareQuery syntax
validation, and pyshacl end-to-end (conforming instances pass, crafted
violations fail) with advanced=True.

Squashed from the five M1-M5 commits on feat/shacl-rule-converters
(7566e30, fac681e, f8ea709, 25f8cd2, 4776977).

Signed-off-by: Carlo van Driesten <carlo.van-driesten@vdl.digital>
…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>
(cherry picked from commit e263def)
…ic bounds

Resolve the adversarial-audit findings on the rule-converter stack. Every
fix carries regression tests; 18 of the 19 new tests fail on the pre-fix
source (the 19th locks in already-correct zero-member has_member
semantics that had no coverage).

- Operator exactness (audit A1/B2/C2): named patterns and the
  compositional fallback now require their conditions to set EXACTLY the
  operators they translate. A new _set_operator_fields helper enumerates
  the constraint-bearing fields actually set on a condition or class
  expression (metadata excluded; scalars never judged by truthiness, so
  minimum_value: 0 still counts). Previously a condition mixing a
  recognized operator with an unrecognized one (equals_string + pattern),
  expression-level any_of/all_of/none_of/exactly_one_of, co-set
  equals_string + equals_string_in, or value_presence: ABSENT combined
  with a bound was partially translated, silently dropping conjuncts and
  producing demonstrated false positives. All such rules are now skipped
  (skip, never mis-translate).

- Boolean-guard range gate (audit A2): the boolean-guard pattern only
  dispatches when the target slot's induced range is boolean. An
  equals_string "true" postcondition on a string-range slot previously
  hijacked the boolean comparison and flagged conforming data; it now
  dispatches to presence-implies-value, which compares the string.

- Nested inner-slot parity (audit B1/C1): _member_conditions resolves the
  container slot in the induced context of the outer class, requires its
  range to be a class, and resolves inner slot URIs and enum values
  against that range class. Previously inner slots resolved against the
  OUTER class: a slot_usage slot_uri override on the member class made
  sh:path and the SPARQL body diverge (constraint silently never fired),
  and an inner slot name colliding with an outer slot_usage override
  queried a predicate members never carry, making has_member's FILTER NOT
  EXISTS vacuously true (false positives on conforming data). The
  _resolve_member_enum_ref special case is subsumed by passing the range
  class to _resolve_enum_value_ref.

- Numeric bound gate (audit B3/C3): _sparql_number returns None for
  anything but int / finite float (bool excluded), and callers skip the
  rule. The metamodel range of minimum_value/maximum_value is Anything:
  a string bound produced unparsable SPARQL that poisoned the entire
  shapes graph at validation time, and a YAML date parsed as arithmetic
  (2020-01-01 = 2018), silently never firing. Docstring corrected.

- elseconditions warning (audit B4): rules with an else branch now log a
  warning that only the forward direction is enforced, consistent with
  the bidirectional/open_world warnings.

- Test hygiene: the vacuous "<Manual> not in query" assertion now checks
  the full IRI form; new zero-member has_member end-to-end test.

Signed-off-by: Carlo van Driesten <carlo.van-driesten@vdl.digital>

- Alias-form and unknown rule keys (audit C4): _rule_slot resolves a
  rule condition key via the class's induced slots, then the underscored
  alias form (my_slot for a slot named "my slot"), then the base slot.
  _slot_uri returns None for names that resolve to no slot and every
  converter skips the rule -- previously an unknown or alias-form key
  fabricated a default_prefix predicate no shape uses, silently emitting
  a vacuous constraint (or an always-firing has_member).
Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
…key constraints

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
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