Repository: pybel/pybel

Issue:
Collapse_all_variants sometimes fails
It appears non-deterministic, which is BAD

Discussion:
cthoyt: Before merging this, remove the whole query folder and start a new PR
codecov[bot]: # [Codecov](https://codecov.io/gh/pybel/pybel/pull/315?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`develop@dca0283`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `26.87%`.

[![Impacted file tree graph](https://codecov.io/gh/pybel/pybel/pull/315/graphs/tree.svg?token=J7joRTRygG&src=pr&height=150&width=650)](https://codecov.io/gh/pybel/pybel/pull/315?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##             develop     #315   +/-   ##
==========================================
  Coverage           ?   84.76%           
==========================================
  Files              ?      129           
  Lines              ?     6775           
  Branches           ?     1092           
==========================================
  Hits               ?     5743           
  Misses             ?      841           
  Partials           ?      191
```


| [Impacted Files](https://codecov.io/gh/pybel/pybel/pull/315?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/pybel/struct/query/constants.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9xdWVyeS9jb25zdGFudHMucHk=) | `0% <0%> (ø)` | |
| [src/pybel/struct/query/query.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9xdWVyeS9xdWVyeS5weQ==) | `0% <0%> (ø)` | |
| [src/pybel/struct/query/exc.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9xdWVyeS9leGMucHk=) | `0% <0%> (ø)` | |
| [src/pybel/struct/query/random\_subgraph.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9xdWVyeS9yYW5kb21fc3ViZ3JhcGgucHk=) | `0% <0%> (ø)` | |
| [src/pybel/struct/query/selection.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9xdWVyeS9zZWxlY3Rpb24ucHk=) | `0% <0%> (ø)` | |
| [src/pybel/struct/mutation/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9tdXRhdGlvbi9fX2luaXRfXy5weQ==) | `100% <100%> (ø)` | |
| [src/pybel/struct/mutation/induction/citation.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9tdXRhdGlvbi9pbmR1Y3Rpb24vY2l0YXRpb24ucHk=) | `100% <100%> (ø)` | |
| [...rc/pybel/struct/filters/edge\_predicate\_builders.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9maWx0ZXJzL2VkZ2VfcHJlZGljYXRlX2J1aWxkZXJzLnB5) | `95.23% <100%> (ø)` | |
| [src/pybel/struct/mutation/induction/\_\_init\_\_.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9tdXRhdGlvbi9pbmR1Y3Rpb24vX19pbml0X18ucHk=) | `100% <100%> (ø)` | |
| [...rc/pybel/struct/mutation/induction/neighborhood.py](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree#diff-c3JjL3B5YmVsL3N0cnVjdC9tdXRhdGlvbi9pbmR1Y3Rpb24vbmVpZ2hib3Job29kLnB5) | `42.85% <42.85%> (ø)` | |
| ... and [6 more](https://codecov.io/gh/pybel/pybel/pull/315/diff?src=pr&el=tree-more) | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pybel/pybel/pull/315?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pybel/pybel/pull/315?src=pr&el=footer). Last update [dca0283...80ba1a1](https://codecov.io/gh/pybel/pybel/pull/315?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/src/pybel/dsl/nodes.py b/src/pybel/dsl/nodes.py
index db6df939..2494d631 100644
--- a/src/pybel/dsl/nodes.py
+++ b/src/pybel/dsl/nodes.py
@@ -46,30 +46,26 @@ class BaseEntity(dict):
 
     @abc.abstractmethod
     def as_tuple(self):
-        """Returns this entity as a canonical tuple
+        """Return this entity as a PyBEL tuple.
 
         :rtype: tuple
         """
 
     @abc.abstractmethod
     def as_bel(self):
-        """Returns this entity as canonical BEL
+        """Return this entity as a BEL string.
 
         :rtype: tuple
         """
 
     def as_sha512(self):
-        """Returns this entity as a hash
+        """Return this entity as a SHA512 hash encoded in UTF-8.
 
         :rtype: str
         """
         return hash_node(self.as_tuple())
 
     def __hash__(self):
-        """Use the tuple serialization of this node as the hash
-
-        :rtype: int
-        """
         return hash(self.as_tuple())
 
     def __str__(self):
@@ -77,14 +73,14 @@ class BaseEntity(dict):
 
 
 class BaseAbundance(BaseEntity):
-    """The superclass for building node data dictionaries"""
+    """The superclass for building node data dictionaries."""
 
     def __init__(self, func, namespace, name=None, identifier=None):
-        """
+        """Build an abundance from a function, namespace and a name and/or identifier.
         :param str func: The PyBEL function
         :param str namespace: The name of the namespace
-        :param Optional[str] name:
-        :param Optional[str] identifier:
+        :param Optional[str] name: The name of this abundance
+        :param Optional[str] identifier: The database identifier for this abundance
         """
         if name is None and identifier is None:
             raise PyBELDSLException('Either name or identifier must be specified')
@@ -94,29 +90,45 @@ class BaseAbundance(BaseEntity):
 
     @property
     def function(self):
+        """The function of this abundance.
+
+        :rtype: str
+        """
         return self[FUNCTION]
 
     @property
     def name(self):
+        """The name of this abundance.
+
+        :rtype: Optional[str]
+        """
         return self.get(NAME)
 
     @property
     def namespace(self):
+        """The namespace of this abundance.
+
+        :rtype: str
+        """
         return self.get(NAMESPACE)
 
     @property
     def identifier(self):
+        """The identifier of this abundance.
+
+        :rtype: Optional[str]
+        """
         return self.get(IDENTIFIER)
 
     def as_tuple(self):
-        """Returns this node as a PyBEL node tuple
+        """Return this node as a PyBEL node tuple.
 
         :rtype: tuple
         """
         return self[FUNCTION], self[NAMESPACE], self[NAME]
 
     def as_bel(self):
-        """Returns this node as BEL
+        """Return this node as a BEL string.
 
         :rtype: str
         """
@@ -128,13 +140,14 @@ class BaseAbundance(BaseEntity):
 
 
 class abundance(BaseAbundance):
-    """Builds an abundance node data dictionary"""
+    """Builds an abundance node data dictionary."""
 
     def __init__(self, namespace, name=None, identifier=None):
-        """
+        """Build a general abundance entitiy.
+
         :param str namespace: The name of the database used to identify this entity
-        :param str name: The database's preferred name or label for this entity
-        :param str identifier: The database's identifier for this entity
+        :param Optional[str] name: The database's preferred name or label for this entity
+        :param Optional[str] identifier: The database's identifier for this entity
 
         Example:
 
diff --git a/src/pybel/struct/filters/edge_predicate_builders.py b/src/pybel/struct/filters/edge_predicate_builders.py
index 23bd9aa4..87612e94 100644
--- a/src/pybel/struct/filters/edge_predicate_builders.py
+++ b/src/pybel/struct/filters/edge_predicate_builders.py
@@ -1,8 +1,9 @@
 # -*- coding: utf-8 -*-
 
+from six import string_types
 
-from .edge_predicates import edge_predicate, keep_edge_permissive
-from ...constants import ANNOTATIONS, CAUSAL_RELATIONS, RELATION
+from .edge_predicates import edge_predicate, has_authors, has_pubmed, keep_edge_permissive
+from ...constants import ANNOTATIONS, CAUSAL_RELATIONS, CITATION, CITATION_AUTHORS, CITATION_REFERENCE, RELATION
 
 __all__ = [
     'build_annotation_dict_all_filter',
@@ -10,6 +11,8 @@ __all__ = [
     'build_upstream_edge_predicate',
     'build_downstream_edge_predicate',
     'build_relation_predicate',
+    'build_pmid_inclusion_filter',
+    'build_author_inclusion_filter',
 ]
 
 
@@ -39,8 +42,8 @@ def _annotation_dict_all_filter(data, query):
 
 
 def build_annotation_dict_all_filter(annotations):
-    """Builds a filter that keeps edges whose data dictionaries's annotations entry are super-dictionaries to the given
-    dictionary.
+    """Build an edge predicate that passes for edges whose data dictionaries's annotations entry are super-dictionaries
+    to the given dictionary.
 
     If no annotations are given, will always evaluate to true.
 
@@ -82,10 +85,9 @@ def _annotation_dict_any_filter(data, query):
 
 
 def build_annotation_dict_any_filter(annotations):
-    """Builds a filter that keeps edges whose data dictionaries's annotations entry contain any match to
-    the target dictionary.
+    """Build an edge predicate that passes for edges whose data dictionaries match the given dictionary.
 
-    If no annotations are given, will always evaluate to true.
+    If the given dictionary is empty, will always evaluate to true.
 
     :param dict annotations: The annotation query dict to match
     :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
@@ -106,34 +108,138 @@ def build_annotation_dict_any_filter(annotations):
 
 
 def build_upstream_edge_predicate(nodes):
+    """Build an edge predicate that pass for relations for which one of the given nodes is the object.
+
+    :param iter[tuple] nodes: An iterable of PyBEL node tuples
+    :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
+    """
+    nodes = set(nodes)
+
     def upstream_filter(graph, u, v, k):
+        """Pass for relations for which one of the given nodes is the object.
+
+        :type graph: pybel.BELGraph
+        :type u: tuple
+        :type v: tuple
+        :type k: int
+        :rtype: bool
+        """
         return v in nodes and graph[u][v][k][RELATION] in CAUSAL_RELATIONS
 
     return upstream_filter
 
 
 def build_downstream_edge_predicate(nodes):
+    """Build an edge predicate that passes for edges for which one of the given nodes is the subject.
+
+    :param iter[tuple] nodes: An iterable of PyBEL node tuples
+    :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
+    """
+    nodes = set(nodes)
+
     def downstream_filter(graph, u, v, k):
+        """Pass for relations for which one of the given nodes is the subject.
+
+        :type graph: pybel.BELGraph
+        :type u: tuple
+        :type v: tuple
+        :type k: int
+        :rtype: bool
+        """
         return u in nodes and graph[u][v][k][RELATION] in CAUSAL_RELATIONS
 
     return downstream_filter
 
 
 def build_relation_predicate(relation):
-    """Build an edge predicate that matches edges with the given relation
+    """Build an edge predicate that passes for edges with the given relation.
 
     :param str relation: A relation string
     :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
     """
 
-    @edge_predicate
-    def is_relation(data):
-        """Only passes on associative edges
+    def relation_predicate(graph, u, v, k):
+        """Pass for relations matching the enclosed value.
 
-        :param dict data: The PyBEL edge data dictionary
-        :return: If the edge is a causal edge
+        :type graph: pybel.BELGraph
+        :type u: tuple
+        :type v: tuple
+        :type k: int
         :rtype: bool
         """
-        return data[RELATION] == relation
+        return relation == graph[u][v][k][RELATION]
 
-    return is_relation
+    return relation_predicate
+
+
+def build_pmid_inclusion_filter(pmids):
+    """Build an edge predicate that passes for edges with citations from the given PubMed identifier(s).
+
+    :param pmids: A PubMed identifier or list of PubMed identifiers to filter for
+    :type pmids: str or iter[str]
+    :return: An edge predicate
+    :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
+    """
+    if isinstance(pmids, string_types):
+        @edge_predicate
+        def pmid_inclusion_filter(data):
+            """Pass for edges with PubMed citations matching the contained PubMed identifier.
+
+            :param dict data: The edge data dictionary
+            :return: If the edge has a PubMed citation with the contained PubMed identifier
+            :rtype: bool
+            """
+            return has_pubmed(data) and data[CITATION][CITATION_REFERENCE] == pmids
+
+    else:
+        pmids = set(pmids)
+
+        @edge_predicate
+        def pmid_inclusion_filter(data):
+            """Pass for edges with PubMed citations matching one of the contained PubMed identifiers.
+
+            :param dict data: The edge data dictionary
+            :return: If the edge has a PubMed citation with one of the contained PubMed identifiers
+            :rtype: bool
+            """
+            return has_pubmed(data) and data[CITATION][CITATION_REFERENCE] in pmids
+
+    return pmid_inclusion_filter
+
+
+def build_author_inclusion_filter(authors):
+    """Build an edge predicate that passes for edges with citations written by the given author(s).
+
+    :param authors: An author or list of authors
+    :type authors: str or iter[str]
+    :return: An edge predicate
+    :rtype: (pybel.BELGraph, tuple, tuple, int) -> bool
+    """
+    if isinstance(authors, string_types):
+        @edge_predicate
+        def author_filter(data):
+            """Pass for edges with citations with an author that matches the contained author.
+
+            :param dict data: The edge data dictionary
+            :return: If the edge has a citation with an author that matches the the contained author
+            :rtype: bool
+            """
+            return has_authors(data) and authors in data[CITATION][CITATION_AUTHORS]
+
+    else:
+        authors = set(authors)
+
+        @edge_predicate
+        def author_filter(data):
+            """Pass for edges with citations with an author that matches one or more of the contained authors.
+
+            :param dict data: The edge data dictionary
+            :return: If the edge has a citation with an author that matches the the contained author
+            :rtype: bool
+            """
+            return has_authors(data) and any(
+                author in data[CITATION][CITATION_AUTHORS]
+                for author in authors
+            )
+
+    return author_filter
diff --git a/src/pybel/struct/filters/node_predicate_builders.py b/src/pybel/struct/filters/node_predicate_builders.py
index f00c624f..b6c37877 100644
--- a/src/pybel/struct/filters/node_predicate_builders.py
+++ b/src/pybel/struct/filters/node_predicate_builders.py
@@ -6,11 +6,14 @@ from collections import Iterable
 
 from six import string_types
 
-from ...constants import FUNCTION
+from ...constants import FUNCTION, NAME
 
 __all__ = [
     'function_inclusion_filter_builder',
     'data_missing_key_builder',
+    'build_node_data_search',
+    'build_node_key_search',
+    'build_node_name_search',
 ]
 
 
@@ -83,3 +86,60 @@ def data_missing_key_builder(key):
         return key not in graph.node[node]
 
     return data_does_not_contain_key
+
+
+def build_node_data_search(key, data_predicate):
+    """Pass for nodes who have the given key in their data dictionaries and whose associated values pass the given
+    filter function.
+
+    :param str key: The node data dictionary key to check
+    :param data_predicate: The filter to apply to the node data dictionary
+    :type data_predicate: (Any) -> bool
+    :return: A node predicate
+    :rtype: (pybel.BELGraph, tuple) -> bool
+    """
+
+    def node_data_filter(graph, node):
+        """Pass if the given node has a given data annotated and passes the contained filter.
+
+        :type graph: pybel.BELGraph
+        :type node: tuple
+        :return: If the node has the contained key in its data dictionary and passes the contained filter
+        :rtype: bool
+        """
+        data = graph.node[node]
+        return key in data and data_predicate(data[key])
+
+    return node_data_filter
+
+
+def build_node_key_search(query, key):
+    """Build a node filter that only passes for nodes whose values for the given key are superstrings of the query
+    string(s).
+
+    :param query: The query string or strings to check if they're in the node name
+    :type query: str or iter[str]
+    :param str key: The key for the node data dictionary. Should refer only to entries that have str values
+    :return: A node predicate
+    :rtype: (pybel.BELGraph, tuple) -> bool
+    """
+    if isinstance(query, string_types):
+        return build_node_data_search(key, lambda s: query.lower() in s.lower())
+
+    if isinstance(query, Iterable):
+        return build_node_data_search(key, lambda s: any(q.lower() in s.lower() for q in query))
+
+    raise TypeError('query is wrong type: %s', query)
+
+
+def build_node_name_search(query):
+    """Search nodes' names.
+
+    Is a thin wrapper around :func:`build_node_key_search` with :data:`pybel.constants.NAME`
+
+    :param query: The query string or strings to check if they're in the node name
+    :type query: str or iter[str]
+    :return: A node predicate
+    :rtype: (pybel.BELGraph, tuple) -> bool
+    """
+    return build_node_key_search(query=query, key=NAME)
diff --git a/src/pybel/struct/graph.py b/src/pybel/struct/graph.py
index aa627185..9ed3ea31 100644
--- a/src/pybel/struct/graph.py
+++ b/src/pybel/struct/graph.py
@@ -10,7 +10,16 @@ from six import string_types
 
 from .operations import left_full_join, left_node_intersection_join, left_outer_join
 from ..canonicalize import edge_to_bel, node_to_bel
-from ..constants import *
+from ..constants import (
+    ANNOTATIONS, CITATION, CITATION_REFERENCE, CITATION_TYPE, CITATION_TYPE_PUBMED, DECREASES, DESCRIPTION,
+    DIRECTLY_DECREASES, DIRECTLY_INCREASES, EQUIVALENT_TO, EVIDENCE, FUNCTION, GRAPH_ANNOTATION_LIST,
+    GRAPH_ANNOTATION_PATTERN, GRAPH_ANNOTATION_URL, GRAPH_METADATA, GRAPH_NAMESPACE_PATTERN, GRAPH_NAMESPACE_URL,
+    GRAPH_PYBEL_VERSION, GRAPH_UNCACHED_NAMESPACES, HASH, HAS_COMPONENT, HAS_MEMBER, HAS_PRODUCT, HAS_REACTANT,
+    HAS_VARIANT, IDENTIFIER, INCREASES, IS_A, MEMBERS, METADATA_AUTHORS, METADATA_CONTACT, METADATA_COPYRIGHT,
+    METADATA_DESCRIPTION, METADATA_DISCLAIMER, METADATA_LICENSES, METADATA_NAME, METADATA_VERSION, NAME, NAMESPACE,
+    OBJECT, ORTHOLOGOUS, PART_OF, PRODUCTS, REACTANTS, RELATION, SUBJECT, TRANSCRIBED_TO, TRANSLATED_TO, VARIANTS,
+    unqualified_edge_code,
+)
 from ..dsl import activity
 from ..tokens import node_to_tuple
 from ..utils import get_version, hash_edge
@@ -435,7 +444,7 @@ class BELGraph(networkx.MultiDiGraph):
         :param v: Either a PyBEL node tuple or PyBEL node data dictionary representing the target node
         :type v: tuple or dict
         """
-        return self.add_unqualified_edge(u,v, PART_OF)
+        return self.add_unqualified_edge(u, v, PART_OF)
 
     def add_has_member(self, u, v):
         """Add an hasMember relationship such that u hasMember v.
@@ -445,7 +454,7 @@ class BELGraph(networkx.MultiDiGraph):
         :param v: Either a PyBEL node tuple or PyBEL node data dictionary representing the target node
         :type v: tuple or dict
         """
-        return self.add_unqualified_edge(u,v, HAS_MEMBER)
+        return self.add_unqualified_edge(u, v, HAS_MEMBER)
 
     def add_increases(self, u, v, evidence, citation, annotations=None, subject_modifier=None, object_modifier=None,
                       **attr):
@@ -470,6 +479,29 @@ class BELGraph(networkx.MultiDiGraph):
                                        annotations=annotations, subject_modifier=subject_modifier,
                                        object_modifier=object_modifier, **attr)
 
+    def add_directly_increases(self, u, v, evidence, citation, annotations=None, subject_modifier=None,
+                               object_modifier=None, **attr):
+        """Wraps :meth:`add_qualified_edge` for :data:`pybel.constants.DIRECTLY_INCREASES`.
+
+        :param tuple or dict u: Either a PyBEL node tuple or PyBEL node data dictionary representing the source node
+        :param tuple or dict v: Either a PyBEL node tuple or PyBEL node data dictionary representing the target node
+        :param str evidence: The evidence string from an article
+        :param dict[str,str] or str citation: The citation data dictionary for this evidence. If a string is given,
+                                                assumes it's a PubMed identifier and auto-fills the citation type.
+        :param annotations: The annotations data dictionary
+        :type annotations: Optional[dict[str,str] or dict[str,set] or dict[str,dict[str,bool]]]
+        :param Optional[dict] subject_modifier: The modifiers (like activity) on the subject node. See data model
+         documentation.
+        :param Optional[dict] object_modifier: The modifiers (like activity) on the object node. See data model
+         documentation.
+
+        :return: The hash of the edge
+        :rtype: str
+        """
+        return self.add_qualified_edge(u=u, v=v, relation=DIRECTLY_INCREASES, evidence=evidence, citation=citation,
+                                       annotations=annotations, subject_modifier=subject_modifier,
+                                       object_modifier=object_modifier, **attr)
+
     def add_decreases(self, u, v, evidence, citation, annotations=None, subject_modifier=None, object_modifier=None,
                       **attr):
         """Wraps :meth:`add_qualified_edge` for :data:`pybel.constants.DECREASES`.
@@ -493,6 +525,29 @@ class BELGraph(networkx.MultiDiGraph):
                                        annotations=annotations, subject_modifier=subject_modifier,
                                        object_modifier=object_modifier, **attr)
 
+    def add_directly_decreases(self, u, v, evidence, citation, annotations=None, subject_modifier=None,
+                               object_modifier=None, **attr):
+        """Wraps :meth:`add_qualified_edge` for :data:`pybel.constants.DIRECTLY_DECREASES`.
+
+        :param tuple or dict u: Either a PyBEL node tuple or PyBEL node data dictionary representing the source node
+        :param tuple or dict v: Either a PyBEL node tuple or PyBEL node data dictionary representing the target node
+        :param str evidence: The evidence string from an article
+        :param dict[str,str] or str citation: The citation data dictionary for this evidence. If a string is given,
+                                                assumes it's a PubMed identifier and auto-fills the citation type.
+        :param annotations: The annotations data dictionary
+        :type annotations: Optional[dict[str,str] or dict[str,set] or dict[str,dict[str,bool]]]
+        :param Optional[dict] subject_modifier: The modifiers (like activity) on the subject node. See data model
+         documentation.
+        :param Optional[dict] object_modifier: The modifiers (like activity) on the object node. See data model
+         documentation.
+
+        :return: The hash of the edge
+        :rtype: str
+        """
+        return self.add_qualified_edge(u=u, v=v, relation=DIRECTLY_DECREASES, evidence=evidence, citation=citation,
+                                       annotations=annotations, subject_modifier=subject_modifier,
+                                       object_modifier=object_modifier, **attr)
+
     def iter_node_data_pairs(self):
         """Iterates over pairs of nodes and their data dictionaries
 
diff --git a/src/pybel/struct/mutation/__init__.py b/src/pybel/struct/mutation/__init__.py
index 1a2ddbf5..d77319b3 100644
--- a/src/pybel/struct/mutation/__init__.py
+++ b/src/pybel/struct/mutation/__init__.py
@@ -2,11 +2,12 @@
 
 """This module contains functions that mutate or make transformations on a network."""
 
-from . import collapse, deletion, expansion, induction, inference, metadata, transfer, utils
+from . import collapse, deletion, expansion, induction, induction_expansion, inference, metadata, transfer, utils
 from .collapse import *
 from .deletion import *
 from .expansion import *
 from .induction import *
+from .induction_expansion import *
 from .inference import *
 from .metadata import *
 from .transfer import *
@@ -17,6 +18,7 @@ __all__ = (
         deletion.__all__ +
         expansion.__all__ +
         induction.__all__ +
+        induction_expansion.__all__ +
         inference.__all__ +
         metadata.__all__ +
         transfer.__all__ +
diff --git a/src/pybel/struct/mutation/collapse/collapse.py b/src/pybel/struct/mutation/collapse/collapse.py
index db2d1482..904228be 100644
--- a/src/pybel/struct/mutation/collapse/collapse.py
+++ b/src/pybel/struct/mutation/collapse/collapse.py
@@ -10,11 +10,20 @@ from ....constants import HAS_VARIANT, RELATION, unqualified_edges
 __all__ = [
     'collapse_pair',
     'collapse_nodes',
-    'collapse_edges_passing_predicates',
     'collapse_all_variants',
 ]
 
 
+def _remove_self_edges(graph):
+    self_edges = [
+        (u, u, k)
+        for u in graph
+        if u in graph[u]
+        for k in graph[u][u]
+    ]
+    graph.remove_edges_from(self_edges)
+
+
 @in_place_transformation
 def collapse_pair(graph, survivor, victim):
     """Rewire all edges from the synonymous node to the survivor node, then deletes the synonymous node.
@@ -46,6 +55,8 @@ def collapse_pair(graph, survivor, victim):
     graph.remove_node(victim)
 
 
+# TODO what happens when collapsing is not consistent? Need to build intermediate mappings and test their consistency.
+
 @in_place_transformation
 def collapse_nodes(graph, survivor_mapping):
     """Collapse all nodes in values to the key nodes, in place.
@@ -59,31 +70,20 @@ def collapse_nodes(graph, survivor_mapping):
         for victim in victims:
             collapse_pair(graph, survivor=survivor, victim=victim)
 
-    # Remove self edges
-    graph.remove_edges_from(
-        (u, u, k)
-        for u in graph
-        if u in graph[u]
-        for k in graph[u][u]
-    )
+    _remove_self_edges(graph)
 
 
 @in_place_transformation
-def collapse_edges_passing_predicates(graph, edge_predicates=None):
-    """Collapse all edges passing the given edge predicates.
+def collapse_all_variants(graph):
+    """Collapse all genes', RNAs', miRNAs', and proteins' variants to their parents.
 
     :param pybel.BELGraph graph: A BEL Graph
-    :param edge_predicates: A predicate or list of predicates
-    :type edge_predicates: None or (pybel.BELGraph, tuple, tuple, int) -> bool or iter[(pybel.BELGraph, tuple, tuple, int) -> bool]
     """
-    for u, v, _ in filter_edges(graph, edge_predicates=edge_predicates):
-        collapse_pair(graph, survivor=u, victim=v)
+    has_variant_predicate = build_relation_predicate(HAS_VARIANT)
 
+    edges = list(filter_edges(graph, has_variant_predicate))
 
-@in_place_transformation
-def collapse_all_variants(graph):
-    """Collapse all genes', RNAs', miRNAs', and proteins' variants to their parents.
+    for u, v, _ in edges:
+        collapse_pair(graph, survivor=u, victim=v)
 
-    :param pybel.BELGraph graph: A BEL Graph
-    """
-    collapse_edges_passing_predicates(graph, build_relation_predicate(HAS_VARIANT))
+    _remove_self_edges(graph)
diff --git a/src/pybel/struct/mutation/induction/__init__.py b/src/pybel/struct/mutation/induction/__init__.py
index f8eb2c66..20cbe05a 100644
--- a/src/pybel/struct/mutation/induction/__init__.py
+++ b/src/pybel/struct/mutation/induction/__init__.py
@@ -1,10 +1,18 @@
 # -*- coding: utf-8 -*-
 
-from . import upstream, utils
+from . import annotations, citation, neighborhood, paths, upstream, utils
+from .annotations import *
+from .citation import *
+from .neighborhood import *
+from .paths import *
 from .upstream import *
 from .utils import *
 
 __all__ = (
+        annotations.__all__ +
+        citation.__all__ +
+        neighborhood.__all__ +
+        paths.__all__ +
         upstream.__all__ +
         utils.__all__
 )
diff --git a/src/pybel/struct/mutation/induction/annotations.py b/src/pybel/struct/mutation/induction/annotations.py
new file mode 100644
index 00000000..2084d760
--- /dev/null
+++ b/src/pybel/struct/mutation/induction/annotations.py
@@ -0,0 +1,47 @@
+# -*- coding: utf-8 -*-
+
+import logging
+
+from .utils import get_subgraph_by_edge_filter
+from ...filters import build_annotation_dict_all_filter, build_annotation_dict_any_filter
+from ...pipeline import transformation
+
+log = logging.getLogger(__name__)
+
+__all__ = [
+    'get_subgraph_by_annotation_value',
+    'get_subgraph_by_annotations',
+]
+
+
+@transformation
+def get_subgraph_by_annotations(graph, annotations, or_=None):
+    """Induce a sub-graph given an annotations filter.
+
+    :param graph: pybel.BELGraph graph: A BEL graph
+    :param dict[str,set[str]] annotations: Annotation filters (match all with :func:`pybel.utils.subdict_matches`)
+    :param boolean or_: if True any annotation should be present, if False all annotations should be present in the
+                        edge. Defaults to True.
+    :return: A subgraph of the original BEL graph
+    :rtype: pybel.BELGraph
+    """
+    edge_filter_builder = (
+        build_annotation_dict_any_filter
+        if (or_ is None or or_) else
+        build_annotation_dict_all_filter
+    )
+
+    return get_subgraph_by_edge_filter(graph, edge_filter_builder(annotations))
+
+
+@transformation
+def get_subgraph_by_annotation_value(graph, annotation, value):
+    """Induce a sub-graph over all edges whose annotations match the given key and value.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param str annotation: The annotation to group by
+    :param str value: The value for the annotation
+    :return: A subgraph of the original BEL graph
+    :rtype: pybel.BELGraph
+    """
+    return get_subgraph_by_annotations(graph, {annotation: {value}})
diff --git a/src/pybel/struct/mutation/induction/citation.py b/src/pybel/struct/mutation/induction/citation.py
new file mode 100644
index 00000000..4253b19e
--- /dev/null
+++ b/src/pybel/struct/mutation/induction/citation.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+
+"""Induction functions based on provenance information."""
+
+import logging
+
+from .utils import get_subgraph_by_edge_filter
+from ...filters.edge_predicate_builders import build_author_inclusion_filter, build_pmid_inclusion_filter
+from ...pipeline import transformation
+
+__all__ = [
+    'get_subgraph_by_pubmed',
+    'get_subgraph_by_authors',
+]
+
+log = logging.getLogger(__name__)
+
+
+@transformation
+def get_subgraph_by_pubmed(graph, pubmed_identifiers):
+    """Induce a sub-graph over the edges retrieved from the given PubMed identifier(s).
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param str or list[str] pubmed_identifiers: A PubMed identifier or list of PubMed identifiers
+    :rtype: pybel.BELGraph
+    """
+    return get_subgraph_by_edge_filter(graph, build_pmid_inclusion_filter(pubmed_identifiers))
+
+
+@transformation
+def get_subgraph_by_authors(graph, authors):
+    """Induce a sub-graph over the edges retrieved publications by the given author(s).
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param str or list[str] authors: An author or list of authors
+    :rtype: pybel.BELGraph
+    """
+    return get_subgraph_by_edge_filter(graph, build_author_inclusion_filter(authors))
diff --git a/src/pybel/struct/mutation/induction/neighborhood.py b/src/pybel/struct/mutation/induction/neighborhood.py
new file mode 100644
index 00000000..08969c6a
--- /dev/null
+++ b/src/pybel/struct/mutation/induction/neighborhood.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+
+"""Functions for selecting by the neighborhoods of nodes."""
+
+import itertools as itt
+
+from ...pipeline import transformation
+from ...utils import update_metadata, update_node_helper
+
+__all__ = [
+    'get_subgraph_by_neighborhood',
+]
+
+
+@transformation
+def get_subgraph_by_neighborhood(graph, nodes):
+    """Get a BEL graph around the neighborhoods of the given nodes. Returns none if no nodes are in the graph.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param iter[tuple] nodes: An iterable of BEL nodes
+    :return: A BEL graph induced around the neighborhoods of the given nodes
+    :rtype: Optional[pybel.BELGraph]
+    """
+    node_set = set(nodes)
+
+    if not any(node in graph for node in node_set):
+        return
+
+    rv = graph.fresh_copy()
+
+    rv.add_edges_from(
+        (
+            (u, v, k, d)
+            if k < 0 else
+            (u, v, d)
+        )
+        for u, v, k, d in itt.chain(
+            graph.in_edges_iter(nodes, keys=True, data=True),
+            graph.out_edges_iter(nodes, keys=True, data=True)
+        )
+    )
+
+    update_node_helper(graph, rv)
+    update_metadata(graph, rv)
+
+    return rv
diff --git a/src/pybel/struct/mutation/induction/paths.py b/src/pybel/struct/mutation/induction/paths.py
new file mode 100644
index 00000000..f3f2973d
--- /dev/null
+++ b/src/pybel/struct/mutation/induction/paths.py
@@ -0,0 +1,97 @@
+# -*- coding: utf-8 -*-
+
+"""Induction methods for graphs over shortest paths."""
+
+import itertools as itt
+
+import logging
+import networkx as nx
+
+from .utils import get_subgraph_by_induction
+from ...pipeline import transformation
+from ....constants import FUNCTION, PATHOLOGY
+
+__all__ = [
+    'get_nodes_in_all_shortest_paths',
+    'get_subgraph_by_all_shortest_paths',
+]
+
+log = logging.getLogger(__name__)
+
+
+def _remove_pathologies_oop(graph):
+    """Remove pathology nodes from the graph."""
+    rv = graph.copy()
+    for node, data in rv.nodes(data=True):
+        if data[FUNCTION] == PATHOLOGY:
+            rv.remove_node(node)
+    return rv
+
+
+def _iterate_nodes_in_shortest_paths(graph, nodes, weight=None):
+    """Iterate over nodes in the shortest paths between all pairs of nodes in the given list.
+
+    :type graph: pybel.BELGraph
+    :type nodes: list[tuple]
+    :param weight: Optional[str]
+    :rtype: iter[tuple]
+    """
+    for source, target in itt.product(nodes, repeat=2):
+        try:
+            paths = nx.all_shortest_paths(graph, source, target, weight=weight)
+            for path in paths:
+                for node in path:
+                    yield node
+        except nx.exception.NetworkXNoPath:
+            continue
+
+
+def get_nodes_in_all_shortest_paths(graph, nodes, weight=None, remove_pathologies=False):
+    """Get a set of nodes in all shortest paths between the given nodes.
+
+    Thinly wraps :func:`networkx.all_shortest_paths`.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param iter[tuple] nodes: The list of nodes to use to use to find all shortest paths
+    :param Optional[str] weight: Edge data key corresponding to the edge weight. If none, uses unweighted search.
+    :param bool remove_pathologies: Should pathology nodes be removed first?
+    :return: A set of nodes appearing in the shortest paths between nodes in the BEL graph
+    :rtype: set[tuple]
+
+    .. note:: This can be trivially parallelized using :func:`networkx.single_source_shortest_path`
+    """
+    if remove_pathologies:
+        graph = _remove_pathologies_oop(graph)
+
+    return set(_iterate_nodes_in_shortest_paths(graph, nodes, weight=weight))
+
+
+@transformation
+def get_subgraph_by_all_shortest_paths(graph, nodes, weight=None, remove_pathologies=False):
+    """Induce a subgraph over the nodes in the pairwise shortest paths between all of the nodes in the given list.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param iter[tuple] nodes: A set of nodes over which to calculate shortest paths
+    :param str weight: Edge data key corresponding to the edge weight. If None, performs unweighted search
+    :param bool remove_pathologies: Should the pathology nodes be deleted before getting shortest paths?
+    :return: A BEL graph induced over the nodes appearing in the shortest paths between the given nodes
+    :rtype: Optional[pybel.BELGraph]
+    """
+    query_nodes = []
+
+    for node in nodes:
+        if node not in graph:
+            log.debug('%s not in %s', node, graph)
+            continue
+        query_nodes.append(node)
+
+    if not query_nodes:
+        return
+
+    induced_nodes = get_nodes_in_all_shortest_paths(graph, query_nodes, weight=weight,
+                                                    remove_pathologies=remove_pathologies)
+
+    if not induced_nodes:
+        return
+
+    return get_subgraph_by_induction(graph, induced_nodes)
diff --git a/src/pybel/struct/mutation/induction/utils.py b/src/pybel/struct/mutation/induction/utils.py
index 85ca2a2f..4f07f159 100644
--- a/src/pybel/struct/mutation/induction/utils.py
+++ b/src/pybel/struct/mutation/induction/utils.py
@@ -5,6 +5,7 @@ from ...pipeline import transformation
 
 __all__ = [
     'get_subgraph_by_edge_filter',
+    'get_subgraph_by_induction',
 ]
 
 
@@ -21,3 +22,17 @@ def get_subgraph_by_edge_filter(graph, edge_predicates=None):
     rv = graph.fresh_copy()
     expand_by_edge_filter(graph, rv, edge_predicates=edge_predicates)
     return rv
+
+
+@transformation
+def get_subgraph_by_induction(graph, nodes):
+    """Induce a sub-graph over the given nodes or return None if none of the nodes are in the given graph.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param iter[tuple] nodes: A list of BEL nodes in the graph
+    :rtype: Optional[pybel.BELGraph]
+    """
+    if all(node not in graph for node in nodes):
+        return
+
+    return graph.subgraph(nodes)
diff --git a/src/pybel/struct/mutation/induction_expansion.py b/src/pybel/struct/mutation/induction_expansion.py
new file mode 100644
index 00000000..d853f4b1
--- /dev/null
+++ b/src/pybel/struct/mutation/induction_expansion.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+
+"""Functions for building graphs that use both expansion and induction procedures."""
+
+import logging
+
+from .expansion import expand_all_node_neighborhoods
+from .expansion.upstream import expand_downstream_causal, expand_upstream_causal
+from .induction.neighborhood import get_subgraph_by_neighborhood
+from .induction.upstream import get_downstream_causal_subgraph, get_upstream_causal_subgraph
+from ..pipeline import transformation
+
+__all__ = [
+    'get_multi_causal_upstream',
+    'get_multi_causal_downstream',
+    'get_subgraph_by_second_neighbors',
+]
+
+log = logging.getLogger(__name__)
+
+
+@transformation
+def get_multi_causal_upstream(graph, nbunch):
+    """Get the union of all the 2-level deep causal upstream subgraphs from the nbunch.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param nbunch: A BEL node or list of BEL nodes
+    :type nbunch: tuple or list[tuple]
+    :return: A subgraph of the original BEL graph
+    :rtype: pybel.BELGraph
+    """
+    result = get_upstream_causal_subgraph(graph, nbunch)
+    expand_upstream_causal(graph, result)
+    return result
+
+
+@transformation
+def get_multi_causal_downstream(graph, nbunch):
+    """Get the union of all of the 2-level deep causal downstream subgraphs from the nbunch.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param nbunch: A BEL node or list of BEL nodes
+    :type nbunch: tuple or list[tuple]
+    :return: A subgraph of the original BEL graph
+    :rtype: pybel.BELGraph
+    """
+    result = get_downstream_causal_subgraph(graph, nbunch)
+    expand_downstream_causal(graph, result)
+    return result
+
+
+@transformation
+def get_subgraph_by_second_neighbors(graph, nodes, filter_pathologies=False):
+    """Get a graph around the neighborhoods of the given nodes and expand to the neighborhood of those nodes.
+
+    Returns none if none of the nodes are in the graph.
+
+    :param pybel.BELGraph graph: A BEL graph
+    :param iter[tuple] nodes: An iterable of BEL nodes
+    :param bool filter_pathologies: Should expansion take place around pathologies?
+    :return: A BEL graph induced around the neighborhoods of the given nodes
+    :rtype: Optional[pybel.BELGraph]
+    """
+    result = get_subgraph_by_neighborhood(graph, nodes)
+
+    if result is None:
+        return
+
+    expand_all_node_neighborhoods(graph, result, filter_pathologies=filter_pathologies)
+    return result


Test patch:
diff --git a/tests/test_dsl.py b/tests/test_dsl.py
index 5a71b144..8df24c17 100644
--- a/tests/test_dsl.py
+++ b/tests/test_dsl.py
@@ -65,14 +65,27 @@ class TestDSL(unittest.TestCase):
 
 
 class TestCentralDogma(unittest.TestCase):
-    def get_parent(self):
+    """Test functions specific for :class:`CentralDogmaAbundance`s."""
+
+    def test_get_parent(self):
+        """Test the get_parent function in :class:`CentralDogmaAbundance`s."""
         ab42 = protein(name='APP', namespace='HGNC', variants=[fragment(start=672, stop=713)])
         app = ab42.get_parent()
         self.assertEqual('p(HGNC:APP)', app.as_bel())
+        self.assertEqual('p(HGNC:APP, frag(672_713))', ab42.as_bel())
 
     def test_with_variants(self):
+        """Test the `with_variant` function in :class:`CentralDogmaAbundance`s."""
+        app = protein(name='APP', namespace='HGNC')
+        ab42 = app.with_variants(fragment(start=672, stop=713))
+        self.assertEqual('p(HGNC:APP)', app.as_bel())
+        self.assertEqual('p(HGNC:APP, frag(672_713))', ab42.as_bel())
+
+    def test_with_variants_list(self):
+        """Test the `with_variant` function in :class:`CentralDogmaAbundance`s."""
         app = protein(name='APP', namespace='HGNC')
         ab42 = app.with_variants([fragment(start=672, stop=713)])
+        self.assertEqual('p(HGNC:APP)', app.as_bel())
         self.assertEqual('p(HGNC:APP, frag(672_713))', ab42.as_bel())
 
 
diff --git a/tests/test_struct/test_filters/test_edge_predicate_builders.py b/tests/test_struct/test_filters/test_edge_predicate_builders.py
new file mode 100644
index 00000000..85c488fc
--- /dev/null
+++ b/tests/test_struct/test_filters/test_edge_predicate_builders.py
@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+
+"""Tests for edge predicate builders."""
+
+import unittest
+
+from pybel.constants import CITATION, CITATION_AUTHORS, CITATION_REFERENCE, CITATION_TYPE, CITATION_TYPE_PUBMED
+from pybel.struct.filters.edge_predicate_builders import (
+    build_author_inclusion_filter, build_pmid_inclusion_filter,
+)
+
+pmid1 = '1'
+pmid2 = '2'
+pmid3 = '3'
+
+author1 = '1'
+author2 = '2'
+author3 = '3'
+
+
+class TestEdgePredicateBuilders(unittest.TestCase):
+    """Tests for edge predicate builders."""
+
+    def test_build_pmid_inclusion_filter(self):
+        pmid_inclusion_filter = build_pmid_inclusion_filter(pmid1)
+
+        self.assertTrue(pmid_inclusion_filter({
+            CITATION: {
+                CITATION_TYPE: CITATION_TYPE_PUBMED,
+                CITATION_REFERENCE: pmid1,
+            }
+        }))
+
+        self.assertFalse(pmid_inclusion_filter({
+            CITATION: {
+                CITATION_TYPE: CITATION_TYPE_PUBMED,
+                CITATION_REFERENCE: pmid2,
+            }
+        }))
+
+    def test_build_pmid_set_inclusion_filter(self):
+        pmids = {pmid1, pmid2}
+        pmid_inclusion_filter = build_pmid_inclusion_filter(pmids)
+
+        self.assertTrue(pmid_inclusion_filter({
+            CITATION: {
+                CITATION_TYPE: CITATION_TYPE_PUBMED,
+                CITATION_REFERENCE: pmid1,
+            }
+        }))
+
+        self.assertTrue(pmid_inclusion_filter({
+            CITATION: {
+                CITATION_TYPE: CITATION_TYPE_PUBMED,
+                CITATION_REFERENCE: pmid2,
+            }
+        }))
+
+        self.assertFalse(pmid_inclusion_filter({
+            CITATION: {
+                CITATION_TYPE: CITATION_TYPE_PUBMED,
+                CITATION_REFERENCE: pmid3,
+            }
+        }))
+
+    def test_build_author_inclusion_filter(self):
+        author_inclusion_filter = build_author_inclusion_filter(author1)
+
+        self.assertTrue(author_inclusion_filter({
+            CITATION: {
+                CITATION_AUTHORS: [author1]
+            }
+        }))
+
+        self.assertTrue(author_inclusion_filter({
+            CITATION: {
+                CITATION_AUTHORS: [author1, author2]
+            }
+        }))
+
+        self.assertFalse(author_inclusion_filter({
+            CITATION: {
+                CITATION_AUTHORS: [author3]
+            }
+        }))
+
+    def test_build_author_set_inclusion_filter(self):
+        author = {author1, author2}
+        author_inclusion_filter = build_author_inclusion_filter(author)
+
+        self.assertTrue(author_inclusion_filter({
+            CITATION: {
+                CITATION_AUTHORS: [author1]
+            }
+        }))
+
+        self.assertTrue(author_inclusion_filter({
+            CITATION: {
+                CITATION_AUTHORS: [author1, author2]
+            }
+        }))
+
+        self.assertFalse(author_inclusion_filter({
+            CITATION: {
+                CITATION_AUTHORS: [author3]
+            }
+        }))
diff --git a/tests/test_struct/test_filters/test_node_predicate_builders.py b/tests/test_struct/test_filters/test_node_predicate_builders.py
index 1b520838..65cc7d4b 100644
--- a/tests/test_struct/test_filters/test_node_predicate_builders.py
+++ b/tests/test_struct/test_filters/test_node_predicate_builders.py
@@ -5,11 +5,14 @@
 import unittest
 
 from pybel import BELGraph
-from pybel.constants import GENE, PROTEIN
+from pybel.constants import GENE, NAME, PROTEIN
 from pybel.dsl import bioprocess, gene, protein
 from pybel.struct import filter_nodes
 from pybel.struct.filters import invert_node_predicate
-from pybel.struct.filters.node_predicate_builders import data_missing_key_builder, function_inclusion_filter_builder
+from pybel.struct.filters.node_predicate_builders import (
+    build_node_data_search, build_node_key_search, build_node_name_search, data_missing_key_builder,
+    function_inclusion_filter_builder,
+)
 from pybel.testing.utils import n
 
 
@@ -89,3 +92,55 @@ class TestNodePredicateBuilders(unittest.TestCase):
 
         self.assertNotIn(p1.as_tuple(), nodes)
         self.assertIn(p2.as_tuple(), nodes)
+
+    def test_build_node_data_search(self):
+        """Test build_node_data_search."""
+
+        def test_key_predicate(datum):
+            """Check the data is greater than zero.
+
+            :rtype: bool
+            """
+            return 0 < datum
+
+        key = n()
+
+        data_predicate = build_node_data_search(key, test_key_predicate)
+
+        graph = BELGraph()
+
+        p1 = protein('HGNC', n())
+        graph.add_node_from_data(p1)
+        graph.node[p1.as_tuple()][key] = 0
+        self.assertFalse(data_predicate(graph, p1.as_tuple()))
+
+        p2 = protein('HGNC', n())
+        graph.add_node_from_data(p2)
+        graph.node[p2.as_tuple()][key] = 5
+        self.assertTrue(data_predicate(graph, p2.as_tuple()))
+
+        p3 = protein('HGNC', n())
+        graph.add_node_from_data(p3)
+        self.assertFalse(data_predicate(graph, p3.as_tuple()))
+
+    def test_build_node_key_search(self):
+        """Test build_node_key_search."""
+        node_key_search = build_node_key_search(query='app', key=NAME)
+        node_name_search = build_node_name_search(query='app')
+
+        graph = BELGraph()
+
+        p1 = protein('HGNC', 'APP')
+        graph.add_node_from_data(p1)
+        self.assertTrue(node_key_search(graph, p1.as_tuple()))
+        self.assertTrue(node_name_search(graph, p1.as_tuple()))
+
+        p2 = protein('MGI', 'app')
+        graph.add_node_from_data(p2)
+        self.assertTrue(node_key_search(graph, p2.as_tuple()))
+        self.assertTrue(node_name_search(graph, p2.as_tuple()))
+
+        p3 = protein('HGNC', 'nope')
+        graph.add_node_from_data(p3)
+        self.assertFalse(node_key_search(graph, p3.as_tuple()))
+        self.assertFalse(node_name_search(graph, p3.as_tuple()))
diff --git a/tests/test_struct/test_filters/test_struct_node_predicates.py b/tests/test_struct/test_filters/test_struct_node_predicates.py
index dd88de00..eadbb6c1 100644
--- a/tests/test_struct/test_filters/test_struct_node_predicates.py
+++ b/tests/test_struct/test_filters/test_struct_node_predicates.py
@@ -423,15 +423,15 @@ class TestEdgePredicate(unittest.TestCase):
         self.assertFalse(is_associative_relation({RELATION: DIRECTLY_DECREASES}))
 
     def test_build_is_association(self):
+        """Test build_relation_predicate."""
         alternate_is_associative_relation = build_relation_predicate(ASSOCIATION)
 
-        self.assertTrue(alternate_is_associative_relation({RELATION: ASSOCIATION}))
+        g = BELGraph()
+        g.add_edge(p1.as_tuple(), p2.as_tuple(), key=0, **{RELATION: ASSOCIATION})
+        g.add_edge(p2.as_tuple(), p3.as_tuple(), key=0, **{RELATION: INCREASES})
 
-        self.assertFalse(alternate_is_associative_relation({RELATION: INCREASES}))
-        self.assertFalse(alternate_is_associative_relation({RELATION: CAUSES_NO_CHANGE}))
-        self.assertFalse(alternate_is_associative_relation({RELATION: DECREASES}))
-        self.assertFalse(alternate_is_associative_relation({RELATION: DIRECTLY_INCREASES}))
-        self.assertFalse(alternate_is_associative_relation({RELATION: DIRECTLY_DECREASES}))
+        self.assertTrue(alternate_is_associative_relation(g, p1.as_tuple(), p2.as_tuple(), 0))
+        self.assertFalse(alternate_is_associative_relation(g, p2.as_tuple(), p3.as_tuple(), 0))
 
     def test_has_degradation(self):
         self.assertTrue(edge_has_degradation({SUBJECT: {MODIFIER: DEGRADATION}}))
diff --git a/tests/test_struct/test_transformations/test_collapse.py b/tests/test_struct/test_transformations/test_collapse.py
index a1bb26dd..ef125844 100644
--- a/tests/test_struct/test_transformations/test_collapse.py
+++ b/tests/test_struct/test_transformations/test_collapse.py
@@ -4,7 +4,7 @@ import unittest
 
 from pybel import BELGraph
 from pybel.constants import DIRECTLY_INCREASES
-from pybel.dsl import abundance, gene, hgvs, mirna, pathology, protein, rna
+from pybel.dsl import abundance, gene, mirna, pathology, pmod, protein, rna
 from pybel.struct.mutation.collapse import collapse_all_variants, collapse_nodes, collapse_to_genes
 from pybel.testing.utils import n
 
@@ -15,6 +15,7 @@ CHEBI = 'CHEBI'
 g1 = gene(HGNC, '1')
 r1 = rna(HGNC, '1')
 p1 = protein(HGNC, '1')
+p1_phosphorylated = protein(HGNC, '1', variants=[pmod('Ph')])
 
 g2 = gene(HGNC, '2')
 r2 = rna(HGNC, '2')
@@ -96,10 +97,9 @@ class TestCollapseDownstream(unittest.TestCase):
 
     def test_collapse_all_variants(self):
         graph = BELGraph()
-        p1_variant = p1.with_variants(hgvs('?'))
-        graph.add_node_from_data(p1_variant)
+        graph.add_node_from_data(p1_phosphorylated)
 
-        graph.add_increases(p1_variant, p2, n(), n())
+        graph.add_increases(p1_phosphorylated, p2, n(), n())
 
         self.assertEqual(3, graph.number_of_nodes())
         self.assertEqual(2, graph.number_of_edges())
@@ -110,5 +110,5 @@ class TestCollapseDownstream(unittest.TestCase):
         self.assertEqual(1, graph.number_of_edges())
 
         self.assertIn(p1.as_tuple(), graph)
-        self.assertNotIn(p1_variant.as_tuple(), graph)
+        self.assertNotIn(p1_phosphorylated.as_tuple(), graph)
         self.assertIn(p2.as_tuple(), graph)
diff --git a/tests/test_struct/test_transformations/test_induction.py b/tests/test_struct/test_transformations/test_induction.py
index 79c91495..9ccd9ebe 100644
--- a/tests/test_struct/test_transformations/test_induction.py
+++ b/tests/test_struct/test_transformations/test_induction.py
@@ -1,12 +1,21 @@
 # -*- coding: utf-8 -*-
 
+"""Tests for PyBEL induction functions."""
+
 import string
 import unittest
 
 from pybel import BELGraph
-from pybel.constants import ASSOCIATION, DECREASES, FUNCTION, INCREASES, PROTEIN
+from pybel.constants import (
+    ASSOCIATION, CITATION_AUTHORS, CITATION_REFERENCE, CITATION_TYPE, CITATION_TYPE_PUBMED,
+    DECREASES, FUNCTION, INCREASES, PROTEIN,
+)
 from pybel.dsl import gene, protein, rna
-from pybel.struct.mutation import expand_upstream_causal, get_upstream_causal_subgraph
+from pybel.struct.mutation.expansion import expand_upstream_causal
+from pybel.struct.mutation.induction.citation import get_subgraph_by_authors, get_subgraph_by_pubmed
+from pybel.struct.mutation.induction.paths import get_nodes_in_all_shortest_paths, get_subgraph_by_all_shortest_paths
+from pybel.struct.mutation.induction.upstream import get_upstream_causal_subgraph
+from pybel.struct.mutation.induction.utils import get_subgraph_by_induction
 from pybel.testing.utils import n
 
 trem2_gene = gene(namespace='HGNC', name='TREM2')
@@ -14,7 +23,9 @@ trem2_rna = rna(namespace='HGNC', name='TREM2')
 trem2_protein = protein(namespace='HGNC', name='TREM2')
 
 
-class TestInduction(unittest.TestCase):
+class TestGraphMixin(unittest.TestCase):
+    """A mixin to enable testing nodes and edge membership in the graph."""
+
     def assertInGraph(self, node, graph):
         """Assert the node is in the graph.
 
@@ -34,16 +45,76 @@ class TestInduction(unittest.TestCase):
         self.assertFalse(graph.has_node_with_data(node))
 
     def assertInEdge(self, source, target, graph):
-        """
+        """Assert the edge is in the graph.
 
         :param source:
         :param target:
-        :param graph:
-        :return:
+        :type graph: pybel.BELGraph
+        :rtype: bool
         """
         self.assertIn(target.as_tuple(), graph[source.as_tuple()])
 
+
+class TestInduction(TestGraphMixin):
+    """Test induction functions."""
+
+    def test_get_subgraph_by_induction(self):
+        """Test get_subgraph_by_induction."""
+        graph = BELGraph()
+        keyword, url = n(), n()
+        graph.namespace_url[keyword] = url
+        a, b, c, d = [protein(namespace='test', name=n()) for _ in range(4)]
+        graph.add_directly_increases(a, b, n(), n())
+        graph.add_directly_increases(b, c, n(), n())
+        graph.add_directly_increases(c, d, n(), n())
+        graph.add_increases(a, d, n(), n())
+
+        nodes = [b.as_tuple(), c.as_tuple()]
+        subgraph = get_subgraph_by_induction(graph, nodes)
+
+        self.assertIn(keyword, subgraph.namespace_url)
+        self.assertEqual(url, subgraph.namespace_url[keyword])
+
+        self.assertNotInGraph(a, subgraph)
+        self.assertInGraph(b, subgraph)
+        self.assertInGraph(c, subgraph)
+        self.assertNotInGraph(d, subgraph)
+
+    def test_get_subgraph_by_all_shortest_paths(self):
+        """Test get_subgraph_by_all_shortest_paths."""
+        graph = BELGraph()
+        keyword, url = n(), n()
+        graph.namespace_url[keyword] = url
+        a, b, c, d, e, f = [protein(namespace='test', name=n()) for _ in range(6)]
+        graph.add_increases(a, b, n(), n())
+        graph.add_increases(a, c, n(), n())
+        graph.add_increases(b, d, n(), n())
+        graph.add_increases(c, d, n(), n())
+        graph.add_increases(a, e, n(), n())
+        graph.add_increases(e, f, n(), n())
+        graph.add_increases(f, d, n(), n())
+
+        query_nodes = [a.as_tuple(), d.as_tuple()]
+        shortest_paths_nodes = get_nodes_in_all_shortest_paths(graph, query_nodes)
+        self.assertIn(a.as_tuple(), shortest_paths_nodes)
+        self.assertIn(b.as_tuple(), shortest_paths_nodes)
+        self.assertIn(c.as_tuple(), shortest_paths_nodes)
+        self.assertIn(d.as_tuple(), shortest_paths_nodes)
+
+        subgraph = get_subgraph_by_all_shortest_paths(graph, query_nodes)
+
+        self.assertIn(keyword, subgraph.namespace_url)
+        self.assertEqual(url, subgraph.namespace_url[keyword])
+
+        self.assertInGraph(a, subgraph)
+        self.assertInGraph(b, subgraph)
+        self.assertInGraph(c, subgraph)
+        self.assertInGraph(d, subgraph)
+        self.assertNotInGraph(e, subgraph)
+        self.assertNotInGraph(f, subgraph)
+
     def test_get_upstream_causal_subgraph(self):
+        """Test get_upstream_causal_subgraph."""
         a, b, c, d, e, f = [protein(namespace='test', name=n()) for _ in range(6)]
         citation, evidence = '', ''
 
@@ -110,3 +181,147 @@ class TestInduction(unittest.TestCase):
         self.assertInEdge(f, b, subgraph)
         self.assertEqual(2, len(subgraph[a.as_tuple()][b.as_tuple()]))
         self.assertEqual(4, subgraph.number_of_edges(), msg='\n'.join(map(str, subgraph.edges())))
+
+
+class TestEdgePredicateBuilders(TestGraphMixin):
+    """Tests for edge predicate builders."""
+
+    def test_build_pmid_inclusion_filter(self):
+        a, b, c, d = [protein(namespace='test', name=n()) for _ in range(4)]
+        p1, p2, p3, p4 = n(), n(), n(), n()
+
+        graph = BELGraph()
+        keyword, url = n(), n()
+        graph.namespace_url[keyword] = url
+        graph.add_increases(a, b, n(), citation=p1)
+        graph.add_increases(a, b, n(), citation=p2)
+        graph.add_increases(b, c, n(), citation=p1)
+        graph.add_increases(b, c, n(), citation=p3)
+        graph.add_increases(c, d, n(), citation=p3)
+
+        subgraph = get_subgraph_by_pubmed(graph, p1)
+
+        self.assertIn(keyword, subgraph.namespace_url)
+        self.assertEqual(url, subgraph.namespace_url[keyword])
+
+        self.assertInGraph(a, subgraph)
+        self.assertInGraph(b, subgraph)
+        self.assertInGraph(c, subgraph)
+        self.assertNotInGraph(d, subgraph)
+
+        empty_subgraph = get_subgraph_by_pubmed(graph, p4)
+        self.assertIn(keyword, subgraph.namespace_url)
+        self.assertEqual(url, subgraph.namespace_url[keyword])
+        self.assertEqual(0, empty_subgraph.number_of_nodes())
+
+    def test_build_pmid_set_inclusion_filter(self):
+        a, b, c, d, e, f = [protein(namespace='test', name=n()) for _ in range(6)]
+        p1, p2, p3, p4, p5, p6 = n(), n(), n(), n(), n(), n()
+
+        graph = BELGraph()
+        keyword, url = n(), n()
+        graph.namespace_url[keyword] = url
+        graph.add_increases(a, b, n(), citation=p1)
+        graph.add_increases(a, b, n(), citation=p2)
+        graph.add_increases(b, c, n(), citation=p1)
+        graph.add_increases(b, c, n(), citation=p3)
+        graph.add_increases(c, d, n(), citation=p3)
+        graph.add_increases(e, f, n(), citation=p4)
+
+        subgraph = get_subgraph_by_pubmed(graph, [p1, p4])
+
+        self.assertIn(keyword, subgraph.namespace_url)
+        self.assertEqual(url, subgraph.namespace_url[keyword])
+
+        self.assertInGraph(a, subgraph)
+        self.assertInGraph(b, subgraph)
+        self.assertInGraph(c, subgraph)
+        self.assertNotInGraph(d, subgraph)
+        self.assertInGraph(e, subgraph)
+        self.assertInGraph(f, subgraph)
+
+        empty_subgraph = get_subgraph_by_pubmed(graph, [p5, p6])
+        self.assertIn(keyword, subgraph.namespace_url)
+        self.assertEqual(url, subgraph.namespace_url[keyword])
+        self.assertEqual(0, empty_subgraph.number_of_nodes())
+
+    def test_build_author_inclusion_filter(self):
+        a, b, c, d = [protein(namespace='test', name=n()) for _ in range(4)]
+        a1, a2, a3, a4, a5 = n(), n(), n(), n(), n()
+
+        c1 = {
+            CITATION_TYPE: CITATION_TYPE_PUBMED,
+            CITATION_REFERENCE: n(),
+            CITATION_AUTHORS: [a1, a2, a3]
+        }
+        c2 = {
+            CITATION_TYPE: CITATION_TYPE_PUBMED,
+            CITATION_REFERENCE: n(),
+            CITATION_AUTHORS: [a1, a4]
+        }
+
+        graph = BELGraph()
+        keyword, url = n(), n()
+        graph.namespace_url[keyword] = url
+        graph.add_increases(a, b, n(), citation=c1)
+        graph.add_increases(a, b, n(), citation=c2)
+        graph.add_increases(b, c, n(), citation=c1)
+        graph.add_increases(c, d, n(), citation=c2)
+
+        subgraph1 = get_subgraph_by_authors(graph, a1)
+
+        self.assertIn(keyword, subgraph1.namespace_url)
+        self.assertEqual(url, subgraph1.namespace_url[keyword])
+
+        self.assertInGraph(a, subgraph1)
+        self.assertInGraph(b, subgraph1)
+        self.assertInGraph(c, subgraph1)
+        self.assertInGraph(d, subgraph1)
+
+        subgraph2 = get_subgraph_by_authors(graph, a2)
+
+        self.assertIn(keyword, subgraph2.namespace_url)
+        self.assertEqual(url, subgraph2.namespace_url[keyword])
+
+        self.assertInGraph(a, subgraph2)
+        self.assertInGraph(b, subgraph2)
+        self.assertInGraph(c, subgraph2)
+        self.assertNotInGraph(d, subgraph2)
+
+        subgraph3 = get_subgraph_by_authors(graph, a5)
+        self.assertIn(keyword, subgraph3.namespace_url)
+        self.assertEqual(url, subgraph3.namespace_url[keyword])
+        self.assertEqual(0, subgraph3.number_of_nodes())
+
+    def test_build_author_set_inclusion_filter(self):
+        a, b, c, d = [protein(namespace='test', name=n()) for _ in range(4)]
+        a1, a2, a3, a4, a5 = n(), n(), n(), n(), n()
+
+        c1 = {
+            CITATION_TYPE: CITATION_TYPE_PUBMED,
+            CITATION_REFERENCE: n(),
+            CITATION_AUTHORS: [a1, a2, a3]
+        }
+        c2 = {
+            CITATION_TYPE: CITATION_TYPE_PUBMED,
+            CITATION_REFERENCE: n(),
+            CITATION_AUTHORS: [a1, a4]
+        }
+
+        graph = BELGraph()
+        keyword, url = n(), n()
+        graph.namespace_url[keyword] = url
+        graph.add_increases(a, b, n(), citation=c1)
+        graph.add_increases(a, b, n(), citation=c2)
+        graph.add_increases(b, c, n(), citation=c1)
+        graph.add_increases(c, d, n(), citation=c2)
+
+        subgraph1 = get_subgraph_by_authors(graph, [a1, a2])
+
+        self.assertIn(keyword, subgraph1.namespace_url)
+        self.assertEqual(url, subgraph1.namespace_url[keyword])
+
+        self.assertInGraph(a, subgraph1)
+        self.assertInGraph(b, subgraph1)
+        self.assertInGraph(c, subgraph1)
+        self.assertInGraph(d, subgraph1)