compare.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647
  1. """
  2. A collection of utilities for canonicalizing and inspecting graphs.
  3. Among other things, they solve of the problem of deterministic bnode
  4. comparisons.
  5. Warning: the time to canonicalize bnodes may increase exponentially on
  6. degenerate larger graphs. Use with care!
  7. Example of comparing two graphs:
  8. ```python
  9. >>> g1 = Graph().parse(format='n3', data='''
  10. ... @prefix : <http://example.org/ns#> .
  11. ... <http://example.org> :rel
  12. ... <http://example.org/same>,
  13. ... [ :label "Same" ],
  14. ... <http://example.org/a>,
  15. ... [ :label "A" ] .
  16. ... ''')
  17. >>> g2 = Graph().parse(format='n3', data='''
  18. ... @prefix : <http://example.org/ns#> .
  19. ... <http://example.org> :rel
  20. ... <http://example.org/same>,
  21. ... [ :label "Same" ],
  22. ... <http://example.org/b>,
  23. ... [ :label "B" ] .
  24. ... ''')
  25. >>>
  26. >>> iso1 = to_isomorphic(g1)
  27. >>> iso2 = to_isomorphic(g2)
  28. ```
  29. These are not isomorphic
  30. ```python
  31. >>> iso1 == iso2
  32. False
  33. ```
  34. Diff the two graphs:
  35. ```python
  36. >>> in_both, in_first, in_second = graph_diff(iso1, iso2)
  37. ```
  38. Present in both:
  39. ```python
  40. >>> def dump_nt_sorted(g):
  41. ... for l in sorted(g.serialize(format='nt').splitlines()):
  42. ... if l: print(l.decode('ascii'))
  43. >>> dump_nt_sorted(in_both) #doctest: +SKIP
  44. <http://example.org>
  45. <http://example.org/ns#rel> <http://example.org/same> .
  46. <http://example.org>
  47. <http://example.org/ns#rel> _:cbcaabaaba17fecbc304a64f8edee4335e .
  48. _:cbcaabaaba17fecbc304a64f8edee4335e
  49. <http://example.org/ns#label> "Same" .
  50. ```
  51. Only in first:
  52. ```python
  53. >>> dump_nt_sorted(in_first) #doctest: +SKIP
  54. <http://example.org>
  55. <http://example.org/ns#rel> <http://example.org/a> .
  56. <http://example.org>
  57. <http://example.org/ns#rel> _:cb124e4c6da0579f810c0ffe4eff485bd9 .
  58. _:cb124e4c6da0579f810c0ffe4eff485bd9
  59. <http://example.org/ns#label> "A" .
  60. ```
  61. Only in second:
  62. ```python
  63. >>> dump_nt_sorted(in_second) #doctest: +SKIP
  64. <http://example.org>
  65. <http://example.org/ns#rel> <http://example.org/b> .
  66. <http://example.org>
  67. <http://example.org/ns#rel> _:cb558f30e21ddfc05ca53108348338ade8 .
  68. _:cb558f30e21ddfc05ca53108348338ade8
  69. <http://example.org/ns#label> "B" .
  70. ```
  71. """
  72. from __future__ import annotations
  73. # TODO:
  74. # - Doesn't handle quads.
  75. # - Add warning and/or safety mechanism before working on large graphs?
  76. # - use this in existing Graph.isomorphic?
  77. __all__ = [
  78. "IsomorphicGraph",
  79. "to_isomorphic",
  80. "isomorphic",
  81. "to_canonical_graph",
  82. "graph_diff",
  83. "similar",
  84. ]
  85. from collections import defaultdict
  86. from datetime import datetime
  87. from hashlib import sha256
  88. from typing import (
  89. TYPE_CHECKING,
  90. Callable,
  91. Dict,
  92. Iterator,
  93. List,
  94. Optional,
  95. Set,
  96. Tuple,
  97. Union,
  98. )
  99. from rdflib.graph import ConjunctiveGraph, Graph, ReadOnlyGraphAggregate, _TripleType
  100. from rdflib.term import BNode, IdentifiedNode, Node, URIRef
  101. if TYPE_CHECKING:
  102. from _hashlib import HASH
  103. def _total_seconds(td):
  104. result = td.days * 24 * 60 * 60
  105. result += td.seconds
  106. result += td.microseconds / 1000000.0
  107. return result
  108. class _runtime: # noqa: N801
  109. def __init__(self, label):
  110. self.label = label
  111. def __call__(self, f):
  112. if self.label is None:
  113. self.label = f.__name__ + "_runtime"
  114. def wrapped_f(*args, **kwargs):
  115. start = datetime.now()
  116. result = f(*args, **kwargs)
  117. if "stats" in kwargs and kwargs["stats"] is not None:
  118. stats = kwargs["stats"]
  119. stats[self.label] = _total_seconds(datetime.now() - start)
  120. return result
  121. return wrapped_f
  122. class _call_count: # noqa: N801
  123. def __init__(self, label):
  124. self.label = label
  125. def __call__(self, f):
  126. if self.label is None:
  127. self.label = f.__name__ + "_runtime"
  128. def wrapped_f(*args, **kwargs):
  129. if "stats" in kwargs and kwargs["stats"] is not None:
  130. stats = kwargs["stats"]
  131. if self.label not in stats:
  132. stats[self.label] = 0
  133. stats[self.label] += 1
  134. return f(*args, **kwargs)
  135. return wrapped_f
  136. class IsomorphicGraph(ConjunctiveGraph):
  137. """An implementation of the RGDA1 graph digest algorithm.
  138. An implementation of RGDA1 (publication below),
  139. a combination of Sayers & Karp's graph digest algorithm using
  140. sum and SHA-256 <http://www.hpl.hp.com/techreports/2003/HPL-2003-235R1.pdf>
  141. and traces <http://pallini.di.uniroma1.it>, an average case
  142. polynomial time algorithm for graph canonicalization.
  143. McCusker, J. P. (2015). WebSig: A Digital Signature Framework for the Web.
  144. Rensselaer Polytechnic Institute, Troy, NY.
  145. http://gradworks.umi.com/3727015.pdf
  146. """
  147. def __init__(self, **kwargs):
  148. super(IsomorphicGraph, self).__init__(**kwargs)
  149. def __eq__(self, other):
  150. """Graph isomorphism testing."""
  151. if not isinstance(other, IsomorphicGraph):
  152. return False
  153. elif len(self) != len(other):
  154. return False
  155. return self.internal_hash() == other.internal_hash()
  156. def __ne__(self, other):
  157. """Negative graph isomorphism testing."""
  158. return not self.__eq__(other)
  159. def __hash__(self):
  160. return super(IsomorphicGraph, self).__hash__()
  161. def graph_digest(self, stats=None):
  162. """Synonym for IsomorphicGraph.internal_hash."""
  163. return self.internal_hash(stats=stats)
  164. def internal_hash(self, stats=None):
  165. """
  166. This is defined instead of `__hash__` to avoid a circular recursion
  167. scenario with the Memory store for rdflib which requires a hash lookup
  168. in order to return a generator of triples.
  169. """
  170. return _TripleCanonicalizer(self).to_hash(stats=stats)
  171. HashFunc = Callable[[str], int]
  172. ColorItem = Tuple[Union[int, str], URIRef, Union[int, str]]
  173. ColorItemTuple = Tuple[ColorItem, ...]
  174. HashCache = Optional[Dict[ColorItemTuple, str]]
  175. Stats = Dict[str, Union[int, str]]
  176. class Color:
  177. def __init__(
  178. self,
  179. nodes: List[IdentifiedNode],
  180. hashfunc: HashFunc,
  181. color: ColorItemTuple = (),
  182. hash_cache: HashCache = None,
  183. ):
  184. if hash_cache is None:
  185. hash_cache = {}
  186. self._hash_cache = hash_cache
  187. self.color = color
  188. self.nodes = nodes
  189. self.hashfunc = hashfunc
  190. self._hash_color = None
  191. def __str__(self):
  192. nodes, color = self.key()
  193. return "Color %s (%s nodes)" % (color, nodes)
  194. def key(self):
  195. return (len(self.nodes), self.hash_color())
  196. def hash_color(self, color: Optional[Tuple[ColorItem, ...]] = None) -> str:
  197. if color is None:
  198. color = self.color
  199. if color in self._hash_cache:
  200. return self._hash_cache[color]
  201. def stringify(x):
  202. if isinstance(x, Node):
  203. return x.n3()
  204. else:
  205. return str(x)
  206. if isinstance(color, Node):
  207. return stringify(color)
  208. value = 0
  209. for triple in color:
  210. value += self.hashfunc(" ".join([stringify(x) for x in triple]))
  211. val: str = "%x" % value
  212. self._hash_cache[color] = val
  213. return val
  214. def distinguish(self, W: Color, graph: Graph): # noqa: N803
  215. colors: Dict[str, Color] = {}
  216. for n in self.nodes:
  217. new_color: Tuple[ColorItem, ...] = list(self.color) # type: ignore[assignment]
  218. for node in W.nodes:
  219. new_color += [ # type: ignore[operator]
  220. (1, p, W.hash_color()) for s, p, o in graph.triples((n, None, node))
  221. ]
  222. new_color += [ # type: ignore[operator]
  223. (W.hash_color(), p, 3) for s, p, o in graph.triples((node, None, n))
  224. ]
  225. new_color = tuple(new_color)
  226. new_hash_color = self.hash_color(new_color)
  227. if new_hash_color not in colors:
  228. c = Color([], self.hashfunc, new_color, hash_cache=self._hash_cache)
  229. colors[new_hash_color] = c
  230. colors[new_hash_color].nodes.append(n)
  231. return colors.values()
  232. def discrete(self):
  233. return len(self.nodes) == 1
  234. def copy(self):
  235. return Color(
  236. self.nodes[:], self.hashfunc, self.color, hash_cache=self._hash_cache
  237. )
  238. _HashT = Callable[[], "HASH"]
  239. class _TripleCanonicalizer:
  240. def __init__(self, graph: Graph, hashfunc: _HashT = sha256):
  241. self.graph = graph
  242. def _hashfunc(s: str):
  243. h = hashfunc()
  244. h.update(str(s).encode("utf8"))
  245. return int(h.hexdigest(), 16)
  246. self._hash_cache: HashCache = {}
  247. self.hashfunc = _hashfunc
  248. def _discrete(self, coloring: List[Color]) -> bool:
  249. return len([c for c in coloring if not c.discrete()]) == 0
  250. def _initial_color(self) -> List[Color]:
  251. """Finds an initial color for the graph.
  252. Finds an initial color of the graph by finding all blank nodes and
  253. non-blank nodes that are adjacent. Nodes that are not adjacent to blank
  254. nodes are not included, as they are a) already colored (by URI or literal)
  255. and b) do not factor into the color of any blank node.
  256. """
  257. bnodes: Set[BNode] = set()
  258. others = set()
  259. self._neighbors = defaultdict(set)
  260. for s, p, o in self.graph:
  261. nodes = set([s, p, o])
  262. b = set([x for x in nodes if isinstance(x, BNode)])
  263. if len(b) > 0:
  264. others |= nodes - b
  265. bnodes |= b
  266. if isinstance(s, BNode):
  267. self._neighbors[s].add(o)
  268. if isinstance(o, BNode):
  269. self._neighbors[o].add(s)
  270. if isinstance(p, BNode):
  271. self._neighbors[p].add(s)
  272. self._neighbors[p].add(p)
  273. if len(bnodes) > 0:
  274. return [Color(list(bnodes), self.hashfunc, hash_cache=self._hash_cache)] + [
  275. # type error: List item 0 has incompatible type "Union[IdentifiedNode, Literal]"; expected "IdentifiedNode"
  276. # type error: Argument 3 to "Color" has incompatible type "Union[IdentifiedNode, Literal]"; expected "Tuple[Tuple[Union[int, str], URIRef, Union[int, str]], ...]"
  277. Color([x], self.hashfunc, x, hash_cache=self._hash_cache) # type: ignore[list-item, arg-type]
  278. for x in others
  279. ]
  280. else:
  281. return []
  282. def _individuate(self, color, individual):
  283. new_color = list(color.color)
  284. new_color.append((len(color.nodes),))
  285. color.nodes.remove(individual)
  286. c = Color(
  287. [individual], self.hashfunc, tuple(new_color), hash_cache=self._hash_cache
  288. )
  289. return c
  290. def _get_candidates(self, coloring: List[Color]) -> Iterator[Tuple[Node, Color]]:
  291. for c in [c for c in coloring if not c.discrete()]:
  292. for node in c.nodes:
  293. yield node, c
  294. def _refine(self, coloring: List[Color], sequence: List[Color]) -> List[Color]:
  295. sequence = sorted(sequence, key=lambda x: x.key(), reverse=True)
  296. coloring = coloring[:]
  297. while len(sequence) > 0 and not self._discrete(coloring):
  298. W = sequence.pop() # noqa: N806
  299. for c in coloring[:]:
  300. if len(c.nodes) > 1 or isinstance(c.nodes[0], BNode):
  301. colors = sorted(
  302. c.distinguish(W, self.graph),
  303. key=lambda x: x.key(),
  304. reverse=True,
  305. )
  306. coloring.remove(c)
  307. coloring.extend(colors)
  308. try:
  309. si = sequence.index(c)
  310. sequence = sequence[:si] + colors + sequence[si + 1 :]
  311. except ValueError:
  312. sequence = colors[1:] + sequence
  313. combined_colors: List[Color] = []
  314. combined_color_map: Dict[str, Color] = dict()
  315. for color in coloring:
  316. color_hash = color.hash_color()
  317. # This is a hash collision, and be combined into a single color for individuation.
  318. if color_hash in combined_color_map:
  319. combined_color_map[color_hash].nodes.extend(color.nodes)
  320. else:
  321. combined_colors.append(color)
  322. combined_color_map[color_hash] = color
  323. return combined_colors
  324. @_runtime("to_hash_runtime")
  325. def to_hash(self, stats: Optional[Stats] = None):
  326. result = 0
  327. for triple in self.canonical_triples(stats=stats):
  328. result += self.hashfunc(" ".join([x.n3() for x in triple]))
  329. if stats is not None:
  330. stats["graph_digest"] = "%x" % result
  331. return result
  332. def _experimental_path(self, coloring: List[Color]) -> List[Color]:
  333. coloring = [c.copy() for c in coloring]
  334. while not self._discrete(coloring):
  335. color = [x for x in coloring if not x.discrete()][0]
  336. node = color.nodes[0]
  337. new_color = self._individuate(color, node)
  338. coloring.append(new_color)
  339. coloring = self._refine(coloring, [new_color])
  340. return coloring
  341. def _create_generator(
  342. self,
  343. colorings: List[List[Color]],
  344. groupings: Optional[Dict[Node, Set[Node]]] = None,
  345. ) -> Dict[Node, Set[Node]]:
  346. if not groupings:
  347. groupings = defaultdict(set)
  348. for group in zip(*colorings):
  349. g = set([c.nodes[0] for c in group])
  350. for n in group:
  351. g |= groupings[n]
  352. for n in g:
  353. groupings[n] = g
  354. return groupings
  355. @_call_count("individuations")
  356. def _traces(
  357. self,
  358. coloring: List[Color],
  359. stats: Optional[Stats] = None,
  360. depth: List[int] = [0],
  361. ) -> List[Color]:
  362. if stats is not None and "prunings" not in stats:
  363. stats["prunings"] = 0
  364. depth[0] += 1
  365. candidates = self._get_candidates(coloring)
  366. best: List[List[Color]] = []
  367. best_score = None
  368. best_experimental_score = None
  369. last_coloring = None
  370. generator: Dict[Node, Set[Node]] = defaultdict(set)
  371. visited: Set[Node] = set()
  372. for candidate, color in candidates:
  373. if candidate in generator:
  374. v = generator[candidate] & visited
  375. if len(v) > 0:
  376. visited.add(candidate)
  377. continue
  378. visited.add(candidate)
  379. coloring_copy: List[Color] = []
  380. color_copy = None
  381. for c in coloring:
  382. c_copy = c.copy()
  383. coloring_copy.append(c_copy)
  384. if c == color:
  385. color_copy = c_copy
  386. new_color = self._individuate(color_copy, candidate)
  387. coloring_copy.append(new_color)
  388. refined_coloring = self._refine(coloring_copy, [new_color])
  389. color_score = tuple([c.key() for c in refined_coloring])
  390. experimental = self._experimental_path(coloring_copy)
  391. experimental_score = set([c.key() for c in experimental])
  392. if last_coloring:
  393. # type error: Statement is unreachable
  394. generator = self._create_generator( # type: ignore[unreachable]
  395. [last_coloring, experimental], generator
  396. )
  397. last_coloring = experimental
  398. if best_score is None or best_score < color_score: # type: ignore[unreachable]
  399. best = [refined_coloring]
  400. best_score = color_score
  401. best_experimental_score = experimental_score
  402. elif best_score > color_score: # type: ignore[unreachable]
  403. # prune this branch.
  404. if stats is not None and isinstance(stats["prunings"], int):
  405. stats["prunings"] += 1
  406. elif experimental_score != best_experimental_score:
  407. best.append(refined_coloring)
  408. else:
  409. # prune this branch.
  410. if stats is not None and isinstance(stats["prunings"], int):
  411. stats["prunings"] += 1
  412. discrete: List[List[Color]] = [x for x in best if self._discrete(x)]
  413. if len(discrete) == 0:
  414. best_score = None
  415. best_depth = None
  416. for coloring in best:
  417. d = [depth[0]]
  418. new_color = self._traces(coloring, stats=stats, depth=d)
  419. color_score = tuple([c.key() for c in refined_coloring])
  420. if best_score is None or color_score > best_score: # type: ignore[unreachable]
  421. discrete = [new_color]
  422. best_score = color_score
  423. best_depth = d[0]
  424. depth[0] = best_depth # type: ignore[assignment]
  425. return discrete[0]
  426. def canonical_triples(self, stats: Optional[Stats] = None):
  427. if stats is not None:
  428. start_coloring = datetime.now()
  429. coloring = self._initial_color()
  430. if stats is not None:
  431. stats["triple_count"] = len(self.graph)
  432. stats["adjacent_nodes"] = max(0, len(coloring) - 1)
  433. coloring = self._refine(coloring, coloring[:])
  434. if stats is not None:
  435. stats["initial_coloring_runtime"] = _total_seconds(
  436. datetime.now() - start_coloring
  437. )
  438. stats["initial_color_count"] = len(coloring)
  439. if not self._discrete(coloring):
  440. depth = [0]
  441. coloring = self._traces(coloring, stats=stats, depth=depth)
  442. if stats is not None:
  443. stats["tree_depth"] = depth[0]
  444. elif stats is not None:
  445. stats["individuations"] = 0
  446. stats["tree_depth"] = 0
  447. if stats is not None:
  448. stats["color_count"] = len(coloring)
  449. bnode_labels: Dict[Node, str] = dict(
  450. [(c.nodes[0], c.hash_color()) for c in coloring]
  451. )
  452. if stats is not None:
  453. stats["canonicalize_triples_runtime"] = _total_seconds(
  454. datetime.now() - start_coloring
  455. )
  456. for triple in self.graph:
  457. result = tuple(self._canonicalize_bnodes(triple, bnode_labels))
  458. yield result
  459. def _canonicalize_bnodes(
  460. self,
  461. triple: _TripleType,
  462. labels: Dict[Node, str],
  463. ):
  464. for term in triple:
  465. if isinstance(term, BNode):
  466. yield BNode(value="cb%s" % labels[term])
  467. else:
  468. yield term
  469. def to_isomorphic(graph: Graph) -> IsomorphicGraph:
  470. if isinstance(graph, IsomorphicGraph):
  471. return graph
  472. result = IsomorphicGraph()
  473. if hasattr(graph, "identifier"):
  474. result = IsomorphicGraph(identifier=graph.identifier)
  475. result += graph
  476. return result
  477. def isomorphic(graph1: Graph, graph2: Graph) -> bool:
  478. """Compare graph for equality.
  479. Uses an algorithm to compute unique hashes which takes bnodes into account.
  480. Example:
  481. ```python
  482. >>> g1 = Graph().parse(format='n3', data='''
  483. ... @prefix : <http://example.org/ns#> .
  484. ... <http://example.org> :rel <http://example.org/a> .
  485. ... <http://example.org> :rel <http://example.org/b> .
  486. ... <http://example.org> :rel [ :label "A bnode." ] .
  487. ... ''')
  488. >>> g2 = Graph().parse(format='n3', data='''
  489. ... @prefix ns: <http://example.org/ns#> .
  490. ... <http://example.org> ns:rel [ ns:label "A bnode." ] .
  491. ... <http://example.org> ns:rel <http://example.org/b>,
  492. ... <http://example.org/a> .
  493. ... ''')
  494. >>> isomorphic(g1, g2)
  495. True
  496. >>> g3 = Graph().parse(format='n3', data='''
  497. ... @prefix : <http://example.org/ns#> .
  498. ... <http://example.org> :rel <http://example.org/a> .
  499. ... <http://example.org> :rel <http://example.org/b> .
  500. ... <http://example.org> :rel <http://example.org/c> .
  501. ... ''')
  502. >>> isomorphic(g1, g3)
  503. False
  504. ```
  505. """
  506. gd1 = _TripleCanonicalizer(graph1).to_hash()
  507. gd2 = _TripleCanonicalizer(graph2).to_hash()
  508. return gd1 == gd2
  509. def to_canonical_graph(
  510. g1: Graph, stats: Optional[Stats] = None
  511. ) -> ReadOnlyGraphAggregate:
  512. """Creates a canonical, read-only graph.
  513. Creates a canonical, read-only graph where all bnode id:s are based on
  514. deterministical SHA-256 checksums, correlated with the graph contents.
  515. """
  516. graph = Graph()
  517. graph += _TripleCanonicalizer(g1).canonical_triples(stats=stats)
  518. return ReadOnlyGraphAggregate([graph])
  519. def graph_diff(g1: Graph, g2: Graph) -> Tuple[Graph, Graph, Graph]:
  520. """Returns three sets of triples: "in both", "in first" and "in second"."""
  521. # bnodes have deterministic values in canonical graphs:
  522. cg1 = to_canonical_graph(g1)
  523. cg2 = to_canonical_graph(g2)
  524. in_both = cg1 * cg2
  525. in_first = cg1 - cg2
  526. in_second = cg2 - cg1
  527. return (in_both, in_first, in_second)
  528. _MOCK_BNODE = BNode()
  529. def similar(g1: Graph, g2: Graph):
  530. """Checks if the two graphs are "similar".
  531. Checks if the two graphs are "similar", by comparing sorted triples where
  532. all bnodes have been replaced by a singular mock bnode (the
  533. `_MOCK_BNODE`).
  534. This is a much cheaper, but less reliable, alternative to the comparison
  535. algorithm in `isomorphic`.
  536. """
  537. return all(t1 == t2 for (t1, t2) in _squashed_graphs_triples(g1, g2))
  538. def _squashed_graphs_triples(g1: Graph, g2: Graph):
  539. for t1, t2 in zip(sorted(_squash_graph(g1)), sorted(_squash_graph(g2))):
  540. yield t1, t2
  541. def _squash_graph(graph: Graph):
  542. return (_squash_bnodes(triple) for triple in graph)
  543. def _squash_bnodes(triple):
  544. return tuple((isinstance(t, BNode) and _MOCK_BNODE) or t for t in triple)