Skip to content

Commit f9b74a0

Browse files
committed
feat(generators): add --deterministic flag with diff-stable WL hashing
Layers Weisfeiler-Lehman structural hashing on top of upstream RDFC-1.0 canonicalization (linkml#3407) to produce diff-stable blank node identifiers. RDFC-1.0 remains always-on as the default serialization; the --deterministic flag adds WL hashing for version-controlled artifacts. Three-phase pipeline (deterministic_turtle): 1. RDFC-1.0 via pyoxigraph — canonical triple ordering 2. WL structural hashing — content-based blank node IDs 3. rdflib re-serialization — idiomatic Turtle syntax Additional --deterministic behaviours: - deterministic_json() for JSON-LD context output - Sorted owl:oneOf, sh:in, sh:ignoredProperties members - Sorted any_of/exactly_one_of expression members in OWL Fixes from review (#1): - Remove trailing newline from context generator return values (avoids double-newline when CLI prints output) - Sort WL collision counter by signature, not c14n ID (prevents unrelated triples from swapping _0/_1 suffixes) - Remove dead code: well_known_prefix_map(), normalize_prefixes (belong in separate --normalize-prefixes PR) Signed-off-by: Carlo van Driesten <carlo.van-driesten@bmw.de>
1 parent 1f14ff5 commit f9b74a0

9 files changed

Lines changed: 1325 additions & 46 deletions

File tree

packages/linkml/src/linkml/generators/jsonldcontextgen.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,61 @@ def end_schema(
309309
with open(frame_path, "w", encoding="UTF-8") as f:
310310
json.dump(frame, f, indent=2, ensure_ascii=False)
311311

312-
return str(as_json(context)) + "\n"
312+
if self.deterministic:
313+
return self._deterministic_context_json(json.loads(str(as_json(context))), indent=3)
314+
return str(as_json(context))
315+
316+
@staticmethod
317+
def _deterministic_context_json(data: dict, indent: int = 3) -> str:
318+
"""Serialize a JSON-LD context with deterministic key ordering.
319+
320+
Preserves the conventional JSON-LD context structure:
321+
1. ``comments`` block first (metadata)
322+
2. ``@context`` block second, with:
323+
a. ``@``-prefixed directives (``@vocab``, ``@base``) first
324+
b. Prefix declarations (string values) second
325+
c. Class/property term entries (object values) last
326+
3. Each group sorted alphabetically within itself
327+
328+
Unlike :func:`deterministic_json`, this understands JSON-LD
329+
conventions so that the output remains human-readable while
330+
still being byte-identical across invocations.
331+
"""
332+
from linkml.utils.generator import deterministic_json
333+
334+
ordered = {}
335+
336+
# 1. "comments" first (if present)
337+
if "comments" in data:
338+
ordered["comments"] = data["comments"]
339+
340+
# 2. "@context" with structured internal ordering
341+
if "@context" in data:
342+
ctx = data["@context"]
343+
ordered_ctx = {}
344+
345+
# 2a. @-prefixed directives (@vocab, @base, etc.)
346+
for k in sorted(k for k in ctx if k.startswith("@")):
347+
ordered_ctx[k] = ctx[k]
348+
349+
# 2b. Prefix declarations (string values — short namespace URIs)
350+
for k in sorted(k for k in ctx if not k.startswith("@") and isinstance(ctx[k], str)):
351+
ordered_ctx[k] = ctx[k]
352+
353+
# 2c. Term definitions (object values) — deep-sorted for determinism
354+
term_entries = {k: v for k, v in ctx.items() if not k.startswith("@") and not isinstance(v, str)}
355+
sorted_terms = json.loads(deterministic_json(term_entries))
356+
for k in sorted(sorted_terms):
357+
ordered_ctx[k] = sorted_terms[k]
358+
359+
ordered["@context"] = ordered_ctx
360+
361+
# 3. Any remaining top-level keys
362+
for k in sorted(data):
363+
if k not in ordered:
364+
ordered[k] = data[k]
365+
366+
return json.dumps(ordered, indent=indent, ensure_ascii=False)
313367

314368
def visit_class(self, cls: ClassDefinition) -> bool:
315369
if self.exclude_imports and cls.name not in self._local_classes:

packages/linkml/src/linkml/generators/jsonldgen.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Generate JSONld from a LinkML schema."""
22

3+
import json
34
import os
45
from collections.abc import Sequence
56
from copy import deepcopy
@@ -205,6 +206,10 @@ def end_schema(self, context: str | Sequence[str] | None = None, context_kwargs:
205206
self.schema["@context"].append({"@base": base_prefix})
206207
# json_obj["@id"] = self.schema.id
207208
out = str(as_json(self.schema, indent=" ")) + "\n"
209+
if self.deterministic:
210+
from linkml.utils.generator import deterministic_json
211+
212+
out = deterministic_json(json.loads(out), indent=2) + "\n"
208213
self.schema = self.original_schema
209214
return out
210215

packages/linkml/src/linkml/generators/owlgen.py

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from linkml_runtime.utils.formatutils import camelcase, underscore
4545
from linkml_runtime.utils.introspection import package_schemaview
4646
from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph
47+
from linkml_runtime.utils.yamlutils import YAMLRoot
4748

4849
logger = logging.getLogger(__name__)
4950

@@ -56,6 +57,21 @@
5657
SWRLB = rdflib.Namespace("http://www.w3.org/2003/11/swrlb#")
5758

5859

60+
def _expression_sort_key(expr: YAMLRoot) -> str:
61+
"""Return a stable sort key for LinkML anonymous expressions.
62+
63+
Used by ``--deterministic`` to order ``any_of``, ``all_of``,
64+
``none_of``, and ``exactly_one_of`` members reproducibly.
65+
66+
This relies on ``YAMLRoot.__repr__()`` which formats objects using
67+
their **field values** (not memory addresses). All anonymous
68+
expression dataclasses in ``linkml_runtime.linkml_model.meta``
69+
use ``@dataclass(repr=False)`` and inherit this field-based repr,
70+
so the output is deterministic across runs.
71+
"""
72+
return repr(expr)
73+
74+
5975
@unique
6076
class MetadataProfile(Enum):
6177
"""
@@ -376,7 +392,14 @@ def serialize(self, **kwargs: Any) -> str:
376392
"""
377393
self.as_graph()
378394
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
379-
return canonicalize_rdf_graph(self.graph, output_format=fmt)
395+
if self.deterministic and fmt == "turtle":
396+
# Deferred to avoid circular import (generator.py imports from this package)
397+
from linkml.utils.generator import deterministic_turtle
398+
399+
data = deterministic_turtle(self.graph)
400+
else:
401+
data = canonicalize_rdf_graph(self.graph, output_format=fmt)
402+
return data
380403

381404
def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None:
382405
"""
@@ -673,25 +696,29 @@ def transform_class_expression(
673696
own_slots = self.get_own_slots(cls)
674697
owl_exprs: list[OWL_EXPRESSION] = []
675698
if cls.any_of:
676-
any_of_expr = self._union_of([self.transform_class_expression(x) for x in cls.any_of])
699+
members = list(cls.any_of)
700+
if self.deterministic:
701+
members = sorted(members, key=_expression_sort_key)
702+
any_of_expr = self._union_of([self.transform_class_expression(x) for x in members])
677703
if any_of_expr:
678704
owl_exprs.append(any_of_expr)
679705
if cls.exactly_one_of:
680-
sub_exprs: list[OWL_EXPRESSION] = self._present(
681-
self.transform_class_expression(x) for x in cls.exactly_one_of
682-
)
706+
members = list(cls.exactly_one_of)
707+
if self.deterministic:
708+
members = sorted(members, key=_expression_sort_key)
709+
sub_exprs: list[OWL_EXPRESSION] = self._present(self.transform_class_expression(x) for x in members)
683710
if isinstance(cls, ClassDefinition):
684711
cls_uri = self._class_uri(cls.name)
685712
listnode = BNode()
686713
Collection(graph, listnode, sub_exprs)
687714
graph.add((cls_uri, OWL.disjointUnionOf, listnode))
688715
else:
689716
sub_sub_exprs: list[OWL_EXPRESSION] = []
690-
for i, x in enumerate(cls.exactly_one_of):
717+
for i, x in enumerate(members):
691718
operand_expr = self.transform_class_expression(x)
692719
if not operand_expr:
693720
continue
694-
rest = cls.exactly_one_of[0:i] + cls.exactly_one_of[i + 1 :]
721+
rest = members[0:i] + members[i + 1 :]
695722
neg_expr = self._complement_of_union_of([self.transform_class_expression(nx) for nx in rest])
696723
pos_expr = self._intersection_of([operand_expr, neg_expr])
697724
if pos_expr:
@@ -701,11 +728,17 @@ def transform_class_expression(
701728
owl_exprs.append(union_expr)
702729
# owl_exprs.extend(sub_exprs)
703730
if cls.all_of:
704-
all_of_expr = self._intersection_of([self.transform_class_expression(x) for x in cls.all_of])
731+
members = list(cls.all_of)
732+
if self.deterministic:
733+
members = sorted(members, key=_expression_sort_key)
734+
all_of_expr = self._intersection_of([self.transform_class_expression(x) for x in members])
705735
if all_of_expr:
706736
owl_exprs.append(all_of_expr)
707737
if cls.none_of:
708-
none_of_expr = self._complement_of_union_of([self.transform_class_expression(x) for x in cls.none_of])
738+
members = list(cls.none_of)
739+
if self.deterministic:
740+
members = sorted(members, key=_expression_sort_key)
741+
none_of_expr = self._complement_of_union_of([self.transform_class_expression(x) for x in members])
709742
if none_of_expr:
710743
owl_exprs.append(none_of_expr)
711744
for slot in own_slots:
@@ -878,19 +911,29 @@ def _get_slot_nodes(
878911
)
879912
return rdflib_nodes or None
880913

881-
if any_of_rdflib_nodes := _get_slot_nodes(slot.any_of):
914+
def _maybe_sort_slots(
915+
slot_definitions: Sequence[SlotDefinition | AnonymousSlotExpression] | None,
916+
) -> Sequence[SlotDefinition | AnonymousSlotExpression] | None:
917+
if slot_definitions and self.deterministic:
918+
return sorted(slot_definitions, key=_expression_sort_key)
919+
return slot_definitions
920+
921+
if any_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.any_of)):
882922
owl_exprs.append(self._union_of(any_of_rdflib_nodes))
883-
if all_of_rdflib_nodes := _get_slot_nodes(slot.all_of):
923+
if all_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.all_of)):
884924
owl_exprs.append(self._intersection_of(all_of_rdflib_nodes))
885-
if none_of_rdflib_nodes := _get_slot_nodes(slot.none_of):
925+
if none_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.none_of)):
886926
owl_exprs.append(self._complement_of_union_of(none_of_rdflib_nodes))
887927
if slot.exactly_one_of:
928+
members = list(slot.exactly_one_of)
929+
if self.deterministic:
930+
members = sorted(members, key=_expression_sort_key)
888931
disj_exprs: list[OWL_EXPRESSION] = []
889-
for i, operand in enumerate(slot.exactly_one_of):
932+
for i, operand in enumerate(members):
890933
operand_expr = self.transform_class_slot_expression(cls, operand, main_slot, owl_types)
891934
if not operand_expr:
892935
continue
893-
rest = slot.exactly_one_of[0:i] + slot.exactly_one_of[i + 1 :]
936+
rest = members[0:i] + members[i + 1 :]
894937
neg_expr = self._complement_of_union_of(
895938
[self.transform_class_slot_expression(cls, x, main_slot, owl_types) for x in rest],
896939
owl_types=owl_types,
@@ -1164,7 +1207,10 @@ def add_enum(self, e: EnumDefinition) -> None:
11641207
owl_types: list[URIRef | None] = []
11651208
enum_owl_type = self._get_metatype(e, self.default_permissible_value_type)
11661209

1167-
for pv in e.permissible_values.values():
1210+
pvs = e.permissible_values.values()
1211+
if self.deterministic:
1212+
pvs = sorted(pvs, key=lambda x: x.text)
1213+
for pv in pvs:
11681214
pv_owl_type = self._get_metatype(pv, enum_owl_type)
11691215
owl_types.append(pv_owl_type)
11701216
if pv_owl_type == RDFS.Literal:

packages/linkml/src/linkml/generators/shaclgen.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,13 @@ def generate_header(self) -> str:
184184
def serialize(self, **args) -> str:
185185
g = self.as_graph()
186186
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
187-
return canonicalize_rdf_graph(g, output_format=fmt)
187+
if self.deterministic and fmt == "turtle":
188+
from linkml.utils.generator import deterministic_turtle
189+
190+
data = deterministic_turtle(g)
191+
else:
192+
data = canonicalize_rdf_graph(g, output_format=fmt)
193+
return data
188194

189195
def as_graph(self) -> Graph:
190196
sv = self.schemaview
@@ -652,13 +658,13 @@ def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None:
652658
sv = self.schemaview
653659
enum = sv.get_enum(r)
654660
pv_node = BNode()
661+
pv_items = list(enum.permissible_values.items())
662+
if self.deterministic:
663+
pv_items = sorted(pv_items, key=lambda x: x[0])
655664
Collection(
656665
g,
657666
pv_node,
658-
[
659-
URIRef(sv.expand_curie(pv.meaning)) if pv.meaning else Literal(pv_name)
660-
for pv_name, pv in enum.permissible_values.items()
661-
],
667+
[URIRef(sv.expand_curie(pv.meaning)) if pv.meaning else Literal(pv_name) for pv_name, pv in pv_items],
662668
)
663669
func(SH["in"], pv_node)
664670

@@ -817,7 +823,10 @@ def collect_child_properties(class_name: str, output: set) -> None:
817823

818824
list_node = BNode()
819825
ignored_properties.add(RDF.type)
820-
Collection(g, list_node, list(ignored_properties))
826+
props = list(ignored_properties)
827+
if self.deterministic:
828+
props = sorted(props, key=str)
829+
Collection(g, list_node, props)
821830

822831
return list_node
823832

@@ -904,7 +913,6 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None:
904913
"are translated into SHACL-SPARQL constraints on the corresponding "
905914
"sh:NodeShape. Use --no-emit-rules to suppress rule generation."
906915
),
907-
),
908916
)
909917
@click.version_option(__version__, "-V", "--version")
910918
def cli(yamlfile, **args):

0 commit comments

Comments
 (0)