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
42 changes: 42 additions & 0 deletions docs/generators/owl.rst
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,48 @@ Other examples
translation of Biolink schema to OWL


Deterministic output
^^^^^^^^^^^^^^^^^^^^

``gen-owl`` output is deterministic by default. The graph is canonicalized with
`RDFC-1.0 <https://www.w3.org/TR/rdf-canon/>`_ before serialization, so repeated
runs over the same schema -- and any two isomorphic graphs -- produce
byte-identical Turtle. No flag is needed, and checked-in artifacts do not churn
between runs.

RDFC-1.0 numbers blank nodes sequentially (``_:c14n0``, ``_:c14n1``, ...) in
canonical order. That is stable for a fixed graph, but inserting a single
statement can shift the numbering of every blank node ordered after it, so an
unrelated one-line schema edit may rewrite large parts of the file. Pass
``--diff-stable`` to derive each label from the node's own neighbourhood
instead, so that only the blank nodes an edit actually touches are renamed:

.. code:: bash

gen-owl --diff-stable schema.yaml

Both modes are deterministic and yield isomorphic graphs; only the choice of
label differs. ``--diff-stable`` is off by default because turning it on
relabels the blank nodes in existing output once.

The same ``--diff-stable/--no-diff-stable`` option is available on ``gen-rdf``,
``gen-shacl`` and ``gen-shex``.

Graphs that are not standard RDF -- literal predicates, as produced by
``gen-shacl`` in annotation mode, or relative IRIs such as the metamodel's
``bibo:status <testing>`` -- cannot be canonicalized under RDFC-1.0. Those fall
back to plain rdflib serialization, with blank-node labels canonicalized by
``rdflib.compare.to_canonical_graph``. Those labels are content-derived rather
than run-local, so the fallback remains reproducible across processes. It emits
an ``RDFCanonicalizationWarning``, and ``--diff-stable`` has no effect on that
path -- it warns rather than silently ignoring the request.

Canonicalization itself is implemented by the
`diffable-rdf <https://github.com/ASCS-eV/diffable-rdf>`_ library;
``linkml_runtime.utils.rdf_canonicalize.canonicalize_rdf_graph`` is a thin
adapter that re-emits the library's log warnings as Python warnings.


Docs
----

Expand Down
28 changes: 27 additions & 1 deletion packages/linkml/src/linkml/generators/owlgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,22 @@ class OwlSchemaGenerator(Generator):
"""Suffix to add to the schema name to create the ontology URI, e.g. .owl.ttl"""

# ObjectVars
diff_stable: bool = False
"""Label blank nodes so that unrelated edits leave them untouched.

Output is already deterministic: RDFC-1.0 guarantees that isomorphic
graphs serialize identically. It does not guarantee that *similar*
graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a
global order, so adding one class can renumber every blank node after
it and rewrite most of the file.

When ``True``, blank-node labels are instead derived from each node's
own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels
only the blank nodes it actually touches. The output stays
deterministic and isomorphic either way; only the choice of label
changes. Off by default because enabling it relabels existing output.
"""

metadata_profile: MetadataProfile | None = None
"""Deprecated - use metadata_profiles."""

Expand Down Expand Up @@ -353,7 +369,7 @@ def serialize(self, **kwargs: Any) -> str:
"""
self.as_graph()
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
return canonicalize_rdf_graph(self.graph, output_format=fmt)
return canonicalize_rdf_graph(self.graph, output_format=fmt, diff_stable=self.diff_stable)

def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None:
"""
Expand Down Expand Up @@ -1844,6 +1860,16 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef:
"specified language tag. Element-level in_language overrides this."
),
)
@click.option(
"--diff-stable/--no-diff-stable",
default=False,
show_default=True,
help=(
"Derive blank-node labels from each node's own neighbourhood so that "
"unrelated edits leave them unchanged. Output is deterministic either "
"way; this makes successive versions of a file diff cleanly."
),
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile: str, metadata_profile: str, **kwargs: Any) -> None:
"""Generate an OWL representation of a LinkML model
Expand Down
28 changes: 27 additions & 1 deletion packages/linkml/src/linkml/generators/rdfgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,22 @@ class RDFGenerator(Generator):
uses_schemaloader = True

# ObjectVars
diff_stable: bool = False
"""Label blank nodes so that unrelated edits leave them untouched.

Output is already deterministic: RDFC-1.0 guarantees that isomorphic
graphs serialize identically. It does not guarantee that *similar*
graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a
global order, so adding one class can renumber every blank node after
it and rewrite most of the file.

When ``True``, blank-node labels are instead derived from each node's
own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels
only the blank nodes it actually touches. The output stays
deterministic and isomorphic either way; only the choice of label
changes. Off by default because enabling it relabels existing output.
"""

emit_metadata: bool = False
context: list[str] = None
original_schema: SchemaDefinition = None
Expand All @@ -89,7 +105,7 @@ def __post_init__(self):

def _data(self, g: Graph) -> str:
fmt = "turtle" if self.format == "ttl" else self.format
return canonicalize_rdf_graph(g, output_format=fmt)
return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable)

def end_schema(self, output: str | None = None, context: str = None, **_) -> str:
gen = JSONLDGenerator(
Expand Down Expand Up @@ -137,6 +153,16 @@ def end_schema(self, output: str | None = None, context: str = None, **_) -> str
multiple=True,
help="JSONLD context file (default: vendored meta.context.jsonld)",
)
@click.option(
"--diff-stable/--no-diff-stable",
default=False,
show_default=True,
help=(
"Derive blank-node labels from each node's own neighbourhood so that "
"unrelated edits leave them unchanged. Output is deterministic either "
"way; this makes successive versions of a file diff cleanly."
),
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile, **kwargs):
"""Generate an RDF representation of a LinkML model"""
Expand Down
28 changes: 27 additions & 1 deletion packages/linkml/src/linkml/generators/shaclgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ class ShaclGenerator(Generator):
ignores any per-slot ``in_language``.
"""

diff_stable: bool = False
"""Label blank nodes so that unrelated edits leave them untouched.

Output is already deterministic: RDFC-1.0 guarantees that isomorphic
graphs serialize identically. It does not guarantee that *similar*
graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a
global order, so adding one class can renumber every blank node after
it and rewrite most of the file.

When ``True``, blank-node labels are instead derived from each node's
own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels
only the blank nodes it actually touches. The output stays
deterministic and isomorphic either way; only the choice of label
changes. Off by default because enabling it relabels existing output.
"""

emit_rules: bool = True
"""Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks.

Expand Down Expand Up @@ -196,7 +212,7 @@ 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
return canonicalize_rdf_graph(g, output_format=fmt)
return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable)

def as_graph(self) -> Graph:
sv = self.schemaview
Expand Down Expand Up @@ -929,6 +945,16 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None:
"sh:NodeShape. Use --no-emit-rules to suppress rule generation."
),
)
@click.option(
"--diff-stable/--no-diff-stable",
default=False,
show_default=True,
help=(
"Derive blank-node labels from each node's own neighbourhood so that "
"unrelated edits leave them unchanged. Output is deterministic either "
"way; this makes successive versions of a file diff cleanly."
),
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile, **args):
"""Generate SHACL turtle from a LinkML model"""
Expand Down
28 changes: 27 additions & 1 deletion packages/linkml/src/linkml/generators/shexgen.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,22 @@ class ShExGenerator(Generator):
uses_schemaloader = True

# ObjectVars
diff_stable: bool = False
"""Label blank nodes so that unrelated edits leave them untouched.

Output is already deterministic: RDFC-1.0 guarantees that isomorphic
graphs serialize identically. It does not guarantee that *similar*
graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a
global order, so adding one class can renumber every blank node after
it and rewrite most of the file.

When ``True``, blank-node labels are instead derived from each node's
own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels
only the blank nodes it actually touches. The output stays
deterministic and isomorphic either way; only the choice of label
changes. Off by default because enabling it relabels existing output.
"""

shex: Schema = field(default_factory=lambda: Schema()) # ShEx Schema being generated
shapes: list = field(default_factory=lambda: [])
shape: Shape | None = None # Current shape being defined
Expand Down Expand Up @@ -177,7 +193,7 @@ def end_schema(self, output: str | None = None, **_) -> str:
g = Graph()
g.parse(data=shex, format="json-ld", version="1.1")
g.bind("owl", OWL)
shex = canonicalize_rdf_graph(g, output_format="turtle")
shex = canonicalize_rdf_graph(g, output_format="turtle", diff_stable=self.diff_stable)
elif self.format == "shex":
g = Graph()
self.namespaces.load_graph(g)
Expand Down Expand Up @@ -258,6 +274,16 @@ def _get_subproperty_values(self, slot: SlotDefinition) -> list:
help="If --expand-subproperty-of (default), slots with subproperty_of will generate NodeConstraint "
"values containing all slot descendants. Use --no-expand-subproperty-of to disable this behavior.",
)
@click.option(
"--diff-stable/--no-diff-stable",
default=False,
show_default=True,
help=(
"Derive blank-node labels from each node's own neighbourhood so that "
"unrelated edits leave them unchanged. Output is deterministic either "
"way; this makes successive versions of a file diff cleanly."
),
)
@click.version_option(__version__, "-V", "--version")
def cli(yamlfile, **args):
"""Generate a ShEx Schema for a LinkML model"""
Expand Down
1 change: 1 addition & 0 deletions packages/linkml_runtime/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ dependencies = [
"prefixmaps >=0.1.4",
"curies>=0.14.6",
"pyoxigraph>=0.5.11",
"diffable-rdf>=0.4.0",
"pydantic>=2.13.5,<3.0.0",
"isodate >=0.7.2, <1.0.0; python_version < '3.11'",
]
Expand Down
Loading
Loading