Repository: vivarium-collective/vivarium-core (language: python)

Issue:
Code Health
**Note**: This depends on #22.

## Code Style

* Get all code to pass lint checks
  * [x] `core/composition.py`
  * [x] `core/control.py`
  * [ ] `core/emitter.py`
  * [x] `core/experiment.py`
  * [ ]  `core/store.py`
  * [x] `core/process.py`
  * [ ] `core/registry.py`


* Complete docstrings
  * [ ] `core/composition.py`
  * [ ] `core/control.py`
  * [ ] `core/emitter.py`
  * [ ] `core/experiment.py`
  * [ ]  `core/store.py`
  * [x] `core/process.py`
  * [ ] `core/registry.py`

### How to Use Pylint

To run pylint, just execute `pylint vivarium`.

The files left to fix are listed in `.pylintrc` after `ignore=`. We use an exclusion list to ensure that once we fix a file, any code added to it also passes the linter. This prevents regressions. It also ensures that any new files added have proper style.

I use pylint's default rules. You can adjust this in `.pylintrc` after the `disable=` line. You can find the list of pylint checks [here](https://pylint.pycqa.org/en/latest/technical_reference/features.html).

### Fixing a File

1. Delete the file from the list in `.pylintrc`
2. Run pylint by executing `pylint vivarium`
3. Fix all the errors that are shown
4. Repeat steps 2-3 until pylint finds no errors

## Test Coverage

* Reach 100% code coverage
  * [x] `core/composition.py`
  * [x] `core/control.py`
  * [ ] `core/emitter.py`
  * [ ] `core/experiment.py`
  * [ ]  `core/store.py`
  * [ ] `core/process.py`
  * [ ] `core/registry.py`

### How to Calculate Coverage

I use the `pytest-cov` plugin to calculate test coverage. Whenever you run the tests, add the `--cov=vivarium` argument to calculate coverage. At the end of the test output, you'll get a table like this:

```
Name                                                  Stmts   Miss  Cover   Missing
-----------------------------------------------------------------------------------
vivarium/__init__.py                                     37      0   100%
vivarium/composites/__init__.py                           0      0   100%
vivarium/composites/injected_glc_phosphorylation.py      13      0   100%
vivarium/core/__init__.py                                 0      0   100%
vivarium/core/composition.py                            403    180    55%   62, 64-68, 141, 143, 156-157, 179-193, 200-221, 247-333, 347, 364-370, 375-386, 417, 445-456, 527-540, 543-544, 548-552, 559-569, 578-580, 614-643, 686-706, 746-755, 998-1000
vivarium/core/control.py                                 85     13    85%   40, 42, 59-60, 63-64, 89-90, 104-106, 112, 119, 211
vivarium/core/emitter.py                                156     80    49%   32-33, 55, 59, 65, 70-73, 86-94, 103, 116, 119, 128, 176-189, 192-196, 199, 203-212, 216-222, 226-227, 232-280, 284-298, 304-312, 320-324
vivarium/core/experiment.py                            1018    104    90%   93, 102, 171, 173, 182-184, 191, 245, 250-251, 262, 267, 271-272, 285-286, 288, 293, 311, 327-328, 381-384, 416, 437, 442, 447, 452, 460-467, 481, 485, 487, 490, 500-502, 520-523, 665-672, 827-831, 855-872, 909, 920, 922, 948-949, 990-997, 1014, 1069, 1076, 1096, 1348-1352, 1461-1462, 1494-1495, 1517, 1546, 1548, 1570-1576, 1677, 2254
vivarium/core/process.py                                277     40    86%   30, 41, 43, 51, 78-81, 100-104, 211, 227, 274, 277-279, 319, 337, 353-361, 370, 386, 392-394, 401, 457, 459, 507, 516, 564
vivarium/core/registry.py                               118     57    52%   77-78, 115-122, 146-153, 164, 182-197, 202-204, 212, 227, 238, 246, 249, 253, 256, 260-264, 271-275, 284-286, 289, 294-296, 300
vivarium/experiments/__init__.py                          0      0   100%
vivarium/experiments/glucose_phosphorylation.py          48      5    90%   91-96, 105
vivarium/library/__init__.py                              0      0   100%
vivarium/library/datum.py                                28     19    32%   2-3, 6-7, 28-41, 44, 47, 50
vivarium/library/dict_utils.py                          163     75    54%   17-20, 33-36, 38, 53, 68, 70, 74, 94, 113-117, 127-132, 136-144, 150-152, 156-175, 183-202, 206, 230-236, 259-261
vivarium/library/fasta.py                                10      8    20%   4-12
vivarium/library/filepath.py                             35     22    37%   22-25, 31-34, 51-58, 73-77, 81-82, 87-89, 93-94
vivarium/library/make_network.py                         87     79     9%   15-27, 33-40, 55-92, 96-125, 136-171
vivarium/library/path.py                                  4      2    50%   4-5
vivarium/library/pretty.py                               24      5    79%   16, 18, 20-21, 26
vivarium/library/schema.py                               39     28    28%   4, 7, 12, 18-25, 28-40, 45-52, 55, 62, 69, 77
vivarium/library/timeseries.py                           32     27    16%   27-50, 61-69
vivarium/library/topology.py                            148     17    89%   36-37, 58-61, 93-98, 107-116
vivarium/library/units.py                                38      6    84%   62-66, 90
vivarium/plots/__init__.py                                0      0   100%
vivarium/plots/agents_multigen.py                       135    127     6%   13-24, 64-226
vivarium/plots/simulation_output.py                     103     16    84%   70, 76, 78-79, 82, 84, 99-103, 122, 129, 132, 138, 172
vivarium/plots/topology.py                              105     17    84%   40, 137-140, 151, 166-180, 187, 202, 255, 268-277
vivarium/processes/__init__.py                            0      0   100%
vivarium/processes/agent_names.py                        14      6    57%   12-14, 17, 29-30
vivarium/processes/burst.py                              58      6    90%   208-212, 217
vivarium/processes/derive_concentrations.py              28     19    32%   20-31, 34, 53-69
vivarium/processes/derive_counts.py                      24      8    67%   32, 35, 55-62
vivarium/processes/divide_condition.py                   15      7    53%   10-11, 14, 17, 25-28
vivarium/processes/engulf.py                             58      6    90%   183-187, 191
vivarium/processes/exchange_a.py                         15      0   100%
vivarium/processes/glucose_phosphorylation.py            39      5    87%   129-139
vivarium/processes/injector.py                           37      9    76%   57, 103-109, 113
vivarium/processes/meta_division.py                      76      9    88%   29, 52, 65, 207-211, 215
vivarium/processes/nonspatial_environment.py             27     15    44%   22-24, 27-72, 75-87
vivarium/processes/remove.py                             47      6    87%   116-120, 124
vivarium/processes/swap_processes.py                     63      6    90%   175-179, 183
vivarium/processes/template_process.py                   38      7    82%   138-146, 151
vivarium/processes/timeline.py                           57      5    91%   43-44, 51, 56-57
vivarium/processes/tree_mass.py                          50      7    86%   69, 168-172, 176
-----------------------------------------------------------------------------------
TOTAL                                                  3752   1048    72%

Required test coverage of 72.0% reached. Total coverage: 72.07%
```

This lists the fraction of lines in each file that are covered by the tests. It also tells you which lines in the file weren't covered so you can add tests for them.

Notice the last line says we require a code coverage of 72%. This is just the current coverage value. If coverage dips below 72%, the tests will fail to make sure we don't regress.

### How to Improve Coverage

1. Run the tests with `pytest --cov=vivarium`.
2. Pick a file that is not completely covered (coverage < 100%) using the coverage output.
3. Match the lines listed as not being covered to the source file. These are the lines you need to write tests for.
4. Add tests that will check that the uncovered lines are working correctly.
5. Run the tests again. You should get a coverage value of 100% for that file.
6. Update the minimum coverage in `.coveragerc` to your current coverage value for the entire project.

## Static Typing

* Annotate all code with types and resolve all issues raised by mypy
  * [x] `core/composition.py`
  * [x] `core/control.py`
  * [ ] `core/emitter.py`
  * [ ] `core/experiment.py`
  * [x] `core/process.py`
  * [ ] `core/registry.py`

### How to Use Mypy

We use type annotations as specified by [PEP 484](https://www.python.org/dev/peps/pep-0484) and prefer annotations to special comments like `# type: str`. See the [documentation for `typing`](https://docs.python.org/3.8/library/typing.html) for more information.

Mypy is configured using the `mypy.ini` file, which looks like this:

```ini
[mypy]

disallow_untyped_defs = True
disallow_incomplete_defs = True

# Ignore missing types from third-party libraries.
[mypy-numpy.*]
ignore_missing_imports = True
...

# Ignore missing types from Vivarium Core files we haven't typed yet.
[mypy-vivarium.core.registry.*]
disallow_untyped_defs = False
disallow_incomplete_defs = False

[mypy-vivarium.core.control.*]
disallow_untyped_defs = False
disallow_incomplete_defs = False
...
```

Notice how we first set `disallow_untyped_defs` and `disallow_incomplete_defs` to True. This means that by default, mypy will raise an error whenever a function is not completely typed. Then, for every file that hasn't been typed yet, we include a section like `[mypy-vivarium.core.registry.*]` for `vivarium/core/registry.py` that sets those options to False. As we add type hints, we can remove files from this list until everything has type annotations.

Also note that we tell mypy to ignore types in our dependencies like `numpy`. Many third-party libraries are poorly-typed, and we want to avoid spending time trying to get mypy to work with them. We will focus on making sure the types in our code are correct instead.

### How to Add Types

1. Pick a file listed in `mypy.ini` as not being covered yet.
2. Delete the file's section from `mypy.ini`.
3. Run mypy by executing `mypy vivarim`
4. Fix any errors raised by mypy
5. Repeat 3-4 until no errors are raised


Discussion / hints:
Tips:

* [PEP 563](https://www.python.org/dev/peps/pep-0563/) makes a small semantic change to Python annotations that will become mandatory in Python 3.10. Ideally we should make the annotations compatible with that now and keep them that way as we fill them out. We can use `from __future__ import annotations` to ensure they're good with it now, or it might suffice to run PyCharm inspections set to check for Python 3.10 compatibility.
  * I'll have to re-read it but IIRC it won't change any of our type hints. The point of the change is to defer processing of the annotations so that (1) they can more easily handle forward references to classes and (2) they avoid taking the time to process annotations when "just" loading code to run it. The change impacts cases like using annotations for RPC protocol definitions, database queries, etc.
* It'd be nice to make PyCharm inspections run clean on the code, even if that means letting it add some comments to ignore some inspections in some locations.
PR #43 adds runscripts to run the static checkers (so we don't have to remember how) and also fixes some checks.

Fix patch:
diff --git a/.coveragerc b/.coveragerc
index edf7facf..d7df6447 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,3 +1,11 @@
 [report]
-fail_under = 70
+fail_under = 85
 show_missing = True
+exclude_lines =
+    pragma: no cover
+omit =
+    vivarium/library/*
+    vivarium/processes/*
+    vivarium/composites/*
+    vivarium/experiments/*
+    vivarium/plots/*
\ No newline at end of file
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 7fc18832..0df4d475 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8]
+        python-version: [3.8]
     steps:
     - uses: actions/checkout@v2
     - name: Setup Python ${{ matrix.python-version }}
diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml
index b5f2b339..cdaaebc0 100644
--- a/.github/workflows/mypy.yml
+++ b/.github/workflows/mypy.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8]
+        python-version: [3.8]
     steps:
     - uses: actions/checkout@v2
     - name: Setup Python ${{ matrix.python-version }}
diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml
index d0574249..86f0ab99 100644
--- a/.github/workflows/pylint.yml
+++ b/.github/workflows/pylint.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8]
+        python-version: [3.8]
     steps:
     - uses: actions/checkout@v2
     - name: Setup Python ${{ matrix.python-version }}
diff --git a/.gitignore b/.gitignore
index 246c6284..6c815bc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
 /build
 /dist
 /doc/_build
+/doc/reference/api
 /vivarium_core.egg-info
 
 *.out
diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
index bba380ea..805edf3d 100644
--- a/.idea/inspectionProfiles/Project_Default.xml
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -3,8 +3,11 @@
     <option name="myName" value="Project Default" />
     <inspection_tool class="ClojureResolveInspection" enabled="false" level="WARNING" enabled_by_default="false" />
     <inspection_tool class="GjsLint" enabled="true" level="WARNING" enabled_by_default="true" />
+    <inspection_tool class="GrazieInspection" enabled="false" level="TYPO" enabled_by_default="false" />
     <inspection_tool class="JSLint" enabled="true" level="ERROR" enabled_by_default="true" />
     <inspection_tool class="Jscs" enabled="true" level="ERROR" enabled_by_default="true" />
+    <inspection_tool class="LanguageDetectionInspection" enabled="false" level="WARNING" enabled_by_default="false" />
+    <inspection_tool class="LongLine" enabled="true" level="WARNING" enabled_by_default="true" />
     <inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
       <option name="ourVersions">
         <value>
@@ -21,5 +24,10 @@
     <inspection_tool class="PyShadowingNamesInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
     <inspection_tool class="PySingleQuotedDocstringInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
     <inspection_tool class="PyUnusedLocalInspection" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
+    <inspection_tool class="SpellCheckingInspection" enabled="false" level="TYPO" enabled_by_default="false">
+      <option name="processCode" value="true" />
+      <option name="processLiterals" value="true" />
+      <option name="processComments" value="true" />
+    </inspection_tool>
   </profile>
 </component>
\ No newline at end of file
diff --git a/.pylintrc b/.pylintrc
index 6cad0744..844fada3 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,5 +1,8 @@
 [MASTER]
 
+# Rules reference:
+# https://pylint.pycqa.org/en/latest/technical_reference/features.html
+# Begins with the statements ignored by default
 disable=print-statement,
         parameter-unpacking,
         unpacking-in-except,
@@ -50,6 +53,22 @@ disable=print-statement,
         cmp-method,
         input-builtin,
         round-builtin,
+# TODO: Ignore docstrings temporarily
+        missing-class-docstring,
+        missing-function-docstring,
+        missing-module-docstring,
+# Rules we ignore that are not ignored by default
+        too-many-instance-attributes,
+        too-many-return-statements,
+        too-many-arguments,
+        too-many-locals,
+        too-many-branches,
+        too-few-public-methods,
+        no-self-use,
+        fixme,
+        unsupported-assignment-operation,  # Buggy with NewType objects
+        unsubscriptable-object,  # Buggy with NewType objects
+        no-member,  # Buggy with NewType objects
 ignore=injected_glc_phosphorylation.py,
        injector.py,
        topology.py,
@@ -59,15 +78,11 @@ ignore=injected_glc_phosphorylation.py,
        path.py,
        divide_condition.py,
        agents_multigen.py,
-       experiment.py,
+       exponential.py,
        burst.py,
-       composition.py,
        dict_utils.py,
-       control.py,
-       emitter.py,
        tree_mass.py,
        filepath.py,
-       process.py,
        exchange_a.py,
        registry.py,
        units.py,
@@ -82,13 +97,19 @@ ignore=injected_glc_phosphorylation.py,
        swap_processes.py,
        schema.py,
        timeline.py,
-       agent_names.py,
        derive_concentrations.py,
        nonspatial_environment.py,
+       store.py,
+# Good variable names which should be accepted
+good-names=pp,pf,_,
 
 [BASIC]
 
 no-docstring-rgx=(^_)|(^[Tt]est)
-max-line-length=72
+max-line-length=80
+max-module-lines=1200
+attr-rgx=^[a-z_][a-z0-9]*((_[a-z0-9]+)*)?$
 variable-rgx=^[a-z][a-z0-9]*((_[a-z0-9]+)*)?$
 argument-rgx=^[a-z][a-z0-9]*((_[a-z0-9]+)*)?$
+include-naming-hint=yes
+max-statements=55
diff --git a/README.md b/README.md
index aaacbf7b..ec614ee9 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Vivarium-core provides a process interface and simulation engine for composing a
 computational biology models.
 
 ## Documentation and Tutorials
-Visit [Vivarium documentation](https://vivarium-core.readthedocs.io/)
+Visit [Vivarium documentation](https://vivarium-core.readthedocs.io/).
 
 ## Installation
 vivarium-core can be used as a python library. To install:
diff --git a/doc/Makefile b/doc/Makefile
index 2710e4f2..ca703d8d 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -8,7 +8,6 @@ SPHINXBUILD   ?= sphinx-build
 SOURCEDIR     = .
 BUILDDIR      = _build
 APIDOCDIR     = reference/api
-CODESOURCE    = ../vivarium/
 
 # Put it first so that "make" without argument is like "make help".
 help:
@@ -20,12 +19,7 @@ clean:
 	@rm -rf _build/*
 	@rm -rf $(APIDOCDIR)/*
 
-# Run sphinx-apidoc to generate the API documentation skeleton files
-apidoc:
-	@rm -rf "$(APIDOCDIR)/*"
-	@sphinx-apidoc -feo "$(APIDOCDIR)" "$(CODESOURCE)"
-
 # Catch-all target: route all unknown targets to Sphinx using the new
 # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
-%: Makefile apidoc
+%: Makefile
 	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/doc/conf.py b/doc/conf.py
index f60de5a4..011bab6b 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -11,9 +11,15 @@
 # documentation root, use os.path.abspath to make it absolute, like shown here.
 #
 import os
+import shutil
 import sys
 sys.path.insert(0, os.path.abspath('..'))
 
+from docutils.nodes import Text
+from sphinx.addnodes import pending_xref
+from sphinx.ext import apidoc
+from sphinx.ext.intersphinx import missing_reference
+
 
 # -- Project information -----------------------------------------------------
 
@@ -51,13 +57,6 @@
 # will help avoid broken links.
 nitpicky = True
 
-# Ignore warnings like:
-# "docstring of ... reference target not found: matplotlib.pyplot.Figure".
-# Is there a way to make those references work?
-nitpick_ignore = [
-    ('py:class', 'networkx.Graph'),
-    ('py:class', 'matplotlib.pyplot.Figure')]
-
 
 # -- Options for HTML output -------------------------------------------------
 
@@ -71,17 +70,34 @@
 # so a file named "default.css" will overwrite the builtin "default.css".
 html_static_path = ['_static']
 
-# -- Options for extensions
-# --------------------------------------------------
+# -- Options for extensions --------------------------------------------------
 
 # -- sphinx.ext.intersphinx options --
 intersphinx_mapping = {
+    'matplotlib': ('https://matplotlib.org/3.3.1/', None),
+    'networkx': ('http://networkx.org/documentation/latest/', None),
     'python': ('https://docs.python.org/3', None),
-    'matplotlib': ('https://matplotlib.org/3.2.2/', None),
     'shapely': ('https://shapely.readthedocs.io/en/latest/', None),
+    'pint': ('https://pint.readthedocs.io/en/stable/', None),
+}
+
+# -- sphinx.ext.napoleon options --
+# Map from the alias to a tuple of the actual ref and the text to
+# display.
+reftarget_aliases = {
+    type_name: ('vivarium.core.types.{}'.format(type_name), type_name)
+    for type_name in (
+        'HierarchyPath', 'Topology', 'Schema', 'State', 'Update',
+        'CompositeDict')
 }
+reftarget_aliases.update({
+    type_name: ('typing.{}'.format(type_name), type_name)
+    for type_name in (
+        'Any', 'Dict', 'Tuple', 'Union', 'Optional', 'Callable', 'List')
+})
 
-# -- sphinx.ext.autodic options --
+
+# -- sphinx.ext.autodoc options --
 autodoc_inherit_docstrings = False
 # The Python dependencies aren't really required for building the docs
 autodoc_mock_imports = [
@@ -91,3 +107,58 @@
 ]
 # Concatenate class and __init__ docstrings
 autoclass_content = 'both'
+
+
+# -- Custom Extensions -------------------------------------------------
+
+
+# This function is adapted from a StackOverflow answer by Oleg Höfling
+# at https://stackoverflow.com/a/62301461. Per StackOverflow's licensing
+# terms, it is available under a CC-BY-SA 4.0 license
+# (https://creativecommons.org/licenses/by-sa/4.0/).
+def resolve_internal_aliases(_, doctree):
+    pending_xrefs = doctree.traverse(condition=pending_xref)
+    for node in pending_xrefs:
+        alias = node.get('reftarget')
+        if alias is not None and alias in reftarget_aliases:
+            resolved_ref, text = reftarget_aliases[alias]
+            node['reftarget'] = resolved_ref
+            text_node = next(iter(
+                node.traverse(lambda n: n.tagname == '#text')))
+            text_node.parent.replace(text_node, Text(text, ''))
+
+
+# This function is adapted from a StackOverflow answer by Oleg Höfling
+# at https://stackoverflow.com/a/62301461. Per StackOverflow's licensing
+# terms, it is available under a CC-BY-SA 4.0 license
+# (https://creativecommons.org/licenses/by-sa/4.0/).
+def resolve_intersphinx_aliases(app, env, node, contnode):
+    alias = node.get('reftarget')
+    if alias is not None and alias in reftarget_aliases:
+        resolved_ref, text = reftarget_aliases[alias]
+        node['reftarget'] = resolved_ref
+        text_node = next(iter(
+            contnode.traverse(lambda n: n.tagname == '#text')))
+        text_node.parent.replace(text, Text(text, ''))
+        return missing_reference(app, env, node, contnode)
+    return None
+
+
+def run_apidoc(_):
+    cur_dir = os.path.abspath(os.path.dirname(__file__))
+    module = os.path.join(cur_dir, '..', 'vivarium')
+    apidoc_dir = os.path.join(cur_dir, 'reference', 'api')
+    if os.path.exists(apidoc_dir):
+        shutil.rmtree(apidoc_dir)
+    os.mkdir(apidoc_dir)
+    apidoc.main(['-f', '-e', '-o', apidoc_dir, module])
+
+
+# This function is adapted from a StackOverflow answer by Oleg Höfling
+# at https://stackoverflow.com/a/62301461. Per StackOverflow's licensing
+# terms, it is available under a CC-BY-SA 4.0 license
+# (https://creativecommons.org/licenses/by-sa/4.0/).
+def setup(app):
+    app.connect('doctree-read', resolve_internal_aliases)
+    app.connect('missing-reference', resolve_intersphinx_aliases)
+    app.connect('builder-inited', run_apidoc)
diff --git a/doc/guides/compartments.rst b/doc/guides/compartments.rst
index ca6022bd..106e61d4 100644
--- a/doc/guides/compartments.rst
+++ b/doc/guides/compartments.rst
@@ -81,11 +81,11 @@ instances of the same process class.
 Stores
 ------
 
-We represent stores with the :py:class:`vivarium.core.experiment.Store`
+We represent stores with the :py:class:`vivarium.core.store.Store`
 class; see its documentation for further details.
 
 .. tip:: To see the data held by a store, you can use the
-   :py:func:`vivarium.core.experiment.Store.get_config` function. This
+   :py:func:`vivarium.core.store.Store.get_config` function. This
    returns a dictionary representation of the store's data. To show this
    dictionary more readably, use
    :py:func:`vivarium.library.pretty.format_dict`.
diff --git a/doc/guides/experiments.rst b/doc/guides/experiments.rst
index d28dd3f4..f9afe6bf 100644
--- a/doc/guides/experiments.rst
+++ b/doc/guides/experiments.rst
@@ -54,7 +54,7 @@ steps are:
 #. Instantiate the compartment that your experiment will simulate.
 #. Generate the processes and topology dictionaries that describe the
    compartment using
-   :py:meth:`vivarium.core.process.Factory.generate`.
+   :py:meth:`vivarium.core.process.Composer.generate`.
 #. Instantiate the experiment, passing along the processes and topology
    dictionaries. We also specify the :term:`emitter` the experiment
    should send data to and the initial state of the model. If we don't
diff --git a/doc/guides/processes.rst b/doc/guides/processes.rst
index 18412ca6..d504f58c 100644
--- a/doc/guides/processes.rst
+++ b/doc/guides/processes.rst
@@ -176,22 +176,11 @@ not accumulated.
 Derivers
 ========
 
-For each port, we can also specify a :term:`deriver`. Each process class
-MAY implement a `derivers` method that returns a dictionary whose keys
-are the ports to which we want to apply derivers. For each port, the
-value in the dictionary must be a dictionary with the following keys:
-
-* **deriver** (:py:class:`str`): The name of the deriver to apply.
-* **port_mapping** (:py:class:`dict`): Maps from ports of the deriver
-  process to ports of the process class we are writing. This is like a
-  :term:`topology`.
-* **config** (:py:class:`dict`): A configuration dictionary that
-  conforms to the requirements of the particular deriver being invoked.
-
-.. WARNING:: Derivers need to be registered for Vivarium Core to know
-   what deriver each name indicates. To register a deriver, just
-   create an instance of it. Many derivers do this automatically on
-   import.
+:term:`Deriver` is subclass of :term:`Process`, which runs after the
+other Processes with a timestep of 0, and derives some states from
+others. For example, concentrations from counts. These are used to
+offload complexity from the dynamical Processes.
+
 
 Next Updates
 ============
diff --git a/doc/reference/api/modules.rst b/doc/reference/api/modules.rst
deleted file mode 100644
index ff22de59..00000000
--- a/doc/reference/api/modules.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium
-========
-
-.. toctree::
-   :maxdepth: 4
-
-   vivarium
diff --git a/doc/reference/api/vivarium.core.composition.rst b/doc/reference/api/vivarium.core.composition.rst
deleted file mode 100644
index 1ebdd176..00000000
--- a/doc/reference/api/vivarium.core.composition.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.core.composition module
-================================
-
-.. automodule:: vivarium.core.composition
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.core.emitter.rst b/doc/reference/api/vivarium.core.emitter.rst
deleted file mode 100644
index b3f96cdf..00000000
--- a/doc/reference/api/vivarium.core.emitter.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.core.emitter module
-============================
-
-.. automodule:: vivarium.core.emitter
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.core.experiment.rst b/doc/reference/api/vivarium.core.experiment.rst
deleted file mode 100644
index d4b6e1a6..00000000
--- a/doc/reference/api/vivarium.core.experiment.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.core.experiment module
-===============================
-
-.. automodule:: vivarium.core.experiment
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.core.process.rst b/doc/reference/api/vivarium.core.process.rst
deleted file mode 100644
index e491b227..00000000
--- a/doc/reference/api/vivarium.core.process.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.core.process module
-============================
-
-.. automodule:: vivarium.core.process
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.core.registry.rst b/doc/reference/api/vivarium.core.registry.rst
deleted file mode 100644
index 098cd228..00000000
--- a/doc/reference/api/vivarium.core.registry.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.core.registry module
-=============================
-
-.. automodule:: vivarium.core.registry
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.core.rst b/doc/reference/api/vivarium.core.rst
deleted file mode 100644
index 7101870a..00000000
--- a/doc/reference/api/vivarium.core.rst
+++ /dev/null
@@ -1,23 +0,0 @@
-vivarium.core package
-=====================
-
-Submodules
-----------
-
-.. toctree::
-   :maxdepth: 4
-
-   vivarium.core.composition
-   vivarium.core.control
-   vivarium.core.emitter
-   vivarium.core.experiment
-   vivarium.core.process
-   vivarium.core.registry
-
-Module contents
----------------
-
-.. automodule:: vivarium.core
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.experiments.glucose_phosphorylation.rst b/doc/reference/api/vivarium.experiments.glucose_phosphorylation.rst
deleted file mode 100644
index 0aef8ce5..00000000
--- a/doc/reference/api/vivarium.experiments.glucose_phosphorylation.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.experiments.glucose\_phosphorylation module
-====================================================
-
-.. automodule:: vivarium.experiments.glucose_phosphorylation
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.experiments.rst b/doc/reference/api/vivarium.experiments.rst
deleted file mode 100644
index 022cb85b..00000000
--- a/doc/reference/api/vivarium.experiments.rst
+++ /dev/null
@@ -1,18 +0,0 @@
-vivarium.experiments package
-============================
-
-Submodules
-----------
-
-.. toctree::
-   :maxdepth: 4
-
-   vivarium.experiments.glucose_phosphorylation
-
-Module contents
----------------
-
-.. automodule:: vivarium.experiments
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.datum.rst b/doc/reference/api/vivarium.library.datum.rst
deleted file mode 100644
index 17063245..00000000
--- a/doc/reference/api/vivarium.library.datum.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.datum module
-=============================
-
-.. automodule:: vivarium.library.datum
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.dict_utils.rst b/doc/reference/api/vivarium.library.dict_utils.rst
deleted file mode 100644
index f9dfa19f..00000000
--- a/doc/reference/api/vivarium.library.dict_utils.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.dict\_utils module
-===================================
-
-.. automodule:: vivarium.library.dict_utils
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.filepath.rst b/doc/reference/api/vivarium.library.filepath.rst
deleted file mode 100644
index ef1e5050..00000000
--- a/doc/reference/api/vivarium.library.filepath.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.filepath module
-================================
-
-.. automodule:: vivarium.library.filepath
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.make_network.rst b/doc/reference/api/vivarium.library.make_network.rst
deleted file mode 100644
index 3f1de36e..00000000
--- a/doc/reference/api/vivarium.library.make_network.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.make\_network module
-=====================================
-
-.. automodule:: vivarium.library.make_network
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.path.rst b/doc/reference/api/vivarium.library.path.rst
deleted file mode 100644
index e9c83c6f..00000000
--- a/doc/reference/api/vivarium.library.path.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.path module
-============================
-
-.. automodule:: vivarium.library.path
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.pretty.rst b/doc/reference/api/vivarium.library.pretty.rst
deleted file mode 100644
index 1f24de1e..00000000
--- a/doc/reference/api/vivarium.library.pretty.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.pretty module
-==============================
-
-.. automodule:: vivarium.library.pretty
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.rst b/doc/reference/api/vivarium.library.rst
deleted file mode 100644
index 02adbbb0..00000000
--- a/doc/reference/api/vivarium.library.rst
+++ /dev/null
@@ -1,27 +0,0 @@
-vivarium.library package
-========================
-
-Submodules
-----------
-
-.. toctree::
-   :maxdepth: 4
-
-   vivarium.library.datum
-   vivarium.library.dict_utils
-   vivarium.library.filepath
-   vivarium.library.make_network
-   vivarium.library.path
-   vivarium.library.pretty
-   vivarium.library.schema
-   vivarium.library.timeseries
-   vivarium.library.topology
-   vivarium.library.units
-
-Module contents
----------------
-
-.. automodule:: vivarium.library
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.timeseries.rst b/doc/reference/api/vivarium.library.timeseries.rst
deleted file mode 100644
index f5321355..00000000
--- a/doc/reference/api/vivarium.library.timeseries.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.timeseries module
-==================================
-
-.. automodule:: vivarium.library.timeseries
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.library.units.rst b/doc/reference/api/vivarium.library.units.rst
deleted file mode 100644
index 5e45dc4a..00000000
--- a/doc/reference/api/vivarium.library.units.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.library.units module
-=============================
-
-.. automodule:: vivarium.library.units
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.derive_concentrations.rst b/doc/reference/api/vivarium.processes.derive_concentrations.rst
deleted file mode 100644
index 5e2bb40e..00000000
--- a/doc/reference/api/vivarium.processes.derive_concentrations.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.derive\_concentrations module
-================================================
-
-.. automodule:: vivarium.processes.derive_concentrations
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.derive_counts.rst b/doc/reference/api/vivarium.processes.derive_counts.rst
deleted file mode 100644
index c0e1a7e3..00000000
--- a/doc/reference/api/vivarium.processes.derive_counts.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.derive\_counts module
-========================================
-
-.. automodule:: vivarium.processes.derive_counts
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.glucose_phosphorylation.rst b/doc/reference/api/vivarium.processes.glucose_phosphorylation.rst
deleted file mode 100644
index 51dc7e11..00000000
--- a/doc/reference/api/vivarium.processes.glucose_phosphorylation.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.glucose\_phosphorylation module
-==================================================
-
-.. automodule:: vivarium.processes.glucose_phosphorylation
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.injector.rst b/doc/reference/api/vivarium.processes.injector.rst
deleted file mode 100644
index b4f7994b..00000000
--- a/doc/reference/api/vivarium.processes.injector.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.injector module
-==================================
-
-.. automodule:: vivarium.processes.injector
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.meta_division.rst b/doc/reference/api/vivarium.processes.meta_division.rst
deleted file mode 100644
index 7965cd11..00000000
--- a/doc/reference/api/vivarium.processes.meta_division.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.meta\_division module
-========================================
-
-.. automodule:: vivarium.processes.meta_division
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.nonspatial_environment.rst b/doc/reference/api/vivarium.processes.nonspatial_environment.rst
deleted file mode 100644
index 15b4275d..00000000
--- a/doc/reference/api/vivarium.processes.nonspatial_environment.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.nonspatial\_environment module
-=================================================
-
-.. automodule:: vivarium.processes.nonspatial_environment
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.rst b/doc/reference/api/vivarium.processes.rst
deleted file mode 100644
index 6a12fff7..00000000
--- a/doc/reference/api/vivarium.processes.rst
+++ /dev/null
@@ -1,34 +0,0 @@
-vivarium.processes package
-==========================
-
-Submodules
-----------
-
-.. toctree::
-   :maxdepth: 4
-
-   vivarium.processes.agent_names
-   vivarium.processes.burst
-   vivarium.processes.derive_concentrations
-   vivarium.processes.derive_counts
-   vivarium.processes.divide_condition
-   vivarium.processes.engulf
-   vivarium.processes.exchange_a
-   vivarium.processes.exponential
-   vivarium.processes.glucose_phosphorylation
-   vivarium.processes.injector
-   vivarium.processes.meta_division
-   vivarium.processes.nonspatial_environment
-   vivarium.processes.remove
-   vivarium.processes.swap_processes
-   vivarium.processes.template_process
-   vivarium.processes.timeline
-   vivarium.processes.tree_mass
-
-Module contents
----------------
-
-.. automodule:: vivarium.processes
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.template_process.rst b/doc/reference/api/vivarium.processes.template_process.rst
deleted file mode 100644
index 9f6104ed..00000000
--- a/doc/reference/api/vivarium.processes.template_process.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.template\_process module
-===========================================
-
-.. automodule:: vivarium.processes.template_process
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.timeline.rst b/doc/reference/api/vivarium.processes.timeline.rst
deleted file mode 100644
index 47546847..00000000
--- a/doc/reference/api/vivarium.processes.timeline.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.timeline module
-==================================
-
-.. automodule:: vivarium.processes.timeline
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.processes.tree_mass.rst b/doc/reference/api/vivarium.processes.tree_mass.rst
deleted file mode 100644
index e57427bc..00000000
--- a/doc/reference/api/vivarium.processes.tree_mass.rst
+++ /dev/null
@@ -1,7 +0,0 @@
-vivarium.processes.tree\_mass module
-====================================
-
-.. automodule:: vivarium.processes.tree_mass
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/api/vivarium.rst b/doc/reference/api/vivarium.rst
deleted file mode 100644
index 8b595b9f..00000000
--- a/doc/reference/api/vivarium.rst
+++ /dev/null
@@ -1,23 +0,0 @@
-vivarium package
-================
-
-Subpackages
------------
-
-.. toctree::
-   :maxdepth: 4
-
-   vivarium.composites
-   vivarium.core
-   vivarium.experiments
-   vivarium.library
-   vivarium.plots
-   vivarium.processes
-
-Module contents
----------------
-
-.. automodule:: vivarium
-   :members:
-   :undoc-members:
-   :show-inheritance:
diff --git a/doc/reference/glossary.rst b/doc/reference/glossary.rst
index 843c9aa0..b8714b69 100644
--- a/doc/reference/glossary.rst
+++ b/doc/reference/glossary.rst
@@ -42,13 +42,18 @@ Glossary
         two child compartments for the two cells in the environment. For
         more details, see our :doc:`guide to compartments</guides/compartments>`.
 
+    composer
+    Composer
+        An object with a generate method, which returns a :term:`Composite` with
+        :term:`Processes` and :term:`Topologies`.
+
     Composite
     composite
     Composites
     composites
-        Composites a collection of :term:`processes` that operate on the
-        compartment's :term:`stores` simultaneously. These are specified by a
-        :term:`topology`.
+        Composites are dictionaries with special keys for :term:`processes` and
+        :term:`topology`. The `processes` key map to a dictionary with initialized
+        processes, and the `topology` specifies how they are wire to :term:`stores`.
 
     Deriver
     deriver
@@ -113,11 +118,6 @@ Glossary
         documentation for the ``Experiment`` class and our :doc:`guide
         to experiments </guides/experiments>` for more details.
 
-    factory
-    Factory
-        An object with a generate method, which returns a dictionary of
-        :term:`Processes` and a dictionary of :term:`Topologies`.
-
     Inner
     inner
         A once-removed internal node position relative to a given node in
@@ -237,9 +237,9 @@ Glossary
     schema key-value pairs
         Each :term:`variable` is defined by a set of schema key-value
         pairs. The available keys are defined in
-        :py:attr:`vivarium.core.experiment.Store.schema_keys`. These
+        :py:attr:`vivarium.core.store.Store.schema_keys`. These
         keys are described in more detail in the documentation for
-        :py:class:`vivarium.core.experiment.Store`.
+        :py:class:`vivarium.core.store.Store`.
 
     Serializer
     serializer
diff --git a/doc/requirements.txt b/doc/requirements.txt
index 31bb8cc8..158e9540 100644
--- a/doc/requirements.txt
+++ b/doc/requirements.txt
@@ -32,7 +32,7 @@ requests==2.24.0
 scipy==1.5.2
 six==1.15.0
 snowballstemmer==2.0.0
-Sphinx==3.2.1
+Sphinx==3.4.3
 sphinx-rtd-theme==0.5.0
 sphinxcontrib-applehelp==1.0.2
 sphinxcontrib-devhelp==1.0.2
diff --git a/mypy.ini b/mypy.ini
index 9d2ce9c7..733a4f82 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -33,10 +33,6 @@ ignore_missing_imports = True
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
 
-[mypy-vivarium.core.control.*]
-disallow_untyped_defs = False
-disallow_incomplete_defs = False
-
 [mypy-vivarium.core.__init__.*]
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
@@ -45,15 +41,11 @@ disallow_incomplete_defs = False
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
 
-[mypy-vivarium.core.process.*]
-disallow_untyped_defs = False
-disallow_incomplete_defs = False
-
-[mypy-vivarium.core.composition.*]
+[mypy-vivarium.core.store.*]
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
 
-[mypy-vivarium.core.emitter.*]
+[mypy-vivarium.core.test_experiment.*]
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
 
@@ -85,10 +77,6 @@ disallow_incomplete_defs = False
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
 
-[mypy-vivarium.processes.agent_names.*]
-disallow_untyped_defs = False
-disallow_incomplete_defs = False
-
 [mypy-vivarium.processes.swap_processes.*]
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
@@ -153,6 +141,10 @@ disallow_incomplete_defs = False
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
 
+[mypy-vivarium.composites.toys.*]
+disallow_untyped_defs = False
+disallow_incomplete_defs = False
+
 [mypy-vivarium.__init__.*]
 disallow_untyped_defs = False
 disallow_incomplete_defs = False
diff --git a/runscripts/all_checks.sh b/runscripts/all_checks.sh
new file mode 100755
index 00000000..d0f57620
--- /dev/null
+++ b/runscripts/all_checks.sh
@@ -0,0 +1,13 @@
+#!/bin/sh
+# Run all checks on this project.
+
+set -eu
+
+echo === mypy ===
+runscripts/mypy.sh
+
+echo === pylint ===
+runscripts/pylint.sh
+
+echo === pytest ===
+runscripts/pytest.sh
diff --git a/runscripts/mypy.sh b/runscripts/mypy.sh
index 74204c6a..e3154d49 100755
--- a/runscripts/mypy.sh
+++ b/runscripts/mypy.sh
@@ -1,4 +1,6 @@
 #!/bin/sh
 # Run the mypy type checker on this project.
 
+set -eu
+
 mypy
diff --git a/runscripts/pylint.sh b/runscripts/pylint.sh
index b85b20ea..3bfc3f54 100755
--- a/runscripts/pylint.sh
+++ b/runscripts/pylint.sh
@@ -1,4 +1,6 @@
 #!/bin/sh
 # Run the pylint static analyzer on this project.
 
+set -eu
+
 pylint vivarium
diff --git a/setup.py b/setup.py
index 598fb851..b1fe2b3c 100644
--- a/setup.py
+++ b/setup.py
@@ -1,10 +1,20 @@
+import re
 import setuptools
 from distutils.core import setup
 
 _ = setuptools  # don't warn about this unused import; it might have side effects
 
 with open("README.md", 'r') as readme:
-    long_description = readme.read()
+    description = readme.read()
+    # Patch the relative links to absolute URLs that will work on PyPI.
+    description2 = re.sub(
+        r']\(([\w/.-]+\.png)\)',
+        r'](https://github.com/vivarium-collective/vivarium-core/raw/master/\1)',
+        description)
+    long_description = re.sub(
+        r']\(([\w/.-]+)\)',
+        r'](https://github.com/vivarium-collective/vivarium-core/blob/master/\1)',
+        description2)
 
 setup(
     name='vivarium-core',
@@ -21,6 +31,11 @@
     author='Eran Agmon, Ryan Spangler',
     author_email='eagmon@stanford.edu, ryan.spangler@gmail.com',
     url='https://github.com/vivarium-collective/vivarium-core',
+    project_urls={
+        'Source': 'https://github.com/vivarium-collective/vivarium-core',
+        'Documentation': 'https://vivarium-core.readthedocs.io/en/latest/',
+        'Changelog': 'https://github.com/vivarium-collective/vivarium-core/blob/master/CHANGELOG.md',
+    },
     license='MIT',
     entry_points={
         'console_scripts': []},
@@ -32,6 +47,7 @@
     long_description_content_type='text/markdown',
     package_data={},
     include_package_data=True,
+    python_requires='>=3.8, <4',
     install_requires=[
         'matplotlib',
         'networkx',
@@ -39,4 +55,19 @@
         'Pint',
         'pymongo',
         'scipy',
-    ])
+    ],
+    classifiers=[
+        'Development Status :: 4 - Beta',
+        'Intended Audience :: Developers',
+        'Intended Audience :: Education',
+        'Intended Audience :: Science/Research',
+        'License :: OSI Approved :: MIT License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+        'Programming Language :: Python :: 3',
+        'Programming Language :: Python :: 3.8',
+        'Programming Language :: Python :: 3.9',
+        'Topic :: Scientific/Engineering',
+    ],
+    keywords='vivarium multi-scale computational-biology biology simulation framework',
+)
diff --git a/vivarium/__init__.py b/vivarium/__init__.py
index 85953ca3..dcd4fd97 100644
--- a/vivarium/__init__.py
+++ b/vivarium/__init__.py
@@ -33,9 +33,11 @@
     update_nonnegative_accumulate, divide_set, divide_split,
     divide_split_dict, divide_zero, assert_no_divide, divide_binomial,
     NumpySerializer, NumpyScalarSerializer, UnitsSerializer,
-    ProcessSerializer, FactorySerializer, FunctionSerializer,
+    ProcessSerializer, ComposerSerializer, FunctionSerializer,
 )
 
+_ = plt  # suppress PyCharm's unused-import warning
+
 
 # register processes
 process_registry.register(DivideCondition.name, DivideCondition)
@@ -71,5 +73,5 @@
 serializer_registry.register('numpy_scalar', NumpyScalarSerializer())
 serializer_registry.register('units', UnitsSerializer())
 serializer_registry.register('process', ProcessSerializer())
-serializer_registry.register('factory', FactorySerializer())
+serializer_registry.register('composer', ComposerSerializer())
 serializer_registry.register('function', FunctionSerializer())
diff --git a/vivarium/composites/injected_glc_phosphorylation.py b/vivarium/composites/injected_glc_phosphorylation.py
index fc48ddbf..66e99542 100644
--- a/vivarium/composites/injected_glc_phosphorylation.py
+++ b/vivarium/composites/injected_glc_phosphorylation.py
@@ -6,13 +6,13 @@
 This is a toy example referenced in the documentation.
 """
 
-from vivarium.core.process import Composite
+from vivarium.core.process import Composer
 
 from vivarium.processes.glucose_phosphorylation import GlucosePhosphorylation
 from vivarium.processes.injector import Injector
 
 
-class InjectedGlcPhosphorylation(Composite):
+class InjectedGlcPhosphorylation(Composer):
 
     defaults = {
         'glucose_phosphorylation': {
diff --git a/vivarium/composites/toys.py b/vivarium/composites/toys.py
new file mode 100644
index 00000000..3b68544e
--- /dev/null
+++ b/vivarium/composites/toys.py
@@ -0,0 +1,446 @@
+import math
+from typing import Optional, Dict, Any
+
+import numpy as np
+
+from vivarium.core.process import Process, Deriver, Composer
+
+quark_colors = ['green', 'red', 'blue']
+quark_spins = ['up', 'down']
+electron_spins = ['-1/2', '1/2']
+electron_orbitals = [
+    str(orbit) + 's'
+    for orbit in range(1, 8)]
+
+
+class ToyTransport(Process):
+    name = 'toy_transport'
+
+    def __init__(
+            self,
+            initial_parameters: Optional[Dict[str, Any]] = None
+    ):
+        initial_parameters = initial_parameters or {}
+        parameters = {'intake_rate': 2}
+        parameters.update(initial_parameters)
+        super().__init__(parameters)
+
+    def ports_schema(self):
+        ports = {
+            'external': ['GLC'],
+            'internal': ['GLC']}
+        return {
+            port_id: {
+                key: {
+                    '_default': 0.0,
+                    '_emit': True}
+                for key in keys}
+            for port_id, keys in ports.items()}
+
+    def next_update(self, timestep, states):
+        update = {}
+        intake = timestep * self.parameters['intake_rate']
+        if states['external']['GLC'] >= intake:
+            update = {
+                'external': {'GLC': -2, 'MASS': 1},
+                'internal': {'GLC': 2}}
+
+        return update
+
+
+class ToyDeriveVolume(Deriver):
+    name = 'toy_derive_volume'
+
+    def __init__(
+            self,
+            initial_parameters: Optional[Dict[str, Any]] = None
+    ):
+        _ = initial_parameters  # ignore initial_parameters
+        parameters: Dict[str, Any] = {}
+        super().__init__(parameters)
+
+    def ports_schema(self):
+        ports = {
+            'compartment': ['MASS', 'DENSITY', 'VOLUME']}
+        return {
+            port_id: {
+                key: {
+                    '_updater': 'set' if key == 'VOLUME' else
+                    'accumulate',
+                    '_default': 0.0,
+                    '_emit': True}
+                for key in keys}
+            for port_id, keys in ports.items()}
+
+    def next_update(self, timestep, states):
+        volume = states['compartment']['MASS'] /\
+                 states['compartment']['DENSITY']
+        update = {
+            'compartment': {'VOLUME': volume}}
+
+        return update
+
+
+class ToyDeath(Process):
+    name = 'toy_death'
+
+    def __init__(
+            self,
+            initial_parameters: Optional[Dict[str, Any]] = None
+    ):
+        initial_parameters = initial_parameters or {}
+        self.targets = initial_parameters.get('targets', [])
+        super().__init__({})
+
+    def ports_schema(self):
+        return {
+            'compartment': {
+                'VOLUME': {
+                    '_default': 0.0,
+                    '_emit': True}},
+            'global': {
+                target: {
+                    '_default': None}
+                for target in self.targets}}
+
+    def next_update(self, timestep, states):
+        volume = states['compartment']['VOLUME']
+        update = {}
+
+        if volume > 1.0:
+            # kill the cell
+            update = {
+                'global': {
+                    '_delete': [
+                        (target,)
+                        for target in self.targets]}}
+
+        return update
+
+
+class ToyCompartment(Composer):
+    '''
+    a toy compartment for testing
+
+    '''
+    def __init__(self, config):
+        super().__init__(config)
+
+    def generate_processes(self, config):
+        return {
+            'metabolism': ToyMetabolism(
+                {'mass_conversion_rate': 0.5}),
+            'transport': ToyTransport(),
+            'death': ToyDeath({'targets': [
+                'metabolism',
+                'transport']}),
+            'external_volume': ToyDeriveVolume(),
+            'internal_volume': ToyDeriveVolume()
+        }
+
+    def generate_topology(self, config):
+        return{
+            'metabolism': {
+                'pool': ('cytoplasm',)},
+            'transport': {
+                'external': ('periplasm',),
+                'internal': ('cytoplasm',)},
+            'death': {
+                'global': tuple(),
+                'compartment': ('cytoplasm',)},
+            'external_volume': {
+                'compartment': ('periplasm',)},
+            'internal_volume': {
+                'compartment': ('cytoplasm',)}}
+
+
+class ToyMetabolism(Process):
+    name = 'toy_metabolism'
+
+    def __init__(
+            self,
+            initial_parameters: Optional[Dict[str, Any]] = None
+    ):
+        initial_parameters = initial_parameters or {}
+        parameters = {'mass_conversion_rate': 1}
+        parameters.update(initial_parameters)
+        super().__init__(parameters)
+
+    def ports_schema(self):
+        ports = {
+            'pool': ['GLC', 'MASS']}
+        return {
+            port_id: {
+                key: {
+                    '_default': 0.0,
+                    '_emit': True}
+                for key in keys}
+            for port_id, keys in ports.items()}
+
+    def next_update(self, timestep, states):
+        update = {}
+        glucose_required = (timestep /
+                            self.parameters['mass_conversion_rate'])
+        if states['pool']['GLC'] >= glucose_required:
+            update = {
+                'pool': {
+                    'GLC': -2,
+                    'MASS': 1}}
+
+        return update
+
+
+class ToyLinearGrowthDeathProcess(Process):
+
+    name = 'toy_linear_growth_death'
+
+    GROWTH_RATE = 1.0
+    THRESHOLD = 6.0
+
+    def __init__(
+            self,
+            initial_parameters: Optional[Dict[str, Any]] = None
+    ):
+        initial_parameters = initial_parameters or {}
+        self.targets = initial_parameters.get('targets')
+        super().__init__(initial_parameters)
+
+    def ports_schema(self):
+        return {
+            'global': {
+                'mass': {
+                    '_default': 1.0,
+                    '_emit': True}},
+            'targets': {
+                target: {
+                    '_default': None}
+                for target in self.targets}}
+
+    def next_update(self, timestep, states):
+        mass = states['global']['mass']
+        mass_grown = (
+            ToyLinearGrowthDeathProcess.GROWTH_RATE * timestep)
+        update = {
+            'global': {'mass': mass_grown},
+        }
+        if mass > ToyLinearGrowthDeathProcess.THRESHOLD:
+            update['global'] = {
+                '_delete': [(target,) for target in self.targets]}
+
+        return update
+
+
+class Proton(Process):
+    name = 'proton'
+    defaults = {
+        'time_step': 1.0,
+        'radius': 0.0}
+
+    def ports_schema(self):
+        return {
+            'radius': {
+                '_updater': 'set',
+                '_default': self.parameters['radius']},
+            'quarks': {
+                '_divider': 'split_dict',
+                '*': {
+                    'color': {
+                        '_updater': 'set',
+                        '_default': quark_colors[0]},
+                    'spin': {
+                        '_updater': 'set',
+                        '_default': quark_spins[0]}}},
+            'electrons': {
+                '*': {
+                    'orbital': {
+                        '_updater': 'set',
+                        '_default': electron_orbitals[0]},
+                    'spin': {
+                        '_default': electron_spins[0]}}}}
+
+    def next_update(self, timestep, states):
+        update = {}
+
+        collapse = np.random.uniform()
+        if collapse < states['radius'] * timestep:
+            update['radius'] = collapse
+            update['quarks'] = {}
+
+            for name in states['quarks'].keys():
+                update['quarks'][name] = {
+                    'color': np.random.choice(quark_colors),
+                    'spin': np.random.choice(quark_spins)}
+
+            update['electrons'] = {}
+            orbitals = electron_orbitals.copy()
+            for name in states['electrons'].keys():
+                np.random.shuffle(orbitals)
+                update['electrons'][name] = {
+                    'orbital': orbitals.pop()}
+
+        return update
+
+
+class Electron(Process):
+    name = 'electron'
+    defaults = {
+        'time_step': 1.0,
+        'spin': electron_spins[0]}
+
+    def ports_schema(self):
+        return {
+            'spin': {
+                '_updater': 'set',
+                '_default': self.parameters['spin']},
+            'proton': {
+                'radius': {
+                    '_default': 0.0}}}
+
+    def next_update(self, timestep, states):
+        update = {}
+
+        if np.random.uniform() < states['proton']['radius']:
+            update['spin'] = np.random.choice(electron_spins)
+
+        return update
+
+
+class Sine(Process):
+    name = 'sine'
+    defaults = {
+        'initial_phase': 0.0}
+
+    def ports_schema(self):
+        return {
+            'frequency': {
+                '_default': 440.0},
+            'amplitude': {
+                '_default': 1.0},
+            'phase': {
+                '_default': self.parameters['initial_phase']},
+            'signal': {
+                '_default': 0.0,
+                '_updater': 'set'}}
+
+    def next_update(self, timestep, states):
+        phase_shift = timestep * states['frequency'] % 1.0
+        signal = states['amplitude'] * math.sin(
+            2 * math.pi * (states['phase'] + phase_shift))
+
+        return {
+            'phase': phase_shift,
+            'signal': signal}
+
+
+class ExchangeA(Process):
+    """ Exchange A
+
+    A minimal exchange process that moves molecules 'A' between internal
+    and external ports
+    """
+    name = 'exchange_a'
+    defaults = {
+        'uptake_rate': 0.0,
+        'secrete_rate': 0.0}
+
+    def __init__(self, parameters=None):
+        super().__init__(parameters)
+        self.uptake_rate = self.parameters['uptake_rate']
+        self.secrete_rate = self.parameters['secrete_rate']
+
+    def ports_schema(self):
+        return {
+            'internal': {
+                'A': {
+                    '_default': 0.0,
+                    '_emit': True}},
+            'external': {
+                'A': {
+                    '_default': 0.0,
+                    '_emit': True}}}
+
+    def next_update(self, timestep, states):
+        a_in = states['internal']['A']
+        a_out = states['external']['A']
+        delta_a_in = a_out * self.uptake_rate - a_in * self.secrete_rate
+        return {
+            'internal': {'A': delta_a_in},
+            'external': {'A': -delta_a_in}}
+
+
+class Po(Process):
+    name = 'po'
+
+    def ports_schema(self):
+        return {
+            'A': {
+                'a1': {'_default': 0},
+                'a2': {'_default': 0},
+                'a3': {'_default': 0}},
+            'B': {
+                'b1': {'_default': 0},
+                'b2': {'_default': 0}}}
+
+    def next_update(self, timestep, states):
+        return {
+            'A': {
+                'a1': 1,
+                'a2': 1,
+                'a3': 1},
+            'B': {
+                'b1': -1,
+                'b2': -1}}
+
+
+class Qo(Process):
+    name = 'qo'
+
+    def ports_schema(self):
+        return {
+            'D': {
+                'd1': {'_default': 0},
+                'd2': {'_default': 0},
+                'd3': {'_default': 0}},
+            'E': {
+                'e1': {'_default': 0},
+                'e2': {'_default': 0}}}
+
+    def next_update(self, timestep, states):
+        return {
+            'D': {
+                'd1': 10,
+                'd2': 10,
+                'd3': 10},
+            'E': {
+                'e1': -10,
+                'e2': -10}}
+
+
+class PoQo(Composer):
+    def generate_processes(self, config=None):
+        return {
+            'po': Po(config),
+            'qo': Qo(config),
+        }
+
+    def generate_topology(self, config=None):
+        return {
+            'po': {
+                'A': {
+                    '_path': ('aaa',),
+                    'a2': ('x',),
+                    'a3': ('..', 'ccc', 'a3')},
+                'B': ('bbb',),
+            },
+            'qo': {
+                'D': {
+                    '_path': (),
+                    'd1': ('aaa', 'd1'),
+                    'd2': ('aaa', 'd2'),
+                    'd3': ('ccc', 'd3')},
+                'E': {
+                    '_path': (),
+                    'e1': ('aaa', 'x'),
+                    'e2': ('bbb', 'e2')}
+            },
+        }
diff --git a/vivarium/core/composition.py b/vivarium/core/composition.py
index 48dc2b63..31316c81 100644
--- a/vivarium/core/composition.py
+++ b/vivarium/core/composition.py
@@ -1,28 +1,38 @@
-import copy
-import csv
+"""
+===========
+Composition
+===========
+
+Helper function for initializing and running experiments.
+"""
+
 import os
-import io
-from typing import Any, Dict, Optional
 import uuid
 
-import numpy as np
+# typing
+from typing import (
+    Any, Dict, Optional, Callable)
+from vivarium.core.types import (
+    Topology, CompositeDict, HierarchyPath)
 
+from vivarium.core.process import Process, Composer
 from vivarium.core.experiment import Experiment
-from vivarium.core.process import (
-    Process,
-    Deriver,
-    Factory,
-    generate_derivers,
-)
 from vivarium.library.dict_utils import (
     deep_merge,
     deep_merge_check,
-    flatten_timeseries,
 )
 
 from vivarium.processes.timeline import TimelineProcess
-from vivarium.processes.nonspatial_environment import NonSpatialEnvironment
-from vivarium.processes.agent_names import AgentNames
+from vivarium.processes.nonspatial_environment import \
+    NonSpatialEnvironment
+
+# toys for testing
+from vivarium.composites.toys import (
+    PoQo,
+    ToyLinearGrowthDeathProcess,
+    ExchangeA,
+    ToyCompartment
+)
 
 REFERENCE_DATA_DIR = os.path.join('vivarium', 'reference_data')
 BASE_OUT_DIR = 'out'
@@ -32,34 +42,31 @@
 COMPOSITE_OUT_DIR = os.path.join(BASE_OUT_DIR, 'composites')
 EXPERIMENT_OUT_DIR = os.path.join(BASE_OUT_DIR, 'experiments')
 
-FACTORY_KEY = '_factory'
-GENERATORS_KEY = FACTORY_KEY  # TODO -- remove all uses of GENERATORS_KEY from dependent libraries
+COMPOSER_KEY = '_composer'
 
 
-######################################################
-# compartment_hierarchy_experiment loading functions #
-######################################################
+# loading functions for compartment_hierarchy_experiment
 
-
-def add_generator_to_tree(
-        processes,
-        topology,
-        generator_type,
-        generator_config: Optional[Dict[str, Any]] = None,
-        generator_topology: Optional[Dict[str, Any]] = None
-):
-    generator_config = generator_config or {}
-    generator_topology = generator_topology or {}
+def add_processes_to_hierarchy(
+        processes: Dict[str, Process],
+        topology: Topology,
+        composer_type: Callable,
+        composer_config: Optional[Dict[str, Any]] = None,
+        composer_topology: Optional[Dict[str, Any]] = None
+) -> CompositeDict:
+    """Use a composer to add processes and topology"""
+    composer_config = composer_config or {}
+    composer_topology = composer_topology or {}
 
     # generate
-    composite = generator_type(generator_config)
-    network = composite.generate()
-    new_processes = network['processes']
-    new_topology = network['topology']
+    composer = composer_type(composer_config)
+    composite = composer.generate()
+    new_processes = composite['processes']
+    new_topology = composite['topology']
 
     # replace process names that already exist
     replace_name = []
-    for name, p in new_processes.items():
+    for name in new_processes.keys():
         if name in processes:
             replace_name.append(name)
     for name in replace_name:
@@ -70,38 +77,45 @@ def add_generator_to_tree(
         del new_topology[name]
 
     # extend processes and topology list
-    new_topology = deep_merge(new_topology, generator_topology)
+    new_topology = deep_merge(new_topology, composer_topology)
     deep_merge(topology, new_topology)
     deep_merge_check(processes, new_processes)
 
-    return processes, topology
+    return {
+        'processes': processes,
+        'topology': topology}
 
 
-def initialize_hierarchy(hierarchy):
-    processes = {}
-    topology = {}
+def initialize_hierarchy(
+        hierarchy: Dict[str, Any]
+) -> CompositeDict:
+    """Make a hierarchy with initialized processes"""
+    processes: Dict[str, Process] = {}
+    topology: Topology = {}
     for key, level in hierarchy.items():
-        if key == FACTORY_KEY:
+        if key == COMPOSER_KEY:
             if isinstance(level, list):
-                for generator_def in level:
-                    add_generator_to_tree(
+                for composer_def in level:
+                    add_processes_to_hierarchy(
                         processes=processes,
                         topology=topology,
-                        generator_type=generator_def['type'],
-                        generator_config=generator_def.get('config', {}),
-                        generator_topology=generator_def.get('topology', {}))
+                        composer_type=composer_def['type'],
+                        composer_config=composer_def.get(
+                            'config', {}),
+                        composer_topology=composer_def.get(
+                            'topology', {}))
 
             elif isinstance(level, dict):
-                add_generator_to_tree(
+                add_processes_to_hierarchy(
                     processes=processes,
                     topology=topology,
-                    generator_type=level['type'],
-                    generator_config=level.get('config', {}),
-                    generator_topology=level.get('topology', {}))
+                    composer_type=level['type'],
+                    composer_config=level.get('config', {}),
+                    composer_topology=level.get('topology', {}))
         else:
-            network = initialize_hierarchy(level)
-            deep_merge_check(processes, {key: network['processes']})
-            deep_merge(topology, {key: network['topology']})
+            composite = initialize_hierarchy(level)
+            deep_merge_check(processes, {key: composite['processes']})
+            deep_merge(topology, {key: composite['topology']})
 
     return {
         'processes': processes,
@@ -123,32 +137,31 @@ def initialize_hierarchy(hierarchy):
 
 
 def compose_experiment(
-        hierarchy=None,
-        settings=None,
-        initial_state=None,
-):
+        hierarchy: Dict[str, Any],
+        settings: Optional[Dict[str, Any]] = None,
+        initial_state: Optional[Dict[str, Any]] = None,
+) -> Experiment:
     """Make an experiment with arbitrarily embedded compartments.
 
-    Arguments:
-        hierarchy: an embedded dictionary mapping the desired topology of
-            nodes, with generators declared under a global FACTORY_KEY that maps
-            to a dictionary with 'type', 'config' , and 'topology' for the
-            processes in the generator. Generators include lone processes.
+    Args:
+        hierarchy: an embedded dictionary mapping the desired topology
+          of nodes, with composers declared under a global COMPOSER_KEY
+          that maps to a dictionary with 'type', 'config', and
+          'topology' for the processes in the Composer. Composers
+          include lone processes.
         settings: experiment configuration settings.
         initial_state: is the initial_state.
 
     Returns:
         The experiment.
     """
-    if settings is None:
-        settings = {}
-    if initial_state is None:
-        initial_state = {}
+    settings = settings or {}
+    initial_state = initial_state or {}
 
     # make the hierarchy
-    network = initialize_hierarchy(hierarchy)
-    processes = network['processes']
-    topology = network['topology']
+    composite = initialize_hierarchy(hierarchy)
+    processes = composite['processes']
+    topology = composite['topology']
 
     experiment_config = {
         'processes': processes,
@@ -161,308 +174,131 @@ def compose_experiment(
     return Experiment(experiment_config)
 
 
-##################################################
-# agent_environment_experiment loading functions #
-##################################################
+# experiment loading functions
+def add_timeline(
+        processes: Dict[str, Process],
+        topology: Dict[str, Topology],
+        timeline: Dict[str, Any],
+) -> None:
+    """Add a timeline process to a composite
 
-
-def make_agents(
-        agent_ids,
-        compartment,
-        config=None
-):
-    """ Generate agents for each id
-    Arguments:
-    * **agent_ids**: list of agent ids
-    * **compartment**: the compartment of the agent type
-    * **config**: compartment configuration
-    Returns:
-        the initialized agent processes and topology
+        Args:
+            processes (dict): with {`process_name`: Process}
+            topology (dict): with {`process_name`: topology mapping}
+            timeline (dict): with `timeline` key. An optional `paths` key
+                overrides the topology mapping from (port: path).
     """
-    if config is None:
-        config = {}
-    processes = {}
-    topology = {}
-    for agent_id in agent_ids:
-        agent_config = copy.deepcopy(config)
-        agent = compartment.generate(dict(
-            agent_config,
-            agent_id=agent_id))
-
-        # save processes and topology
-        processes[agent_id] = agent['processes']
-        topology[agent_id] = agent['topology']
-
-    return {
-        'processes': processes,
-        'topology': topology}
-
-
-def make_agent_ids(agents_config):
-    """ Add agent ids to an agent config """
-    agent_ids = []
-    remove = []
-    for idx, config in enumerate(agents_config):
-        number = config.get('number', 1)
-        if number < 1:
-            remove.append(idx)
-            continue
-        if 'name' in config:
-            name = config['name']
-            if number > 1:
-                new_agent_ids = [name + '_' + str(num) for num in range(number)]
-            else:
-                new_agent_ids = [name]
-        else:
-            new_agent_ids = [str(uuid.uuid1()) for num in range(number)]
-        config['ids'] = new_agent_ids
-        agent_ids.extend(new_agent_ids)
-    # remove configs with number = 0
-    for index in sorted(remove, reverse=True):
-        del agents_config[index]
-
-    return agent_ids
-
-
-def agent_environment_experiment(
-        agents_config=None,
-        environment_config=None,
-        initial_state=None,
-        initial_agent_state=None,
-        settings=None,
-        invoke=None,
-):
-    """Make an experiment with agents placed in an environment under an `agents` store.
-
-    Arguments:
-        agents_config: the configuration for the agents
-        environment_config: the configuration for the environment
-        initial_state: the initial state for the hierarchy, with
-            environment at the top level.
-        initial_agent_state: the initial_state for agents, set under each agent_id.
-        settings: settings include **emitter** and **agent_names**. May
-            also include **timeline**.
-        invoke: is the invoke object for calling updates.
-
-    Returns:
-        The experiment.
+    timeline_paths = timeline.get('paths', {})
+    timeline_process = TimelineProcess(timeline)
+    processes.update({
+        TimelineProcess.name: timeline_process})
+
+    # add topology
+    timeline_topology = {
+        port: timeline_paths.get(port, (port,))
+        for port in timeline_process.ports()}
+    topology.update({TimelineProcess.name: timeline_topology})
+
+
+def add_environment(
+        processes: Dict[str, Any],
+        topology: Dict[str, Any],
+        environment: Dict[str, Any],
+) -> None:
+    """Add a NonSpatialEnvironment to a composite
+
+        Args:
+            processes (dict): with {`process_name`: Process}
+            topology (dict): with {`process_name`: topology mapping}
+            environment (dict): with `environment` key. An optional `paths` key
+                overrides the topology mapping from (port: path).
     """
-    if settings is None:
-        settings = {}
-    if initial_state is None:
-        initial_state = {}
-
-    # initialize the agents
-    agents = {
-        'processes': {},
-        'topology': {}}
-    if isinstance(agents_config, dict):
-        # dict with single agent config
-        agent_type = agents_config['type']
-        agent_ids = agents_config['ids']
-        agent_compartment = agent_type(agents_config['config'])
-        agents = make_agents(agent_ids, agent_compartment, agents_config['config'])
-
-        if initial_agent_state:
-            initial_state['agents'] = {
-                agent_id: initial_agent_state
-                for agent_id in agent_ids}
-
-    elif isinstance(agents_config, list):
-        # list with multiple agent configurations
-        for config in agents_config:
-            agent_type = config['type']
-            agent_ids = config['ids']
-            agent_compartment = agent_type(config['config'])
-            new_agents = make_agents(agent_ids, agent_compartment, config['config'])
-            deep_merge(agents['processes'], new_agents['processes'])
-            deep_merge(agents['topology'], new_agents['topology'])
-
-            if initial_agent_state:
-                if 'agents' not in initial_state:
-                    initial_state['agents'] = {}
-                initial_state['agents'].update({
-                    agent_id: initial_agent_state
-                    for agent_id in agent_ids})
-
-    if 'agents' in initial_state and 'diffusion' in environment_config['config']:
-        environment_config[
-            'config']['diffusion']['agents'] = initial_state['agents']
-
-    # initialize the environment
-    environment_type = environment_config['type']
-    environment_compartment = environment_type(environment_config['config'])
-
-    # combine processes and topologies
-    network = environment_compartment.generate()
-    processes = network['processes']
-    topology = network['topology']
-    processes['agents'] = agents['processes']
-    topology['agents'] = agents['topology']
-
-    if settings.get('agent_names') is True:
-        # add an AgentNames processes, which saves the current agent names
-        # to as store at the top level of the hierarchy
-        processes['agent_names'] = AgentNames({})
-        topology['agent_names'] = {
-            'agents': ('agents',),
-            'names': ('names',)
-        }
-
-    if 'timeline' in settings:
-        # Adding a timeline to a process requires the timeline argument
-        # in settings to have a 'timeline' key. An optional 'paths' key
-        # overrides the topology mapping from {port: path}.
-        timeline = settings['timeline']
-        timeline_process = TimelineProcess(timeline)
-        timeline_paths = timeline.get('paths', {})
-        processes.update({'timeline_process': timeline_process})
-        timeline_ports = {
-            port: timeline_paths.get(port, (port,))
-            for port in timeline_process.ports()}
-        topology.update({'timeline_process': timeline_ports})
 
-    experiment_config = {
-        'processes': processes,
-        'topology': topology,
-        'initial_state': initial_state,
-    }
-
-    if invoke:
-        experiment_config['invoke'] = invoke
-    for key, setting in settings.items():
-        if key in experiment_config_keys:
-            experiment_config[key] = setting
-    return Experiment(experiment_config)
+    overide_topology = environment.get('paths', {})
+    environment_process = NonSpatialEnvironment(environment)
+    processes.update({
+        environment_process.name: environment_process})
 
+    # add topology
+    environment_topology = environment_process.generate_topology({})[
+        environment_process.name]
+    environment_topology = deep_merge(
+        environment_topology,
+        overide_topology)
+    topology.update({
+        environment_process.name: environment_topology})
 
-###########################
-# basic loading functions #
-###########################
 
 def process_in_experiment(
-        process,
-        settings=None,
-        initial_state=None,
-):
-    if settings is None:
-        settings = {}
-    if initial_state is None:
-        initial_state = {}
-
-    timeline = settings.get('timeline', {})
-    environment = settings.get('environment', {})
-    paths = settings.get('topology', {})
+        process: Process,
+        settings: Dict[str, Any] = None,
+        initial_state: Dict[str, Any] = None,
+) -> Experiment:
+    """put a Process in an Experiment
+
+    Args:
+        process (Process): the Process to put into the Experiment
+        settings (dict): a dictionary of optional configuration options,
+            keywords include timeline, environment, and topology that
+            add to or modify the Process.
+        initial_state (dict): initial state to overrides the defaults.
+
+    Returns:
+        an **Experiment**
+    """
+    settings = settings or {}
+    initial_state = initial_state or {}
 
+    override_topology = settings.get('topology', {})
     processes = {'process': process}
     topology = {
         'process': {
-            port: paths.get(port, (port,)) for port in process.ports_schema().keys()}}
-
-    if timeline:
-        # Adding a timeline to a process requires the timeline argument
-        # in settings to have a 'timeline' key. An optional 'paths' key
-        # overrides the topology mapping from {port: path}.
-        timeline_process = TimelineProcess(timeline)
-        timeline_paths = timeline.get('paths', {})
-        processes.update({'timeline_process': timeline_process})
-        timeline_ports = {
-            port: timeline_paths.get(port, (port,))
-            for port in timeline_process.ports()}
-        topology.update({'timeline_process': timeline_ports})
-
-    if environment:
-        # Environment requires ports for external, fields, dimensions,
-        # and global (for location)
-        ports = environment.get(
-            'ports',
-            {
-                'external': ('external',),
-                'fields': ('fields',),
-                'dimensions': ('dimensions',),
-                'global': ('global',),
-            }
-        )
-        environment_process = NonSpatialEnvironment(environment)
-        processes.update({'environment_process': environment_process})
-        topology.update({
-            'environment_process': {
-                'external': ports['external'],
-                'fields': ports['fields'],
-                'dimensions': ports['dimensions'],
-                'global': ports['global'],
-            },
-        })
-
-    # add derivers
-    derivers = generate_derivers(processes, topology)
-    processes = deep_merge(processes, derivers['processes'])
-    topology = deep_merge(topology, derivers['topology'])
+            port: override_topology.get(port, (port,))
+            for port in process.ports_schema().keys()}}
 
-    # initialize the experiment
-    experiment_config = {
+    composite = {
         'processes': processes,
-        'topology': topology,
-        'initial_state': initial_state}
-    for key, setting in settings.items():
-        if key in experiment_config_keys:
-            experiment_config[key] = setting
-    return Experiment(experiment_config)
+        'topology': topology}
 
+    return composite_in_experiment(
+        composite=composite,
+        settings=settings,
+        initial_state=initial_state
+    )
 
-def compartment_in_experiment(
-        compartment,
-        settings=None,
-        initial_state=None,
-):
-    if settings is None:
-        settings = {}
-    if initial_state is None:
-        initial_state = {}
 
-    compartment_config = settings.get('compartment', {})
-    timeline = settings.get('timeline')
-    environment = settings.get('environment')
-    outer_path = settings.get('outer_path', tuple())
+def composite_in_experiment(
+        composite: CompositeDict,
+        settings: Dict[str, Any] = None,
+        initial_state: Dict[str, Any] = None,
+) -> Experiment:
+    """put a Composite in an Experiment
+
+    Args:
+        composite: the :term:`Composite` object.
+        settings (dict): a dictionary of options, including composite_config
+            for configuring the composite. Additional  keywords include
+            timeline, environment, and outer_path.
+        initial_state (dict): initial state to overrides the defaults.
+
+    Returns:
+        an :term:`Experiment`
+    """
+    settings = settings or {}
+    initial_state = initial_state or {}
 
-    network = compartment.generate(compartment_config, outer_path)
-    processes = network['processes']
-    topology = network['topology']
+    processes = composite['processes']
+    topology = composite['topology']
 
+    timeline = settings.get('timeline', None)
     if timeline is not None:
-        # Adding a timeline to a compartment requires the timeline argument
-        # in settings to have a 'timeline' key. An optional 'paths' key
-        # overrides the topology mapping from {port: path}.
-        timeline_process = TimelineProcess(timeline)
-        timeline_paths = timeline.get('paths', {})
-        processes.update({'timeline_process': timeline_process})
-        timeline_ports = {
-            port: timeline_paths.get(port, (port,))
-            for port in timeline_process.ports()}
-        topology.update({'timeline_process': timeline_ports})
+        add_timeline(processes, topology, timeline)
+        all_times = [t[0] for t in timeline['timeline']]
+        settings['total_time'] = max(all_times)
 
+    environment = settings.get('environment', None)
     if environment is not None:
-        # Environment requires ports for external, fields, dimensions,
-        # and global (for location)
-        ports = environment.get(
-            'ports',
-            {
-                'external': ('external',),
-                'fields': ('fields',),
-                'dimensions': ('dimensions',),
-                'global': ('global',),
-            }
-        )
-        environment_process = NonSpatialEnvironment(environment)
-        processes.update({'environment_process': environment_process})
-        topology.update({
-            'environment_process': {
-                'external': ports['external'],
-                'fields': ports['fields'],
-                'dimensions': ports['dimensions'],
-                'global': ports['global'],
-            },
-        })
+        add_environment(processes, topology, environment)
 
     # initialize the experiment
     experiment_config = {
@@ -475,52 +311,92 @@ def compartment_in_experiment(
     return Experiment(experiment_config)
 
 
-########################
-# simulation functions #
-########################
+def composer_in_experiment(
+        composer: Composer,
+        settings: Dict[str, Any] = None,
+        initial_state: Dict[str, Any] = None,
+        config: Dict[str, Any] = None,
+        outer_path: HierarchyPath = (),
+) -> Experiment:
+    """generate a Composite in an Experiment
+
+    Args:
+        composer: a :term:`Composer` object.
+        settings (dict): a dictionary of options, including composite_config
+            for configuring the composite. Additional  keywords include
+            timeline, environment, and outer_path.
+        initial_state (dict): initial state to overrides the defaults.
+        config: updates values in composer's config
+        outer_path: path to the processes and topology
+
+    Returns:
+        an :term:`Experiment`
+    """
+    composite = composer.generate(config, outer_path)
+    return composite_in_experiment(
+        composite=composite,
+        settings=settings,
+        initial_state=initial_state,
+    )
+
+
+# simulate helper functions
 
-def simulate_process(process, settings: Optional[Dict[str, Any]] = None):
+def simulate_process(
+        process: Process,
+        settings: Optional[Dict[str, Any]] = None
+) -> Dict:
+    """Put a :term:`Process` in an :term:`Experiment` and simulate it"""
     settings = settings or {}
     experiment = process_in_experiment(process, settings)
     return simulate_experiment(experiment, settings)
 
 
-def simulate_process_in_experiment(
-        process,
-        settings: Optional[Dict[str, Any]] = None):
+def simulate_composite(
+        composite: CompositeDict,
+        settings: Optional[Dict[str, Any]] = None
+) -> Dict:
+    """Put a :term:`Composite` in an :term:`Experiment` and simulate it"""
     settings = settings or {}
-    experiment = process_in_experiment(process, settings)
+    experiment = composite_in_experiment(composite, settings)
     return simulate_experiment(experiment, settings)
 
 
-def simulate_compartment_in_experiment(
-        compartment,
-        settings: Optional[Dict[str, Any]] = None):
+def simulate_composer(
+        composer: Composer,
+        settings: Optional[Dict[str, Any]] = None,
+        config: Optional[Dict[str, Any]] = None,
+) -> Dict:
+    """Initialize a :term:`Composer` in an :term:`Experiment` and simulate it"""
     settings = settings or {}
-    experiment = compartment_in_experiment(compartment, settings)
+    config = config or {}
+    outer_path = settings.get('outer_path', tuple())
+    composite = composer.generate(config, outer_path)
+    initial_state = settings.get('initial_state')
+    experiment = composite_in_experiment(
+        composite,
+        settings=settings,
+        initial_state=initial_state)
     return simulate_experiment(experiment, settings)
 
 
 def simulate_experiment(
-        experiment,
-        settings: Optional[Dict[str, Any]] = None):
-    '''
-    run an experiment simulation
-        Requires:
+        experiment: Experiment,
+        settings: Optional[Dict[str, Any]] = None
+) -> Dict:
+    """Simulate an term:`Experiment`
+
+        Args:
         - a configured experiment
 
-    Returns:
-        - a timeseries of variables from all ports.
-        - if 'return_raw_data' is True, it returns the raw data instead
-    '''
+        Returns:
+            - a timeseries of variables from all ports.
+            - if 'return_raw_data' is True, it returns the raw data instead
+    """
     settings = settings or {}
     total_time = settings.get('total_time', 10)
     return_raw_data = settings.get('return_raw_data', False)
 
-    if 'timeline' in settings:
-        all_times = [t[0] for t in settings['timeline']['timeline']]
-        total_time = max(all_times)
-
     # run simulation
     experiment.update(total_time)
     experiment.end()
@@ -528,485 +404,140 @@ def simulate_experiment(
     # return data from emitter
     if return_raw_data:
         return experiment.emitter.get_data()
-    else:
-        return experiment.emitter.get_timeseries()
-
-
-########################
-# timeseries functions #
-########################
-def agent_timeseries_from_data(data, agents_key='cells'):
-    timeseries = {}
-    for time, all_states in data.items():
-        agent_data = all_states[agents_key]
-        for agent_id, ports in agent_data.items():
-            if agent_id not in timeseries:
-                timeseries[agent_id] = {}
-            for port_id, states in ports.items():
-                if port_id not in timeseries[agent_id]:
-                    timeseries[agent_id][port_id] = {}
-                for state_id, state in states.items():
-                    if state_id not in timeseries[agent_id][port_id]:
-                        timeseries[agent_id][port_id][state_id] = []
-                    timeseries[agent_id][port_id][state_id].append(state)
-    return timeseries
-
-
-def save_timeseries(timeseries, out_dir='out'):
-    flattened = flatten_timeseries(timeseries)
-    save_flat_timeseries(flattened, out_dir)
-
-
-def save_flat_timeseries(timeseries, out_dir='out'):
-    '''Save a timeseries as a CSV in out_dir'''
-    rows = np.transpose(list(timeseries.values())).tolist()  # type: Any
-    with open(os.path.join(out_dir, 'simulation_data.csv'), 'w') as f:
-        writer = csv.writer(f)
-        writer.writerow(timeseries.keys())
-        writer.writerows(rows)
-
-
-def load_timeseries(path_to_csv):
-    '''Load a timeseries saved as a CSV using save_timeseries.
-
-    The timeseries is returned in flattened form.
-    '''
-    with io.open(path_to_csv, 'r', newline='') as f:
-        reader = csv.DictReader(f)
-        timeseries = {}
-        for row in reader:
-            for header, elem in row.items():
-                if elem == '':
-                    elem = None
-                if elem is not None:
-                    elem = float(elem)
-                timeseries.setdefault(header, []).append(elem)
-    return timeseries
-
-
-def timeseries_to_ndarrays(timeseries, keys=None):
-    '''After filtering by keys, convert timeseries to dict of ndarrays
+    return experiment.emitter.get_timeseries()
 
-    Returns:
-        dict: Mapping from timeseries variables to an ndarray of the
-            variable values.
-    '''
-    if keys is None:
-        keys = timeseries.keys()
-    return {
-        key: np.array(timeseries[key], dtype=np.float) for key in keys}
-
-
-def _prepare_timeseries_for_comparison(
-    timeseries1, timeseries2, keys=None,
-    required_frac_checked=0.9,
-):
-    '''Prepare two timeseries for comparison
-
-    Arguments:
-        timeseries1: One timeseries. Must be flattened and include times
-            under the 'time' key.
-        timeseries2: The other timeseries. Same requirements as
-            timeseries1.
-        keys: Keys of the timeseries whose values will be checked for
-            correlation. If not specified, all keys present in both
-            timeseries are used.
-        required_frac_checked: The required fraction of timepoints in a
-            timeseries that must be checked. If this requirement is not
-            satisfied, which might occur if the two timeseries share few
-            timepoints, the test wll fail.
 
-    Returns:
-        A tuple of an ndarray for each of the two timeseries and a list of
-        the keys for the rows of the arrays. Each ndarray has a row for
-        each key, in the order of keys. The ndarrays have only the
-        columns corresponding to the timepoints common to both
-        timeseries.
-
-    Raises:
-        AssertionError: If a correlation is strictly below the
-            threshold or if too few timepoints are common to both
-            timeseries.
-    '''
-    if 'time' not in timeseries1 or 'time' not in timeseries2:
-        raise AssertionError('Both timeseries must have key "time"')
-    if keys is None:
-        keys = set(timeseries1.keys()) & set(timeseries2.keys())
-    else:
-        if 'time' not in keys:
-            keys.append('time')
-    keys = list(keys)
-    shared_times = set(timeseries1['time']) & set(timeseries2['time'])
-    frac_timepoints_checked = (
-        len(shared_times)
-        / min(len(timeseries1['time']), len(timeseries2['time']))
-    )
-    if frac_timepoints_checked < required_frac_checked:
-        raise AssertionError(
-            'The timeseries share too few timepoints: '
-            '{} < {}'.format(
-                frac_timepoints_checked, required_frac_checked)
-        )
-    masked = []
-    for ts in (timeseries1, timeseries2):
-        arrays_dict = timeseries_to_ndarrays(ts, keys)
-        arrays_dict_shared_times = {}
-        for key, array in arrays_dict.items():
-            # Filters out times after data ends
-            times_for_array = arrays_dict['time'][:len(array)]
-            arrays_dict_shared_times[key] = array[
-                np.isin(times_for_array, list(shared_times))]
-        masked.append(arrays_dict_shared_times)
-    return (
-        masked[0],
-        masked[1],
-        keys,
-    )
+# Tests
+def test_process_in_experiment() -> None:
+    process = ExchangeA()
+    experiment = process_in_experiment(process)
+    assert experiment.processes['process'] is process
 
 
-def assert_timeseries_correlated(
-    timeseries1, timeseries2, keys=None,
-    default_threshold=(1 - 1e-10), thresholds: Optional[Dict[str, Any]] = None,
-    required_frac_checked=0.9,
-):
-    '''Check that two timeseries are correlated.
-
-    Uses a Pearson correlation coefficient. Only the data from
-    timepoints common to both timeseries are compared.
-
-    Arguments:
-        timeseries1: One timeseries. Must be flattened and include times
-            under the 'time' key.
-        timeseries2: The other timeseries. Same requirements as
-            timeseries1.
-        keys: Keys of the timeseries whose values will be checked for
-            correlation. If not specified, all keys present in both
-            timeseries are used.
-        default_threshold: The threshold correlation coefficient to use
-            when a threshold is not specified in thresholds.
-        thresholds: Dictionary of key-value pairs where the key is a key
-            in both timeseries and the value is the threshold
-            correlation coefficient to use when checking that key
-        required_frac_checked: The required fraction of timepoints in a
-            timeseries that must be checked. If this requirement is not
-            satisfied, which might occur if the two timeseries share few
-            timepoints, the test wll fail. This is also the fraction of
-            timepoints for each variable that must be non-nan in both
-            timeseries. Note that the denominator of this fraction is
-            the number of shared timepoints that are non-nan in either
-            of the timeseries.
-
-    Raises:
-        AssertionError: If a correlation is strictly below the
-            threshold or if too few timepoints are common to both
-            timeseries.
-    '''
-    thresholds = thresholds or {}
-    arrays1, arrays2, keys = _prepare_timeseries_for_comparison(
-        timeseries1, timeseries2, keys, required_frac_checked)
-    for key in keys:
-        both_nan = np.isnan(arrays1[key]) & np.isnan(arrays2[key])
-        valid_indices = ~(
-            np.isnan(arrays1[key]) | np.isnan(arrays2[key]))
-        frac_checked = valid_indices.sum() / (~both_nan).sum()
-        if frac_checked < required_frac_checked:
-            raise AssertionError(
-                'Timeseries share too few non-nan values for variable '
-                '{}: {} < {}'.format(
-                    key, frac_checked, required_frac_checked
-                )
-            )
-        corrcoef = np.corrcoef(
-            arrays1[key][valid_indices],
-            arrays2[key][valid_indices],
-        )[0][1]
-        threshold = thresholds.get(key, default_threshold)
-        if corrcoef < threshold:
-            raise AssertionError(
-                'The correlation coefficient for '
-                '{} is too small: {} < {}'.format(
-                    key, corrcoef, threshold)
-            )
-
-
-def assert_timeseries_close(
-    timeseries1, timeseries2, keys=None,
-    default_tolerance=(1 - 1e-10), tolerances: Optional[Dict[str, Any]] = None,
-    required_frac_checked=0.9,
-):
-    '''Check that two timeseries are similar.
-
-    Ensures that each pair of data points between the two timeseries are
-    within a tolerance of each other, after filtering out timepoints not
-    common to both timeseries.
-
-    Arguments:
-        timeseries1: One timeseries. Must be flattened and include times
-            under the 'time' key.
-        timeseries2: The other timeseries. Same requirements as
-            timeseries1.
-        keys: Keys of the timeseries whose values will be checked for
-            correlation. If not specified, all keys present in both
-            timeseries are used.
-        default_tolerance: The tolerance to use when not specified in
-            tolerances.
-        tolerances: Dictionary of key-value pairs where the key is a key
-            in both timeseries and the value is the tolerance to use
-            when checking that key.
-        required_frac_checked: The required fraction of timepoints in a
-            timeseries that must be checked. If this requirement is not
-            satisfied, which might occur if the two timeseries share few
-            timepoints, the test wll fail.
-
-    Raises:
-        AssertionError: If a pair of data points have a difference
-            strictly above the tolerance threshold or if too few
-            timepoints are common to both timeseries.
-    '''
-    tolerances = tolerances or {}
-    arrays1, arrays2, keys = _prepare_timeseries_for_comparison(
-        timeseries1, timeseries2, keys, required_frac_checked)
-    for key in keys:
-        tolerance = tolerances.get(key, default_tolerance)
-        close_mask = np.isclose(
-            arrays1[key], arrays2[key], atol=tolerance, equal_nan=True)
-        if not np.all(close_mask):
-            print('Timeseries 1:', arrays1[key][~close_mask])
-            print('Timeseries 2:', arrays2[key][~close_mask])
-            raise AssertionError(
-                'The data for {} differed by more than {}'.format(
-                    key, tolerance)
-            )
-
-
-#########
-# Tests #
-#########
-
-class ToyLinearGrowthDeathProcess(Process):
-
-    name = 'toy_linear_growth_death'
-
-    GROWTH_RATE = 1.0
-    THRESHOLD = 6.0
-
-    def __init__(self, initial_parameters: Optional[Dict[str, Any]] = None):
-        initial_parameters = initial_parameters or {}
-        self.targets = initial_parameters.get('targets')
-        super(ToyLinearGrowthDeathProcess, self).__init__(initial_parameters)
-
-    def ports_schema(self):
-        return {
-            'global': {
-                'mass': {
-                    '_default': 1.0,
-                    '_emit': True}},
-            'targets': {
-                target: {
-                    '_default': None}
-                for target in self.targets}}
-
-    def next_update(self, timestep, states):
-        mass = states['global']['mass']
-        mass_grown = (
-            ToyLinearGrowthDeathProcess.GROWTH_RATE * timestep)
-        update = {
-            'global': {'mass': mass_grown},
-        }
-        if mass > ToyLinearGrowthDeathProcess.THRESHOLD:
-            update['global'] = {
-                '_delete': [(target,) for target in self.targets]}
-
-        return update
-
-
-class TestSimulateProcess:
-
-    def test_process_deletion(self):
-        '''Check that processes are successfully deleted'''
-        process = ToyLinearGrowthDeathProcess({'targets': ['process']})
-        settings = {
-            'emit_step': 1,
-            'topology': {
-                'global': ('global',),
-                'targets': tuple()}}
-
-        timeseries = simulate_process(process, settings)
-        expected_masses = [
-            # Mass stops increasing the iteration after mass > 5 because
-            # cell dies
-            1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 7.0, 7.0]
-        masses = timeseries['global']['mass']
-        assert masses == expected_masses
-
-
-#################
-# Toy processes #
-#################
-
-class ToyMetabolism(Process):
-    name = 'toy_metabolism'
-
-    def __init__(self, initial_parameters: Optional[Dict[str, Any]] = None):
-        initial_parameters = initial_parameters or {}
-        parameters = {'mass_conversion_rate': 1}
-        parameters.update(initial_parameters)
-        super(ToyMetabolism, self).__init__(parameters)
-
-    def ports_schema(self):
-        ports = {
-            'pool': ['GLC', 'MASS']}
-        return {
-            port_id: {
-                key: {
-                    '_default': 0.0,
-                    '_emit': True}
-                for key in keys}
-            for port_id, keys in ports.items()}
-
-    def next_update(self, timestep, states):
-        update = {}
-        glucose_required = timestep / self.parameters['mass_conversion_rate']
-        if states['pool']['GLC'] >= glucose_required:
-            update = {
-                'pool': {
-                    'GLC': -2,
-                    'MASS': 1}}
-
-        return update
-
-
-class ToyTransport(Process):
-    name = 'toy_transport'
-
-    def __init__(self, initial_parameters: Optional[Dict[str, Any]] = None):
-        initial_parameters = initial_parameters or {}
-        parameters = {'intake_rate': 2}
-        parameters.update(initial_parameters)
-        super(ToyTransport, self).__init__(parameters)
-
-    def ports_schema(self):
-        ports = {
-            'external': ['GLC'],
-            'internal': ['GLC']}
-        return {
-            port_id: {
-                key: {
-                    '_default': 0.0,
-                    '_emit': True}
-                for key in keys}
-            for port_id, keys in ports.items()}
-
-    def next_update(self, timestep, states):
-        update = {}
-        intake = timestep * self.parameters['intake_rate']
-        if states['external']['GLC'] >= intake:
-            update = {
-                'external': {'GLC': -2, 'MASS': 1},
-                'internal': {'GLC': 2}}
-
-        return update
-
-
-class ToyDeriveVolume(Deriver):
-    name = 'toy_derive_volume'
-
-    def __init__(self, initial_parameters: Optional[Dict[str, Any]] = None):
-        _ = initial_parameters  # ignore initial_parameters
-        parameters: Dict[str, Any] = {}
-        super(ToyDeriveVolume, self).__init__(parameters)
-
-    def ports_schema(self):
-        ports = {
-            'compartment': ['MASS', 'DENSITY', 'VOLUME']}
-        return {
-            port_id: {
-                key: {
-                    '_updater': 'set' if key == 'VOLUME' else 'accumulate',
-                    '_default': 0.0,
-                    '_emit': True}
-                for key in keys}
-            for port_id, keys in ports.items()}
-
-    def next_update(self, timestep, states):
-        volume = states['compartment']['MASS'] / states['compartment']['DENSITY']
-        update = {
-            'compartment': {'VOLUME': volume}}
-
-        return update
-
-
-class ToyDeath(Process):
-    name = 'toy_death'
-
-    def __init__(self, initial_parameters: Optional[Dict[str, Any]] = None):
-        initial_parameters = initial_parameters or {}
-        self.targets = initial_parameters.get('targets', [])
-        super(ToyDeath, self).__init__({})
-
-    def ports_schema(self):
-        return {
-            'compartment': {
-                'VOLUME': {
-                    '_default': 0.0,
-                    '_emit': True}},
-            'global': {
-                target: {
-                    '_default': None}
-                for target in self.targets}}
-
-    def next_update(self, timestep, states):
-        volume = states['compartment']['VOLUME']
-        update = {}
-
-        if volume > 1.0:
-            # kill the cell
-            update = {
-                'global': {
-                    '_delete': [
-                        (target,)
-                        for target in self.targets]}}
-
-        return update
-
-
-class ToyCompartment(Factory):
-    '''
-    a toy compartment for testing
-
-    '''
-    def __init__(self, config):
-        super(ToyCompartment, self).__init__(config)
-
-    def generate_processes(self, config):
-        return {
-            'metabolism': ToyMetabolism(
-                {'mass_conversion_rate': 0.5}),  # example of overriding default parameters
-            'transport': ToyTransport(),
-            'death': ToyDeath({'targets': [
-                'metabolism',
-                'transport']}),
-            'external_volume': ToyDeriveVolume(),
-            'internal_volume': ToyDeriveVolume()
-        }
-
-    def generate_topology(self, config):
-        return{
-            'metabolism': {
-                'pool': ('cytoplasm',)},
-            'transport': {
-                'external': ('periplasm',),
-                'internal': ('cytoplasm',)},
-            'death': {
-                'global': tuple(),
-                'compartment': ('cytoplasm',)},
-            'external_volume': {
-                'compartment': ('periplasm',)},
-            'internal_volume': {
-                'compartment': ('cytoplasm',)}}
-
-
-def test_compartment():
+def test_process_in_experiment_timeline() -> None:
+    timeline = [
+        (0, {('internal', 'A'): 0}),
+        (1, {('internal', 'A'): 1}),
+    ]
+    process = ExchangeA()
+    experiment = process_in_experiment(
+        process,
+        settings={
+            'timeline': {'timeline': timeline}})
+    assert experiment.processes['process'] is process
+    assert isinstance(
+        experiment.processes['timeline'],
+        TimelineProcess)
+
+
+def test_process_in_experiment_environment() -> None:
+    process = ExchangeA()
+    experiment = process_in_experiment(
+        process,
+        settings={'environment': {}})
+
+    assert experiment.processes['process'] is process
+    assert isinstance(
+        experiment.processes['nonspatial_environment'],
+        NonSpatialEnvironment)
+
+
+def test_composer_in_experiment() -> None:
+    composer = PoQo({
+        '_schema': {'po': {'A': {'a1': {'_emit': True}}}}})
+
+    timeline = [
+        (0, {('aaa', 'a1'): 50}),
+        (5, {('aaa', 'a1'): 10}),
+        (15, {})]
+    settings = {
+        'environment': {},
+        'timeline': {'timeline': timeline}}
+    experiment = composer_in_experiment(composer=composer, settings=settings)
+
+    assert isinstance(
+        experiment.processes['nonspatial_environment'],
+        NonSpatialEnvironment)
+
+    output = simulate_experiment(experiment, settings)
+
+    # check that timeline worked
+    assert output['aaa']['a1'][6] == 10
+    assert settings['total_time'] == 15
+    assert len(output['aaa']['a1']) == 16
+
+
+def test_composite_in_experiment() -> None:
+    composer = PoQo({
+        '_schema': {'po': {'A': {'a2': {'_emit': True}}}}})
+    composite = composer.generate()
+    settings: Dict = {}
+    experiment = composite_in_experiment(
+        composite=composite,
+        settings=settings)
+    assert experiment.processes['po'] is composite['processes']['po']
+
+    output = simulate_composite(composite, settings)
+    assert output['aaa']['x'][-1] == -90
+
+
+def test_compose_experiment() -> None:
+    hierarchy = {
+        COMPOSER_KEY:
+            {
+                'type': ExchangeA,
+                'config': {},
+            }}
+    experiment = compose_experiment(
+        hierarchy=hierarchy,
+        settings={'experiment_name': 'test'})
+    experiment.update(10)
+
+
+def test_replace_names() -> None:
+    """if processes on the same level have the same name, add uuid string"""
+    # declare the hierarchy
+    hierarchy = {
+        COMPOSER_KEY: [
+            {
+                'type': ExchangeA,
+                'config': {},
+            },
+            {
+                'type': ExchangeA,
+                'config': {},
+            }
+        ]}
+
+    # configure experiment
+    experiment = compose_experiment(
+        hierarchy=hierarchy)
+    process_names = list(experiment.processes.keys())
+
+    assert len(process_names) == 2
+    # process_names[1] has added string
+    assert process_names[0] in process_names[1]
+
+
+def test_process_deletion() -> None:
+    """Check that processes are successfully deleted"""
+    process = ToyLinearGrowthDeathProcess({'targets': ['process']})
+    settings = {
+        'emit_step': 1,
+        'topology': {
+            'global': ('global',),
+            'targets': tuple()}}
+
+    output = simulate_process(process, settings)
+    expected_masses = [
+        # Mass stops increasing the iteration after mass > 5 because
+        # cell dies
+        1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 7.0, 7.0]
+    masses = output['global']['mass']
+    assert masses == expected_masses
+
+
+def test_composer() -> Dict:
     toy_compartment = ToyCompartment({})
     settings = {
         'total_time': 10,
@@ -1019,10 +550,14 @@ def test_compartment():
                 'GLC': 0,
                 'MASS': 3,
                 'DENSITY': 10}}}
-    return simulate_compartment_in_experiment(toy_compartment, settings)
+    return simulate_composer(toy_compartment, settings)
 
 
 if __name__ == '__main__':
-    TestSimulateProcess().test_process_deletion()
-    timeseries = test_compartment()
-    print(timeseries)
+    # test_process_deletion()
+    # test_composer()
+    # test_replace_names()
+    # test_process_in_experiment_environment()
+    # test_composer_in_experiment()
+
+    test_composite_in_experiment()  # pragma: no cover
diff --git a/vivarium/core/control.py b/vivarium/core/control.py
index c9ca75b4..6b2183c5 100644
--- a/vivarium/core/control.py
+++ b/vivarium/core/control.py
@@ -3,45 +3,55 @@
 Experiment Control
 ===================
 
-Run experiments and analyses from the command line
+Run experiments and analyses from the command line.
 """
 
 import os
 import argparse
 import copy
 
+# typing
+from typing import (
+    Any, Dict, Optional, Union, Sequence)
+from vivarium.core.types import OutputDict
+
 from vivarium.core.experiment import timestamp
 from vivarium.core.composition import (
-    simulate_compartment_in_experiment,
-    ToyCompartment,
+    test_composer,
     BASE_OUT_DIR,
 )
+from vivarium.composites.toys import ToyCompartment
 
 from vivarium.plots.simulation_output import plot_simulation_output
 
 
-def make_dir(out_dir):
-    os.makedirs(out_dir, exist_ok=True)
+def make_dir(out_dir: str = 'out') -> None:
+    os.makedirs(out_dir, exist_ok=True)  # pragma: no cover
+
 
+class Control:
+    """ Control experiments from the command line
 
-class Control(object):
+    Load experiments, plots, and workflows in this Control class,
+    and trigger them from the command line
+    """
 
     def __init__(
             self,
-            out_dir=None,
-            experiments=None,
-            compartments=None,
-            plots=None,
-            workflows=None,
-            args=None,
-    ):
-        if workflows is None:
-            workflows = {}
-        if experiments is None:
-            experiments = {}
+            out_dir: Optional[str] = None,
+            experiments: Optional[Dict[str, Any]] = None,
+            composers: Optional[Dict[str, Any]] = None,
+            plots: Optional[Dict[str, Any]] = None,
+            workflows: Optional[Dict[str, Any]] = None,
+            args: Optional[Sequence[str]] = None,
+    ) -> None:
+        workflows = workflows or {}
+        experiments = experiments or {}
+        plots = plots or {}
+        workflows = workflows or {}
 
         self.experiments_library = experiments
-        self.compartments_library = compartments
+        self.compposers_library = composers
         self.plots_library = plots
         self.workflows_library = workflows
         self.output_data = None
@@ -62,7 +72,9 @@ def __init__(
             workflow_id = str(self.args.workflow)
             self.run_workflow(workflow_id)
 
-    def parse_args(self, args=None):
+    def parse_args(
+            self, args: Optional[Sequence[str]] = None
+    ) -> argparse.Namespace:
         parser = argparse.ArgumentParser(
             description='command line control of experiments'
         )
@@ -80,15 +92,19 @@ def parse_args(self, args=None):
         )
         return parser.parse_args(args)
 
-    def run_experiment(self, experiment_id):
+    def run_experiment(self, experiment_id: str) -> OutputDict:
         experiment = self.experiments_library[experiment_id]
         if isinstance(experiment, dict):
             kwargs = experiment.get('kwargs', {})
             return experiment['experiment'](**kwargs)
-        elif callable(experiment):
-            return experiment()
+        return experiment()
 
-    def run_one_plot(self, plot_id, data, out_dir=None):
+    def run_one_plot(
+            self,
+            plot_id: str,
+            data: OutputDict,
+            out_dir: Optional[str] = None,
+    ) -> None:
         data_copy = copy.deepcopy(data)
         plot_spec = self.plots_library[plot_id]
         if isinstance(plot_spec, dict):
@@ -106,10 +122,12 @@ def run_one_plot(self, plot_id, data, out_dir=None):
                 data=data_copy,
                 out_dir=out_dir)
 
-    def run_plots(self, plot_ids, data, out_dir=None):
-        if out_dir is None:
-            out_dir = self.out_dir
-        make_dir(out_dir)
+    def run_plots(
+            self,
+            plot_ids: Union[list, str],
+            data: OutputDict,
+            out_dir: Optional[str] = None,
+    ) -> None:
 
         if isinstance(plot_ids, list):
             for plot_id in plot_ids:
@@ -117,7 +135,7 @@ def run_plots(self, plot_ids, data, out_dir=None):
         else:
             self.run_one_plot(plot_ids, data, out_dir=out_dir)
 
-    def run_workflow(self, workflow_id):
+    def run_workflow(self, workflow_id: str) -> None:
         workflow = self.workflows_library[workflow_id]
         experiment_id = workflow['experiment']
         plot_ids = workflow['plots']
@@ -136,27 +154,18 @@ def run_workflow(self, workflow_id):
 
 
 # testing
-def toy_experiment():
-    toy_compartment = ToyCompartment({})
-    settings = {
-        'total_time': 10,
-        'initial_state': {
-            'periplasm': {
-                'GLC': 20,
-                'MASS': 100,
-                'DENSITY': 10},
-            'cytoplasm': {
-                'GLC': 0,
-                'MASS': 3,
-                'DENSITY': 10}}}
-    return simulate_compartment_in_experiment(toy_compartment, settings)
-
-
-def toy_plot(data, config=None, out_dir='out'):
+
+def toy_plot(
+        data: OutputDict,
+        config: Optional[Dict] = None,
+        out_dir: Optional[str] = 'out'
+) -> None:
+    del config  # unused
     plot_simulation_output(data, out_dir=out_dir)
 
 
-def toy_control(args=None):
+def toy_control(
+        args: Optional[Sequence[str]] = None) -> Control:
     """ a toy example of control
 
     To run:
@@ -166,9 +175,9 @@ def toy_control(args=None):
         # put in dictionary with name
         '1': {
             'name': 'exp_1',
-            'experiment': toy_experiment},
+            'experiment': test_composer},
         # map to function to run as is
-        '2': toy_experiment,
+        '2': test_composer,
     }
     plot_library = {
         # put in dictionary with config
@@ -178,20 +187,24 @@ def toy_control(args=None):
         # map to function to run as is
         '2': toy_plot
     }
-    compartment_library = {
+    composers_library = {
         'agent': ToyCompartment,
     }
     workflow_library = {
         '1': {
             'name': 'test_workflow',
             'experiment': '1',
-            'plots': ['1']}
+            'plots': ['1']},
+        '2': {
+            'name': 'test_workflow',
+            'experiment': '1',
+            'plots': '2'}
     }
 
     control = Control(
         out_dir=os.path.join('out', 'control_test'),
         experiments=experiment_library,
-        compartments=compartment_library,
+        composers=composers_library,
         plots=plot_library,
         workflows=workflow_library,
         args=args,
@@ -200,10 +213,12 @@ def toy_control(args=None):
     return control
 
 
-def test_control():
-    control = toy_control(args=[])
+def test_control() -> None:
+    toy_control(args=['-w', '1'])
+    toy_control(args=['-w', '2'])
+    control = toy_control(args=['-e', '2'])
     control.run_workflow('1')
 
 
 if __name__ == '__main__':
-    toy_control()
+    test_control()  # pragma: no cover
diff --git a/vivarium/core/emitter.py b/vivarium/core/emitter.py
index 6a9041ab..9a1add31 100644
--- a/vivarium/core/emitter.py
+++ b/vivarium/core/emitter.py
@@ -1,10 +1,19 @@
-"""Emitters
 """
+========
+Emitters
+========
+
+Emitters log configuration data and time-series data somewhere.
+"""
+
+from __future__ import annotations
 
-from pymongo import MongoClient
 import json
+from typing import Any, Dict, List, Optional, Tuple
 from urllib.parse import quote_plus
 
+from pymongo import MongoClient
+
 from vivarium.library.units import remove_units
 from vivarium.library.dict_utils import (
     value_in_embedded_dict,
@@ -12,7 +21,6 @@
 )
 from vivarium.core.process import deserialize_value
 
-
 HISTORY_INDEXES = [
     'time',
     'type',
@@ -27,37 +35,30 @@
 SECRETS_PATH = 'secrets.json'
 
 
-def create_indexes(table, columns):
-    '''Create all of the necessary indexes for the given table name.'''
-    for column in columns:
-        table.create_index(column)
+def get_emitter(config: Optional[Dict[str, str]]) -> Emitter:
+    """Construct an Emitter using the provided config.
 
-
-def get_emitter(config):
-    '''Get an emitter based on the provided config.
-
-    The available emitter type names and their classes are:
+    The available Emitter type names and their classes are:
 
     * ``database``: :py:class:`DatabaseEmitter`
     * ``null``: :py:class:`NullEmitter`
+    * ``print``: :py:class:`Emitter`, prints to stdout
     * ``timeseries``: :py:class:`TimeSeriesEmitter`
 
     Arguments:
-        config (dict): Requires three keys:
-            * type: Type of emitter ('database' for a database emitter).
-            * emitter: Any configuration the emitter type requires to initialize.
-            * keys: A list of state keys to emit for each state label.
+        config (dict): Required keys:
+            * 'type': The emitter type name (e.g. 'database').
 
     Returns:
-        Emitter: An instantiated emitter.
-    '''
+        Emitter: A new Emitter instance.
+    """
 
     if config is None:
-        config = {'type': 'print'}
+        config = {}
     emitter_type = config.get('type', 'print')
 
     if emitter_type == 'database':
-        emitter = DatabaseEmitter(config)
+        emitter: Emitter = DatabaseEmitter(config)
     elif emitter_type == 'null':
         emitter = NullEmitter(config)
     elif emitter_type == 'timeseries':
@@ -68,103 +69,108 @@ def get_emitter(config):
     return emitter
 
 
-def configure_emitter(config, processes, topology):
-    emitter_config = config.get('emitter', {})
-    emitter_config['experiment_id'] = config.get('experiment_id')
-    emitter_config['simulation_id'] = config.get('simulation_id')
-    return get_emitter(emitter_config)
-
-
-def path_timeseries_from_data(data):
+def path_timeseries_from_data(data: dict) -> dict:
+    """Does something with timeseries data."""
     embedded_timeseries = timeseries_from_data(data)
     return path_timeseries_from_embedded_timeseries(embedded_timeseries)
 
 
-def path_timeseries_from_embedded_timeseries(embedded_timeseries):
+def path_timeseries_from_embedded_timeseries(embedded_timeseries: dict) -> dict:
+    """Does something with timeseries data."""
     times_vector = embedded_timeseries['time']
-    path_timeseries = make_path_dict({key: value for key, value in embedded_timeseries.items() if key != 'time'})
+    path_timeseries = make_path_dict(
+        {key: val for key, val in embedded_timeseries.items() if key != 'time'})
     path_timeseries['time'] = times_vector
     return path_timeseries
 
 
-def time_indexed_timeseries_from_data(data):
+def time_indexed_timeseries_from_data(data: Dict[float, Any]) -> dict:
+    """Does something with timeseries data."""
     times_vector = list(data.keys())
-    embedded_timeseries = {}
-    for time_index, (time, value) in enumerate(data.items()):
+    embedded_timeseries: dict = {}
+    for time_index, value in enumerate(data.values()):
         if isinstance(value, dict):
-            embedded_timeseries = value_in_embedded_dict(value, embedded_timeseries, time_index)
-        else:
-            pass
+            embedded_timeseries = value_in_embedded_dict(
+                value, embedded_timeseries, time_index)
     embedded_timeseries['time'] = times_vector
     return embedded_timeseries
 
 
-def timeseries_from_data(data):
+def timeseries_from_data(data: dict) -> dict:
+    """Does something with timeseries data."""
     times_vector = list(data.keys())
-    embedded_timeseries = {}
-    for time, value in data.items():
+    embedded_timeseries: dict = {}
+    for value in data.values():
         if isinstance(value, dict):
-            embedded_timeseries = value_in_embedded_dict(value, embedded_timeseries)
-        else:
-            pass
+            embedded_timeseries = value_in_embedded_dict(
+                value, embedded_timeseries)
 
     embedded_timeseries['time'] = times_vector
     return embedded_timeseries
 
 
-class Emitter(object):
-    '''
-    Emit data to terminal
-    '''
-    def __init__(self, config):
+class Emitter:
+    """
+    Emit data to stdout
+    """
+    def __init__(self, config: Dict[str, str]) -> None:
         self.config = config
 
-    def emit(self, data):
+    def emit(self, data: Dict[str, Any]) -> None:
         print(data)
 
-    def get_data(self):
+    def get_data(self) -> dict:
         return {}
 
-    def get_data_deserialized(self):
+    def get_data_deserialized(self) -> Any:
         return deserialize_value(self.get_data())
 
-    def get_data_unitless(self):
+    def get_data_unitless(self) -> Any:
         return remove_units(self.get_data_deserialized())
 
-    def get_path_timeseries(self):
+    def get_path_timeseries(self) -> dict:
         return path_timeseries_from_data(self.get_data_deserialized())
 
-    def get_timeseries(self):
+    def get_timeseries(self) -> dict:
         return timeseries_from_data(self.get_data_deserialized())
 
 
 class NullEmitter(Emitter):
-    '''
+    """
     Don't emit anything
-    '''
-    def emit(self, data):
+    """
+    def emit(self, data: Dict[str, Any]) -> None:
         pass
 
 
 class TimeSeriesEmitter(Emitter):
+    """
+    Accumulate the timeseries history portion of the "emitted" data to a table
+    in RAM.
+    """
 
-    def __init__(self, config):
+    def __init__(self, config: Dict[str, str]) -> None:
         super().__init__(config)
-        self.saved_data = {}
-
-    def emit(self, data):
-        # save history data
+        self.saved_data: Dict[float, Dict[str, Any]] = {}
+
+    def emit(self, data: Dict[str, Any]) -> None:
+        """
+        Emit the timeseries history portion of `data`, which is
+        `data['data'] if data['table'] == 'history'` and put it at
+        `data['data']['time']` in the history.
+        """
         if data['table'] == 'history':
             emit_data = data['data']
-            time = emit_data.pop('time')
+            time = emit_data.pop('time')  # TODO: OK to modify caller's dict?
             self.saved_data[time] = emit_data
 
-    def get_data(self):
+    def get_data(self) -> dict:
+        """ Return the accumulated timeseries history of "emitted" data. """
         return self.saved_data
 
 
 class DatabaseEmitter(Emitter):
-    '''
+    """
     Emit data to a mongoDB database
 
     Example:
@@ -175,41 +181,52 @@ class DatabaseEmitter(Emitter):
     ... }
     >>> # The line below works only if you have to have 27017 open locally
     >>> # emitter = DatabaseEmitter(config)
-    '''
+    """
     client = None
     default_host = 'localhost:27017'
 
-    def __init__(self, config):
+    @classmethod
+    def create_indexes(cls, table: Any, columns: List[str]) -> None:
+        """Create the listed column indexes for the given DB table."""
+        for column in columns:
+            table.create_index(column)
+
+    def __init__(self, config: Dict[str, str]) -> None:
+        """config may have 'host' and 'database' items."""
+        # TODO(jerry): Will this create the DB tables or does it fail if they
+        #  don't already exist?
         super().__init__(config)
         self.experiment_id = config.get('experiment_id')
 
         # create singleton instance of mongo client
         if DatabaseEmitter.client is None:
-            DatabaseEmitter.client = MongoClient(config.get('host', self.default_host))
+            DatabaseEmitter.client = MongoClient(
+                config.get('host', self.default_host))
 
         self.db = getattr(self.client, config.get('database', 'simulations'))
         self.history = getattr(self.db, 'history')
         self.configuration = getattr(self.db, 'configuration')
         self.phylogeny = getattr(self.db, 'phylogeny')
-        create_indexes(self.history, HISTORY_INDEXES)
-        create_indexes(self.configuration, CONFIGURATION_INDEXES)
-        create_indexes(self.phylogeny, CONFIGURATION_INDEXES)
-
-    def emit(self, data_config):
-        data = data_config['data']
-        data.update({
-            'experiment_id': self.experiment_id})
-        table = getattr(self.db, data_config['table'])
-        table.insert_one(data)
-
-    def get_data(self):
+        self.create_indexes(self.history, HISTORY_INDEXES)
+        self.create_indexes(self.configuration, CONFIGURATION_INDEXES)
+        self.create_indexes(self.phylogeny, CONFIGURATION_INDEXES)
+
+    def emit(self, data: Dict[str, Any]) -> None:
+        emit_data: dict = data['data']
+        emit_data['experiment_id'] = self.experiment_id
+        # TODO(jerry): Should this pop('table') from emit_data?
+        table = getattr(self.db, emit_data['table'])
+        table.insert_one(emit_data)
+
+    def get_data(self) -> dict:
         return get_history_data_db(self.history, self.experiment_id)
 
 
-def get_history_data_db(history_collection, experiment_id):
+def get_history_data_db(
+        history_collection: Any, experiment_id: Any) -> Dict[float, dict]:
+    """Query MongoDB for history data."""
     query = {'experiment_id': experiment_id}
-    raw_data = history_collection.find(query)
-    raw_data = list(raw_data)
+    raw_data = list(history_collection.find(query))
     data = {}
     for datum in raw_data:
         time = datum['time']
@@ -219,7 +236,8 @@ def get_history_data_db(history_collection, experiment_id):
     return data
 
 
-def get_atlas_client(secrets_path):
+def get_atlas_client(secrets_path: str) -> Any:
+    """Open a MongoDB client using the named secrets config JSON file."""
     with open(secrets_path, 'r') as f:
         secrets = json.load(f)
     emitter_config = get_atlas_database_emitter_config(
@@ -229,12 +247,14 @@ def get_atlas_client(secrets_path):
     return client[emitter_config['database']]
 
 
-def get_local_client(host, port, database_name):
+def get_local_client(host: str, port: Any, database_name: str) -> Any:
+    """Open a MongoDB client onto the given host, port, and DB."""
     client = MongoClient('{}:{}'.format(host, port))
     return client[database_name]
 
 
-def data_from_database(experiment_id, client):
+def data_from_database(experiment_id: str, client: Any) -> Tuple[dict, Any]:
+    """Fetch something from a MongoDB."""
     # Retrieve environment config
     config_collection = client.configuration
     environment_config = config_collection.find_one({
@@ -287,7 +307,9 @@ def data_from_database(experiment_id, client):
     return data, environment_config
 
 
-def data_to_database(data, environment_config, client):
+def data_to_database(
+        data: Dict[float, dict], environment_config: Any, client: Any) -> Any:
+    """Insert something into a MongoDB."""
     history_collection = client.history
     reshaped_data = []
     for time, timepoint_data in data.items():
@@ -306,15 +328,16 @@ def data_to_database(data, environment_config, client):
 
 
 def get_atlas_database_emitter_config(
-    username, password, cluster_subdomain, database
-):
+    username: str, password: str, cluster_subdomain: Any, database: str
+) -> Dict[str, Any]:
+    """Construct an Emitter config for a MongoDB on the Atlas service."""
     username = quote_plus(username)
     password = quote_plus(password)
     database = quote_plus(database)
 
     uri = (
         "mongodb+srv://{}:{}@{}.mongodb.net/"
-        + "?retryWrites=true&w=majority"
+        "?retryWrites=true&w=majority"
     ).format(username, password, cluster_subdomain)
     return {
         'type': 'database',
@@ -323,7 +346,9 @@ def get_atlas_database_emitter_config(
     }
 
 
-def emit_environment_config(environment_config, emitter):
+def emit_environment_config(
+        environment_config: Dict[str, Any], emitter: Emitter) -> None:
+    """Emit a multibody bounds environment config to the given Emitter."""
     config = {
         'bounds': environment_config['multibody']['bounds'],
         'type': 'environment_config',
diff --git a/vivarium/core/experiment.py b/vivarium/core/experiment.py
index 8ad3642f..6ee9c3e3 100644
--- a/vivarium/core/experiment.py
+++ b/vivarium/core/experiment.py
@@ -1,49 +1,40 @@
-'''
-==========================================
-Experiment and Store Classes
-==========================================
-'''
+"""
+==========
+Experiment
+==========
+
+Experiment runs the simulation.
+"""
 
 import os
-import copy
+import logging as log
+import pprint
+from multiprocessing import Pool
+from typing import Any, Dict
 import math
 import datetime
 import time as clock
 import uuid
 
-import numpy as np
-import logging as log
-
-import pprint
-
-from multiprocessing import Pool
-from typing import Any, Dict, Tuple, Union
-
 from pymongo.errors import PyMongoError
 
-from vivarium.library.units import units, Quantity
-from vivarium.library.dict_utils import (
-    deep_merge,
-    MULTI_UPDATE_KEY,
-)
+from vivarium.composites.toys import Proton, Electron, Sine, PoQo
+from vivarium.core.store import hierarchy_depth, Store
 from vivarium.core.emitter import get_emitter
 from vivarium.core.process import (
-    Composite,
+    HierarchyPath,
     Deriver,
     Process,
     ParallelProcess,
-    serialize_dictionary,
+    serialize_value,
+    Composer,
 )
 from vivarium.library.topology import (
-    get_in, delete_in, assoc_path,
-    without, inverse_topology,
-    dict_to_paths
-)
-from vivarium.core.registry import (
-    divider_registry,
-    updater_registry,
-    serializer_registry,
+    delete_in,
+    assoc_path,
+    inverse_topology
 )
+from vivarium.library.units import units
 
 pretty = pprint.PrettyPrinter(indent=2)
 
@@ -56,10 +47,6 @@ def pf(x):
     return pretty.pformat(x)
 
 
-EMPTY_UPDATES = None, None, None
-
-Path = Union[Tuple[str, ...], Tuple[()]]
-
 log.basicConfig(level=os.environ.get("LOGLEVEL", log.WARNING))
 
 
@@ -69,1082 +56,6 @@ def starts_with(a_list, sub):
         for i, el in enumerate(sub))
 
 
-# Store
-def key_for_value(d, looking):
-    found = None
-    for key, value in d.items():
-        if looking == value:
-            found = key
-            break
-    return found
-
-
-def depth(tree, path=()):
-    '''
-    Create a mapping of every path in the tree to the node living at
-    that path in the tree.
-    '''
-
-    base = {}
-
-    for key, inner in tree.items():
-        down = tuple(path + (key,))
-        if isinstance(inner, dict):
-            base.update(depth(inner, down))
-        else:
-            base[down] = inner
-
-    return base
-
-
-def schema_for(port, keys, initial_state, default=0.0, updater='accumulate'):
-    return {
-        key: {
-            '_default': initial_state.get(
-                port, {}).get(key, default),
-            '_updater': updater}
-        for key in keys}
-
-
-def topology_path(topology, path):
-    pass
-
-
-def always_true(_):
-    return True
-
-
-def identity(y):
-    return y
-
-
-class Store(object):
-    """Holds a subset of the overall model state
-
-    The total state of the model can be broken down into :term:`stores`,
-    each of which is represented by an instance of this `Store` class.
-    The store's state is a set of :term:`variables`, each of which is
-    defined by a set of :term:`schema key-value pairs`. The valid schema
-    keys are listed in :py:attr:`schema_keys`, and they are:
-
-    * **_default** (Type should match the variable value): The default
-      value of the variable.
-    * **_updater** (:py:class:`str`): The name of the :term:`updater` to
-      use. By default this is ``accumulate``.
-    * **_divider** (:py:class:`str`): The name of the :term:`divider` to
-      use. Note that ``_divider`` is not included in the ``schema_keys``
-      set because it can be applied to any node in the hierarchy, not
-      just leaves (which represent variables).
-    * **_value** (Type should match the variable value): The current
-      value of the variable. This is ``None`` by default.
-    * **_properties** (:py:class:`dict`): Extra properties of the
-      variable that don't have a specific schema key. This is an empty
-      dictionary by default.
-    * **_emit** (:py:class:`bool`): Whether to emit the variable to the
-      :term:`emitter`. This is ``False`` by default.
-    """
-    schema_keys = {
-        '_default',
-        '_updater',
-        '_value',
-        '_properties',
-        '_emit',
-        '_serializer',
-    }
-
-    def __init__(self, config, outer=None, source=None):
-        self.outer = outer
-        self.inner = {}
-        self.subschema = {}
-        self.subtopology = {}
-        self.properties = {}
-        self.default = None
-        self.updater = None
-        self.value = None
-        self.units = None
-        self.divider = None
-        self.emit = False
-        self.sources = {}
-        self.leaf = False
-        self.serializer = None
-        self.topology = {}
-
-        self.apply_config(config, source)
-
-    def check_default(self, new_default):
-        defaults_equal = False
-        if self.default is not None:
-            self_default_comp = self.default
-            new_default_comp = new_default
-            if isinstance(self_default_comp, np.ndarray):
-                self_default_comp = self.default.tolist()
-            if isinstance(new_default_comp, np.ndarray):
-                new_default_comp = self.default.tolist()
-            defaults_equal = self_default_comp == new_default_comp
-        if defaults_equal:
-            if (
-                not isinstance(new_default, np.ndarray)
-                and not isinstance(self.default, np.ndarray)
-                and new_default == 0
-                and self.default != 0
-            ):
-                log.debug('_default schema conflict: {} and {}. selecting {}'.format(
-                    self.default, new_default, self.default))
-                return self.default
-            log.debug('_default schema conflict: {} and {}. selecting {}'.format(
-                self.default, new_default, new_default))
-        return new_default
-
-    def check_value(self, new_value):
-        if self.value is not None and new_value != self.value:
-            raise Exception('_value schema conflict: {} and {}'.format(new_value, self.value))
-        return new_value
-
-    def merge_subtopology(self, subtopology):
-        self.subtopology = deep_merge(self.subtopology, subtopology)
-
-    def apply_subschema_config(self, subschema):
-        self.subschema = deep_merge(
-            self.subschema,
-            subschema)
-
-    def apply_config(self, config, source=None):
-        '''
-        Expand the tree by applying additional config.
-
-        Special keys for the config are:
-
-        * _default - Default value for this node.
-        * _properties - An arbitrary map of keys to values. This can be used
-          for any properties which exist outside of the operation of the
-          tree (like mass or energy).
-        * _updater - Which updater to use. Default is 'accumulate' which
-          adds the new value to the existing value, but 'set' is common
-          as well. You can also provide your own function here instead
-          of a string key into the updater library.
-        * _emit - whether or not to emit the values under this point in the tree.
-        * _divider - What to do with this node when division happens.
-          Default behavior is to leave it alone, but you can also pass
-          'split' here, or a function of your choosing. If you need
-          other values from the state you need to supply a dictionary
-          here containing the updater and the topology for where the
-          other state values are coming from. This has two keys:
-
-            * divider - a function that takes the existing value and any
-              values supplied from the adjoining topology.
-            * topology - a mapping of keys to paths where the value for
-              those keys will be found. This will be passed in as the
-              second argument to the divider function.
-
-        * _subschema/* - If this node was declared to house an unbounded set
-          of related states, the schema for these states is held in this
-          nodes subschema and applied whenever new subkeys are added
-          here.
-        * _subtopology - The subschema is informed by the subtopology to
-          map the process perspective to the actual tree structure.
-        '''
-
-        if '*' in config:
-            self.apply_subschema_config(config['*'])
-            config = without(config, '*')
-
-        if '_subschema' in config:
-            if source:
-                self.sources[source] = config['_subschema']
-            self.apply_subschema_config(config['_subschema'])
-            config = without(config, '_subschema')
-
-        if '_subtopology' in config:
-            self.merge_subtopology(config['_subtopology'])
-            config = without(config, '_subtopology')
-
-        if '_topology' in config:
-            self.topology = config['_topology']
-            config = without(config, '_topology')
-
-        if '_divider' in config:
-            self.divider = config['_divider']
-            if isinstance(self.divider, str):
-                self.divider = divider_registry.access(self.divider)
-            if isinstance(self.divider, dict) and isinstance(self.divider['divider'], str):
-                self.divider['divider'] = divider_registry.access(self.divider['divider'])
-            config = without(config, '_divider')
-
-        if self.schema_keys & set(config.keys()):
-            if self.inner:
-                raise Exception('trying to assign leaf values to a branch at: {}'.format(self.path_for()))
-            self.leaf = True
-
-            if '_units' in config:
-                self.units = config['_units']
-                self.serializer = serializer_registry.access('units')
-
-            if '_serializer' in config:
-                self.serializer = config['_serializer']
-                if isinstance(self.serializer, str):
-                    self.serializer = serializer_registry.access(self.serializer)
-
-            if '_default' in config:
-                self.default = self.check_default(config.get('_default'))
-                if isinstance(self.default, Quantity):
-                    self.units = self.units or self.default.units
-                    self.serializer = self.serializer or serializer_registry.access('units')
-                elif isinstance(self.default, list) and len(self.default) > 0 and isinstance(self.default[0], Quantity):
-                    self.units = self.units or self.default[0].units
-                    self.serializer = self.serializer or serializer_registry.access('units')
-                elif isinstance(self.default, np.ndarray):
-                    self.serializer = self.serializer or serializer_registry.access('numpy')
-
-            if '_value' in config:
-                self.value = self.check_value(config.get('_value'))
-                if isinstance(self.value, Quantity):
-                    self.units = self.value.units
-
-            self.updater = config.get(
-                '_updater',
-                self.updater or 'accumulate',
-            )
-
-            self.properties = deep_merge(
-                self.properties,
-                config.get('_properties', {}))
-
-            self.emit = config.get('_emit', self.emit)
-
-            if source:
-                self.sources[source] = config
-
-        else:
-            if self.leaf and config:
-                raise Exception('trying to assign create inner for leaf node: {}'.format(self.path_for()))
-
-            # self.value = None
-
-            for key, child in config.items():
-                if key not in self.inner:
-                    self.inner[key] = Store(child, outer=self, source=source)
-                else:
-                    self.inner[key].apply_config(child, source=source)
-
-    def get_updater(self, update):
-        updater = self.updater
-        if isinstance(update, dict) and '_updater' in update:
-            updater = update['_updater']
-
-        if isinstance(updater, str):
-            updater = updater_registry.access(updater)
-        return updater
-
-    def get_config(self, sources=False):
-        '''
-        Assemble a dictionary representation of the config for this node.
-        A desired property is that the node can be exactly recreated by
-        applying the resulting config to an empty node again.
-        '''
-
-        config = {}
-
-        if self.properties:
-            config['_properties'] = self.properties
-        if self.subschema:
-            config['_subschema'] = self.subschema
-        if self.subtopology:
-            config['_subtopology'] = self.subtopology
-        if self.divider:
-            config['_divider'] = self.divider
-
-        if sources and self.sources:
-            config['_sources'] = self.sources
-
-        if self.inner:
-            child_config = {
-                key: child.get_config(sources)
-                for key, child in self.inner.items()}
-            config.update(child_config)
-
-        else:
-            config.update({
-                '_default': self.default,
-                '_value': self.value})
-            if self.updater:
-                config['_updater'] = self.updater
-            if self.units:
-                config['_units'] = self.units
-            if self.emit:
-                config['_emit'] = self.emit
-
-        return config
-
-    def top(self):
-        '''
-        Find the top of this tree.
-        '''
-
-        if self.outer:
-            return self.outer.top()
-        else:
-            return self
-
-    def path_for(self):
-        '''
-        Find the path to this node.
-        '''
-
-        if self.outer:
-            key = key_for_value(self.outer.inner, self)
-            above = self.outer.path_for()
-            return above + (key,)
-        else:
-            return tuple()
-
-    def get_value(self, condition=None, f=None):
-        '''
-        Pull the values out of the tree in a structure symmetrical to the tree.
-        '''
-
-        if self.inner:
-            if condition is None:
-                condition = always_true
-
-            if f is None:
-                f = identity
-
-            return {
-                key: f(child.get_value(condition, f))
-                for key, child in self.inner.items()
-                if condition(child)}
-        else:
-            if self.subschema:
-                return {}
-            else:
-                return self.value
-
-    def get_path(self, path):
-        '''
-        Get the node at the given path relative to this node.
-        '''
-
-        if path:
-            step = path[0]
-            if step == '..':
-                child = self.outer
-            else:
-                child = self.inner.get(step)
-
-            if child:
-                return child.get_path(path[1:])
-            else:
-                # TODO: more handling for bad paths?
-                # TODO: check deleted?
-                return None
-        else:
-            return self
-
-    def get_paths(self, paths):
-        return {
-            key: self.get_path(path)
-            for key, path in paths.items()}
-
-    def get_values(self, paths):
-        return {
-            key: self.get_in(path)
-            for key, path in paths.items()}
-
-    def get_in(self, path):
-        return self.get_path(path).get_value()
-
-    def get_template(self, template):
-        """
-        Pass in a template dict with None for each value you want to
-        retrieve from the tree!
-        """
-
-        state = {}
-        for key, value in template.items():
-            child = self.inner[key]
-            if value is None:
-                state[key] = child.get_value()
-            else:
-                state[key] = child.get_template(value)
-        return state
-
-    def emit_data(self):
-        data = {}
-        if self.inner:
-            for key, child in self.inner.items():
-                child_data = child.emit_data()
-                if child_data is not None or child_data == 0:
-                    data[key] = child_data
-            return data
-        else:
-            if self.emit:
-                if self.serializer:
-                    if isinstance(self.value, list) and self.units:
-                        return self.serializer.serialize([v.to(self.units) for v in self.value])
-                    elif self.units:
-                        return self.serializer.serialize(self.value.to(self.units))
-                    else:
-                        return self.serializer.serialize(self.value)
-                elif isinstance(self.value, Process):
-                    return self.value.pull_data()
-                else:
-                    if self.units:
-                        return self.value.to(self.units).magnitude
-                    else:
-                        return self.value
-
-    def delete_path(self, path):
-        '''
-        Delete the subtree at the given path.
-        '''
-
-        if not path:
-            self.inner = {}
-            self.value = None
-            return self
-        else:
-            target = self.get_path(path[:-1])
-            remove = path[-1]
-            if remove in target.inner:
-                lost = target.inner[remove]
-                del target.inner[remove]
-                return lost
-
-    def divide_value(self):
-        '''
-        Apply the divider for each node to the value in that node to
-        assemble two parallel divided states of this subtree.
-        '''
-
-        if self.divider:
-            # divider is either a function or a dict with topology
-            if isinstance(self.divider, dict):
-                divider = self.divider['divider']
-                topology = self.divider['topology']
-                state = self.outer.get_values(topology)
-                return divider(self.get_value(), state)
-            else:
-                return self.divider(self.get_value())
-        elif self.inner:
-            daughters = [{}, {}]
-            for key, child in self.inner.items():
-                division = child.divide_value()
-                if division:
-                    for daughter, divide in zip(daughters, division):
-                        daughter[key] = divide
-            return daughters
-
-    def reduce(self, reducer, initial=None):
-        '''
-        Call the reducer on each node accumulating over the result.
-        '''
-
-        value = initial
-
-        for path, node in self.depth():
-            value = reducer(value, path, node)
-        return value
-
-    def set_value(self, value):
-        '''
-        Set the value for the given tree elements directly instead of using
-        the updaters from their nodes.
-        '''
-
-        if self.inner or self.subschema:
-            for child, inner_value in value.items():
-                if child not in self.inner:
-                    if self.subschema:
-                        self.inner[child] = Store(self.subschema, self)
-                    else:
-                        pass
-
-                        # TODO: continue to ignore extra keys?
-                        # print("setting value that doesn't exist in tree {} {}".format(
-                        #     child, inner_value))
-
-                if child in self.inner:
-                    self.inner[child].set_value(inner_value)
-        else:
-            self.value = value
-
-    def apply_defaults(self):
-        '''
-        If value is None, set to default.
-        '''
-        if self.inner:
-            for child in self.inner.values():
-                child.apply_defaults()
-        else:
-            if self.value is None:
-                self.value = self.default
-
-    def apply_update(self, update, state=None):
-        '''
-        Given an arbitrary update, map all the values in that update
-        to their positions in the tree where they apply, and update
-        these values using each node's `_updater`.
-
-        Arguments:
-            update: The update being applied.
-            process_topology: The topology of the process from which this update was passed.
-            state: The state at the start of the time step.
-            experiment_topology: The topology of the full experiment, with all processes and
-                their port mappings.
-
-        There are five topology update methods, which use the following special update keys:
-
-        * `_add` - Adds states into the subtree, given a list of dicts containing:
-
-            * path - Path to the added state key.
-            * state - The value of the added state.
-
-        * `_move` - Moves a node from a source to a target location in the tree.
-            This uses an update to an :term:`outer` port, which contains both the
-            source and target node locations. Can move multiple nodes according to
-            a list of dicts containing:
-
-            * source - the source path from an outer process port
-            * target - the location where the node will be placed.
-
-        * `_generate` - The value has four keys, which are essentially
-          the arguments to the `generate()` function:
-
-            * path - Path into the tree to generate this subtree.
-            * processes - Tree of processes to generate.
-            * topology - Connections of all the process's `ports_schema()`.
-            * initial_state - Initial state for this new subtree.
-
-        * `_divide` - Performs cell division by constructing two new
-          daughter cells and removing the mother. Takes a dict with two keys:
-
-            * mother - The id of the mother (for removal)
-            * daughters - List of two new daughter generate directives, of the
-                same form as the `_generate` value above.
-
-        * `_delete` - The value here is a list of paths (tuples) to delete from
-          the tree.
-
-
-        Additional special update keys are used for different update operations:
-
-        * `_updater` - Override the default updater with any updater you want.
-
-        * `_reduce` - This allows a reduction over the entire subtree from some
-          point downward. Its three keys are:
-
-            * from - What point to start the reduction.
-            * initial - The initial value of the reduction.
-            * reducer - A function of three arguments, which is called
-              on every node from the `from` point in the tree down:
-
-                * value - The current accumulated value of the reduction.
-                * path - The path to this point in the tree
-                * node - The actual node being visited.
-
-              This function returns the next `value` for the reduction.
-              The result of the reduction will be assigned to this point
-              in the tree.
-        '''
-
-        if isinstance(update, dict) and MULTI_UPDATE_KEY in update:
-            # apply multiple updates to same node
-            multi_update = update[MULTI_UPDATE_KEY]
-            assert isinstance(multi_update, list)
-            for update_value in multi_update:
-                self.apply_update(update_value, state)
-            return EMPTY_UPDATES
-
-        elif self.inner or self.subschema:
-            # Branch update: this node has an inner
-
-            process_updates, topology_updates, deletions = [], [], []
-            update = dict(update)  # avoid mutating the caller's dict
-
-            add_entries = update.pop('_add', None)
-            if add_entries is not None:
-                # add a list of sub-states
-                for added in add_entries:
-                    path = (added['key'],)
-                    added_state = added['state']
-
-                    target = self.establish_path(path, {})
-                    self.apply_subschema_path(path)
-                    target.apply_defaults()
-                    target.set_value(added_state)
-
-            move_entries = update.pop('_move', None)
-            if move_entries is not None:
-                # move nodes from source to target path
-                for move in move_entries:
-                    # get the source node
-                    source_key = move['source']
-                    source_path = (source_key,)
-                    source_node = self.get_path(source_path)
-
-                    # move source node to target path
-                    target_port = move['target']
-                    target_topology = state.topology[target_port]
-                    target_node = state.outer.get_path(target_topology)
-                    target = target_node.add_node(source_path, source_node)
-                    target_path = target.path_for() + source_path
-
-                    # find the paths to all the processes
-                    source_process_paths = source_node.depth(
-                        filter_function=lambda x: isinstance(x.value, Process))
-
-                    # find the process and topology updates
-                    for path, process in source_process_paths:
-                        process_updates.append((
-                            target_path + path, process.value))
-                        topology_updates.append((
-                            target_path + path, process.topology))
-
-                    self.delete_path(source_path)
-
-                    here = self.path_for()
-                    source_absolute = tuple(here + source_path)
-                    deletions.append(source_absolute)
-
-            generate_entries = update.pop('_generate', None)
-            if generate_entries is not None:
-                # generate a list of new processes
-                for generate in generate_entries:
-                    key = generate.get('key')
-                    path = (key,) if key else tuple()
-
-                    here = self.path_for()
-
-                    self.generate(
-                        path,
-                        generate['processes'],
-                        generate['topology'],
-                        generate['initial_state'])
-
-                    root = here + path
-                    process_paths = dict_to_paths(root, generate['processes'])
-                    process_updates.extend(process_paths)
-
-                    topology_paths = [
-                        (root + (key,), topology)
-                        for key, topology in generate['topology'].items()]
-                    topology_updates.extend(topology_paths)
-
-                    self.apply_subschema_path(path)
-                    self.get_path(path).apply_defaults()
-
-            divide = update.pop('_divide', None)
-            if divide is not None:
-                # use dividers to find initial states for daughters
-                mother = divide['mother']
-                daughters = divide['daughters']
-                initial_state = self.inner[mother].get_value(
-                    condition=lambda child: not (isinstance(child.value, Process)),
-                    f=lambda child: copy.deepcopy(child))
-                daughter_states = self.inner[mother].divide_value()
-
-                here = self.path_for()
-
-                for daughter, daughter_state in zip(daughters, daughter_states):
-
-                    # use initial state as default, merge in divided values
-                    initial_state = deep_merge(
-                        initial_state,
-                        daughter_state)
-
-                    daughter_key = daughter['key']
-                    daughter_path = (daughter_key,)
-
-                    self.generate(
-                        daughter_path,
-                        daughter['processes'],
-                        daughter['topology'],
-                        daughter['initial_state'])
-
-                    root = here + daughter_path
-                    process_paths = dict_to_paths(root, daughter['processes'])
-                    process_updates.extend(process_paths)
-
-                    topology_paths = [
-                        (root + (key,), topology)
-                        for key, topology in daughter['topology'].items()]
-                    topology_updates.extend(topology_paths)
-
-                    self.apply_subschema_path(daughter_path)
-                    target = self.get_path(daughter_path)
-                    target.apply_defaults()
-                    target.set_value(initial_state)
-
-                mother_path = (mother,)
-                self.delete_path(mother_path)
-                deletions.append(tuple(here + mother_path))
-
-            delete_keys = update.pop('_delete', None)
-
-            for key, value in update.items():
-                if key in self.inner:
-                    inner = self.inner[key]
-                    inner_topology, inner_processes, inner_deletions = inner.apply_update(
-                        value, state)
-
-                    if inner_topology:
-                        topology_updates.extend(inner_topology)
-                    if inner_processes:
-                        process_updates.extend(inner_processes)
-                    if inner_deletions:
-                        deletions.extend(inner_deletions)
-
-                elif key == '..':
-                    self.outer.apply_update(value, state)
-
-            if delete_keys is not None:
-                # delete a list of paths
-                here = self.path_for()
-                for key in delete_keys:
-                    path = (key,)
-                    self.delete_path(path)
-                    deletions.append(tuple(here + path))
-
-            return topology_updates, process_updates, deletions
-
-        else:
-            # Leaf update: this node has no inner
-
-            updater = self.get_updater(update)
-
-            if isinstance(update, dict) and '_reduce' in update:
-                reduction = update['_reduce']
-                top = self.get_path(reduction.get('from'))
-                update = top.reduce(
-                    reduction['reducer'],
-                    initial=reduction['initial'])
-
-            if isinstance(update, dict) and self.schema_keys & set(update.keys()):
-                if '_updater' in update:
-                    update = update.get('_value', self.default)
-
-            self.value = updater(self.value, update)
-
-            return EMPTY_UPDATES
-
-    def inner_value(self, key):
-        '''
-        Get the value of an inner state
-        '''
-
-        if key in self.inner:
-            return self.inner[key].get_value()
-
-    def topology_state(self, topology):
-        '''
-        Fill in the structure of the given topology with the values at all
-        the paths the topology points at. Essentially, anywhere in the topology
-        that has a tuple path will be filled in with the value at that path.
-
-        This is the inverse function of the standalone `inverse_topology`.
-        '''
-
-        state = {}
-
-        for key, path in topology.items():
-            if key == '*':
-                if isinstance(path, dict):
-                    node, path = self.outer_path(path)
-                    for child, child_node in node.inner.items():
-                        state[child] = child_node.topology_state(path)
-                else:
-                    node = self.get_path(path)
-                    for child, child_node in node.inner.items():
-                        state[child] = child_node.get_value()
-            elif isinstance(path, dict):
-                node, path = self.outer_path(path)
-                state[key] = node.topology_state(path)
-            else:
-                state[key] = self.get_path(path).get_value()
-        return state
-
-    def schema_topology(self, schema, topology):
-        '''
-        Fill in the structure of the given schema with the values located according
-        to the given topology.
-        '''
-
-        state = {}
-
-        if self.leaf:
-            state = self.get_value()
-        else:
-            for key, subschema in schema.items():
-                path = topology.get(key)
-                if key == '*':
-                    if isinstance(path, dict):
-                        node, path = self.outer_path(path)
-                        for child, child_node in node.inner.items():
-                            state[child] = child_node.schema_topology(subschema, path)
-                    else:
-                        node = self.get_path(path)
-                        for child, child_node in node.inner.items():
-                            state[child] = child_node.schema_topology(subschema, {})
-                elif key == '_divider':
-                    pass
-                elif isinstance(path, dict):
-                    node, path = self.outer_path(path)
-                    state[key] = node.schema_topology(subschema, path)
-                else:
-                    if path is None:
-                        path = (key,)
-                    node = self.get_path(path)
-                    if node:
-                        state[key] = node.schema_topology(subschema, {})
-                    else:
-                        # node is None, it was likely deleted
-                        print('{} is None'.format(path))
-
-        return state
-
-    def state_for(self, path, keys):
-        '''
-        Get the value of a state at a given path
-        '''
-
-        state = self.get_path(path)
-        if state is None:
-            return {}
-        elif keys and keys[0] == '*':
-            return state.get_value()
-        else:
-            return {
-                key: state.inner_value(key)
-                for key in keys}
-
-    def depth(self, path=(), filter_function=None):
-        '''
-        Create a mapping of every path in the tree to the node living at
-        that path in the tree. An optional `filter` argument is a function
-        that can declares the instances that will be returned, for example:
-        * filter=lambda x: isinstance(x.value, Process)
-        '''
-        base = []
-        if filter_function is None or filter_function(self):
-            base += [(path, self)]
-
-        for key, child in self.inner.items():
-            down = tuple(path + (key,))
-            base += child.depth(down, filter_function)
-        return base
-
-    def apply_subschema_path(self, path):
-        if path:
-            inner = self.inner[path[0]]
-            if self.subschema:
-                subtopology = self.subtopology or {}
-                inner.topology_ports(
-                    self.subschema,
-                    subtopology,
-                    source=self.path_for() + ('*',))
-            inner.apply_subschema_path(path[1:])
-
-    def apply_subschema(self, subschema=None, subtopology=None, source=None):
-        '''
-        Apply a subschema to all inner nodes (either provided or from this
-        node's personal subschema) as governed by the given/personal
-        subtopology.
-        '''
-
-        if subschema is None:
-            subschema = self.subschema
-        if subtopology is None:
-            subtopology = self.subtopology or {}
-
-        inner = list(self.inner.items())
-
-        for child_key, child in inner:
-            child.topology_ports(
-                subschema,
-                subtopology,
-                source=self.path_for() + ('*',))
-
-    def apply_subschemas(self):
-        '''
-        Apply all subschemas from all nodes at this point or lower in the tree.
-        '''
-
-        if self.subschema:
-            self.apply_subschema()
-        for child in self.inner.values():
-            child.apply_subschemas()
-
-    def update_subschema(self, path, subschema):
-        '''
-        Merge a new subschema into an existing subschema at the given path.
-        '''
-
-        target = self.get_path(path)
-        if target.subschema is None:
-            target.subschema = subschema
-        else:
-            target.subschema = deep_merge(
-                target.subschema,
-                subschema)
-        return target
-
-    def establish_path(self, path, config, source=None):
-        '''
-        Create a node at the given path if it does not exist, then
-        apply a config to it.
-
-        Paths can include '..' to go up a level (which raises an exception
-        if that level does not exist).
-        '''
-
-        if len(path) > 0:
-            path_step = path[0]
-            remaining = path[1:]
-
-            if path_step == '..':
-                if not self.outer:
-                    raise Exception('outer does not exist for path: {}'.format(path))
-
-                return self.outer.establish_path(
-                    remaining,
-                    config,
-                    source=source)
-            else:
-                if path_step not in self.inner:
-                    self.inner[path_step] = Store({}, outer=self, source=source)
-
-                return self.inner[path_step].establish_path(
-                    remaining,
-                    config,
-                    source=source)
-        else:
-            self.apply_config(config, source=source)
-            return self
-
-    def add_node(self, path, node):
-        ''' Add a node instance at the provided path '''
-        target = self.establish_path(path[:-1], {})
-        if target.get_value() and path[-1] in target.get_value():
-            # this path already exists, update it
-            self.apply_update({path[-1]: node.get_value()})
-        else:
-            node.outer = target
-            target.inner.update({path[-1]: node})
-        return target
-
-    def outer_path(self, path, source=None):
-        '''
-        Address a topology with the `_path` keyword if present,
-        establishing a path to this node and using it as the
-        starting point for future path operations.
-        '''
-
-        node = self
-        if '_path' in path:
-            node = self.establish_path(
-                path['_path'],
-                {},
-                source=source)
-            path = without(path, '_path')
-
-        return node, path
-
-    def topology_ports(self, schema, topology, source=None):
-        '''
-        Distribute a schema into the tree by mapping its ports
-        according to the given topology.
-        '''
-
-        source = source or self.path_for()
-
-        if set(schema.keys()) & self.schema_keys:
-            self.get_path(topology).apply_config(schema)
-        else:
-            mismatch_topology = (
-                set(topology.keys()) - set(schema.keys()))
-            mismatch_schema = (
-                set(schema.keys()) - set(topology.keys()))
-            if mismatch_topology:
-                raise Exception(
-                    'topology for the process {} \n at path {} uses undeclared ports: {}'.format(
-                        source, self.path_for(), str(mismatch_topology)))
-            if mismatch_schema:
-                log.info(
-                    'process {} has ports that are not included in the topology: {}'.format(
-                        source, mismatch_schema))
-
-            for port, subschema in schema.items():
-                path = topology.get(port, (port,))
-
-                if port == '*':
-                    subschema_config = {
-                        '_subschema': subschema}
-                    if isinstance(path, dict):
-                        node, path = self.outer_path(
-                            path, source=source)
-                        node.merge_subtopology(path)
-                        node.apply_config(subschema_config)
-                    else:
-                        node = self.establish_path(
-                            path,
-                            subschema_config,
-                            source=source)
-                    node.apply_subschema()
-                    node.apply_defaults()
-
-                elif isinstance(path, dict):
-                    node, path = self.outer_path(
-                        path, source=source)
-
-                    node.topology_ports(
-                        subschema,
-                        path,
-                        source=source)
-
-                else:
-                    self.establish_path(
-                        path,
-                        subschema,
-                        source=source)
-
-    def generate_paths(self, processes, topology):
-        for key, subprocess in processes.items():
-            subtopology = topology[key]
-            if isinstance(subprocess, Process):
-                process_state = Store({
-                    '_value': subprocess,
-                    '_updater': 'set',
-                    '_topology': subtopology,
-                    '_serializer': 'process'}, outer=self)
-                self.inner[key] = process_state
-
-                subprocess.schema = subprocess.get_schema()
-                self.topology_ports(
-                    subprocess.schema,
-                    subtopology,
-                    source=self.path_for() + (key,))
-            else:
-                if key not in self.inner:
-                    self.inner[key] = Store({}, outer=self)
-                self.inner[key].generate_paths(
-                    subprocess,
-                    subtopology)
-
-    def generate(self, path, processes, topology, initial_state):
-        '''
-        Generate a subtree of this store at the given path.
-        The processes will be mapped into locations in the tree by the
-        topology, and once everything is constructed the initial_state
-        will be applied.
-        '''
-
-        target = self.establish_path(path, {})
-        target.generate_paths(processes, topology)
-        target.set_value(initial_state)
-        target.apply_subschemas()
-        target.apply_defaults()
-
-
 def invert_topology(update, args):
     path, topology = args
     return inverse_topology(path[:-1], update, topology)
@@ -1172,7 +83,7 @@ def invoke_process(process, interval, states):
     return process.next_update(interval, states)
 
 
-class Defer(object):
+class Defer:
     def __init__(self, defer, f, args):
         self.defer = defer
         self.f = f
@@ -1184,7 +95,7 @@ def get(self):
             self.args)
 
 
-class InvokeProcess(object):
+class InvokeProcess:
     def __init__(self, process, interval, states):
         self.process = process
         self.interval = interval
@@ -1194,11 +105,11 @@ def __init__(self, process, interval, states):
             self.interval,
             self.states)
 
-    def get(self, timeout=0):
+    def get(self):
         return self.update
 
 
-class MultiInvoke(object):
+class MultiInvoke:
     def __init__(self, pool):
         self.pool = pool
 
@@ -1208,7 +119,7 @@ def invoke(self, process, interval, states):
         return result
 
 
-class Experiment(object):
+class Experiment:
     def __init__(self, config):
         # type: (Dict[str, Any]) -> None
         """Defines simulations
@@ -1221,7 +132,7 @@ def __init__(self, config):
                     maps :term:`process` names to process objects. You
                     will usually get this from the ``processes``
                     attribute of the dictionary from
-                    :py:meth:`vivarium.core.process.Factory.generate`.
+                    :py:meth:`vivarium.core.process.Composer.generate`.
                 * **topology** (:py:class:`dict`): A dictionary that
                     maps process names to sub-dictionaries. These
                     sub-dictionaries map the process's port names to
@@ -1257,7 +168,8 @@ def __init__(self, config):
         self.emit_step = config.get('emit_step')
 
         # display settings
-        self.experiment_name = config.get('experiment_name', self.experiment_id)
+        self.experiment_name = config.get(
+            'experiment_name', self.experiment_id)
         self.description = config.get('description', '')
         self.display_info = config.get('display_info', True)
         self.progress_bar = config.get('progress_bar', False)
@@ -1267,11 +179,11 @@ def __init__(self, config):
 
         # parallel settings
         self.invoke = config.get('invoke', InvokeProcess)
-        self.parallel: Dict[Path, ParallelProcess] = {}
+        self.parallel: Dict[HierarchyPath, ParallelProcess] = {}
 
         # get a mapping of all paths to processes
-        self.process_paths: Dict[Path, Process] = {}
-        self.deriver_paths: Dict[Path, Deriver] = {}
+        self.process_paths: Dict[HierarchyPath, Process] = {}
+        self.deriver_paths: Dict[HierarchyPath, Deriver] = {}
         self.find_process_paths(self.processes)
 
         # initialize the state
@@ -1284,6 +196,8 @@ def __init__(self, config):
         emitter_config = config.get('emitter', 'timeseries')
         if isinstance(emitter_config, str):
             emitter_config = {'type': emitter_config}
+        else:
+            emitter_config = dict(emitter_config)
         emitter_config['experiment_id'] = self.experiment_id
         self.emitter = get_emitter(emitter_config)
 
@@ -1297,7 +211,7 @@ def __init__(self, config):
         self.emit_data()
 
         # logging information
-        log.info('experiment {}'.format(self.experiment_id))
+        log.info('experiment %s', str(self.experiment_id))
 
         log.info('\nPROCESSES:')
         log.info(pf(self.processes))
@@ -1318,7 +232,7 @@ def add_process_path(self, process, path):
             self.process_paths[path] = process
 
     def find_process_paths(self, processes):
-        tree = depth(processes)
+        tree = hierarchy_depth(processes)
         for path, process in tree.items():
             self.add_process_path(process, path)
 
@@ -1329,7 +243,7 @@ def emit_configuration(self):
             'name': self.experiment_name,
             'description': self.description,
             'topology': self.topology,
-            'processes': serialize_dictionary(self.processes),
+            'processes': serialize_value(self.processes),
             'state': self.state.get_config()
         }
         emit_config = {
@@ -1339,7 +253,7 @@ def emit_configuration(self):
             self.emitter.emit(emit_config)
         except PyMongoError:
             log.exception("emitter.emit", exc_info=True, stack_info=True)
-            # TODO -- handle large parameter sets in self.processes to meet mongoDB limit
+            # TODO -- handle large parameter sets to meet mongoDB limit
             del emit_config['data']['processes']
             del emit_config['data']['state']
             self.emitter.emit(emit_config)
@@ -1353,9 +267,8 @@ def invoke_process(self, process, path, interval, states):
             self.parallel[path].update(interval, states)
 
             return self.parallel[path]
-        else:
-            # if not parallel, perform a normal invocation
-            return self.invoke(process, interval, states)
+        # if not parallel, perform a normal invocation
+        return self.invoke(process, interval, states)
 
     def process_update(self, path, process, store, states, interval):
 
@@ -1387,8 +300,8 @@ def apply_update(self, update, state):
             update, state)
 
         if topology_updates:
-            for path, update in topology_updates:
-                assoc_path(self.topology, path, update)
+            for path, topology_update in topology_updates:
+                assoc_path(self.topology, path, topology_update)
 
         if process_updates:
             for path, process in process_updates:
@@ -1432,7 +345,7 @@ def emit_data(self):
             'time': self.experiment_time})
         emit_config = {
             'table': 'history',
-            'data': serialize_dictionary(data)}
+            'data': serialize_value(data)}
         self.emitter.emit(emit_config)
 
     def send_updates(self, update_tuples):
@@ -1443,7 +356,8 @@ def send_updates(self, update_tuples):
         self.run_derivers()
 
     def update(self, interval):
-        """ Run each process for the given interval and update the related states. """
+        """ Run each process for the given interval and update the states.
+        """
 
         time = 0
         emit_time = self.emit_step
@@ -1508,7 +422,7 @@ def empty_front(t):
                         full_step = timestep
 
                 else:
-                    # don't shoot past processes that didn't get to run this time
+                    # don't shoot past processes that didn't run this time
                     process_delay = process_time - time
                     if process_delay < full_step:
                         full_step = process_delay
@@ -1549,7 +463,7 @@ def empty_front(t):
                         emit_time += self.emit_step
 
         # post-simulation
-        for process_name, advance in front.items():
+        for advance in front.values():
             assert advance['time'] == time == interval
             assert len(advance['update']) == 0
 
@@ -1596,133 +510,15 @@ def print_progress_bar(
     """
     progress = ("{0:." + str(decimals) + "f}").format(total - iteration)
     filled_length = int(length * iteration // total)
-    bar = '█' * filled_length + '-' * (length - filled_length)
-    print(f'\rProgress:|{bar}| {progress}/{float(total)} simulated seconds remaining    ', end='\r')
+    filled_bar = '█' * filled_length + '-' * (length - filled_length)
+    print(
+        f'\rProgress:|{filled_bar}| {progress}/{float(total)} '
+        f'simulated seconds remaining    ', end='\r')
     # Print New Line on Complete
     if iteration == total:
         print()
 
 
-# Tests
-quark_colors = ['green', 'red', 'blue']
-quark_spins = ['up', 'down']
-electron_spins = ['-1/2', '1/2']
-electron_orbitals = [
-    str(orbit) + 's'
-    for orbit in range(1, 8)]
-
-
-class Proton(Process):
-    name = 'proton'
-    defaults = {
-        'time_step': 1.0,
-        'radius': 0.0}
-
-    def __init__(self, parameters=None):
-        super(Proton, self).__init__(parameters)
-
-    def ports_schema(self):
-        return {
-            'radius': {
-                '_updater': 'set',
-                '_default': self.parameters['radius']},
-            'quarks': {
-                '_divider': 'split_dict',
-                '*': {
-                    'color': {
-                        '_updater': 'set',
-                        '_default': quark_colors[0]},
-                    'spin': {
-                        '_updater': 'set',
-                        '_default': quark_spins[0]}}},
-            'electrons': {
-                '*': {
-                    'orbital': {
-                        '_updater': 'set',
-                        '_default': electron_orbitals[0]},
-                    'spin': {
-                        '_default': electron_spins[0]}}}}
-
-    def next_update(self, timestep, states):
-        update = {}
-
-        collapse = np.random.random()
-        if collapse < states['radius'] * timestep:
-            update['radius'] = collapse
-            update['quarks'] = {}
-
-            for name, quark in states['quarks'].items():
-                update['quarks'][name] = {
-                    'color': np.random.choice(quark_colors),
-                    'spin': np.random.choice(quark_spins)}
-
-            update['electrons'] = {}
-            orbitals = electron_orbitals.copy()
-            for name, electron in states['electrons'].items():
-                np.random.shuffle(orbitals)
-                update['electrons'][name] = {
-                    'orbital': orbitals.pop()}
-
-        return update
-
-
-class Electron(Process):
-    name = 'electron'
-    defaults = {
-        'time_step': 1.0,
-        'spin': electron_spins[0]}
-
-    def __init__(self, parameters=None):
-        super(Electron, self).__init__(parameters)
-
-    def ports_schema(self):
-        return {
-            'spin': {
-                '_updater': 'set',
-                '_default': self.parameters['spin']},
-            'proton': {
-                'radius': {
-                    '_default': 0.0}}}
-
-    def next_update(self, timestep, states):
-        update = {}
-
-        if np.random.random() < states['proton']['radius']:
-            update['spin'] = np.random.choice(electron_spins)
-
-        return update
-
-
-class Sine(Process):
-    name = 'sine'
-    defaults = {
-        'initial_phase': 0.0}
-
-    def __init__(self, parameters=None):
-        super(Sine, self).__init__(parameters)
-
-    def ports_schema(self):
-        return {
-            'frequency': {
-                '_default': 440.0},
-            'amplitude': {
-                '_default': 1.0},
-            'phase': {
-                '_default': self.parameters['initial_phase']},
-            'signal': {
-                '_default': 0.0,
-                '_updater': 'set'}}
-
-    def next_update(self, timestep, states):
-        phase_shift = timestep * states['frequency'] % 1.0
-        signal = states['amplitude'] * math.sin(
-            2 * math.pi * (states['phase'] + phase_shift))
-
-        return {
-            'phase': phase_shift,
-            'signal': signal}
-
-
 def make_proton(parallel=False):
     processes = {
         'proton': Proton({'_parallel': parallel}),
@@ -1876,7 +672,7 @@ class Slow(Process):
         defaults = {'timestep': 3.0}
 
         def __init__(self, config=None):
-            super(Slow, self).__init__(config)
+            super().__init__(config)
 
         def ports_schema(self):
             return {
@@ -1899,7 +695,7 @@ class Fast(Process):
         defaults = {'timestep': 0.3}
 
         def __init__(self, config=None):
-            super(Fast, self).__init__(config)
+            super().__init__(config)
 
         def ports_schema(self):
             return {
@@ -1967,9 +763,9 @@ def next_update(self, timestep, states):
                     'a': 1,
                     'b': 2}}
 
-    class SplitPort(Composite):
+    class SplitPort(Composer):
         """splits OnePort's ports into two stores"""
-        name = 'split_port_generator'
+        name = 'split_port_composer'
 
         def generate_processes(self, config):
             return {
@@ -2025,9 +821,9 @@ def next_update(self, timestep, states):
                 'B': {'a': 1},
                 'C': {'a': 1}}
 
-    class MergePort(Composite):
+    class MergePort(Composer):
         """combines both of MultiPort's ports into one store"""
-        name = 'multi_port_generator'
+        name = 'multi_port_composer'
 
         def generate_processes(self, config):
             return {
@@ -2057,81 +853,6 @@ def generate_topology(self, config):
 
 
 def test_complex_topology():
-    class Po(Process):
-        name = 'po'
-
-        def ports_schema(self):
-            return {
-                'A': {
-                    'a1': {'_default': 0},
-                    'a2': {'_default': 0},
-                    'a3': {'_default': 0}},
-                'B': {
-                    'b1': {'_default': 0},
-                    'b2': {'_default': 0}}}
-
-        def next_update(self, timestep, states):
-            return {
-                'A': {
-                    'a1': 1,
-                    'a2': 1,
-                    'a3': 1},
-                'B': {
-                    'b1': -1,
-                    'b2': -1}}
-
-    class Qo(Process):
-        name = 'qo'
-
-        def ports_schema(self):
-            return {
-                'D': {
-                    'd1': {'_default': 0},
-                    'd2': {'_default': 0},
-                    'd3': {'_default': 0}},
-                'E': {
-                    'e1': {'_default': 0},
-                    'e2': {'_default': 0}}}
-
-        def next_update(self, timestep, states):
-            return {
-                'D': {
-                    'd1': 10,
-                    'd2': 10,
-                    'd3': 10},
-                'E': {
-                    'e1': -10,
-                    'e2': -10}}
-
-    class PoQo(Composite):
-        def generate_processes(self, config=None):
-            return {
-                'po': Po(config),
-                'qo': Qo(config),
-            }
-
-        def generate_topology(self, config=None):
-            return {
-                'po': {
-                    'A': {
-                        '_path': ('aaa',),
-                        'a2': ('x',),
-                        'a3': ('..', 'ccc', 'a3')},
-                    'B': ('bbb',),
-                },
-                'qo': {
-                    'D': {
-                        '_path': (),
-                        'd1': ('aaa', 'd1'),
-                        'd2': ('aaa', 'd2'),
-                        'd3': ('ccc', 'd3')},
-                    'E': {
-                        '_path': (),
-                        'e1': ('aaa', 'x'),
-                        'e2': ('bbb', 'e2')}
-                },
-            }
-
 
     # make the experiment
     outer_path = ('universe', 'agent')
@@ -2197,7 +918,7 @@ def test_depth():
         'B': {
             'BA': 6}}
 
-    dissected = depth(nested)
+    dissected = hierarchy_depth(nested)
     assert len(dissected) == 3
     assert dissected[('A', 'AB', 'ABC')] == 11
 
@@ -2245,18 +966,22 @@ def next_update(self, timestep, states):
             return {
                 'A': {'a': 1 * units.mm}}
 
-    class MultiUnits(Composite):
-        name = 'multi_units_generator'
+    class MultiUnits(Composer):
+        name = 'multi_units_composer'
 
         def generate_processes(self, config):
             return {
-                'units_micrometer': UnitsMicrometer({}),
-                'units_millimeter': UnitsMillimeter({})}
+                'units_micrometer':
+                    UnitsMicrometer({}),
+                'units_millimeter':
+                    UnitsMillimeter({})}
 
         def generate_topology(self, config):
             return {
-                'units_micrometer': {'A': ('aaa',)},
-                'units_millimeter': {'A': ('aaa',)}}
+                'units_micrometer': {
+                    'A': ('aaa',)},
+                'units_millimeter': {
+                    'A': ('aaa',)}}
 
     # run experiment
     multi_unit = MultiUnits({})
diff --git a/vivarium/core/process.py b/vivarium/core/process.py
index c9ee3738..45867d25 100644
--- a/vivarium/core/process.py
+++ b/vivarium/core/process.py
@@ -1,75 +1,150 @@
 """
-==========================================
-Factory, Process, and Composite Classes
-==========================================
+============================
+Composer and Process Classes
+============================
 """
 
-import copy
-import numpy as np
-import abc
-from typing import Any, Dict, Optional
+from __future__ import annotations
 
-from bson.objectid import ObjectId
+import abc
+import copy
 from multiprocessing import Pipe
 from multiprocessing import Process as Multiprocess
+from multiprocessing.connection import Connection
+from typing import (
+    Any, Callable, Dict, Optional, Union, List, cast)
+
+from bson.objectid import ObjectId
+import numpy as np
 from pint.errors import UndefinedUnitError
 
 from vivarium.library.topology import inverse_topology
 from vivarium.library.units import Quantity
-from vivarium.core.registry import process_registry, serializer_registry
+from vivarium.core.registry import serializer_registry
 from vivarium.library.dict_utils import deep_merge, deep_merge_check
+from vivarium.core.types import (
+    HierarchyPath, Topology, Schema, State, Update, CompositeDict)
 
 DEFAULT_TIME_STEP = 1.0
 
 
-def serialize_value(value):
+def serialize_value(value: Any) -> Any:
+    """Attempt to serialize a value.
+
+    For this function, consider "serializable" to mean serializiable by
+    this function.  This function can serialize the following kinds of
+    values:
+
+    * :py:class:`dict` whose keys implement ``__str__`` and whose values
+      are serializable. Keys are serialized by calling ``__str__`` and
+      values are serialized by this function.
+    * :py:class:`list` and :py:class:`tuple`, whose values are
+      serializable. The value is serialized as a list of the serialized
+      values.
+    * Numpy ndarray objects are handled by
+      :py:class:`vivarium.core.registry.NumpySerializer`.
+    * :py:class:`pint.Quantity` objects are handled by
+      :py:class:`vivarium.core.registry.UnitsSerializer`.
+    * Functions are handled by
+      :py:class:`vivarium.core.registry.FunctionSerializer`.
+    * :py:class:`vivarium.core.process.Process` objects are handled by
+      :py:class:`vivarium.core.registry.ProcessSerializer`.
+    * :py:class:`vivarium.core.process.Composer` objects are handled by
+      :py:class:`vivarium.core.registry.ComposerSerializer`.
+    * Numpy scalars are handled by
+      :py:class:`vivarium.core.registry.NumpyScalarSerializer`.
+    * ``ObjectId`` objects are serialized by calling its ``__str__``
+      function.
+
+    When provided with a serializable value, the returned serialized
+    value is suitable for inclusion in a JSON object.
+
+    Args:
+        value: The value to serialize.
+
+    Returns:
+        The serialized value if ``value`` is serializable. Otherwise,
+        ``value`` is returned unaltered.
+    """
     if isinstance(value, dict):
-        return serialize_dictionary(value)
-    elif isinstance(value, list):
-        return serialize_list(value)
-    elif isinstance(value, tuple):
-        return serialize_list(list(value))
-    elif isinstance(value, np.ndarray):
+        value = cast(dict, value)
+        return _serialize_dictionary(value)
+    if isinstance(value, list):
+        value = cast(list, value)
+        return _serialize_list(value)
+    if isinstance(value, tuple):
+        value = cast(tuple, value)
+        return _serialize_list(list(value))
+    if isinstance(value, np.ndarray):
+        value = cast(np.ndarray, value)
         return serializer_registry.access('numpy').serialize(value)
-    elif isinstance(value, Quantity):
+    if isinstance(value, Quantity):
+        value = cast(Quantity, value)
         return serializer_registry.access('units').serialize(value)
-    elif callable(value):
+    if callable(value):
+        value = cast(Callable, value)
         return serializer_registry.access('function').serialize(value)
-    elif isinstance(value, Process):
-        return serialize_dictionary(serializer_registry.access('process').serialize(value))
-    elif isinstance(value, Factory):
-        return serialize_dictionary(
-            serializer_registry.access('factory').serialize(value))
-    elif isinstance(value, (np.integer, np.floating)):
-        return serializer_registry.access('numpy_scalar').serialize(value)
-    elif isinstance(value, ObjectId):
+    if isinstance(value, Process):
+        value = cast(Process, value)
+        return _serialize_dictionary(
+            serializer_registry.access('process').serialize(value))
+    if isinstance(value, Composer):
+        value = cast(Composer, value)
+        return _serialize_dictionary(
+            serializer_registry.access('composer').serialize(value))
+    if isinstance(value, (np.integer, np.floating)):
+        return serializer_registry.access(
+            'numpy_scalar').serialize(value)
+    if isinstance(value, ObjectId):
+        value = cast(ObjectId, value)
         return str(value)
-    else:
-        return value
+    return value
+
 
+def deserialize_value(value: Any) -> Any:
+    """Attempt to deserialize a value.
 
-def deserialize_value(value):
+    Supports deserializing the following kinds ov values:
+
+    * :py:class:`dict` with serialized values and keys that need not be
+      deserialized. The values will be deserialized with this function.
+    * :py:class:`list` of serialized values. The values will be
+      deserialized with this function.
+    * :py:class:`str` which are serialized :py:class:`pint.Quantity`
+      values.  These will be deserialized with
+      :py:class:`vivarium.core.registry.UnitsSerializer`.
+
+    Args:
+        value: The value to deserialize.
+
+    Returns:
+        The deserialized value if ``value`` is of a supported type.
+        Otherwise, returns ``value`` unmodified.
+    """
     if isinstance(value, dict):
-        return deserialize_dictionary(value)
-    elif isinstance(value, list):
-        return deserialize_list(value)
-    elif isinstance(value, str):
+        value = cast(dict, value)
+        return _deserialize_dictionary(value)
+    if isinstance(value, list):
+        value = cast(list, value)
+        return _deserialize_list(value)
+    if isinstance(value, str):
+        value = cast(str, value)
         try:
-            return serializer_registry.access('units').deserialize(value)
+            return serializer_registry.access(
+                'units').deserialize(value)
         except UndefinedUnitError:
             return value
-    else:
-        return value
+    return value
 
 
-def serialize_list(lst):
+def _serialize_list(lst: list) -> list:
     serialized = []
     for value in lst:
         serialized.append(serialize_value(value))
     return serialized
 
 
-def serialize_dictionary(d):
+def _serialize_dictionary(d: dict) -> Dict[str, Any]:
     serialized = {}
     for key, value in d.items():
         if not isinstance(key, str):
@@ -78,93 +153,97 @@ def serialize_dictionary(d):
     return serialized
 
 
-def deserialize_list(lst):
+def _deserialize_list(lst: list) -> list:
     deserialized = []
     for value in lst:
         deserialized.append(deserialize_value(value))
     return deserialized
 
 
-def deserialize_dictionary(d):
+def _deserialize_dictionary(d: dict) -> dict:
     deserialized = {}
     for key, value in d.items():
         deserialized[key] = deserialize_value(value)
     return deserialized
 
 
-def assoc_in(d, path, value):
+def assoc_in(d: dict, path: HierarchyPath, value: Any) -> dict:
+    """Insert a value into a dictionary at an arbitrary depth.
+
+    Empty dictionaries will be created as needed to insert the value at
+    the specified depth.
+
+    >>> d = {'a': {'b': 1}}
+    >>> assoc_in(d, ('a', 'c', 'd'), 2)
+    {'a': {'b': 1, 'c': {'d': 2}}}
+
+    Args:
+        d: Dictionary to insert into.
+        path: Path in the dictionary where the value will be inserted.
+            Each element of the path is dictionary key, which will be
+            added if not already present. Any given element (except the
+            first) refers to a key in the dictionary that is the value
+            associated with the immediately preceding path element.
+        value: The value to insert.
+
+    Returns:
+        Dictionary with the value inserted.
+    """
     if path:
-        return dict(d, **{path[0]: assoc_in(d.get(path[0], {}), path[1:], value)})
-    else:
-        return value
+        return dict(
+            d,
+            **{
+                path[0]: assoc_in(d.get(path[0], {}), path[1:], value)
+            }
+        )
+    return value
 
 
-def override_schemas(overrides, processes):
+def _override_schemas(
+        overrides: Dict[str, Schema],
+        processes: Dict[str, Process]) -> None:
     for key, override in overrides.items():
         process = processes[key]
         if isinstance(process, Process):
             process.merge_overrides(override)
-        else:
-            override_schemas(override, process)
-
-
-def generate_derivers(processes, topology):
-    deriver_processes = {}
-    deriver_topology = {}
-    for process_key, node in processes.items():
-        subtopology = topology[process_key]
-        if isinstance(node, Process):
-            for deriver_key, config in node.derivers().items():
-                if deriver_key not in deriver_processes:
-                    # generate deriver process
-                    deriver_config = config.get('config', {})
-                    generate = config['deriver']
-                    if isinstance(generate, str):
-                        generate = process_registry.access(generate)
-
-                    deriver = generate(deriver_config)
-                    deriver_processes[deriver_key] = deriver
-
-                    # generate deriver topology
-                    deriver_ports = deriver.ports()
-                    deriver_topology[deriver_key] = {
-                        port: (port,) for port in deriver_ports.keys()}
-                    for target, source in config.get('port_mapping', {}).items():
-                        path = subtopology[source]
-                        deriver_topology[deriver_key][target] = path
-        else:
-            subderivers = generate_derivers(node, subtopology)
-            deriver_processes[process_key] = subderivers['processes']
-            deriver_topology[process_key] = subderivers['topology']
-    return {
-        'processes': deriver_processes,
-        'topology': deriver_topology}
+        elif isinstance(process, dict):
+            _override_schemas(override, process)
 
 
-def get_composite_initial_state(processes, topology):
+def _get_composite_initial_state(
+        processes: Dict[str, Process],
+        topology: Topology) -> State:
     initial_state = {}
     for path, node in processes.items():
         if isinstance(node, dict):
             for key in node.keys():
-                initial_state[key] = get_composite_initial_state(node, topology[path])
+                initial_state[key] = _get_composite_initial_state(
+                    node, cast(Topology, topology[path]))
         elif isinstance(node, Process):
             process_topology = topology[path]
             process_state = node.initial_state()
-            process_path = tuple()
-            state = inverse_topology(process_path, process_state, process_topology)
+            process_path: HierarchyPath = tuple()
+            state = inverse_topology(
+                process_path, process_state, process_topology)
             initial_state = deep_merge(initial_state, state)
 
     return initial_state
 
 
-class Factory(metaclass=abc.ABCMeta):
-    """Factory parent class
-
-    All :term:`factory` classes must inherit from this class.
-    """
+class Composer(metaclass=abc.ABCMeta):
     defaults: Dict[str, Any] = {}
 
-    def __init__(self, config=None):
+    def __init__(self, config: Optional[dict] = None) -> None:
+        """Base class for :term:`composer` classes.
+
+        Composers generate :term:`composites`.
+
+        All :term:`composer` classes must inherit from this class.
+
+        Args:
+            config: Dictionary of configuration options that can
+                override the class defaults.
+        """
         if config is None:
             config = {}
         if 'name' in config:
@@ -175,11 +254,14 @@ def __init__(self, config=None):
         self.config = copy.deepcopy(self.defaults)
         self.config = deep_merge(self.config, config)
 
-    @abc.abstractmethod
-    def generate_processes(self, config):
-        """Generate processes dictionary
+        self.merge_processes = self.config.pop('_processes', {})
+        self.merge_topology = self.config.pop('_topology', {})
+        self.schema_override = self.config.pop('_schema', {})
 
-        Every subclass must override this method.
+    def initial_state(self, config: Optional[dict] = None) -> State:
+        """ Merge all processes' initial states
+
+        Every subclass may override this method.
 
         Arguments:
             config (dict): A dictionary of configuration options. All
@@ -188,44 +270,70 @@ def generate_processes(self, config):
 
         Returns:
             dict: Subclass implementations must return a dictionary
+            mapping state paths to initial values.
+        """
+        network = self.generate(config)
+        processes = cast(Dict[str, Any], network['processes'])
+        topology = network['topology']
+        initial_state = _get_composite_initial_state(processes, topology)
+        return initial_state
+
+    @abc.abstractmethod
+    def generate_processes(
+            self,
+            config: Optional[dict]) -> Dict[str, Any]:
+        """Generate processes dictionary.
+
+        Every subclass must override this method.
+
+        Args:
+            config: A dictionary of configuration options. All
+                subclass implementation must accept this parameter, but
+                some may ignore it.
+
+        Returns:
+            Subclass implementations must return a dictionary
             mapping process names to instantiated and configured process
             objects.
         """
         return {}
 
     @abc.abstractmethod
-    def generate_topology(self, config):
-        """Generate topology dictionary
+    def generate_topology(self, config: Optional[dict]) -> Topology:
+        """Generate topology dictionary.
 
         Every subclass must override this method.
 
-        Arguments:
-            config (dict): A dictionary of configuration options. All
+        Args:
+            config: A dictionary of configuration options. All
                 subclass implementation must accept this parameter, but
                 some may ignore it.
 
         Returns:
-            dict: Subclass implementations must return a :term:`topology`
+            Subclass implementations must return a :term:`topology`
             dictionary.
         """
         return {}
 
-    def generate(self, config=None, path=tuple()):
-        '''Generate processes and topology dictionaries
+    def generate(
+            self,
+            config: Optional[dict] = None,
+            path: HierarchyPath = ()) -> CompositeDict:
+        """Generate processes and topology dictionaries.
 
-        Arguments:
-            config (dict): Updates values in the configuration declared
-                in the constructor
-            path (tuple): Tuple with ('path', 'to', 'level') associates
-                the processes and topology at this level
+        Args:
+            config: Updates values in the configuration declared
+                in the constructor.
+            path: Tuple with ('path', 'to', 'level') associates
+                the processes and topology at this level.
 
         Returns:
-            dict: Dictionary with two keys: ``processes``, which has a
-            value of a processes dictionary, and ``topology``, which has
-            a value of a topology dictionary. Both are suitable to be
-            passed to the constructor for
+            Dictionary with keys ``processes``, which has a value of a
+            processes dictionary, and ``topology``, which has a value of
+            a topology dictionary. Both are suitable to be passed to the
+            constructor for
             :py:class:`vivarium.core.experiment.Experiment`.
-        '''
+        """
 
         if config is None:
             config = self.config
@@ -236,120 +344,78 @@ def generate(self, config=None, path=tuple()):
         processes = self.generate_processes(config)
         topology = self.generate_topology(config)
 
-        # add derivers
-        derivers = generate_derivers(processes, topology)
-        processes = deep_merge(derivers['processes'], processes)
-        topology = deep_merge(derivers['topology'], topology)
-
-        return {
-            'processes': assoc_in({}, path, processes),
-            'topology': assoc_in({}, path, topology)}
-
-
-class Composite(Factory):
-    """Composite parent class
-
-    All :term:`composite` classes must inherit from this class.
-    """
-
-    def __init__(self, config=None):
-        super().__init__(config)
-
-        self.merge_processes = self.config.pop('_processes', {})
-        self.merge_topology = self.config.pop('_topology', {})
-        self.schema_override = self.config.pop('_schema', {})
-
-    def initial_state(self, config=None):
-        """ Merge all processes' initial states
-
-        Every subclass may override this method.
-
-        Arguments:
-            config (dict): A dictionary of configuration options. All
-                subclass implementation must accept this parameter, but
-                some may ignore it.
-
-        Returns:
-            dict: Subclass implementations must return a dictionary
-            mapping state paths to initial values.
-        """
-        network = self.generate(config)
-        processes = network['processes']
-        topology = network['topology']
-        initial_state = get_composite_initial_state(processes, topology)
-        return initial_state
-
-    def generate_processes(self, config):
-        return {}
-
-    def generate_topology(self, config):
-        return {}
-
-    def generate(self, config=None, path=tuple()):
-        network = super().generate(config=config)
-        processes = network['processes']
-        topology = network['topology']
-
         # add merged processes
-        # TODO - this assumes all merge_processes are already initialized.
+        # TODO - this assumes all merge_processes are initialized.
         # TODO - make option to initialize new processes here
         processes = deep_merge(processes, self.merge_processes)
         topology = deep_merge(topology, self.merge_topology)
 
-        override_schemas(self.schema_override, processes)
+        _override_schemas(self.schema_override, processes)
 
         return {
             'processes': assoc_in({}, path, processes),
-            'topology': assoc_in({}, path, topology)}
+            'topology': assoc_in({}, path, topology),
+        }
+
+    def get_parameters(self) -> dict:
+        """Get the parameters for all :term:`processes`.
 
-    def get_parameters(self):
+        Returns:
+            A map from process names to dictionaries of those processes'
+            parameters.
+        """
         network = self.generate({})
-        processes = network['processes']
+        processes = cast(Dict[str, Process], network['processes'])
         return {
             process_id: process.parameters
             for process_id, process in processes.items()}
 
     def merge(
             self,
-            processes: Optional[Dict[str, Any]] = None,
-            topology: Optional[Dict[str, Any]] = None,
-            schema_override: Optional[Dict[str, Any]] = None):
+            processes: Optional[Dict[str, Process]] = None,
+            topology: Optional[Topology] = None,
+            schema_override: Optional[Schema] = None) -> None:
         processes = processes or {}
         topology = topology or {}
         schema_override = schema_override or {}
 
-        for name, process in processes.items():
+        for process in processes.values():
             assert isinstance(process, Process)
 
-        self.merge_processes = deep_merge_check(self.merge_processes, processes)
+        self.merge_processes = deep_merge_check(
+            self.merge_processes, processes)
         self.merge_topology = deep_merge(self.merge_topology, topology)
-        self.schema_override = deep_merge(self.schema_override, schema_override)
-
+        self.schema_override = deep_merge(
+            self.schema_override, schema_override)
 
-class Process(Composite, metaclass=abc.ABCMeta):
-    """Process parent class
 
-    All :term:`process` classes must inherit from this class.
-    """
+class Process(Composer, metaclass=abc.ABCMeta):
     defaults: Dict[str, Any] = {}
 
-    def __init__(self, parameters=None):
+    def __init__(self, parameters: Optional[dict] = None) -> None:
+        """Process parent class.
+
+        All :term:`process` classes must inherit from this class. Each
+        class can provide a ``defaults`` class variable to specify the
+        process defaults as a dictionary.
+
+        Args:
+            parameters: Override the class defaults. If this contains a
+                ``_register`` key, the process will register itself in
+                the process registry.
+        """
         super().__init__(parameters)
 
         self.parameters = self.config
         self.parallel = self.config.pop('_parallel', False)
-        if self.config.get('_register'):
-            self.register()
-
-        if 'time_step' not in self.parameters:
-            self.parameters['time_step'] = DEFAULT_TIME_STEP
+        self.parameters.setdefault('time_step', DEFAULT_TIME_STEP)
 
-    def initial_state(self, config=None):
-        """Get initial state in embedded path dictionary
+    def initial_state(self, config: Optional[dict] = None) -> State:
+        """Get initial state in embedded path dictionary.
 
         Every subclass may override this method.
 
-        Arguments:
+        Args:
             config (dict): A dictionary of configuration options. All
                 subclass implementation must accept this parameter, but
                 some may ignore it.
@@ -358,53 +424,93 @@ def initial_state(self, config=None):
             dict: Subclass implementations must return a dictionary
             mapping state paths to initial values.
         """
-        raise Exception('{} does not include an "initial_state" function'.format(self.name))
-
-    def register(self, name=None):
-        process_registry.register(name or self.name, self)
-
-    def generate_processes(self, config):
-        return {self.name: self}
-
-    def generate_topology(self, config):
+        raise Exception(
+            '{} does not include an "initial_state" function'.format(
+                self.name))
+
+    def generate_processes(
+            self, config: Optional[dict] = None) -> Dict[str, Any]:
+        config = config or {}
+        name = config.get('name', self.name)
+        return {name: self}
+
+    def generate_topology(self, config: Optional[dict] = None) -> Topology:
+        config = config or {}
+        name = config.get('name', self.name)
+        override_topology = config.get('topology', {})
         ports = self.ports()
         return {
-            self.name: {
-                port: (port,) for port in ports.keys()}}
+            name: {
+                port: override_topology.get(port, (port,))
+                for port in ports.keys()}}
+
+    def get_schema(self, override: Optional[Schema] = None) -> dict:
+        """Get the process's schema, optionally with a schema override.
 
-    def get_schema(self, override=None):
+        Args:
+            override: Override schema
+
+        Returns:
+            The combined schema.
+        """
         ports = copy.deepcopy(self.ports_schema())
         deep_merge(ports, self.schema_override)
         deep_merge(ports, override)
         return ports
 
-    def merge_overrides(self, override):
+    def merge_overrides(self, override: Schema) -> None:
+        """Add a schema override to the process's schema overrides.
+
+        Args:
+            override: The schema override to add.
+        """
         deep_merge(self.schema_override, override)
 
-    def ports(self):
+    def ports(self) -> Dict[str, List[str]]:
+        """Get ports and each port's variables.
+
+        Returns:
+            A map from port names to lists of the variables that go into
+            that port.
+        """
         ports_schema = self.ports_schema()
         return {
             port: list(states.keys())
             for port, states in ports_schema.items()}
 
-    def calculate_timestep(self, states):
-        ''' Return the next process time step
+    def local_timestep(self) -> Union[float, int]:
+        """Get a process's favored timestep.
 
-        A process subclass may override this method.
-        By default it returns self.parameters['time_step'].
-        '''
+        The timestep may change over the course of the simulation.
+        Processes must not assume that their favored timestep will
+        actually be used. To customize their timestep, processes can
+        override this method.
+
+        Returns:
+            Favored timestep.
+        """
         return self.parameters['time_step']
 
-    def local_timestep(self):
-        '''
-        Returns the favored timestep for this process.
-        Meant to be overridden in subclasses, unless 1.0 is a happy value.
-        '''
+    def calculate_timestep(self, states: Optional[State]) -> Union[float, int]:
+        """Return the next process time step
+
+        A process subclass may override this method to implement
+        adaptive timesteps. By default it returns self.parameters['time_step'].
+        """
+        _ = states
         return self.parameters['time_step']
 
-    def default_state(self):
+    def default_state(self) -> State:
+        """Get the default values of the variables in each port.
+
+        The default values are computed based on the schema.
+
+        Returns:
+            A state dictionary that assigns each variable's default
+            value to that variable.
+        """
         schema = self.ports_schema()
-        state = {}
+        state: State = {}
         for port, states in schema.items():
             for key, value in states.items():
                 if '_default' in value:
@@ -413,55 +519,89 @@ def default_state(self):
                     state[port][key] = value['_default']
         return state
 
-    def is_deriver(self):
+    def is_deriver(self) -> bool:
+        """Check whether this process is a deriver.
+
+        Returns:
+            Whether this process is a deriver. This class always returns
+            ``False``, but subclasses may change this.
+        """
         return False
 
-    def derivers(self):
-        return {}
+    def get_private_state(self) -> State:
+        """Get the process's private state.
+
+        Processes can store state in instance variables instead of in
+        the :term:`stores` that hold the simulation-wide state. These
+        instance variables hold a private state that is not shared with
+        other processes. You can override this function to let
+        :term:`experiments` emit the process's private state.
 
-    def pull_data(self):
+        Returns:
+            An empty dictionary. You may override this behavior to
+            return your process's private state.
+        """
         return {}
 
     @abc.abstractmethod
-    def ports_schema(self):
-        '''
-        ports_schema returns a dictionary that declares which states are expected by the processes,
-        and how each state will behave.
-
-        state keys can be assigned properties through schema_keys declared in Store:
-            '_default'
-            '_updater'
-            '_divider'
-            '_value'
-            '_properties'
-            '_emit'
-            '_serializer'
-        '''
+    def ports_schema(self) -> Schema:
+        """Get the schemas for each port.
+
+        This must be overridden by any subclasses.
+
+        Returns:
+            A dictionary that declares which states are expected by the
+            processes, and how each state will behave. State keys can be
+            assigned properties through schema_keys declared in
+            :py:class:`vivarium.core.store.Store`.
+        """
         return {}
 
-    def or_default(self, parameters, key):
-        return parameters.get(key, self.defaults[key])
+    def or_default(self, parameters: Dict[str, Any], key: str) -> Any:
+        """Get parameter from dictionary, falling back to defaults.
+
+        Args:
+            parameters: Dictionary to get parameter from.
+            key: Parameter key.
 
-    def derive_defaults(self, original_key, derived_key, f):
-        source = self.parameters.get(original_key)
-        self.parameters[derived_key] = f(source)
-        return self.parameters[derived_key]
+        Returns:
+            The value of ``key`` from ``parameters``, or the value in
+            the class defaults if not in ``parameters``.
+
+        Raises:
+            KeyError: If ``key`` is in neither the provided dictionary
+                nor the process defaults.
+        """
+        return parameters.get(key, self.defaults[key])
 
     @abc.abstractmethod
-    def next_update(self, timestep, states):
-        '''
-        Find the next update given the current states this process cares about.
-        This is the main function a new process would override.
-        '''
+    def next_update(
+            self, timestep: Union[float, int], states: State) -> Update:
+        """Compute the next update to the simulation state.
+
+        Args:
+            timestep: The duration for which the update should be
+                computed.
+            states: The pre-update simulation state. This will take the
+                same form as the process's schema, except with a value
+                for each variable.
+
+        Returns:
+            An empty dictionary for now. This should be overridden by
+            each subclass to return an update.
+        """
         return {}
 
 
 class Deriver(Process, metaclass=abc.ABCMeta):
-    def is_deriver(self):
+    """Base class for :term:`derivers`."""
+
+    def is_deriver(self) -> bool:
+        """Returns ``True`` to signal this process is a deriver."""
         return True
 
 
-def run_update(connection, process):
+def _run_update(connection: Connection, process: Process) -> None:
     running = True
 
     while running:
@@ -478,46 +618,76 @@ def run_update(connection, process):
     connection.close()
 
 
-class ParallelProcess(object):
-    def __init__(self, process):
+class ParallelProcess:
+    def __init__(self, process: Process) -> None:
+        """Wraps a :py:class:`Process` for multiprocessing.
+
+        To run a simulation distributed across multiple processors, we
+        use Python's multiprocessing tools. This object runs in the main
+        process and manages communication between the main (parent)
+        process and the child process with the :py:class:`Process` that
+        this object manages.
+
+        Args:
+            process: The Process to manage.
+        """
         self.process = process
         self.parent, self.child = Pipe()
         self.multiprocess = Multiprocess(
-            target=run_update,
+            target=_run_update,
             args=(self.child, self.process))
         self.multiprocess.start()
 
-    def update(self, interval, states):
+    def update(
+            self, interval: Union[float, int], states: State) -> None:
+        """Request an update from the process.
+
+        Args:
+            interval: The length of the timestep for which the update
+                should be computed.
+            states: The pre-update state of the simulation.
+        """
         self.parent.send((interval, states))
 
-    def get(self):
+    def get(self) -> Update:
+        """Get an update from the process.
+
+        Returns:
+            The update from the process.
+        """
         return self.parent.recv()
 
-    def end(self):
+    def end(self) -> None:
+        """End the child process."""
         self.parent.send((-1, None))
         self.multiprocess.join()
 
 
-def test_composite_initial_state():
+def test_composite_initial_state() -> None:
     """
-    test that initial state in composite merges individual processes' initial states
+    test that initial state in composite merges individual processes'
+    initial states
     """
     class AA(Process):
         name = 'AA'
 
-        def initial_state(self, config=None):
+        def initial_state(self, config: Optional[dict] = None) -> State:
             return {'a_port': {'a': 1}}
 
-        def ports_schema(self):
+        def ports_schema(self) -> Schema:
             return {'a_port': {'a': {'_emit': True}}}
 
-        def next_update(self, timestep, states):
+        def next_update(
+                self,
+                timestep: Union[float, int],
+                states: State) -> Update:
             return {'a_port': {'a': 1}}
 
-    class BB(Composite):
+    class BB(Composer):
         name = 'BB'
 
-        def generate_processes(self, config):
+        def generate_processes(
+                self, config: Optional[dict]) -> Dict[str, Any]:
             return {
                 'a1': AA({}),
                 'a2': AA({}),
@@ -525,7 +695,7 @@ def generate_processes(self, config):
                     'a3_store': AA({})}
             }
 
-        def generate_topology(self, config):
+        def generate_topology(self, config: Optional[dict]) -> Topology:
             return {
                 'a1': {
                     'a_port': ('a1_store',)
@@ -556,7 +726,7 @@ def generate_topology(self, config):
 class ToyProcess(Process):
     name = 'toy'
 
-    def ports_schema(self):
+    def ports_schema(self) -> Schema:
         return {
             'A': {
                 'a': {'_default': 0},
@@ -565,7 +735,8 @@ def ports_schema(self):
                 'a': {'_default': 0},
                 'b': {'_default': 0}}}
 
-    def next_update(self, timestep, states):
+    def next_update(
+            self, timestep: Union[float, int], states: State) -> Update:
         return {
             'A': {
                 'a': 1,
@@ -575,17 +746,21 @@ def next_update(self, timestep, states):
                 'b': states['B']['a']}}
 
 
-class ToyComposite(Composite):
+class ToyComposite(Composer):
     defaults = {
         'A':  {'name': 'A'},
         'B': {'name': 'B'}}
 
-    def generate_processes(self, config=None):
+    def generate_processes(
+            self,
+            config: Optional[dict]) -> Dict[str, ToyProcess]:
+        assert config is not None
         return {
             'A': ToyProcess(config['A']),
             'B': ToyProcess(config['B'])}
 
-    def generate_topology(self, config=None):
+    def generate_topology(
+            self, config: Optional[dict] = None) -> Topology:
         return {
             'A': {
                 'A': ('aaa',),
@@ -595,27 +770,62 @@ def generate_topology(self, config=None):
                 'B': ('ccc',)}}
 
 
-def test_composite_merge():
+def test_composite_merge() -> None:
+    composer = ToyComposite()
+    initial_network = composer.generate()
 
-    generator = ToyComposite()
-    initial_network = generator.generate()
+    expected_initial_topology = {
+        'A': {
+            'A': ('aaa',),
+            'B': ('bbb',),
+        },
+        'B': {
+            'A': ('bbb',),
+            'B': ('ccc',),
+        },
+    }
+    assert initial_network['topology'] == expected_initial_topology
+
+    for key in ('A', 'B'):
+        assert key in initial_network['processes']
+        assert isinstance(initial_network['processes'][key], ToyProcess)
 
     # merge
-    merge_processes = {
+    merge_processes: Dict[str, Process] = {
         'C': ToyProcess({'name': 'C'})}
-    merge_topology = {
+    merge_topology: Topology = {
         'C': {
             'A': ('aaa',),
             'B': ('bbb',)}}
-    generator.merge(
+    composer.merge(
         merge_processes,
         merge_topology)
 
     config = {'A': {'name': 'D'}, 'B': {'name': 'E'}}
-    merged_network = generator.generate(config)
+    merged_network = composer.generate(config)
+
+    expected_merged_topology = {
+        'A': {
+            'A': ('aaa',),
+            'B': ('bbb',),
+        },
+        'B': {
+            'A': ('bbb',),
+            'B': ('ccc',),
+        },
+        'C': {
+            'A': ('aaa',),
+            'B': ('bbb',),
+        },
+    }
+    assert merged_network['topology'] == expected_merged_topology
 
+    for key in ('A', 'B', 'C'):
+        assert key in merged_network['processes']
+        assert isinstance(merged_network['processes'][key], ToyProcess)
 
-def test_get_composite():
+
+def test_get_composite() -> None:
     a = ToyProcess({'name': 'a'})
 
     a.merge(
@@ -639,7 +849,9 @@ def test_get_composite():
 
 
 if __name__ == '__main__':
-    # test_composite_initial_state()
-    # test_composite_merge()
-
+    print('Running test_composite_initial_state')
+    test_composite_initial_state()
+    print('Running test_composite_merge()')
+    test_composite_merge()
+    print('Running test_get_composite()')
     test_get_composite()
diff --git a/vivarium/core/registry.py b/vivarium/core/registry.py
index c357a702..1b900f64 100644
--- a/vivarium/core/registry.py
+++ b/vivarium/core/registry.py
@@ -120,6 +120,7 @@ def update_merge(current_value, new_value):
             update[k] = new
     return update
 
+
 def update_set(current_value, new_value):
     """Set Updater
 
@@ -128,6 +129,7 @@ def update_set(current_value, new_value):
     """
     return new_value
 
+
 def update_accumulate(current_value, new_value):
     """Accumulate Updater
 
@@ -136,6 +138,7 @@ def update_accumulate(current_value, new_value):
     """
     return current_value + new_value
 
+
 def update_nonnegative_accumulate(current_value, new_value):
     """Non-negative Accumulate Updater
 
@@ -233,11 +236,11 @@ def divide_split_dict(state):
 
 
 def assert_no_divide(state):
-    '''Assert that the variable is never divided
+    """Assert that the variable is never divided
 
     Raises:
         AssertionError: If the variable is divided
-    '''
+    """
     raise AssertionError('Variable cannot be divided')
 
 
@@ -260,9 +263,9 @@ def deserialize(self, data):
 
 class NumpyScalarSerializer(Serializer):
     def serialize(self, data):
-        if isinstance(data, np.integer):
+        if isinstance(data, (int, np.integer)):
             return int(data)
-        if isinstance(data, np.floating):
+        if isinstance(data, (float, np.floating)):
             return float(data)
         raise ValueError(
             'Cannot serialize numpy scalar {} of type {}.'.format(
@@ -310,7 +313,7 @@ def serialize(self, data):
         return dict(data.parameters, _name=data.name)
 
 
-class FactorySerializer(Serializer):
+class ComposerSerializer(Serializer):
     def serialize(self, data):
         return dict(data.config, _name=str(type(data)))
 
diff --git a/vivarium/core/store.py b/vivarium/core/store.py
new file mode 100644
index 00000000..c4a540e6
--- /dev/null
+++ b/vivarium/core/store.py
@@ -0,0 +1,1091 @@
+"""
+=====
+Store
+=====
+
+The file system for storing and updating state variables during an experiment.
+"""
+
+import copy
+import logging as log
+
+import numpy as np
+from pint import Quantity
+
+from vivarium import divider_registry, serializer_registry, updater_registry
+from vivarium.core.process import Process
+from vivarium.library.dict_utils import deep_merge, MULTI_UPDATE_KEY
+from vivarium.library.topology import without, dict_to_paths
+
+EMPTY_UPDATES = None, None, None
+
+
+def key_for_value(d, looking):
+    found = None
+    for key, value in d.items():
+        if looking == value:
+            found = key
+            break
+    return found
+
+
+def hierarchy_depth(hierarchy, path=()):
+    """
+    Create a mapping of every path in the hierarchy to the node living at
+    that path in the hierarchy.
+    """
+
+    base = {}
+
+    for key, inner in hierarchy.items():
+        down = tuple(path + (key,))
+        if isinstance(inner, dict):
+            base.update(hierarchy_depth(inner, down))
+        else:
+            base[down] = inner
+
+    return base
+
+
+def always_true(_):
+    return True
+
+
+def identity(y):
+    return y
+
+
+class Store:
+    """Holds a subset of the overall model state
+
+    The total state of the model can be broken down into :term:`stores`,
+    each of which is represented by an instance of this `Store` class.
+    The store's state is a set of :term:`variables`, each of which is
+    defined by a set of :term:`schema key-value pairs`. The valid schema
+    keys are listed in :py:attr:`schema_keys`, and they are:
+
+    * **_default** (Type should match the variable value): The default
+      value of the variable.
+    * **_updater** (:py:class:`str`): The name of the :term:`updater` to
+      use. By default this is ``accumulate``.
+    * **_divider** (:py:class:`str`): The name of the :term:`divider` to
+      use. Note that ``_divider`` is not included in the ``schema_keys``
+      set because it can be applied to any node in the hierarchy, not
+      just leaves (which represent variables).
+    * **_value** (Type should match the variable value): The current
+      value of the variable. This is ``None`` by default.
+    * **_properties** (:py:class:`dict`): Extra properties of the
+      variable that don't have a specific schema key. This is an empty
+      dictionary by default.
+    * **_emit** (:py:class:`bool`): Whether to emit the variable to the
+      :term:`emitter`. This is ``False`` by default.
+    """
+    schema_keys = {
+        '_default',
+        '_updater',
+        '_value',
+        '_properties',
+        '_emit',
+        '_serializer',
+    }
+
+    def __init__(self, config, outer=None, source=None):
+        self.outer = outer
+        self.inner = {}
+        self.subschema = {}
+        self.subtopology = {}
+        self.properties = {}
+        self.default = None
+        self.updater = None
+        self.value = None
+        self.units = None
+        self.divider = None
+        self.emit = False
+        self.sources = {}
+        self.leaf = False
+        self.serializer = None
+        self.topology = {}
+
+        self.apply_config(config, source)
+
+    def check_default(self, new_default):
+        defaults_equal = False
+        if self.default is not None:
+            self_default_comp = self.default
+            new_default_comp = new_default
+            if isinstance(self_default_comp, np.ndarray):
+                self_default_comp = self.default.tolist()
+            if isinstance(new_default_comp, np.ndarray):
+                new_default_comp = self.default.tolist()
+            defaults_equal = self_default_comp == new_default_comp
+        if defaults_equal:
+            if (
+                not isinstance(new_default, np.ndarray)
+                and not isinstance(self.default, np.ndarray)
+                and new_default == 0
+                and self.default != 0
+            ):
+                log.debug(
+                    '_default schema conflict: %s and %s. selecting %s',
+                    str(self.default), str(new_default), str(self.default))
+                return self.default
+            log.debug(
+                '_default schema conflict: %s and %s. selecting %s',
+                str(self.default), str(new_default), str(new_default))
+        return new_default
+
+    def check_value(self, new_value):
+        if self.value is not None and new_value != self.value:
+            raise Exception(
+                '_value schema conflict: {} and {}'.format(
+                    new_value, self.value))
+        return new_value
+
+    def merge_subtopology(self, subtopology):
+        self.subtopology = deep_merge(self.subtopology, subtopology)
+
+    def apply_subschema_config(self, subschema):
+        self.subschema = deep_merge(
+            self.subschema,
+            subschema)
+
+    def apply_config(self, config, source=None):
+        """
+        Expand the tree by applying additional config.
+
+        Special keys for the config are:
+
+        * _default - Default value for this node.
+        * _properties - An arbitrary map of keys to values. This can be used
+          for any properties which exist outside of the operation of the
+          tree (like mass or energy).
+        * _updater - Which updater to use. Default is 'accumulate' which
+          adds the new value to the existing value, but 'set' is common
+          as well. You can also provide your own function here instead
+          of a string key into the updater library.
+        * _emit - whether or not to emit the values under this point in
+           the tree.
+        * _divider - What to do with this node when division happens.
+          Default behavior is to leave it alone, but you can also pass
+          'split' here, or a function of your choosing. If you need
+          other values from the state you need to supply a dictionary
+          here containing the updater and the topology for where the
+          other state values are coming from. This has two keys:
+
+            * divider - a function that takes the existing value and any
+              values supplied from the adjoining topology.
+            * topology - a mapping of keys to paths where the value for
+              those keys will be found. This will be passed in as the
+              second argument to the divider function.
+
+        * _subschema/* - If this node was declared to house an unbounded set
+          of related states, the schema for these states is held in this
+          nodes subschema and applied whenever new subkeys are added
+          here.
+        * _subtopology - The subschema is informed by the subtopology to
+          map the process perspective to the actual tree structure.
+        """
+
+        if '*' in config:
+            self.apply_subschema_config(config['*'])
+            config = without(config, '*')
+
+        if '_subschema' in config:
+            if source:
+                self.sources[source] = config['_subschema']
+            self.apply_subschema_config(config['_subschema'])
+            config = without(config, '_subschema')
+
+        if '_subtopology' in config:
+            self.merge_subtopology(config['_subtopology'])
+            config = without(config, '_subtopology')
+
+        if '_topology' in config:
+            self.topology = config['_topology']
+            config = without(config, '_topology')
+
+        if '_divider' in config:
+            self.divider = config['_divider']
+            if isinstance(self.divider, str):
+                self.divider = divider_registry.access(self.divider)
+            if isinstance(self.divider, dict) and isinstance(
+                    self.divider['divider'], str):
+                self.divider['divider'] = divider_registry.access(
+                    self.divider['divider'])
+            config = without(config, '_divider')
+
+        if self.schema_keys & set(config.keys()):
+            if self.inner:
+                raise Exception(
+                    'trying to assign leaf values to a branch at: {}'.format(
+                        self.path_for()))
+            self.leaf = True
+
+            if '_units' in config:
+                self.units = config['_units']
+                self.serializer = serializer_registry.access('units')
+
+            if '_serializer' in config:
+                self.serializer = config['_serializer']
+                if isinstance(self.serializer, str):
+                    self.serializer = serializer_registry.access(
+                        self.serializer)
+
+            if '_default' in config:
+                self.default = self.check_default(config.get('_default'))
+                if isinstance(self.default, Quantity):
+                    self.units = self.units or self.default.units
+                    self.serializer = (self.serializer or
+                                       serializer_registry.access('units'))
+                elif isinstance(self.default, list) and \
+                        len(self.default) > 0 and \
+                        isinstance(self.default[0], Quantity):
+                    self.units = self.units or self.default[0].units
+                    self.serializer = (self.serializer or
+                                       serializer_registry.access('units'))
+                elif isinstance(self.default, np.ndarray):
+                    self.serializer = (self.serializer or
+                                       serializer_registry.access('numpy'))
+
+            if '_value' in config:
+                self.value = self.check_value(config.get('_value'))
+                if isinstance(self.value, Quantity):
+                    self.units = self.value.units
+
+            self.updater = config.get(
+                '_updater',
+                self.updater or 'accumulate',
+            )
+
+            self.properties = deep_merge(
+                self.properties,
+                config.get('_properties', {}))
+
+            self.emit = config.get('_emit', self.emit)
+
+            if source:
+                self.sources[source] = config
+
+        else:
+            if self.leaf and config:
+                raise Exception(
+                    'trying to assign create inner for leaf node: {}'.format(
+                        self.path_for()))
+
+            # self.value = None
+
+            for key, child in config.items():
+                if key not in self.inner:
+                    self.inner[key] = Store(child, outer=self, source=source)
+                else:
+                    self.inner[key].apply_config(child, source=source)
+
+    def get_updater(self, update):
+        updater = self.updater
+        if isinstance(update, dict) and '_updater' in update:
+            updater = update['_updater']
+
+        if isinstance(updater, str):
+            updater = updater_registry.access(updater)
+        return updater
+
+    def get_config(self, sources=False):
+        """
+        Assemble a dictionary representation of the config for this node.
+        A desired property is that the node can be exactly recreated by
+        applying the resulting config to an empty node again.
+        """
+
+        config = {}
+
+        if self.properties:
+            config['_properties'] = self.properties
+        if self.subschema:
+            config['_subschema'] = self.subschema
+        if self.subtopology:
+            config['_subtopology'] = self.subtopology
+        if self.divider:
+            config['_divider'] = self.divider
+
+        if sources and self.sources:
+            config['_sources'] = self.sources
+
+        if self.inner:
+            child_config = {
+                key: child.get_config(sources)
+                for key, child in self.inner.items()}
+            config.update(child_config)
+
+        else:
+            config.update({
+                '_default': self.default,
+                '_value': self.value})
+            if self.updater:
+                config['_updater'] = self.updater
+            if self.units:
+                config['_units'] = self.units
+            if self.emit:
+                config['_emit'] = self.emit
+
+        return config
+
+    def top(self):
+        """
+        Find the top of this tree.
+        """
+
+        if self.outer:
+            return self.outer.top()
+        return self
+
+    def path_for(self):
+        """
+        Find the path to this node.
+        """
+
+        if self.outer:
+            key = key_for_value(self.outer.inner, self)
+            above = self.outer.path_for()
+            return above + (key,)
+        return tuple()
+
+    def get_value(self, condition=None, f=None):
+        """
+        Pull the values out of the tree in a structure symmetrical to the tree.
+        """
+
+        if self.inner:
+            if condition is None:
+                condition = always_true
+
+            if f is None:
+                f = identity
+
+            return {
+                key: f(child.get_value(condition, f))
+                for key, child in self.inner.items()
+                if condition(child)}
+        if self.subschema:
+            return {}
+        return self.value
+
+    def get_path(self, path):
+        """
+        Get the node at the given path relative to this node.
+        """
+
+        if path:
+            step = path[0]
+            if step == '..':
+                child = self.outer
+            else:
+                child = self.inner.get(step)
+
+            if child:
+                return child.get_path(path[1:])
+            # TODO: more handling for bad paths?
+            # TODO: check deleted?
+            return None
+        return self
+
+    def get_paths(self, paths):
+        return {
+            key: self.get_path(path)
+            for key, path in paths.items()}
+
+    def get_values(self, paths):
+        return {
+            key: self.get_in(path)
+            for key, path in paths.items()}
+
+    def get_in(self, path):
+        return self.get_path(path).get_value()
+
+    def get_template(self, template):
+        """
+        Pass in a template dict with None for each value you want to
+        retrieve from the tree!
+        """
+
+        state = {}
+        for key, value in template.items():
+            child = self.inner[key]
+            if value is None:
+                state[key] = child.get_value()
+            else:
+                state[key] = child.get_template(value)
+        return state
+
+    def emit_data(self):
+        data = {}
+        if self.inner:
+            for key, child in self.inner.items():
+                child_data = child.emit_data()
+                if child_data is not None or child_data == 0:
+                    data[key] = child_data
+            return data
+        if self.emit:
+            if self.serializer:
+                if isinstance(self.value, list) and self.units:
+                    return self.serializer.serialize(
+                        [v.to(self.units) for v in self.value])
+                if self.units:
+                    return self.serializer.serialize(
+                        self.value.to(self.units))
+                return self.serializer.serialize(self.value)
+            if self.units:
+                return self.value.to(self.units).magnitude
+            return self.value
+        return None
+
+    def delete_path(self, path):
+        """
+        Delete the subtree at the given path.
+        """
+
+        if not path:
+            self.inner = {}
+            self.value = None
+            return self
+        target = self.get_path(path[:-1])
+        remove = path[-1]
+        if remove in target.inner:
+            lost = target.inner[remove]
+            del target.inner[remove]
+            return lost
+        return None
+
+    def divide_value(self):
+        """
+        Apply the divider for each node to the value in that node to
+        assemble two parallel divided states of this subtree.
+        """
+
+        if self.divider:
+            # divider is either a function or a dict with topology
+            if isinstance(self.divider, dict):
+                divider = self.divider['divider']
+                topology = self.divider['topology']
+                state = self.outer.get_values(topology)
+                return divider(self.get_value(), state)
+            return self.divider(self.get_value())
+        if self.inner:
+            daughters = [{}, {}]
+            for key, child in self.inner.items():
+                division = child.divide_value()
+                if division:
+                    for daughter, divide in zip(daughters, division):
+                        daughter[key] = divide
+            return daughters
+        return None
+
+    def reduce(self, reducer, initial=None):
+        """
+        Call the reducer on each node accumulating over the result.
+        """
+
+        value = initial
+
+        for path, node in self.depth():
+            value = reducer(value, path, node)
+        return value
+
+    def set_value(self, value):
+        """
+        Set the value for the given tree elements directly instead of using
+        the updaters from their nodes.
+        """
+
+        if self.inner or self.subschema:
+            for child, inner_value in value.items():
+                if child not in self.inner:
+                    if self.subschema:
+                        self.inner[child] = Store(self.subschema, self)
+                    else:
+                        pass
+                        # TODO: continue to ignore extra keys?
+
+                if child in self.inner:
+                    self.inner[child].set_value(inner_value)
+        else:
+            self.value = value
+
+    def apply_defaults(self):
+        """
+        If value is None, set to default.
+        """
+        if self.inner:
+            for child in self.inner.values():
+                child.apply_defaults()
+        else:
+            if self.value is None:
+                self.value = self.default
+
+    def apply_update(self, update, state=None):
+        """
+        Given an arbitrary update, map all the values in that update
+        to their positions in the tree where they apply, and update
+        these values using each node's `_updater`.
+
+        Arguments:
+            update: The update being applied.
+            state: The state at the start of the time step.
+
+        There are five topology update methods, which use the following
+        special update keys:
+
+        * `_add` - Adds states into the subtree, given a list of dicts
+            containing:
+
+            * path - Path to the added state key.
+            * state - The value of the added state.
+
+        * `_move` - Moves a node from a source to a target location in the
+          tree. This uses an update to an :term:`outer` port, which
+          contains both the source and target node locations. Can move
+          multiple nodes according to a list of dicts containing:
+
+            * source - the source path from an outer process port
+            * target - the location where the node will be placed.
+
+        * `_generate` - The value has four keys, which are essentially
+          the arguments to the `generate()` function:
+
+            * path - Path into the tree to generate this subtree.
+            * processes - Tree of processes to generate.
+            * topology - Connections of all the process's `ports_schema()`.
+            * initial_state - Initial state for this new subtree.
+
+        * `_divide` - Performs cell division by constructing two new
+          daughter cells and removing the mother. Takes a dict with two keys:
+
+            * mother - The id of the mother (for removal)
+            * daughters - List of two new daughter generate directives, of the
+                same form as the `_generate` value above.
+
+        * `_delete` - The value here is a list of paths (tuples) to delete from
+          the tree.
+
+
+        Additional special update keys for different update operations:
+
+        * `_updater` - Override the default updater with any updater you want.
+
+        * `_reduce` - This allows a reduction over the entire subtree from some
+          point downward. Its three keys are:
+
+            * from - What point to start the reduction.
+            * initial - The initial value of the reduction.
+            * reducer - A function of three arguments, which is called
+              on every node from the `from` point in the tree down:
+
+                * value - The current accumulated value of the reduction.
+                * path - The path to this point in the tree
+                * node - The actual node being visited.
+
+              This function returns the next `value` for the reduction.
+              The result of the reduction will be assigned to this point
+              in the tree.
+        """
+
+        if isinstance(update, dict) and MULTI_UPDATE_KEY in update:
+            # apply multiple updates to same node
+            multi_update = update[MULTI_UPDATE_KEY]
+            assert isinstance(multi_update, list)
+            for update_value in multi_update:
+                self.apply_update(update_value, state)
+            return EMPTY_UPDATES
+
+        if self.inner or self.subschema:
+            # Branch update: this node has an inner
+
+            process_updates, topology_updates, deletions = [], [], []
+            update = dict(update)  # avoid mutating the caller's dict
+
+            add_entries = update.pop('_add', None)
+            if add_entries is not None:
+                # add a list of sub-states
+                for added in add_entries:
+                    path = (added['key'],)
+                    added_state = added['state']
+
+                    target = self.establish_path(path, {})
+                    self.apply_subschema_path(path)
+                    target.apply_defaults()
+                    target.set_value(added_state)
+
+            move_entries = update.pop('_move', None)
+            if move_entries is not None:
+                # move nodes from source to target path
+                for move in move_entries:
+                    # get the source node
+                    source_key = move['source']
+                    source_path = (source_key,)
+                    source_node = self.get_path(source_path)
+
+                    # move source node to target path
+                    target_port = move['target']
+                    target_topology = state.topology[target_port]
+                    target_node = state.outer.get_path(target_topology)
+                    target = target_node.add_node(source_path, source_node)
+                    target_path = target.path_for() + source_path
+
+                    # find the paths to all the processes
+                    source_process_paths = source_node.depth(
+                        filter_function=lambda x: isinstance(x.value, Process))
+
+                    # find the process and topology updates
+                    for path, process in source_process_paths:
+                        process_updates.append((
+                            target_path + path, process.value))
+                        topology_updates.append((
+                            target_path + path, process.topology))
+
+                    self.delete_path(source_path)
+
+                    here = self.path_for()
+                    source_absolute = tuple(here + source_path)
+                    deletions.append(source_absolute)
+
+            generate_entries = update.pop('_generate', None)
+            if generate_entries is not None:
+                # generate a list of new processes
+                for generate in generate_entries:
+                    key = generate.get('key')
+                    path = (key,) if key else tuple()
+
+                    here = self.path_for()
+
+                    self.generate(
+                        path,
+                        generate['processes'],
+                        generate['topology'],
+                        generate['initial_state'])
+
+                    root = here + path
+                    process_paths = dict_to_paths(root, generate['processes'])
+                    process_updates.extend(process_paths)
+
+                    topology_paths = [
+                        (root + (key,), topology)
+                        for key, topology in generate['topology'].items()]
+                    topology_updates.extend(topology_paths)
+
+                    self.apply_subschema_path(path)
+                    self.get_path(path).apply_defaults()
+
+            divide = update.pop('_divide', None)
+            if divide is not None:
+                # use dividers to find initial states for daughters
+                mother = divide['mother']
+                daughters = divide['daughters']
+                initial_state = self.inner[mother].get_value(
+                    condition=lambda child: not
+                    (isinstance(child.value, Process)),
+                    f=lambda child: copy.deepcopy(child))
+                daughter_states = self.inner[mother].divide_value()
+
+                here = self.path_for()
+
+                for daughter, daughter_state in \
+                        zip(daughters, daughter_states):
+
+                    # use initial state as default, merge in divided values
+                    initial_state = deep_merge(
+                        initial_state,
+                        daughter_state)
+
+                    daughter_key = daughter['key']
+                    daughter_path = (daughter_key,)
+
+                    self.generate(
+                        daughter_path,
+                        daughter['processes'],
+                        daughter['topology'],
+                        daughter['initial_state'])
+
+                    root = here + daughter_path
+                    process_paths = dict_to_paths(root, daughter['processes'])
+                    process_updates.extend(process_paths)
+
+                    topology_paths = [
+                        (root + (key,), topology)
+                        for key, topology in daughter['topology'].items()]
+                    topology_updates.extend(topology_paths)
+
+                    self.apply_subschema_path(daughter_path)
+                    target = self.get_path(daughter_path)
+                    target.apply_defaults()
+                    target.set_value(initial_state)
+
+                mother_path = (mother,)
+                self.delete_path(mother_path)
+                deletions.append(tuple(here + mother_path))
+
+            delete_keys = update.pop('_delete', None)
+
+            for key, value in update.items():
+                if key in self.inner:
+                    inner = self.inner[key]
+                    inner_topology, inner_processes, inner_deletions = \
+                        inner.apply_update(value, state)
+
+                    if inner_topology:
+                        topology_updates.extend(inner_topology)
+                    if inner_processes:
+                        process_updates.extend(inner_processes)
+                    if inner_deletions:
+                        deletions.extend(inner_deletions)
+
+                elif key == '..':
+                    self.outer.apply_update(value, state)
+
+            if delete_keys is not None:
+                # delete a list of paths
+                here = self.path_for()
+                for key in delete_keys:
+                    path = (key,)
+                    self.delete_path(path)
+                    deletions.append(tuple(here + path))
+
+            return topology_updates, process_updates, deletions
+
+        # Leaf update: this node has no inner
+
+        updater = self.get_updater(update)
+
+        if isinstance(update, dict) and '_reduce' in update:
+            reduction = update['_reduce']
+            top = self.get_path(reduction.get('from'))
+            update = top.reduce(
+                reduction['reducer'],
+                initial=reduction['initial'])
+
+        if isinstance(update, dict) and \
+                self.schema_keys and set(update.keys()):
+            if '_updater' in update:
+                update = update.get('_value', self.default)
+
+        self.value = updater(self.value, update)
+
+        return EMPTY_UPDATES
+
+    def inner_value(self, key):
+        """
+        Get the value of an inner state
+        """
+
+        if key in self.inner:
+            return self.inner[key].get_value()
+        return None
+
+    def topology_state(self, topology):
+        """
+        Fill in the structure of the given topology with the values at all
+        the paths the topology points at. Essentially, anywhere in the topology
+        that has a tuple path will be filled in with the value at that path.
+
+        This is the inverse function of the standalone `inverse_topology`.
+        """
+
+        state = {}
+
+        for key, path in topology.items():
+            if key == '*':
+                if isinstance(path, dict):
+                    node, path = self.outer_path(path)
+                    for child, child_node in node.inner.items():
+                        state[child] = child_node.topology_state(path)
+                else:
+                    node = self.get_path(path)
+                    for child, child_node in node.inner.items():
+                        state[child] = child_node.get_value()
+            elif isinstance(path, dict):
+                node, path = self.outer_path(path)
+                state[key] = node.topology_state(path)
+            else:
+                state[key] = self.get_path(path).get_value()
+        return state
+
+    def schema_topology(self, schema, topology):
+        """
+        Fill in the structure of the given schema with the values
+        located according to the given topology.
+        """
+
+        state = {}
+
+        if self.leaf:
+            state = self.get_value()
+        else:
+            for key, subschema in schema.items():
+                path = topology.get(key)
+                if key == '*':
+                    if isinstance(path, dict):
+                        node, path = self.outer_path(path)
+                        for child, child_node in node.inner.items():
+                            state[child] = child_node.schema_topology(
+                                subschema, path)
+                    else:
+                        node = self.get_path(path)
+                        for child, child_node in node.inner.items():
+                            state[child] = child_node.schema_topology(
+                                subschema, {})
+                elif key == '_divider':
+                    pass
+                elif isinstance(path, dict):
+                    node, path = self.outer_path(path)
+                    state[key] = node.schema_topology(subschema, path)
+                else:
+                    if path is None:
+                        path = (key,)
+                    node = self.get_path(path)
+                    if node:
+                        state[key] = node.schema_topology(subschema, {})
+                    else:
+                        # node is None, it was likely deleted
+                        print('{} is None'.format(path))
+
+        return state
+
+    def state_for(self, path, keys):
+        """
+        Get the value of a state at a given path
+        """
+
+        state = self.get_path(path)
+        if state is None:
+            return {}
+        if keys and keys[0] == '*':
+            return state.get_value()
+        return {
+            key: state.inner_value(key)
+            for key in keys}
+
+    def depth(self, path=(), filter_function=None):
+        """
+        Create a mapping of every path in the tree to the node living at
+        that path in the tree. An optional `filter` argument is a function
+        that can declares the instances that will be returned, for example:
+        * filter=lambda x: isinstance(x.value, Process)
+        """
+        base = []
+        if filter_function is None or filter_function(self):
+            base += [(path, self)]
+
+        for key, child in self.inner.items():
+            down = tuple(path + (key,))
+            base += child.depth(down, filter_function)
+        return base
+
+    def apply_subschema_path(self, path):
+        if path:
+            inner = self.inner[path[0]]
+            if self.subschema:
+                subtopology = self.subtopology or {}
+                inner.topology_ports(
+                    self.subschema,
+                    subtopology,
+                    source=self.path_for() + ('*',))
+            inner.apply_subschema_path(path[1:])
+
+    def apply_subschema(self, subschema=None, subtopology=None):
+        """
+        Apply a subschema to all inner nodes (either provided or from this
+        node's personal subschema) as governed by the given/personal
+        subtopology.
+        """
+
+        if subschema is None:
+            subschema = self.subschema
+        if subtopology is None:
+            subtopology = self.subtopology or {}
+
+        inner = list(self.inner.values())
+
+        for child in inner:
+            child.topology_ports(
+                subschema,
+                subtopology,
+                source=self.path_for() + ('*',))
+
+    def apply_subschemas(self):
+        """
+        Apply all subschemas from all nodes at this point or lower in the tree.
+        """
+
+        if self.subschema:
+            self.apply_subschema()
+        for child in self.inner.values():
+            child.apply_subschemas()
+
+    def update_subschema(self, path, subschema):
+        """
+        Merge a new subschema into an existing subschema at the given path.
+        """
+
+        target = self.get_path(path)
+        if target.subschema is None:
+            target.subschema = subschema
+        else:
+            target.subschema = deep_merge(
+                target.subschema,
+                subschema)
+        return target
+
+    def establish_path(self, path, config, source=None):
+        """
+        Create a node at the given path if it does not exist, then
+        apply a config to it.
+
+        Paths can include '..' to go up a level (which raises an exception
+        if that level does not exist).
+        """
+
+        if len(path) > 0:
+            path_step = path[0]
+            remaining = path[1:]
+
+            if path_step == '..':
+                if not self.outer:
+                    raise Exception(
+                        'outer does not exist for path: {}'.format(path))
+
+                return self.outer.establish_path(
+                    remaining,
+                    config,
+                    source=source)
+            if path_step not in self.inner:
+                self.inner[path_step] = Store(
+                    {}, outer=self, source=source)
+
+            return self.inner[path_step].establish_path(
+                remaining,
+                config,
+                source=source)
+        self.apply_config(config, source=source)
+        return self
+
+    def add_node(self, path, node):
+        """ Add a node instance at the provided path """
+        target = self.establish_path(path[:-1], {})
+        if target.get_value() and path[-1] in target.get_value():
+            # this path already exists, update it
+            self.apply_update({path[-1]: node.get_value()})
+        else:
+            node.outer = target
+            target.inner.update({path[-1]: node})
+        return target
+
+    def outer_path(self, path, source=None):
+        """
+        Address a topology with the `_path` keyword if present,
+        establishing a path to this node and using it as the
+        starting point for future path operations.
+        """
+
+        node = self
+        if '_path' in path:
+            node = self.establish_path(
+                path['_path'],
+                {},
+                source=source)
+            path = without(path, '_path')
+
+        return node, path
+
+    def topology_ports(self, schema, topology, source=None):
+        """
+        Distribute a schema into the tree by mapping its ports
+        according to the given topology.
+        """
+
+        source = source or self.path_for()
+
+        if set(schema.keys()) & self.schema_keys:
+            self.get_path(topology).apply_config(schema)
+        else:
+            mismatch_topology = (
+                set(topology.keys()) - set(schema.keys()))
+            mismatch_schema = (
+                set(schema.keys()) - set(topology.keys()))
+            if mismatch_topology:
+                raise Exception(
+                    'topology for the process {} \n at path {} uses '
+                    'undeclared ports: {}'.format(
+                        source, self.path_for(), str(mismatch_topology)))
+            if mismatch_schema:
+                log.info(
+                    'process %s has ports that are not included in '
+                    'the topology: %s', str(source), str(mismatch_schema))
+
+            for port, subschema in schema.items():
+                path = topology.get(port, (port,))
+
+                if port == '*':
+                    subschema_config = {
+                        '_subschema': subschema}
+                    if isinstance(path, dict):
+                        node, path = self.outer_path(
+                            path, source=source)
+                        node.merge_subtopology(path)
+                        node.apply_config(subschema_config)
+                    else:
+                        node = self.establish_path(
+                            path,
+                            subschema_config,
+                            source=source)
+                    node.apply_subschema()
+                    node.apply_defaults()
+
+                elif isinstance(path, dict):
+                    node, path = self.outer_path(
+                        path, source=source)
+
+                    node.topology_ports(
+                        subschema,
+                        path,
+                        source=source)
+
+                else:
+                    self.establish_path(
+                        path,
+                        subschema,
+                        source=source)
+
+    def generate_paths(self, processes, topology):
+        for key, subprocess in processes.items():
+            subtopology = topology[key]
+            if isinstance(subprocess, Process):
+                process_state = Store({
+                    '_value': subprocess,
+                    '_updater': 'set',
+                    '_topology': subtopology,
+                    '_serializer': 'process'}, outer=self)
+                self.inner[key] = process_state
+
+                subprocess.schema = subprocess.get_schema()
+                self.topology_ports(
+                    subprocess.schema,
+                    subtopology,
+                    source=self.path_for() + (key,))
+            else:
+                if key not in self.inner:
+                    self.inner[key] = Store({}, outer=self)
+                self.inner[key].generate_paths(
+                    subprocess,
+                    subtopology)
+
+    def generate(self, path, processes, topology, initial_state):
+        """
+        Generate a subtree of this store at the given path.
+        The processes will be mapped into locations in the tree by the
+        topology, and once everything is constructed the initial_state
+        will be applied.
+        """
+
+        target = self.establish_path(path, {})
+        target.generate_paths(processes, topology)
+        target.set_value(initial_state)
+        target.apply_subschemas()
+        target.apply_defaults()
diff --git a/vivarium/core/types.py b/vivarium/core/types.py
new file mode 100644
index 00000000..a170a2cd
--- /dev/null
+++ b/vivarium/core/types.py
@@ -0,0 +1,37 @@
+"""
+=====
+Types
+=====
+"""
+
+from typing import Tuple, Dict, Any, Union
+
+
+#: Relative path between nodes in the :term:`hierarchy`. Like Unix file
+#: paths, ".." refers to the parent directory.
+HierarchyPath = Tuple[str, ...]
+
+#: Mapping from :term:`ports` to paths that specify which node in the
+#: :term:`hierarchy` should be wired to each port.
+Topology = Dict[str, Union[HierarchyPath, dict]]
+
+#: A dictionary that specifies a :term:`schema`.
+Schema = Dict[str, Any]
+
+#: A dictionary that has the form of a :term:`schema`, except instead of
+#: specifying the properties of each :term:`variable`, it specifies each
+#: variable's value.
+State = Dict[str, Any]
+
+#: A dictionary defining an :term:`update`.
+Update = Dict[str, Any]
+
+#: A dictionary that specifies the :term:`processes` and
+#: :term:`topology` of a :term:`composite`.
+CompositeDict = Dict[str, Any]
+# TODO(jerry): ^ Dict values should be Union[Process, Topology, CompositeDict]
+#  but Process would make recursive imports and CompositeDict would make
+#  recursive types. Fix this by switching from a dict to a class or dataclass.
+
+#: A dictionary that contains the retrieved output of an :term:`experiment`
+OutputDict = Dict[Union[Tuple, str], Any]
diff --git a/vivarium/library/dict_utils.py b/vivarium/library/dict_utils.py
index d00d4bee..e1bf9f9f 100644
--- a/vivarium/library/dict_utils.py
+++ b/vivarium/library/dict_utils.py
@@ -1,8 +1,11 @@
+from __future__ import annotations
+
 import collections.abc
 import copy
 from functools import reduce
 import operator
 import traceback
+from typing import Optional
 
 from vivarium.library.units import Quantity
 
@@ -211,16 +214,24 @@ def str_to_tuple_keys(dictionary):
     return dictionary
 
 
-def keys_list(d):
+def keys_list(d: dict) -> list:
+    """Return list(d.keys())."""
     return list(d.keys())
 
 
 def value_in_embedded_dict(
-        data, timeseries: dict = None, time_index=None):
+        data: dict,
+        timeseries: dict = None,
+        time_index: Optional[float] = None) -> dict:
     """
-    converts data from a single time step into an embedded dictionary with lists of values.
+    converts data from a single time step into an embedded dictionary with lists
+    of values.
     If the value has a unit, saves under a key with (key, unit_string).
     """
+    # TODO(jerry): ^^^ Explain this further. Note that this function modifies
+    #  timeseries.
+    # TODO(jerry): Refine the type declarations.
+    # TODO(jerry): Use dictionary.setdefault(key, default) to simplify.
     timeseries = timeseries or {}
 
     for key, value in data.items():
@@ -274,7 +285,7 @@ def get_value_from_path(dictionary, path):
 
 
 def make_path_dict(embedded_dict):
-    """ converts embedded dict to a flat dict with path names as keys """
+    """ converts embedded_dict to a flat dict with path names as keys """
     path_dict = {}
     paths_list = get_path_list_from_dict(embedded_dict)
     for path in paths_list:
diff --git a/vivarium/library/make_network.py b/vivarium/library/make_network.py
index 9e80da36..f83d9f5d 100644
--- a/vivarium/library/make_network.py
+++ b/vivarium/library/make_network.py
@@ -140,8 +140,7 @@ def save_network(nodes, edges, plotOutDir='out/network'):
     '''
 
     out_dir = os.path.join(plotOutDir)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     nodes_out = os.path.join(out_dir, 'nodes.csv')
     edges_out = os.path.join(out_dir, 'edges.csv')
 
diff --git a/vivarium/library/pretty.py b/vivarium/library/pretty.py
index 5ab4a60f..d35f6d76 100644
--- a/vivarium/library/pretty.py
+++ b/vivarium/library/pretty.py
@@ -6,7 +6,7 @@
 import pint
 
 from vivarium.library.units import units
-from vivarium.core.process import Process, Factory
+from vivarium.core.process import Process, Composer
 
 
 def _json_serialize(elem):
@@ -16,7 +16,7 @@ def _json_serialize(elem):
         return '<function {}>'.format(elem.__name__)
     if inspect.isclass(elem):
         return str(elem)
-    if isinstance(elem, (Factory, Process)):
+    if isinstance(elem, (Composer, Process)):
         to_strip_regex = ' at 0x[0-9a-f]+>$'
         return re.sub(to_strip_regex, '>', repr(elem))
     if isinstance(elem, pint.Quantity):
diff --git a/vivarium/library/timeseries.py b/vivarium/library/timeseries.py
index f09d8ec8..69e02781 100644
--- a/vivarium/library/timeseries.py
+++ b/vivarium/library/timeseries.py
@@ -1,6 +1,11 @@
 import csv
+import io
 import os
-from typing import Dict, List
+from typing import Dict, List, Optional, Any
+
+import numpy as np
+
+from vivarium.library.dict_utils import flatten_timeseries
 
 
 def process_path_timeseries_for_csv(path_ts):
@@ -66,3 +71,238 @@ def save_flat_timeseries(timeseries, out_dir, filename):
         writer = csv.DictWriter(f, timeseries.keys(), delimiter=',')
         writer.writeheader()
         writer.writerows(rows)
+
+
+def assert_timeseries_close(
+    timeseries1, timeseries2, keys=None,
+    default_tolerance=(1 - 1e-10), tolerances: Optional[Dict[str, Any]] = None,
+    required_frac_checked=0.9,
+):
+    '''Check that two timeseries are similar.
+
+    Ensures that each pair of data points between the two timeseries are
+    within a tolerance of each other, after filtering out timepoints not
+    common to both timeseries.
+
+    Arguments:
+        timeseries1: One timeseries. Must be flattened and include times
+            under the 'time' key.
+        timeseries2: The other timeseries. Same requirements as
+            timeseries1.
+        keys: Keys of the timeseries whose values will be checked for
+            correlation. If not specified, all keys present in both
+            timeseries are used.
+        default_tolerance: The tolerance to use when not specified in
+            tolerances.
+        tolerances: Dictionary of key-value pairs where the key is a key
+            in both timeseries and the value is the tolerance to use
+            when checking that key.
+        required_frac_checked: The required fraction of timepoints in a
+            timeseries that must be checked. If this requirement is not
+            satisfied, which might occur if the two timeseries share few
+            timepoints, the test wll fail.
+
+    Raises:
+        AssertionError: If a pair of data points have a difference
+            strictly above the tolerance threshold or if too few
+            timepoints are common to both timeseries.
+    '''
+    tolerances = tolerances or {}
+    arrays1, arrays2, keys = _prepare_timeseries_for_comparison(
+        timeseries1, timeseries2, keys, required_frac_checked)
+    for key in keys:
+        tolerance = tolerances.get(key, default_tolerance)
+        close_mask = np.isclose(
+            arrays1[key], arrays2[key], atol=tolerance, equal_nan=True)
+        if not np.all(close_mask):
+            print('Timeseries 1:', arrays1[key][~close_mask])
+            print('Timeseries 2:', arrays2[key][~close_mask])
+            raise AssertionError(
+                'The data for {} differed by more than {}'.format(
+                    key, tolerance)
+            )
+
+
+def assert_timeseries_correlated(
+    timeseries1, timeseries2, keys=None,
+    default_threshold=(1 - 1e-10), thresholds: Optional[Dict[str, Any]] = None,
+    required_frac_checked=0.9,
+):
+    '''Check that two timeseries are correlated.
+
+    Uses a Pearson correlation coefficient. Only the data from
+    timepoints common to both timeseries are compared.
+
+    Arguments:
+        timeseries1: One timeseries. Must be flattened and include times
+            under the 'time' key.
+        timeseries2: The other timeseries. Same requirements as
+            timeseries1.
+        keys: Keys of the timeseries whose values will be checked for
+            correlation. If not specified, all keys present in both
+            timeseries are used.
+        default_threshold: The threshold correlation coefficient to use
+            when a threshold is not specified in thresholds.
+        thresholds: Dictionary of key-value pairs where the key is a key
+            in both timeseries and the value is the threshold
+            correlation coefficient to use when checking that key
+        required_frac_checked: The required fraction of timepoints in a
+            timeseries that must be checked. If this requirement is not
+            satisfied, which might occur if the two timeseries share few
+            timepoints, the test wll fail. This is also the fraction of
+            timepoints for each variable that must be non-nan in both
+            timeseries. Note that the denominator of this fraction is
+            the number of shared timepoints that are non-nan in either
+            of the timeseries.
+
+    Raises:
+        AssertionError: If a correlation is strictly below the
+            threshold or if too few timepoints are common to both
+            timeseries.
+    '''
+    thresholds = thresholds or {}
+    arrays1, arrays2, keys = _prepare_timeseries_for_comparison(
+        timeseries1, timeseries2, keys, required_frac_checked)
+    for key in keys:
+        both_nan = np.isnan(arrays1[key]) & np.isnan(arrays2[key])
+        valid_indices = ~(
+            np.isnan(arrays1[key]) | np.isnan(arrays2[key]))
+        frac_checked = valid_indices.sum() / (~both_nan).sum()
+        if frac_checked < required_frac_checked:
+            raise AssertionError(
+                'Timeseries share too few non-nan values for variable '
+                '{}: {} < {}'.format(
+                    key, frac_checked, required_frac_checked
+                )
+            )
+        corrcoef = np.corrcoef(
+            arrays1[key][valid_indices],
+            arrays2[key][valid_indices],
+        )[0][1]
+        threshold = thresholds.get(key, default_threshold)
+        if corrcoef < threshold:
+            raise AssertionError(
+                'The correlation coefficient for '
+                '{} is too small: {} < {}'.format(
+                    key, corrcoef, threshold)
+            )
+
+
+def _prepare_timeseries_for_comparison(
+    timeseries1, timeseries2, keys=None,
+    required_frac_checked=0.9,
+):
+    '''Prepare two timeseries for comparison
+
+    Arguments:
+        timeseries1: One timeseries. Must be flattened and include times
+            under the 'time' key.
+        timeseries2: The other timeseries. Same requirements as
+            timeseries1.
+        keys: Keys of the timeseries whose values will be checked for
+            correlation. If not specified, all keys present in both
+            timeseries are used.
+        required_frac_checked: The required fraction of timepoints in a
+            timeseries that must be checked. If this requirement is not
+            satisfied, which might occur if the two timeseries share few
+            timepoints, the test wll fail.
+
+    Returns:
+        A tuple of an ndarray for each of the two timeseries and a list of
+        the keys for the rows of the arrays. Each ndarray has a row for
+        each key, in the order of keys. The ndarrays have only the
+        columns corresponding to the timepoints common to both
+        timeseries.
+
+    Raises:
+        AssertionError: If a correlation is strictly below the
+            threshold or if too few timepoints are common to both
+            timeseries.
+    '''
+    if 'time' not in timeseries1 or 'time' not in timeseries2:
+        raise AssertionError('Both timeseries must have key "time"')
+    if keys is None:
+        keys = set(timeseries1.keys()) & set(timeseries2.keys())
+    else:
+        if 'time' not in keys:
+            keys.append('time')
+    keys = list(keys)
+    shared_times = set(timeseries1['time']) & set(timeseries2['time'])
+    frac_timepoints_checked = (
+        len(shared_times)
+        / min(len(timeseries1['time']), len(timeseries2['time']))
+    )
+    if frac_timepoints_checked < required_frac_checked:
+        raise AssertionError(
+            'The timeseries share too few timepoints: '
+            '{} < {}'.format(
+                frac_timepoints_checked, required_frac_checked)
+        )
+    masked = []
+    for ts in (timeseries1, timeseries2):
+        arrays_dict = timeseries_to_ndarrays(ts, keys)
+        arrays_dict_shared_times = {}
+        for key, array in arrays_dict.items():
+            # Filters out times after data ends
+            times_for_array = arrays_dict['time'][:len(array)]
+            arrays_dict_shared_times[key] = array[
+                np.isin(times_for_array, list(shared_times))]
+        masked.append(arrays_dict_shared_times)
+    return (
+        masked[0],
+        masked[1],
+        keys,
+    )
+
+
+def timeseries_to_ndarrays(timeseries, keys=None):
+    '''After filtering by keys, convert timeseries to dict of ndarrays
+
+    Returns:
+        dict: Mapping from timeseries variables to an ndarray of the
+            variable values.
+    '''
+    if keys is None:
+        keys = timeseries.keys()
+    return {
+        key: np.array(timeseries[key], dtype=float) for key in keys}
+
+
+def load_timeseries(path_to_csv):
+    '''Load a timeseries saved as a CSV using save_timeseries.
+
+    The timeseries is returned in flattened form.
+    '''
+    with io.open(path_to_csv, 'r', newline='') as f:
+        reader = csv.DictReader(f)
+        timeseries = {}
+        for row in reader:
+            for header, elem in row.items():
+                if elem == '':
+                    elem = None
+                if elem is not None:
+                    elem = float(elem)
+                timeseries.setdefault(header, []).append(elem)
+    return timeseries
+
+
+def save_timeseries(timeseries, out_dir='out', filename='timeseries.csv'):
+    flattened = flatten_timeseries(timeseries)
+    save_flat_timeseries(flattened, out_dir, filename)
+
+
+def agent_timeseries_from_data(data, agents_key='cells'):
+    timeseries = {}
+    for time, all_states in data.items():
+        agent_data = all_states[agents_key]
+        for agent_id, ports in agent_data.items():
+            if agent_id not in timeseries:
+                timeseries[agent_id] = {}
+            for port_id, states in ports.items():
+                if port_id not in timeseries[agent_id]:
+                    timeseries[agent_id][port_id] = {}
+                for state_id, state in states.items():
+                    if state_id not in timeseries[agent_id][port_id]:
+                        timeseries[agent_id][port_id][state_id] = []
+                    timeseries[agent_id][port_id][state_id].append(state)
+    return timeseries
diff --git a/vivarium/library/units.py b/vivarium/library/units.py
index 850b11af..f798c737 100644
--- a/vivarium/library/units.py
+++ b/vivarium/library/units.py
@@ -20,6 +20,8 @@
 False
 '''
 
+from typing import Any
+
 import pint
 # noinspection PyProtectedMember
 from pint.quantity import _Quantity as Quantity
@@ -33,7 +35,7 @@
 pint.set_application_registry(units)
 
 
-def remove_units(collection):
+def remove_units(collection: Any) -> Any:
     '''Strip the units from a collection or scalar
 
     If no units are present, the provided object is returned unaltered.
diff --git a/vivarium/plots/agents_multigen.py b/vivarium/plots/agents_multigen.py
index 5e3af888..7e1ab3ca 100644
--- a/vivarium/plots/agents_multigen.py
+++ b/vivarium/plots/agents_multigen.py
@@ -206,11 +206,12 @@ def plot_agents_multigen(
             ax.yaxis.get_offset_text().set_fontsize(tick_label_size)
 
             # if last state in this column, add time ticks
-            if (stack_column and row_idx >= highest_row-1) or \
-               (not stack_column and (row_idx >= highest_row or
-                    path_idx >= len(ordered_paths[port_id]) - 1)):
-                        set_axes(ax, True)
-                        ax.set_xlabel('time (s)', fontsize=title_size)
+            if ((stack_column and row_idx >= highest_row - 1) or
+                    (not stack_column and
+                     (row_idx >= highest_row or
+                      path_idx >= len(ordered_paths[port_id]) - 1))):
+                set_axes(ax, True)
+                ax.set_xlabel('time (s)', fontsize=title_size)
             else:
                 set_axes(ax)
             ax.set_xlim([time_vec[0], time_vec[-1]])
diff --git a/vivarium/plots/simulation_output.py b/vivarium/plots/simulation_output.py
index 3871a306..ba23b7a2 100644
--- a/vivarium/plots/simulation_output.py
+++ b/vivarium/plots/simulation_output.py
@@ -62,6 +62,7 @@ def plot_simulation_output(
               highlighted, even if they are otherwise to be removed
               TODO: Obsolete?
     '''
+    int_or_float = (int, np.int32, np.int64, float, np.float32, np.float64)
 
     settings = settings or {}
     plot_fontsize = 8
@@ -147,7 +148,7 @@ def plot_simulation_output(
 
             ax = fig.add_subplot(grid[row_idx, col_idx])  # grid is (row, column)
 
-            if not all(isinstance(state, (int, float, np.int64, np.int32)) for state in series):
+            if not all(isinstance(state, int_or_float) for state in series):
                 # check if series is a list of ints or floats
                 ax.title.set_text(str(port) + ': ' + str(state_id) + ' (non numeric)')
             else:
diff --git a/vivarium/plots/topology.py b/vivarium/plots/topology.py
index c37c4976..97c2e2d1 100644
--- a/vivarium/plots/topology.py
+++ b/vivarium/plots/topology.py
@@ -6,9 +6,10 @@
 import numpy as np
 import matplotlib.pyplot as plt
 from matplotlib.path import Path
+from matplotlib.figure import Figure
 import networkx as nx
 
-from vivarium.core.process import Process, Factory
+from vivarium.core.process import Process, Composer
 
 
 def construct_storage_path() -> Path:
@@ -101,7 +102,7 @@ def graph_figure(
         buffer: float = 1.0,
         border_width: float = 3,
         label_pos: float = 0.65,
-) -> plt.Figure:
+) -> Figure:
     """ Make a figure from a networkx graph.
 
     :param graph: the networkx.Graph to plot
@@ -264,9 +265,9 @@ def next_update(self, timestep, states):
             'c': {'molecule': 1}}
 
 
-class MergePort(Factory):
+class MergePort(Composer):
     """combines both of MultiPort's ports into one store"""
-    name = 'multi_port_generator'
+    name = 'multi_port_composer'
     defaults = {
         'topology': {
             'multiport1': {
diff --git a/vivarium/processes/agent_names.py b/vivarium/processes/agent_names.py
deleted file mode 100644
index 1cf93ac9..00000000
--- a/vivarium/processes/agent_names.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from typing import Any, Dict
-
-from vivarium.core.process import Deriver
-
-
-class AgentNames(Deriver):
-
-    name = 'agent_names'
-    defaults: Dict[str, Any] = {}
-
-    def __init__(self, initial_parameters=None):
-        if initial_parameters is None:
-            initial_parameters = {}
-        super(AgentNames, self).__init__(initial_parameters)
-
-    def ports_schema(self):
-        return {
-            'agents': {
-                '*': {}
-            },
-            'names': {
-                '_default': [],
-                '_updater': 'set',
-                '_emit': True,
-            },
-        }
-
-    def next_update(self, timestep, states):
-        agents = states['agents']
-        return {'names': list(agents.keys())}
diff --git a/vivarium/processes/burst.py b/vivarium/processes/burst.py
index 5dc9ec5a..d33416a4 100644
--- a/vivarium/processes/burst.py
+++ b/vivarium/processes/burst.py
@@ -9,14 +9,14 @@
 from vivarium.core.experiment import pp
 from vivarium.core.process import (
     Deriver,
-    Factory,
+    Composer,
 )
 from vivarium.core.composition import (
     compose_experiment,
-    GENERATORS_KEY,
+    COMPOSER_KEY,
     PROCESS_OUT_DIR,
 )
-from vivarium.processes.exchange_a import ExchangeA
+from vivarium.composites.toys import ExchangeA
 from vivarium.processes.timeline import TimelineProcess
 
 
@@ -81,7 +81,7 @@ def next_update(self, timestep, states):
 
 
 # test
-class ToyAgent(Factory):
+class ToyAgent(Composer):
     defaults = {
         'exchange': {
             'uptake_rate': 0.1},
@@ -147,7 +147,7 @@ def test_burst():
 
     # declare the hierarchy
     hierarchy = {
-        GENERATORS_KEY: [
+        COMPOSER_KEY: [
             {
                 'type': TimelineProcess,
                 'config': {'timeline': timeline},
@@ -159,7 +159,7 @@ def test_burst():
         ],
         'agents': {
             agent_1_id: {
-                GENERATORS_KEY: {
+                COMPOSER_KEY: {
                     'type': ToyAgent,
                     'config': {
                         'agent_id': agent_1_id,
@@ -167,7 +167,7 @@ def test_burst():
                 },
                 'agents': {
                     agent_2_id: {
-                        GENERATORS_KEY: {
+                        COMPOSER_KEY: {
                             'type': ToyAgent,
                             'config': {
                                 'agent_id': agent_2_id
@@ -203,8 +203,7 @@ def test_burst():
 
 def run_burst():
     out_dir = os.path.join(PROCESS_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     output = test_burst()
     pp(output)
 
diff --git a/vivarium/processes/engulf.py b/vivarium/processes/engulf.py
index a8388b92..70a42a1e 100644
--- a/vivarium/processes/engulf.py
+++ b/vivarium/processes/engulf.py
@@ -9,14 +9,14 @@
 from vivarium.core.experiment import pp
 from vivarium.core.process import (
     Deriver,
-    Factory,
+    Composer,
 )
 from vivarium.core.composition import (
     compose_experiment,
-    GENERATORS_KEY,
+    COMPOSER_KEY,
     PROCESS_OUT_DIR,
 )
-from vivarium.processes.exchange_a import ExchangeA
+from vivarium.composites.toys import ExchangeA
 from vivarium.processes.timeline import TimelineProcess
 
 
@@ -72,7 +72,7 @@ def next_update(self, timestep, states):
 
 
 # test
-class ToyAgent(Factory):
+class ToyAgent(Composer):
     defaults = {
         'exchange': {'uptake_rate': 0.1},
         'engulf': {
@@ -126,7 +126,7 @@ def test_engulf():
 
     # declare the hierarchy
     hierarchy = {
-        GENERATORS_KEY: [
+        COMPOSER_KEY: [
             {
                 'type': TimelineProcess,
                 'config': {'timeline': timeline},
@@ -138,7 +138,7 @@ def test_engulf():
         ],
         'agents': {
             agent_id: {
-                GENERATORS_KEY: {
+                COMPOSER_KEY: {
                     'type': ToyAgent,
                     'config': {
                         'exchange': {
@@ -176,8 +176,7 @@ def test_engulf():
 
 def run_engulf():
     out_dir = os.path.join(PROCESS_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     output = test_engulf()
     pp(output)
 
diff --git a/vivarium/processes/exchange_a.py b/vivarium/processes/exchange_a.py
deleted file mode 100644
index e251f14e..00000000
--- a/vivarium/processes/exchange_a.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from vivarium.core.process import Process
-
-
-class ExchangeA(Process):
-    """ Exchange A
-
-    A minimal exchange process that moves molecules 'A' between internal
-    and external ports
-    """
-    name = 'exchange_a'
-    defaults = {
-        'uptake_rate': 0.0,
-        'secrete_rate': 0.0}
-
-    def __init__(self, parameters=None):
-        super(ExchangeA, self).__init__(parameters)
-        self.uptake_rate = self.parameters['uptake_rate']
-        self.secrete_rate = self.parameters['secrete_rate']
-
-    def ports_schema(self):
-        return {
-            'internal': {
-                'A': {
-                    '_default': 0.0,
-                    '_emit': True}},
-            'external': {
-                'A': {
-                    '_default': 0.0,
-                    '_emit': True}}}
-
-    def next_update(self, timestep, states):
-        a_in = states['internal']['A']
-        a_out = states['external']['A']
-        delta_a_in = a_out * self.uptake_rate - a_in * self.secrete_rate
-        return {
-            'internal': {'A': delta_a_in},
-            'external': {'A': -delta_a_in}}
diff --git a/vivarium/processes/growth_rate.py b/vivarium/processes/growth_rate.py
index 1665ae23..c87877c1 100644
--- a/vivarium/processes/growth_rate.py
+++ b/vivarium/processes/growth_rate.py
@@ -58,9 +58,10 @@ def next_update(self, timestep, states):
         growth_noise = states['rates']['growth_noise']
 
         variable_update = {
-            variable: value * (np.exp(growth_rate[variable] +
-                       np.random.normal(0, growth_noise[variable])
-                       ) * timestep) - value
+            variable: value * (
+                    np.exp(growth_rate[variable] +
+                           np.random.normal(0, growth_noise[variable]))
+                    * timestep) - value
             for variable, value in variables.items()}
 
         return {'variables': variable_update}
@@ -102,6 +103,5 @@ def main():
     plot_simulation_output(data, plot_settings, out_dir)
 
 
-
 if __name__ == '__main__':
     main()
diff --git a/vivarium/processes/injector.py b/vivarium/processes/injector.py
index d54cb333..754bbb6e 100644
--- a/vivarium/processes/injector.py
+++ b/vivarium/processes/injector.py
@@ -16,8 +16,8 @@
 from vivarium.core.composition import (
     simulate_process,
     TEST_OUT_DIR,
-    save_timeseries,
 )
+from vivarium.library.timeseries import save_timeseries
 from vivarium.plots.simulation_output import plot_simulation_output
 from vivarium.core.process import Process
 
@@ -99,12 +99,11 @@ def test_injector():
 
 def main():
     out_dir = os.path.join(TEST_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     timeseries = run_injector()
     plot_settings = {}
     plot_simulation_output(timeseries, plot_settings, out_dir)
-    save_timeseries(timeseries, out_dir)
+    save_timeseries(timeseries, out_dir, NAME + '.csv')
 
 
 if __name__ == '__main__':
diff --git a/vivarium/processes/meta_division.py b/vivarium/processes/meta_division.py
index 0337d4d2..728278ad 100644
--- a/vivarium/processes/meta_division.py
+++ b/vivarium/processes/meta_division.py
@@ -4,11 +4,11 @@
 
 from vivarium.core.process import (
     Deriver,
-    Factory
+    Composer
 )
 from vivarium.core.composition import (
     compose_experiment,
-    GENERATORS_KEY,
+    COMPOSER_KEY,
     PROCESS_OUT_DIR,
 )
 from vivarium.core.experiment import (
@@ -16,7 +16,7 @@
 )
 
 # processes
-from vivarium.processes.exchange_a import ExchangeA
+from vivarium.composites.toys import ExchangeA
 from vivarium.processes.timeline import TimelineProcess
 
 NAME = 'meta_division'
@@ -53,7 +53,7 @@ def __init__(self, initial_parameters=None):
 
         # must provide a compartment to generate new daughters
         self.agent_id = initial_parameters['agent_id']
-        self.generator = initial_parameters['generator']
+        self.composer = initial_parameters['composer']
         self.daughter_ids_function = self.or_default(
             initial_parameters, 'daughter_ids_function')
 
@@ -81,12 +81,12 @@ def next_update(self, timestep, states):
             daughter_updates = []
 
             for daughter_id in daughter_ids:
-                generator = self.generator.generate({
+                composer = self.composer.generate({
                     'agent_id': daughter_id})
                 daughter_updates.append({
                     'key': daughter_id,
-                    'processes': generator['processes'],
-                    'topology': generator['topology'],
+                    'processes': composer['processes'],
+                    'topology': composer['topology'],
                     'initial_state': {}})
 
             log.info(
@@ -104,7 +104,7 @@ def next_update(self, timestep, states):
 
 
 # test
-class ToyAgent(Factory):
+class ToyAgent(Composer):
     defaults = {
         'exchange': {'uptake_rate': 0.1},
         'agents_path': ('..', '..', 'agents')}
@@ -114,7 +114,7 @@ def generate_processes(self, config):
         division_config = dict(
             {},
             agent_id=agent_id,
-            generator=self)
+            composer=self)
 
         return {
             'exchange': ExchangeA(config['exchange']),
@@ -159,7 +159,7 @@ def test_division():
 
     # declare the hierarchy
     hierarchy = {
-        GENERATORS_KEY: [
+        COMPOSER_KEY: [
             {
                 'type': TimelineProcess,
                 'config': {'timeline': timeline},
@@ -171,7 +171,7 @@ def test_division():
         ],
         'agents': {
             agent_id: {
-                GENERATORS_KEY: {
+                COMPOSER_KEY: {
                     'type': ToyAgent,
                     'config': {'agent_id': agent_id}
                 },
@@ -203,8 +203,7 @@ def test_division():
 
 def run_division():
     out_dir = os.path.join(PROCESS_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     output = test_division()
     pp(output)
 
diff --git a/vivarium/processes/remove.py b/vivarium/processes/remove.py
index 4e9baae5..40beed43 100644
--- a/vivarium/processes/remove.py
+++ b/vivarium/processes/remove.py
@@ -10,14 +10,13 @@
 from vivarium.core.experiment import pp
 from vivarium.core.process import (
     Deriver,
-    Factory,
+    Composer,
 )
 from vivarium.core.composition import (
-    simulate_compartment_in_experiment,
+    simulate_composer,
     PROCESS_OUT_DIR,
 )
-from vivarium.processes.exchange_a import ExchangeA
-
+from vivarium.composites.toys import ExchangeA
 
 NAME = 'remove'
 
@@ -51,7 +50,7 @@ def next_update(self, timestep, states):
 
 
 # test
-class ToyLivingCompartment(Factory):
+class ToyLivingCompartment(Composer):
     defaults = {
         'exchange': {'uptake_rate': 0.1},
         'death': {}
@@ -104,7 +103,7 @@ def test_remove():
         'timeline': {
             'timeline': timeline},
         'initial_state': initial_state}
-    output = simulate_compartment_in_experiment(
+    output = simulate_composer(
         compartment,
         settings)
 
@@ -116,8 +115,7 @@ def test_remove():
 
 def run_remove():
     out_dir = os.path.join(PROCESS_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     output = test_remove()
     pp(output)
 
diff --git a/vivarium/processes/swap_processes.py b/vivarium/processes/swap_processes.py
index 263c0a96..ce88a20e 100644
--- a/vivarium/processes/swap_processes.py
+++ b/vivarium/processes/swap_processes.py
@@ -9,15 +9,14 @@
 
 from vivarium.core.process import (
     Deriver,
-    Factory,
+    Composer,
 )
 from vivarium.core.composition import (
-    simulate_compartment_in_experiment,
+    simulate_composer,
     PROCESS_OUT_DIR,
 )
 from vivarium.plots.simulation_output import plot_simulation_output
-from vivarium.processes.exchange_a import ExchangeA
-
+from vivarium.composites.toys import ExchangeA
 
 NAME = 'swap_compartment'
 
@@ -84,7 +83,7 @@ def next_update(self, timestep, states):
 
 
 # test
-class ToyDeadCompartment(Factory):
+class ToyDeadCompartment(Composer):
     defaults = {
         'secrete': {
             'secrete_rate': 0.1}}
@@ -100,7 +99,7 @@ def generate_topology(self, config):
                 'external': ('external',)}}
 
 
-class ToyLivingCompartment(Factory):
+class ToyLivingCompartment(Composer):
     defaults = {
         'exchange': {'uptake_rate': 0.1},
         'death': {
@@ -155,7 +154,7 @@ def test_death():
         'timeline': {
             'timeline': timeline},
         'initial_state': initial_state}
-    output = simulate_compartment_in_experiment(
+    output = simulate_composer(
         compartment,
         settings)
 
@@ -175,8 +174,7 @@ def test_death():
 
 def run_death():
     out_dir = os.path.join(PROCESS_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     output = test_death()
     plot_simulation_output(output, {}, out_dir)
 
diff --git a/vivarium/processes/template_process.py b/vivarium/processes/template_process.py
index fb49f3b4..221fba90 100644
--- a/vivarium/processes/template_process.py
+++ b/vivarium/processes/template_process.py
@@ -8,7 +8,7 @@
 
 from vivarium.core.process import Process
 from vivarium.core.composition import (
-    simulate_process_in_experiment,
+    simulate_process,
     PROCESS_OUT_DIR,
 )
 from vivarium.plots.simulation_output import plot_simulation_output
@@ -67,12 +67,6 @@ def ports_schema(self):
             },
         }
 
-    def derivers(self):
-        '''
-        declare which derivers are needed for this process
-        '''
-        return {}
-
     def next_update(self, timestep, states):
 
         # get the states
@@ -118,7 +112,7 @@ def run_template_process():
     sim_settings = {
         'total_time': 10,
         'initial_state': initial_state}
-    output = simulate_process_in_experiment(template_process, sim_settings)
+    output = simulate_process(template_process, sim_settings)
 
     return output
 
@@ -136,8 +130,7 @@ def main():
     '''Simulate the process and plot results.'''
     # make an output directory to save plots
     out_dir = os.path.join(PROCESS_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
 
     output = run_template_process()
 
diff --git a/vivarium/processes/toy_gillespie.py b/vivarium/processes/toy_gillespie.py
index 5e68506a..fff816ab 100644
--- a/vivarium/processes/toy_gillespie.py
+++ b/vivarium/processes/toy_gillespie.py
@@ -9,10 +9,10 @@
 import os
 import numpy as np
 
-from vivarium.core.process import Process, Composite
+from vivarium.core.process import Process, Composer
 from vivarium.core.composition import (
     process_in_experiment,
-    compartment_in_experiment,
+    composite_in_experiment,
     PROCESS_OUT_DIR,
 )
 from vivarium.core.registry import process_registry
@@ -170,7 +170,7 @@ def next_update(self, timestep, states):
         return super().next_update(timestep, states)
 
 
-class StochasticTscTrl(Composite):
+class StochasticTscTrl(Composer):
     """
     composite toy model with stochastic transcription,
     deterministic translation.
@@ -226,12 +226,12 @@ def test_gillespie_process(total_time=1000):
     return gillespie_data
 
 def test_gillespie_composite(total_time=10000):
-    stochastic_tsc_trl = StochasticTscTrl()
+    stochastic_tsc_trl = StochasticTscTrl().generate()
 
     # make the experiment
     exp_settings = {
         'experiment_id': 'stochastic_tsc_trl'}
-    stoch_experiment = compartment_in_experiment(
+    stoch_experiment = composite_in_experiment(
         stochastic_tsc_trl,
         exp_settings)
 
diff --git a/vivarium/processes/tree_mass.py b/vivarium/processes/tree_mass.py
index 36ed336c..eff18caf 100644
--- a/vivarium/processes/tree_mass.py
+++ b/vivarium/processes/tree_mass.py
@@ -166,8 +166,7 @@ def test_tree_mass():
 
 def run_tree_mass():
     out_dir = os.path.join(PROCESS_OUT_DIR, NAME)
-    if not os.path.exists(out_dir):
-        os.makedirs(out_dir)
+    os.makedirs(out_dir, exist_ok=True)
     output = test_tree_mass()
     pp(output)
 


Test patch:
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index e29ccd71..d8441acd 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -14,7 +14,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8]
+        python-version: [3.8]
     steps:
     - uses: actions/checkout@v2
     - name: Setup Python ${{ matrix.python-version }}
diff --git a/pytest.ini b/pytest.ini
index af64e508..9b393007 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -3,4 +3,4 @@ python_files = *.py
 addopts = --doctest-modules --strict-markers
 testpaths = vivarium
 markers =
-    slow: indicates slow tests (deselect with '-m "not slow"')
+    slow: indicates slow tests (deselect with '-m "not slow"')
\ No newline at end of file
diff --git a/runscripts/doctests.sh b/runscripts/doctests.sh
index f113c2a2..d4f1ff5e 100755
--- a/runscripts/doctests.sh
+++ b/runscripts/doctests.sh
@@ -1,4 +1,6 @@
 #!/bin/sh
 # Run this project's doctests.
 
+set -eu
+
 doc/test.sh ci
diff --git a/runscripts/pytest.sh b/runscripts/pytest.sh
index 4bbbfcbe..42342902 100755
--- a/runscripts/pytest.sh
+++ b/runscripts/pytest.sh
@@ -2,4 +2,6 @@
 # Run pytest tests on this project.
 # Configured by `pytest.ini` and `.coveragerc`.
 
+set -eu
+
 pytest --cov=vivarium