diff env/lib/python3.7/site-packages/rdflib/compare.py @ 0:26e78fe6e8c4 draft

"planemo upload commit c699937486c35866861690329de38ec1a5d9f783"
author shellac
date Sat, 02 May 2020 07:14:21 -0400
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/env/lib/python3.7/site-packages/rdflib/compare.py	Sat May 02 07:14:21 2020 -0400
@@ -0,0 +1,562 @@
+# -*- coding: utf-8 -*-
+"""
+A collection of utilities for canonicalizing and inspecting graphs.
+
+Among other things, they solve of the problem of deterministic bnode
+comparisons.
+
+Warning: the time to canonicalize bnodes may increase exponentially on
+degenerate larger graphs. Use with care!
+
+Example of comparing two graphs::
+
+    >>> g1 = Graph().parse(format='n3', data='''
+    ...     @prefix : <http://example.org/ns#> .
+    ...     <http://example.org> :rel
+    ...         <http://example.org/same>,
+    ...         [ :label "Same" ],
+    ...         <http://example.org/a>,
+    ...         [ :label "A" ] .
+    ... ''')
+    >>> g2 = Graph().parse(format='n3', data='''
+    ...     @prefix : <http://example.org/ns#> .
+    ...     <http://example.org> :rel
+    ...         <http://example.org/same>,
+    ...         [ :label "Same" ],
+    ...         <http://example.org/b>,
+    ...         [ :label "B" ] .
+    ... ''')
+    >>>
+    >>> iso1 = to_isomorphic(g1)
+    >>> iso2 = to_isomorphic(g2)
+
+These are not isomorphic::
+
+    >>> iso1 == iso2
+    False
+
+Diff the two graphs::
+
+    >>> in_both, in_first, in_second = graph_diff(iso1, iso2)
+
+Present in both::
+
+    >>> def dump_nt_sorted(g):
+    ...     for l in sorted(g.serialize(format='nt').splitlines()):
+    ...         if l: print(l.decode('ascii'))
+
+    >>> dump_nt_sorted(in_both) #doctest: +SKIP
+    <http://example.org>
+        <http://example.org/ns#rel> <http://example.org/same> .
+    <http://example.org>
+        <http://example.org/ns#rel> _:cbcaabaaba17fecbc304a64f8edee4335e .
+    _:cbcaabaaba17fecbc304a64f8edee4335e
+        <http://example.org/ns#label> "Same" .
+
+Only in first::
+
+    >>> dump_nt_sorted(in_first) #doctest: +SKIP
+    <http://example.org>
+        <http://example.org/ns#rel> <http://example.org/a> .
+    <http://example.org>
+        <http://example.org/ns#rel> _:cb124e4c6da0579f810c0ffe4eff485bd9 .
+    _:cb124e4c6da0579f810c0ffe4eff485bd9
+        <http://example.org/ns#label> "A" .
+
+Only in second::
+
+    >>> dump_nt_sorted(in_second) #doctest: +SKIP
+    <http://example.org>
+        <http://example.org/ns#rel> <http://example.org/b> .
+    <http://example.org>
+        <http://example.org/ns#rel> _:cb558f30e21ddfc05ca53108348338ade8 .
+    _:cb558f30e21ddfc05ca53108348338ade8
+        <http://example.org/ns#label> "B" .
+"""
+
+
+# TODO:
+# - Doesn't handle quads.
+# - Add warning and/or safety mechanism before working on large graphs?
+# - use this in existing Graph.isomorphic?
+
+__all__ = ['IsomorphicGraph', 'to_isomorphic', 'isomorphic',
+           'to_canonical_graph', 'graph_diff', 'similar']
+
+from rdflib.graph import Graph, ConjunctiveGraph, ReadOnlyGraphAggregate
+from rdflib.term import BNode, Node
+try:
+    import hashlib
+    sha256 = hashlib.sha256
+except ImportError:
+    # for Python << 2.5
+    import sha256
+    sha256 = sha256.new
+
+from datetime import datetime
+from collections import defaultdict
+
+
+def _total_seconds(td):
+    result = td.days * 24 * 60 * 60
+    result += td.seconds
+    result += td.microseconds / 1000000.0
+    return result
+
+
+class _runtime(object):
+    def __init__(self, label):
+        self.label = label
+
+    def __call__(self, f):
+        if self.label is None:
+            self.label = f.__name__ + "_runtime"
+
+        def wrapped_f(*args, **kwargs):
+            start = datetime.now()
+            result = f(*args, **kwargs)
+            if 'stats' in kwargs and kwargs['stats'] is not None:
+                stats = kwargs['stats']
+                stats[self.label] = _total_seconds(datetime.now() - start)
+            return result
+        return wrapped_f
+
+
+class _call_count(object):
+    def __init__(self, label):
+        self.label = label
+
+    def __call__(self, f):
+        if self.label is None:
+            self.label = f.__name__ + "_runtime"
+
+        def wrapped_f(*args, **kwargs):
+            if 'stats' in kwargs and kwargs['stats'] is not None:
+                stats = kwargs['stats']
+                if self.label not in stats:
+                    stats[self.label] = 0
+                stats[self.label] += 1
+            return f(*args, **kwargs)
+        return wrapped_f
+
+
+class IsomorphicGraph(ConjunctiveGraph):
+    """An implementation of the RGDA1 graph digest algorithm.
+
+    An implementation of RGDA1 (publication below),
+    a combination of Sayers & Karp's graph digest algorithm using
+    sum and SHA-256 <http://www.hpl.hp.com/techreports/2003/HPL-2003-235R1.pdf>
+    and traces <http://pallini.di.uniroma1.it>, an average case
+    polynomial time algorithm for graph canonicalization.
+    
+    McCusker, J. P. (2015). WebSig: A Digital Signature Framework for the Web.
+    Rensselaer Polytechnic Institute, Troy, NY.
+    http://gradworks.umi.com/3727015.pdf
+    """
+
+    def __init__(self, **kwargs):
+        super(IsomorphicGraph, self).__init__(**kwargs)
+
+    def __eq__(self, other):
+        """Graph isomorphism testing."""
+        if not isinstance(other, IsomorphicGraph):
+            return False
+        elif len(self) != len(other):
+            return False
+        return self.internal_hash() == other.internal_hash()
+
+    def __ne__(self, other):
+        """Negative graph isomorphism testing."""
+        return not self.__eq__(other)
+
+    def graph_digest(self, stats=None):
+        """Synonym for IsomorphicGraph.internal_hash."""
+        return self.internal_hash(stats=stats)
+
+    def internal_hash(self, stats=None):
+        """
+        This is defined instead of __hash__ to avoid a circular recursion
+        scenario with the Memory store for rdflib which requires a hash lookup
+        in order to return a generator of triples.
+        """
+        return _TripleCanonicalizer(self).to_hash(stats=stats)
+
+
+class Color:
+    def __init__(self, nodes, hashfunc, color=(), hash_cache=None):
+        if hash_cache is None:
+            hash_cache = {}
+        self._hash_cache = hash_cache
+        self.color = color
+        self.nodes = nodes
+        self.hashfunc = hashfunc
+        self._hash_color = None
+
+    def key(self):
+        return (len(self.nodes), self.hash_color())
+
+    def hash_color(self, color=None):
+        if color is None:
+            color = self.color
+        if color in self._hash_cache:
+            return self._hash_cache[color]
+
+        def stringify(x):
+            if isinstance(x, Node):
+                return x.n3()
+            else:
+                return str(x)
+        if isinstance(color, Node):
+            return stringify(color)
+        value = 0
+        for triple in color:
+            value += self.hashfunc(' '.join([stringify(x) for x in triple]))
+        val = "%x" % value
+        self._hash_cache[color] = val
+        return val
+
+    def distinguish(self, W, graph):
+        colors = {}
+        for n in self.nodes:
+            new_color = list(self.color)
+            for node in W.nodes:
+                new_color += [
+                    (1, p, W.hash_color())
+                    for s, p, o in graph.triples((n, None, node))]
+                new_color += [
+                    (W.hash_color(), p, 3)
+                    for s, p, o in graph.triples((node, None, n))]
+            new_color = tuple(new_color)
+            new_hash_color = self.hash_color(new_color)
+
+            if new_hash_color not in colors:
+                c = Color(
+                    [], self.hashfunc, new_color,
+                    hash_cache=self._hash_cache)
+                colors[new_hash_color] = c
+            colors[new_hash_color].nodes.append(n)
+        return list(colors.values())
+
+    def discrete(self):
+        return len(self.nodes) == 1
+
+    def copy(self):
+        return Color(
+            self.nodes[:], self.hashfunc, self.color,
+            hash_cache=self._hash_cache)
+
+
+class _TripleCanonicalizer(object):
+
+    def __init__(self, graph, hashfunc=sha256):
+        self.graph = graph
+
+        def _hashfunc(s):
+            h = hashfunc()
+            h.update(str(s).encode("utf8"))
+            return int(h.hexdigest(), 16)
+        self._hash_cache = {}
+        self.hashfunc = _hashfunc
+
+
+    def _discrete(self, coloring):
+        return len([c for c in coloring if not c.discrete()]) == 0
+
+
+    def _initial_color(self):
+        """Finds an initial color for the graph.
+
+        Finds an initial color fo the graph by finding all blank nodes and
+        non-blank nodes that are adjacent. Nodes that are not adjacent to blank
+        nodes are not included, as they are a) already colored (by URI or literal)
+        and b) do not factor into the color of any blank node.
+        """
+        bnodes = set()
+        others = set()
+        self._neighbors = defaultdict(set)
+        for s, p, o in self.graph:
+            nodes = set([s, o])
+            b = set([x for x in nodes if isinstance(x, BNode)])
+            if len(b) > 0:
+                others |= nodes - b
+                bnodes |= b
+                if isinstance(s, BNode):
+                    self._neighbors[s].add(o)
+                if isinstance(o, BNode):
+                    self._neighbors[o].add(s)
+        if len(bnodes) > 0:
+            return [
+                    Color(list(bnodes), self.hashfunc, hash_cache=self._hash_cache)
+                ] + [
+                    Color([x], self.hashfunc, x, hash_cache=self._hash_cache)
+                    for x in others
+                ]
+        else:
+            return []
+
+    def _individuate(self, color, individual):
+        new_color = list(color.color)
+        new_color.append((len(color.nodes),))
+
+        color.nodes.remove(individual)
+        c = Color([individual], self.hashfunc, tuple(new_color),
+                  hash_cache=self._hash_cache)
+        return c
+
+    def _get_candidates(self, coloring):
+        candidates = [c for c in coloring if not c.discrete()]
+        for c in [c for c in coloring if not c.discrete()]:
+            for node in c.nodes:
+                yield node, c
+
+    def _refine(self, coloring, sequence):
+        sequence = sorted(sequence, key=lambda x: x.key(), reverse=True)
+        coloring = coloring[:]
+        while len(sequence) > 0 and not self._discrete(coloring):
+            W = sequence.pop()
+            for c in coloring[:]:
+                if len(c.nodes) > 1:
+                    colors = sorted(c.distinguish(W, self.graph),
+                                    key=lambda x: x.key(),
+                                    reverse=True)
+                    coloring.remove(c)
+                    coloring.extend(colors)
+                    try:
+                        si = sequence.index(c)
+                        sequence = sequence[:si] + colors + sequence[si+1:]
+                    except ValueError:
+                        sequence = colors[1:] + sequence
+
+        return coloring
+
+    @_runtime("to_hash_runtime")
+    def to_hash(self, stats=None):
+        result = 0
+        for triple in self.canonical_triples(stats=stats):
+            result += self.hashfunc(' '.join([x.n3() for x in triple]))
+        if stats is not None:
+            stats['graph_digest'] = "%x" % result
+        return result
+
+    def _experimental_path(self, coloring):
+        coloring = [c.copy() for c in coloring]
+        while not self._discrete(coloring):
+            color = [x for x in coloring if not x.discrete()][0]
+            node = color.nodes[0]
+            new_color = self._individuate(color, node)
+            coloring.append(new_color)
+            coloring = self._refine(coloring, [new_color])
+        return coloring
+
+    def _create_generator(self, colorings, groupings=None):
+        if not groupings:
+            groupings = defaultdict(set)
+        for group in zip(*colorings):
+            g = set([c.nodes[0] for c in group])
+            for n in group:
+                g |= groupings[n]
+            for n in g:
+                groupings[n] = g
+        return groupings
+
+    @_call_count("individuations")
+    def _traces(self, coloring, stats=None, depth=[0]):
+        if stats is not None and 'prunings' not in stats:
+            stats['prunings'] = 0
+        depth[0] += 1
+        candidates = self._get_candidates(coloring)
+        best = []
+        best_score = None
+        best_experimental = None
+        best_experimental_score = None
+        last_coloring = None
+        generator = defaultdict(set)
+        visited = set()
+        for candidate, color in candidates:
+            if candidate in generator:
+                v = generator[candidate] & visited
+                if len(v) > 0:
+                    visited.add(candidate)
+                    continue
+            visited.add(candidate)
+            coloring_copy = []
+            color_copy = None
+            for c in coloring:
+                c_copy = c.copy()
+                coloring_copy.append(c_copy)
+                if c == color:
+                    color_copy = c_copy
+            new_color = self._individuate(color_copy, candidate)
+            coloring_copy.append(new_color)
+            refined_coloring = self._refine(coloring_copy, [new_color])
+            color_score = tuple([c.key() for c in refined_coloring])
+            experimental = self._experimental_path(coloring_copy)
+            experimental_score = set([c.key() for c in experimental])
+            if last_coloring:
+                generator = self._create_generator(
+                    [last_coloring, experimental],
+                    generator)
+            last_coloring = experimental
+            if best_score is None or best_score < color_score:
+                best = [refined_coloring]
+                best_score = color_score
+                best_experimental = experimental
+                best_experimental_score = experimental_score
+            elif best_score > color_score:
+                # prune this branch.
+                if stats is not None:
+                    stats['prunings'] += 1
+            elif experimental_score != best_experimental_score:
+                best.append(refined_coloring)
+            else:
+                # prune this branch.
+                if stats is not None:
+                    stats['prunings'] += 1
+        discrete = [x for x in best if self._discrete(x)]
+        if len(discrete) == 0:
+            best_score = None
+            best_depth = None
+            for coloring in best:
+                d = [depth[0]]
+                new_color = self._traces(coloring, stats=stats, depth=d)
+                color_score = tuple([c.key() for c in refined_coloring])
+                if best_score is None or color_score > best_score:
+                    discrete = [new_color]
+                    best_score = color_score
+                    best_depth = d[0]
+            depth[0] = best_depth
+        return discrete[0]
+
+    def canonical_triples(self, stats=None):
+        if stats is not None:
+            start_canonicalization = datetime.now()
+        if stats is not None:
+            start_coloring = datetime.now()
+        coloring = self._initial_color()
+        if stats is not None:
+            stats['triple_count'] = len(self.graph)
+            stats['adjacent_nodes'] = max(0, len(coloring) - 1)
+        coloring = self._refine(coloring, coloring[:])
+        if stats is not None:
+            stats['initial_coloring_runtime'] = _total_seconds(datetime.now() - start_coloring)
+            stats['initial_color_count'] = len(coloring)
+
+        if not self._discrete(coloring):
+            depth = [0]
+            coloring = self._traces(coloring, stats=stats, depth=depth)
+            if stats is not None:
+                stats['tree_depth'] = depth[0]
+        elif stats is not None:
+            stats['individuations'] = 0
+            stats['tree_depth'] = 0
+        if stats is not None:
+            stats['color_count'] = len(coloring)
+
+        bnode_labels = dict([(c.nodes[0], c.hash_color()) for c in coloring])
+        if stats is not None:
+            stats["canonicalize_triples_runtime"] = _total_seconds(datetime.now() - start_coloring)
+        for triple in self.graph:
+            result = tuple(self._canonicalize_bnodes(triple, bnode_labels))
+            yield result
+
+    def _canonicalize_bnodes(self, triple, labels):
+        for term in triple:
+            if isinstance(term, BNode):
+                yield BNode(value="cb%s" % labels[term])
+            else:
+                yield term
+
+
+def to_isomorphic(graph):
+    if isinstance(graph, IsomorphicGraph):
+        return graph
+    return IsomorphicGraph(store=graph.store)
+
+
+def isomorphic(graph1, graph2):
+    """Compare graph for equality.
+
+    Uses an algorithm to compute unique hashes which takes bnodes into account.
+
+    Examples::
+
+        >>> g1 = Graph().parse(format='n3', data='''
+        ...     @prefix : <http://example.org/ns#> .
+        ...     <http://example.org> :rel <http://example.org/a> .
+        ...     <http://example.org> :rel <http://example.org/b> .
+        ...     <http://example.org> :rel [ :label "A bnode." ] .
+        ... ''')
+        >>> g2 = Graph().parse(format='n3', data='''
+        ...     @prefix ns: <http://example.org/ns#> .
+        ...     <http://example.org> ns:rel [ ns:label "A bnode." ] .
+        ...     <http://example.org> ns:rel <http://example.org/b>,
+        ...             <http://example.org/a> .
+        ... ''')
+        >>> isomorphic(g1, g2)
+        True
+
+        >>> g3 = Graph().parse(format='n3', data='''
+        ...     @prefix : <http://example.org/ns#> .
+        ...     <http://example.org> :rel <http://example.org/a> .
+        ...     <http://example.org> :rel <http://example.org/b> .
+        ...     <http://example.org> :rel <http://example.org/c> .
+        ... ''')
+        >>> isomorphic(g1, g3)
+        False
+    """
+    gd1 = _TripleCanonicalizer(graph1).to_hash()
+    gd2 = _TripleCanonicalizer(graph2).to_hash()
+    return gd1 == gd2
+
+
+
+def to_canonical_graph(g1):
+    """Creates a canonical, read-only graph.
+
+    Creates a canonical, read-only graph where all bnode id:s are based on
+    deterministical SHA-256 checksums, correlated with the graph contents.
+    """
+    graph = Graph()
+    graph += _TripleCanonicalizer(g1).canonical_triples()
+    return ReadOnlyGraphAggregate([graph])
+
+
+def graph_diff(g1, g2):
+    """Returns three sets of triples: "in both", "in first" and "in second"."""
+    # bnodes have deterministic values in canonical graphs:
+    cg1 = to_canonical_graph(g1)
+    cg2 = to_canonical_graph(g2)
+    in_both = cg1 * cg2
+    in_first = cg1 - cg2
+    in_second = cg2 - cg1
+    return (in_both, in_first, in_second)
+
+
+
+_MOCK_BNODE = BNode()
+
+
+def similar(g1, g2):
+    """Checks if the two graphs are "similar".
+
+    Checks if the two graphs are "similar", by comparing sorted triples where
+    all bnodes have been replaced by a singular mock bnode (the
+    ``_MOCK_BNODE``).
+
+    This is a much cheaper, but less reliable, alternative to the comparison
+    algorithm in ``isomorphic``.
+    """
+    return all(t1 == t2 for (t1, t2) in _squashed_graphs_triples(g1, g2))
+
+
+def _squashed_graphs_triples(g1, g2):
+    for (t1, t2) in zip(sorted(_squash_graph(g1)), sorted(_squash_graph(g2))):
+        yield t1, t2
+
+
+def _squash_graph(graph):
+    return (_squash_bnodes(triple) for triple in graph)
+
+
+def _squash_bnodes(triple):
+    return tuple((isinstance(t, BNode) and _MOCK_BNODE) or t for t in triple)