Repository: pybel/pybel
Pull request #449 (2020-06-29 20:08:20)

Add initial graph dispatches
This PR adds a whole new paradigm for how PyBEL code is accessed - by adding objects into the BELGraph that hold a reference to the graph itself and wrap the many summary, mutation, and filter functions available in PyBEL and even beyond to tools like PyBEL-Tools (by using some sketchy importing)


Patch:
diff --git a/README.rst b/README.rst
index b8fd0c63..7f217cb1 100644
--- a/README.rst
+++ b/README.rst
@@ -82,6 +82,80 @@ check the `I/O documentation <https://pybel.readthedocs.io/en/latest/reference/i
    >>> pybel.dump(graph, 'my_graph.bel.graphdati.json')
    >>> # write as CX JSON for NDEx
    >>> pybel.dump(graph, 'my_graph.bel.cx.json')
+   >>> # write as INDRA JSON for INDRA
+   >>> pybel.dump(graph, 'my_graph.indra.json')
+
+Summarizing the Contents of the Graph
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+The ``BELGraph`` object has several "dispatches" which are properties that organize its various functionalities.
+One is the ``BELGraph.summary`` dispatch, which allows for printing summaries to the console.
+
+These examples will use the `RAS Model <https://emmaa.indra.bio/dashboard/rasmodel?tab=model>`_  from EMMAA,
+so you'll have to be sure to ``pip install indra`` first. The graph can be acquired and summarized with
+``BELGraph.summary.statistics()`` as in:
+
+.. code-block:: python
+
+    >>> import pybel
+    >>> graph = pybel.from_emmaa('rasmodel', date='2020-05-29-17-31-58')  # Needs
+    >>> graph.summary.statistics()
+    ---------------------  -------------------
+    Name                   rasmodel
+    Version                2020-05-29-17-31-58
+    Number of Nodes        126
+    Number of Namespaces   5
+    Number of Edges        206
+    Number of Annotations  4
+    Number of Citations    1
+    Number of Authors      0
+    Network Density        1.31E-02
+    Number of Components   1
+    Number of Warnings     0
+    ---------------------  -------------------
+
+The number of nodes of each type can be summarized with ``BELGraph.summary.nodes()`` as in:
+
+.. code-block:: python
+
+    >>> graph.summary.nodes(examples=False)
+    Type (3)        Count
+    ------------  -------
+    Protein            97
+    Complex            27
+    Abundance           2
+
+
+The number of nodes with each namespace can be summarized with ``BELGraph.summary.namespaces()`` as in:
+
+.. code-block:: python
+
+    >>> graph.summary.namespaces(examples=False)
+    Namespace (4)      Count
+    ---------------  -------
+    HGNC                  94
+    FPLX                   3
+    CHEBI                  1
+    TEXT                   1
+
+The edges can be summarized with ``BELGraph.summary.edges()`` as in:
+
+.. code-block:: python
+
+    >>> graph.summary.edges(examples=False)
+    Edge Type (12)                       Count
+    ---------------------------------  -------
+    Protein increases Protein               64
+    Protein hasVariant Protein              48
+    Protein partOf Complex                  47
+    Complex increases Protein               20
+    Protein decreases Protein                9
+    Complex directlyIncreases Protein        8
+    Protein increases Complex                3
+    Abundance partOf Complex                 3
+    Protein increases Abundance              1
+    Complex partOf Complex                   1
+    Protein decreases Abundance              1
+    Abundance decreases Protein              1
 
 Grounding the Graph
 ~~~~~~~~~~~~~~~~~~~
diff --git a/docs/source/reference/struct/datamodel.rst b/docs/source/reference/struct/datamodel.rst
index 3d96878b..ac7380d6 100644
--- a/docs/source/reference/struct/datamodel.rst
+++ b/docs/source/reference/struct/datamodel.rst
@@ -53,6 +53,27 @@ Graph
     .. automethod:: __and__
     .. automethod:: __iand__
 
+
+Dispatches
+~~~~~~~~~~
+Dispatches are classes that enable easy access to summary, mutation, and other functions
+that consume graphs directly through the :class:`pybel.BELGraph` interface.
+
+.. autoclass:: pybel.struct.graph.CountDispatch
+    :members:
+
+.. autoclass:: pybel.struct.graph.InduceDispatch
+    :members:
+
+.. autoclass:: pybel.struct.graph.SummaryDispatch
+    :members:
+
+.. autoclass:: pybel.struct.graph.ExpandDispatch
+    :members:
+
+.. autoclass:: pybel.struct.graph.PlotDispatch
+    :members:
+
 Nodes
 -----
 Nodes (or *entities*) in a :class:`pybel.BELGraph` represent physical entities' abundances. Most contain information
diff --git a/setup.cfg b/setup.cfg
index 413316ef..8e194350 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -60,6 +60,8 @@ install_requires =
     requests_file
     pyparsing
     tqdm
+    humanize
+    tabulate
     pandas
 
 # Random options
diff --git a/src/pybel/cli.py b/src/pybel/cli.py
index a8af5b10..b64269c1 100644
--- a/src/pybel/cli.py
+++ b/src/pybel/cli.py
@@ -176,7 +176,7 @@ def summarize(graph: BELGraph):
 def _print_summary(graph: BELGraph, ticks: bool = False):
     if not ticks:
         click.secho('Summary', fg='red', bold=True)
-        graph.summarize()
+        graph.summary()
 
     unused_namespaces = get_unused_namespaces(graph)
     if unused_namespaces:
diff --git a/src/pybel/io/biodati_client.py b/src/pybel/io/biodati_client.py
index 6b2ef58a..0be28a3b 100644
--- a/src/pybel/io/biodati_client.py
+++ b/src/pybel/io/biodati_client.py
@@ -298,7 +298,7 @@ def _main():
     """Run with python -m pybel.io.graphdati."""
     network_id = '01E46GDFQAGK5W8EFS9S9WMH12'
     graph = from_biodati(network_id=network_id)
-    graph.summarize()
+    graph.summary()
 
 
 if __name__ == '__main__':
diff --git a/src/pybel/io/emmaa.py b/src/pybel/io/emmaa.py
index 0e239b5a..66f24921 100644
--- a/src/pybel/io/emmaa.py
+++ b/src/pybel/io/emmaa.py
@@ -50,6 +50,9 @@ def from_emmaa(model: str, *, date: Optional[str] = None) -> BELGraph:
         covid19_emmaa_graph = pybel.from_emmaa('covid19', '2020-04-23-17-44-57')
         covid19_emmaa_graph.summarize()
     """
+    if date is None:
+        date = _get_latest_date(model)
+
     statements = get_statements_from_emmaa(model=model, date=date)
     return from_indra_statements(statements, name=model, version=date)
 
diff --git a/src/pybel/struct/filters/edge_predicate_builders.py b/src/pybel/struct/filters/edge_predicate_builders.py
index 9380f2ff..6684204c 100644
--- a/src/pybel/struct/filters/edge_predicate_builders.py
+++ b/src/pybel/struct/filters/edge_predicate_builders.py
@@ -4,7 +4,8 @@
 
 from typing import Iterable, Mapping
 
-from .edge_predicates import edge_predicate, has_authors, has_pubmed, keep_edge_permissive
+from .edge_filters import invert_edge_predicate
+from .edge_predicates import edge_predicate, has_authors, has_pubmed, true_edge_predicate
 from .typing import EdgePredicate
 from ..graph import BELGraph
 from ...constants import ANNOTATIONS, CAUSAL_RELATIONS, CITATION, CITATION_AUTHORS, CITATION_IDENTIFIER, RELATION
@@ -12,12 +13,14 @@ from ...dsl import BaseEntity
 from ...typing import EdgeData, Strings
 
 __all__ = [
+    'build_pmid_exclusion_filter',
     'build_annotation_dict_all_filter',
     'build_annotation_dict_any_filter',
     'build_upstream_edge_predicate',
     'build_downstream_edge_predicate',
     'build_relation_predicate',
     'build_pmid_inclusion_filter',
+    'build_pmid_exclusion_filter',
     'build_author_inclusion_filter',
 ]
 
@@ -53,7 +56,7 @@ def build_annotation_dict_all_filter(annotations: Mapping[str, Iterable[str]]) -
     :param annotations: The annotation query dict to match
     """
     if not annotations:
-        return keep_edge_permissive
+        return true_edge_predicate
 
     @edge_predicate
     def annotation_dict_all_filter(edge_data: EdgeData) -> bool:
@@ -88,7 +91,7 @@ def build_annotation_dict_any_filter(annotations: Mapping[str, Iterable[str]]) -
     :param annotations: The annotation query dict to match
     """
     if not annotations:
-        return keep_edge_permissive
+        return true_edge_predicate
 
     @edge_predicate
     def annotation_dict_any_filter(edge_data: EdgeData) -> bool:
@@ -167,6 +170,14 @@ def build_pmid_inclusion_filter(pmids: Strings) -> EdgePredicate:
     return pmid_inclusion_filter
 
 
+def build_pmid_exclusion_filter(pmids: Strings) -> EdgePredicate:
+    """Fail for edges with citations whose references are one of the given PubMed identifiers.
+
+    :param pmids: A PubMed identifier or list of PubMed identifiers to filter against
+    """
+    return invert_edge_predicate(build_pmid_inclusion_filter(pmids))
+
+
 def build_author_inclusion_filter(authors: Strings) -> EdgePredicate:
     """Build an edge predicate that passes for edges with citations written by the given author(s)."""
     if isinstance(authors, str):
diff --git a/src/pybel/struct/filters/edge_predicates.py b/src/pybel/struct/filters/edge_predicates.py
index c01b8079..7c210449 100644
--- a/src/pybel/struct/filters/edge_predicates.py
+++ b/src/pybel/struct/filters/edge_predicates.py
@@ -18,7 +18,8 @@ from ...typing import EdgeData
 
 __all__ = [
     'edge_predicate',
-    'keep_edge_permissive',
+    'true_edge_predicate',
+    'false_edge_predicate',
     'has_provenance',
     'has_pubmed',
     'has_authors',
@@ -57,15 +58,16 @@ def edge_predicate(func: DictEdgePredicate) -> EdgePredicate:  # noqa: D202
     return _wrapped
 
 
-def keep_edge_permissive(*args, **kwargs) -> bool:
-    """Return true for all edges.
-
-    :param dict data: A PyBEL edge data dictionary from a :class:`pybel.BELGraph`
-    :return: Always returns :code:`True`
-    """
+def true_edge_predicate(graph: BELGraph, u: BaseEntity, v: BaseEntity, k: str) -> bool:
+    """Return true for all edges."""
     return True
 
 
+def false_edge_predicate(graph: BELGraph, u: BaseEntity, v: BaseEntity, k: str) -> bool:
+    """Return false for all edges."""
+    return False
+
+
 @edge_predicate
 def has_provenance(edge_data: EdgeData) -> bool:
     """Check if the edge has provenance information (i.e. citation and evidence)."""
diff --git a/src/pybel/struct/filters/node_filters.py b/src/pybel/struct/filters/node_filters.py
index 8a130bdf..a1a67029 100644
--- a/src/pybel/struct/filters/node_filters.py
+++ b/src/pybel/struct/filters/node_filters.py
@@ -16,6 +16,7 @@ from typing import Iterable, Set
 from .node_predicate_builders import function_inclusion_filter_builder, namespace_inclusion_builder
 from .node_predicates import concatenate_node_predicates
 from .typing import NodePredicates
+from ..graph import BELGraph
 from ...dsl import BaseEntity
 from ...typing import Strings
 
@@ -29,7 +30,7 @@ __all__ = [
 ]
 
 
-def filter_nodes(graph, node_predicates: NodePredicates) -> Iterable[BaseEntity]:
+def filter_nodes(graph: BELGraph, node_predicates: NodePredicates) -> Iterable[BaseEntity]:
     """Apply a set of predicates to the nodes iterator of a BEL graph."""
     concatenated_predicate = concatenate_node_predicates(node_predicates=node_predicates)
     for node in graph:
@@ -37,17 +38,17 @@ def filter_nodes(graph, node_predicates: NodePredicates) -> Iterable[BaseEntity]
             yield node
 
 
-def get_nodes(graph, node_predicates: NodePredicates) -> Set[BaseEntity]:
+def get_nodes(graph: BELGraph, node_predicates: NodePredicates) -> Set[BaseEntity]:
     """Get the set of all nodes that pass the predicates."""
     return set(filter_nodes(graph, node_predicates=node_predicates))
 
 
-def count_passed_node_filter(graph, node_predicates: NodePredicates) -> int:
+def count_passed_node_filter(graph: BELGraph, node_predicates: NodePredicates) -> int:
     """Count how many nodes pass a given set of node predicates."""
     return sum(1 for _ in filter_nodes(graph, node_predicates=node_predicates))
 
 
-def summarize_node_filter(graph, node_filters: NodePredicates) -> None:
+def summarize_node_filter(graph: BELGraph, node_filters: NodePredicates) -> None:
     """Print a summary of the number of nodes passing a given set of filters.
 
     :param graph: A BEL graph
@@ -57,7 +58,7 @@ def summarize_node_filter(graph, node_filters: NodePredicates) -> None:
     print('{}/{} nodes passed'.format(passed, graph.number_of_nodes()))
 
 
-def get_nodes_by_function(graph, func: Strings) -> Set[BaseEntity]:
+def get_nodes_by_function(graph: BELGraph, func: Strings) -> Set[BaseEntity]:
     """Get all nodes with the given function(s)."""
     return get_nodes(graph, function_inclusion_filter_builder(func))
 
diff --git a/src/pybel/struct/filters/node_predicate_builders.py b/src/pybel/struct/filters/node_predicate_builders.py
index 25b8bb74..4b376ff2 100644
--- a/src/pybel/struct/filters/node_predicate_builders.py
+++ b/src/pybel/struct/filters/node_predicate_builders.py
@@ -4,6 +4,7 @@
 
 from typing import Any, Callable, Iterable, List, Union
 
+from .node_predicates import concatenate_node_predicates, invert_node_predicate
 from .typing import NodePredicate
 from ..graph import BELGraph
 from ...constants import CONCEPT, NAME
@@ -12,6 +13,7 @@ from ...typing import Strings
 
 __all__ = [
     'function_inclusion_filter_builder',
+    'function_exclusion_filter_builder',
     'data_missing_key_builder',
     'build_node_data_search',
     'build_node_graph_data_search',
@@ -59,6 +61,14 @@ def _collection_function_inclusion_builder(funcs: Iterable[str]) -> NodePredicat
     return functions_inclusion_filter
 
 
+def function_exclusion_filter_builder(func: Strings) -> NodePredicate:
+    """Build a filter that fails on nodes of the given function(s).
+
+    :param func: A BEL Function or list/set/tuple of BEL functions
+    """
+    return invert_node_predicate(function_inclusion_filter_builder(func))
+
+
 def data_missing_key_builder(key: str) -> NodePredicate:  # noqa: D202
     """Build a filter that passes only on nodes that don't have the given key in their data dictionary.
 
diff --git a/src/pybel/struct/filters/node_predicates/activities.py b/src/pybel/struct/filters/node_predicates/activities.py
index 12f3e97b..26983d1c 100644
--- a/src/pybel/struct/filters/node_predicates/activities.py
+++ b/src/pybel/struct/filters/node_predicates/activities.py
@@ -3,9 +3,9 @@
 """Pre-defined predicates for nodes."""
 
 from ..utils import part_has_modifier
+from ...graph import BELGraph
 from ....constants import ACTIVITY, DEGRADATION, OBJECT, SUBJECT, TRANSLOCATION
 from ....dsl import BaseEntity
-from ....struct.graph import BELGraph
 
 __all__ = [
     'has_edge_modifier',
@@ -40,16 +40,16 @@ def has_edge_modifier(graph: BELGraph, node: BaseEntity, modifier: str) -> bool:
     return modifier_in_subject or modifier_in_object
 
 
-def has_activity(graph, node: BaseEntity) -> bool:
+def has_activity(graph: BELGraph, node: BaseEntity) -> bool:
     """Return true if over any of the node's edges, it has a molecular activity."""
     return has_edge_modifier(graph, node, ACTIVITY)
 
 
-def is_degraded(graph, node: BaseEntity) -> bool:
+def is_degraded(graph: BELGraph, node: BaseEntity) -> bool:
     """Return true if over any of the node's edges, it is degraded."""
     return has_edge_modifier(graph, node, DEGRADATION)
 
 
-def is_translocated(graph, node: BaseEntity) -> bool:
+def is_translocated(graph: BELGraph, node: BaseEntity) -> bool:
     """Return true if over any of the node's edges, it is translocated."""
     return has_edge_modifier(graph, node, TRANSLOCATION)
diff --git a/src/pybel/struct/filters/node_predicates/misc.py b/src/pybel/struct/filters/node_predicates/misc.py
index d74a0e65..4498e9b1 100644
--- a/src/pybel/struct/filters/node_predicates/misc.py
+++ b/src/pybel/struct/filters/node_predicates/misc.py
@@ -6,6 +6,7 @@ from typing import Iterable, Type
 
 from .utils import node_predicate
 from ..typing import NodePredicate
+from ...graph import BELGraph
 from ....constants import PART_OF, RELATION
 from ....dsl import BaseEntity, ListAbundance
 
@@ -41,7 +42,7 @@ def one_of(nodes: Iterable[BaseEntity]) -> NodePredicate:
 
 
 def is_isolated_list_abundance(
-    graph,
+    graph: BELGraph,
     node: BaseEntity,
     cls: Type[ListAbundance] = ListAbundance,
 ) -> bool:
diff --git a/src/pybel/struct/filters/node_predicates/relations.py b/src/pybel/struct/filters/node_predicates/relations.py
index f8113017..8721abf2 100644
--- a/src/pybel/struct/filters/node_predicates/relations.py
+++ b/src/pybel/struct/filters/node_predicates/relations.py
@@ -4,6 +4,7 @@
 
 from typing import Set
 
+from ...graph import BELGraph
 from ....constants import CAUSAL_RELATIONS, RELATION
 from ....dsl import BaseEntity
 
@@ -24,7 +25,7 @@ __all__ = [
 ]
 
 
-def has_in_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
+def has_in_edges(graph: BELGraph, node: BaseEntity, edge_types: Set[str]) -> bool:
     """Check if the node has any in-edges in the given set.
 
     :param graph: A BEL graph
@@ -37,7 +38,7 @@ def has_in_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
     )
 
 
-def no_in_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
+def no_in_edges(graph: BELGraph, node: BaseEntity, edge_types: Set[str]) -> bool:
     """Check if the node does not have any in-edges in the given set.
 
     :param graph: A BEL graph
@@ -50,7 +51,7 @@ def no_in_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
     )
 
 
-def has_out_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
+def has_out_edges(graph: BELGraph, node: BaseEntity, edge_types: Set[str]) -> bool:
     """Check if the node has any out-edges in the given set.
 
     :param graph: A BEL graph
@@ -63,7 +64,7 @@ def has_out_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
     )
 
 
-def no_out_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
+def no_out_edges(graph: BELGraph, node: BaseEntity, edge_types: Set[str]) -> bool:
     """Check if the node does not have any out-edges in the given set.
 
     :param graph: A BEL graph
@@ -76,7 +77,7 @@ def no_out_edges(graph, node: BaseEntity, edge_types: Set[str]) -> bool:
     )
 
 
-def has_causal_in_edges(graph, node: BaseEntity) -> bool:
+def has_causal_in_edges(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node has any causal in-edges.
 
     :param graph: A BEL graph
@@ -85,7 +86,7 @@ def has_causal_in_edges(graph, node: BaseEntity) -> bool:
     return has_in_edges(graph, node, CAUSAL_RELATIONS)
 
 
-def no_causal_in_edges(graph, node: BaseEntity) -> bool:
+def no_causal_in_edges(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node does not have any causal in-edges.
 
     :param graph: A BEL graph
@@ -94,7 +95,7 @@ def no_causal_in_edges(graph, node: BaseEntity) -> bool:
     return no_in_edges(graph, node, CAUSAL_RELATIONS)
 
 
-def has_causal_out_edges(graph, node: BaseEntity) -> bool:
+def has_causal_out_edges(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node has any causal out-edges.
 
     :param graph: A BEL graph
@@ -103,7 +104,7 @@ def has_causal_out_edges(graph, node: BaseEntity) -> bool:
     return has_out_edges(graph, node, CAUSAL_RELATIONS)
 
 
-def no_causal_out_edges(graph, node: BaseEntity) -> bool:
+def no_causal_out_edges(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node does not have any causal out-edges.
 
     :param graph: A BEL graph
@@ -112,7 +113,7 @@ def no_causal_out_edges(graph, node: BaseEntity) -> bool:
     return no_out_edges(graph, node, CAUSAL_RELATIONS)
 
 
-def has_causal_edges(graph, node: BaseEntity) -> bool:
+def has_causal_edges(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node has any causal out-edges or in-edges.
 
     :param graph: A BEL graph
@@ -121,7 +122,7 @@ def has_causal_edges(graph, node: BaseEntity) -> bool:
     return has_causal_in_edges(graph, node) or has_causal_out_edges(graph, node)
 
 
-def no_causal_edges(graph, node: BaseEntity) -> bool:
+def no_causal_edges(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node does not have any causal out-edges or in-edges.
 
     :param graph: A BEL graph
@@ -130,7 +131,7 @@ def no_causal_edges(graph, node: BaseEntity) -> bool:
     return no_causal_in_edges(graph, node) and no_causal_out_edges(graph, node)
 
 
-def is_causal_source(graph, node: BaseEntity) -> bool:
+def is_causal_source(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node has causal out-edges but no causal in-edges.
 
     :param graph: A BEL graph
@@ -139,7 +140,7 @@ def is_causal_source(graph, node: BaseEntity) -> bool:
     return no_causal_in_edges(graph, node) and has_causal_out_edges(graph, node)
 
 
-def is_causal_sink(graph, node: BaseEntity) -> bool:
+def is_causal_sink(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node has causal in-edges but no causal out-edges.
 
     :param graph: A BEL graph
@@ -148,7 +149,7 @@ def is_causal_sink(graph, node: BaseEntity) -> bool:
     return has_causal_in_edges(graph, node) and no_causal_out_edges(graph, node)
 
 
-def is_causal_central(graph, node: BaseEntity) -> bool:
+def is_causal_central(graph: BELGraph, node: BaseEntity) -> bool:
     """Check if the node has both causal in-edges and also causal out-edges.
 
     :param graph: A BEL graph
diff --git a/src/pybel/struct/graph.py b/src/pybel/struct/graph.py
index 01d17579..491dd9e7 100644
--- a/src/pybel/struct/graph.py
+++ b/src/pybel/struct/graph.py
@@ -3,15 +3,18 @@
 """Contains the main data structure for PyBEL."""
 
 import logging
-from collections import defaultdict
+import warnings
+from collections import Counter, defaultdict
 from copy import deepcopy
 from functools import partialmethod
 from itertools import chain
 from typing import Any, Dict, Hashable, Iterable, List, Mapping, Optional, Set, TextIO, Tuple, Union
 
 import networkx as nx
+from tabulate import tabulate
 
 from .operations import left_full_join, left_node_intersection_join, left_outer_join
+from .utils import update_metadata
 from ..canonicalize import edge_to_bel
 from ..constants import (
     ACTIVITY, ANNOTATIONS, ASSOCIATION, CAUSES_NO_CHANGE, CITATION, CITATION_AUTHORS, CITATION_DB, CITATION_IDENTIFIER,
@@ -112,6 +115,53 @@ class BELGraph(nx.MultiDiGraph):
         if path:
             self.path = path
 
+        #: A refernce to the parent graph
+        self.parent = None
+        self._count = CountDispatch(self)
+        self._expand = ExpandDispatch(self)
+        self._induce = InduceDispatch(self)
+        self._plot = PlotDispatch(self)
+        self._summary = SummaryDispatch(self)
+
+    def child(self) -> 'BELGraph':
+        """Create an empty graph with a "parent" reference back to this one."""
+        rv = BELGraph()
+        rv.parent = self
+        update_metadata(source=self, target=rv)
+        return rv
+
+    @property
+    def count(self) -> 'CountDispatch':  # noqa: D401
+        """A dispatch to count functions.
+
+        Can be used like this:
+
+        >>> from pybel.examples import sialic_acid_graph
+        >>> sialic_acid_graph.count.functions()
+        Counter({'Protein': 7, 'Complex': 1, 'Abundance': 1})
+        """
+        return self._count
+
+    @property
+    def summary(self) -> 'SummaryDispatch':  # noqa: D401
+        """A dispatch to summarize the graph."""
+        return self._summary
+
+    @property
+    def expand(self) -> 'ExpandDispatch':  # noqa: D401
+        """A dispatch to expand the graph w.r.t. its parent."""
+        return self._expand
+
+    @property
+    def induce(self) -> 'InduceDispatch':  # noqa: D401
+        """A dispatch to mutate the graph."""
+        return self._induce
+
+    @property
+    def plot(self) -> 'PlotDispatch':  # noqa: D401
+        """A dispatch to plot the graph using :mod:`matplotlib` and :mod:`seaborn`."""
+        return self._plot
+
     @property
     def path(self) -> Optional[str]:  # noqa: D401
         """The graph's path, if it was derived from a BEL document."""
@@ -309,12 +359,7 @@ class BELGraph(nx.MultiDiGraph):
 
     def number_of_citations(self) -> int:
         """Return the number of citations contained within the graph."""
-        return len(set(self._iterate_citations()))
-
-    def _iterate_citations(self) -> Iterable[Tuple[str, str]]:
-        for _, _, data in self.edges(data=True):
-            if CITATION in data:
-                yield data[CITATION][CITATION_DB], data[CITATION][CITATION_IDENTIFIER]
+        return self.count.citations()
 
     def number_of_authors(self) -> int:
         """Return the number of authors contained within the graph."""
@@ -322,14 +367,7 @@ class BELGraph(nx.MultiDiGraph):
 
     def get_authors(self) -> Set[str]:
         """Get the authors for the citations in the graph."""
-        return set(self._iterate_authors())
-
-    def _iterate_authors(self) -> Iterable[str]:
-        return chain.from_iterable(
-            data[CITATION][CITATION_AUTHORS]
-            for _, _, data in self.edges(data=True)
-            if CITATION in data and CITATION_AUTHORS in data[CITATION]
-        )
+        return set(self.count.authors())
 
     def __str__(self):
         return '{} v{}'.format(self.name, self.version)
@@ -665,15 +703,12 @@ class BELGraph(nx.MultiDiGraph):
     def __add__(self, other: 'BELGraph') -> 'BELGraph':
         """Copy this graph and join it with another graph with it using :func:`pybel.struct.left_full_join`.
 
-        :param BELGraph other: Another BEL graph
-        :rtype: BELGraph
+        :param other: Another BEL graph
 
         Example usage:
 
-        >>> import pybel
-        >>> g = pybel.from_bel_script('...')
-        >>> h = pybel.from_bel_script('...')
-        >>> k = g + h
+        >>> from pybel.examples import ras_tloc_graph, braf_graph
+        >>> k = ras_tloc_graph + braf_graph
         """
         if not isinstance(other, BELGraph):
             raise TypeError('{} is not a {}'.format(other, self.__class__.__name__))
@@ -685,15 +720,12 @@ class BELGraph(nx.MultiDiGraph):
     def __iadd__(self, other: 'BELGraph') -> 'BELGraph':
         """Join another graph into this one, in-place, using :func:`pybel.struct.left_full_join`.
 
-        :param BELGraph other: Another BEL graph
-        :rtype: BELGraph
+        :param other: Another BEL graph
 
         Example usage:
 
-        >>> import pybel
-        >>> g = pybel.from_bel_script('...')
-        >>> h = pybel.from_bel_script('...')
-        >>> g += h
+        >>> from pybel.examples import ras_tloc_graph, braf_graph
+        >>> ras_tloc_graph += braf_graph
         """
         if not isinstance(other, BELGraph):
             raise TypeError('{} is not a {}'.format(other, self.__class__.__name__))
@@ -706,15 +738,12 @@ class BELGraph(nx.MultiDiGraph):
 
         Uses :func:`pybel.struct.left_outer_join`.
 
-        :param BELGraph other: Another BEL graph
-        :rtype: BELGraph
+        :param other: Another BEL graph
 
         Example usage:
 
-        >>> import pybel
-        >>> g = pybel.from_bel_script('...')
-        >>> h = pybel.from_bel_script('...')
-        >>> k = g & h
+        >>> from pybel.examples import ras_tloc_graph, braf_graph
+        >>> k = ras_tloc_graph & braf_graph
         """
         if not isinstance(other, BELGraph):
             raise TypeError('{} is not a {}'.format(other, self.__class__.__name__))
@@ -726,15 +755,12 @@ class BELGraph(nx.MultiDiGraph):
     def __iand__(self, other: 'BELGraph') -> 'BELGraph':
         """Join another graph into this one, in-place, using :func:`pybel.struct.left_outer_join`.
 
-        :param BELGraph other: Another BEL graph
-        :rtype: BELGraph
+        :param other: Another BEL graph
 
         Example usage:
 
-        >>> import pybel
-        >>> g = pybel.from_bel_script('...')
-        >>> h = pybel.from_bel_script('...')
-        >>> g &= h
+        >>> from pybel.examples import ras_tloc_graph, braf_graph
+        >>> ras_tloc_graph &= braf_graph
         """
         if not isinstance(other, BELGraph):
             raise TypeError('{} is not a {}'.format(other, self.__class__.__name__))
@@ -745,15 +771,12 @@ class BELGraph(nx.MultiDiGraph):
     def __xor__(self, other: 'BELGraph') -> 'BELGraph':
         """Join this graph with another using :func:`pybel.struct.left_node_intersection_join`.
 
-        :param BELGraph other: Another BEL graph
-        :rtype: BELGraph
+        :param other: Another BEL graph
 
         Example usage:
 
-        >>> import pybel
-        >>> g = pybel.from_bel_script('...')
-        >>> h = pybel.from_bel_script('...')
-        >>> k = g ^ h
+        >>> from pybel.examples import ras_tloc_graph, braf_graph
+        >>> k = ras_tloc_graph ^ braf_graph
         """
         if not isinstance(other, BELGraph):
             raise TypeError('{} is not a {}'.format(other, self.__class__.__name__))
@@ -763,6 +786,7 @@ class BELGraph(nx.MultiDiGraph):
     @staticmethod
     def node_to_bel(n: BaseEntity) -> str:
         """Serialize a node as BEL."""
+        warnings.warn('use node.as_bel()', DeprecationWarning)
         return n.as_bel()
 
     @staticmethod
@@ -827,31 +851,28 @@ class BELGraph(nx.MultiDiGraph):
 
     def _describe_list(self) -> List[Tuple[str, float]]:
         """Return useful information about the graph as a list of tuples."""
-        number_nodes = self.number_of_nodes()
-        return [
-            ('Number of Nodes', number_nodes),
-            ('Number of Edges', self.number_of_edges()),
-            ('Number of Citations', self.number_of_citations()),
-            ('Number of Authors', self.number_of_authors()),
-            ('Network Density', '{:.2E}'.format(nx.density(self))),
-            ('Number of Components', nx.number_weakly_connected_components(self)),
-            ('Number of Warnings', self.number_of_warnings()),
-        ]
+        warnings.warn('use graph.summary.list()', DeprecationWarning)
+        return self.summary.list()
 
     def summary_dict(self) -> Mapping[str, float]:
         """Return a dictionary that summarizes the graph."""
-        return dict(self._describe_list())
+        warnings.warn('use graph.summary.dict()', DeprecationWarning)
+        return self.summary.dict()
 
     def summary_str(self) -> str:
         """Return a string that summarizes the graph."""
-        return '{}\n'.format(self) + '\n'.join(
-            '{}: {}'.format(label, value)
-            for label, value in self._describe_list()
-        )
+        warnings.warn('use graph.summary.str()', DeprecationWarning)
+        return self.summary.str()
 
     def summarize(self, file: Optional[TextIO] = None) -> None:
         """Print a summary of the graph."""
-        print(self.summary_str(), file=file)
+        warnings.warn('use graph.summary()', DeprecationWarning)
+        self.summary(file=file)
+
+    def ground(self, **kwargs) -> 'BELGraph':
+        """Ground this graph."""
+        from ..grounding import ground
+        return ground(self, **kwargs)
 
 
 def _clean_annotations(annotations_dict: AnnotationsHint) -> AnnotationsDict:
@@ -900,3 +921,225 @@ def _handle_citation(citation: Union[str, Tuple[str, str], CitationDict]) -> Cit
         raise ValueError('citation was None')
     else:
         raise TypeError('citation is the wrong type: {}'.format(citation))
+
+
+class Dispatch:
+    def __init__(self, graph: BELGraph):
+        self.graph = graph
+
+
+class CountDispatch(Dispatch):
+    """A dispatch for count functions that can be found at :data:`pybel.BELGraph.count`."""
+
+    def functions(self) -> Counter:
+        """Count the functions in a graph.
+
+        >>> from pybel.examples import sialic_acid_graph
+        >>> sialic_acid_graph.count.functions()
+        Counter({'Protein': 7, 'Complex': 1, 'Abundance': 1})
+        """
+        from .summary import count_functions
+        return count_functions(self.graph)
+
+    def namespaces(self) -> Counter:
+        """Return a counter of namespaces' occurrences in nodes in the graph."""
+        from .summary import count_namespaces
+        return count_namespaces(self.graph)
+
+    def pathologies(self) -> Counter:
+        """Return a counter of pathologies' occurrences in edges in the graph."""
+        from .summary import count_pathologies
+        return count_pathologies(self.graph)
+
+    def annotations(self) -> Counter:
+        """Return a counter of annotations' occurrences in edges in the graph."""
+        from .summary import count_annotations
+        return count_annotations(self.graph)
+
+    def variants(self) -> Counter:
+        """Return a counter of variants' occurrences in nodes in the graph."""
+        from .summary import count_variants
+        return count_variants(self.graph)
+
+    def relations(self) -> Counter:
+        """Return a counter of relations' occurrences in edges in the graph."""
+        from .summary import count_relations
+        return count_relations(self.graph)
+
+    def error_types(self) -> Counter:
+        """Return a counter of error types' occurrences in BEL script underlying the graph."""
+        from .summary import count_error_types
+        return count_error_types(self.graph)
+
+    def names_by_namespace(self, namespace: str) -> Counter:
+        from .summary import count_names_by_namespace
+        return count_names_by_namespace(self.graph, namespace=namespace)
+
+    def modifications(self) -> Counter:
+        """Return a counter of relation modifications' occurrences (activity, translocation, etc.) in the graph."""
+        from .summary.node_summary import count_modifications
+        return count_modifications(self.graph)
+
+    def authors(self) -> Counter:
+        """Return a counter of the number of edges to which each author contributed in the graph."""
+        return Counter(_iterate_authors(self.graph))
+
+    def citations(self) -> int:
+        """Return the number of citations."""
+        return len(set(_iterate_citations(self.graph)))
+
+
+def _iterate_citations(graph: BELGraph) -> Iterable[Tuple[str, str]]:
+    for _, _, data in graph.edges(data=True):
+        if CITATION in data:
+            yield data[CITATION][CITATION_DB], data[CITATION][CITATION_IDENTIFIER]
+
+
+def _iterate_authors(graph: BELGraph) -> Iterable[str]:
+    return chain.from_iterable(
+        data[CITATION][CITATION_AUTHORS]
+        for _, _, data in graph.edges(data=True)
+        if CITATION in data and CITATION_AUTHORS in data[CITATION]
+    )
+
+
+class SummaryDispatch(Dispatch):
+    """A dispatch for summary printing functions that can be found at :data:`pybel.BELGraph.summary`."""
+
+    def __call__(self, file: Optional[TextIO] = None, examples: bool = True) -> None:
+        self.statistics(file=file)
+        print('', file=file)
+        self.nodes(file=file, examples=examples)
+        print('', file=file)
+        self.namespaces(file=file, examples=examples)
+        print('', file=file)
+        self.edges(file=file, examples=examples)
+        print('', file=file)
+
+    def statistics(self, file: Optional[TextIO] = None):
+        """Print summary statistics on the graph."""
+        print(self.str(), file=file)
+
+    def nodes(self, file: Optional[TextIO] = None, examples: bool = True):
+        """Print a summary of the nodes' functions in the graph."""
+        from .summary.supersummary import functions
+        functions(self.graph, file=file, examples=examples)
+
+    def namespaces(self, file: Optional[TextIO] = None, examples: bool = True):
+        """Print a summary of the nodes' namespaces in the graph."""
+        from .summary.supersummary import namespaces
+        namespaces(self.graph, file=file, examples=examples)
+
+    def edges(self, file: Optional[TextIO] = None, examples: bool = True):
+        """Print a summary of the edges' types in the graph."""
+        from .summary.supersummary import edges
+        edges(self.graph, file=file, examples=examples)
+
+    def citations(self, n: Optional[int] = 15, file: Optional[TextIO] = None):
+        """Print a summary of the top citations' frequencies in the graph."""
+        from .summary.supersummary import citations
+        citations(self.graph, n=n, file=file)
+
+    def dict(self) -> Mapping[str, float]:
+        """Return a dictionary that summarizes the graph."""
+        return dict(self.list())
+
+    def str(self) -> str:
+        """Return a string that summarizes the graph."""
+        return tabulate(self.list())
+
+    def list(self) -> List[Tuple[str, float]]:
+        """Return a list of tuples that summarize the graph."""
+        number_nodes = self.graph.number_of_nodes()
+        return [
+            ('Name', self.graph.name),
+            ('Version', self.graph.version),
+            ('Number of Nodes', number_nodes),
+            ('Number of Namespaces', len(self.graph.count.namespaces())),
+            ('Number of Edges', self.graph.number_of_edges()),
+            ('Number of Annotations', len(self.graph.count.annotations())),
+            ('Number of Citations', self.graph.number_of_citations()),
+            ('Number of Authors', self.graph.number_of_authors()),
+            ('Network Density', '{:.2E}'.format(nx.density(self.graph))),
+            ('Number of Components', nx.number_weakly_connected_components(self.graph)),
+            ('Number of Warnings', self.graph.number_of_warnings()),
+        ]
+
+
+class PlotDispatch(Dispatch):
+    """A dispatch for count functions that can be found at :data:`pybel.BELGraph.plot`."""
+
+    def summary(self, save: Optional[str] = None, **kwargs):
+        """Plot a summary of the graph's nodes and edges using :mod:`matplotlib`."""
+        from pybel_tools.summary.visualization import plot_summary
+        fig, axes = plot_summary(self.graph, **kwargs)
+        if save:
+            fig.save(save)
+
+
+class ExpandDispatch(Dispatch):
+    """A dispatch for count functions that can be found at :data:`pybel.BELGraph.expand`."""
+
+    @property
+    def parent(self) -> BELGraph:
+        """Get the parent BEL graph."""
+        if not self.graph.parent:
+            raise RuntimeError('Can not use expand dispatch on graph without a parent')
+        return self.graph.parent
+
+    def neighborhood(self, node: BaseEntity) -> BELGraph:
+        """Expand around the neighborhood of a given node.
+
+        >>> from pybel.examples import braf_graph
+        >>> from pybel.dsl import Protein
+        >>> thpo = Protein(namespace='HGNC', name='THPO', identifier='11795')
+        >>> braf = Protein(namespace='HGNC', name='BRAF', identifier='1097')
+        >>> raf1 = Protein(namespace='HGNC', name='RAF1', identifier='9829')
+        >>> elk1 = Protein(namespace='HGNC', name='ELK1', identifier='3321')
+        >>> subgraph_1 = braf_graph.induce.paths([braf, elk1])
+        >>> assert thpo not in subgraph_1 and raf1 not in subgraph_1
+        >>> subgraph_2 = subgraph_1.expand.neighborhood(braf)
+        >>> assert thpo in subgraph_2 and raf1 not in subgraph_2
+        """
+        from .mutation import expand_node_neighborhood
+        cp = self.graph.copy()
+        expand_node_neighborhood(universe=self.parent, graph=cp, node=node)
+        return cp
+
+    def periphery(self, **kwargs):
+        """Expand around the periphery of the graph w.r.t. its parent graph."""
+        from pybel_tools.mutation.expansion import expand_periphery
+        cp = self.graph.copy()
+        expand_periphery(universe=self.parent, graph=cp, **kwargs)
+        return cp
+
+    def internal(self, **kwargs):
+        """Expand missing edges between nodes in the graph w.r.t. its parent graph."""
+        from pybel_tools.mutation.expansion import expand_internal
+        cp = self.graph.copy()
+        expand_internal(universe=self.parent, graph=cp, **kwargs)
+        return cp
+
+
+class InduceDispatch(Dispatch):
+    """A dispatch for induction functions that can be found at :data:`pybel.BELGraph.induce`."""
+
+    def paths(self, nodes: Iterable[BaseEntity]) -> Optional[BELGraph]:
+        """Induce a subgraph on shortest paths between the nodes."""
+        from .mutation import get_subgraph_by_all_shortest_paths
+        return get_subgraph_by_all_shortest_paths(self.graph, nodes)
+
+    def neighborhood(self, nodes: Iterable[BaseEntity]) -> Optional[BELGraph]:
+        """Induce a subgraph around the neighborhood."""
+        from .mutation import get_subgraph_by_neighborhood
+        return get_subgraph_by_neighborhood(self.graph, nodes)
+
+    def random(self, **kwargs) -> Optional[BELGraph]:
+        """Induce a random subgraph."""
+        from .mutation import get_random_subgraph
+        return get_random_subgraph(self.graph, **kwargs)
+
+    def annotation(self, prefix: str, identifier: str) -> Optional[BELGraph]:
+        """Induce a subgraph on edges with the given annotation."""
+        from .mutation import get_subgraph_by_annotation_value
+        return get_subgraph_by_annotation_value(self.graph, prefix, identifier)
diff --git a/src/pybel/struct/grouping/annotations.py b/src/pybel/struct/grouping/annotations.py
index 0eebf140..7bfbdf77 100644
--- a/src/pybel/struct/grouping/annotations.py
+++ b/src/pybel/struct/grouping/annotations.py
@@ -4,8 +4,9 @@
 
 import logging
 from collections import defaultdict
+from typing import Mapping, Optional
 
-from .utils import cleanup
+from ..graph import BELGraph
 from ...constants import ANNOTATIONS
 
 __all__ = [
@@ -15,8 +16,8 @@ __all__ = [
 logger = logging.getLogger(__name__)
 
 
-def _get_subgraphs_by_annotation_disregard_undefined(graph, annotation):
-    result = defaultdict(graph.__class__)
+def _get_subgraphs_by_annotation_disregard_undefined(graph: BELGraph, annotation: str) -> Mapping[str, BELGraph]:
+    result = defaultdict(graph.child)
 
     for source, target, key, data in graph.edges(keys=True, data=True):
         annotation_dict = data.get(ANNOTATIONS)
@@ -33,8 +34,12 @@ def _get_subgraphs_by_annotation_disregard_undefined(graph, annotation):
     return dict(result)
 
 
-def _get_subgraphs_by_annotation_keep_undefined(graph, annotation, sentinel):
-    result = defaultdict(graph.__class__)
+def _get_subgraphs_by_annotation_keep_undefined(
+    graph: BELGraph,
+    annotation: str,
+    sentinel: Optional[str],
+) -> Mapping[str, BELGraph]:
+    result = defaultdict(graph.child)
 
     for source, target, key, data in graph.edges(keys=True, data=True):
         annotation_dict = data.get(ANNOTATIONS)
@@ -48,19 +53,20 @@ def _get_subgraphs_by_annotation_keep_undefined(graph, annotation, sentinel):
     return dict(result)
 
 
-def get_subgraphs_by_annotation(graph, annotation, sentinel=None):
+def get_subgraphs_by_annotation(
+    graph: BELGraph,
+    annotation: str,
+    sentinel: Optional[str] = None,
+) -> Mapping[str, BELGraph]:
     """Stratify the given graph into sub-graphs based on the values for edges' annotations.
 
-    :param pybel.BELGraph graph: A BEL graph
-    :param str annotation: The annotation to group by
-    :param Optional[str] sentinel: The value to stick unannotated edges into. If none, does not keep undefined.
-    :rtype: dict[str,pybel.BELGraph]
+    :param graph: A BEL graph
+    :param annotation: The annotation to group by
+    :param sentinel: The value to stick unannotated edges into. If none, does not keep undefined.
     """
     if sentinel is not None:
         subgraphs = _get_subgraphs_by_annotation_keep_undefined(graph, annotation, sentinel)
     else:
         subgraphs = _get_subgraphs_by_annotation_disregard_undefined(graph, annotation)
 
-    cleanup(graph, subgraphs)
-
     return subgraphs
diff --git a/src/pybel/struct/grouping/provenance.py b/src/pybel/struct/grouping/provenance.py
index 20e4fe89..72366b43 100644
--- a/src/pybel/struct/grouping/provenance.py
+++ b/src/pybel/struct/grouping/provenance.py
@@ -3,8 +3,9 @@
 """Utility functions for grouping sub-graphs by citation."""
 
 from collections import defaultdict
+from typing import Mapping, Tuple
 
-from .utils import cleanup
+from ..graph import BELGraph
 from ...constants import CITATION, CITATION_DB, CITATION_IDENTIFIER
 
 __all__ = [
@@ -12,13 +13,13 @@ __all__ = [
 ]
 
 
-def get_subgraphs_by_citation(graph):
+def get_subgraphs_by_citation(graph: BELGraph) -> Mapping[Tuple[str, str], BELGraph]:
     """Stratify the graph based on citations.
 
-    :type graph: pybel.BELGraph
-    :rtype: dict[tuple[str,str],pybel.BELGraph]
+    :param graph: A BEL graph
+    :return: A mapping of each citation db/id to the BEL graph from it.
     """
-    rv = defaultdict(graph.__class__)
+    rv = defaultdict(graph.child)
 
     for u, v, key, data in graph.edges(keys=True, data=True):
         if CITATION not in data:
@@ -27,6 +28,4 @@ def get_subgraphs_by_citation(graph):
 
         rv[dk].add_edge(u, v, key=key, **data)
 
-    cleanup(graph, rv)
-
     return dict(rv)
diff --git a/src/pybel/struct/grouping/utils.py b/src/pybel/struct/grouping/utils.py
deleted file mode 100644
index 2ef66275..00000000
--- a/src/pybel/struct/grouping/utils.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# -*- coding: utf-8 -*-
-
-"""Utility functions for grouping sub-graphs."""
-
-from ..utils import update_metadata, update_node_helper
-
-__all__ = [
-    'cleanup',
-]
-
-
-def cleanup(graph, subgraphs):
-    """Clean up the metadata in the subgraphs.
-
-    :type graph: pybel.BELGraph
-    :type subgraphs: dict[Any,pybel.BELGraph]
-    """
-    for subgraph in subgraphs.values():
-        update_node_helper(graph, subgraph)
-        update_metadata(graph, subgraph)
diff --git a/src/pybel/struct/mutation/__init__.py b/src/pybel/struct/mutation/__init__.py
index 87675833..c95607e8 100644
--- a/src/pybel/struct/mutation/__init__.py
+++ b/src/pybel/struct/mutation/__init__.py
@@ -2,7 +2,6 @@
 
 """This module contains functions that mutate or make transformations on a network."""
 
-from pybel.struct.mutation.inference.transfer import *
 from . import collapse, deletion, expansion, induction, induction_expansion, inference, metadata, utils
 from .collapse import *
 from .deletion import *
@@ -11,6 +10,7 @@ from .induction import *
 from .induction_expansion import *
 from .inference import *
 from .inference import transfer
+from .inference.transfer import *
 from .metadata import *
 from .utils import *
 
diff --git a/src/pybel/struct/mutation/expansion/neighborhood.py b/src/pybel/struct/mutation/expansion/neighborhood.py
index 3ee24fcc..dce384d2 100644
--- a/src/pybel/struct/mutation/expansion/neighborhood.py
+++ b/src/pybel/struct/mutation/expansion/neighborhood.py
@@ -2,11 +2,14 @@
 
 """Functions for expanding the neighborhoods of nodes."""
 
+import itertools as itt
 from typing import Iterable
 
 from ...filters.node_predicates import is_pathology
+from ...filters.typing import EdgeIterator
+from ...graph import BELGraph
 from ...pipeline import uni_in_place_transformation
-from ...utils import update_metadata, update_node_helper
+from ...utils import update_metadata
 from ....dsl import BaseEntity
 
 __all__ = [
@@ -15,15 +18,16 @@ __all__ = [
     'expand_node_neighborhood',
     'expand_nodes_neighborhoods',
     'expand_all_node_neighborhoods',
+    'expand_internal',
 ]
 
 
 @uni_in_place_transformation
-def expand_node_predecessors(universe, graph, node: BaseEntity) -> None:
+def expand_node_predecessors(universe: BELGraph, graph: BELGraph, node: BaseEntity):
     """Expand around the predecessors of the given node in the result graph.
 
-    :param pybel.BELGraph universe: The graph containing the stuff to add
-    :param pybel.BELGraph graph: The graph to add stuff to
+    :param universe: The graph containing the stuff to add
+    :param graph: The graph to add stuff to
     :param node: A BEL node
     """
     skip_successors = set()
@@ -39,17 +43,15 @@ def expand_node_predecessors(universe, graph, node: BaseEntity) -> None:
         for source, successor, key, data in universe.out_edges(node, data=True, keys=True)
         if successor not in skip_successors
     )
-
-    update_node_helper(universe, graph)
     update_metadata(universe, graph)
 
 
 @uni_in_place_transformation
-def expand_node_successors(universe, graph, node: BaseEntity) -> None:
+def expand_node_successors(universe: BELGraph, graph: BELGraph, node: BaseEntity) -> None:
     """Expand around the successors of the given node in the result graph.
 
-    :param pybel.BELGraph universe: The graph containing the stuff to add
-    :param pybel.BELGraph graph: The graph to add stuff to
+    :param universe: The graph containing the stuff to add
+    :param graph: The graph to add stuff to
     :param node: A BEL node
     """
     skip_predecessors = set()
@@ -66,18 +68,17 @@ def expand_node_successors(universe, graph, node: BaseEntity) -> None:
         if predecessor not in skip_predecessors
     )
 
-    update_node_helper(universe, graph)
     update_metadata(universe, graph)
 
 
 @uni_in_place_transformation
-def expand_node_neighborhood(universe, graph, node: BaseEntity) -> None:
+def expand_node_neighborhood(universe: BELGraph, graph: BELGraph, node: BaseEntity) -> None:
     """Expand around the neighborhoods of the given node in the result graph.
 
     Note: expands complexes' members
 
-    :param pybel.BELGraph universe: The graph containing the stuff to add
-    :param pybel.BELGraph graph: The graph to add stuff to
+    :param universe: The graph containing the stuff to add
+    :param graph: The graph to add stuff to
     :param node: A BEL node
     """
     expand_node_predecessors(universe, graph, node)
@@ -85,11 +86,11 @@ def expand_node_neighborhood(universe, graph, node: BaseEntity) -> None:
 
 
 @uni_in_place_transformation
-def expand_nodes_neighborhoods(universe, graph, nodes: Iterable[BaseEntity]) -> None:
+def expand_nodes_neighborhoods(universe: BELGraph, graph: BELGraph, nodes: Iterable[BaseEntity]) -> None:
     """Expand around the neighborhoods of the given node in the result graph.
 
-    :param pybel.BELGraph universe: The graph containing the stuff to add
-    :param pybel.BELGraph graph: The graph to add stuff to
+    :param universe: The graph containing the stuff to add
+    :param graph: The graph to add stuff to
     :param nodes: Nodes from the query graph
     """
     for node in nodes:
@@ -97,7 +98,7 @@ def expand_nodes_neighborhoods(universe, graph, nodes: Iterable[BaseEntity]) ->
 
 
 @uni_in_place_transformation
-def expand_all_node_neighborhoods(universe, graph, filter_pathologies: bool = False) -> None:
+def expand_all_node_neighborhoods(universe: BELGraph, graph: BELGraph, filter_pathologies: bool = False) -> None:
     """Expand the neighborhoods of all nodes in the given graph.
 
     :param pybel.BELGraph universe: The graph containing the stuff to add
@@ -109,3 +110,26 @@ def expand_all_node_neighborhoods(universe, graph, filter_pathologies: bool = Fa
             continue
 
         expand_node_neighborhood(universe, graph, node)
+
+
+@uni_in_place_transformation
+def expand_internal(
+    universe: BELGraph,
+    graph: BELGraph,
+) -> None:
+    """Expand on edges between entities in the sub-graph that pass the given filters, in place.
+
+    :param universe: The full graph
+    :param graph: A sub-graph to find the upstream information
+    """
+    for u, v, key in iterate_internal(universe, graph):
+        graph.add_edge(u, v, key=key, **universe[u][v][key])
+
+
+def iterate_internal(universe: BELGraph, graph: BELGraph) -> EdgeIterator:
+    """Iterate over edges that are in the universe but not the target graph."""
+    for u, v in itt.product(graph, repeat=2):
+        if graph.has_edge(u, v) or not universe.has_edge(u, v):
+            continue
+        for key in universe[u][v]:
+            yield u, v, key
diff --git a/src/pybel/struct/mutation/induction/annotations.py b/src/pybel/struct/mutation/induction/annotations.py
index bbd907d1..a53d28ce 100644
--- a/src/pybel/struct/mutation/induction/annotations.py
+++ b/src/pybel/struct/mutation/induction/annotations.py
@@ -3,9 +3,11 @@
 """Functions for inducing graphs based on edge annotations."""
 
 import logging
+from typing import Iterable, Mapping, Optional, Union
 
 from .utils import get_subgraph_by_edge_filter
 from ...filters.edge_predicate_builders import build_annotation_dict_all_filter, build_annotation_dict_any_filter
+from ...graph import BELGraph
 from ...pipeline import transformation
 
 __all__ = [
@@ -17,15 +19,18 @@ logger = logging.getLogger(__name__)
 
 
 @transformation
-def get_subgraph_by_annotations(graph, annotations, or_=None):
+def get_subgraph_by_annotations(
+    graph: BELGraph,
+    annotations: Mapping[str, Iterable[str]],
+    or_: Optional[bool] = None,
+) -> BELGraph:
     """Induce a sub-graph given an annotations filter.
 
     :param graph: pybel.BELGraph graph: A BEL graph
     :param dict[str,iter[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.
+    :param 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
@@ -37,15 +42,13 @@ def get_subgraph_by_annotations(graph, annotations, or_=None):
 
 
 @transformation
-def get_subgraph_by_annotation_value(graph, annotation, values):
+def get_subgraph_by_annotation_value(graph: BELGraph, annotation: str, values: Union[str, Iterable[str]]) -> BELGraph:
     """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 graph: A BEL graph
+    :param annotation: The annotation to group by
     :param values: The value(s) for the annotation
-    :type values: str or iter[str]
     :return: A subgraph of the original BEL graph
-    :rtype: pybel.BELGraph
     """
     if isinstance(values, str):
         values = {values}
diff --git a/src/pybel/struct/mutation/induction/neighborhood.py b/src/pybel/struct/mutation/induction/neighborhood.py
index 1e574fa0..1674e513 100644
--- a/src/pybel/struct/mutation/induction/neighborhood.py
+++ b/src/pybel/struct/mutation/induction/neighborhood.py
@@ -3,10 +3,11 @@
 """Functions for selecting by the neighborhoods of nodes."""
 
 import itertools as itt
-from typing import Iterable
+from typing import Iterable, Optional
 
+from ...graph import BELGraph
 from ...pipeline import transformation
-from ...utils import update_metadata, update_node_helper
+from ...utils import update_metadata
 from ....dsl import BaseEntity
 
 __all__ = [
@@ -15,30 +16,25 @@ __all__ = [
 
 
 @transformation
-def get_subgraph_by_neighborhood(graph, nodes: Iterable[BaseEntity]):
+def get_subgraph_by_neighborhood(graph: BELGraph, nodes: Iterable[BaseEntity]) -> Optional[BELGraph]:
     """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 graph: A BEL graph
     :param 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.__class__()
+    rv = graph.child()
     rv.add_edges_from(
         itt.chain(
             graph.in_edges(nodes, keys=True, data=True),
             graph.out_edges(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
index 39b25321..08f48b98 100644
--- a/src/pybel/struct/mutation/induction/paths.py
+++ b/src/pybel/struct/mutation/induction/paths.py
@@ -5,11 +5,12 @@
 import itertools as itt
 import logging
 import random
-from typing import Iterable, List, Optional, Tuple
+from typing import Iterable, List, Optional, Set, Tuple
 
 import networkx as nx
 
 from .utils import get_subgraph_by_induction
+from ...graph import BELGraph
 from ...pipeline import transformation
 from ....constants import FUNCTION, PATHOLOGY
 from ....dsl import BaseEntity
@@ -23,7 +24,7 @@ __all__ = [
 logger = logging.getLogger(__name__)
 
 
-def _remove_pathologies_oop(graph):
+def _remove_pathologies_oop(graph: BELGraph):
     """Remove pathology nodes from the graph."""
     rv = graph.copy()
     victims = [
@@ -36,14 +37,11 @@ def _remove_pathologies_oop(graph):
 
 
 def _iterate_nodes_in_shortest_paths(
-    graph,
+    graph: BELGraph,
     nodes: Iterable[BaseEntity],
     weight: Optional[str] = None,
 ) -> Iterable[BaseEntity]:
-    """Iterate over nodes in the shortest paths between all pairs of nodes in the given list.
-
-    :type graph: pybel.BELGraph
-    """
+    """Iterate over nodes in the shortest paths between all pairs of nodes in the given list."""
     for source, target in itt.product(nodes, repeat=2):
         try:
             paths = nx.all_shortest_paths(graph, source, target, weight=weight)
@@ -55,16 +53,16 @@ def _iterate_nodes_in_shortest_paths(
 
 
 def get_nodes_in_all_shortest_paths(
-    graph,
+    graph: BELGraph,
     nodes: Iterable[BaseEntity],
     weight: Optional[str] = None,
     remove_pathologies: bool = False,
-):
+) -> Set[BaseEntity]:
     """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 graph: A BEL graph
     :param nodes: The list of nodes to use to use to find all shortest paths
     :param weight: Edge data key corresponding to the edge weight. If none, uses unweighted search.
     :param remove_pathologies: Should pathology nodes be removed first?
@@ -84,7 +82,7 @@ def get_subgraph_by_all_shortest_paths(
     nodes: Iterable[BaseEntity],
     weight: Optional[str] = None,
     remove_pathologies: bool = False,
-):
+) -> Optional[BELGraph]:
     """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
@@ -118,10 +116,10 @@ def get_subgraph_by_all_shortest_paths(
     return get_subgraph_by_induction(graph, induced_nodes)
 
 
-def get_random_path(graph) -> List[BaseEntity]:
+def get_random_path(graph: BELGraph) -> List[BaseEntity]:
     """Get a random path from the graph as a list of nodes.
 
-    :param pybel.BELGraph graph: A BEL graph
+    :param graph: A BEL graph
     """
     wg = graph.to_undirected()
 
diff --git a/src/pybel/struct/mutation/induction/random_subgraph.py b/src/pybel/struct/mutation/induction/random_subgraph.py
index ce6940da..b827f39c 100644
--- a/src/pybel/struct/mutation/induction/random_subgraph.py
+++ b/src/pybel/struct/mutation/induction/random_subgraph.py
@@ -9,8 +9,9 @@ from operator import itemgetter
 from typing import Any, Iterable, Mapping, Optional, Set, Tuple
 
 from ..utils import remove_isolated_nodes
+from ...graph import BELGraph
 from ...pipeline import transformation
-from ...utils import update_metadata, update_node_helper
+from ...utils import update_metadata
 from ....dsl import BaseEntity
 
 __all__ = [
@@ -22,10 +23,10 @@ __all__ = [
 logger = logging.getLogger(__name__)
 
 
-def _random_edge_iterator(graph, n_edges: int) -> Iterable[Tuple[BaseEntity, BaseEntity, int, Mapping]]:
+def _random_edge_iterator(graph: BELGraph, n_edges: int) -> Iterable[Tuple[BaseEntity, BaseEntity, int, Mapping]]:
     """Get a random set of edges from the graph and randomly samples a key from each.
 
-    :type graph: pybel.BELGraph
+    :param graph: A BEL graph
     :param n_edges: Number of edges to randomly select from the given graph
     """
     edges = list(graph.edges())
@@ -37,19 +38,15 @@ def _random_edge_iterator(graph, n_edges: int) -> Iterable[Tuple[BaseEntity, Bas
 
 
 @transformation
-def get_graph_with_random_edges(graph, n_edges: int):
+def get_graph_with_random_edges(graph: BELGraph, n_edges: int) -> BELGraph:
     """Build a new graph from a seeding of edges.
 
-    :type graph: pybel.BELGraph
+    :param graph: A BEL graph
     :param n_edges: Number of edges to randomly select from the given graph
-    :rtype: pybel.BELGraph
     """
-    result = graph.__class__()
-    result.add_edges_from(_random_edge_iterator(graph, n_edges))
-
-    update_metadata(graph, result)
-    update_node_helper(graph, result)
-    return result
+    rv = graph.child()
+    rv.add_edges_from(_random_edge_iterator(graph, n_edges))
+    return rv
 
 
 #: How many edges should be sampled from a graph that's still reasonable to display
@@ -170,17 +167,22 @@ def _helper(
 
 
 @transformation
-def get_random_subgraph(graph, number_edges=None, number_seed_edges=None, seed=None, invert_degrees=None):
+def get_random_subgraph(
+    graph: BELGraph,
+    number_edges: Optional[int] = None,
+    number_seed_edges: Optional[int] = None,
+    seed: Optional[int] = None,
+    invert_degrees: Optional[bool] = None,
+) -> BELGraph:
     """Generate a random subgraph based on weighted random walks from random seed edges.
 
     :type graph: pybel.BELGraph graph
-    :param Optional[int] number_edges: Maximum number of edges. Defaults to
+    :param number_edges: Maximum number of edges. Defaults to
      :data:`pybel_tools.constants.SAMPLE_RANDOM_EDGE_COUNT` (250).
-    :param Optional[int] number_seed_edges: Number of nodes to start with (which likely results in different components
+    :param number_seed_edges: Number of nodes to start with (which likely results in different components
      in large graphs). Defaults to :data:`SAMPLE_RANDOM_EDGE_SEED_COUNT` (5).
-    :param Optional[int] seed: A seed for the random state
-    :param Optional[bool] invert_degrees: Should the degrees be inverted? Defaults to true.
-    :rtype: pybel.BELGraph
+    :param seed: A seed for the random state
+    :param invert_degrees: Should the degrees be inverted? Defaults to true.
     """
     if number_edges is None:
         number_edges = SAMPLE_RANDOM_EDGE_COUNT
@@ -216,8 +218,4 @@ def get_random_subgraph(graph, number_edges=None, number_seed_edges=None, seed=N
     logger.debug('removing isolated nodes')
     remove_isolated_nodes(result)
 
-    # update metadata
-    update_node_helper(graph, result)
-    update_metadata(graph, result)
-
     return result
diff --git a/src/pybel/struct/mutation/induction/utils.py b/src/pybel/struct/mutation/induction/utils.py
index 7e50d7a1..2a7ca427 100644
--- a/src/pybel/struct/mutation/induction/utils.py
+++ b/src/pybel/struct/mutation/induction/utils.py
@@ -10,6 +10,7 @@ from ..utils import expand_by_edge_filter
 from ...filters.edge_predicates import is_causal_relation
 from ...filters.node_filters import filter_nodes
 from ...filters.typing import EdgePredicates, NodePredicates
+from ...graph import BELGraph
 from ...operations import subgraph
 from ...pipeline import transformation
 from ....dsl import BaseEntity
@@ -24,26 +25,24 @@ __all__ = [
 
 
 @transformation
-def get_subgraph_by_edge_filter(graph, edge_predicates: Optional[EdgePredicates] = None):
+def get_subgraph_by_edge_filter(graph: BELGraph, edge_predicates: Optional[EdgePredicates] = None) -> BELGraph:
     """Induce a sub-graph on all edges that pass the given filters.
 
-    :param pybel.BELGraph graph: A BEL graph
+    :param graph: A BEL graph
     :param edge_predicates: An edge predicate or list of edge predicates
     :return: A BEL sub-graph induced over the edges passing the given filters
-    :rtype: pybel.BELGraph
     """
-    rv = graph.__class__()
+    rv = graph.child()
     expand_by_edge_filter(graph, rv, edge_predicates=edge_predicates)
     return rv
 
 
 @transformation
-def get_subgraph_by_induction(graph, nodes: Iterable[BaseEntity]):
+def get_subgraph_by_induction(graph: BELGraph, nodes: Iterable[BaseEntity]) -> Optional[BELGraph]:
     """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 graph: A BEL graph
     :param nodes: A list of BEL nodes in the graph
-    :rtype: Optional[pybel.BELGraph]
     """
     nodes = tuple(nodes)
 
@@ -54,32 +53,29 @@ def get_subgraph_by_induction(graph, nodes: Iterable[BaseEntity]):
 
 
 @transformation
-def get_subgraph_by_node_filter(graph, node_predicates: NodePredicates):
+def get_subgraph_by_node_filter(graph: BELGraph, node_predicates: NodePredicates) -> BELGraph:
     """Induce a sub-graph on the nodes that pass the given predicate(s).
 
-    :param pybel.BELGraph graph: A BEL graph
+    :param graph: A BEL graph
     :param node_predicates: A node predicate or list of node predicates
-    :rtype: pybel.BELGraph
     """
     return get_subgraph_by_induction(graph, filter_nodes(graph, node_predicates))
 
 
 @transformation
-def get_largest_component(graph):
+def get_largest_component(graph: BELGraph) -> BELGraph:
     """Get the giant component of a graph.
 
-    :param pybel.BELGraph graph: A BEL graph
-    :rtype: pybel.BELGraph
+    :param graph: A BEL graph
     """
     biggest_component_nodes = max(nx.weakly_connected_components(graph), key=len)
     return subgraph(graph, biggest_component_nodes)
 
 
 @transformation
-def get_causal_subgraph(graph):
+def get_causal_subgraph(graph: BELGraph) -> BELGraph:
     """Build a new sub-graph induced over the causal edges.
 
-    :param pybel.BELGraph graph: A BEL graph
-    :rtype: pybel.BELGraph
+    :param graph: A BEL graph
     """
     return get_subgraph_by_edge_filter(graph, is_causal_relation)
diff --git a/src/pybel/struct/mutation/utils.py b/src/pybel/struct/mutation/utils.py
index e401d120..15a0eb21 100644
--- a/src/pybel/struct/mutation/utils.py
+++ b/src/pybel/struct/mutation/utils.py
@@ -7,7 +7,7 @@ import networkx as nx
 from ..filters.edge_filters import filter_edges
 from ..filters.typing import EdgePredicates
 from ..pipeline import in_place_transformation, transformation, uni_in_place_transformation
-from ..utils import update_metadata, update_node_helper
+from ..utils import update_metadata
 
 __all__ = [
     'remove_isolated_nodes',
@@ -54,6 +54,5 @@ def expand_by_edge_filter(source, target, edge_predicates: EdgePredicates):
         for u, v, k in filter_edges(source, edge_predicates=edge_predicates)
     )
 
-    update_node_helper(source, target)
     update_metadata(source, target)
     # TODO smarter ways of ensuring metadata
diff --git a/src/pybel/struct/operations.py b/src/pybel/struct/operations.py
index 1cddb8c4..967e1ad2 100644
--- a/src/pybel/struct/operations.py
+++ b/src/pybel/struct/operations.py
@@ -7,7 +7,7 @@ from typing import Iterable
 import networkx as nx
 from tqdm import tqdm
 
-from .utils import update_metadata, update_node_helper
+from .utils import update_metadata
 from ..dsl import BaseEntity
 
 __all__ = [
@@ -28,18 +28,18 @@ def subgraph(graph, nodes: Iterable[BaseEntity]):
     sg = graph.subgraph(nodes)
 
     # see implementation for .copy()
-    result = graph.__class__()
-    result.graph.update(sg.graph)
+    rv = graph.child()
+    rv.graph.update(sg.graph)
 
     for node, data in sg.nodes(data=True):
-        result.add_node(node, **data)
+        rv.add_node(node, **data)
 
-    result.add_edges_from(
+    rv.add_edges_from(
         (u, v, key, datadict.copy())
         for u, v, key, datadict in sg.edges(keys=True, data=True)
     )
 
-    return result
+    return rv
 
 
 def left_full_join(g, h) -> None:
@@ -67,7 +67,6 @@ def left_full_join(g, h) -> None:
     )
 
     update_metadata(h, g)
-    update_node_helper(h, g)
     g.warnings.extend(h.warnings)
 
 
diff --git a/src/pybel/struct/summary/node_summary.py b/src/pybel/struct/summary/node_summary.py
index 29b8b838..57988fdc 100644
--- a/src/pybel/struct/summary/node_summary.py
+++ b/src/pybel/struct/summary/node_summary.py
@@ -7,14 +7,13 @@ import typing
 from collections import Counter, defaultdict
 from typing import Any, Iterable, List, Mapping, Optional, Set, Tuple
 
-from ..filters.node_predicates import has_variant
+from ..filters import get_nodes, has_activity, has_variant, is_degraded, is_translocated
 from ..graph import BELGraph
 from ...constants import (
     ACTIVITY, CONCEPT, EFFECT, FROM_LOC, FUSION, KIND, LOCATION, MEMBERS, MODIFIER, NAME, NAMESPACE, OBJECT, PARTNER_3P,
     PARTNER_5P, SUBJECT, TO_LOC, TRANSLOCATION, VARIANTS,
 )
-from ...dsl import BaseConcept, BaseEntity, CentralDogma, FusionBase, ListAbundance, Pathology, Reaction
-from ...dsl.node_classes import EntityVariant
+from ...dsl import BaseConcept, BaseEntity, CentralDogma, EntityVariant, FusionBase, ListAbundance, Pathology, Reaction
 from ...language import Entity
 
 __all__ = [
@@ -352,3 +351,36 @@ def node_is_grounded(node: BaseEntity) -> bool:
         entity.identifier is not None and entity.name is not None
         for entity in iterate_node_entities(node)
     )
+
+
+def get_degradations(graph: BELGraph) -> Set[BaseEntity]:
+    """Get all nodes that are degraded."""
+    return get_nodes(graph, is_degraded)
+
+
+def get_activities(graph: BELGraph) -> Set[BaseEntity]:
+    """Get all nodes that have molecular activities."""
+    return get_nodes(graph, has_activity)
+
+
+def get_translocated(graph: BELGraph) -> Set[BaseEntity]:
+    """Get all nodes that are translocated."""
+    return get_nodes(graph, is_translocated)
+
+
+def count_modifications(graph: BELGraph) -> Counter:
+    """Get a modifications count dictionary."""
+    return Counter(remove_falsy_values({
+        'Translocations': len(get_translocated(graph)),
+        'Degradations': len(get_degradations(graph)),
+        'Molecular Activities': len(get_activities(graph)),
+    }))
+
+
+def remove_falsy_values(counter: Mapping[Any, int]) -> Mapping[Any, int]:
+    """Remove all values that are zero."""
+    return {
+        label: count
+        for label, count in counter.items()
+        if count
+    }
diff --git a/src/pybel/struct/summary/provenance.py b/src/pybel/struct/summary/provenance.py
index ccabfc4a..f0d07ca7 100644
--- a/src/pybel/struct/summary/provenance.py
+++ b/src/pybel/struct/summary/provenance.py
@@ -5,6 +5,7 @@
 from typing import Iterable, Set
 
 from ..filters.edge_predicates import has_pubmed
+from ..graph import BELGraph
 from ...constants import CITATION, CITATION_IDENTIFIER
 
 __all__ = [
@@ -13,10 +14,10 @@ __all__ = [
 ]
 
 
-def iterate_pubmed_identifiers(graph) -> Iterable[str]:
+def iterate_pubmed_identifiers(graph: BELGraph) -> Iterable[str]:
     """Iterate over all PubMed identifiers in a graph.
 
-    :param pybel.BELGraph graph: A BEL graph
+    :param graph: A BEL graph
     :return: An iterator over the PubMed identifiers in the graph
     """
     return (
@@ -26,10 +27,10 @@ def iterate_pubmed_identifiers(graph) -> Iterable[str]:
     )
 
 
-def get_pubmed_identifiers(graph) -> Set[str]:
+def get_pubmed_identifiers(graph: BELGraph) -> Set[str]:
     """Get the set of all PubMed identifiers cited in the construction of a graph.
 
-    :param pybel.BELGraph graph: A BEL graph
+    :param graph: A BEL graph
     :return: A set of all PubMed identifiers cited in the construction of this graph
     """
     return set(iterate_pubmed_identifiers(graph))
diff --git a/src/pybel/struct/summary/supersummary.py b/src/pybel/struct/summary/supersummary.py
new file mode 100644
index 00000000..565ebcd9
--- /dev/null
+++ b/src/pybel/struct/summary/supersummary.py
@@ -0,0 +1,125 @@
+# -*- coding: utf-8 -*-
+
+"""Utilities for BEL graphs."""
+
+import logging
+import random
+from collections import Counter
+from typing import Optional, TextIO
+
+import pandas as pd
+from humanize import intword
+from tabulate import tabulate
+
+from ..graph import BELGraph
+from ...constants import CITATION, CITATION_DB, CITATION_IDENTIFIER, RELATION, TWO_WAY_RELATIONS
+from ...dsl import BaseConcept
+from ...utils import multidict
+
+logger = logging.getLogger(__name__)
+
+
+def function_table_df(graph: BELGraph, examples: bool = True) -> pd.DataFrame:
+    """Create a dataframe describing the functions in the graph."""
+    function_mapping = multidict((node.function, node) for node in graph)
+    function_c = Counter({function: len(nodes) for function, nodes in function_mapping.items()})
+    if not examples:
+        return pd.DataFrame(function_c.most_common(), columns=['Type', 'Count'])
+    return pd.DataFrame(
+        [
+            (function, count, random.choice(function_mapping[function]))  # noqa:S311
+            for function, count in function_c.most_common()
+        ],
+        columns=['Type', 'Count', 'Example'],
+    )
+
+
+def functions(graph, file: Optional[TextIO] = None, examples: bool = True):
+    """Print a summary of the functions in the graph."""
+    df = function_table_df(graph, examples=examples)
+    headers = list(df.columns)
+    headers[0] += ' ({})'.format(len(df.index))
+    print(tabulate(df.values, headers=headers), file=file)
+
+
+def namespaces_table_df(graph: BELGraph, examples: bool = True) -> pd.DataFrame:
+    """Create a dataframe describing the namespaces in the graph."""
+    namespace_mapping = multidict((node.namespace, node) for node in graph if isinstance(node, BaseConcept))
+    namespace_c = Counter({namespace: len(nodes) for namespace, nodes in namespace_mapping.items()})
+    if not examples:
+        return pd.DataFrame(namespace_c.most_common(), columns=['Namespace', 'Count'])
+    return pd.DataFrame(
+        [
+            (namespace, count, random.choice(namespace_mapping[namespace]))  # noqa:S311
+            for namespace, count in namespace_c.most_common()
+        ],
+        columns=['Namespace', 'Count', 'Example'],
+    )
+
+
+def namespaces(graph: BELGraph, file: Optional[TextIO] = None, examples: bool = True) -> None:
+    """Print a summary of the namespaces in the graph."""
+    df = namespaces_table_df(graph, examples=examples)
+    headers = list(df.columns)
+    headers[0] += ' ({})'.format(len(df.index))
+    print(tabulate(df.values, headers=headers), file=file)
+
+
+def edge_table_df(graph: BELGraph, *, examples: bool = True, minimum: Optional[int] = None) -> pd.DataFrame:
+    """Create a dataframe describing the edges in the graph."""
+    edge_mapping = multidict(
+        (f'{u.function} {d[RELATION]} {v.function}', graph.edge_to_bel(u, v, d, use_identifiers=True))
+        for u, v, d in graph.edges(data=True)
+        if d[RELATION] not in TWO_WAY_RELATIONS or u.function > v.function
+    )
+    edge_c = Counter({top_level_edge: len(edges) for top_level_edge, edges in edge_mapping.items()})
+    if examples:
+        rows = [
+            (top_level_edge, count, random.choice(edge_mapping[top_level_edge]))  # noqa:S311
+            for top_level_edge, count in edge_c.most_common()
+            if not minimum or count >= minimum
+        ]
+        columns = ['Edge Type', 'Count', 'Example']
+    else:
+        rows = edge_c.most_common()
+        if minimum:
+            rows = [(k, count) for k, count in rows if count >= minimum]
+        columns = ['Edge Type', 'Count']
+    return pd.DataFrame(rows, columns=columns)
+
+
+def edges(
+    graph: BELGraph,
+    *,
+    examples: bool = True,
+    minimum: Optional[int] = None,
+    file: Optional[TextIO] = None,
+) -> None:
+    """Print a summary of the edges in the graph."""
+    df = edge_table_df(graph, examples=examples, minimum=minimum)
+    headers = list(df.columns)
+    headers[0] += ' ({})'.format(intword(len(df.index)))
+    print(tabulate(df.values, headers=headers), file=file)
+
+
+def citations(graph: BELGraph, n: Optional[int] = 15, file: Optional[TextIO] = None) -> None:
+    """Print a summary of the citations in the graph."""
+    edge_mapping = multidict(
+        ((data[CITATION][CITATION_DB], data[CITATION][CITATION_IDENTIFIER]), graph.edge_to_bel(u, v, data))
+        for u, v, data in graph.edges(data=True)
+        if CITATION in data
+    )
+    edge_c = Counter({top_level_edge: len(edges) for top_level_edge, edges in edge_mapping.items()})
+    df = pd.DataFrame(
+        [
+            (':'.join(top_level_edge), count, random.choice(edge_mapping[top_level_edge]))  # noqa:S311
+            for top_level_edge, count in edge_c.most_common(n=n)
+        ],
+        columns=['Citation', 'Count', 'Example'],
+    )
+
+    if n is None or len(edge_mapping) < n:
+        print('{} Citation Count: {}'.format(graph, len(edge_mapping)))
+    else:
+        print('{} Citation Count: {} (Showing top {})'.format(graph, len(edge_mapping), n))
+    print(tabulate(df.values, headers=df.columns), file=file)
diff --git a/src/pybel/struct/utils.py b/src/pybel/struct/utils.py
index d41de91c..b6cf793b 100644
--- a/src/pybel/struct/utils.py
+++ b/src/pybel/struct/utils.py
@@ -2,8 +2,6 @@
 
 """Utilities for :mod:`pybel.struct`."""
 
-import networkx as nx
-
 from ..constants import (
     GRAPH_ANNOTATION_LIST, GRAPH_ANNOTATION_PATTERN, GRAPH_ANNOTATION_URL, GRAPH_NAMESPACE_PATTERN,
     GRAPH_NAMESPACE_URL,
@@ -11,7 +9,6 @@ from ..constants import (
 
 __all__ = [
     'update_metadata',
-    'update_node_helper',
 ]
 
 
@@ -31,14 +28,3 @@ def update_metadata(source, target) -> None:
             target.annotation_list[keyword] = values
         else:
             target.annotation_list[keyword].update(values)
-
-
-def update_node_helper(source: nx.Graph, target: nx.Graph) -> None:
-    """Update the nodes' data dictionaries in the target graph from the source graph.
-
-    :param source: The universe of all knowledge
-    :param target: The target BEL graph
-    """
-    for node in target:
-        if node in source:
-            target.nodes[node].update(source.nodes[node])
diff --git a/src/pybel/utils.py b/src/pybel/utils.py
index e69e63f5..2450b7d9 100644
--- a/src/pybel/utils.py
+++ b/src/pybel/utils.py
@@ -6,10 +6,11 @@ import hashlib
 import json
 import logging
 import pickle
+import typing
 from collections import defaultdict
 from collections.abc import Iterable, MutableMapping
 from datetime import datetime
-from typing import Any, Mapping, Optional, Tuple
+from typing import Any, List, Mapping, Optional, Tuple, TypeVar
 
 from .constants import (
     ACTIVITY, CITATION, CITATION_DB, CITATION_DB_NAME, CITATION_IDENTIFIER, DEGRADATION, EFFECT, EVIDENCE, FROM_LOC,
@@ -317,3 +318,15 @@ class CitationDict(dict):
 
     def __hash__(self):
         return hash((self[CITATION_DB], self[CITATION_IDENTIFIER]))
+
+
+X = TypeVar('X')
+Y = TypeVar('Y')
+
+
+def multidict(pairs: typing.Iterable[Tuple[X, Y]]) -> Mapping[X, List[Y]]:
+    """Accumulate a multidict from a list of pairs."""
+    rv = defaultdict(list)
+    for key, value in pairs:
+        rv[key].append(value)
+    return dict(rv)
diff --git a/tox.ini b/tox.ini
index 75a92c03..2985a5a4 100644
--- a/tox.ini
+++ b/tox.ini
@@ -41,7 +41,9 @@ whitelist_externals =
 commands =
     pytest --doctest-modules \
         src/pybel/struct/summary \
-        src/pybel/struct/filters src/pybel/struct/mutation
+        src/pybel/struct/filters \
+        src/pybel/struct/mutation \
+        src/pybel/struct/graph.py
 deps =
     pytest
 


Test patch:
diff --git a/tests/test_struct/test_filters/test_struct_filters.py b/tests/test_struct/test_filters/test_struct_filters.py
index bf72707d..8132bb6a 100644
--- a/tests/test_struct/test_filters/test_struct_filters.py
+++ b/tests/test_struct/test_filters/test_struct_filters.py
@@ -14,7 +14,7 @@ from pybel.struct.filters.edge_predicate_builders import (
     _annotation_dict_all_filter, _annotation_dict_any_filter, build_annotation_dict_all_filter,
     build_annotation_dict_any_filter,
 )
-from pybel.struct.filters.edge_predicates import keep_edge_permissive
+from pybel.struct.filters.edge_predicates import true_edge_predicate
 from pybel.struct.filters.node_predicates import true_node_predicate
 from pybel.struct.filters.typing import EdgeIterator
 from pybel.testing.utils import n
@@ -75,11 +75,11 @@ class TestNodeFilters(unittest.TestCase):
         self.assertEqual({(1, 2)}, edges)
 
     def test_keep_edge_permissive(self):
-        edges = make_edge_iterator_set(filter_edges(self.graph, keep_edge_permissive))
+        edges = make_edge_iterator_set(filter_edges(self.graph, true_edge_predicate))
         self.assertEqual({(1, 2)}, edges)
 
     def test_keep_edge_unpermissive(self):
-        keep_edge_restrictive = invert_edge_predicate(keep_edge_permissive)
+        keep_edge_restrictive = invert_edge_predicate(true_edge_predicate)
         edges = make_edge_iterator_set(filter_edges(self.graph, keep_edge_restrictive))
         self.assertEqual(set(), edges)
 
@@ -88,7 +88,7 @@ class TestNodeFilters(unittest.TestCase):
         self.assertEqual(({(1, 2)}), edges)
 
     def test_concatenate_single_edge_filter(self):
-        edges = make_edge_iterator_set(filter_edges(self.graph, [keep_edge_permissive]))
+        edges = make_edge_iterator_set(filter_edges(self.graph, [true_edge_predicate]))
         self.assertEqual({(1, 2)}, edges)
 
     def test_concatenate_multiple_edge_filter(self):
diff --git a/tests/test_struct/test_struct_graph.py b/tests/test_struct/test_struct_graph.py
index b2d79686..4ec95cd7 100644
--- a/tests/test_struct/test_struct_graph.py
+++ b/tests/test_struct/test_struct_graph.py
@@ -3,6 +3,7 @@
 """Tests for data structures in PyBEL."""
 
 import os
+import random
 import tempfile
 import unittest
 from io import StringIO
@@ -80,26 +81,56 @@ class TestStruct(unittest.TestCase):
 
     def test_add_simple(self):
         """Test that a simple node can be added, but not duplicated."""
-        graph = BELGraph(name='Test', version='0.0.0')
-
-        namespace, name = n(), n()
-
-        graph.add_node_from_data(protein(namespace=namespace, name=name))
+        graph = BELGraph()
+        node = protein(namespace='TEST', name='YFG')
+        graph.add_node_from_data(node)
         self.assertEqual(1, graph.number_of_nodes())
 
-        graph.add_node_from_data(protein(namespace=namespace, name=name))
-        self.assertEqual(1, graph.number_of_nodes())
+        graph.add_node_from_data(node)
+        self.assertEqual(1, graph.number_of_nodes(), msg='should not add same node again')
 
+    def test_summarize(self):
+        """Test summarizing a graph."""
+        self.maxDiff = None
         sio = StringIO()
-        graph.summarize(file=sio)
-        test_str = """Test v0.0.0
-Number of Nodes: 1
-Number of Edges: 0
-Number of Citations: 0
-Number of Authors: 0
-Network Density: 0.00E+00
-Number of Components: 1
-Number of Warnings: 0"""
+
+        random.seed(5)
+        pybel.examples.sialic_acid_graph.version = '1.0.0'
+        pybel.examples.sialic_acid_graph.summary(file=sio, examples=False)
+        test_str = """---------------------  -----------------
+Name                   Sialic Acid Graph
+Version                1.0.0
+Number of Nodes        9
+Number of Namespaces   3
+Number of Edges        11
+Number of Annotations  2
+Number of Citations    1
+Number of Authors      0
+Network Density        1.53E-01
+Number of Components   1
+Number of Warnings     0
+---------------------  -----------------
+
+Type (3)      Count
+----------  -------
+Protein           7
+Complex           1
+Abundance         1
+
+Namespace (2)      Count
+---------------  -------
+hgnc                   7
+chebi                  1
+
+Edge Type (7)                        Count
+---------------------------------  -------
+Protein increases Protein                3
+Protein directlyIncreases Protein        2
+Protein directlyDecreases Protein        2
+Complex increases Protein                1
+Abundance partOf Complex                 1
+Protein partOf Complex                   1
+Protein hasVariant Protein               1"""
         self.assertEqual(test_str.strip(), sio.getvalue().strip())
 
     def test_citation_type_error(self):
diff --git a/tests/test_struct/test_struct_operations.py b/tests/test_struct/test_struct_operations.py
index 9c0788ee..f042abdf 100644
--- a/tests/test_struct/test_struct_operations.py
+++ b/tests/test_struct/test_struct_operations.py
@@ -23,15 +23,10 @@ class TestLeftFullJoin(unittest.TestCase):
         g = BELGraph()
         g.add_increases(p1, p2, citation='PMID1', evidence='Evidence 1')
 
-        self.tag = 'EXTRANEOUS'
-        self.tag_value = 'MOST DEFINITELY'
-
         h = BELGraph()
         h.add_increases(p1, p2, citation='PMID1', evidence='Evidence 1')
         h.add_increases(p1, p2, citation='PMID2', evidence='Evidence 2')
         h.add_increases(p1, p3, citation='PMID1', evidence='Evidence 3')
-        h.nodes[p1][self.tag] = self.tag_value
-        h.nodes[p3][self.tag] = self.tag_value
 
         self.g = g
         self.h = h
@@ -54,12 +49,6 @@ class TestLeftFullJoin(unittest.TestCase):
 
         :param j: The resulting graph from G += H
         """
-        self.assertIn(self.tag, j.nodes[p1])
-        self.assertNotIn(self.tag, j.nodes[p2])
-        self.assertIn(self.tag, j.nodes[p3])
-        self.assertEqual(self.tag_value, j.nodes[p1][self.tag])
-        self.assertEqual(self.tag_value, j.nodes[p3][self.tag])
-
         self.assertEqual(3, j.number_of_nodes())
         self.assertEqual(3, j.number_of_edges(), msg="G edges:\n{}".format('\n'.join(map(str, j.edges(data=True)))))
 
@@ -262,7 +251,3 @@ class TestInnerJoin(unittest.TestCase):
     def test_intersection_trivial(self):
         res = node_intersection([self.g])
         self.assertEqual(self.g, res)
-
-
-if __name__ == '__main__':
-    unittest.main()