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
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
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@

import pyoxigraph as ox
import rdflib
from diffable_rdf import wl_relabel_quads
from rdflib.compare import to_canonical_graph


Expand Down Expand Up @@ -287,6 +288,7 @@ def _is_safe_prefix_iri(iri: str) -> bool:
def canonicalize_rdf_graph(
graph: rdflib.Graph,
output_format: str = "turtle",
diff_stable: bool = False,
) -> str:
"""Serialize an rdflib Graph deterministically using RDFC-1.0 canonicalization.

Expand All @@ -300,6 +302,13 @@ def canonicalize_rdf_graph(

:param graph: The rdflib Graph to serialize.
:param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``).
:param diff_stable: Derive blank-node labels from each node's own
neighbourhood instead of RDFC-1.0's global ``c14nN`` numbering, so that
editing one part of a schema does not renumber unrelated blank nodes.
Output is deterministic and isomorphic either way; only the choice of
label changes. Off by default because enabling it relabels existing
output. Has no effect on the rdflib fallback path (non-standard RDF),
which warns rather than silently ignoring the request.
:return: Deterministic string serialization of the graph.
"""
ox_format = _FORMAT_MAP.get(output_format.lower())
Expand Down Expand Up @@ -330,6 +339,23 @@ def canonicalize_rdf_graph(
RDFCanonicalizationWarning,
stacklevel=2,
)
if diff_stable:
# Weisfeiler-Lehman refinement consumes canonical pyoxigraph quads,
# and this path exists precisely because pyoxigraph refused the
# graph, so there are none to refine. The fallback is deterministic
# but not diff-stable: say so rather than returning output that
# silently ignores the argument. ``shaclgen --include-annotations``
# reaches this path, because an annotation tag without a ``:``
# becomes a literal predicate.
warnings.warn(
"diff_stable=True was requested but this graph took the rdflib fallback, "
"which cannot apply Weisfeiler-Lehman blank-node labels. Output is "
"deterministic but NOT diff-stable: an unrelated edit may still renumber "
"blank nodes. Make the offending terms standard RDF (absolute IRIs, IRI "
"predicates) to get diff-stable labels.",
RDFCanonicalizationWarning,
stacklevel=2,
)
return _deterministic_fallback_serialize(graph, output_format)

dataset = ox.Dataset()
Expand All @@ -339,13 +365,26 @@ def canonicalize_rdf_graph(
# 3. Canonicalize blank node labels with RDFC-1.0.
dataset.canonicalize(ox.CanonicalizationAlgorithm.RDFC_1_0)

quads = list(dataset)

# 3b. Optionally re-label blank nodes with locality-sensitive hashes.
# RDFC-1.0 guarantees that identical graphs get identical labels, but it
# does not guarantee that *similar* graphs get similar labels: the labels
# are assigned by a global ordering, so inserting one blank node can
# renumber every label after it and turn a one-line semantic change into a
# whole-file diff. Weisfeiler-Lehman labels are derived only from each
# node's local neighbourhood, so unrelated regions keep their labels.
# Output stays deterministic and isomorphic either way; this only changes
# which label each blank node receives.
if diff_stable:
quads = wl_relabel_quads(quads)

# 4. Sort triples for deterministic ordering.
# RDFC-1.0 stabilizes blank-node labels but pyoxigraph's Dataset
# iteration order is not sorted and varies across processes (verified
# empirically against pyoxigraph 0.5.8). The explicit string-key sort
# is load-bearing for byte-identical output across runs; see
# tests/linkml_runtime/test_utils/test_rdf_canonicalize.py::test_sort_is_load_bearing.
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)),
Expand Down
Loading
Loading