Skip to content

chore: feature integration branch for the ENVITED-X pipeline - #14

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

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

Conversation

@jdsika

@jdsika jdsika commented May 7, 2026 •

Copy link
Copy Markdown

Purpose

Feature integration branch. This branch is consumed directly by downstream
ENVITED-X projects, so it carries the features developed in this fork, applied
on top of current upstream main. It is not an upstream submission — each
feature is submitted individually (see the table below).

Based on upstream main at 62d30bf16. Head: e3081ef8b.

Contents

Twenty commits, in dependency order.

# Commits Feature PR
1 6 RDF determinism: opt-in diff-stable blank-node labels, diffable-rdf 0.4.0 #24
2 1 docs(owl): document deterministic serialization and --diff-stable #27
3 3 SHACL rules → SPARQL: presence-implies-value, compositional fallback, docs #19 #20 #23
4 1 sh:pattern for pattern constraints inside any_of #13
5 1 SHACL class-level boolean expressions: any_of / all_of / exactly_one_of / none_of → sh:or / sh:and / sh:xone / sh:not #29 (linkml#4028)
6 1 JSON Schema propertyNames from inlined-dict key constraints #16
7 1 --normalize-prefixes for well-known prefix names #4
8 1 gen-owl: a permissible value's instantiates becomes an rdf:type of its IRI #31
9 1 gen-owl: IRI-valued metadata (owl:versionIRI, owl:priorVersion, prov:wasDerivedFrom, dcterms:license, …) emitted as IRIs #33 (linkml#3546)
10 1 string-derived xsd:anyURI types stay typed literals in gen-shacl, gen-jsonld-context and the RDF dumper #32
11 1 gen-shacl --inlined-as-node: inlined values validated by their range class's shape, class shapes require their parents' shapes #30 (stacked on #29)
12 1 gen-shacl: has_member → sh:qualifiedValueShape + sh:qualifiedMinCount 1 #34 (stacked on #30; linkml#2465)
13 1 gen-owl: the DCMI agent terms (dcterms:creator, contributor, publisher, rightsHolder) join the IRI-valued metadata #36 (stacked on #33; linkml#3546)

Rows 8–13 are cherry-picks of their PR commits; their source changes are identical to the PRs (compared line by line, context aside). The one textual conflict, docs/generators/owl.rst and test_owlgen.py between rows 8 and 9, was two independent insertions at the same place and keeps both.

Not on this branch

Already merged upstream — dropped from this branch

Quality gates

Every commit is signed and carries its original authorship (the two SHACL rule
features remain authored by Rayene Messaoud).

On the head, run as CI does:

  • pytest tests/linkml/ --ignore=tests/linkml/test_notebooks -m "not kroki" -n 8 — 12514 passed,
    2029 skipped, 12 xfailed, 0 failed
  • pytest tests/linkml_runtime -m "not kroki" — 1838 passed, 323 skipped, 2 xfailed
  • no tracked file rewritten by the run
  • ruff check and ruff format --check (ruff 0.11.13) clean on every file rows 8–13 change

Known caveat

The --normalize-prefixes commit pins prefixmaps to a git revision, because
the fix it needs (linkml/prefixmaps#82) is merged but unreleased — PyPI is
still on 0.2.6. This is fine for consumers installing from git, but it breaks
the Docker image build
, since pip install of the built wheel has to fetch the
dependency from GitHub and the base image has no git.

@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
@rmessaou
rmessaou force-pushed the feat/envited-x-pipeline branch from 97e73d0 to ef7ad85 Compare July 8, 2026 09:02
@jdsika jdsika self-assigned this Jul 11, 2026
@jdsika
jdsika force-pushed the feat/envited-x-pipeline branch from 6aa7702 to 233adc3 Compare September 11, 2026 14:23
@jdsika jdsika changed the title chore: consolidated feature branch for ENVITED-X pipeline chore: feature integration branch for the ENVITED-X pipeline Sep 11, 2026
@jdsika
jdsika force-pushed the feat/envited-x-pipeline branch from 233adc3 to b8a388e Compare September 11, 2026 14:44
jdsika and others added 9 commits October 2, 2026 14:06
RDFC-1.0 canonicalization already makes RDF output deterministic:
isomorphic graphs always serialize identically. It does not make output
diffable. Blank nodes are numbered `c14nN` in a single global order, so
inserting one class can renumber every blank node after it and rewrite
most of the file. A one-line semantic change lands as a whole-file diff,
which makes generated OWL/SHACL hard to review and noisy to keep under
version control.

Add a `diff_stable` argument to `canonicalize_rdf_graph()` and a
`--diff-stable/--no-diff-stable` flag to the four RDF generators. When
enabled, blank-node labels are derived from each node's own neighbourhood
via Weisfeiler-Lehman refinement, so an edit relabels only the blank
nodes it actually touches.

Measured churn on a real schema (add one class, count changed lines):

    generator   default   --diff-stable
    owlgen         2091              17
    shexgen         796              50
    shaclgen        291              13
    rdfgen          115              25

Output stays deterministic and isomorphic either way; only the choice of
label changes. Off by default, because enabling it relabels existing
output.

The refinement itself lives in `diffable-rdf`, whose only dependencies
(rdflib, pyoxigraph) are already linkml-runtime dependencies at higher
versions, so this adds no new transitive dependencies.
…-opping

Bump the floor to diffable-rdf 0.3.0 and add the missing uv.lock entry: the
dependency was declared in pyproject.toml but never locked, so "uv lock --check"
and the "uv sync --frozen" anti-malware gate would both have failed CI.

0.3.0 also fixes two defects in the Weisfeiler-Lehman labelling this feature
relies on. Disconnected blank-node components now converge independently, so an
edit in one region no longer relabels an unrelated one. And the suffix used to
tell structurally indistinguishable nodes apart was assigned in c14nN *text*
order, so c14n10 sorted between c14n1 and c14n2 -- adding a tenth tied blank
node relabelled eight of the nine already there, the exact opposite of what this
labelling is for.

Separately, diff_stable=True was silently ignored whenever pyoxigraph refused
the graph and canonicalize_rdf_graph degraded to rdflib. Weisfeiler-Lehman
refinement consumes canonical pyoxigraph quads, and that path exists precisely
because there are none, so the argument could not be honoured -- but the caller
was never told. "shaclgen --include-annotations --diff-stable" reaches it, via
the literal predicate an annotation tag without a ':' produces, and returned
output byte-identical to --no-diff-stable. It now warns, with a regression test
asserting the warning and the byte-identical output that makes silence
misleading.
0.4.0 carries graph.base through the library's rdflib fallback, verifying
that every absolute IRI of the source survives a re-read rather than
dropping the directive outright, and adds a diff_stable parameter to
canonicalize_rdf_graph.

The lock entry is written by hand because the workspace sets
exclude-newer = "7 days", which filters any release younger than that from
resolution; 0.3.0 was pinned the same way for the same reason, and both
become resolvable normally on 2026-09-18. uv lock --check and
uv sync --all-groups both accept the entry.

https://github.com/ASCS-eV/diffable-rdf/releases/tag/v0.4.0
Registers a diffable_rdf pytest marker and applies it to the six
diff_stable tests (including the parametrized per-generator test), so
an external CI job can run 'pytest -m diffable_rdf' against a local
diffable-rdf checkout without pulling in the rest of the suite.

Follow-up to a review comment on this PR requesting canary tests from
linkml running in diffable-rdf's own CI, modeled on the numpydantic
project's tests-linkml.yml workflow.
Expose runtime and LinkML extras with a lazy import and an installation hint.
Move usage guidance into the collaboration guide and shorten generator help.

Assert designed RDF edits, source preservation, generator output and CLI flags.
Check ordinary and extra wheel installs in the existing package build job.
Addresses the second review round.

Remove the installed-wheel checks and the CI step that drove them. They
were a one-off apparatus for something the test suite already has a
mechanism for, and part of it only asserted that uv builds wheel metadata
correctly from pyproject.toml rather than testing anything about linkml.

Test the missing extra with a new mock_missing_import fixture built on the
existing MockImportErrorFinder, so the next optional dependency needs no
fixture of its own. Guard the import the way bigquerygen already does:
find_spec raises under that fixture, so the install hint never surfaced.

Drop the default-value test, restore the pre-existing sort test, and cut
the CLI matrix to the single case that shows the flag reaching the
serializer.

Record the convention in AGENTS.md, point MockImportErrorFinder at the
general fixture, and link the guide from the diff_stable parameter.
gen-owl canonicalizes its output with RDFC-1.0 before serializing, and the
determinism work adds a --diff-stable option on top of it, but neither is
mentioned anywhere in the generator documentation. Describe what is guaranteed
without passing any flag, why RDFC-1.0's sequential blank-node numbering can
still produce noisy diffs across schema edits, and what --diff-stable changes.

Also record the behaviour users meet in practice but cannot discover from
--help: that the same option exists on gen-rdf, gen-shacl and gen-shex, and
that graphs which are not standard RDF -- literal predicates from SHACL
annotation mode, relative IRIs such as the metamodel's bibo:status <testing> --
take an rdflib fallback that stays reproducible across processes, warns, and
deliberately does not honour --diff-stable.
The rules-to-SHACL-SPARQL converter added in linkml#3451 recognised a single
named pattern.  This adds the presence-implies-value pattern: a
precondition asserting `value_presence: PRESENT` on one slot, and a
postcondition constraining another slot with `equals_string` or
`equals_string_in`.  It reads as "if the guard slot is present, the
target slot must be present and hold one of the allowed values", and
generalises the existing boolean guard to arbitrary enum values.

The boolean guard is now gated on the target slot's range actually
being `boolean`.  Without that gate a slot of range `string` carrying
`equals_string: "true"` was translated as a boolean comparison, which
does not match the string `"true"` in the data and so flagged
conforming instances as violations.  String-ranged slots now fall
through to the presence-implies-value pattern and compare as strings.

Pattern matching is exact: each converter requires its conditions to
set precisely the operators it translates.  A rule whose conditions
carry anything further -- extra scalar operators, or expression-level
any_of / all_of / none_of / exactly_one_of -- is skipped rather than
partially translated, since dropping a term would either widen the
precondition (false positives) or weaken the postcondition (false
negatives).  Slot resolution goes through induced slots so that
`slot_usage` overrides, `slot_uri` overrides and alias-form keys
resolve to the same IRI that `sh:path` emits.

Co-authored-by: jdsika <carlo.van-driesten@vdl.digital>
…ersion

The named-pattern converters only recognise whole-rule shapes, so a rule
one operator away from a known pattern produced no constraint at all.
This adds a compositional fallback, tried only after every named pattern
has declined, that builds the query from the operators present rather
than from a fixed template.

Preconditions become a conjunction of graph patterns and FILTERs;
the single postcondition becomes its negation.  Together they select
focus nodes satisfying every precondition while violating the
postcondition, which is exactly the SHACL-SPARQL violation contract of
SHACL 5.3.1, with $this pre-bound to the focus node.  Operator support
is declared per operator, and the builder returns None -- skipping the
rule -- as soon as it meets one it does not handle, so an unsupported
combination is never partially translated.

This covers conditional-required and conditional-absent postconditions,
numeric threshold preconditions, nested-object preconditions and
has_member list membership, in any combination the operators allow.

Numeric bounds are validated before interpolation.  minimum_value and
maximum_value have metamodel range Anything, so YAML strings, dates and
.nan / .inf reach the generator unchanged; interpolating them raw
either produced unparsable SPARQL that poisons the whole shapes graph
at validation time, or -- for a date such as 2020-01-01 -- parsed as an
arithmetic expression that silently never fires.  Only int and finite
float are rendered; anything else skips the rule.  String literals are
escaped rather than interpolated, and a slot carrying both
minimum_value and maximum_value now yields both bounds instead of only
the first.

Nested and member slots resolve against the range class of their
container, so an inner slot is no longer shadowed by a same-named slot
on the outer class, and a range narrowed through slot_usage resolves
its enum permissible values from the narrowed range.

Co-authored-by: jdsika <carlo.van-driesten@vdl.digital>
jdsika and others added 5 commits October 2, 2026 14:06
The SHACL generator translates LinkML rules into sh:sparql constraints,
but the generator documentation did not mention it, so the feature was
undiscoverable and its limits undocumented.

Describe the recognised named patterns and the compositional fallback,
the --emit-rules flag, the skip-never-mis-translate contract and which
rule attributes warn, with a worked YAML-to-Turtle example.  Note that
SPARQL-based constraints need a processor with SHACL-SPARQL support.
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>
…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>
…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>

Includes the documentation for the emitted propertyNames constraint.
… 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.

Includes the documentation for the flag in docs/generators/owl.rst.
@jdsika
jdsika force-pushed the feat/envited-x-pipeline branch from 3026d81 to abd2032 Compare October 2, 2026 12:10
jdsika added 5 commits October 2, 2026 17:14
A permissible value can be an instance of a class defined in another schema:
an open vocabulary declares the class, and an extension contributes named
individuals of it. The metamodel states this with `instantiates` on the
permissible value ("an element in another schema which this element
instantiates"), but gen-owl ignored it - its slot URI is linkml:instantiates,
which add_metadata skips - so the individual came out typed only with the
enum's own class. Validators checking `sh:class` against the open vocabulary
then rejected it.

Each `instantiates` value of a permissible value with an IRI now becomes an
rdf:type of that IRI. The enum still closes its own class with owl:oneOf; the
instantiated class stays open. On a permissible value rendered as a literal
there is nothing to type, so `instantiates` is ignored with a warning, as
`meaning` already is.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
gen-owl renders every annotation and every string-ranged metadata slot as a
literal. For properties whose value is a resource by their own specification
that is wrong: an ontology's owl:priorVersion, owl:versionIRI,
prov:wasDerivedFrom, dcterms:license or dcterms:conformsTo comes out as
"https://..." instead of an IRI, so it can be neither dereferenced nor
followed by tools that read the ontology header. There was no way to express
these header terms at all.

A value of one of those properties is now emitted as an IRI when it is an
absolute IRI or a CURIE with a declared prefix; every other value, and every
other property, keeps its literal. The properties are the OWL 2 ontology
properties and owl:versionIRI, rdfs:seeAlso and rdfs:isDefinedBy, the DCMI
terms relating a resource to another resource, and prov:wasDerivedFrom,
prov:wasRevisionOf and prov:hadPrimarySource. dcterms:identifier is left out:
DCMI gives it the range rdfs:Literal.

Refs: linkml#3546
Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
A schema can hold a URI as a link to a resource or as data. A link is an IRI
node. A URI reference held as data - a file path such as ./data/file.png,
which may be relative - has to stay a literal: as an IRI, JSON-LD resolves it
against the document base and it becomes a machine-specific absolute IRI.

The way to declare the second is a type derived from string with
uri: xsd:anyURI. The OWL generator already decides by type ancestry
(is_xsd_anyuri_range): only uri, uriorcurie and the types derived from them
are promoted to IRIs by --xsd-anyuri-as-iri. The other three RDF components
keyed on the xsd:anyURI datatype instead, so such a type was an IRI in SHACL,
in the JSON-LD context (with the flag) and in the RDF dumper, and a literal
in OWL:

- gen-shacl emits sh:nodeKind sh:Literal and sh:datatype xsd:anyURI for it
- gen-jsonld-context keeps "@type": "xsd:anyURI" with --xsd-anyuri-as-iri,
  through the same predicate, for single ranges and any_of branches
- the RDF dumper emits a typed literal for it, and resolves the type with
  inheritance, so a type derived from uri without a datatype of its own is
  an IRI node instead of a plain literal

uri, uriorcurie and their derived types behave exactly as before.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
…inlined-as-node

A class-ranged value is either inlined - its content is part of the instance -
or a reference to an object described elsewhere. LinkML reads an inlined value
as an instance of the slot's range class whether or not it states a type, and
the JSON Schema generator validates it structurally through $ref. gen-shacl
always emitted sh:class <class_uri> in class-URI naming mode, so an inlined
value was only validated once it carried that rdf:type, and an untyped one was
rejected for its type instead of being checked for its content.

--inlined-as-node (default off) emits sh:node <range class shape> for inlined
values, and keeps sh:class for references, whose type is all that is visible
from the referring shape. Inlined is decided by SchemaView.is_inlined: the slot
declares inlined or inlined_as_list, directly or through its ancestors, or the
range class has no identifier. Each any_of member is decided on its own range,
and the range of a class-expression slot condition for the slot it names; the
is_a of a class expression stays sh:class, as it tests a type.
The referenced shape is named after the class_uri plus --suffix, as emitted.

A value reached through sh:node is validated by its range class's shape
alone, without the targets of the ancestors' shapes. The metamodel states that
a class's rules and expressions apply to all its members, so under the option
each class shape also requires its is_a parent's and mixins' shapes.
Native names mode already emits sh:node and is unaffected.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
has_member states that a slot has at least one value satisfying an
expression. gen-shacl ignored it without a warning, so "a manifest has at
least one artifact of category data" generated a shape that accepted every
manifest (linkml#2465).

It now becomes sh:qualifiedValueShape with sh:qualifiedMinCount 1 on the
slot's property shape, the SHACL component with exactly that meaning. The
member shape takes the expression's range, numeric bounds, pattern and
equals_string / equals_string_in / equals_number, translated as in a slot
condition, and its range_expression as a nested class expression over the
slots of the value's class; a member's class range is decided with
--inlined-as-node as the slot's values are. As in the JSON Schema
generator's `contains`, a
condition inside the expression constrains the values that are present;
required: true is what makes a member need the slot.

has_member is accepted on a slot and inside a class-level slot condition.
In the latter each member of the operator is its own shape, so all_of can
require several different members of one slot. A has_member using anything
else is not emitted and is logged as not translated to SHACL, as an
untranslatable class expression is.

Signed-off-by: jdsika <carlo.van-driesten@vdl.digital>
DCMI Metadata Terms give dcterms:creator, dcterms:contributor,
dcterms:publisher and dcterms:rightsHolder the range dcterms:Agent, so their
value is a resource, like the properties gen-owl already emits as IRIs. As an
annotation they still came out as literals: an ontology header could name its
publisher only as "https://..." text, and an ORCID or organisation IRI given
as dcterms:creator was not followable.

They join the IRI-valued metadata properties: a value that is an absolute
IRI or a CURIE with a declared prefix is emitted as an IRI; an agent named in
plain text keeps its literal.

Refs: linkml#3546
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