Skip to content

Commit eac6364

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 07a7b2b commit eac6364

8 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
@@ -236,7 +236,61 @@ def end_schema(
236236
with open(frame_path, "w", encoding="UTF-8") as f:
237237
json.dump(frame, f, indent=2, ensure_ascii=False)
238238

239-
return str(as_json(context)) + "\n"
239+
if self.deterministic:
240+
return self._deterministic_context_json(json.loads(str(as_json(context))), indent=3)
241+
return str(as_json(context))
242+
243+
@staticmethod
244+
def _deterministic_context_json(data: dict, indent: int = 3) -> str:
245+
"""Serialize a JSON-LD context with deterministic key ordering.
246+
247+
Preserves the conventional JSON-LD context structure:
248+
1. ``comments`` block first (metadata)
249+
2. ``@context`` block second, with:
250+
a. ``@``-prefixed directives (``@vocab``, ``@base``) first
251+
b. Prefix declarations (string values) second
252+
c. Class/property term entries (object values) last
253+
3. Each group sorted alphabetically within itself
254+
255+
Unlike :func:`deterministic_json`, this understands JSON-LD
256+
conventions so that the output remains human-readable while
257+
still being byte-identical across invocations.
258+
"""
259+
from linkml.utils.generator import deterministic_json
260+
261+
ordered = {}
262+
263+
# 1. "comments" first (if present)
264+
if "comments" in data:
265+
ordered["comments"] = data["comments"]
266+
267+
# 2. "@context" with structured internal ordering
268+
if "@context" in data:
269+
ctx = data["@context"]
270+
ordered_ctx = {}
271+
272+
# 2a. @-prefixed directives (@vocab, @base, etc.)
273+
for k in sorted(k for k in ctx if k.startswith("@")):
274+
ordered_ctx[k] = ctx[k]
275+
276+
# 2b. Prefix declarations (string values — short namespace URIs)
277+
for k in sorted(k for k in ctx if not k.startswith("@") and isinstance(ctx[k], str)):
278+
ordered_ctx[k] = ctx[k]
279+
280+
# 2c. Term definitions (object values) — deep-sorted for determinism
281+
term_entries = {k: v for k, v in ctx.items() if not k.startswith("@") and not isinstance(v, str)}
282+
sorted_terms = json.loads(deterministic_json(term_entries))
283+
for k in sorted(sorted_terms):
284+
ordered_ctx[k] = sorted_terms[k]
285+
286+
ordered["@context"] = ordered_ctx
287+
288+
# 3. Any remaining top-level keys
289+
for k in sorted(data):
290+
if k not in ordered:
291+
ordered[k] = data[k]
292+
293+
return json.dumps(ordered, indent=indent, ensure_ascii=False)
240294

241295
def visit_class(self, cls: ClassDefinition) -> bool:
242296
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
@@ -203,6 +204,10 @@ def end_schema(self, context: str | Sequence[str] | None = None, context_kwargs:
203204
self.schema["@context"].append({"@base": base_prefix})
204205
# json_obj["@id"] = self.schema.id
205206
out = str(as_json(self.schema, indent=" ")) + "\n"
207+
if self.deterministic:
208+
from linkml.utils.generator import deterministic_json
209+
210+
out = deterministic_json(json.loads(out), indent=2) + "\n"
206211
self.schema = self.original_schema
207212
return out
208213

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

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

4748
logger = logging.getLogger(__name__)
4849

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

5758

59+
def _expression_sort_key(expr: YAMLRoot) -> str:
60+
"""Return a stable sort key for LinkML anonymous expressions.
61+
62+
Used by ``--deterministic`` to order ``any_of``, ``all_of``,
63+
``none_of``, and ``exactly_one_of`` members reproducibly.
64+
65+
This relies on ``YAMLRoot.__repr__()`` which formats objects using
66+
their **field values** (not memory addresses). All anonymous
67+
expression dataclasses in ``linkml_runtime.linkml_model.meta``
68+
use ``@dataclass(repr=False)`` and inherit this field-based repr,
69+
so the output is deterministic across runs.
70+
"""
71+
return repr(expr)
72+
73+
5874
@unique
5975
class MetadataProfile(Enum):
6076
"""
@@ -300,7 +316,14 @@ def serialize(self, **kwargs: Any) -> str:
300316
"""
301317
self.as_graph()
302318
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
303-
return canonicalize_rdf_graph(self.graph, output_format=fmt)
319+
if self.deterministic and fmt == "turtle":
320+
# Deferred to avoid circular import (generator.py imports from this package)
321+
from linkml.utils.generator import deterministic_turtle
322+
323+
data = deterministic_turtle(self.graph)
324+
else:
325+
data = canonicalize_rdf_graph(self.graph, output_format=fmt)
326+
return data
304327

305328
def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None:
306329
"""
@@ -568,25 +591,29 @@ def transform_class_expression(
568591
own_slots = self.get_own_slots(cls)
569592
owl_exprs: list[OWL_EXPRESSION] = []
570593
if cls.any_of:
571-
any_of_expr = self._union_of([self.transform_class_expression(x) for x in cls.any_of])
594+
members = list(cls.any_of)
595+
if self.deterministic:
596+
members = sorted(members, key=_expression_sort_key)
597+
any_of_expr = self._union_of([self.transform_class_expression(x) for x in members])
572598
if any_of_expr:
573599
owl_exprs.append(any_of_expr)
574600
if cls.exactly_one_of:
575-
sub_exprs: list[OWL_EXPRESSION] = self._present(
576-
self.transform_class_expression(x) for x in cls.exactly_one_of
577-
)
601+
members = list(cls.exactly_one_of)
602+
if self.deterministic:
603+
members = sorted(members, key=_expression_sort_key)
604+
sub_exprs: list[OWL_EXPRESSION] = self._present(self.transform_class_expression(x) for x in members)
578605
if isinstance(cls, ClassDefinition):
579606
cls_uri = self._class_uri(cls.name)
580607
listnode = BNode()
581608
Collection(graph, listnode, sub_exprs)
582609
graph.add((cls_uri, OWL.disjointUnionOf, listnode))
583610
else:
584611
sub_sub_exprs: list[OWL_EXPRESSION] = []
585-
for i, x in enumerate(cls.exactly_one_of):
612+
for i, x in enumerate(members):
586613
operand_expr = self.transform_class_expression(x)
587614
if not operand_expr:
588615
continue
589-
rest = cls.exactly_one_of[0:i] + cls.exactly_one_of[i + 1 :]
616+
rest = members[0:i] + members[i + 1 :]
590617
neg_expr = self._complement_of_union_of([self.transform_class_expression(nx) for nx in rest])
591618
pos_expr = self._intersection_of([operand_expr, neg_expr])
592619
if pos_expr:
@@ -596,11 +623,17 @@ def transform_class_expression(
596623
owl_exprs.append(union_expr)
597624
# owl_exprs.extend(sub_exprs)
598625
if cls.all_of:
599-
all_of_expr = self._intersection_of([self.transform_class_expression(x) for x in cls.all_of])
626+
members = list(cls.all_of)
627+
if self.deterministic:
628+
members = sorted(members, key=_expression_sort_key)
629+
all_of_expr = self._intersection_of([self.transform_class_expression(x) for x in members])
600630
if all_of_expr:
601631
owl_exprs.append(all_of_expr)
602632
if cls.none_of:
603-
none_of_expr = self._complement_of_union_of([self.transform_class_expression(x) for x in cls.none_of])
633+
members = list(cls.none_of)
634+
if self.deterministic:
635+
members = sorted(members, key=_expression_sort_key)
636+
none_of_expr = self._complement_of_union_of([self.transform_class_expression(x) for x in members])
604637
if none_of_expr:
605638
owl_exprs.append(none_of_expr)
606639
for slot in own_slots:
@@ -773,19 +806,29 @@ def _get_slot_nodes(
773806
)
774807
return rdflib_nodes or None
775808

776-
if any_of_rdflib_nodes := _get_slot_nodes(slot.any_of):
809+
def _maybe_sort_slots(
810+
slot_definitions: Sequence[SlotDefinition | AnonymousSlotExpression] | None,
811+
) -> Sequence[SlotDefinition | AnonymousSlotExpression] | None:
812+
if slot_definitions and self.deterministic:
813+
return sorted(slot_definitions, key=_expression_sort_key)
814+
return slot_definitions
815+
816+
if any_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.any_of)):
777817
owl_exprs.append(self._union_of(any_of_rdflib_nodes))
778-
if all_of_rdflib_nodes := _get_slot_nodes(slot.all_of):
818+
if all_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.all_of)):
779819
owl_exprs.append(self._intersection_of(all_of_rdflib_nodes))
780-
if none_of_rdflib_nodes := _get_slot_nodes(slot.none_of):
820+
if none_of_rdflib_nodes := _get_slot_nodes(_maybe_sort_slots(slot.none_of)):
781821
owl_exprs.append(self._complement_of_union_of(none_of_rdflib_nodes))
782822
if slot.exactly_one_of:
823+
members = list(slot.exactly_one_of)
824+
if self.deterministic:
825+
members = sorted(members, key=_expression_sort_key)
783826
disj_exprs: list[OWL_EXPRESSION] = []
784-
for i, operand in enumerate(slot.exactly_one_of):
827+
for i, operand in enumerate(members):
785828
operand_expr = self.transform_class_slot_expression(cls, operand, main_slot, owl_types)
786829
if not operand_expr:
787830
continue
788-
rest = slot.exactly_one_of[0:i] + slot.exactly_one_of[i + 1 :]
831+
rest = members[0:i] + members[i + 1 :]
789832
neg_expr = self._complement_of_union_of(
790833
[self.transform_class_slot_expression(cls, x, main_slot, owl_types) for x in rest],
791834
owl_types=owl_types,
@@ -1059,7 +1102,10 @@ def add_enum(self, e: EnumDefinition) -> None:
10591102
owl_types: list[URIRef | None] = []
10601103
enum_owl_type = self._get_metatype(e, self.default_permissible_value_type)
10611104

1062-
for pv in e.permissible_values.values():
1105+
pvs = e.permissible_values.values()
1106+
if self.deterministic:
1107+
pvs = sorted(pvs, key=lambda x: x.text)
1108+
for pv in pvs:
10631109
pv_owl_type = self._get_metatype(pv, enum_owl_type)
10641110
owl_types.append(pv_owl_type)
10651111
if pv_owl_type == RDFS.Literal:

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,13 @@ def generate_header(self) -> str:
9595
def serialize(self, **args) -> str:
9696
g = self.as_graph()
9797
fmt = "turtle" if self.format in ["owl", "ttl"] else self.format
98-
return canonicalize_rdf_graph(g, output_format=fmt)
98+
if self.deterministic and fmt == "turtle":
99+
from linkml.utils.generator import deterministic_turtle
100+
101+
data = deterministic_turtle(g)
102+
else:
103+
data = canonicalize_rdf_graph(g, output_format=fmt)
104+
return data
99105

100106
def as_graph(self) -> Graph:
101107
sv = self.schemaview
@@ -313,13 +319,13 @@ def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None:
313319
sv = self.schemaview
314320
enum = sv.get_enum(r)
315321
pv_node = BNode()
322+
pv_items = list(enum.permissible_values.items())
323+
if self.deterministic:
324+
pv_items = sorted(pv_items, key=lambda x: x[0])
316325
Collection(
317326
g,
318327
pv_node,
319-
[
320-
URIRef(sv.expand_curie(pv.meaning)) if pv.meaning else Literal(pv_name)
321-
for pv_name, pv in enum.permissible_values.items()
322-
],
328+
[URIRef(sv.expand_curie(pv.meaning)) if pv.meaning else Literal(pv_name) for pv_name, pv in pv_items],
323329
)
324330
func(SH["in"], pv_node)
325331

@@ -473,7 +479,10 @@ def collect_child_properties(class_name: str, output: set) -> None:
473479

474480
list_node = BNode()
475481
ignored_properties.add(RDF.type)
476-
Collection(g, list_node, list(ignored_properties))
482+
props = list(ignored_properties)
483+
if self.deterministic:
484+
props = sorted(props, key=str)
485+
Collection(g, list_node, props)
477486

478487
return list_node
479488

0 commit comments

Comments
 (0)