Skip to content

Commit

Permalink
Add edge aliasing and edge constraints (#119)
Browse files Browse the repository at this point in the history
* Add edge aliases to the language

* Centralize all constraint processing and defer to end of transform

* Add some type and edge-case cleanup

* Add tests for named edge constraints

* Add simple-graph edge attribute matching

* Add tests for in-memory graph executors

* Add cypher syntax generation for dynamic edge constraints

* Add support for quoted attribute names
  • Loading branch information
j6k4m8 committed Jun 6, 2022
1 parent 16e5f84 commit 07e3b7c
Show file tree
Hide file tree
Showing 11 changed files with 623 additions and 91 deletions.
8 changes: 1 addition & 7 deletions dotmotif/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,7 @@
from .executors.NetworkXExecutor import NetworkXExecutor
from .executors.GrandIsoExecutor import GrandIsoExecutor

try:
# For backwards compatibility:
from .executors.Neo4jExecutor import Neo4jExecutor
except ImportError:
pass

__version__ = "0.11.0"
__version__ = "0.12.0"

DEFAULT_MOTIF_PARSER = ParserV2

Expand Down
7 changes: 4 additions & 3 deletions dotmotif/executors/GrandIsoExecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,14 +93,15 @@ def _node_attr_match_fn(
else self._validate_multigraph_any_edge_constraints
)
)
_edge_dynamic_constraint_validator = self._validate_dynamic_edge_constraints

results = []
for r in graph_matches:
if _doesnt_have_any_of_motifs_negative_edges(r) and (
_edge_constraint_validator(r, self.graph, motif.list_edge_constraints())
# and self._validate_node_constraints(
# r, self.graph, motif.list_node_constraints()
# )
and _edge_dynamic_constraint_validator(
r, self.graph, motif.list_dynamic_edge_constraints()
)
and self._validate_dynamic_node_constraints(
r, self.graph, motif.list_dynamic_node_constraints()
)
Expand Down
22 changes: 22 additions & 0 deletions dotmotif/executors/Neo4jExecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,28 @@ def motif_to_cypher(
)
)

# Dynamic edge constraints:
# Constraints are of the form:
# {('A', 'B'): {'weight': {'==': ['A', 'C', 'weight']}}}
for (u, v), constraints in motif.list_dynamic_edge_constraints().items():
for this_attr, ops in constraints.items():
for op, (that_u, that_v, that_attr) in ops.items():
this_edge_name = edge_mapping[(u, v)]
that_edge_name = edge_mapping[(that_u, that_v)]
cypher_edge_constraints.append(
(
"NOT ({}[{}] {} {}[{}])"
if _operator_negation_infix(op)
else "{}[{}] {} {}[{}]"
).format(
this_edge_name,
_quoted_if_necessary(this_attr),
_remapped_operator(op),
that_edge_name,
_quoted_if_necessary(that_attr),
)
)

conditions.extend([*cypher_node_constraints, *cypher_edge_constraints])

if count_only:
Expand Down
71 changes: 61 additions & 10 deletions dotmotif/executors/NetworkXExecutor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Copyright 2021 The Johns Hopkins University Applied Physics Laboratory.
Copyright 2022 The Johns Hopkins University Applied Physics Laboratory.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -61,7 +61,9 @@ def _edge_satisfies_constraints(edge_attributes: dict, constraints: dict) -> boo
return True


def _edge_satisfies_many_constraints_for_muligraph_any_edges(edge_attributes: dict, constraints: dict) -> List[Tuple[str, str, str]]:
def _edge_satisfies_many_constraints_for_muligraph_any_edges(
edge_attributes: dict, constraints: dict
) -> List[Tuple[str, str, str]]:
"""
Returns a subset of constraints that this edge matches, in the form (key, op, val).
"""
Expand All @@ -82,6 +84,7 @@ def _edge_satisfies_many_constraints_for_muligraph_any_edges(edge_attributes: di
matched_constraints.append((key, operator, value))
return matched_constraints


def _node_satisfies_constraints(node_attributes: dict, constraints: dict) -> bool:
"""
Check if a single node satisfies the constraints.
Expand Down Expand Up @@ -134,7 +137,10 @@ def __init__(self, **kwargs) -> None:
if self.graph.is_multigraph():
self._host_is_multigraph = True
self._multigraph_edge_match = kwargs.get("multigraph_edge_match", "any")
assert self._multigraph_edge_match in ("all", "any"), "_multigraph_edge_match must be one of 'all' or 'any'."
assert self._multigraph_edge_match in (
"all",
"any",
), "_multigraph_edge_match must be one of 'all' or 'any'."

def _validate_node_constraints(
self, node_isomorphism_map: dict, graph: nx.DiGraph, constraints: dict
Expand Down Expand Up @@ -235,6 +241,45 @@ def _validate_edge_constraints(
return False
return True

def _validate_dynamic_edge_constraints(
self, node_isomorphism_map: dict, graph: nx.DiGraph, constraints: dict
):
"""
Validate all edge constraints on a subgraph.
Constraints are of the form:
{('A', 'B'): {'weight': {'==': ['A', 'C', 'weight']}}}
Arguments:
node_isomorphism_map (dict[nodename:str, nodeID:str]): A mapping of
node names to node IDs (where name comes from the motif and the
ID comes from the haystack graph).
graph (nx.DiGraph): The haystack graph
constraints (dict[(motif_u, motif_v), dict[operator, value]]): Map
of constraints on the MOTIF node names.
Returns:
bool: If the isomorphism satisfies the edge constraints
"""
for (motif_U, motif_V), constraint_list in constraints.items():
for (this_attr, ops) in constraint_list.items():
for op, (that_u, that_v, that_attr) in ops.items():
this_graph_u = node_isomorphism_map[motif_U]
this_graph_v = node_isomorphism_map[motif_V]
that_graph_u = node_isomorphism_map[that_u]
that_graph_v = node_isomorphism_map[that_v]
this_edge_attr = graph.get_edge_data(
this_graph_u, this_graph_v
).get(this_attr)
that_edge_attr = graph.get_edge_data(
that_graph_u, that_graph_v
).get(that_attr)
if not _OPERATORS[op](this_edge_attr, that_edge_attr):
return False
return True

def _validate_multigraph_all_edge_constraints(
self, node_isomorphism_map: dict, graph: nx.DiGraph, constraints: dict
):
Expand Down Expand Up @@ -283,7 +328,11 @@ def _validate_multigraph_any_edge_constraints(
# Check each edge in graph for constraints
constraint_list_copy = copy.deepcopy(constraint_list)
for _, _, edge_attrs in graph.edges((graph_u, graph_v), data=True):
matched_constraints = _edge_satisfies_many_constraints_for_muligraph_any_edges(edge_attrs, constraint_list_copy)
matched_constraints = (
_edge_satisfies_many_constraints_for_muligraph_any_edges(
edge_attrs, constraint_list_copy
)
)
if matched_constraints:
# Remove matched constraints from the list
for constraint in matched_constraints:
Expand All @@ -302,7 +351,6 @@ def _validate_multigraph_any_edge_constraints(

return True


def count(self, motif: "dotmotif.Motif", limit: int = None):
"""
Count the occurrences of a motif in a graph.
Expand All @@ -325,7 +373,6 @@ def find(self, motif: "dotmotif.Motif", limit: int = None):
# We need to first remove "negative" nodes from the motif, and then
# filter them out later on. Though this reduces the speed of the graph-
# matching, NetworkX does not seem to support this out of the box.
# TODO: Confirm that networkx does not support this out of the box.

if motif.ignore_direction or not self.graph.is_directed:
graph_constructor = nx.Graph
Expand Down Expand Up @@ -367,19 +414,23 @@ def _doesnt_have_any_of_motifs_negative_edges(mapping):
]

_edge_constraint_validator = (
self._validate_edge_constraints if not self._host_is_multigraph else (
self._validate_edge_constraints
if not self._host_is_multigraph
else (
self._validate_multigraph_all_edge_constraints
if self._multigraph_edge_match == "all"
else self._validate_multigraph_any_edge_constraints
)
)
_edge_dynamic_constraint_validator = self._validate_dynamic_edge_constraints
# Now, filter on attributes:
res = [
r
for r in results
if (
_edge_constraint_validator(
r, self.graph, motif.list_edge_constraints()
_edge_constraint_validator(r, self.graph, motif.list_edge_constraints())
and _edge_dynamic_constraint_validator(
r, self.graph, motif.list_dynamic_edge_constraints()
)
and self._validate_node_constraints(
r, self.graph, motif.list_node_constraints()
Expand All @@ -397,4 +448,4 @@ def _doesnt_have_any_of_motifs_negative_edges(mapping):
)
)
]
return res
return res[:limit] if limit is not None else res
16 changes: 16 additions & 0 deletions dotmotif/executors/test_dm_cypher.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,22 @@ def test_dynamic_constraints_in_cypher(self):
)


class TestDynamicEdgeConstraints(unittest.TestCase):
def test_dynamic_constraints_in_cypher(self):
dm = dotmotif.Motif(enforce_inequality=True)
dm.from_motif(
"""
A -> B as AB
A -> C as AC
AB.weight >= AC.weight
"""
)
self.assertIn(
"""WHERE A_B["weight"] >= A_C["weight"]""",
Neo4jExecutor.motif_to_cypher(dm).strip(),
)


class BugReports(unittest.TestCase):
def test_fix_where_clause__github_35(self):
dm = dotmotif.Motif(enforce_inequality=True)
Expand Down
135 changes: 135 additions & 0 deletions dotmotif/executors/test_grandisoexecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,138 @@ def test_dynamic_constraints_in_macros_two_result(self):
dm = dotmotif.Motif(parser=ParserV2)
res = GrandIsoExecutor(graph=G).find(dm.from_motif(exp))
self.assertEqual(len(res), 2)


class TestNamedEdgeConstraints(unittest.TestCase):
def test_equality_edge_attributes(self):
host = nx.DiGraph()
host.add_edge("A", "B", weight=1)
host.add_edge("A", "C", weight=1)

exp = """\
A -> B as A_B
A -> C as A_C
A_B.weight == A_C.weight
"""

dm = dotmotif.Motif(parser=ParserV2)
res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp))
self.assertEqual(len(res), 2)

host = nx.DiGraph()
host.add_edge("A", "B", weight=1)
host.add_edge("A", "C", weight=2)

exp = """\
A -> B as A_B
A -> C as A_C
A_B.weight == A_C.weight
"""

dm = dotmotif.Motif(parser=ParserV2)
res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp))
self.assertEqual(len(res), 0)

def test_inequality_edge_attributes(self):
host = nx.DiGraph()
host.add_edge("A", "B", weight=1)
host.add_edge("A", "C", weight=1)

exp = """\
A -> B as A_B
A -> C as A_C
A_B.weight != A_C.weight
"""

dm = dotmotif.Motif(parser=ParserV2)
res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp))
self.assertEqual(len(res), 0)

host = nx.DiGraph()
host.add_edge("A", "B", weight=1)
host.add_edge("A", "C", weight=2)

exp = """\
A -> B as A_B
A -> C as A_C
A_B.weight != A_C.weight
"""

dm = dotmotif.Motif(parser=ParserV2)
res = GrandIsoExecutor(graph=host).find(dm.from_motif(exp))
self.assertEqual(len(res), 2)

def test_aliased_edge_comparison(self):
exp = """\
A -> B as ab
A -> C as ac
ab.type = ac.type
"""
dm = dotmotif.Motif(exp)
host = nx.DiGraph()
host.add_edge("A", "B", type="a")
host.add_edge("A", "C", type="b")
host.add_edge("A", "D", type="b")
res = GrandIsoExecutor(graph=host).find(dm)
self.assertEqual(len(res), 2)

def test_aliased_edge_comparisons(self):
exp = """\
A -> B as ab
B -> C as bc
C -> D as cd
ab.length >= bc.length
bc.length >= cd.length
"""
dm = dotmotif.Motif(exp)
host = nx.DiGraph()
host.add_edge("A", "B", length=1)
host.add_edge("B", "C", length=1)
host.add_edge("C", "D", length=1)
res = GrandIsoExecutor(graph=host).find(dm)
self.assertEqual(len(res), 1)

def test_aliased_edge_comparisons_with_different_edge_attributes(self):
exp = """\
B -> C as bc
C -> D as cd
bc.length > cd.weight
"""
dm = dotmotif.Motif(exp)
host = nx.DiGraph()
host.add_edge("A", "C", length=2)
host.add_edge("B", "C", length=2)
host.add_edge("C", "D", length=1, weight=1)
res = GrandIsoExecutor(graph=host).find(dm)
self.assertEqual(len(res), 2)


# class TestEdgeConstraintsInMacros(unittest.TestCase):
# def test_edge_comparison_in_macro(self):
# host = nx.DiGraph()
# host.add_edge("A", "B", foo=1)
# host.add_edge("A", "C", foo=2)
# host.add_edge("B", "C", foo=0.5)
# host.add_edge("C", "D", foo=0.25)
# host.add_edge("D", "C", foo=1)
# host.add_edge("C", "B", foo=2)
# host.add_edge("B", "A", foo=2)
# E = GrandIsoExecutor(graph=host)

# M = Motif(
# """

# descending(a, b, c) {
# a -> b as Edge1
# b -> c as Edge2
# Edge1.foo > Edge2.foo
# }

# descending(a, b, c)
# descending(b, c, d)

# """
# )
# assert E.count(M) == 1

0 comments on commit 07e3b7c

Please sign in to comment.