diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst
index 4b6f076fe8..1f76e3ef6c 100644
--- a/docs/generators/owl.rst
+++ b/docs/generators/owl.rst
@@ -311,6 +311,40 @@ Other examples
translation of Biolink schema to OWL
+Deterministic output
+^^^^^^^^^^^^^^^^^^^^
+
+Generated Turtle can differ between runs — blank-node identifiers and
+statement order depend on Python dict ordering and rdflib serialization
+internals — which makes version-controlled artifacts show large spurious
+diffs. Use ``--deterministic`` for byte-identical output across invocations:
+
+.. code:: bash
+
+ gen-owl --deterministic schema.yaml
+
+The pipeline has three phases:
+
+1. `RDFC-1.0 `_ canonicalization (via
+ `pyoxigraph `_), so isomorphic
+ inputs produce identical triple sets;
+2. Weisfeiler–Lehman structural hashing replaces the sequential ``_:c14nN``
+ labels with content-derived ones, so adding or removing a triple only
+ renames the directly involved blank nodes (diff-stable output);
+3. re-serialization with rdflib recovers idiomatic Turtle — inline blank
+ nodes (`Turtle §2.7 `_), collection
+ syntax (`§2.8 `_) — and only
+ declares prefixes actually used in the graph.
+
+All triples are preserved; only the syntactic form is normalised. Unordered
+collections such as ``owl:oneOf`` items are additionally sorted. The option
+is available on ``gen-owl``, ``gen-shacl``, ``gen-jsonld``, and
+``gen-jsonld-context`` (for JSON output it deep-sorts objects instead).
+
+``pyoxigraph >= 0.4.0`` is required and imported lazily — it is only needed
+when the flag is used and is deliberately not a core dependency.
+
+
Docs
----
diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py
index c30afc72a5..b7dc780d33 100644
--- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py
+++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py
@@ -236,7 +236,61 @@ def end_schema(
with open(frame_path, "w", encoding="UTF-8") as f:
json.dump(frame, f, indent=2, ensure_ascii=False)
- return str(as_json(context)) + "\n"
+ if self.deterministic:
+ return self._deterministic_context_json(json.loads(str(as_json(context))), indent=3)
+ return str(as_json(context))
+
+ @staticmethod
+ def _deterministic_context_json(data: dict, indent: int = 3) -> str:
+ """Serialize a JSON-LD context with deterministic key ordering.
+
+ Preserves the conventional JSON-LD context structure:
+ 1. ``comments`` block first (metadata)
+ 2. ``@context`` block second, with:
+ a. ``@``-prefixed directives (``@vocab``, ``@base``) first
+ b. Prefix declarations (string values) second
+ c. Class/property term entries (object values) last
+ 3. Each group sorted alphabetically within itself
+
+ Unlike :func:`deterministic_json`, this understands JSON-LD
+ conventions so that the output remains human-readable while
+ still being byte-identical across invocations.
+ """
+ from linkml.utils.generator import deterministic_json
+
+ ordered = {}
+
+ # 1. "comments" first (if present)
+ if "comments" in data:
+ ordered["comments"] = data["comments"]
+
+ # 2. "@context" with structured internal ordering
+ if "@context" in data:
+ ctx = data["@context"]
+ ordered_ctx = {}
+
+ # 2a. @-prefixed directives (@vocab, @base, etc.)
+ for k in sorted(k for k in ctx if k.startswith("@")):
+ ordered_ctx[k] = ctx[k]
+
+ # 2b. Prefix declarations (string values — short namespace URIs)
+ for k in sorted(k for k in ctx if not k.startswith("@") and isinstance(ctx[k], str)):
+ ordered_ctx[k] = ctx[k]
+
+ # 2c. Term definitions (object values) — deep-sorted for determinism
+ term_entries = {k: v for k, v in ctx.items() if not k.startswith("@") and not isinstance(v, str)}
+ sorted_terms = json.loads(deterministic_json(term_entries))
+ for k in sorted(sorted_terms):
+ ordered_ctx[k] = sorted_terms[k]
+
+ ordered["@context"] = ordered_ctx
+
+ # 3. Any remaining top-level keys
+ for k in sorted(data):
+ if k not in ordered:
+ ordered[k] = data[k]
+
+ return json.dumps(ordered, indent=indent, ensure_ascii=False)
def visit_class(self, cls: ClassDefinition) -> bool:
if self.exclude_imports and cls.name not in self._local_classes:
diff --git a/packages/linkml/src/linkml/generators/jsonldgen.py b/packages/linkml/src/linkml/generators/jsonldgen.py
index 75d2068e16..4744c03861 100644
--- a/packages/linkml/src/linkml/generators/jsonldgen.py
+++ b/packages/linkml/src/linkml/generators/jsonldgen.py
@@ -1,5 +1,6 @@
"""Generate JSONld from a LinkML schema."""
+import json
import os
from collections.abc import Sequence
from copy import deepcopy
@@ -203,6 +204,10 @@ def end_schema(self, context: str | Sequence[str] | None = None, context_kwargs:
self.schema["@context"].append({"@base": base_prefix})
# json_obj["@id"] = self.schema.id
out = str(as_json(self.schema, indent=" ")) + "\n"
+ if self.deterministic:
+ from linkml.utils.generator import deterministic_json
+
+ out = deterministic_json(json.loads(out), indent=2) + "\n"
self.schema = self.original_schema
return out
diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py
index 4b06d1d7ea..c7716250c8 100644
--- a/packages/linkml/src/linkml/generators/owlgen.py
+++ b/packages/linkml/src/linkml/generators/owlgen.py
@@ -23,6 +23,7 @@
from linkml.utils.deprecation import deprecation_warning
from linkml.utils.generator import Generator, shared_arguments
from linkml.utils.language_tags import LanguageTagResolver
+from linkml.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime import SchemaView
from linkml_runtime.linkml_model.meta import (
AnonymousClassExpression,
@@ -43,7 +44,7 @@
)
from linkml_runtime.utils.formatutils import camelcase, underscore
from linkml_runtime.utils.introspection import package_schemaview
-from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
+from linkml_runtime.utils.yamlutils import YAMLRoot
logger = logging.getLogger(__name__)
@@ -56,6 +57,21 @@
SWRLB = rdflib.Namespace("http://www.w3.org/2003/11/swrlb#")
+def _expression_sort_key(expr: YAMLRoot) -> str:
+ """Return a stable sort key for LinkML anonymous expressions.
+
+ Used by ``--deterministic`` to order ``any_of``, ``all_of``,
+ ``none_of``, and ``exactly_one_of`` members reproducibly.
+
+ This relies on ``YAMLRoot.__repr__()`` which formats objects using
+ their **field values** (not memory addresses). All anonymous
+ expression dataclasses in ``linkml_runtime.linkml_model.meta``
+ use ``@dataclass(repr=False)`` and inherit this field-based repr,
+ so the output is deterministic across runs.
+ """
+ return repr(expr)
+
+
@unique
class MetadataProfile(Enum):
"""
@@ -348,6 +364,10 @@ def serialize(self, **kwargs: Any) -> str:
"""
self.as_graph()
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
+ if self.deterministic and fmt == "turtle":
+ from linkml.utils.generator import deterministic_turtle
+
+ return deterministic_turtle(self.graph)
return canonicalize_rdf_graph(self.graph, output_format=fmt)
def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None:
@@ -654,13 +674,17 @@ def transform_class_expression(
own_slots = self.get_own_slots(cls)
owl_exprs: list[OWL_EXPRESSION] = []
if cls.any_of:
- any_of_expr = self._union_of([self.transform_class_expression(x) for x in cls.any_of])
+ members = list(cls.any_of)
+ if self.deterministic:
+ members = sorted(members, key=_expression_sort_key)
+ any_of_expr = self._union_of([self.transform_class_expression(x) for x in members])
if any_of_expr:
owl_exprs.append(any_of_expr)
if cls.exactly_one_of:
- sub_exprs: list[OWL_EXPRESSION] = self._present(
- self.transform_class_expression(x) for x in cls.exactly_one_of
- )
+ members = list(cls.exactly_one_of)
+ if self.deterministic:
+ members = sorted(members, key=_expression_sort_key)
+ sub_exprs: list[OWL_EXPRESSION] = self._present(self.transform_class_expression(x) for x in members)
if isinstance(cls, ClassDefinition):
cls_uri = self._class_uri(cls.name)
listnode = BNode()
@@ -668,11 +692,11 @@ def transform_class_expression(
graph.add((cls_uri, OWL.disjointUnionOf, listnode))
else:
sub_sub_exprs: list[OWL_EXPRESSION] = []
- for i, x in enumerate(cls.exactly_one_of):
+ for i, x in enumerate(members):
operand_expr = self.transform_class_expression(x)
if not operand_expr:
continue
- rest = cls.exactly_one_of[0:i] + cls.exactly_one_of[i + 1 :]
+ rest = members[0:i] + members[i + 1 :]
neg_expr = self._complement_of_union_of([self.transform_class_expression(nx) for nx in rest])
pos_expr = self._intersection_of([operand_expr, neg_expr])
if pos_expr:
@@ -682,11 +706,17 @@ def transform_class_expression(
owl_exprs.append(union_expr)
# owl_exprs.extend(sub_exprs)
if cls.all_of:
- all_of_expr = self._intersection_of([self.transform_class_expression(x) for x in cls.all_of])
+ members = list(cls.all_of)
+ if self.deterministic:
+ members = sorted(members, key=_expression_sort_key)
+ all_of_expr = self._intersection_of([self.transform_class_expression(x) for x in members])
if all_of_expr:
owl_exprs.append(all_of_expr)
if cls.none_of:
- none_of_expr = self._complement_of_union_of([self.transform_class_expression(x) for x in cls.none_of])
+ members = list(cls.none_of)
+ if self.deterministic:
+ members = sorted(members, key=_expression_sort_key)
+ none_of_expr = self._complement_of_union_of([self.transform_class_expression(x) for x in members])
if none_of_expr:
owl_exprs.append(none_of_expr)
for slot in own_slots:
@@ -859,19 +889,29 @@ def _get_slot_nodes(
)
return rdflib_nodes or None
- if any_of_rdflib_nodes := _get_slot_nodes(slot.any_of):
+ def _maybe_sort_slots(
+ slot_definitions: Sequence[SlotDefinition | AnonymousSlotExpression] | None,
+ ) -> Sequence[SlotDefinition | AnonymousSlotExpression] | None:
+ if slot_definitions and self.deterministic:
+ return sorted(slot_definitions, key=_expression_sort_key)
+ return slot_definitions
+
+ if any_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.any_of)):
owl_exprs.append(self._union_of(any_of_rdflib_nodes))
- if all_of_rdflib_nodes := _get_slot_nodes(slot.all_of):
+ if all_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.all_of)):
owl_exprs.append(self._intersection_of(all_of_rdflib_nodes))
- if none_of_rdflib_nodes := _get_slot_nodes(slot.none_of):
+ if none_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.none_of)):
owl_exprs.append(self._complement_of_union_of(none_of_rdflib_nodes))
if slot.exactly_one_of:
+ members = list(slot.exactly_one_of)
+ if self.deterministic:
+ members = sorted(members, key=_expression_sort_key)
disj_exprs: list[OWL_EXPRESSION] = []
- for i, operand in enumerate(slot.exactly_one_of):
+ for i, operand in enumerate(members):
operand_expr = self.transform_class_slot_expression(cls, operand, main_slot, owl_types)
if not operand_expr:
continue
- rest = slot.exactly_one_of[0:i] + slot.exactly_one_of[i + 1 :]
+ rest = members[0:i] + members[i + 1 :]
neg_expr = self._complement_of_union_of(
[self.transform_class_slot_expression(cls, x, main_slot, owl_types) for x in rest],
owl_types=owl_types,
@@ -1154,7 +1194,10 @@ def add_enum(self, e: EnumDefinition) -> None:
owl_types: list[URIRef | None] = []
enum_owl_type = self._get_metatype(e, self.default_permissible_value_type)
- for pv in e.permissible_values.values():
+ pvs = e.permissible_values.values()
+ if self.deterministic:
+ pvs = sorted(pvs, key=lambda x: x.text)
+ for pv in pvs:
pv_owl_type = self._get_metatype(pv, enum_owl_type)
owl_types.append(pv_owl_type)
if pv_owl_type == RDFS.Literal:
diff --git a/packages/linkml/src/linkml/generators/rdfgen.py b/packages/linkml/src/linkml/generators/rdfgen.py
index a3fcf6a848..95d832f2b3 100644
--- a/packages/linkml/src/linkml/generators/rdfgen.py
+++ b/packages/linkml/src/linkml/generators/rdfgen.py
@@ -19,8 +19,8 @@
from linkml._version import __version__
from linkml.generators.jsonldgen import JSONLDGenerator
from linkml.utils.generator import Generator, shared_arguments
+from linkml.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime.linkml_model import SchemaDefinition
-from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
@dataclass
diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py
index afdd0cf953..6771e99517 100644
--- a/packages/linkml/src/linkml/generators/shaclgen.py
+++ b/packages/linkml/src/linkml/generators/shaclgen.py
@@ -16,9 +16,9 @@
from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor
from linkml.utils.generator import Generator, shared_arguments
from linkml.utils.language_tags import LanguageTagResolver
+from linkml.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName
from linkml_runtime.utils.formatutils import underscore
-from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str
logger = logging.getLogger(__name__)
@@ -180,6 +180,10 @@ def generate_header(self) -> str:
def serialize(self, **args) -> str:
g = self.as_graph()
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
+ if self.deterministic and fmt == "turtle":
+ from linkml.utils.generator import deterministic_turtle
+
+ return deterministic_turtle(g)
return canonicalize_rdf_graph(g, output_format=fmt)
def as_graph(self) -> Graph:
@@ -418,13 +422,13 @@ def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None:
sv = self.schemaview
enum = sv.get_enum(r)
pv_node = BNode()
+ pv_items = list(enum.permissible_values.items())
+ if self.deterministic:
+ pv_items = sorted(pv_items, key=lambda x: x[0])
Collection(
g,
pv_node,
- [
- URIRef(sv.expand_curie(pv.meaning)) if pv.meaning else Literal(pv_name)
- for pv_name, pv in enum.permissible_values.items()
- ],
+ [URIRef(sv.expand_curie(pv.meaning)) if pv.meaning else Literal(pv_name) for pv_name, pv in pv_items],
)
func(SH["in"], pv_node)
diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py
index 40a93ffbc9..704dd1ae61 100644
--- a/packages/linkml/src/linkml/generators/shexgen.py
+++ b/packages/linkml/src/linkml/generators/shexgen.py
@@ -15,6 +15,7 @@
from linkml._version import __version__
from linkml.generators.common.subproperty import get_subproperty_values
from linkml.utils.generator import Generator, shared_arguments
+from linkml.utils.rdf_canonicalize import canonicalize_rdf_graph
from linkml_runtime.linkml_model.meta import (
ClassDefinition,
ElementName,
@@ -26,7 +27,6 @@
from linkml_runtime.linkml_model.types import SHEX
from linkml_runtime.utils.formatutils import camelcase, sfx
from linkml_runtime.utils.metamodelcore import URIorCURIE
-from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
@dataclass
diff --git a/packages/linkml/src/linkml/utils/generator.py b/packages/linkml/src/linkml/utils/generator.py
index 88fc485851..212c6bdbf9 100644
--- a/packages/linkml/src/linkml/utils/generator.py
+++ b/packages/linkml/src/linkml/utils/generator.py
@@ -24,7 +24,7 @@
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
-from typing import ClassVar, TextIO, Union, cast
+from typing import TYPE_CHECKING, ClassVar, TextIO, Union, cast
import click
from click import Argument, Command, Option
@@ -37,6 +37,10 @@
from linkml.utils.schemaloader import SchemaLoader
from linkml.utils.typereferences import References
from linkml_runtime import SchemaView
+
+if TYPE_CHECKING:
+ from rdflib import Graph as RdfGraph
+
from linkml_runtime.linkml_model.meta import (
ClassDefinition,
ClassDefinitionName,
@@ -78,6 +82,294 @@ def _resolved_metamodel(mergeimports):
return metamodel
+def _wl_signatures(
+ quads: list,
+ iterations: int = 4,
+) -> dict[str, str]:
+ """Compute Weisfeiler-Lehman structural signatures for blank nodes.
+
+ Uses 1-dimensional WL colour refinement [1]_ to assign each blank
+ node a deterministic signature derived from its multi-hop
+ neighbourhood structure. The signature depends only on predicate
+ IRIs, literal values, and named-node IRIs — **not** on blank-node
+ identifiers — so it remains stable when unrelated triples are added
+ or removed.
+
+ Parameters
+ ----------
+ quads : list
+ Canonical quads from pyoxigraph (after RDFC-1.0).
+ iterations : int
+ Number of WL refinement rounds (default 4).
+
+ Returns
+ -------
+ dict[str, str]
+ Mapping from canonical blank-node ID (e.g. ``c14n42``) to a
+ truncated SHA-256 hash suitable for use as a stable blank-node
+ label.
+
+ References
+ ----------
+ .. [1] Weisfeiler, B. & Leman, A. (1968). "The reduction of a graph
+ to canonical form and the algebra which appears therein."
+ """
+ import hashlib
+
+ import pyoxigraph # guaranteed available — caller (deterministic_turtle) checks
+
+ # Collect all blank node IDs and build adjacency index.
+ bnode_ids: set[str] = set()
+ # outgoing[b] = list of (predicate_str, object_str_or_bnode_id, is_bnode)
+ outgoing: dict[str, list[tuple[str, str, bool]]] = {}
+ # incoming[b] = list of (subject_str_or_bnode_id, predicate_str, is_bnode)
+ incoming: dict[str, list[tuple[str, str, bool]]] = {}
+
+ for q in quads:
+ s, p, o = q.subject, q.predicate, q.object
+ s_is_bn = isinstance(s, pyoxigraph.BlankNode)
+ o_is_bn = isinstance(o, pyoxigraph.BlankNode)
+ p_str = str(p)
+
+ if s_is_bn:
+ bnode_ids.add(s.value)
+ outgoing.setdefault(s.value, []).append((p_str, o.value if o_is_bn else str(o), o_is_bn))
+ if o_is_bn:
+ bnode_ids.add(o.value)
+ incoming.setdefault(o.value, []).append((s.value if s_is_bn else str(s), p_str, s_is_bn))
+
+ # Initialise signatures: named-node edges only (no bnode IDs).
+ sig: dict[str, str] = {}
+ for bid in bnode_ids:
+ parts = []
+ for p_str, o_str, o_is_bn in outgoing.get(bid, []):
+ if not o_is_bn:
+ parts.append(f"+{p_str}={o_str}")
+ for s_str, p_str, s_is_bn in incoming.get(bid, []):
+ if not s_is_bn:
+ parts.append(f"-{s_str}={p_str}")
+ sig[bid] = "|".join(sorted(parts))
+
+ # Iterative refinement: incorporate neighbour signatures.
+ for _ in range(iterations):
+ new_sig: dict[str, str] = {}
+ for bid in bnode_ids:
+ parts = [sig[bid]]
+ for p_str, o_str, o_is_bn in outgoing.get(bid, []):
+ if o_is_bn:
+ parts.append(f"+{p_str}={sig.get(o_str, '')}")
+ for s_str, p_str, s_is_bn in incoming.get(bid, []):
+ if s_is_bn:
+ parts.append(f"-{sig.get(s_str, '')}={p_str}")
+ new_sig[bid] = "|".join(sorted(parts))
+ sig = new_sig
+
+ # Convert signatures to truncated SHA-256 hashes.
+ # Use 12 hex chars (48 bits) — birthday-bound collision probability
+ # is ~n²/2^49: ~0.002% at 100k nodes. Collisions are handled by
+ # appending a counter (see below), so correctness is preserved.
+ hash_map: dict[str, str] = {}
+ seen_hashes: dict[str, int] = {}
+ for bid in sorted(bnode_ids):
+ digest = hashlib.sha256(sig[bid].encode("utf-8")).hexdigest()[:12]
+ # Handle collisions by appending a counter.
+ count = seen_hashes.get(digest, 0)
+ seen_hashes[digest] = count + 1
+ label = f"b{digest}" if count == 0 else f"b{digest}_{count}"
+ hash_map[bid] = label
+
+ return hash_map
+
+
+def deterministic_turtle(graph: "RdfGraph") -> str:
+ """Serialize an RDF graph to Turtle with deterministic output ordering.
+
+ Uses a three-phase hybrid pipeline for **correctness**, **diff
+ stability**, and **readability**:
+
+ 1. **RDFC-1.0** [1]_ (via ``pyoxigraph``) canonicalizes the graph,
+ ensuring isomorphic inputs produce identical triple sets.
+ 2. **Weisfeiler-Lehman structural hashing** replaces the sequential
+ ``_:c14nN`` identifiers with content-based hashes derived from
+ each blank node's multi-hop neighbourhood. These hashes depend
+ only on predicate IRIs, literal values, and named-node IRIs —
+ not on blank-node numbering — so adding or removing a triple
+ only affects the identifiers of directly involved blank nodes.
+ 3. **Hybrid rdflib re-serialization** parses the canonicalized,
+ WL-hashed triples back into an rdflib ``Graph`` and serializes
+ with rdflib's native Turtle writer. This recovers idiomatic
+ Turtle features that pyoxigraph cannot emit:
+
+ - **Inline blank nodes** (``[ … ]``) for singly-referenced
+ blank nodes (Turtle §2.7 [2]_), instead of verbose named
+ ``_:bHASH`` syntax.
+ - **Collection syntax** (``( … )``) for ``rdf:List`` chains
+ (Turtle §2.8 [2]_).
+ - **Prefix filtering**: only prefixes actually used in the
+ graph's IRIs are declared, following the practice of Apache
+ Jena, Eclipse RDF4J, and Raptor.
+
+ All triples from the source graph are preserved — the hybrid step
+ only changes syntactic form, never semantic content.
+
+ Parameters
+ ----------
+ graph : rdflib.Graph
+ An rdflib Graph to serialize.
+
+ Returns
+ -------
+ str
+ Deterministic Turtle string with ``@prefix`` declarations.
+
+ References
+ ----------
+ .. [1] W3C (2024). "RDF Dataset Canonicalization (RDFC-1.0)."
+ W3C Recommendation. https://www.w3.org/TR/rdf-canon/
+ .. [2] W3C (2014). "RDF 1.1 Turtle — Terse RDF Triple Language."
+ W3C Recommendation. https://www.w3.org/TR/turtle/
+ """
+ try:
+ import pyoxigraph
+ except ImportError as exc:
+ raise ImportError(
+ "pyoxigraph >= 0.4.0 is required for --deterministic output. "
+ "Install it with: pip install 'pyoxigraph>=0.4.0'"
+ ) from exc
+
+ from rdflib import BNode, Graph, Literal, URIRef
+
+ # ── Phase 1: RDFC-1.0 canonicalization ──────────────────────────
+ nt_data = graph.serialize(format="nt")
+
+ dataset = pyoxigraph.Dataset(pyoxigraph.parse(nt_data, format=pyoxigraph.RdfFormat.N_TRIPLES))
+ dataset.canonicalize(pyoxigraph.CanonicalizationAlgorithm.RDFC_1_0)
+
+ canonical_quads = list(dataset)
+
+ # ── Phase 2: WL structural hashing for diff-stable blank node IDs
+ wl_map = _wl_signatures(canonical_quads)
+
+ def _remap(term):
+ if isinstance(term, pyoxigraph.BlankNode) and term.value in wl_map:
+ return pyoxigraph.BlankNode(wl_map[term.value])
+ return term
+
+ remapped = [pyoxigraph.Triple(_remap(q.subject), q.predicate, _remap(q.object)) for q in canonical_quads]
+
+ # ── Phase 3: Hybrid rdflib re-serialization ─────────────────────
+ # Convert pyoxigraph terms to rdflib terms and populate a clean
+ # Graph that only carries explicitly-bound prefixes.
+ def _to_rdflib(term):
+ """Convert a pyoxigraph term to the equivalent rdflib term."""
+ if isinstance(term, pyoxigraph.NamedNode):
+ return URIRef(term.value)
+ if isinstance(term, pyoxigraph.BlankNode):
+ return BNode(term.value)
+ if isinstance(term, pyoxigraph.Literal):
+ if term.language:
+ return Literal(term.value, lang=term.language)
+ if term.datatype:
+ dt_iri = term.datatype.value
+ # In RDF 1.1, simple literals are syntactic sugar for
+ # xsd:string (Turtle §2.5.1). Preserve the shorter form
+ # to match the original owlgen output and avoid spurious
+ # diffs on every string literal.
+ if dt_iri == "http://www.w3.org/2001/XMLSchema#string":
+ return Literal(term.value)
+ return Literal(term.value, datatype=URIRef(dt_iri))
+ return Literal(term.value)
+ raise TypeError(f"Unexpected pyoxigraph term type: {type(term).__name__}: {term}")
+
+ result_graph = Graph(bind_namespaces="none")
+ for triple in remapped:
+ result_graph.add(
+ (
+ _to_rdflib(triple.subject),
+ _to_rdflib(triple.predicate),
+ _to_rdflib(triple.object),
+ )
+ )
+
+ # Bind only prefixes whose namespace IRI is actually referenced
+ # by at least one subject, predicate, or object in the graph.
+ # This filters out rdflib's ~27 built-in default bindings
+ # (brick, csvw, doap, …) that leak through Graph() even when
+ # the schema never declared them.
+ used_iris: set[str] = set()
+ for s, p, o in result_graph:
+ for term in (s, p, o):
+ if isinstance(term, URIRef):
+ used_iris.add(str(term))
+
+ for pfx, ns in sorted(graph.namespaces()):
+ pfx_s, ns_s = str(pfx), str(ns)
+ if pfx_s and any(iri.startswith(ns_s) for iri in used_iris):
+ result_graph.bind(pfx_s, ns_s)
+
+ # rdflib's Turtle serializer always emits a trailing double newline;
+ # normalize to a single newline for consistent file endings.
+ return result_graph.serialize(format="turtle").rstrip("\n") + "\n"
+
+
+def deterministic_json(obj: object, indent: int = 3, preserve_list_order_keys: frozenset[str] | None = None) -> str:
+ """Serialize a JSON-compatible object with deterministic ordering.
+
+ Recursively sorts all dict keys *and* list elements to produce
+ stable output across Python versions and process invocations.
+
+ List elements are sorted by their canonical JSON representation
+ (``json.dumps(item, sort_keys=True)``), which handles lists of
+ dicts, strings, and mixed types.
+
+ :param obj: A JSON-serializable object (typically parsed from ``as_json``).
+ :param indent: Number of spaces for indentation.
+ :param preserve_list_order_keys: Dict keys whose list values must NOT be
+ sorted (e.g. ``@context``, ``@list`` in JSON-LD where array order is
+ semantic). Defaults to ``_JSONLD_ORDERED_KEYS``.
+ :returns: Deterministic JSON string.
+ """
+ import json
+
+ skip = preserve_list_order_keys if preserve_list_order_keys is not None else _JSONLD_ORDERED_KEYS
+
+ def _deep_sort(value: object, parent_key: str = "") -> object:
+ if isinstance(value, dict):
+ return {k: _deep_sort(v, parent_key=k) for k, v in sorted(value.items())}
+ if isinstance(value, list):
+ sorted_items = [_deep_sort(item) for item in value]
+ if parent_key in skip:
+ return sorted_items
+ try:
+ return sorted(sorted_items, key=lambda x: json.dumps(x, sort_keys=True, ensure_ascii=False))
+ except TypeError:
+ return sorted_items
+ return value
+
+ return json.dumps(_deep_sort(obj), indent=indent, ensure_ascii=False)
+
+
+# JSON-LD keys whose array values carry ordering semantics and must not
+# be sorted. @context arrays define an override cascade (JSON-LD 1.1
+# §4.1); @list containers are explicitly ordered; @graph and @set are
+# included defensively.
+_JSONLD_ORDERED_KEYS: frozenset[str] = frozenset({"@context", "@list", "@graph", "@set", "imports"})
+
+
+def well_known_prefix_map() -> dict[str, str]:
+ """Return a mapping from namespace URI to standard prefix name.
+
+ Uses rdflib's curated default namespace bindings as the source of truth.
+ For example, ``https://schema.org/`` maps to ``schema``.
+
+ This allows generators to normalise non-standard prefix aliases
+ (e.g. ``sdo`` for ``https://schema.org/``) to their conventional names.
+ """
+ from rdflib import Graph as RdfGraph
+
+ return {str(ns): str(pfx) for pfx, ns in RdfGraph().namespaces() if str(pfx)}
+
+
@dataclass
class Generator(metaclass=abc.ABCMeta):
"""
@@ -139,6 +431,9 @@ class Generator(metaclass=abc.ABCMeta):
mergeimports: bool | None = True
"""True means merge non-linkml sources into importing package. False means separate packages"""
+ deterministic: bool = False
+ """True means produce stable, reproducible output with sorted keys and canonical blank-node ordering"""
+
source_file_date: str | None = None
"""Modification date of input source file"""
@@ -180,6 +475,10 @@ class Generator(metaclass=abc.ABCMeta):
stacktrace: bool = False
"""True means print stack trace, false just error message"""
+ normalize_prefixes: bool = False
+ """True means normalise non-standard prefix aliases to rdflib's curated default names
+ (e.g. ``sdo`` → ``schema`` for ``https://schema.org/``)."""
+
include: str | Path | SchemaDefinition | None = None
"""If set, include extra schema outside of the imports mechanism"""
@@ -986,6 +1285,26 @@ def decorator(f: Command) -> Command:
callback=stacktrace_callback,
)
)
+ f.params.append(
+ Option(
+ ("--deterministic/--no-deterministic",),
+ default=False,
+ show_default=True,
+ help="Generate stable, reproducible output with sorted keys and canonical blank-node ordering. "
+ "Supported by OWL, SHACL, JSON-LD, and JSON-LD Context generators. "
+ "Useful when generated artifacts are stored in version control.",
+ )
+ )
+ f.params.append(
+ Option(
+ ("--normalize-prefixes/--no-normalize-prefixes",),
+ default=False,
+ show_default=True,
+ help="Normalise non-standard prefix aliases to rdflib's curated default names "
+ "(e.g. sdo → schema for https://schema.org/). "
+ "Supported by OWL, SHACL, and JSON-LD Context generators.",
+ )
+ )
return f
diff --git a/packages/linkml/src/linkml/utils/rdf_canonicalize.py b/packages/linkml/src/linkml/utils/rdf_canonicalize.py
new file mode 100644
index 0000000000..4b6f093b29
--- /dev/null
+++ b/packages/linkml/src/linkml/utils/rdf_canonicalize.py
@@ -0,0 +1,226 @@
+"""Deterministic RDF serialization via pyoxigraph RDFC-1.0 canonicalization.
+
+This module provides a function to canonicalize an rdflib Graph using
+pyoxigraph's RDFC-1.0 implementation, producing deterministic output
+with stable blank node labels and sorted triples.
+
+**Known limitations:**
+
+1. **xsd:string normalization**: pyoxigraph follows RDF 1.1, where plain
+ string literals and ``"text"^^xsd:string`` are identical. The output
+ will never contain explicit ``^^xsd:string`` annotations. Code that
+ re-parses the output with rdflib will see ``Literal("x")`` (datatype
+ ``None``) rather than ``Literal("x", datatype=XSD.string)``.
+
+2. **Non-standard RDF**: Graphs with literal predicates (e.g. SHACL
+ annotation mode) are rejected by pyoxigraph. This function falls
+ back to rdflib's serializer for such graphs.
+
+3. **Numeric short forms**: pyoxigraph uses Turtle short forms for
+ ``xsd:integer`` (``42``), ``xsd:boolean`` (``true``), and
+ ``xsd:decimal`` (``1.23``). rdflib parses these back with the
+ correct datatype, so this is lossless.
+
+4. **Base IRI / prefix collision**: When a graph has ``@base`` and a
+ prefix whose namespace equals the base IRI (e.g. rdflib's auto-bound
+ ``base:`` prefix), pyoxigraph emits CURIEs like ``base:label`` that
+ rdflib rejects. We skip such prefixes during serialization.
+
+5. **Trailing escaped dot in PN_LOCAL**: pyoxigraph emits CURIEs like
+ ``prefix:local\\.`` for IRIs whose local part ends with ``.``. This
+ is valid Turtle (PN_LOCAL_ESC), but rdflib's notation3 parser rejects
+ it because it conflicts with the statement-terminator dot. We
+ post-process the output to expand such CURIEs to full ```` form.
+"""
+
+import io
+import logging
+import re
+
+import pyoxigraph as ox
+import rdflib
+
+logger = logging.getLogger(__name__)
+
+# Mapping from rdflib/LinkML format strings to pyoxigraph RdfFormat objects.
+_FORMAT_MAP: dict[str, ox.RdfFormat] = {
+ "turtle": ox.RdfFormat.TURTLE,
+ "ttl": ox.RdfFormat.TURTLE,
+ "nt": ox.RdfFormat.N_TRIPLES,
+ "ntriples": ox.RdfFormat.N_TRIPLES,
+ "n-triples": ox.RdfFormat.N_TRIPLES,
+ "nt11": ox.RdfFormat.N_TRIPLES,
+ "nquads": ox.RdfFormat.N_QUADS,
+ "n-quads": ox.RdfFormat.N_QUADS,
+ "xml": ox.RdfFormat.RDF_XML,
+ "rdf/xml": ox.RdfFormat.RDF_XML,
+ "trig": ox.RdfFormat.TRIG,
+ "n3": ox.RdfFormat.N3,
+}
+
+# Formats that support prefix declarations.
+_PREFIX_FORMATS = frozenset({ox.RdfFormat.TURTLE, ox.RdfFormat.TRIG, ox.RdfFormat.N3, ox.RdfFormat.RDF_XML})
+
+
+# Characters that may appear escaped in a Turtle PN_LOCAL via PN_LOCAL_ESC.
+_PN_LOCAL_ESC_UNESCAPE = re.compile(r"\\([_~.\-!$&'()*+,;=/?#@%])")
+
+
+def _expand_trailing_dot_curies(turtle_text: str, prefixes: dict[str, str]) -> str:
+ """Replace CURIEs whose local part ends in ``\\.`` with full ```` form.
+
+ rdflib's notation3 parser rejects PN_LOCAL ending in an escaped dot
+ even though Turtle permits it (PN_LOCAL_ESC). pyoxigraph emits this
+ form for IRIs ending in ``.`` (e.g. ``biolink:StrandEnum#.``). We
+ rewrite each such CURIE to its expanded ```` form so the output
+ round-trips through rdflib.
+ """
+ if not prefixes:
+ return turtle_text
+
+ # Match: a prefix name, ':', a local part (no whitespace or token
+ # delimiters), ending in ``\.``, followed by whitespace. Use a
+ # negative lookbehind to avoid matching inside ``<...>`` or word
+ # characters that would make this a substring of something else.
+ pattern = re.compile(
+ r"(?\"'\[\]]*?\\\.)"
+ r"(?=\s)"
+ )
+
+ def replace(match: re.Match[str]) -> str:
+ prefix = match.group(1)
+ local_escaped = match.group(2)
+ namespace = prefixes.get(prefix)
+ if namespace is None:
+ return match.group(0)
+ local = _PN_LOCAL_ESC_UNESCAPE.sub(r"\1", local_escaped)
+ return f"<{namespace}{local}>"
+
+ return pattern.sub(replace, turtle_text)
+
+
+def _is_safe_prefix_iri(iri: str) -> bool:
+ """Check whether a namespace IRI is safe for prefix serialization.
+
+ pyoxigraph rejects IRIs with invalid code-points (e.g. double ``#``),
+ and rdflib's Turtle parser cannot round-trip CURIEs whose namespace
+ contains query parameters or fragments in unexpected positions. This
+ function returns ``False`` for such IRIs so they can be skipped during
+ prefix collection.
+ """
+ # A namespace IRI should end with '/' or '#'. If '#' appears
+ # *before* the final character, the IRI contains an embedded
+ # fragment which produces unusable CURIEs.
+ if "#" in iri[:-1]:
+ return False
+ # Query parameters in namespace IRIs produce CURIEs that rdflib
+ # cannot parse back.
+ if "?" in iri:
+ return False
+ return True
+
+
+def canonicalize_rdf_graph(
+ graph: rdflib.Graph,
+ output_format: str = "turtle",
+) -> str:
+ """Serialize an rdflib Graph deterministically using RDFC-1.0 canonicalization.
+
+ The graph is transferred to pyoxigraph via N-Triples, canonicalized
+ with RDFC-1.0, sorted, and serialized back to the requested format.
+ Prefix bindings from the rdflib Graph are preserved in the output
+ for formats that support them (Turtle, TriG, N3, RDF/XML).
+
+ Falls back to plain rdflib serialization for unsupported formats or
+ graphs containing non-standard RDF (e.g. literal predicates).
+
+ :param graph: The rdflib Graph to serialize.
+ :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``).
+ :return: Deterministic string serialization of the graph.
+ """
+ ox_format = _FORMAT_MAP.get(output_format.lower())
+ if ox_format is None:
+ logger.warning(
+ "pyoxigraph does not support format %r; falling back to rdflib serializer",
+ output_format,
+ )
+ # rdflib's Turtle serializer emits a trailing double newline;
+ # normalize to single newline for consistent file endings.
+ data = graph.serialize(format=output_format)
+ return data.rstrip("\n") + "\n" if data.endswith("\n") else data
+
+ # 1. Transfer rdflib graph to pyoxigraph via N-Triples.
+ nt_data = graph.serialize(format="nt")
+ nt_bytes = nt_data.encode("utf-8") if isinstance(nt_data, str) else nt_data
+
+ # 2. Parse into pyoxigraph and build a Dataset for canonicalization.
+ # Fall back to rdflib if the graph contains non-standard RDF
+ # (e.g. literal predicates from annotations) that pyoxigraph rejects.
+ try:
+ triples = list(ox.parse(io.BytesIO(nt_bytes), format=ox.RdfFormat.N_TRIPLES))
+ except SyntaxError:
+ logger.warning(
+ "Graph contains non-standard RDF that pyoxigraph cannot parse; falling back to rdflib serializer"
+ )
+ return graph.serialize(format=output_format)
+
+ dataset = ox.Dataset()
+ for triple in triples:
+ dataset.add(ox.Quad(triple.subject, triple.predicate, triple.object, ox.DefaultGraph()))
+
+ # 3. Canonicalize blank node labels with RDFC-1.0.
+ dataset.canonicalize(ox.CanonicalizationAlgorithm.RDFC_1_0)
+
+ # 4. Sort triples for deterministic ordering.
+ quads = list(dataset)
+ sorted_triples = sorted(
+ (ox.Triple(q.subject, q.predicate, q.object) for q in quads),
+ key=lambda t: (str(t.subject), str(t.predicate), str(t.object)),
+ )
+
+ # 5. Collect prefixes for formats that support them.
+ base_iri = str(graph.base) if graph.base else None
+ prefixes: dict[str, str] | None = None
+ if ox_format in _PREFIX_FORMATS:
+ prefixes = {}
+ for prefix, namespace in graph.namespace_manager.namespaces():
+ if not prefix: # skip empty prefix (base)
+ continue
+ ns_str = str(namespace)
+ # Skip prefixes whose namespace matches the base IRI to avoid
+ # pyoxigraph emitting CURIEs like `base:label` that conflict
+ # with the @base directive.
+ if base_iri and ns_str == base_iri:
+ continue
+ # Skip namespace IRIs that pyoxigraph rejects or that produce
+ # CURIEs rdflib cannot round-trip. Valid namespace IRIs for
+ # prefix use should end with '/' or '#' and contain no query
+ # parameters or fragment-like characters in the middle.
+ if not _is_safe_prefix_iri(ns_str):
+ continue
+ prefixes[str(prefix)] = ns_str
+ used_prefixes = prefixes
+ try:
+ result_bytes = ox.serialize(
+ sorted_triples,
+ format=ox_format,
+ prefixes=prefixes,
+ base_iri=base_iri,
+ )
+ except ValueError:
+ # pyoxigraph rejects prefixes with invalid IRIs (e.g. containing
+ # fragment-like characters such as double '#'). Retry without
+ # the offending prefixes by falling back to no prefixes, which
+ # still produces valid (if verbose) Turtle.
+ logger.warning("pyoxigraph rejected one or more prefix IRIs; serializing without prefix declarations")
+ result_bytes = ox.serialize(
+ sorted_triples,
+ format=ox_format,
+ )
+ used_prefixes = None
+ result = result_bytes.decode("utf-8")
+ if ox_format in _PREFIX_FORMATS and used_prefixes:
+ result = _expand_trailing_dot_curies(result, used_prefixes)
+ return result
diff --git a/tests/linkml/test_generators/test_deterministic_benchmark.py b/tests/linkml/test_generators/test_deterministic_benchmark.py
new file mode 100644
index 0000000000..b7488a8dda
--- /dev/null
+++ b/tests/linkml/test_generators/test_deterministic_benchmark.py
@@ -0,0 +1,356 @@
+"""Benchmark: deterministic Turtle serializer on real-world ontologies.
+
+Evaluates the ``--deterministic`` flag against schema.org (~16 000 triples,
+~800 classes, ~1 400 properties) and the kitchen_sink LinkML schema to
+demonstrate four properties:
+
+1. **Semantic equivalence** — ``rdflib.compare.isomorphic()`` confirms that
+ deterministic and non-deterministic outputs encode the same RDF graph.
+2. **Byte-level stability** — SHA-256 identity across repeated runs proves
+ that deterministic output is truly reproducible.
+3. **Diff quality** — controlled mutations show that small schema changes
+ produce small, focused diffs (high signal-to-noise ratio).
+4. **Performance** — generation time stays within acceptable bounds even
+ on large real-world graphs.
+
+Schema.org tests exercise ``deterministic_turtle()`` directly on a
+pre-existing OWL ontology. Kitchen_sink tests exercise the full
+``OwlSchemaGenerator`` / ``ShaclGenerator`` pipeline with LinkML schemas.
+
+References
+----------
+- W3C RDFC-1.0: https://www.w3.org/TR/rdf-canon/
+- W3C Turtle 1.1: https://www.w3.org/TR/turtle/
+- schema.org: https://schema.org/docs/developers.html
+"""
+
+import difflib
+import hashlib
+import time
+from pathlib import Path
+
+import pytest
+import yaml
+from rdflib import Graph
+from rdflib.compare import isomorphic
+
+from linkml.generators.owlgen import OwlSchemaGenerator
+from linkml.generators.shaclgen import ShaclGenerator
+from linkml.utils.generator import deterministic_turtle
+
+_has_pyoxigraph = False
+try:
+ import pyoxigraph
+
+ _has_pyoxigraph = hasattr(pyoxigraph, "Dataset")
+except ImportError:
+ pass
+
+pytestmark = pytest.mark.skipif(
+ not _has_pyoxigraph,
+ reason="pyoxigraph >= 0.4.0 required for deterministic benchmarks",
+)
+
+KITCHEN_SINK = str(Path(__file__).parent / "input" / "kitchen_sink.yaml")
+SCHEMA_ORG_URL = "https://schema.org/version/latest/schemaorg-current-https.ttl"
+
+
+def _sha256(text: str) -> str:
+ return hashlib.sha256(text.encode()).hexdigest()
+
+
+def _diff_line_count(a: str, b: str) -> int:
+ """Count lines present in *b* but not in *a* (unified-diff additions)."""
+ al = a.strip().splitlines()
+ bl = b.strip().splitlines()
+ return sum(
+ 1 for line in difflib.unified_diff(al, bl, lineterm="") if line.startswith("+") and not line.startswith("+++")
+ )
+
+
+# ── Schema.org: direct serializer benchmark ────────────────────────
+
+
+@pytest.fixture(scope="module")
+def schema_org_graph():
+ """Download and parse schema.org as an rdflib Graph.
+
+ Cached for the module so the network fetch only happens once.
+ Skips all dependent tests if the download fails.
+ """
+ try:
+ import urllib.request
+
+ with urllib.request.urlopen(SCHEMA_ORG_URL, timeout=60) as resp:
+ data = resp.read().decode("utf-8")
+ except Exception as exc:
+ pytest.skip(f"Could not fetch schema.org: {exc}")
+
+ g = Graph()
+ g.parse(data=data, format="turtle")
+ return g
+
+
+@pytest.mark.network
+class TestSchemaOrgDeterministicSerializer:
+ """Benchmark ``deterministic_turtle()`` on schema.org OWL ontology."""
+
+ def test_semantic_equivalence(self, schema_org_graph):
+ """Deterministic serialization must be isomorphic to the original graph."""
+ det_ttl = deterministic_turtle(schema_org_graph)
+
+ g_det = Graph()
+ g_det.parse(data=det_ttl, format="turtle")
+
+ assert len(g_det) == len(schema_org_graph), (
+ f"Triple count mismatch: original={len(schema_org_graph)}, deterministic={len(g_det)}"
+ )
+ assert isomorphic(g_det, schema_org_graph), (
+ "Deterministic output is NOT isomorphic to original schema.org graph"
+ )
+
+ def test_byte_stability(self, schema_org_graph):
+ """Two deterministic runs must produce byte-identical output."""
+ run1 = deterministic_turtle(schema_org_graph)
+ run2 = deterministic_turtle(schema_org_graph)
+ assert _sha256(run1) == _sha256(run2), "Deterministic serializer produced different output across runs"
+
+ def test_prefix_filtering(self, schema_org_graph):
+ """Only prefixes actually used in the graph should be declared."""
+ det_ttl = deterministic_turtle(schema_org_graph)
+
+ # Extract declared prefixes
+ declared = {}
+ for line in det_ttl.splitlines():
+ if line.startswith("@prefix"):
+ parts = line.split()
+ pfx = parts[1].rstrip(":")
+ ns = parts[2].strip("<>")
+ declared[pfx] = ns
+
+ # Collect all IRIs in the graph
+ from rdflib import URIRef
+
+ used_iris = set()
+ for s, p, o in schema_org_graph:
+ for term in (s, p, o):
+ if isinstance(term, URIRef):
+ used_iris.add(str(term))
+
+ # Every declared prefix must have at least one IRI using it
+ for pfx, ns in declared.items():
+ assert any(iri.startswith(ns) for iri in used_iris), f"Prefix '{pfx}:' <{ns}> declared but no IRI uses it"
+
+ def test_performance(self, schema_org_graph):
+ """Serialization must complete within 60 seconds for ~16K triples."""
+ start = time.time()
+ det_ttl = deterministic_turtle(schema_org_graph)
+ elapsed = time.time() - start
+ triple_count = len(schema_org_graph)
+ throughput = triple_count / elapsed if elapsed > 0 else float("inf")
+
+ # Log for benchmark visibility (shows with pytest -v)
+ print(f"\n schema.org: {triple_count} triples in {elapsed:.1f}s ({throughput:.0f} triples/s)")
+
+ assert elapsed < 60.0, f"Serialization took {elapsed:.1f}s (limit: 60s) for {triple_count} triples"
+ assert len(det_ttl) > 1000, "Output suspiciously short"
+
+
+# ── Kitchen_sink: full pipeline benchmark ───────────────────────────
+
+
+def _mutate_kitchen_sink(description_suffix: str = "", add_slot: bool = False) -> str:
+ """Create a mutated copy of kitchen_sink.yaml **in the same directory** and return its path.
+
+ The copy must live alongside the original so that LinkML relative imports
+ (``linkml:types``, ``core``, etc.) resolve correctly.
+
+ Uses a unique filename (via ``os.getpid()``) to avoid race conditions
+ when tests run in parallel under pytest-xdist.
+
+ Parameters
+ ----------
+ description_suffix
+ Text appended to the first class description found.
+ add_slot
+ If True, adds a synthetic ``benchmark_notes`` slot to the first class.
+ """
+ import os
+
+ ks_path = Path(KITCHEN_SINK)
+ schema = yaml.safe_load(ks_path.read_text())
+
+ if description_suffix or add_slot:
+ # Find the first class with a description
+ for cls_name, cls_def in schema.get("classes", {}).items():
+ if isinstance(cls_def, dict) and cls_def.get("description"):
+ if description_suffix:
+ cls_def["description"] += description_suffix
+ if add_slot:
+ slots = cls_def.get("slots", [])
+ slots.append("benchmark_notes")
+ cls_def["slots"] = slots
+ break
+
+ # Define the synthetic slot if adding one
+ if add_slot:
+ slots_dict = schema.setdefault("slots", {})
+ slots_dict["benchmark_notes"] = {
+ "description": "Synthetic benchmark slot for diff quality testing.",
+ "range": "string",
+ }
+
+ # Write in the same directory so relative imports resolve.
+ # Use PID to avoid race conditions with pytest-xdist workers.
+ out_path = ks_path.parent / f"_benchmark_mutated_{os.getpid()}_kitchen_sink.yaml"
+ out_path.write_text(
+ yaml.dump(schema, default_flow_style=False, allow_unicode=True),
+ encoding="utf-8",
+ )
+ return str(out_path)
+
+
+@pytest.mark.parametrize(
+ "generator_cls",
+ [OwlSchemaGenerator, ShaclGenerator],
+ ids=["owl", "shacl"],
+)
+class TestKitchenSinkDiffQuality:
+ """Measure diff quality on the kitchen_sink schema with controlled mutations."""
+
+ def test_mutation_description_change(self, generator_cls):
+ """A single description change must produce a small, focused diff.
+
+ Deterministic mode should change only the affected line(s) and their
+ immediate context (e.g. SHACL may repeat descriptions in sh:description).
+ Non-deterministic mode produces a much larger diff due to blank-node
+ and property-ordering instability.
+ """
+ base = generator_cls(KITCHEN_SINK, deterministic=True).serialize()
+ mutated_path = _mutate_kitchen_sink(description_suffix=" (benchmark edit)")
+ try:
+ mutated = generator_cls(mutated_path, deterministic=True).serialize()
+ finally:
+ Path(mutated_path).unlink(missing_ok=True)
+
+ det_diff = _diff_line_count(base, mutated)
+
+ # Non-deterministic baseline for comparison
+ non_base = generator_cls(KITCHEN_SINK, deterministic=False).serialize()
+ non_mutated_path = _mutate_kitchen_sink(description_suffix=" (benchmark edit)")
+ try:
+ non_mutated = generator_cls(non_mutated_path, deterministic=False).serialize()
+ finally:
+ Path(non_mutated_path).unlink(missing_ok=True)
+
+ non_diff = _diff_line_count(non_base, non_mutated)
+
+ # The deterministic diff must be small (description + any SHACL mirrors)
+ assert det_diff <= 20, (
+ f"Deterministic diff too large for a 1-description change: {det_diff} lines (expected ≤20)"
+ )
+ # Signal-to-noise: deterministic must be at least 5× smaller
+ if non_diff > 0:
+ ratio = non_diff / max(det_diff, 1)
+ assert ratio >= 5, (
+ f"Insufficient noise reduction: det={det_diff}, non-det={non_diff}, ratio={ratio:.1f}× (expected ≥5×)"
+ )
+
+ print(
+ f"\n {generator_cls.__name__} description mutation: "
+ f"det={det_diff} lines, non-det={non_diff} lines, "
+ f"noise reduction={non_diff / max(det_diff, 1):.0f}×"
+ )
+
+ def test_mutation_add_slot(self, generator_cls):
+ """Adding a new slot must produce a proportionally small diff.
+
+ A new slot adds ~10-20 triples (label, range, domain, restrictions).
+ The diff should be roughly proportional to the new content, not a
+ full-file rewrite.
+ """
+ base = generator_cls(KITCHEN_SINK, deterministic=True).serialize()
+ mutated_path = _mutate_kitchen_sink(add_slot=True)
+ try:
+ mutated = generator_cls(mutated_path, deterministic=True).serialize()
+ finally:
+ Path(mutated_path).unlink(missing_ok=True)
+
+ det_diff = _diff_line_count(base, mutated)
+
+ # Non-deterministic baseline for comparison
+ non_base = generator_cls(KITCHEN_SINK, deterministic=False).serialize()
+ non_mutated_path = _mutate_kitchen_sink(add_slot=True)
+ try:
+ non_mutated = generator_cls(non_mutated_path, deterministic=False).serialize()
+ finally:
+ Path(non_mutated_path).unlink(missing_ok=True)
+
+ non_diff = _diff_line_count(non_base, non_mutated)
+
+ g_base = Graph()
+ g_base.parse(data=base, format="turtle")
+ g_mut = Graph()
+ g_mut.parse(data=mutated, format="turtle")
+ new_triples = len(g_mut) - len(g_base)
+
+ # Diff should be proportional to new triples (allow 5× margin)
+ assert det_diff <= max(new_triples * 5, 40), (
+ f"Deterministic diff ({det_diff} lines) disproportionate to new triples ({new_triples})"
+ )
+ # Signal-to-noise: deterministic must be at least 5× smaller
+ if non_diff > 0:
+ ratio = non_diff / max(det_diff, 1)
+ assert ratio >= 5, (
+ f"Insufficient noise reduction: det={det_diff}, non-det={non_diff}, ratio={ratio:.1f}× (expected ≥5×)"
+ )
+
+ print(
+ f"\n {generator_cls.__name__} add-slot mutation: "
+ f"det_diff={det_diff} lines, non-det={non_diff} lines, "
+ f"new_triples={new_triples}, noise reduction={non_diff / max(det_diff, 1):.0f}×"
+ )
+
+ print(f"\n {generator_cls.__name__} add-slot mutation: det_diff={det_diff} lines, new_triples={new_triples}")
+
+
+@pytest.mark.parametrize(
+ "generator_cls",
+ [OwlSchemaGenerator, ShaclGenerator],
+ ids=["owl", "shacl"],
+)
+class TestKitchenSinkEquivalence:
+ """Verify semantic equivalence between deterministic and non-deterministic modes."""
+
+ def test_triple_count_matches(self, generator_cls):
+ """Both modes must produce the same number of triples."""
+ det = generator_cls(KITCHEN_SINK, deterministic=True).serialize()
+ nondet = generator_cls(KITCHEN_SINK, deterministic=False).serialize()
+
+ g_det = Graph()
+ g_det.parse(data=det, format="turtle")
+ g_nondet = Graph()
+ g_nondet.parse(data=nondet, format="turtle")
+
+ assert len(g_det) == len(g_nondet), (
+ f"Triple count mismatch: deterministic={len(g_det)}, non-deterministic={len(g_nondet)}"
+ )
+
+ def test_byte_stability_across_runs(self, generator_cls):
+ """Three deterministic runs must produce identical output."""
+ runs = [generator_cls(KITCHEN_SINK, deterministic=True).serialize() for _ in range(3)]
+ hashes = [_sha256(r) for r in runs]
+ assert hashes[0] == hashes[1] == hashes[2], f"Deterministic output varies across runs: {hashes}"
+
+ def test_non_deterministic_instability(self, generator_cls):
+ """Non-deterministic output should vary across runs (documents the problem).
+
+ This test is advisory — it passes regardless but logs the instability.
+ """
+ runs = [generator_cls(KITCHEN_SINK, deterministic=False).serialize() for _ in range(3)]
+ hashes = [_sha256(r) for r in runs]
+ identical = hashes[0] == hashes[1] == hashes[2]
+ print(
+ f"\n {generator_cls.__name__} non-det stable: {identical} "
+ f"(expected: False for Turtle due to bnode/ordering instability)"
+ )
diff --git a/tests/linkml/test_generators/test_deterministic_output.py b/tests/linkml/test_generators/test_deterministic_output.py
new file mode 100644
index 0000000000..6721c2ac93
--- /dev/null
+++ b/tests/linkml/test_generators/test_deterministic_output.py
@@ -0,0 +1,481 @@
+"""Tests for deterministic generator output.
+
+When ``deterministic=True``, generators must produce byte-identical output
+across multiple invocations. This ensures version-controlled artifacts don't
+show spurious diffs from blank-node relabeling or dict-ordering instability.
+
+Generators must also produce **isomorphic** output — the deterministic
+serialization must encode the same RDF graph as non-deterministic mode.
+"""
+
+import json
+import time
+from pathlib import Path
+
+import pytest
+from rdflib import Graph
+from rdflib.compare import isomorphic
+
+from linkml.generators.jsonldcontextgen import ContextGenerator
+from linkml.generators.jsonldgen import JSONLDGenerator
+from linkml.generators.owlgen import OwlSchemaGenerator
+from linkml.generators.shaclgen import ShaclGenerator
+
+# Deterministic Turtle requires pyoxigraph >= 0.4.0 (for Dataset/canonicalize).
+# When an older version is present (e.g. pulled in by morph-kgc), skip these tests.
+_has_pyoxigraph = False
+try:
+ import pyoxigraph
+
+ _has_pyoxigraph = hasattr(pyoxigraph, "Dataset")
+except ImportError:
+ pass
+
+pytestmark = pytest.mark.skipif(not _has_pyoxigraph, reason="pyoxigraph >= 0.4.0 required for deterministic tests")
+
+SCHEMA = str(Path(__file__).parent / "input" / "personinfo.yaml")
+
+
+@pytest.mark.parametrize(
+ "generator_cls,kwargs",
+ [
+ (OwlSchemaGenerator, {}),
+ (ShaclGenerator, {}),
+ (ContextGenerator, {}),
+ (JSONLDGenerator, {}),
+ ],
+ ids=["owl", "shacl", "context", "jsonld"],
+)
+def test_deterministic_output_is_identical_across_runs(generator_cls, kwargs):
+ """Generate output twice with deterministic=True and verify identity."""
+ out1 = generator_cls(SCHEMA, deterministic=True, **kwargs).serialize()
+ out2 = generator_cls(SCHEMA, deterministic=True, **kwargs).serialize()
+ # JSONLDGenerator embeds a generation_date timestamp — normalize it
+ if generator_cls is JSONLDGenerator:
+ import re
+
+ ts_re = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}")
+ out1 = ts_re.sub("TIMESTAMP", out1)
+ out2 = ts_re.sub("TIMESTAMP", out2)
+ assert out1 == out2, f"{generator_cls.__name__} produced different output across runs"
+ assert len(out1) > 100, "Output suspiciously short — generator may have failed silently"
+
+
+@pytest.mark.parametrize(
+ "generator_cls",
+ [ContextGenerator, JSONLDGenerator],
+ ids=["context", "jsonld"],
+)
+def test_deterministic_json_has_sorted_keys(generator_cls):
+ """When deterministic=True, JSON dict keys should be sorted at all levels.
+
+ For the ContextGenerator, @context keys use grouped ordering (prefixes
+ before term entries) — each group is sorted, but not globally.
+ """
+ out = generator_cls(SCHEMA, deterministic=True).serialize()
+ parsed = json.loads(out)
+
+ is_context_gen = generator_cls is ContextGenerator
+
+ def _check_sorted_keys(obj, path="root"):
+ if isinstance(obj, dict):
+ keys = list(obj.keys())
+ # Context generator groups @context keys: @-directives, prefixes, terms
+ if is_context_gen and path == "root.@context":
+ at_keys = [k for k in keys if k.startswith("@")]
+ prefix_keys = [k for k in keys if not k.startswith("@") and isinstance(obj[k], str)]
+ term_keys = [k for k in keys if not k.startswith("@") and not isinstance(obj[k], str)]
+ assert at_keys == sorted(at_keys), f"@-keys not sorted: {at_keys}"
+ assert prefix_keys == sorted(prefix_keys), f"Prefix keys not sorted: {prefix_keys}"
+ assert term_keys == sorted(term_keys), f"Term keys not sorted: {term_keys}"
+ else:
+ assert keys == sorted(keys), f"Keys not sorted at {path}: {keys}"
+ for k, v in obj.items():
+ _check_sorted_keys(v, f"{path}.{k}")
+ elif isinstance(obj, list):
+ for i, item in enumerate(obj):
+ _check_sorted_keys(item, f"{path}[{i}]")
+
+ _check_sorted_keys(parsed)
+
+
+@pytest.mark.parametrize(
+ "generator_cls",
+ [ContextGenerator, JSONLDGenerator],
+ ids=["context", "jsonld"],
+)
+def test_deterministic_json_lists_are_sorted(generator_cls):
+ """When deterministic=True, JSON list elements should be sorted.
+
+ Lists under JSON-LD structural keys (``@context``, ``@list``, ``imports``,
+ etc.) are exempt because their ordering carries semantic meaning.
+ """
+ out = generator_cls(SCHEMA, deterministic=True).serialize()
+ parsed = json.loads(out)
+
+ # JSON-LD keys whose array values carry ordering semantics.
+ _ORDERED_KEYS = {"@context", "@list", "@graph", "@set", "imports"}
+
+ def _check_sorted_lists(obj, path="root", parent_key=""):
+ if isinstance(obj, dict):
+ for k, v in obj.items():
+ _check_sorted_lists(v, f"{path}.{k}", parent_key=k)
+ elif isinstance(obj, list):
+ if parent_key not in _ORDERED_KEYS:
+ str_items = [json.dumps(item, sort_keys=True, ensure_ascii=False) for item in obj]
+ assert str_items == sorted(str_items), f"List not sorted at {path}"
+ for i, item in enumerate(obj):
+ _check_sorted_lists(item, f"{path}[{i}]")
+
+ _check_sorted_lists(parsed)
+
+
+@pytest.mark.parametrize(
+ "generator_cls",
+ [OwlSchemaGenerator, ShaclGenerator],
+ ids=["owl", "shacl"],
+)
+def test_deterministic_turtle_preserves_at_prefix(generator_cls):
+ """deterministic_turtle must produce standard @prefix, not SPARQL PREFIX."""
+ out = generator_cls(SCHEMA, deterministic=True).serialize()
+ assert "@prefix" in out, "Output uses non-standard prefix syntax"
+ assert "PREFIX " not in out, "Output uses SPARQL PREFIX instead of Turtle @prefix"
+
+
+def test_deterministic_turtle_performance():
+ """Deterministic OWL generation must complete within 10 seconds for personinfo.
+
+ The Weisfeiler-Lehman approach is O(n log n), so this should easily pass.
+ The previous canon=True approach was exponential and failed this test
+ for graphs above ~250 triples.
+ """
+ start = time.time()
+ out = OwlSchemaGenerator(SCHEMA, deterministic=True).serialize()
+ elapsed = time.time() - start
+ assert elapsed < 10.0, f"Deterministic generation took {elapsed:.1f}s (limit: 10s)"
+ assert len(out) > 100, "Output suspiciously short"
+
+
+def test_shacl_closed_ignored_properties_deterministic():
+ """sh:ignoredProperties in closed shapes must be deterministic.
+
+ ``_build_ignored_properties`` collects inherited slots into a set; without
+ explicit sorting this produces different ``rdf:first``/``rdf:rest`` chains
+ on each run. With ``deterministic=True`` (and sorted Collection inputs)
+ the output must be byte-identical.
+ """
+ runs = [ShaclGenerator(SCHEMA, deterministic=True, closed=True).serialize() for _ in range(3)]
+ assert runs[0] == runs[1] == runs[2], "sh:ignoredProperties ordering differs across runs"
+ assert "sh:ignoredProperties" in runs[0], "Expected closed shapes with sh:ignoredProperties"
+
+
+def test_shacl_enum_in_deterministic():
+ """sh:in RDF lists for enums must be deterministic.
+
+ ``_build_enum_constraint`` iterates ``enum.permissible_values.items()``
+ (dict iteration order) into a ``Collection``. Without sorting, the
+ ``rdf:first``/``rdf:rest`` chain varies across runs.
+ """
+ runs = [ShaclGenerator(SCHEMA, deterministic=True).serialize() for _ in range(3)]
+ assert runs[0] == runs[1] == runs[2], "sh:in enum list ordering differs across runs"
+ assert "sh:in" in runs[0], "Expected sh:in constraints for enums"
+
+
+def test_owl_enum_one_of_deterministic():
+ """owl:oneOf RDF lists for enums must be deterministic.
+
+ ``_boolean_expression`` feeds ``pv_uris`` (from ``permissible_values``)
+ into a ``Collection``. Without sorting, ``owl:oneOf`` list ordering varies.
+ """
+ runs = [OwlSchemaGenerator(SCHEMA, deterministic=True).serialize() for _ in range(3)]
+ assert runs[0] == runs[1] == runs[2], "owl:oneOf enum list ordering differs across runs"
+
+
+KITCHEN_SINK = str(Path(__file__).parent / "input" / "kitchen_sink.yaml")
+
+
+def test_deterministic_large_schema():
+ """End-to-end idempotency on a complex schema (kitchen_sink).
+
+ Exercises many code paths simultaneously: closed shapes, enums, imports,
+ class hierarchies, and mixed ranges.
+ """
+ owl1 = OwlSchemaGenerator(KITCHEN_SINK, deterministic=True).serialize()
+ owl2 = OwlSchemaGenerator(KITCHEN_SINK, deterministic=True).serialize()
+ assert owl1 == owl2, "OWL output differs across runs for kitchen_sink"
+ assert len(owl1) > 500, "kitchen_sink output suspiciously short"
+
+ shacl1 = ShaclGenerator(KITCHEN_SINK, deterministic=True).serialize()
+ shacl2 = ShaclGenerator(KITCHEN_SINK, deterministic=True).serialize()
+ assert shacl1 == shacl2, "SHACL output differs across runs for kitchen_sink"
+ assert len(shacl1) > 500, "kitchen_sink output suspiciously short"
+
+
+def test_deterministic_context_preserves_jsonld_structure():
+ """Deterministic JSON-LD context must preserve conventional structure.
+
+ JSON-LD contexts have a conventional layout:
+ 1. ``comments`` block first (metadata)
+ 2. ``@context`` block second, with prefixes grouped before term entries
+
+ ``deterministic_json()`` would scramble this by sorting all keys
+ uniformly. The context generator must use JSON-LD-aware ordering.
+ """
+ out = ContextGenerator(SCHEMA, deterministic=True, metadata=True).serialize()
+ parsed = json.loads(out)
+
+ # Top-level key order: "comments" before "@context"
+ top_keys = list(parsed.keys())
+ assert "comments" in top_keys, "Expected 'comments' block with metadata=True"
+ assert top_keys.index("comments") < top_keys.index("@context"), (
+ f"'comments' should precede '@context', got: {top_keys}"
+ )
+
+ # Inside @context: @-directives, then prefixes (str values), then terms (dict values)
+ ctx = parsed["@context"]
+ ctx_keys = list(ctx.keys())
+
+ at_keys = [k for k in ctx_keys if k.startswith("@")]
+ prefix_keys = [k for k in ctx_keys if not k.startswith("@") and isinstance(ctx[k], str)]
+ term_keys = [k for k in ctx_keys if not k.startswith("@") and not isinstance(ctx[k], str)]
+
+ # Verify grouping: all @-keys before all prefix keys before all term keys
+ last_at = max(ctx_keys.index(k) for k in at_keys) if at_keys else -1
+ first_prefix = min(ctx_keys.index(k) for k in prefix_keys) if prefix_keys else len(ctx_keys)
+ last_prefix = max(ctx_keys.index(k) for k in prefix_keys) if prefix_keys else -1
+ first_term = min(ctx_keys.index(k) for k in term_keys) if term_keys else len(ctx_keys)
+
+ assert last_at < first_prefix, "@-directives must come before prefixes"
+ assert last_prefix < first_term, "Prefixes must come before term entries"
+
+ # Verify each group is sorted internally
+ assert at_keys == sorted(at_keys), f"@-directives not sorted: {at_keys}"
+ assert prefix_keys == sorted(prefix_keys), f"Prefixes not sorted: {prefix_keys}"
+ assert term_keys == sorted(term_keys), f"Term entries not sorted: {term_keys}"
+
+
+def test_non_deterministic_is_default():
+ """Verify that ``deterministic`` defaults to False."""
+ gen = OwlSchemaGenerator(SCHEMA)
+ assert gen.deterministic is False
+
+
+def test_wl_handles_structurally_similar_bnodes():
+ """Blank nodes with identical local structure but different named neighbours
+ must receive different WL signatures and thus different stable labels.
+
+ This tests the core WL property: two BNodes that differ only in their
+ connected named nodes (URIs/literals) must be distinguishable.
+ """
+ from rdflib import BNode, Graph, Namespace, URIRef
+
+ from linkml.utils.generator import deterministic_turtle
+
+ RDF_TYPE = URIRef("http://www.w3.org/1999/02/22-rdf-syntax-ns#type")
+ OWL_RESTRICTION = URIRef("http://www.w3.org/2002/07/owl#Restriction")
+ OWL_ON_PROP = URIRef("http://www.w3.org/2002/07/owl#onProperty")
+ OWL_ALL_VALUES = URIRef("http://www.w3.org/2002/07/owl#allValuesFrom")
+
+ EX = Namespace("http://example.org/")
+ g = Graph()
+
+ # Two restrictions with same structure but different property URIs
+ r1 = BNode()
+ g.add((r1, RDF_TYPE, OWL_RESTRICTION))
+ g.add((r1, OWL_ON_PROP, EX.alpha))
+ g.add((r1, OWL_ALL_VALUES, EX.Target1))
+
+ r2 = BNode()
+ g.add((r2, RDF_TYPE, OWL_RESTRICTION))
+ g.add((r2, OWL_ON_PROP, EX.beta))
+ g.add((r2, OWL_ALL_VALUES, EX.Target2))
+
+ RDFS_SUBCLASS = URIRef("http://www.w3.org/2000/01/rdf-schema#subClassOf")
+ g.add((EX.MyClass, RDFS_SUBCLASS, r1))
+ g.add((EX.MyClass, RDFS_SUBCLASS, r2))
+
+ # Must be deterministic across runs
+ out1 = deterministic_turtle(g)
+ out2 = deterministic_turtle(g)
+ assert out1 == out2, "WL-based serializer is not deterministic for similar BNodes"
+
+ # Both restrictions must appear (not collapsed)
+ assert "alpha" in out1
+ assert "beta" in out1
+
+
+def test_deterministic_turtle_no_bnodes():
+ """Graphs with no blank nodes should still produce sorted, deterministic output."""
+ from rdflib import Graph, Literal, Namespace
+ from rdflib.namespace import RDFS
+
+ from linkml.utils.generator import deterministic_turtle
+
+ EX = Namespace("http://example.org/")
+ g = Graph()
+ g.add((EX.B, RDFS.label, Literal("B")))
+ g.add((EX.A, RDFS.label, Literal("A")))
+
+ out1 = deterministic_turtle(g)
+ out2 = deterministic_turtle(g)
+ assert out1 == out2
+
+ # A should appear before B (sorted)
+ a_pos = out1.find("example.org/A")
+ b_pos = out1.find("example.org/B")
+ assert a_pos < b_pos, "Triples should be sorted: A before B"
+
+
+@pytest.mark.xfail(
+ reason=(
+ "Collection sorting (owl:oneOf, sh:in) in deterministic mode intentionally "
+ "reorders RDF list triples for canonical output. The resulting graph is "
+ "semantically equivalent (OWL/SHACL interpret these as unordered sets) but "
+ "not RDF-isomorphic because rdf:first/rdf:rest chains encode ordering."
+ ),
+ strict=True,
+)
+@pytest.mark.parametrize(
+ "generator_cls",
+ [OwlSchemaGenerator, ShaclGenerator],
+ ids=["owl", "shacl"],
+)
+def test_deterministic_turtle_is_isomorphic(generator_cls):
+ """Deterministic output is NOT RDF-isomorphic to non-deterministic output.
+
+ This documents the trade-off identified in linkml/linkml#3295 review:
+ deterministic mode sorts Collection inputs (owl:oneOf, sh:in,
+ sh:ignoredProperties) to produce canonical RDF list ordering. Since RDF
+ Collections encode order via rdf:first/rdf:rest triples, the sorted graph
+ is structurally different from the insertion-order graph — even though the
+ OWL/SHACL semantics are identical (these Collections represent sets).
+
+ The test is marked xfail(strict=True) so that it:
+ - Documents the known, intentional non-isomorphism
+ - Alerts maintainers if the behaviour changes (strict xfail fails on pass)
+ """
+ out_det = generator_cls(SCHEMA, deterministic=True).serialize()
+ out_nondet = generator_cls(SCHEMA, deterministic=False).serialize()
+
+ g_det = Graph()
+ g_det.parse(data=out_det, format="turtle")
+
+ g_nondet = Graph()
+ g_nondet.parse(data=out_nondet, format="turtle")
+
+ assert len(g_det) == len(g_nondet), (
+ f"Triple count mismatch: deterministic={len(g_det)}, non-deterministic={len(g_nondet)}"
+ )
+ assert isomorphic(g_det, g_nondet), (
+ f"{generator_cls.__name__}: deterministic output is NOT isomorphic "
+ "to non-deterministic output — the serialization changed the graph"
+ )
+
+
+@pytest.mark.parametrize(
+ "generator_cls",
+ [OwlSchemaGenerator, ShaclGenerator],
+ ids=["owl", "shacl"],
+)
+def test_non_deterministic_output_unchanged(generator_cls):
+ """Non-deterministic output must still produce valid RDF.
+
+ Ensures that changes for deterministic mode don't break default behavior.
+ """
+ out = generator_cls(SCHEMA, deterministic=False).serialize()
+ assert len(out) > 100, "Output suspiciously short"
+ g = Graph()
+ g.parse(data=out, format="turtle")
+ assert len(g) > 50, f"Graph has too few triples ({len(g)})"
+
+
+@pytest.mark.parametrize(
+ "generator_cls,kwargs",
+ [
+ (OwlSchemaGenerator, {}),
+ (ShaclGenerator, {}),
+ (ContextGenerator, {}),
+ (JSONLDGenerator, {}),
+ ],
+ ids=["owl", "shacl", "context", "jsonld"],
+)
+def test_non_deterministic_produces_valid_output(generator_cls, kwargs):
+ """All generators must produce valid output in non-deterministic mode."""
+ out = generator_cls(SCHEMA, deterministic=False, **kwargs).serialize()
+ assert len(out) > 100, f"{generator_cls.__name__} output suspiciously short"
+
+
+@pytest.mark.xfail(
+ reason=(
+ "Collection sorting in deterministic mode produces non-isomorphic RDF "
+ "(different rdf:first/rdf:rest triples). See test_deterministic_turtle_is_isomorphic."
+ ),
+ strict=True,
+)
+@pytest.mark.parametrize(
+ "generator_cls",
+ [OwlSchemaGenerator, ShaclGenerator],
+ ids=["owl", "shacl"],
+)
+def test_deterministic_kitchen_sink_isomorphic(generator_cls):
+ """Isomorphism check on the complex kitchen_sink schema.
+
+ Expected to fail for the same reason as test_deterministic_turtle_is_isomorphic:
+ Collection sorting changes the RDF structure while preserving OWL/SHACL semantics.
+ """
+ out_det = generator_cls(KITCHEN_SINK, deterministic=True).serialize()
+ out_nondet = generator_cls(KITCHEN_SINK, deterministic=False).serialize()
+
+ g_det = Graph()
+ g_det.parse(data=out_det, format="turtle")
+
+ g_nondet = Graph()
+ g_nondet.parse(data=out_nondet, format="turtle")
+
+ assert isomorphic(g_det, g_nondet), (
+ f"{generator_cls.__name__}: kitchen_sink deterministic output is NOT isomorphic to non-deterministic output"
+ )
+
+
+@pytest.mark.skipif(False, reason="does not require pyoxigraph")
+def test_expression_sort_key_is_stable():
+ """``_expression_sort_key`` must produce stable, content-based keys.
+
+ LinkML anonymous expressions inherit ``YAMLRoot.__repr__()``, which
+ formats objects using **field values** (not memory addresses).
+ The ``_expression_sort_key`` helper relies on this for deterministic
+ ordering of ``any_of`` / ``all_of`` / ``none_of`` members.
+
+ This test verifies that:
+ 1. Two distinct objects with identical fields produce the same key.
+ 2. Objects with different fields produce different keys.
+ 3. Sorting is stable across repeated calls.
+ """
+ from linkml.generators.owlgen import _expression_sort_key
+ from linkml_runtime.linkml_model.meta import AnonymousClassExpression, AnonymousSlotExpression
+
+ # Two distinct objects with identical content → same key
+ a1 = AnonymousClassExpression(is_a="Parent")
+ a2 = AnonymousClassExpression(is_a="Parent")
+ assert a1 is not a2
+ assert _expression_sort_key(a1) == _expression_sort_key(a2)
+
+ # Different content → different keys
+ b = AnonymousClassExpression(is_a="Child")
+ assert _expression_sort_key(a1) != _expression_sort_key(b)
+
+ # Sorting stability: same order every time
+ items = [b, a1, a2]
+ for _ in range(5):
+ result = sorted(items, key=_expression_sort_key)
+ # "Child" < "Parent" alphabetically, so b comes first
+ assert _expression_sort_key(result[0]) == _expression_sort_key(b)
+ assert _expression_sort_key(result[1]) == _expression_sort_key(result[2]) # a1, a2 together
+
+ # Slot expressions work too
+ s1 = AnonymousSlotExpression(range="string")
+ s2 = AnonymousSlotExpression(range="integer")
+ assert _expression_sort_key(s1) != _expression_sort_key(s2)
+ order1 = sorted([s2, s1], key=_expression_sort_key)
+ order2 = sorted([s1, s2], key=_expression_sort_key)
+ assert [_expression_sort_key(x) for x in order1] == [_expression_sort_key(x) for x in order2]