Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.w3.org/TR/rdf-canon/>`_ canonicalization (via
`pyoxigraph <https://pypi.org/project/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 <https://www.w3.org/TR/turtle/#BNodes>`_), collection
syntax (`§2.8 <https://www.w3.org/TR/turtle/#collections>`_) — 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
----

Expand Down
56 changes: 55 additions & 1 deletion packages/linkml/src/linkml/generators/jsonldcontextgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions packages/linkml/src/linkml/generators/jsonldgen.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Generate JSONld from a LinkML schema."""

import json
import os
from collections.abc import Sequence
from copy import deepcopy
Expand Down Expand Up @@ -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

Expand Down
73 changes: 58 additions & 15 deletions packages/linkml/src/linkml/generators/owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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__)

Expand All @@ -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):
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -654,25 +674,29 @@ 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()
Collection(graph, listnode, sub_exprs)
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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/linkml/src/linkml/generators/rdfgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions packages/linkml/src/linkml/generators/shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion packages/linkml/src/linkml/generators/shexgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading
Loading