Repository: popsim-consortium/demes-python (language: python)

Issue:
mypy throws crazy errors about ruamel every second time it's run
Maybe there's a problem with my setup? I tried removing the `.mypy_cache`. `ruamel.yaml` does in fact have type annotations.
```
$ mypy
Success: no issues found in 14 source files
$ mypy
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/2and3/jinja2/sandbox.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/http/client.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/email/mime/base.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/http/server.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/2and3/jinja2/__init__.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/osutil.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/usr/lib/python3.8/site-packages/packaging/specifiers.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/statistics.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/compat.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/warning_types.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/pathlib.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/entry_points.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
demes/load_dump.py:6: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
demes/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
demes/__init__.py:1: note: See https://mypy.readthedocs.io/en/latest/running_mypy.html#missing-imports
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/email/mime/nonmultipart.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/email/mime/multipart.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/http/cookiejar.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/urllib/request.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/urllib/error.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/http_client.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/email_mime_base.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/SimpleHTTPServer.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/CGIHTTPServer.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/BaseHTTPServer.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/matching.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/tags.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/urllib/response.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/usr/lib/python3.8/site-packages/packaging/markers.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/_io/terminalwriter.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/_code/source.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/mark/expression.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/config/exceptions.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/deprecated.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
demes/__main__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
demes/convert/msprime_.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/stdlib/3/email/mime/text.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/urllib_error.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/http_cookiejar.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/email_mime_nonmultipart.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/email_mime_multipart.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/urllib/request.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/urllib/error.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/pycode/parser.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/pycode/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/parallel.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/rst.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/pycompat.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/highlighting.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/inspect.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/ext/autodoc/mock.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/ext/autodoc/importer.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/domains/math.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/domains/changeset.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/directives/patches.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/directives/other.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/directives/code.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/docfields.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/versioning.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/transforms/references.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/transforms/i18n.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/domains/citation.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/directives/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/io.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/environment/adapters/asset.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/parsers.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/domains/std.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/domains/index.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/nodes.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/transforms/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/environment/adapters/toctree.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/addnodes.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/builders/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/i18n.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/docutils.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/theming.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/roles.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/registry.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/project.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/extension.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/events.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/environment/collectors/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/domains/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/ext/autodoc/directive.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/logging.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/environment/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/config.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/application.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/ext/autodoc/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/smartypants.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/domains/python.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/jinja2glue.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/search/en.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/search/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/util/template.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/sphinx/builders/gettext.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/_io/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
demes/convert/stdpopsim_.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
demes/convert/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/email_mime_text.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/_code/code.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/config/argparsing.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/database.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
tests/test_moments.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
tests/test_convert.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/urllib/__init__.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/moves/__init__.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/mypy/typeshed/third_party/3/six/__init__.pyi:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/_code/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/compat.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/shrinking/lexical.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/utils.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/reflection.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/dfa/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/floats.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/coverage.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/data.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/shrinking/learned_dfas.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/dfa/lstar.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/shrinking/floats.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/datatree.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/validation.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/shrinking/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/_settings.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/reporting.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/escalation.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/shrinking/dfas.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/shrinker.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/pareto.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/strategies.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/collections.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/engine.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/core.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/control.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/strings.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/recursive.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/numbers.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/misc.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/lazy.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/deferred.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/shared.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/functions.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/core.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/ipaddress.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/datetime.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/extra/pytz.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/extra/numpy.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/flatmapped.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/internal/conjecture/optimiser.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/featureflags.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/provisional.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/random.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/regex.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/extra/django/_fields.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/types.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/stateful.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/extra/django/_impl.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/strategies/_internal/attrs.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/hypothesis/extra/django/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/usr/lib/python3.8/site-packages/packaging/requirements.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/assertion/util.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/python_api.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
tests/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/config/findpaths.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/hookspec.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/config/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/mark/structures.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/mark/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/fixtures.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/recwarn.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/nodes.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/reports.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/assertion/truncate.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/runner.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/debugging.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/main.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/assertion/rewrite.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/python.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/assertion/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/pytest/collect.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/pytest/__init__.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/helpconfig.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/cacheprovider.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/capture.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/terminal.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
/home/grg/.local/lib/python3.8/site-packages/_pytest/warnings.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
tests/test_load_dump.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
tests/test_demes.py:1: error: Skipping analyzing 'ruamel': found module but no type hints or library stubs
Found 191 errors in 191 files (checked 14 source files)
```


Discussion / hints:
See https://github.com/python/mypy/issues/7276

Fix patch:
diff --git a/demes/__init__.py b/demes/__init__.py
index 9a1f278a..1ef84fdd 100644
--- a/demes/__init__.py
+++ b/demes/__init__.py
@@ -8,5 +8,18 @@
 except ImportError:
     pass
 
-from .demes import Epoch, Migration, Pulse, Deme, Graph, Split, Branch, Merge, Admix
+from .demes import (
+    Builder,
+    Epoch,
+    Migration,
+    SymmetricMigration,
+    AsymmetricMigration,
+    Pulse,
+    Deme,
+    Graph,
+    Split,
+    Branch,
+    Merge,
+    Admix,
+)
 from .load_dump import load_asdict, loads_asdict, load, loads, dump, dumps
diff --git a/demes/convert/msprime_.py b/demes/convert/msprime_.py
index 3f2e9a90..e4f726fe 100644
--- a/demes/convert/msprime_.py
+++ b/demes/convert/msprime_.py
@@ -1,5 +1,4 @@
 from typing import List, Mapping, Tuple
-import typing
 import math
 import collections
 import itertools
@@ -33,8 +32,8 @@ def to_msprime(graph: demes.Graph):
     pop_id = {deme.id: j for j, deme in enumerate(graph.demes)}
 
     def growth_rate(epoch: demes.Epoch) -> float:
-        initial_size = typing.cast(float, epoch.end_size)
-        final_size = typing.cast(float, epoch.start_size)
+        initial_size = epoch.end_size
+        final_size = epoch.start_size
         if initial_size == final_size:
             growth_rate = 0.0
         else:
@@ -61,7 +60,7 @@ def growth_rate(epoch: demes.Epoch) -> float:
             initial_size = Ne_invalid
             _growth_rate = 0.0
         else:
-            initial_size = typing.cast(float, deme.epochs[-1].end_size)
+            initial_size = deme.epochs[-1].end_size
             _growth_rate = growth_rate(deme.epochs[-1])
         population_configurations.append(
             msprime.PopulationConfiguration(
@@ -94,9 +93,7 @@ def growth_rate(epoch: demes.Epoch) -> float:
                         population_id=pop_id[deme.id],
                     )
                 )
-            if epoch == deme.epochs[0] and not math.isinf(
-                typing.cast(float, epoch.start_time)
-            ):
+            if epoch == deme.epochs[0] and not math.isinf(epoch.start_time):
                 # If this deme doesn't exist at time=inf, invalidate Ne when
                 # the deme ceases to exist.
                 demographic_events.append(
@@ -119,19 +116,15 @@ def growth_rate(epoch: demes.Epoch) -> float:
         )
 
     mig_rate_events = []
-    for migration in reversed(graph.migrations):
-        dest = pop_id[migration.source]
-        source = pop_id[migration.dest]
-        start_time = migration.end_time
-        end_time = migration.start_time
 
+    def append_migration(dest, source, start_time, end_time, rate):
         if start_time == 0:
-            migration_matrix[dest][source] = migration.rate
+            migration_matrix[dest][source] = rate
         else:
             mig_rate_events.append(
                 msprime.MigrationRateChange(
                     time=start_time,
-                    rate=migration.rate,
+                    rate=rate,
                     matrix_index=(dest, source),
                 )
             )
@@ -141,6 +134,22 @@ def growth_rate(epoch: demes.Epoch) -> float:
             )
         )
 
+    for migration in reversed(graph.migrations):
+        rate = migration.rate
+        start_time = migration.end_time
+        end_time = migration.start_time
+        if isinstance(migration, demes.AsymmetricMigration):
+            dest = pop_id[migration.source]
+            source = pop_id[migration.dest]
+            append_migration(dest, source, start_time, end_time, rate)
+        else:
+            assert isinstance(migration, demes.SymmetricMigration)
+            for x, y in itertools.permutations(migration.demes, 2):
+                pop_x = pop_id[x]
+                pop_y = pop_id[y]
+                append_migration(pop_x, pop_y, start_time, end_time, rate)
+                append_migration(pop_y, pop_x, start_time, end_time, rate)
+
     # Collapse migration rate events in the same generation.
     # This is not strictly needed, but usually results in fewer events.
     mig_rate_events.sort(key=lambda de: de.time)
@@ -212,11 +221,11 @@ def from_msprime(
     # up the correct `Epoch`s, `Migration`s, and `Pulse`s outside of the graph.
     gtmp: Mapping[str, dict] = {"demes": {}}
 
-    # List of demes.Epoch, keyed by deme id.
-    epochs: Mapping[str, List[demes.Epoch]] = collections.defaultdict(list)
+    # List of epoch dicts, keyed by deme id.
+    epochs: Mapping[str, List[dict]] = collections.defaultdict(list)
     # List of deme.Migration, keyed by (source, dest) indexes
     migrations: Mapping[
-        Tuple[int, int], List[demes.Migration]
+        Tuple[int, int], List[demes.AsymmetricMigration]
     ] = collections.defaultdict(list)
     # migration_matrix in the previous ddb epoch
     prev_mm = np.zeros((num_pops, num_pops))
@@ -240,7 +249,7 @@ def from_msprime(
                         gtmp["demes"][pop_name] = {
                             "ancestors": [],
                             "proportions": [],
-                            "epochs": [demes.Epoch(start_size=1)],
+                            "epochs": [dict(start_size=1)],
                         }
                     pop_param_changes.add(pop_name)
 
@@ -257,7 +266,7 @@ def from_msprime(
                     gtmp["demes"][parent] = {
                         "ancestors": [],
                         "proportions": [],
-                        "epochs": [demes.Epoch(start_size=1)],
+                        "epochs": [dict(start_size=1)],
                     }
 
             if math.isclose(sum(proportions), 1):
@@ -266,7 +275,7 @@ def from_msprime(
                 # Set attributes after deme creation, to avoid internal
                 # checks about the ancestors' existence time intervals.
                 gtmp["demes"][child]["epochs"] = [
-                    demes.Epoch(start_time=ddb_epoch.start_time, start_size=1)
+                    dict(start_time=ddb_epoch.start_time, start_size=1)
                 ]
                 gtmp["demes"][child]["ancestors"] = ancestors
                 gtmp["demes"][child]["proportions"] = proportions
@@ -294,17 +303,15 @@ def from_msprime(
             if deme_id not in epochs:
                 epochs[deme_id].append(gtmp["demes"][deme_id]["epochs"][0])
             last_epoch = epochs[deme_id][-1]
-            last_epoch.end_time = ddb_epoch.start_time
-            if last_epoch.start_time == ddb_epoch.end_time:
-                last_epoch.start_size = pop.end_size
-            last_epoch.end_size = pop.start_size
+            last_epoch["end_time"] = ddb_epoch.start_time
+            if last_epoch.get("start_time") == ddb_epoch.end_time:
+                last_epoch["start_size"] = pop.end_size
+            last_epoch["end_size"] = pop.start_size
 
             if name[j] in pop_param_changes:
                 # Add new epoch, to be fixed in the next ddb_epoch iteration.
                 epochs[deme_id].append(
-                    demes.Epoch(
-                        start_time=ddb_epoch.start_time, end_time=0, start_size=1
-                    )
+                    dict(start_time=ddb_epoch.start_time, end_time=0, start_size=1)
                 )
 
         # Construct per-pair lists of migrations from the migration matrix.
@@ -315,7 +322,7 @@ def from_msprime(
                     continue
                 if prev_mm[j, k] != msp_mm[j, k] and msp_mm[j, k] != 0:
                     # new Migration
-                    m = demes.Migration(
+                    m = demes.AsymmetricMigration(
                         source=name[j],
                         dest=name[k],
                         start_time=ddb_epoch.end_time,
@@ -333,39 +340,40 @@ def from_msprime(
 
     for deme_id in epochs.keys():
         epoch = epochs[deme_id][0]
-        if epoch.start_time is None or math.isinf(epoch.start_time):
-            epochs[deme_id][0] = demes.Epoch(
-                start_time=epoch.start_time,
-                end_time=epoch.end_time,
-                start_size=epoch.end_size,
-                end_size=epoch.end_size,
+        start_time = epoch.get("start_time")
+        if start_time is None or math.isinf(start_time):
+            epochs[deme_id][0] = dict(
+                start_time=start_time,
+                end_time=epoch["end_time"],
+                start_size=epoch["end_size"],
+                end_size=epoch["end_size"],
             )
 
     # Create a fresh demes graph, now that we have complete epoch information
     # for each deme. This also validates consistency between parameters.
-    g = demes.Graph(
+    b = demes.Builder(
         description="Converted from msprime demography.",
         time_units="generations",
     )
 
     for deme_id, deme_dict in gtmp["demes"].items():
-        g.deme(
+        b.add_deme(
             deme_id,
             ancestors=deme_dict["ancestors"],
             proportions=deme_dict["proportions"],
-            start_time=epochs[deme_id][0].start_time,
+            start_time=epochs[deme_id][0]["start_time"],
             epochs=[
-                demes.Epoch(
-                    end_time=epoch.end_time,
-                    start_size=epoch.start_size,
-                    end_size=epoch.end_size,
+                dict(
+                    end_time=epoch["end_time"],
+                    start_size=epoch["start_size"],
+                    end_size=epoch["end_size"],
                 )
                 for epoch in epochs[deme_id]
             ],
         )
 
     for pulse in pulses:
-        g.pulse(
+        b.add_pulse(
             source=pulse.source,
             dest=pulse.dest,
             proportion=pulse.proportion,
@@ -374,7 +382,7 @@ def from_msprime(
 
     for migration_list in migrations.values():
         for migration in migration_list:
-            g.migration(
+            b.add_migration(
                 source=migration.source,
                 dest=migration.dest,
                 start_time=migration.start_time,
@@ -382,7 +390,7 @@ def from_msprime(
                 rate=migration.rate,
             )
 
-    return g
+    return b.resolve()
 
 
 if __name__ == "__main__":
diff --git a/demes/demes.py b/demes/demes.py
index 6fde2050..18e46719 100644
--- a/demes/demes.py
+++ b/demes/demes.py
@@ -1,5 +1,4 @@
-import typing
-from typing import List, Union, Optional, Dict
+from typing import List, Union, Optional, Dict, Mapping, MutableMapping, Any
 import itertools
 import math
 import numbers
@@ -67,27 +66,6 @@ def valid_deme_id(self, attribute, value):
         )
 
 
-def isclose(
-    a: Optional[Number],
-    b: Optional[Number],
-    *,
-    rel_tol=_ISCLOSE_REL_TOL,
-    abs_tol=_ISCLOSE_ABS_TOL,
-) -> bool:
-    """
-    Wrapper around math.isclose() that handles None.
-    """
-    if None in (a, b):
-        return (a,) == (b,)
-    else:
-        return math.isclose(
-            typing.cast(float, a),
-            typing.cast(float, b),
-            rel_tol=rel_tol,
-            abs_tol=abs_tol,
-        )
-
-
 def isclose_deme_proportions(
     a_ids: List[ID],
     a_proportions: List[Proportion],
@@ -107,13 +85,76 @@ def isclose_deme_proportions(
     a = sorted(zip(a_ids, a_proportions), key=operator.itemgetter(0))
     b = sorted(zip(b_ids, b_proportions), key=operator.itemgetter(0))
     for (a_id, a_proportion), (b_id, b_proportion) in zip(a, b):
-        if a_id != b_id or not isclose(
+        if a_id != b_id or not math.isclose(
             a_proportion, b_proportion, rel_tol=rel_tol, abs_tol=abs_tol
         ):
             return False
     return True
 
 
+def validate_item(name, value, required_type, scope):
+    if not isinstance(value, required_type):
+        raise TypeError(
+            f"{scope}: field '{name}' must be a {required_type}; "
+            f"current type is {type(value)}."
+        )
+
+
+# We need to use this trick because None is a meaninful input value for these
+# pop_x functions.
+NO_DEFAULT = object()
+
+
+def pop_item(data, name, *, required_type, default=NO_DEFAULT, scope=""):
+    if name in data:
+        value = data.pop(name)
+        validate_item(name, value, required_type, scope=scope)
+    else:
+        if default is NO_DEFAULT:
+            raise KeyError(f"{scope}: required field '{name}' not found")
+        value = default
+    return value
+
+
+def pop_list(data, name, default=NO_DEFAULT, required_type=None, scope=""):
+    value = pop_item(data, name, default=default, required_type=list)
+    if required_type is not None and default is not None:
+        for item in value:
+            validate_item(name, item, required_type, scope)
+    return value
+
+
+def pop_object(data, name, default=NO_DEFAULT, scope=""):
+    return pop_item(
+        data, name, default=default, required_type=MutableMapping, scope=scope
+    )
+
+
+def check_empty(data, scope):
+    assert isinstance(data, Mapping)
+    if len(data) != 0:
+        keys = list(data.keys())
+        if len(keys) == 1:
+            raise KeyError(f"{scope}: unexpected field: {keys[0]}")
+        else:
+            raise KeyError(f"{scope}: unexpected fields: {keys}")
+
+
+def check_defaults(defaults, allowed_fields, scope):
+    for key in defaults.keys():
+        if key not in allowed_fields:
+            raise KeyError(
+                f"{scope}: unexpected field: {key}. "
+                f"Allowed fields are: {allowed_fields}"
+            )
+
+
+def insert_defaults(data, defaults):
+    for key, value in defaults.items():
+        if key not in data:
+            data[key] = value
+
+
 @attr.s(auto_attribs=True, kw_only=True)
 class Epoch:
     """
@@ -130,65 +171,37 @@ class Epoch:
         If ``start_size != end_size``, the population size changes
         monotonically between the start and end times.
     :ivar size_function: The size change function. Common options are constant,
-        exponential, or linear, though any string is valid. Warning: downstream
-        simulators might not understand the size_function provided.
-    :ivar selfing_rate: An optional selfing rate for this epoch.
-    :ivar cloning_rate: An optional cloning rate for this epoch.
+        exponential, or linear, though any string is valid.
+
+        .. warning::
+
+            Downstream simulators might not understand the size_function provided.
+
+    :ivar selfing_rate: The selfing rate for this epoch.
+    :ivar cloning_rate: The cloning rate for this epoch.
     """
 
-    start_time: Optional[Time] = attr.ib(
-        default=None,
-        validator=attr.validators.optional([int_or_float, non_negative]),
-    )
-    end_time: Optional[Time] = attr.ib(
-        default=None,
-        validator=attr.validators.optional([int_or_float, non_negative, finite]),
-    )
-    start_size: Optional[Size] = attr.ib(
-        default=None,
-        validator=attr.validators.optional([int_or_float, positive, finite]),
-    )
-    end_size: Optional[Size] = attr.ib(
-        default=None,
-        validator=attr.validators.optional([int_or_float, positive, finite]),
-    )
-    size_function: Optional[str] = attr.ib(
-        default=None,
-        validator=attr.validators.optional(
-            [attr.validators.instance_of(str), nonzero_len]
-        ),
+    start_time: Time = attr.ib(validator=[int_or_float, non_negative])
+    end_time: Time = attr.ib(validator=[int_or_float, non_negative, finite])
+    start_size: Size = attr.ib(validator=[int_or_float, positive, finite])
+    end_size: Size = attr.ib(validator=[int_or_float, positive, finite])
+    size_function: str = attr.ib(
+        validator=[attr.validators.instance_of(str), nonzero_len]
     )
-    selfing_rate: Optional[Proportion] = attr.ib(
-        default=None,
-        validator=attr.validators.optional([int_or_float, unit_interval]),
+    selfing_rate: Proportion = attr.ib(
+        default=0, validator=[int_or_float, unit_interval]
     )
-    cloning_rate: Optional[Proportion] = attr.ib(
-        default=None,
-        validator=attr.validators.optional([int_or_float, unit_interval]),
+    cloning_rate: Proportion = attr.ib(
+        default=0, validator=[int_or_float, unit_interval]
     )
 
     def __attrs_post_init__(self):
-        if self.start_size is None and self.end_size is None:
-            raise ValueError("must set either start_size or end_size")
-        if (
-            self.start_time is not None
-            and self.end_time is not None
-            and self.start_time <= self.end_time
-        ):
+        if self.start_time <= self.end_time:
             raise ValueError("must have start_time > end_time")
-        if (
-            self.start_time is not None
-            and self.start_size is not None
-            and self.end_size is not None
-        ):
-            if math.isinf(self.start_time) and self.start_size != self.end_size:
-                raise ValueError("if start time is inf, must be a constant size epoch")
-        if (
-            self.size_function == "constant"
-            and self.start_size is not None
-            and self.end_size is not None
-            and self.start_size != self.end_size
-        ):
+        # XXX: these tests should use math.isclose()
+        if math.isinf(self.start_time) and self.start_size != self.end_size:
+            raise ValueError("if start time is inf, must be a constant size epoch")
+        if self.size_function == "constant" and self.start_size != self.end_size:
             raise ValueError("start_size != end_size, but size_function is constant")
 
     @property
@@ -204,10 +217,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-    ) -> bool:
+    ):
         """
-        Returns true if the epoch and ``other`` epoch implement essentially
-        the same epoch and raises AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following attributes:
         ``start_time``, ``end_time``, ``start_size``, ``end_size``,
         ``size_function``, ``selfing_rate``, ``cloning_rate``.
@@ -220,43 +233,38 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-
-        :return: True if the two epochs are equivalent, raises AssertionError\
-                 otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
         ), f"Failed as other epoch is not instance of {self.__class__} type."
-        assert isclose(
+        assert math.isclose(
             self.start_time, other.start_time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for start_time {self.start_time} != {other.start_time} (other)."
-        assert isclose(
+        assert math.isclose(
             self.end_time, other.end_time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for end_time {self.end_time} != {other.end_time} (other)."
-        assert isclose(
+        assert math.isclose(
             self.start_size, other.start_size, rel_tol=rel_tol, abs_tol=abs_tol
         ), (
             f"Failed for start_size "
             f"{self.start_size} != {other.start_size} (other)."
         )
-        assert isclose(
+        assert math.isclose(
             self.end_size, other.end_size, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for end_size {self.end_size} != {other.end_size} (other)."
         assert self.size_function == other.size_function
-        assert isclose(
+        assert math.isclose(
             self.selfing_rate, other.selfing_rate, rel_tol=rel_tol, abs_tol=abs_tol
         ), (
             f"Failed for selfing_rate "
             f"{self.selfing_rate} != {other.selfing_rate} (other)."
         )
-        assert isclose(
+        assert math.isclose(
             self.cloning_rate, other.cloning_rate, rel_tol=rel_tol, abs_tol=abs_tol
         ), (
             f"Failed for cloning_rate "
             f"{self.cloning_rate} != {other.cloning_rate} (other)."
         )
-        return True
 
     def isclose(
         self,
@@ -292,28 +300,25 @@ def isclose(
 @attr.s(auto_attribs=True, kw_only=True)
 class Migration:
     """
-    Parameters for continuous migration from one deme to another.
-    Source and destination demes follow the forwards-in-time convention,
-    of migrations born in the source deme having children in the dest
-    deme.
+    Parameters for continuous migration. Migration may be symmetric, in which
+    case the list of demes will be specified. Alternately, migration may be
+    asymmetric from one deme to another. In the latter case,
+    source and destination demes follow the forwards-in-time convention,
+    of migrations born in the source deme having children in the dest deme.
 
-    :ivar source: The source deme.
-    :ivar dest: The destination deme.
     :ivar start_time: The time at which the migration rate becomes activate.
     :ivar end_time: The time at which the migration rate is deactivated.
     :ivar rate: The rate of migration. Set to zero to disable migrations after
         the given time.
     """
 
-    source: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
-    dest: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
     start_time: Time = attr.ib(validator=[int_or_float, non_negative])
     end_time: Time = attr.ib(validator=[int_or_float, non_negative, finite])
     rate: Rate = attr.ib(validator=[int_or_float, non_negative, finite])
 
     def __attrs_post_init__(self):
-        if self.source == self.dest:
-            raise ValueError("source and dest cannot be the same deme")
+        if not (self.start_time > self.end_time):
+            raise ValueError("must have start_time > end_time")
 
     def assert_close(
         self,
@@ -321,12 +326,12 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-    ) -> bool:
+    ):
         """
-        Returns true if the migration is equal to the ``other`` migration and
-        raises AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following attributes:
-        ``source``, ``dest``, ``start_time``, ``end_time``, ``rate``.
+        `start_time``, ``end_time``, ``rate``.
 
         :param other: The migration to compare against.
         :type other: :class:`.Migration`
@@ -336,26 +341,19 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-
-        :return: True if the two migrations are equivalent, raises\
-                 AssertionError otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
         ), f"Failed as other migration is not instance of {self.__class__} type."
-        assert self.source == other.source
-        assert self.dest == other.dest
-        assert isclose(
+        assert math.isclose(
             self.start_time, other.start_time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for start_time {self.start_time} != {other.start_time} (other)."
-        assert isclose(
+        assert math.isclose(
             self.end_time, other.end_time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for end_time {self.end_time} != {other.end_time} (other)."
-        assert isclose(
+        assert math.isclose(
             self.rate, other.rate, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for rate {self.rate} != {other.rate} (other)."
-        return True
 
     def isclose(
         self,
@@ -388,6 +386,96 @@ def isclose(
             return False
 
 
+@attr.s(auto_attribs=True, kw_only=True)
+class SymmetricMigration(Migration):
+    """
+    :ivar demes: The list of demes for symmetric migration.
+    """
+
+    demes: List[ID] = attr.ib(
+        validator=attr.validators.deep_iterable(
+            member_validator=attr.validators.and_(
+                attr.validators.instance_of(str), valid_deme_id
+            ),
+            iterable_validator=attr.validators.instance_of(list),
+        ),
+    )
+
+    def __attrs_post_init__(self):
+        super().__attrs_post_init__()
+        if len(self.demes) < 2:
+            raise ValueError("must have at least 2 demes for symmetric migration")
+        if len(self.demes) != len(set(self.demes)):
+            raise ValueError("demes for symmetric migration must be unique")
+
+    def assert_close(
+        self,
+        other,
+        *,
+        rel_tol=_ISCLOSE_REL_TOL,
+        abs_tol=_ISCLOSE_ABS_TOL,
+    ):
+        """
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
+        Compares values of the following attributes:
+        ``demes``, ``start_time``, ``end_time``, ``rate``.
+
+        :param other: The migration to compare against.
+        :type other: :class:`.Migration`
+        :param ret_tol: The relative tolerance permitted for numerical
+                        comparisons. See documentation for :func:`math.isclose`
+        :type ret_tol: float
+        :param abs_tol: The absolute tolerance permitted for numerical
+                        comparisons. See documentation for :func:`math.isclose`.
+        :type abs_tol: float
+        """
+        super().assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
+        assert sorted(self.demes) == sorted(other.demes)
+
+
+@attr.s(auto_attribs=True, kw_only=True)
+class AsymmetricMigration(Migration):
+    """
+    :ivar source: The source deme for asymmetric migration.
+    :ivar dest: The destination deme for asymmetric migration.
+    """
+
+    source: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
+    dest: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
+
+    def __attrs_post_init__(self):
+        super().__attrs_post_init__()
+        if self.source == self.dest:
+            raise ValueError("source and dest cannot be the same deme")
+
+    def assert_close(
+        self,
+        other,
+        *,
+        rel_tol=_ISCLOSE_REL_TOL,
+        abs_tol=_ISCLOSE_ABS_TOL,
+    ):
+        """
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
+        Compares values of the following attributes:
+        ``source``, ``dest``, ``start_time``, ``end_time``, ``rate``.
+
+        :param other: The migration to compare against.
+        :type other: :class:`.Migration`
+        :param ret_tol: The relative tolerance permitted for numerical
+                        comparisons. See documentation for :func:`math.isclose`
+        :type ret_tol: float
+        :param abs_tol: The absolute tolerance permitted for numerical
+                        comparisons. See documentation for :func:`math.isclose`.
+        :type abs_tol: float
+        """
+        super().assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
+        assert self.source == other.source
+        assert self.dest == other.dest
+
+
 @attr.s(auto_attribs=True, kw_only=True)
 class Pulse:
     """
@@ -419,10 +507,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-    ) -> bool:
+    ):
         """
-        Returns true if the pulse is equal to the ``other`` pulse and raises
-        AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following attributes:
         ``source``, ``dest``, ``time``, ``proportion``.
 
@@ -434,23 +522,18 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-
-        :return: True if the two pulses are equivalent, raises AssertionError\
-                 otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
         ), f"Failed as other pulse is not instance of {self.__class__} type."
         assert self.source == other.source
         assert self.dest == other.dest
-        assert isclose(
+        assert math.isclose(
             self.time, other.time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for time {self.time} != {other.time} (other)."
-        assert isclose(
+        assert math.isclose(
             self.proportion, other.proportion, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for proportion {self.proportion} != {other.proportion} (other)."
-        return True
 
     def isclose(
         self,
@@ -522,11 +605,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-        raise_ex=False,
     ) -> bool:
         """
-        Returns true if the split is equal to the ``other`` split and raises
-        AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following attributes:
         ``parent``, ``children``, ``time``.
 
@@ -538,17 +620,13 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-
-        :return: True if the two splits are equivalent, raises AssertionError\
-                 otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
         ), f"Failed as other split is not instance of {self.__class__} type."
         assert self.parent == other.parent
         assert sorted(self.children) == sorted(other.children)
-        assert isclose(
+        assert math.isclose(
             self.time, other.time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for time {self.time} != {other.time} (other)."
         return True
@@ -573,10 +651,6 @@ def isclose(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-        :param raise_ex: Determines if exception (ValueError) should be raised
-                         when splits are not close. The error will contain
-                         information about attributes that are not equal.
-        :type raise_ex: bool
 
         :return: True if the two splits are equivalent, False otherwise.
         :rtype: bool
@@ -613,10 +687,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-    ) -> bool:
+    ):
         """
-        Returns true if the branch is equal to the ``other`` branch and raises
-        AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following attributes:
         ``parent``, ``child``, ``time``.
 
@@ -628,24 +702,15 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-        :param raise_ex: Determines if exception (ValueError) should be raised
-                         when branches are not close. The error will contain
-                         information about attributes that are not equal.
-        :type raise_ex: bool
-
-        :return: True if the two branches are equivalent, raises\
-                 AssertionError otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
         ), f"failed as other branch is not instance of {self.__class__} type."
         assert self.parent == other.parent
-        assert sorted(self.child) == sorted(other.child)
-        assert isclose(
+        assert self.child == other.child
+        assert math.isclose(
             self.time, other.time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for time {self.time} != {other.time} (other)."
-        return True
 
     def isclose(
         self,
@@ -731,10 +796,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-    ) -> bool:
+    ):
         """
-        Returns true if the merge is equal to the ``other`` merge and raises
-        AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following attributes:
         ``parents``, ``proportions``, ``child``, ``time``.
 
@@ -746,10 +811,6 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-
-        :return: True if the two merges are equivalent, raises AssertionError\
-                 otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
@@ -766,11 +827,10 @@ def assert_close(
             f"parents: {self.parents}, {other.parents} (other), "
             f"proportions: {self.proportions}, {other.proportions} (other)."
         )
-        assert sorted(self.child) == sorted(other.child)
-        assert isclose(
+        assert self.child == other.child
+        assert math.isclose(
             self.time, other.time, rel_tol=rel_tol, abs_tol=abs_tol
         ), f"Failed for time {self.time} != {other.time} (other)."
-        return True
 
     def isclose(
         self,
@@ -856,11 +916,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-        raise_ex=False,
-    ) -> bool:
+    ):
         """
-        Returns true if the admixture is equal to the ``other`` admixture and
-        raises AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following attributes:
         ``parents``, ``proportions``, ``child``, ``time``.
 
@@ -872,14 +931,6 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-        :param raise_ex: Determines if exception (ValueError) should be raised
-                         when admixtures are not close. The error will contain
-                         information about attributes that are not equal.
-        :type raise_ex: bool
-
-        :return: True if the two admixtures are equivalent, raises\
-                 AssertionError otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
@@ -896,14 +947,13 @@ def assert_close(
             f"parents: {self.parents}, {other.parents} (other), "
             f"proportions: {self.proportions}, {other.proportions} (other)."
         )
-        assert sorted(self.child) == sorted(other.child)
-        assert isclose(
+        assert self.child == other.child
+        assert math.isclose(
             self.time,
             other.time,
             rel_tol=rel_tol,
             abs_tol=abs_tol,
         ), f"Failed for time {self.time} != {other.time} (other)."
-        return True
 
     def isclose(
         self,
@@ -924,10 +974,6 @@ def isclose(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-        :param raise_ex: Determines if exception (ValueError) should be raised
-                         when admixtures are not close. The error will contain
-                         information about attributes that are not equal.
-        :type raise_ex: bool
 
         :return: True if the two admixtures are equivalent, False otherwise.
         :rtype: bool
@@ -943,9 +989,6 @@ def isclose(
 class Deme:
     """
     A collection of individuals that are exchangeable at any fixed time.
-    This class is not intended to be instantiated directly. It is instead
-    recommended to add demes to a :class:`.Graph` object using the
-    :meth:`Graph.deme` method.
 
     :ivar str id: A string identifier for the deme.
     :ivar str description: A description of the deme. May be ``None``.
@@ -959,10 +1002,6 @@ class Deme:
     :ivar epochs: A list of epochs, which define the population size(s) of
         the deme. The deme must be created with all epochs listed.
     :vartype epochs: list of :class:`.Epoch`
-    :ivar defaults: A dictionary of default attributes, such as cloning and
-        selfing rates, that are inherited by epochs if those attributes are
-        not specified in the epoch.
-    :vartype defaults: dict
     """
 
     id: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
@@ -991,39 +1030,94 @@ class Deme:
             iterable_validator=attr.validators.instance_of(list),
         )
     )
-    defaults: dict = attr.ib()
+    start_time: Time = attr.ib(validator=[int_or_float, positive])
 
     @ancestors.validator
     def _check_ancestors(self, _attribute, _value):
         if len(set(self.ancestors)) != len(self.ancestors):
-            raise ValueError(f"duplicate ancestors in {self.ancestors}")
+            raise ValueError(f"deme {self.id}: duplicate ancestors in {self.ancestors}")
         if self.id in self.ancestors:
-            raise ValueError(f"{self.id} cannot be its own ancestor")
+            raise ValueError(f"deme {self.id}: deme cannot be its own ancestor")
 
     @proportions.validator
     def _check_proportions(self, attribute, _value):
         if len(self.proportions) > 0 and not math.isclose(sum(self.proportions), 1.0):
-            raise ValueError("proportions must sum to 1.0")
+            raise ValueError(f"deme {self.id}: ancestry proportions must sum to 1.0")
         for proportion in self.proportions:
             unit_interval(self, attribute, proportion)
             positive(self, attribute, proportion)
 
     @epochs.validator
     def _check_epochs(self, _attribute, _value):
-        # every deme must have at least one epoch
-        if len(self.epochs) == 0:
-            raise ValueError("Demes must be defined with at least one epoch")
         # check epoch times align
         for i, epoch in enumerate(self.epochs):
             if i > 0:
                 if self.epochs[i - 1].end_time != epoch.start_time:
-                    raise ValueError("Epoch start and end times must align")
+                    raise ValueError(
+                        f"deme {self.id}: "
+                        f"epoch[{i}].start_time != epoch[{i}-1].end_time"
+                    )
 
     def __attrs_post_init__(self):
         # We check the lengths of ancestors and proportions match
         # after the validators have confirmed that these are indeed lists.
         if len(self.ancestors) != len(self.proportions):
-            raise ValueError("ancestors and proportions must have same length")
+            raise ValueError(
+                f"deme {self.id}: ancestors and proportions have different lengths"
+            )
+
+    def _add_epoch(
+        self,
+        *,
+        end_time,
+        start_size=None,
+        end_size=None,
+        size_function=None,
+        selfing_rate=0,
+        cloning_rate=0,
+    ):
+        if len(self.epochs) == 0:
+            start_time = self.start_time
+            # The first epoch is special.
+            if start_size is None and end_size is None:
+                raise KeyError(
+                    f"deme {self.id}: first epoch must have start_size or end_size"
+                )
+            if start_size is None:
+                start_size = end_size
+            if end_size is None:
+                end_size = start_size
+
+        else:
+            start_time = self.epochs[-1].end_time
+            # Set size based on previous epoch.
+            if start_size is None:
+                start_size = self.epochs[-1].end_size
+            if end_size is None:
+                end_size = start_size
+
+        # XXX: use math.isclose()?
+        if size_function is None:
+            if start_size == end_size:
+                size_function = "constant"
+            else:
+                size_function = "exponential"
+
+        try:
+            epoch = Epoch(
+                start_time=start_time,
+                end_time=end_time,
+                start_size=start_size,
+                end_size=end_size,
+                size_function=size_function,
+                selfing_rate=selfing_rate,
+                cloning_rate=cloning_rate,
+            )
+        except (TypeError, ValueError) as e:
+            raise e.__class__(
+                f"deme {self.id}: epoch[{len(self.epochs)}]: invalid epoch"
+            ) from e
+        self.epochs.append(epoch)
 
     def assert_close(
         self,
@@ -1031,10 +1125,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-    ) -> bool:
+    ):
         """
-        Returns true if the deme is equal to the ``other`` deme and raises
-        AssertionError otherwise.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Compares values of the following objects:
         ``id``, ``ancestors``, ``proportions``, epochs.
 
@@ -1046,15 +1140,11 @@ def assert_close(
         :param abs_tol: The absolute tolerance permitted for numerical
                         comparisons. See documentation for :func:`math.isclose`.
         :type abs_tol: float
-
-        :return: True if the two demes are equivalent, raises AssertionError\
-                 otherwise.
-        :rtype: bool
         """
         assert (
             self.__class__ is other.__class__
         ), f"Failed as other deme is not instance of {self.__class__} type."
-        assert sorted(self.id) == sorted(other.id)
+        assert self.id == other.id
         assert isclose_deme_proportions(
             self.ancestors,
             self.proportions,
@@ -1072,7 +1162,6 @@ def assert_close(
                 e1.assert_close(e2, rel_tol=rel_tol, abs_tol=abs_tol)
             except AssertionError as e:
                 raise AssertionError(f"Failed for epochs (number {i})") from e
-        return True
 
     def isclose(
         self,
@@ -1103,13 +1192,6 @@ def isclose(
         except AssertionError:
             return False
 
-    @property
-    def start_time(self):
-        """
-        The start time of the deme's existence.
-        """
-        return self.epochs[0].start_time
-
     @property
     def end_time(self):
         """
@@ -1128,12 +1210,10 @@ def time_span(self):
 @attr.s(auto_attribs=True, kw_only=True)
 class Graph:
     """
-    The Graph class provides a high-level API for constructing a demographic
-    model. The methods on this class ensure validity of a  model at all stages
-    of construction. They also allow omission of detail, when there is a single
-    unambiguous interpretation (or a very sensible default). The semantics
-    exactly match those for loading the ``yaml`` file, as the :func:`.load`
-    function uses this API internally.
+    The Graph class provides a high-level API for working with a demographic
+    model. A Graph object matches Demes' data model, with a small number of
+    additional redundant attributes that make the Graph a more convenient
+    object to use when inspecting a model's properties.
 
     :ivar str description: A human readable description of the demography.
         May be ``None``.
@@ -1151,16 +1231,11 @@ class Graph:
         should be be given here as a list.
     :vartype doi: list of str
     :ivar demes: A list of demes in the demography.
-        Use :meth:`.deme` to add a deme.
     :vartype demes: list of :class:`.Deme`
     :ivar migrations: A list of continuous migrations for the demography.
-        Use :meth:`migration` or :meth:`symmetric_migration` to add migrations.
     :vartype migrations: list of :class:`.Migration`
     :ivar pulses: A list of migration pulses for the demography.
-        Use :meth:`pulse` to add a pulse.
     :vartype pulses: list of :class:`.Pulse`
-    :ivar defaults: A dictionary of default attributes for demes and epochs.
-    :vartype defaults: dict
     """
 
     description: Optional[str] = attr.ib(
@@ -1186,7 +1261,6 @@ class Graph:
     demes: List[Deme] = attr.ib(factory=list, init=False)
     migrations: List[Migration] = attr.ib(factory=list, init=False)
     pulses: List[Pulse] = attr.ib(factory=list, init=False)
-    defaults: dict = attr.ib(default={})
 
     def __attrs_post_init__(self):
         self._deme_map: Dict[ID, Deme] = dict()
@@ -1214,12 +1288,10 @@ def assert_close(
         *,
         rel_tol=_ISCLOSE_REL_TOL,
         abs_tol=_ISCLOSE_ABS_TOL,
-    ) -> bool:
+    ):
         """
-        Returns true if the graph and ``other`` implement essentially
-        the same demographic model and raises AssertionError otherwise.
-        Numerical values are compared using the
-        :func:`math.isclose` function, from which this method takes its name.
+        Raises AssertionError if the object is not equal to ``other``,
+        up to a numerical tolerance.
         Furthermore, the following implementation details are ignored during
         the comparison:
 
@@ -1228,11 +1300,6 @@ def assert_close(
             - The order in which admixture ``pulses`` were specified.
             - The order in which ``demes`` were specified.
             - The order in which a deme's ``ancestors`` were specified.
-            - The ``selfing_rate`` and ``cloning_rate`` attributes of the deme
-              graph, or of the demes (if any). Theses attributes are considered
-              conveniences, and are propagated to the relevant demes'
-              epochs. The ``selfing_rate`` and ``cloning_rate`` attributes of
-              each epoch *are* evaluated for equality between the two models.
 
         :param other: The graph to compare against.
         :type other: :class:`.Graph`
@@ -1240,15 +1307,12 @@ def assert_close(
             comparisons. See documentation for :func:`math.isclose`.
         :param float abs_tol: The absolute tolerance permitted for numerical
             comparisons. See documentation for :func:`math.isclose`.
-        :return: True if the two graphs implement the same model, raises\
-                 AssertionError otherwise.
-        :rtype: bool
         """
 
-        def sorted_eq(aa, bb, *, rel_tol, abs_tol, name) -> bool:
+        def assert_sorted_eq(aa, bb, *, rel_tol, abs_tol, name, key=None):
             # Order-agnostic equality check.
             assert len(aa) == len(bb)
-            for (a, b) in zip(sorted(aa), sorted(bb)):
+            for (a, b) in zip(sorted(aa, key=key), sorted(bb, key=key)):
                 try:
                     a.assert_close(b, rel_tol=rel_tol, abs_tol=abs_tol)
                 except AssertionError as e:
@@ -1257,31 +1321,31 @@ def sorted_eq(aa, bb, *, rel_tol, abs_tol, name) -> bool:
                             f"Failed for {name} {a.id} and {b.id}"
                         ) from e
                     raise AssertionError(f"Failed for {name}") from e
-            return True
 
         assert (
             self.__class__ is other.__class__
         ), f"Failed as other graph is not instance of {self.__class__} type."
         assert self.time_units == other.time_units
         assert self.generation_time == other.generation_time
-        return (
-            sorted_eq(
-                self.demes, other.demes, rel_tol=rel_tol, abs_tol=abs_tol, name="demes"
-            )
-            and sorted_eq(
-                self.migrations,
-                other.migrations,
-                rel_tol=rel_tol,
-                abs_tol=abs_tol,
-                name="migrations",
-            )
-            and sorted_eq(
-                self.pulses,
-                other.pulses,
-                rel_tol=rel_tol,
-                abs_tol=abs_tol,
-                name="pulses",
-            )
+        assert_sorted_eq(
+            self.demes, other.demes, rel_tol=rel_tol, abs_tol=abs_tol, name="demes"
+        )
+        assert_sorted_eq(
+            self.migrations,
+            other.migrations,
+            rel_tol=rel_tol,
+            abs_tol=abs_tol,
+            name="migrations",
+            # Sort using class name first (asymmetric versus symmetric)
+            # then using attributes of the migration objects.
+            key=lambda obj: (obj.__class__.__name__, obj),
+        )
+        assert_sorted_eq(
+            self.pulses,
+            other.pulses,
+            rel_tol=rel_tol,
+            abs_tol=abs_tol,
+            name="pulses",
         )
 
     def isclose(
@@ -1303,11 +1367,6 @@ def isclose(
             - The order in which admixture ``pulses`` were specified.
             - The order in which ``demes`` were specified.
             - The order in which a deme's ``ancestors`` were specified.
-            - The ``selfing_rate`` and ``cloning_rate`` attributes of the deme
-              graph, or of the demes (if any). Theses attributes are considered
-              conveniences, and are propagated to the relevant demes'
-              epochs. The ``selfing_rate`` and ``cloning_rate`` attributes of
-              each epoch *are* evaluated for equality between the two models.
 
         :param other: The graph to compare against.
         :type other: :class:`.Graph`
@@ -1324,16 +1383,14 @@ def isclose(
         except AssertionError:
             return False
 
-    def deme(
+    def _add_deme(
         self,
-        id,
         *,
+        id,
         description=None,
         ancestors=None,
         proportions=None,
-        epochs=None,
         start_time=None,
-        defaults={},
     ) -> Deme:
         """
         Add a deme to the graph.
@@ -1358,112 +1415,68 @@ def deme(
             - If the deme has multiple ancestors, the ``start_time`` must be
               provided.
 
-        :param epochs: Epochs that define population sizes, selfing rates, and
-            cloning rates, for the deme over various time periods.
-            If the final epoch's ``end_time`` is ``None``, it will be set to ``0``.
-        :param defaults: Default attributes for epochs, including cloning_rate
-            and selfing_rate.
         :return: Newly created deme.
         :rtype: :class:`.Deme`
         """
         # some basic deme property checks
         if id in self:
-            raise ValueError(f"deme {id} already exists in this graph")
-        if epochs is None:
-            raise ValueError(f"deme {id} must have at least one specified epoch")
+            raise ValueError(f"deme[{len(self.demes)}] {id}: field 'id' must be unique")
         if ancestors is None:
             ancestors = []
         if not isinstance(ancestors, list):
-            raise TypeError(f"ancestors must be a list of deme IDs for deme {id}")
+            raise TypeError(
+                f"deme[{len(self.demes)}] {id}: field 'ancestors' must be "
+                "a list of deme IDs"
+            )
         for ancestor in ancestors:
             if ancestor not in self:
-                raise ValueError(f"ancestor deme {ancestor} not in graph for deme {id}")
+                raise ValueError(
+                    f"deme[{len(self.demes)}] {id}: ancestor deme '{ancestor}' "
+                    "not found. Note: ancestor demes must be specified before "
+                    "their children."
+                )
         if proportions is None:
             if len(ancestors) == 1:
                 proportions = [1.0]
             else:
                 proportions = []
 
-        # set the start time to first epoch's start time.
-        # if first epoch does not have a start time, set to inf or to
-        # the ancestor's end time
+        # set the start time to inf or to the ancestor's end time
         if start_time is None:
             if len(ancestors) > 0:
                 if len(ancestors) > 1:
                     raise ValueError(
-                        "with multiple ancestors, start_time must be specified "
-                        f"for deme {id}"
+                        f"deme[{len(self.demes)}] {id}: "
+                        "field 'start_time' not found, "
+                        "but is required for demes with multiple ancestors"
                     )
                 start_time = self[ancestors[0]].end_time
             else:
-                start_time = float("inf")
+                start_time = math.inf
 
         if len(ancestors) == 0 and not math.isinf(start_time):
-            raise ValueError(f"deme {id} has finite start_time, but no ancestors")
-
-        # the first epoch's start time is set to deme's start time
-        if epochs[0].start_time is None:
-            epochs[0].start_time = start_time
-        elif epochs[0].start_time != start_time:
             raise ValueError(
-                f"deme and first epoch start times do not align for deme {id}"
+                f"deme[{len(self.demes)}] {id}: field 'ancestors' not found, "
+                "but is required for demes with a finite 'start_time'"
             )
-        # fix deme's end time to 0 if not set
-        if epochs[-1].end_time is None:
-            epochs[-1].end_time = 0
 
         # check start time is valid wrt ancestor time intervals
         for ancestor in ancestors:
             anc = self[ancestor]
             if not (anc.start_time > start_time >= anc.end_time):
                 raise ValueError(
-                    f"start_time={start_time} is outside the interval "
-                    f"of existence for ancestor {ancestor} "
-                    f"({anc.start_time}, {anc.end_time})"
+                    f"deme[{len(self.demes)}] {id}: start_time={start_time} is "
+                    "outside the interval of existence for ancestor "
+                    f"'{ancestor}' ({anc.start_time}, {anc.end_time}]"
                 )
 
-        # set default cloning and selfing rates
-        cloning_rate = defaults.get("cloning_rate", self.defaults.get("cloning_rate"))
-        selfing_rate = defaults.get("selfing_rate", self.defaults.get("cloning_rate"))
-
-        # fill in all attributes of epochs
-        for i in range(len(epochs)):
-            # set the start time based on the last epoch's end time
-            if epochs[i].start_time is None:
-                epochs[i].start_time = epochs[i - 1].end_time
-            if epochs[i].end_time is None:
-                raise ValueError("all epochs must specify the end time")
-            if epochs[i].start_time <= epochs[i].end_time:
-                raise ValueError(f"epoch {i} has start_time <= end_time for deme {id}")
-            if i > 0 and epochs[i].start_time != epochs[i - 1].end_time:
-                raise ValueError(
-                    "epoch start and end times do not align for deme {id}, "
-                    f"epochs {i - 1} and {i}"
-                )
-            # for each subsequent epoch, fill in start size, final size,
-            # and size function as necessary based on last epoch
-            if epochs[i].start_size is None:
-                epochs[i].start_size = epochs[i - 1].end_size
-            if epochs[i].end_size is None:
-                epochs[i].end_size = epochs[i].start_size
-            if epochs[i].size_function is None:
-                if epochs[i].start_size == epochs[i].end_size:
-                    epochs[i].size_function = "constant"
-                else:
-                    epochs[i].size_function = "exponential"
-            # set per-epoch selfing and cloning rates
-            if epochs[i].selfing_rate is None:
-                epochs[i].selfing_rate = selfing_rate
-            if epochs[i].cloning_rate is None:
-                epochs[i].cloning_rate = cloning_rate
-
         deme = Deme(
             id=id,
             description=description,
             ancestors=ancestors,
             proportions=proportions,
-            epochs=epochs,
-            defaults=defaults,
+            start_time=start_time,
+            epochs=[],
         )
         self._deme_map[deme.id] = deme
         self.demes.append(deme)
@@ -1485,9 +1498,31 @@ def _check_time_intersection(self, deme1, deme2, time):
                 )
         return time_lo, time_hi
 
-    def symmetric_migration(
+    def _check_overlapping_migrations(self, *, source, dest, start_time, end_time):
+        for migration in self.migrations:
+            if (
+                isinstance(migration, SymmetricMigration)
+                and source in migration.demes
+                and dest in migration.demes
+            ) or (
+                isinstance(migration, AsymmetricMigration)
+                and source == migration.source
+                and dest == migration.dest
+            ):
+                if (
+                    start_time >= migration.start_time > end_time
+                    or start_time > migration.end_time >= end_time
+                    or migration.start_time >= start_time > migration.end_time
+                    or migration.start_time > end_time >= migration.end_time
+                ):
+                    raise ValueError(
+                        "new migration overlaps exisiting migration "
+                        f"between {source} and {dest}"
+                    )
+
+    def _add_symmetric_migration(
         self, *, demes, rate, start_time=None, end_time=None
-    ) -> List[Migration]:
+    ) -> SymmetricMigration:
         """
         Add continuous symmetric migrations between all pairs of demes in a list.
 
@@ -1497,26 +1532,32 @@ def symmetric_migration(
         :param start_time: The time at which the migration rate is enabled.
         :param end_time: The time at which the migration rate is disabled.
         :return: List of newly created migrations.
-        :rtype: list of :class:`.Migration`
+        :rtype: list of :class:`.SymmetricMigration`
         """
         if not isinstance(demes, list) or len(demes) < 2:
             raise ValueError("must specify a list of two or more deme IDs")
-        migrations = list()
+        if start_time is None:
+            start_time = min(self[deme_id].start_time for deme_id in demes)
+        if end_time is None:
+            end_time = max(self[deme_id].end_time for deme_id in demes)
         for source, dest in itertools.permutations(demes, 2):
-            migrations.append(
-                self.migration(
-                    source=source,
-                    dest=dest,
-                    rate=rate,
-                    start_time=start_time,
-                    end_time=end_time,
-                )
+            self._check_time_intersection(source, dest, start_time)
+            self._check_time_intersection(source, dest, end_time)
+            self._check_overlapping_migrations(
+                source=source, dest=dest, start_time=start_time, end_time=end_time
             )
-        return migrations
+        migration = SymmetricMigration(
+            demes=demes,
+            start_time=start_time,
+            end_time=end_time,
+            rate=rate,
+        )
+        self.migrations.append(migration)
+        return migration
 
-    def migration(
+    def _add_asymmetric_migration(
         self, *, source, dest, rate, start_time=None, end_time=None
-    ) -> Migration:
+    ) -> AsymmetricMigration:
         """
         Add continuous migration from one deme to another.
         Source and destination demes follow the forwards-in-time convention,
@@ -1533,7 +1574,7 @@ def migration(
             If ``None``, the end time is defined by the latest time at which
             the demes coexist.
         :return: Newly created migration.
-        :rtype: :class:`.Migration`
+        :rtype: :class:`.AsymmetricMigration`
         """
         for deme_id in (source, dest):
             if deme_id not in self:
@@ -1547,23 +1588,10 @@ def migration(
             end_time = time_lo
         else:
             self._check_time_intersection(source, dest, end_time)
-        for existing_migration in self.migrations:
-            if source == existing_migration.source and dest == existing_migration.dest:
-                if (
-                    start_time >= existing_migration.start_time > end_time
-                    or start_time > existing_migration.end_time >= end_time
-                    or existing_migration.start_time
-                    >= start_time
-                    > existing_migration.end_time
-                    or existing_migration.start_time
-                    > end_time
-                    >= existing_migration.end_time
-                ):
-                    raise ValueError(
-                        "new migration overlaps exisiting migration "
-                        f"from {source} to {dest}"
-                    )
-        migration = Migration(
+        self._check_overlapping_migrations(
+            source=source, dest=dest, start_time=start_time, end_time=end_time
+        )
+        migration = AsymmetricMigration(
             source=source,
             dest=dest,
             start_time=start_time,
@@ -1574,7 +1602,7 @@ def migration(
         self.migrations.append(migration)
         return migration
 
-    def pulse(self, *, source, dest, proportion, time) -> Pulse:
+    def _add_pulse(self, *, source, dest, proportion, time) -> Pulse:
         """
         Add a pulse of migration at a fixed time.
         Source and destination demes follow the forwards-in-time convention.
@@ -1714,12 +1742,6 @@ def list_demographic_events(self):
             )
         return demo_events
 
-    def validate(self):
-        """
-        Validates the demographic model.
-        """
-        self.fromdict(self.asdict())
-
     def in_generations(self):
         """
         Return a copy of the graph with times in units of generations.
@@ -1741,32 +1763,188 @@ def in_generations(self):
         return graph
 
     @classmethod
-    def fromdict(cls, data):
+    def fromdict(cls, data: MutableMapping[str, Any]):
         """
         Return a graph from a dict representation. The inverse of asdict().
         """
-        g = cls(
-            description=data.get("description"),
-            time_units=data.get("time_units"),
-            generation_time=data.get("generation_time"),
-            doi=data.get("doi", []),
-            defaults=data.get("defaults", {}),
+        if not isinstance(data, MutableMapping):
+            raise TypeError("data is not a dictionary")
+
+        # Don't modify the input data dict.
+        data = copy.deepcopy(data)
+
+        defaults = pop_object(data, "defaults", {}, scope="toplevel")
+        deme_defaults = pop_object(defaults, "deme", {}, scope="defaults")
+        migration_defaults = pop_object(defaults, "migration", {}, scope="defaults")
+        pulse_defaults = pop_object(defaults, "pulse", {}, scope="defaults")
+        # epoch defaults may also be specified within a Deme definition.
+        global_epoch_defaults = pop_object(defaults, "epoch", {}, scope="defaults")
+        check_empty(defaults, "defaults")
+
+        if "time_units" not in data:
+            raise KeyError("toplevel: required field 'time_units' not found")
+
+        graph = cls(
+            description=data.pop("description", None),
+            time_units=data.pop("time_units"),
+            doi=data.pop("doi", []),
+            generation_time=data.pop("generation_time", None),
         )
+        check_defaults(
+            deme_defaults,
+            ["description", "start_time", "ancestors", "proportions"],
+            "defaults: deme",
+        )
+
+        for i, deme_data in enumerate(
+            pop_list(data, "demes", required_type=MutableMapping, scope="toplevel")
+        ):
+            insert_defaults(deme_data, deme_defaults)
+            if "id" not in deme_data:
+                raise KeyError("deme[{i}]: required field 'id' not found")
+            deme = graph._add_deme(
+                id=deme_data.pop("id"),
+                description=deme_data.pop("description", None),
+                start_time=deme_data.pop("start_time", None),
+                ancestors=deme_data.pop("ancestors", None),
+                proportions=deme_data.pop("proportions", None),
+            )
+
+            local_defaults = pop_object(
+                deme_data, "defaults", {}, scope=f"deme[{i}] {deme.id}"
+            )
+            local_epoch_defaults = pop_object(
+                local_defaults, "epoch", {}, scope=f"deme[{i}] {deme.id}: defaults"
+            )
+            check_empty(local_defaults, f"deme[{i}] {deme.id}: defaults")
+            epoch_defaults = global_epoch_defaults.copy()
+            epoch_defaults.update(local_epoch_defaults)
+
+            check_defaults(
+                epoch_defaults,
+                [
+                    "end_time",
+                    "start_size",
+                    "end_size",
+                    "selfing_rate",
+                    "cloning_rate",
+                    "size_function",
+                ],
+                f"deme[{i}] {deme.id}: defaults: epoch",
+            )
 
-        for deme in data.get("demes", []):
-            if "epochs" in deme:
-                deme["epochs"] = [Epoch(**epoch) for epoch in deme["epochs"]]
-            g.deme(**deme)
-        for migration_type, migration_list in data.get("migrations", dict()).items():
-            if migration_type == "symmetric":
-                for m in migration_list:
-                    g.symmetric_migration(**m)
-            if migration_type == "asymmetric":
-                for m in migration_list:
-                    g.migration(**m)
-        for pulse in data.get("pulses", []):
-            g.pulse(**pulse)
-        return g
+            if len(epoch_defaults) == 0 and "epochs" not in deme_data:
+                # This condition would be caught downstream, because start_size
+                # or end_size are required for the first epoch. But we check
+                # here to provide a more informative error message.
+                raise KeyError(
+                    f"deme[{i}] {deme.id}: required field 'epochs' not found"
+                )
+
+            # There is always at least one epoch defined with the default values.
+            epochs = pop_list(
+                deme_data,
+                "epochs",
+                [{}],
+                required_type=MutableMapping,
+                scope=f"deme[{i}] {deme.id}",
+            )
+            for j, epoch_data in enumerate(epochs):
+                insert_defaults(epoch_data, epoch_defaults)
+                if "end_time" not in epoch_data:
+                    if j == len(epochs) - 1:
+                        epoch_data["end_time"] = 0
+                    else:
+                        raise KeyError(
+                            f"deme[{i}] {deme.id}: epoch[{j}]: "
+                            "required field 'end_time' not found"
+                        )
+                deme._add_epoch(
+                    end_time=epoch_data.pop("end_time"),
+                    start_size=epoch_data.pop("start_size", None),
+                    end_size=epoch_data.pop("end_size", None),
+                    size_function=epoch_data.pop("size_function", None),
+                    selfing_rate=epoch_data.pop("selfing_rate", 0),
+                    cloning_rate=epoch_data.pop("cloning_rate", 0),
+                )
+                check_empty(epoch_data, f"deme[{i}] {deme.id}: epoch[{j}]")
+            check_empty(deme_data, f"deme[{i}] {deme.id}")
+
+        check_defaults(
+            migration_defaults,
+            ["rate", "start_time", "end_time", "source", "dest", "demes"],
+            "defaults: migration",
+        )
+        for i, migration_data in enumerate(
+            pop_list(
+                data, "migrations", [], required_type=MutableMapping, scope="toplevel"
+            )
+        ):
+            insert_defaults(migration_data, migration_defaults)
+            if "rate" not in migration_data:
+                raise KeyError(f"migration[{i}]: required field 'rate' not found")
+            if not (
+                # symmetric
+                (
+                    "demes" in migration_data
+                    and "source" not in migration_data
+                    and "dest" not in migration_data
+                )
+                # asymmetric
+                or (
+                    "demes" not in migration_data
+                    and "source" in migration_data
+                    and "dest" in migration_data
+                )
+            ):
+                raise KeyError(
+                    f"migration[{i}]: must be symmetric (specify 'demes' list) "
+                    "*or* asymmetric (specify both 'source' and 'dest')."
+                )
+            try:
+                if "demes" in migration_data:
+                    graph._add_symmetric_migration(
+                        demes=migration_data.pop("demes"),
+                        rate=migration_data.pop("rate"),
+                        start_time=migration_data.pop("start_time", None),
+                        end_time=migration_data.pop("end_time", None),
+                    )
+                else:
+                    graph._add_asymmetric_migration(
+                        source=migration_data.pop("source"),
+                        dest=migration_data.pop("dest"),
+                        rate=migration_data.pop("rate"),
+                        start_time=migration_data.pop("start_time", None),
+                        end_time=migration_data.pop("end_time", None),
+                    )
+            except (TypeError, ValueError) as e:
+                raise e.__class__(f"migration[{i}]: invalid migration") from e
+            check_empty(migration_data, f"migration[{i}]")
+
+        check_defaults(
+            pulse_defaults, ["source", "dest", "time", "proportion"], "defaults: pulse"
+        )
+        for i, pulse_data in enumerate(
+            pop_list(data, "pulses", [], required_type=MutableMapping, scope="toplevel")
+        ):
+            insert_defaults(pulse_data, pulse_defaults)
+            for field in ("source", "dest", "time", "proportion"):
+                if field not in pulse_data:
+                    raise KeyError(f"pulse[{i}]: required field '{field}' not found")
+            try:
+                graph._add_pulse(
+                    source=pulse_data.pop("source"),
+                    dest=pulse_data.pop("dest"),
+                    time=pulse_data.pop("time"),
+                    proportion=pulse_data.pop("proportion"),
+                )
+            except (TypeError, ValueError) as e:
+                raise e.__class__(f"pulse[{i}]: invalid pulse") from e
+            check_empty(pulse_data, f"pulse[{i}]")
+
+        check_empty(data, "toplevel")
+
+        return graph
 
     def asdict(self):
         """
@@ -1796,20 +1974,16 @@ def coerce_numbers(inst, attribute, value):
             deme["start_time"] = deme["epochs"][0]["start_time"]
             for epoch in deme["epochs"]:
                 del epoch["start_time"]
-        migrations = data.pop("migrations", None)
-        if migrations is not None:
-            data["migrations"] = {"asymmetric": migrations}
+                if epoch["selfing_rate"] == 0:
+                    del epoch["selfing_rate"]
+                if epoch["cloning_rate"] == 0:
+                    del epoch["cloning_rate"]
         return data
 
-    def asdict_simplified(self, custom_attributes=[]):
+    def asdict_simplified(self):
         """
         Return a simplified dict representation of the graph.
-
-        :param custom_attributes: List of additional attributes to simplify, which
-            are not ``selfing_rate`` or ``cloning_rate``.
         """
-        if not isinstance(custom_attributes, list):
-            raise TypeError("custom_attributes must be a list of attributes")
 
         def simplify_epochs(data):
             """
@@ -1835,100 +2009,22 @@ def simplify_epochs(data):
 
         def simplify_migration_rates(data):
             """
-            Collapse symmetric migration rates, and remove redundant information
+            Remove redundant information
             about start and end times if they are implied by the time overlap
             interval of the demes involved.
-
-            To collapse symmetric migrations, we collect all source/dest migration
-            pairs for each set of migration attributes (rate, start_time, end_time),
-            and then iteratively check for all-way symmetric migration between all
-            demes that are involved in migrations for the given set of migration
-            attributes.
             """
 
-            def collapse_demes(pairs):
-                all_demes = []
-                for pair in pairs:
-                    if pair[0] not in all_demes:
-                        all_demes.append(pair[0])
-                    if pair[1] not in all_demes:
-                        all_demes.append(pair[1])
-                return all_demes
-
-            symmetric = []
-            asymmetric = data["migrations"]["asymmetric"].copy()
-            # first remove start/end times if equal time intersections
-            rate_sets = {}
-            # keys of rate_types are (rate, start_time, end_time)
-            for migration in data["migrations"]["asymmetric"]:
-                source = migration["source"]
-                dest = migration["dest"]
-                time_lo, time_hi = self._check_time_intersection(source, dest, None)
-                if migration["end_time"] == time_lo:
-                    del migration["end_time"]
-                if migration["start_time"] == time_hi:
-                    del migration["start_time"]
-                k = tuple(
-                    migration.get(key) for key in ("rate", "start_time", "end_time")
-                )
-                rate_sets.setdefault(k, [])
-                rate_sets[k].append((source, dest))
-
-            for k, pairs in rate_sets.items():
-                if len(pairs) == 1:
-                    continue
-                # list of all demes that are source or dest in this rate set
-                all_demes = collapse_demes(pairs)
-
-                # we check all possible sets of n-way symmetric migration
-                i = len(all_demes)
-                while len(all_demes) >= 2 and i >= 2:
-                    # loop through each possible set for a given set size i
-                    compress_demes = False
-                    for deme_set in itertools.combinations(all_demes, i):
-                        # check if all (source, dest) pairs exist in pairs of migration
-                        all_present = True
-                        for deme_pair in itertools.permutations(deme_set, 2):
-                            if deme_pair not in pairs:
-                                all_present = False
-                                break
-                        # if they do all exist
-                        if all_present:
-                            compress_demes = True
-                            # remove from asymmetric list
-                            for deme_pair in itertools.permutations(deme_set, 2):
-                                mig = {
-                                    "source": deme_pair[0],
-                                    "dest": deme_pair[1],
-                                    "rate": k[0],
-                                }
-                                if k[1] is not None:
-                                    mig["start_time"] = k[1]
-                                if k[2] is not None:
-                                    mig["end_time"] = k[2]
-                                asymmetric.remove(mig)
-                                pairs.remove(deme_pair)
-                            # add to symmetric list
-                            sym_mig = dict(demes=[d for d in deme_set], rate=k[0])
-                            if k[1] is not None:
-                                sym_mig["start_time"] = k[1]
-                            if k[2] is not None:
-                                sym_mig["end_time"] = k[2]
-                            symmetric.append(sym_mig)
-                    # if we found a set of symmetric migrations, compress all_demes
-                    if compress_demes:
-                        all_demes = collapse_demes(pairs)
-                        i = min(i, len(all_demes))
-                    # otherwise, check one set size smaller
-                    else:
-                        i -= 1
+            for migration in data["migrations"]:
+                demes = migration.get("demes", [])
+                if len(demes) == 0:
+                    demes = [migration["source"], migration["dest"]]
 
-            if len(symmetric) > 0:
-                data["migrations"]["symmetric"] = symmetric
-            if len(asymmetric) == 0:
-                del data["migrations"]["asymmetric"]
-            else:
-                data["migrations"]["asymmetric"] = asymmetric
+                time_lo = min(self[deme_id].start_time for deme_id in demes)
+                time_hi = max(self[deme_id].end_time for deme_id in demes)
+                if migration["start_time"] == time_lo:
+                    del migration["start_time"]
+                if migration["end_time"] == time_hi:
+                    del migration["end_time"]
 
         data = self.asdict()
 
@@ -1937,3 +2033,194 @@ def collapse_demes(pairs):
         simplify_epochs(data)
 
         return data
+
+
+class Builder:
+    """
+    The Builder provides a set of convenient methods for incrementally
+    constructing a deme graph. The state of the graph is stored internally as a
+    dictionary of objects following Demes' data model, and may be converted
+    into a fully-resolved :class:`Graph` object using the :meth:`.resolve()` method.
+
+    TODO: add some example code.
+
+    :ivar dict data: The data dictionary of the graph's current state.
+        The objects nested within this dictionary follow Demes' data model,
+        as described in the :ref:`schema <sec_schema>`.
+
+        .. note::
+            Users may freely modify the data dictionary, as long as the data
+            model is not violated.
+    """
+
+    def __init__(
+        self,
+        *,
+        description: str = None,
+        time_units: str = "generations",
+        generation_time: float = None,
+        doi: list = None,
+        defaults: dict = None,
+    ):
+        """
+        :param str description: A human readable description of the demography.
+            May be ``None``.
+        :param str time_units: The units of time used for the demography. This is
+            commonly ``years`` or ``generations``, but can be any string.
+        :param float generation_time: The generation time of demes, in units given
+            by the ``time_units`` parameter. Concretely, dividing all times
+            by ``generation_time`` will convert the graph to have time
+            units in generations.  If ``generation_time`` is ``None``, the units
+            are assumed to be in generations already.
+        :param doi: If the graph describes a published demography, the DOI(s)
+            should be be given here as a list.
+        :vartype doi: list of str
+        """
+        self.data: MutableMapping[str, Any] = dict(time_units=time_units)
+        if description is not None:
+            self.data["description"] = description
+        if generation_time is not None:
+            self.data["generation_time"] = generation_time
+        if doi is not None:
+            self.data["doi"] = doi
+        if defaults is not None:
+            self.data["defaults"] = defaults
+
+    def add_deme(
+        self,
+        id: str,
+        *,
+        description: str = None,
+        ancestors: list = None,
+        proportions: list = None,
+        start_time: float = None,
+        epochs: list = None,
+        defaults: dict = None,
+    ):
+        """
+        Add a deme. Ancestor demes must be added before their children.
+
+        :param str id: A string identifier for the deme.
+        :param str description: A description of the deme. May be ``None``.
+        :param ancestors: List of string identifiers for the deme's ancestors.
+            This may be ``None``, indicating the deme has no ancestors.
+        :vartype ancestors: list of str
+        :param proportions: If ``ancestors`` is not ``None``, this indicates the
+            proportions of ancestry from each ancestor. This list has the same
+            length as ``ancestors``, and must sum to 1.
+        :vartype proportions: list of float
+        :param float start_time: The deme's start time.
+        :param epochs: List of epoch dictionaries. Each dictionary contains
+            parameters to be passed to the deme's DemeProxy.add_epoch() method
+        :vartype epochs: list of dict
+        """
+        deme: MutableMapping[str, Any] = dict(id=id)
+        if description is not None:
+            deme["description"] = description
+        if ancestors is not None:
+            deme["ancestors"] = ancestors
+        if proportions is not None:
+            deme["proportions"] = proportions
+        if start_time is not None:
+            deme["start_time"] = start_time
+        if epochs is not None:
+            deme["epochs"] = epochs
+        if defaults is not None:
+            deme["defaults"] = defaults
+
+        if "demes" not in self.data:
+            self.data["demes"] = []
+        self.data["demes"].append(deme)
+
+    def add_migration(
+        self,
+        *,
+        rate: float,
+        demes: list = None,
+        source: str = None,
+        dest: str = None,
+        start_time: float = None,
+        end_time: float = None,
+    ):
+        """
+        Add continuous symmetric migrations between all pairs of demes in a list,
+        or alternately, add asymmetric migration from one deme to another.
+        Source and destination demes follow the forwards-in-time convention,
+        so that the migration rate refers to the movement of individuals from
+        the ``source`` deme to the ``dest`` deme.
+
+        :param demes: list of deme IDs. Migration is symmetric between all
+            pairs of demes in this list. If not specified, migration will
+            be asymmetric (and ``source`` and ``dest`` must be given).
+        :vartype demes: list of str
+        :param str source: The ID of the source deme.
+        :param str dest: The ID of the destination deme.
+        :param float rate: The rate of migration per generation.
+        :param float start_time: The time at which the migration rate is enabled.
+            If ``None``, the start time is defined by the earliest time at
+            which the demes coexist.
+        :param float end_time: The time at which the migration rate is disabled.
+            If ``None``, the end time is defined by the latest time at which
+            the demes coexist.
+        """
+        migration: MutableMapping[str, Any] = dict(rate=rate)
+        if demes is not None:
+            migration["demes"] = demes
+        if source is not None:
+            migration["source"] = source
+        if dest is not None:
+            migration["dest"] = dest
+        if start_time is not None:
+            migration["start_time"] = start_time
+        if end_time is not None:
+            migration["end_time"] = end_time
+
+        if "migrations" not in self.data:
+            self.data["migrations"] = []
+        self.data["migrations"].append(migration)
+
+    def add_pulse(self, *, source: str, dest: str, proportion: float, time: float):
+        """
+        Add a pulse of migration at a fixed time.
+        Source and destination demes follow the forwards-in-time convention.
+
+        :param str source: The ID of the source deme.
+        :param str dest: The ID of the destination deme.
+        :param float proportion: At the instant after migration, this is the
+            expected proportion of individuals in the destination deme made up
+            of individuals from the source deme.
+        :param float time: The time at which migrations occur.
+        """
+        pulse = dict(
+            source=source,
+            dest=dest,
+            proportion=proportion,
+            time=time,
+        )
+        if "pulses" not in self.data:
+            self.data["pulses"] = []
+        self.data["pulses"].append(pulse)
+
+    def resolve(self):
+        """
+        Resolve the data dictionary into a Graph.
+
+        :return: The fully-resolved Graph.
+        :rtype: Graph
+        """
+        return Graph.fromdict(self.data)
+
+    @classmethod
+    def fromdict(cls, data: MutableMapping[str, Any]):
+        """
+        Make a Builder object from an existing data dictionary.
+
+        :param MutableMapping data: The data dictionary to initialise the
+            graph's state. The objects nested within this dictionary must
+            follow Demes' data model, as described in the
+            :ref:`schema <sec_schema>`.
+        """
+        builder = cls()
+        builder.data = data
+        # TODO: should we check some basic properties for correctness?
+        return builder
diff --git a/docs/api.rst b/docs/api.rst
index 1c05df7b..4ae8b3bf 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -21,61 +21,64 @@ Here we load the Gutenkunst et al. (2009) Out-of-Africa model from
         print(migration)
 
 A demographic model can instead be constructed by instantiating a
-:class:`.Graph` directly, then adding demes, migrations, and admixture
+:class:`.Builder`, then adding demes, migrations, and admixture
 pulses via the methods available on this class.
 
 .. jupyter-execute::
 
-    g = demes.Graph(
+    b = demes.Builder(
         description="Gutenkunst et al. (2009) three-population model.",
         doi=["10.1371/journal.pgen.1000695"],
         time_units="years",
         generation_time=25,
     )
-    g.deme("ancestral", epochs=[demes.Epoch(end_time=220e3, start_size=7300)])
-    g.deme("AMH", ancestors=["ancestral"], epochs=[demes.Epoch(end_time=140e3, start_size=12300)])
-    g.deme("OOA", ancestors=["AMH"], epochs=[demes.Epoch(end_time=21.2e3, start_size=2100)])
-    g.deme("YRI", ancestors=["AMH"], epochs=[demes.Epoch(start_size=12300, end_time=0)])
-    g.deme("CEU", ancestors=["OOA"], epochs=[demes.Epoch(start_size=1000, end_size=29725, end_time=0)])
-    g.deme("CHB", ancestors=["OOA"], epochs=[demes.Epoch(start_size=510, end_size=54090, end_time=0)])
-    g.symmetric_migration(demes=["YRI", "OOA"], rate=25e-5)
-    g.symmetric_migration(demes=["YRI", "CEU"], rate=3e-5)
-    g.symmetric_migration(demes=["YRI", "CHB"], rate=1.9e-5)
-    g.symmetric_migration(demes=["CEU", "CHB"], rate=9.6e-5)
-
-We can check that our implementation matches the example from the gallery
-with the :meth:`Graph.isclose` method.
+    b.add_deme("ancestral", epochs=[dict(end_time=220e3, start_size=7300)])
+    b.add_deme("AMH", ancestors=["ancestral"], epochs=[dict(end_time=140e3, start_size=12300)])
+    b.add_deme("OOA", ancestors=["AMH"], epochs=[dict(end_time=21.2e3, start_size=2100)])
+    b.add_deme("YRI", ancestors=["AMH"], epochs=[dict(start_size=12300)])
+    b.add_deme("CEU", ancestors=["OOA"], epochs=[dict(start_size=1000, end_size=29725)])
+    b.add_deme("CHB", ancestors=["OOA"], epochs=[dict(start_size=510, end_size=54090)])
+    b.add_migration(demes=["YRI", "OOA"], rate=25e-5)
+    b.add_migration(demes=["YRI", "CEU"], rate=3e-5)
+    b.add_migration(demes=["YRI", "CHB"], rate=1.9e-5)
+    b.add_migration(demes=["CEU", "CHB"], rate=9.6e-5)
+
+The builder object can then be "resolved" into a :class:`.Graph` using the
+:meth:`Builder.resolve` method. We can check that our implementation matches
+the example from the gallery with the :meth:`Graph.isclose` method.
 
 .. jupyter-execute::
 
-    print(g.isclose(g_ooa))
+    graph = b.resolve()
+    print(graph.isclose(g_ooa))
 
 For some demographic models, using the Python API can be far less cumbersome
-than writing the equivalent ``yaml`` file. For example, defining a ring of demes,
-with migration between adjacent demes, can be done with the following code.
+than writing the equivalent ``yaml`` file. For example, we can define a ring of
+demes, with migration between adjacent demes, as follows.
 
 .. jupyter-execute::
 
     import demes
 
     M = 10  # number of demes
-    g = demes.Graph(
+    b = demes.Builder(
         description=f"a ring of {M} demes, with migration between adjacent demes",
         time_units="generations",
     )
 
     for j in range(M):
-        g.deme(f"deme{j}", epochs=[demes.Epoch(start_size=1000, end_time=0)])
+        b.add_deme(f"deme{j}", epochs=[dict(start_size=1000)])
         if j > 0:
-            g.symmetric_migration(demes=[f"deme{j - 1}", f"deme{j}"], rate=1e-5)
-    g.symmetric_migration(demes=[f"deme{M - 1}", "deme0"], rate=1e-5)
+            b.add_migration(demes=[f"deme{j - 1}", f"deme{j}"], rate=1e-5)
+    b.add_migration(demes=[f"deme{M - 1}", "deme0"], rate=1e-5)
+    graph_ring = b.resolve()
 
 The graph can then be written out to a new ``yaml`` file using the
 :func:`.dump` function.
 
 .. jupyter-execute::
 
-    demes.dump(g, "/tmp/my_model.yml")
+    demes.dump(graph_ring, "/tmp/ring.yml")
 
 API Reference
 -------------
@@ -85,6 +88,10 @@ API Reference
 .. autofunction:: demes.dump
 .. autofunction:: demes.dumps
 
+.. autoclass:: demes.Builder
+   :members:
+
+
 .. autoclass:: demes.Graph
     :members:
 
@@ -97,5 +104,11 @@ API Reference
 .. autoclass:: demes.Migration
     :members:
 
+.. autoclass:: demes.AsymmetricMigration
+    :members:
+
+.. autoclass:: demes.SymmetricMigration
+    :members:
+
 .. autoclass:: demes.Pulse
     :members:
diff --git a/docs/development.rst b/docs/development.rst
index 86344823..9bf85669 100644
--- a/docs/development.rst
+++ b/docs/development.rst
@@ -89,7 +89,7 @@ manually from the top-level folder of the sources.
 
  .. code-block:: sh
 
-    python -m pytest -v tests --cov=demes
+    python -m pytest -v tests --cov=demes --cov-report=term-missing
 
 This will produce lots of output, indicating which tests passed, and which
 failed (if any). There may also be warnings. Any warnings that are triggered
diff --git a/examples/browning_america.yml b/examples/browning_america.yml
index f8392013..3f2663b4 100644
--- a/examples/browning_america.yml
+++ b/examples/browning_america.yml
@@ -51,7 +51,6 @@ demes:
       end_size: 54664
       end_time: 0
 migrations:
-  symmetric:
   - demes: [AFR, OOA]
     rate: 15e-5
   - demes: [AFR, EUR]
diff --git a/examples/cloning_example.yml b/examples/cloning_example.yml
index 3612a7cc..379bdb54 100644
--- a/examples/cloning_example.yml
+++ b/examples/cloning_example.yml
@@ -2,7 +2,7 @@ description: An example demography to play around with cloning attributes.
 generation_time: 1
 time_units: generations
 defaults:
-  cloning_rate: 0.05
+  epoch: {cloning_rate: 0.05}
 demes:
   - id: root
     description: Root population
@@ -13,7 +13,7 @@ demes:
     description: Population with epochs and changing cloning rates
     ancestors: [root]
     defaults:
-      cloning_rate: 0.2
+      epoch: {cloning_rate: 0.2}
     epochs:
     - start_size: 1e4
       end_time: 500
diff --git a/examples/gutenkunst_ooa.yml b/examples/gutenkunst_ooa.yml
index 2988360b..5f0c18ee 100644
--- a/examples/gutenkunst_ooa.yml
+++ b/examples/gutenkunst_ooa.yml
@@ -47,7 +47,6 @@ demes:
       end_size: 54090
       end_time: 0
 migrations:
-  symmetric:
   - demes: [YRI, OOA]
     rate: 25e-5
   - demes: [YRI, CEU]
diff --git a/examples/jacobs_papuans.yml b/examples/jacobs_papuans.yml
index fc5602a8..8beef9ee 100644
--- a/examples/jacobs_papuans.yml
+++ b/examples/jacobs_papuans.yml
@@ -89,7 +89,6 @@ demes:
     - start_size: 6962.0
       end_time: 0
 migrations:
-  symmetric:
   - demes: [YRI, Ghost]
     rate: 0.000179
     start_time: 1659.0
diff --git a/examples/offshoots.yml b/examples/offshoots.yml
index a145efaf..f4e63fa3 100644
--- a/examples/offshoots.yml
+++ b/examples/offshoots.yml
@@ -23,13 +23,11 @@ demes:
     - start_size: 200
       end_time: 0
 migrations:
-  asymmetric:
   - source: ancestral
     dest: offshoot1
     start_time: 200
     end_time: 100
     rate: 1e-4
-  symmetric:
   - demes: [ancestral, offshoot2]
     rate: 1e-5
   - demes: [offshoot1, offshoot2]
diff --git a/examples/selfing_example.yml b/examples/selfing_example.yml
index b91566fe..a49bb986 100644
--- a/examples/selfing_example.yml
+++ b/examples/selfing_example.yml
@@ -2,12 +2,12 @@ description: An example demography to play around with selfing attributes.
 generation_time: 1
 time_units: generations
 defaults:
-  selfing_rate: 0.05
+  epoch: {selfing_rate: 0.05}
 demes:
 - id: root
   description: Root population
   defaults:
-    selfing_rate: 0.1
+    epoch: {selfing_rate: 0.1}
   epochs:
   - start_size: 1e3
     end_time: 1000
@@ -15,7 +15,7 @@ demes:
   description: Population with epochs and changing selfing rates
   ancestors: [root]
   defaults:
-    selfing_rate: 0.2
+    epoch: {selfing_rate: 0.2}
   epochs:
   - start_size: 1e4
     end_time: 500
diff --git a/setup.cfg b/setup.cfg
index 7e6f735b..55e4ee9c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -46,6 +46,9 @@ ignore_missing_imports = True
 [mypy-numpy.*]
 ignore_missing_imports = True
 
+[mypy-ruamel.*]
+ignore_missing_imports = True
+
 [mypy-stdpopsim.*]
 ignore_missing_imports = True
 


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
index d687ced2..f22f5ec5 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -69,22 +69,20 @@ def epochs_lists(draw, start_time=math.inf, max_epochs=5):
     assert max_epochs >= 2
     times = draw(
         st.lists(
-            st.floats(min_value=0, max_value=start_time),
+            st.floats(min_value=0, max_value=start_time, exclude_max=True),
             unique=True,
-            min_size=2,
+            min_size=1,
             max_size=max_epochs,
         )
     )
     times.sort(reverse=True)
-    if start_time != times[0]:
-        times.insert(0, start_time)
     epochs = []
 
-    for end_time in times[1:]:
+    for i, end_time in enumerate(times):
         start_size = draw(
             st.floats(min_value=0, exclude_min=True, allow_infinity=False)
         )
-        if math.isinf(start_time):
+        if i == 0 and math.isinf(start_time):
             end_size = start_size
         else:
             end_size = draw(
@@ -94,8 +92,7 @@ def epochs_lists(draw, start_time=math.inf, max_epochs=5):
         selfing_rate = draw(st.floats(min_value=0, max_value=1))
 
         epochs.append(
-            demes.Epoch(
-                start_time=start_time,
+            dict(
                 end_time=end_time,
                 start_size=start_size,
                 end_size=end_size,
@@ -103,7 +100,6 @@ def epochs_lists(draw, start_time=math.inf, max_epochs=5):
                 selfing_rate=selfing_rate,
             )
         )
-        start_time = end_time
 
     return epochs
 
@@ -130,7 +126,7 @@ def test_something(self, g):
         time_units = "generations"
     else:
         time_units = draw(yaml_strings())
-    g = demes.Graph(
+    b = demes.Builder(
         description=draw(yaml_strings()),
         generation_time=generation_time,
         time_units=time_units,
@@ -141,18 +137,21 @@ def test_something(self, g):
         ancestors = []
         proportions = []
         start_time = math.inf
-        if len(g.demes) > 0:
+        n_demes = 0 if "demes" not in b.data else len(b.data["demes"])
+        if n_demes > 0:
             # draw indices into demes list to use as ancestors
             anc_idx = draw(
                 st.lists(
-                    st.integers(min_value=0, max_value=len(g.demes) - 1),
+                    st.integers(min_value=0, max_value=n_demes - 1),
                     unique=True,
-                    max_size=len(g.demes),
+                    max_size=n_demes,
                 )
             )
             if len(anc_idx) > 0:
-                time_hi = min(g.demes[j].start_time for j in anc_idx)
-                time_lo = max(g.demes[j].end_time for j in anc_idx)
+                time_hi = min(b.data["demes"][j]["start_time"] for j in anc_idx)
+                time_lo = max(
+                    b.data["demes"][j]["epochs"][-1]["end_time"] for j in anc_idx
+                )
                 if time_lo < time_hi and time_lo < 1e308:
                     # The proposed ancestors exist at the same time.
                     # Draw a start time and the ancestry proportions.
@@ -165,7 +164,7 @@ def test_something(self, g):
                             exclude_min=time_lo == 0,
                         )
                     )
-                    ancestors = [g.demes[j].id for j in anc_idx]
+                    ancestors = [b.data["demes"][j]["id"] for j in anc_idx]
                     if len(ancestors) == 1:
                         proportions = [1.0]
                     else:
@@ -178,7 +177,7 @@ def test_something(self, g):
                         )
                         psum = sum(proportions)
                         proportions = [p / psum for p in proportions]
-        g.deme(
+        b.add_deme(
             id=id,
             description=draw(st.none() | yaml_strings()),
             ancestors=ancestors,
@@ -189,12 +188,18 @@ def test_something(self, g):
 
     n_interactions = draw(st.integers(min_value=0, max_value=max_interactions))
     n_tries = 100
-    for j in range(len(g.demes) - 1):
-        for k in range(j + 1, len(g.demes)):
-            dj = g.demes[j].id
-            dk = g.demes[k].id
-            time_lo = max(g[dj].end_time, g[dk].end_time)
-            time_hi = min(g[dj].start_time, g[dk].start_time)
+    n_demes = len(b.data["demes"])
+    for j in range(n_demes - 1):
+        for k in range(j + 1, n_demes):
+            dj = b.data["demes"][j]["id"]
+            dk = b.data["demes"][k]["id"]
+            time_lo = max(
+                b.data["demes"][j]["epochs"][-1]["end_time"],
+                b.data["demes"][k]["epochs"][-1]["end_time"],
+            )
+            time_hi = min(
+                b.data["demes"][j]["start_time"], b.data["demes"][k]["start_time"]
+            )
             if time_hi - time_lo < 1e308:
                 # Demes j and k don't exist at the same time,
                 # or the interval is too small to draw a floating
@@ -230,7 +235,7 @@ def test_something(self, g):
                 if no_overlap:
                     migration_intervals[(dj, dk)].append([max(times), min(times)])
                     successes += 1
-                    g.migration(
+                    b.add_migration(
                         source=source,
                         dest=dest,
                         start_time=max(times),
@@ -257,7 +262,7 @@ def test_something(self, g):
                         exclude_max=True,
                     )
                 )
-                g.pulse(
+                b.add_pulse(
                     source=source,
                     dest=dest,
                     time=time,
@@ -272,4 +277,4 @@ def test_something(self, g):
         if n_interactions <= 0:
             break
 
-    return g
+    return b.resolve()
diff --git a/tests/test_demes.py b/tests/test_demes.py
index 800feb5b..a9c60afd 100644
--- a/tests/test_demes.py
+++ b/tests/test_demes.py
@@ -1,12 +1,17 @@
 import unittest
 import copy
 import pathlib
+import math
+import typing
 
 import pytest
+import hypothesis as hyp
 
 from demes import (
+    Builder,
     Epoch,
-    Migration,
+    AsymmetricMigration,
+    SymmetricMigration,
     Pulse,
     Deme,
     Graph,
@@ -17,195 +22,528 @@
     load,
 )
 import demes
+import tests
 
 
 class TestEpoch(unittest.TestCase):
     def test_bad_time(self):
-        for time in ("0", "inf", {}, [], float("NaN")):
+        for time in ("0", "inf", {}, [], math.nan):
             with self.assertRaises(TypeError):
-                Epoch(start_time=time, end_time=0, start_size=1)
+                Epoch(
+                    start_time=time,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                )
             with self.assertRaises(TypeError):
-                Epoch(start_time=100, end_time=time, start_size=1)
+                Epoch(
+                    start_time=100,
+                    end_time=time,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                )
 
         for start_time in (-10000, -1, -1e-9):
             with self.assertRaises(ValueError):
-                Epoch(start_time=start_time, end_time=0, start_size=1)
-        for end_time in (-10000, -1, -1e-9, float("inf")):
+                Epoch(
+                    start_time=start_time,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                )
+        for end_time in (-10000, -1, -1e-9, math.inf):
             with self.assertRaises(ValueError):
-                Epoch(start_time=100, end_time=end_time, start_size=1)
+                Epoch(
+                    start_time=100,
+                    end_time=end_time,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                )
 
     def test_bad_time_span(self):
         with self.assertRaises(ValueError):
-            Epoch(start_time=1, end_time=1, start_size=1)
+            Epoch(
+                start_time=1,
+                end_time=1,
+                start_size=1,
+                end_size=1,
+                size_function="constant",
+            )
         with self.assertRaises(ValueError):
-            Epoch(start_time=1, end_time=2, start_size=1)
+            Epoch(
+                start_time=1,
+                end_time=2,
+                start_size=1,
+                end_size=1,
+                size_function="constant",
+            )
 
     def test_bad_size(self):
-        for size in ("0", "100", {}, [], float("NaN")):
+        for size in ("0", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
-                Epoch(start_time=1, end_time=0, start_size=size)
+                Epoch(
+                    start_time=1,
+                    end_time=0,
+                    start_size=size,
+                    end_size=1,
+                    size_function="exponential",
+                )
             with self.assertRaises(TypeError):
-                Epoch(start_time=1, end_time=0, start_size=1, end_size=size)
+                Epoch(
+                    start_time=1,
+                    end_time=0,
+                    start_size=1,
+                    end_size=size,
+                    size_function="exponential",
+                )
 
-        for size in (-10000, -1, -1e-9, 0, float("inf")):
+        for size in (-10000, -1, -1e-9, 0, math.inf):
             with self.assertRaises(ValueError):
-                Epoch(start_time=1, end_time=0, start_size=size)
+                Epoch(
+                    start_time=1,
+                    end_time=0,
+                    start_size=size,
+                    end_size=1,
+                    size_function="exponential",
+                )
             with self.assertRaises(ValueError):
-                Epoch(start_time=1, end_time=0, start_size=1, end_size=size)
+                Epoch(
+                    start_time=1,
+                    end_time=0,
+                    start_size=1,
+                    end_size=size,
+                    size_function="exponential",
+                )
 
-    def test_missing_size(self):
-        with self.assertRaises(ValueError):
-            Epoch(start_time=1, end_time=0)
+    def test_missing_values(self):
+        with self.assertRaises(TypeError):
+            Epoch()
+        with self.assertRaises(TypeError):
+            Epoch(start_time=1, end_time=0, start_size=1, end_size=1)
+        with self.assertRaises(TypeError):
+            Epoch(start_time=1, end_time=0, start_size=1, size_function="constant")
+        with self.assertRaises(TypeError):
+            Epoch(start_time=1, end_time=0, end_size=1, size_function="constant")
+        with self.assertRaises(TypeError):
+            Epoch(start_time=1, start_size=1, end_size=1, size_function="constant")
+        with self.assertRaises(TypeError):
+            Epoch(end_time=0, start_size=1, end_size=1, size_function="constant")
 
     def test_valid_epochs(self):
-        Epoch(end_time=0, start_size=1)
-        Epoch(end_time=0, end_size=1)
-        Epoch(start_time=float("inf"), end_time=0, start_size=1)
-        Epoch(start_time=float("inf"), end_time=10, start_size=1)
-        Epoch(start_time=100, end_time=99, start_size=1)
-        Epoch(end_time=0, start_size=1, end_size=1)
-        Epoch(end_time=0, start_size=1, end_size=100)
-        Epoch(end_time=0, start_size=100, end_size=1)
-        Epoch(start_time=20, end_time=10, start_size=1, end_size=100)
+        Epoch(
+            start_time=math.inf,
+            end_time=0,
+            start_size=1,
+            end_size=1,
+            size_function="constant",
+        )
+        Epoch(
+            start_time=math.inf,
+            end_time=10,
+            start_size=1,
+            end_size=1,
+            size_function="constant",
+        )
+        Epoch(
+            start_time=100,
+            end_time=99,
+            start_size=1,
+            end_size=1,
+            size_function="constant",
+        )
+        Epoch(
+            start_time=100,
+            end_time=0,
+            start_size=1,
+            end_size=100,
+            size_function="exponential",
+        )
+        Epoch(
+            start_time=20,
+            end_time=10,
+            start_size=1,
+            end_size=100,
+            size_function="exponential",
+        )
         for rate in (0, 1, 0.5, 1e-5):
-            Epoch(end_time=0, start_size=1, selfing_rate=rate)
-            Epoch(end_time=0, start_size=1, cloning_rate=rate)
-            Epoch(end_time=0, start_size=1, selfing_rate=rate, cloning_rate=rate)
-        Epoch(end_time=0, start_size=1, size_function="constant")
-        Epoch(end_time=0, end_size=1, size_function="constant")
-        Epoch(end_time=0, start_size=1, end_size=1, size_function="constant")
+            Epoch(
+                start_time=math.inf,
+                end_time=0,
+                start_size=1,
+                end_size=1,
+                size_function="constant",
+                selfing_rate=rate,
+            )
+            Epoch(
+                start_time=math.inf,
+                end_time=0,
+                start_size=1,
+                end_size=1,
+                size_function="constant",
+                cloning_rate=rate,
+            )
+            Epoch(
+                start_time=math.inf,
+                end_time=0,
+                start_size=1,
+                end_size=1,
+                size_function="constant",
+                selfing_rate=rate,
+                cloning_rate=rate,
+            )
         for fn in ("linear", "exponential", "N(t) = 6 * log(t)"):
-            Epoch(end_time=0, start_size=1, end_size=10, size_function=fn)
-            Epoch(end_time=0, start_size=1, size_function=fn)
-            Epoch(end_time=0, end_size=10, size_function=fn)
+            Epoch(
+                start_time=100, end_time=0, start_size=1, end_size=10, size_function=fn
+            )
 
     def test_time_span(self):
-        e = Epoch(start_time=float("inf"), end_time=0, start_size=1)
-        self.assertEqual(e.time_span, float("inf"))
-        e = Epoch(start_time=100, end_time=20, start_size=1)
+        e = Epoch(
+            start_time=math.inf,
+            end_time=0,
+            start_size=1,
+            end_size=1,
+            size_function="constant",
+        )
+        self.assertEqual(e.time_span, math.inf)
+        e = Epoch(
+            start_time=100,
+            end_time=20,
+            start_size=1,
+            end_size=1,
+            size_function="constant",
+        )
         self.assertEqual(e.time_span, 80)
 
     def test_inf_start_time_constant_epoch(self):
         with self.assertRaises(ValueError):
-            Epoch(start_time=float("inf"), end_time=0, start_size=10, end_size=20)
+            Epoch(
+                start_time=math.inf,
+                end_time=0,
+                start_size=10,
+                end_size=20,
+                size_function="exponential",
+            )
 
     def test_isclose(self):
         eps = 1e-50
-        e1 = Epoch(end_time=0, start_size=1)
+        e1 = Epoch(
+            start_time=10,
+            end_time=0,
+            start_size=1,
+            end_size=1,
+            size_function="exponential",
+        )
         self.assertTrue(e1.isclose(e1))
-        self.assertTrue(e1.isclose(Epoch(end_time=0 + eps, start_size=1)))
-        self.assertTrue(e1.isclose(Epoch(end_time=0, start_size=1 + eps)))
+        self.assertTrue(
+            e1.isclose(
+                Epoch(
+                    start_time=10,
+                    end_time=0 + eps,
+                    start_size=1,
+                    end_size=1,
+                    size_function="exponential",
+                )
+            )
+        )
+        self.assertTrue(
+            e1.isclose(
+                Epoch(
+                    start_time=10,
+                    end_time=0,
+                    start_size=1 + eps,
+                    end_size=1,
+                    size_function="exponential",
+                )
+            )
+        )
 
-        self.assertFalse(e1.isclose(Epoch(end_time=1e-9, start_size=1)))
-        self.assertFalse(e1.isclose(Epoch(end_time=0, start_size=1 + 1e-9)))
-        self.assertFalse(e1.isclose(Epoch(start_time=10, end_time=0, start_size=1)))
-        self.assertFalse(e1.isclose(Epoch(end_time=0, start_size=1, end_size=2)))
         self.assertFalse(
-            Epoch(end_time=0, start_size=1, end_size=2).isclose(
+            e1.isclose(
+                Epoch(
+                    start_time=10,
+                    end_time=1e-9,
+                    start_size=1,
+                    end_size=1,
+                    size_function="exponential",
+                )
+            )
+        )
+        self.assertFalse(
+            e1.isclose(
                 Epoch(
+                    start_time=10,
+                    end_time=0,
+                    start_size=1 + 1e-9,
+                    end_size=1,
+                    size_function="exponential",
+                )
+            )
+        )
+        self.assertFalse(
+            e1.isclose(
+                Epoch(
+                    start_time=math.inf,
                     end_time=0,
                     start_size=1,
-                    end_size=2,
+                    end_size=1,
                     size_function="exponential",
                 )
             )
         )
-        self.assertFalse(e1.isclose(Epoch(end_time=0, start_size=1, selfing_rate=0.1)))
-        self.assertFalse(e1.isclose(Epoch(end_time=0, start_size=1, cloning_rate=0.1)))
+        self.assertFalse(
+            e1.isclose(
+                Epoch(
+                    start_time=10,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                )
+            )
+        )
+        self.assertFalse(
+            e1.isclose(
+                Epoch(
+                    start_time=10,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                    selfing_rate=0.1,
+                )
+            )
+        )
+        self.assertFalse(
+            e1.isclose(
+                Epoch(
+                    start_time=10,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                    cloning_rate=0.1,
+                )
+            )
+        )
         self.assertFalse(e1.isclose(None))
         self.assertFalse(e1.isclose(123))
         self.assertFalse(e1.isclose("foo"))
 
     def test_bad_selfing_rate(self):
-        for rate in ("0", "1e-4", "inf", [], {}, float("NaN")):
+        for rate in ("0", "1e-4", "inf", [], {}, math.nan):
             with self.assertRaises(TypeError):
-                Epoch(start_time=100, end_time=0, start_size=1, selfing_rate=rate)
+                Epoch(
+                    start_time=100,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                    selfing_rate=rate,
+                )
 
-        for rate in (-10000, 10000, -1, -1e-9, 1.2, float("inf")):
+        for rate in (-10000, 10000, -1, -1e-9, 1.2, math.inf):
             with self.assertRaises(ValueError):
-                Epoch(start_time=100, end_time=0, start_size=1, selfing_rate=rate)
+                Epoch(
+                    start_time=100,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                    selfing_rate=rate,
+                )
 
     def test_bad_cloning_rate(self):
-        for rate in ("0", "1e-4", "inf", [], {}, float("NaN")):
+        for rate in ("0", "1e-4", "inf", [], {}, math.nan):
             with self.assertRaises(TypeError):
-                Epoch(start_time=100, end_time=0, start_size=1, cloning_rate=rate)
+                Epoch(
+                    start_time=100,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                    cloning_rate=rate,
+                )
 
-        for rate in (-10000, 10000, -1, -1e-9, 1.2, float("inf")):
+        for rate in (-10000, 10000, -1, -1e-9, 1.2, math.inf):
             with self.assertRaises(ValueError):
-                Epoch(start_time=100, end_time=0, start_size=1, cloning_rate=rate)
+                Epoch(
+                    start_time=100,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                    cloning_rate=rate,
+                )
 
     def test_bad_size_function(self):
-        for fn in (0, 1e5, [], {}, float("NaN")):
+        for fn in (0, 1e5, [], {}, math.nan):
             with self.assertRaises(TypeError):
-                Epoch(end_time=0, start_size=1, end_size=10, size_function=fn)
+                Epoch(
+                    start_time=10,
+                    end_time=0,
+                    start_size=1,
+                    end_size=10,
+                    size_function=fn,
+                )
 
         for fn in ("", "constant"):
             with self.assertRaises(ValueError):
-                Epoch(end_time=0, start_size=1, end_size=10, size_function=fn)
+                Epoch(
+                    start_time=10,
+                    end_time=0,
+                    start_size=1,
+                    end_size=10,
+                    size_function=fn,
+                )
 
 
 class TestMigration(unittest.TestCase):
     def test_bad_time(self):
-        for time in ("inf", "100", {}, [], float("NaN")):
+        for time in ("inf", "100", {}, [], math.nan):
+            with self.assertRaises(TypeError):
+                AsymmetricMigration(
+                    source="a", dest="b", start_time=time, end_time=0, rate=0.1
+                )
+            with self.assertRaises(TypeError):
+                AsymmetricMigration(
+                    source="a", dest="b", start_time=100, end_time=time, rate=0.1
+                )
             with self.assertRaises(TypeError):
-                Migration(source="a", dest="b", start_time=time, end_time=0, rate=0.1)
+                SymmetricMigration(
+                    demes=["a", "b"], start_time=time, end_time=0, rate=0.1
+                )
             with self.assertRaises(TypeError):
-                Migration(source="a", dest="b", start_time=100, end_time=time, rate=0.1)
+                SymmetricMigration(
+                    demes=["a", "b"], start_time=100, end_time=time, rate=0.1
+                )
 
         for time in (-10000, -1, -1e-9):
             with self.assertRaises(ValueError):
-                Migration(source="a", dest="b", start_time=time, end_time=0, rate=0.1)
-        for time in (-10000, -1, -1e-9, float("inf")):
+                AsymmetricMigration(
+                    source="a", dest="b", start_time=time, end_time=0, rate=0.1
+                )
+            with self.assertRaises(ValueError):
+                SymmetricMigration(
+                    demes=["a", "b"], start_time=time, end_time=0, rate=0.1
+                )
+        for time in (-10000, -1, -1e-9, math.inf):
+            with self.assertRaises(ValueError):
+                AsymmetricMigration(
+                    source="a", dest="b", start_time=100, end_time=time, rate=0.1
+                )
             with self.assertRaises(ValueError):
-                Migration(source="a", dest="b", start_time=100, end_time=time, rate=0.1)
+                SymmetricMigration(
+                    demes=["a", "b"], start_time=100, end_time=time, rate=0.1
+                )
+
+        # start_time == end_time
+        with self.assertRaises(ValueError):
+            AsymmetricMigration(
+                source="a", dest="b", start_time=100, end_time=100, rate=0.1
+            )
+        with self.assertRaises(ValueError):
+            SymmetricMigration(demes=["a", "b"], start_time=100, end_time=100, rate=0.1)
+
+        # start_time < end_time
+        with self.assertRaises(ValueError):
+            AsymmetricMigration(
+                source="a", dest="b", start_time=10, end_time=100, rate=0.1
+            )
+        with self.assertRaises(ValueError):
+            SymmetricMigration(demes=["a", "b"], start_time=10, end_time=100, rate=0.1)
 
     def test_bad_rate(self):
-        for rate in ("inf", "100", {}, [], float("NaN")):
+        for rate in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
-                Migration(source="a", dest="b", start_time=10, end_time=0, rate=rate)
+                AsymmetricMigration(
+                    source="a", dest="b", start_time=10, end_time=0, rate=rate
+                )
+            with self.assertRaises(TypeError):
+                SymmetricMigration(
+                    demes=["a", "b"], start_time=10, end_time=0, rate=rate
+                )
 
-        for rate in (-10000, -1, -1e-9, float("inf")):
+        for rate in (-10000, -1, -1e-9, math.inf):
+            with self.assertRaises(ValueError):
+                AsymmetricMigration(
+                    source="a", dest="b", start_time=10, end_time=0, rate=rate
+                )
             with self.assertRaises(ValueError):
-                Migration(source="a", dest="b", start_time=10, end_time=0, rate=rate)
+                SymmetricMigration(
+                    demes=["a", "b"], start_time=10, end_time=0, rate=rate
+                )
 
     def test_bad_demes(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (0, math.inf, 1e3, {}, []):
+            with self.assertRaises(TypeError):
+                AsymmetricMigration(
+                    source=id, dest="a", start_time=10, end_time=0, rate=0.1
+                )
             with self.assertRaises(TypeError):
-                Migration(source=id, dest="a", start_time=10, end_time=0, rate=0.1)
+                AsymmetricMigration(
+                    source="a", dest=id, start_time=10, end_time=0, rate=0.1
+                )
             with self.assertRaises(TypeError):
-                Migration(source="a", dest=id, start_time=10, end_time=0, rate=0.1)
+                SymmetricMigration(demes=["a", id], start_time=10, end_time=0, rate=0.1)
 
         for id in ("a", "", "pop 1"):
             with self.assertRaises(ValueError):
-                Migration(source=id, dest="a", start_time=10, end_time=0, rate=0.1)
+                AsymmetricMigration(
+                    source=id, dest="a", start_time=10, end_time=0, rate=0.1
+                )
+            with self.assertRaises(ValueError):
+                AsymmetricMigration(
+                    source="a", dest=id, start_time=10, end_time=0, rate=0.1
+                )
             with self.assertRaises(ValueError):
-                Migration(source="a", dest=id, start_time=10, end_time=0, rate=0.1)
+                SymmetricMigration(demes=["a", id], start_time=10, end_time=0, rate=0.1)
+
+        # need at least two demes for symmetric migration
+        with self.assertRaises(ValueError):
+            SymmetricMigration(demes=["a"], start_time=10, end_time=0, rate=0.1)
+        with self.assertRaises(ValueError):
+            SymmetricMigration(demes=[], start_time=10, end_time=0, rate=0.1)
 
     def test_valid_migration(self):
-        Migration(source="a", dest="b", start_time=float("inf"), end_time=0, rate=1e-9)
-        Migration(source="a", dest="b", start_time=1000, end_time=999, rate=0.9)
+        AsymmetricMigration(
+            source="a", dest="b", start_time=math.inf, end_time=0, rate=1e-9
+        )
+        AsymmetricMigration(
+            source="a", dest="b", start_time=1000, end_time=999, rate=0.9
+        )
+        SymmetricMigration(demes=["a", "b"], start_time=math.inf, end_time=0, rate=1e-9)
+        SymmetricMigration(demes=["a", "b"], start_time=1000, end_time=999, rate=0.9)
+        SymmetricMigration(
+            demes=["a", "b", "c", "d", "e"], start_time=1, end_time=0, rate=0.9
+        )
 
     def test_isclose(self):
         eps = 1e-50
-        m1 = Migration(source="a", dest="b", start_time=1, end_time=0, rate=1e-9)
+        m1 = AsymmetricMigration(
+            source="a", dest="b", start_time=1, end_time=0, rate=1e-9
+        )
         self.assertTrue(m1.isclose(m1))
         self.assertTrue(
             m1.isclose(
-                Migration(
+                AsymmetricMigration(
                     source="a", dest="b", start_time=1, end_time=0, rate=1e-9 + eps
                 )
             )
         )
         self.assertTrue(
             m1.isclose(
-                Migration(
+                AsymmetricMigration(
                     source="a", dest="b", start_time=1 + eps, end_time=0, rate=1e-9
                 )
             )
         )
         self.assertTrue(
             m1.isclose(
-                Migration(
+                AsymmetricMigration(
                     source="a", dest="b", start_time=1, end_time=0 + eps, rate=1e-9
                 )
             )
@@ -213,27 +551,37 @@ def test_isclose(self):
 
         self.assertFalse(
             m1.isclose(
-                Migration(source="b", dest="a", start_time=1, end_time=0, rate=1e-9)
+                AsymmetricMigration(
+                    source="b", dest="a", start_time=1, end_time=0, rate=1e-9
+                )
             )
         )
         self.assertFalse(
             m1.isclose(
-                Migration(source="a", dest="b", start_time=1, end_time=0, rate=2e-9)
+                AsymmetricMigration(
+                    source="a", dest="b", start_time=1, end_time=0, rate=2e-9
+                )
             )
         )
         self.assertFalse(
             m1.isclose(
-                Migration(source="a", dest="c", start_time=1, end_time=0, rate=1e-9)
+                AsymmetricMigration(
+                    source="a", dest="c", start_time=1, end_time=0, rate=1e-9
+                )
             )
         )
         self.assertFalse(
             m1.isclose(
-                Migration(source="a", dest="c", start_time=2, end_time=0, rate=1e-9)
+                AsymmetricMigration(
+                    source="a", dest="c", start_time=2, end_time=0, rate=1e-9
+                )
             )
         )
         self.assertFalse(
             m1.isclose(
-                Migration(source="a", dest="c", start_time=1, end_time=0.1, rate=1e-9)
+                AsymmetricMigration(
+                    source="a", dest="c", start_time=1, end_time=0.1, rate=1e-9
+                )
             )
         )
         self.assertFalse(m1.isclose(None))
@@ -243,25 +591,25 @@ def test_isclose(self):
 
 class TestPulse(unittest.TestCase):
     def test_bad_time(self):
-        for time in ("inf", "100", {}, [], float("NaN")):
+        for time in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Pulse(source="a", dest="b", time=time, proportion=0.1)
 
-        for time in (-10000, -1, -1e-9, float("inf")):
+        for time in (-10000, -1, -1e-9, math.inf):
             with self.assertRaises(ValueError):
                 Pulse(source="a", dest="b", time=time, proportion=0.1)
 
     def test_bad_proportion(self):
-        for proportion in ("inf", "100", {}, [], float("NaN")):
+        for proportion in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Pulse(source="a", dest="b", time=1, proportion=proportion)
 
-        for proportion in (-10000, -1, -1e-9, 1.2, 100, float("inf")):
+        for proportion in (-10000, -1, -1e-9, 1.2, 100, math.inf):
             with self.assertRaises(ValueError):
                 Pulse(source="a", dest="b", time=1, proportion=proportion)
 
     def test_bad_demes(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Pulse(source=id, dest="a", time=1, proportion=0.1)
             with self.assertRaises(TypeError):
@@ -307,11 +655,11 @@ def test_isclose(self):
 
 class TestSplit(unittest.TestCase):
     def test_bad_time(self):
-        for time in ("inf", "100", {}, [], float("NaN")):
+        for time in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Split(parent="a", children=["b", "c"], time=time)
 
-        for time in [-1e-12, -1, float("inf")]:
+        for time in [-1e-12, -1, math.inf]:
             with self.assertRaises(ValueError):
                 Split(parent="a", children=["b", "c"], time=time)
 
@@ -319,7 +667,7 @@ def test_bad_children(self):
         for children in (None, "b", {"b": 1}, set("b"), ("b",)):
             with self.assertRaises(TypeError):
                 Split(parent="a", children=children, time=1)
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Split(parent="a", children=[id], time=1)
 
@@ -331,7 +679,7 @@ def test_bad_children(self):
                 Split(parent="a", children=[id], time=1)
 
     def test_bad_parent(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Split(parent=id, children=["b"], time=1)
 
@@ -368,16 +716,16 @@ def test_isclose(self):
 
 class TestBranch(unittest.TestCase):
     def test_bad_time(self):
-        for time in ("inf", "100", {}, [], float("NaN")):
+        for time in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Branch(parent="a", child="b", time=time)
 
-        for time in [-1e-12, -1, float("inf")]:
+        for time in [-1e-12, -1, math.inf]:
             with self.assertRaises(ValueError):
                 Branch(parent="a", child="b", time=time)
 
     def test_bad_child(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Branch(parent="a", child=id, time=1)
 
@@ -386,7 +734,7 @@ def test_bad_child(self):
                 Branch(parent="a", child=id, time=1)
 
     def test_bad_parent(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Branch(parent=id, child="b", time=1)
 
@@ -414,16 +762,16 @@ def test_isclose(self):
 
 class TestMerge(unittest.TestCase):
     def test_bad_time(self):
-        for time in ("inf", "100", {}, [], float("NaN")):
+        for time in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Merge(parents=["a", "b"], proportions=[0.5, 0.5], child="c", time=time)
 
-        for time in [-1e-12, -1, float("inf")]:
+        for time in [-1e-12, -1, math.inf]:
             with self.assertRaises(ValueError):
                 Merge(parents=["a", "b"], proportions=[0.5, 0.5], child="c", time=time)
 
     def test_bad_child(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Merge(parents=["a", "b"], proportions=[0.5, 0.5], child=id, time=1)
 
@@ -435,7 +783,7 @@ def test_bad_parents(self):
         for parents in (None, "b", {"b": 1}, set("b"), ("b", "b")):
             with self.assertRaises(TypeError):
                 Merge(parents=parents, proportions=[0.5, 0.5], child="c", time=1)
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Merge(parents=["a", id], proportions=[0.5, 0.5], child="c", time=1)
             with self.assertRaises(TypeError):
@@ -450,11 +798,11 @@ def test_bad_parents(self):
             Merge(parents=["a"], proportions=[1], child="b", time=1)
 
     def test_bad_proportions(self):
-        for proportion in ("inf", "100", {}, [], float("NaN")):
+        for proportion in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Merge(parents=["a", "b"], child="c", time=1, proportions=[proportion])
 
-        for proportion in (-10000, -1, -1e-9, 1.2, 100, float("inf")):
+        for proportion in (-10000, -1, -1e-9, 1.2, 100, math.inf):
             with self.assertRaises(ValueError):
                 Merge(
                     parents=["a", "b"], child="c", time=1, proportions=[proportion, 0.5]
@@ -582,16 +930,16 @@ def test_isclose(self):
 
 class TestAdmix(unittest.TestCase):
     def test_bad_time(self):
-        for time in ("inf", "100", {}, [], float("NaN")):
+        for time in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Admix(parents=["a", "b"], proportions=[0.5, 0.5], child="c", time=time)
 
-        for time in [-1e-12, -1, float("inf")]:
+        for time in [-1e-12, -1, math.inf]:
             with self.assertRaises(ValueError):
                 Admix(parents=["a", "b"], proportions=[0.5, 0.5], child="c", time=time)
 
     def test_bad_child(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Admix(parents=["a", "b"], proportions=[0.5, 0.5], child=id, time=1)
 
@@ -603,7 +951,7 @@ def test_bad_parents(self):
         for parents in (None, "b", {"b": 1}, set("b"), ("b", "b")):
             with self.assertRaises(TypeError):
                 Admix(parents=parents, proportions=[0.5, 0.5], child="c", time=1)
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Admix(parents=["a", id], proportions=[0.5, 0.5], child="c", time=1)
             with self.assertRaises(TypeError):
@@ -618,11 +966,11 @@ def test_bad_parents(self):
             Admix(parents=["a"], proportions=[1], child="b", time=1)
 
     def test_bad_proportions(self):
-        for proportion in ("inf", "100", {}, [], float("NaN")):
+        for proportion in ("inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Admix(parents=["a", "b"], child="c", time=1, proportions=[proportion])
 
-        for proportion in (-10000, -1, -1e-9, 1.2, 100, float("inf")):
+        for proportion in (-10000, -1, -1e-9, 1.2, 100, math.inf):
             with self.assertRaises(ValueError):
                 Admix(
                     parents=["a", "b"], child="c", time=1, proportions=[proportion, 0.5]
@@ -761,10 +1109,18 @@ def test_properties(self):
             description="b",
             ancestors=["c"],
             proportions=[1],
-            epochs=[Epoch(start_time=float("inf"), end_time=0, start_size=1)],
-            defaults={},
+            start_time=math.inf,
+            epochs=[
+                Epoch(
+                    start_time=math.inf,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                )
+            ],
         )
-        self.assertEqual(deme.start_time, float("inf"))
+        self.assertEqual(deme.start_time, math.inf)
         self.assertEqual(deme.end_time, 0)
         self.assertEqual(deme.ancestors[0], "c")
         self.assertEqual(deme.proportions[0], 1)
@@ -774,12 +1130,30 @@ def test_properties(self):
             description="b",
             ancestors=["c"],
             proportions=[1],
+            start_time=100,
             epochs=[
-                Epoch(start_time=100, end_time=50, start_size=1),
-                Epoch(start_time=50, end_time=20, start_size=100),
-                Epoch(start_time=20, end_time=1, start_size=200),
+                Epoch(
+                    start_time=100,
+                    end_time=50,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                ),
+                Epoch(
+                    start_time=50,
+                    end_time=20,
+                    start_size=100,
+                    end_size=100,
+                    size_function="constant",
+                ),
+                Epoch(
+                    start_time=20,
+                    end_time=1,
+                    start_size=200,
+                    end_size=1,
+                    size_function="exponential",
+                ),
             ],
-            defaults={},
         )
         self.assertEqual(deme.start_time, 100)
         self.assertEqual(deme.end_time, 1)
@@ -789,21 +1163,37 @@ def test_properties(self):
             description=None,
             ancestors=["c"],
             proportions=[1],
-            epochs=[Epoch(start_time=float("inf"), end_time=0, start_size=1)],
-            defaults={},
+            start_time=math.inf,
+            epochs=[
+                Epoch(
+                    start_time=math.inf,
+                    end_time=0,
+                    start_size=1,
+                    end_size=1,
+                    size_function="constant",
+                )
+            ],
         )
         self.assertEqual(deme.description, None)
 
     def test_bad_id(self):
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Deme(
                     id=id,
                     description="b",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=float("inf"), end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=math.inf,
+                    epochs=[
+                        Epoch(
+                            start_time=math.inf,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
         for id in ["", "501", "pop-1", "pop.2", "pop 3"]:
             with self.assertRaises(ValueError):
@@ -812,19 +1202,36 @@ def test_bad_id(self):
                     description="b",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=float("inf"), end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=math.inf,
+                    epochs=[
+                        Epoch(
+                            start_time=math.inf,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
 
     def test_bad_description(self):
-        for description in (0, float("inf"), 1e3, {}, []):
+        for description in (0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Deme(
                     id="a",
                     description=description,
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=float("inf"), end_time=0, start_size=1)],
+                    start_time=math.inf,
+                    epochs=[
+                        Epoch(
+                            start_time=math.inf,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
         with self.assertRaises(ValueError):
             Deme(
@@ -832,8 +1239,16 @@ def test_bad_description(self):
                 description="",
                 ancestors=[],
                 proportions=[],
-                epochs=[Epoch(start_time=float("inf"), end_time=0, start_size=1)],
-                defaults={},
+                start_time=math.inf,
+                epochs=[
+                    Epoch(
+                        start_time=math.inf,
+                        end_time=0,
+                        start_size=1,
+                        end_size=1,
+                        size_function="constant",
+                    )
+                ],
             )
 
     def test_bad_ancestors(self):
@@ -844,18 +1259,34 @@ def test_bad_ancestors(self):
                     description="b",
                     ancestors=ancestors,
                     proportions=[1],
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
-        for id in (None, 0, float("inf"), 1e3, {}, []):
+        for id in (None, 0, math.inf, 1e3, {}, []):
             with self.assertRaises(TypeError):
                 Deme(
                     id="a",
                     description="b",
                     ancestors=[id],
                     proportions=[1],
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
         for id in ["", "501", "pop-1", "pop.2", "pop 3"]:
             with self.assertRaises(ValueError):
@@ -864,8 +1295,16 @@ def test_bad_ancestors(self):
                     description="b",
                     ancestors=[id],
                     proportions=[1],
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
 
         with self.assertRaises(ValueError):
@@ -874,8 +1313,16 @@ def test_bad_ancestors(self):
                 description="b",
                 ancestors=["a", "c"],
                 proportions=[0.5, 0.5],
-                epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                defaults={},
+                start_time=10,
+                epochs=[
+                    Epoch(
+                        start_time=10,
+                        end_time=0,
+                        start_size=1,
+                        end_size=1,
+                        size_function="constant",
+                    )
+                ],
             )
         with self.assertRaises(ValueError):
             # duplicate ancestors
@@ -884,30 +1331,54 @@ def test_bad_ancestors(self):
                 description="test",
                 ancestors=["x", "x"],
                 proportions=[0.5, 0.5],
-                epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                defaults={},
+                start_time=10,
+                epochs=[
+                    Epoch(
+                        start_time=10,
+                        end_time=0,
+                        start_size=1,
+                        end_size=1,
+                        size_function="constant",
+                    )
+                ],
             )
 
     def test_bad_proportions(self):
-        for proportions in (None, {}, 1e5, "proportions", float("NaN")):
+        for proportions in (None, {}, 1e5, "proportions", math.nan):
             with self.assertRaises(TypeError):
                 Deme(
                     id="a",
                     description="test",
                     ancestors=[],
                     proportions=proportions,
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
-        for proportion in (None, "inf", "100", {}, [], float("NaN")):
+        for proportion in (None, "inf", "100", {}, [], math.nan):
             with self.assertRaises(TypeError):
                 Deme(
                     id="a",
                     description="test",
                     ancestors=["b"],
                     proportions=[proportion],
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
 
         for proportions in (
@@ -922,19 +1393,35 @@ def test_bad_proportions(self):
                     description="test",
                     ancestors=["x", "y"],
                     proportions=proportions,
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
 
-        for proportion in (-10000, -1, -1e-9, 1.2, 100, float("inf")):
+        for proportion in (-10000, -1, -1e-9, 1.2, 100, math.inf):
             with self.assertRaises(ValueError):
                 Deme(
                     id="a",
                     description="test",
                     ancestors=["b", "c"],
                     proportions=[0.5, proportion],
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
             with self.assertRaises(ValueError):
                 Deme(
@@ -942,38 +1429,70 @@ def test_bad_proportions(self):
                     description="test",
                     ancestors=["b", "c"],
                     proportions=[proportion, 0.5],
-                    epochs=[Epoch(start_time=10, end_time=0, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=0,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        )
+                    ],
                 )
 
     def test_epochs_out_of_order(self):
-        for time in (5, -1, float("inf")):
+        for time in (5, -1, math.inf):
             with self.assertRaises(ValueError):
                 Deme(
                     id="a",
                     description="b",
                     ancestors=["c"],
                     proportions=[1],
+                    start_time=10,
                     epochs=[
-                        Epoch(start_time=10, end_time=5, start_size=1),
-                        Epoch(start_time=5, end_time=time, start_size=100),
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        ),
+                        Epoch(
+                            start_time=5,
+                            end_time=time,
+                            start_size=100,
+                            end_size=100,
+                            size_function="constant",
+                        ),
                     ],
-                    defaults={},
                 )
 
     def test_epochs_are_a_partition(self):
-        for start_time, end_time in [(float("inf"), 100), (200, 100)]:
+        for start_time, end_time in [(math.inf, 100), (200, 100)]:
             with self.assertRaises(ValueError):
                 Deme(
                     id="a",
                     description="b",
                     ancestors=["c"],
                     proportions=[1],
+                    start_time=start_time,
                     epochs=[
-                        Epoch(start_time=start_time, end_time=end_time, start_size=1),
-                        Epoch(start_time=50, end_time=0, start_size=2),
+                        Epoch(
+                            start_time=start_time,
+                            end_time=end_time,
+                            start_size=1,
+                            end_size=1,
+                            size_function="constant",
+                        ),
+                        Epoch(
+                            start_time=50,
+                            end_time=0,
+                            start_size=2,
+                            end_size=2,
+                            size_function="constant",
+                        ),
                     ],
-                    defaults={},
                 )
 
     def test_bad_epochs(self):
@@ -984,28 +1503,27 @@ def test_bad_epochs(self):
                     description="b",
                     ancestors=["c"],
                     proportions=[1],
+                    start_time=10,
                     epochs=epochs,
-                    defaults={},
                 )
-        with self.assertRaises(ValueError):
-            Deme(
-                id="a",
-                description="b",
-                ancestors=["c"],
-                proportions=[1],
-                epochs=[],
-                defaults={},
-            )
 
     def test_time_span(self):
-        for start_time, end_time in zip((float("inf"), 100, 20), (0, 20, 0)):
+        for start_time, end_time in zip((math.inf, 100, 20), (0, 20, 0)):
             deme = Deme(
                 id="a",
                 description="b",
                 ancestors=["c"],
                 proportions=[1],
-                epochs=[Epoch(start_time=start_time, end_time=end_time, start_size=1)],
-                defaults={},
+                start_time=start_time,
+                epochs=[
+                    Epoch(
+                        start_time=start_time,
+                        end_time=end_time,
+                        start_size=1,
+                        end_size=1,
+                        size_function="constant",
+                    )
+                ],
             )
             self.assertEqual(deme.time_span, start_time - end_time)
         with self.assertRaises(ValueError):
@@ -1014,8 +1532,16 @@ def test_time_span(self):
                 description="b",
                 ancestors=["c"],
                 proportions=[1],
-                epochs=[Epoch(start_time=100, end_time=100, start_size=1)],
-                defaults={},
+                start_time=100,
+                epochs=[
+                    Epoch(
+                        start_time=100,
+                        end_time=100,
+                        start_size=1,
+                        end_size=1,
+                        size_function="constant",
+                    )
+                ],
             )
 
     def test_isclose(self):
@@ -1024,8 +1550,16 @@ def test_isclose(self):
             description="foo deme",
             ancestors=[],
             proportions=[],
-            epochs=[Epoch(start_time=10, end_time=5, start_size=1)],
-            defaults={},
+            start_time=10,
+            epochs=[
+                Epoch(
+                    start_time=10,
+                    end_time=5,
+                    start_size=1,
+                    end_size=1,
+                    size_function="exponential",
+                )
+            ],
         )
         self.assertTrue(d1.isclose(d1))
         self.assertTrue(
@@ -1035,8 +1569,16 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=10, end_time=5, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                        )
+                    ],
                 )
             )
         )
@@ -1048,8 +1590,16 @@ def test_isclose(self):
                     description="bar deme",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=10, end_time=5, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                        )
+                    ],
                 )
             )
         )
@@ -1065,8 +1615,16 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=10, end_time=5, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                        )
+                    ],
                 )
             )
         )
@@ -1077,8 +1635,16 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=["x"],
                     proportions=[1],
-                    epochs=[Epoch(start_time=10, end_time=5, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                        )
+                    ],
                 )
             )
         )
@@ -1089,8 +1655,16 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=9, end_time=5, start_size=1)],
-                    defaults={},
+                    start_time=9,
+                    epochs=[
+                        Epoch(
+                            start_time=9,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                        )
+                    ],
                 )
             )
         )
@@ -1101,8 +1675,16 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=10, end_time=9, start_size=1)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=9,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                        )
+                    ],
                 )
             )
         )
@@ -1114,8 +1696,16 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=[],
                     proportions=[],
-                    epochs=[Epoch(start_time=10, end_time=5, start_size=9)],
-                    defaults={},
+                    start_time=10,
+                    epochs=[
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=9,
+                            end_size=1,
+                            size_function="exponential",
+                        )
+                    ],
                 )
             )
         )
@@ -1126,10 +1716,17 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=[],
                     proportions=[],
+                    start_time=10,
                     epochs=[
-                        Epoch(start_time=10, end_time=5, start_size=1, selfing_rate=0.1)
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                            selfing_rate=0.1,
+                        )
                     ],
-                    defaults={},
                 )
             )
         )
@@ -1140,28 +1737,32 @@ def test_isclose(self):
                     description="foo deme",
                     ancestors=[],
                     proportions=[],
+                    start_time=10,
                     epochs=[
-                        Epoch(start_time=10, end_time=5, start_size=1, cloning_rate=0.1)
+                        Epoch(
+                            start_time=10,
+                            end_time=5,
+                            start_size=1,
+                            end_size=1,
+                            size_function="exponential",
+                            cloning_rate=0.1,
+                        )
                     ],
-                    defaults={},
                 )
             )
         )
 
-    # APR (7/28): Add tests for selfing rate, cloning rate, and size function.
-    # Also add tests for any implied values.
-
 
 class TestGraph(unittest.TestCase):
     def test_bad_generation_time(self):
-        for generation_time in ([], {}, "42", "inf", float("NaN")):
+        for generation_time in ([], {}, "42", "inf", math.nan):
             with self.assertRaises(TypeError):
                 Graph(
                     description="test",
                     time_units="years",
                     generation_time=generation_time,
                 )
-        for generation_time in (-100, -1e-9, 0, float("inf"), None):
+        for generation_time in (-100, -1e-9, 0, math.inf, None):
             with self.assertRaises(ValueError):
                 Graph(
                     description="test",
@@ -1175,7 +1776,7 @@ def test_description(self):
         Graph(time_units="generations")
 
     def test_bad_description(self):
-        for description in ([], {}, 0, 1e5, float("inf")):
+        for description in ([], {}, 0, 1e5, math.inf):
             with self.assertRaises(TypeError):
                 Graph(
                     description=description,
@@ -1229,14 +1830,14 @@ def test_doi(self):
         )
 
     def test_bad_doi(self):
-        for doi_list in ({}, "10.1000/123456", float("inf"), 1e5, 0):
+        for doi_list in ({}, "10.1000/123456", math.inf, 1e5, 0):
             with self.assertRaises(TypeError):
                 Graph(
                     description="test",
                     time_units="generations",
                     doi=doi_list,
                 )
-        for doi in (None, {}, [], float("inf"), 1e5, 0):
+        for doi in (None, {}, [], math.inf, 1e5, 0):
             with self.assertRaises(TypeError):
                 Graph(
                     description="test",
@@ -1309,686 +1910,1026 @@ def test_in_generations(self):
             i += 1
         self.assertGreater(i, 0)
 
-    def test_bad_migration_time(self):
-        dg = demes.Graph(description="test bad migration", time_units="generations")
-        dg.deme("deme1", epochs=[Epoch(start_size=1000, end_time=0)])
-        dg.deme("deme2", epochs=[Epoch(end_time=100, start_size=1000)])
-        with self.assertRaises(ValueError):
-            dg.migration(
-                source="deme1", dest="deme2", rate=0.01, start_time=1000, end_time=0
-            )
-
-    def test_overlapping_migrations(self):
-        dg = demes.Graph(description="test", time_units="generations")
-        dg.deme("A", epochs=[Epoch(start_size=1, end_time=0)])
-        dg.deme("B", epochs=[Epoch(start_size=1, end_time=0)])
-        dg.migration(source="A", dest="B", rate=0.01)
-        with self.assertRaises(ValueError):
-            dg.migration(source="A", dest="B", start_time=10, rate=0.02)
-        with self.assertRaises(ValueError):
-            dg.symmetric_migration(demes=["A", "B"], rate=0.02)
-        with self.assertRaises(ValueError):
-            dg.symmetric_migration(demes=["A", "B"], rate=0.02, end_time=100)
-        dg.migration(source="B", dest="A", rate=0.03, start_time=100, end_time=10)
-        with self.assertRaises(ValueError):
-            dg.migration(source="B", dest="A", rate=0.04, start_time=50)
-        dg.migration(source="B", dest="A", rate=0.03, start_time=5)
-        with self.assertRaises(ValueError):
-            dg.migration(source="B", dest="A", rate=0.05, start_time=10)
-        dg.migration(source="B", dest="A", rate=0.01, start_time=10, end_time=5)
-
-    def test_bad_pulse_time(self):
-        dg = demes.Graph(description="test bad pulse time", time_units="generations")
-        dg.deme("deme1", epochs=[Epoch(start_size=1000, end_time=0)])
-        dg.deme("deme2", epochs=[Epoch(end_time=100, start_size=1000)])
-        with self.assertRaises(ValueError):
-            dg.pulse(source="deme1", dest="deme2", proportion=0.1, time=10)
-
-    def test_bad_deme(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("b", epochs=[Epoch(start_size=1, end_time=0)])
-        with self.assertRaises(ValueError):
-            # no epochs given
-            dg.deme("a")
-        with self.assertRaises(ValueError):
-            # no start_size
-            dg.deme("a", epochs=[Epoch(end_time=1)])
-        with self.assertRaises(TypeError):
-            # ancestors must be a list
-            dg.deme(
-                "a",
-                ancestors="b",
-                start_time=10,
-                epochs=[Epoch(start_size=1, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # ancestor c doesn't exist
-            dg.deme(
-                "a",
-                ancestors=["b", "c"],
-                proportions=[0.5, 0.5],
-                epochs=[Epoch(start_size=1, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # start_time is more recent than an epoch's start_time
-            dg.deme(
-                "a",
-                ancestors=["b"],
-                start_time=15,
-                epochs=[
-                    Epoch(start_size=1, start_time=20, end_time=10),
-                    Epoch(end_time=0, start_size=2),
-                ],
-            )
-
-    def test_duplicate_deme(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("a", epochs=[Epoch(start_size=1, end_time=0)])
-        with self.assertRaises(ValueError):
-            dg.deme("a", epochs=[Epoch(start_size=1, end_time=0)])
-
-    def test_ancestor_not_in_graph(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        with self.assertRaises(ValueError):
-            dg.deme("a", ancestors=["b"], epochs=[Epoch(start_size=1)])
-
-    def test_duplicate_ancestors(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("a", epochs=[Epoch(start_size=100, end_time=50)])
-        with self.assertRaises(ValueError):
-            dg.deme(
-                "b",
-                ancestors=["a", "a"],
-                proportions=[0.5, 0.5],
-                start_time=100,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-
-    def test_bad_start_time_wrt_ancestors(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("ancestral", epochs=[Epoch(start_size=100)])
-        dg.deme(
-            "a",
-            start_time=100,
-            ancestors=["ancestral"],
-            epochs=[Epoch(start_size=100, end_time=50)],
-        )
-        dg.deme("b", epochs=[Epoch(start_size=100, end_time=0)])
-        with self.assertRaises(ValueError):
-            # start_time too old
-            dg.deme(
-                "c",
-                ancestors=["a"],
-                start_time=200,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # start_time too young
-            dg.deme(
-                "c",
-                ancestors=["a"],
-                start_time=20,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # start_time too old
-            dg.deme(
-                "c",
-                ancestors=["a", "b"],
-                proportions=[0.5, 0.5],
-                start_time=200,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # start_time too young
-            dg.deme(
-                "c",
-                ancestors=["a", "b"],
-                proportions=[0.5, 0.5],
-                start_time=20,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # start_time not provided
-            dg.deme(
-                "c",
-                ancestors=["a", "b"],
-                proportions=[0.5, 0.5],
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # finite start time, but no ancestors
-            dg.deme("c", start_time=100, epochs=[Epoch(start_size=100)])
-
-    def test_proportions_default(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("a", epochs=[Epoch(start_size=100, end_time=50)])
-        dg.deme("b", epochs=[Epoch(start_size=100, end_time=50)])
-        with self.assertRaises(ValueError):
-            # proportions missing
-            dg.deme(
-                "c",
-                ancestors=["a", "b"],
-                start_time=100,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # proportions wrong length
-            dg.deme(
-                "c",
-                ancestors=["a", "b"],
-                proportions=[1],
-                start_time=100,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        with self.assertRaises(ValueError):
-            # proportions wrong length
-            dg.deme(
-                "c",
-                ancestors=["a", "b"],
-                proportions=[1 / 3, 1 / 3, 1 / 3],
-                start_time=100,
-                epochs=[Epoch(start_size=100, end_time=0)],
-            )
-        dg.deme("c", ancestors=["b"], epochs=[Epoch(start_size=100, end_time=0)])
-        self.assertEqual(len(dg["c"].proportions), 1)
-        self.assertEqual(dg["c"].proportions[0], 1.0)
-
-    def test_bad_epochs(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        # end_time and start_time of epochs don't align
-        with self.assertRaises(ValueError):
-            dg.deme(
-                "a",
-                epochs=[
-                    Epoch(start_size=1, end_time=100),
-                    Epoch(start_size=1, start_time=101, end_time=0),
-                ],
-            )
-
-    def test_bad_migration(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        with self.assertRaises(ValueError):
-            dg.symmetric_migration(demes=[], rate=0)
-        with self.assertRaises(ValueError):
-            dg.symmetric_migration(demes=["a"], rate=0.1)
-        with self.assertRaises(ValueError):
-            dg.migration(source="a", dest="b", rate=0.1)
-        dg.deme("a", epochs=[Epoch(start_size=100, end_time=0)])
-        with self.assertRaises(ValueError):
-            dg.migration(source="a", dest="b", rate=0.1)
-        with self.assertRaises(ValueError):
-            dg.migration(source="b", dest="a", rate=0.1)
-
-    def test_bad_pulse(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("a", epochs=[Epoch(start_size=100, end_time=0)])
-        with self.assertRaises(ValueError):
-            dg.pulse(source="a", dest="b", proportion=0.1, time=10)
-        with self.assertRaises(ValueError):
-            dg.pulse(source="b", dest="a", proportion=0.1, time=10)
-
-    def test_pulse_same_time(self):
-        g1 = Graph(description="test", time_units="generations")
-        for j in range(4):
-            g1.deme(f"d{j}", epochs=[Epoch(start_size=1000, end_time=0)])
-
-        T = 100  # time of pulses
-
-        # Warn for duplicate pulses
-        g2 = copy.deepcopy(g1)
-        g2.pulse(source="d0", dest="d1", time=T, proportion=0.1)
-        with pytest.warns(UserWarning):
-            g2.pulse(source="d0", dest="d1", time=T, proportion=0.1)
-
-        # Warn for: d0 -> d1; d1 -> d2.
-        g2 = copy.deepcopy(g1)
-        g2.pulse(source="d0", dest="d1", time=T, proportion=0.1)
-        with pytest.warns(UserWarning):
-            g2.pulse(source="d1", dest="d2", time=T, proportion=0.1)
-
-        # Warn for: d0 -> d2; d1 -> d2.
-        g2 = copy.deepcopy(g1)
-        g2.pulse(source="d0", dest="d2", time=T, proportion=0.1)
-        with pytest.warns(UserWarning):
-            g2.pulse(source="d1", dest="d2", time=T, proportion=0.1)
-
-        # Shouldn't warn for: d0 -> d1; d0 -> d2.
-        g2 = copy.deepcopy(g1)
-        g2.pulse(source="d0", dest="d1", time=T, proportion=0.1)
-        with pytest.warns(None) as record:
-            g2.pulse(source="d0", dest="d2", time=T, proportion=0.1)
-        assert len(record) == 0
-
-        # Shouldn't warn for: d0 -> d1; d2 -> d3.
-        g2 = copy.deepcopy(g1)
-        g2.pulse(source="d0", dest="d1", time=T, proportion=0.1)
-        with pytest.warns(None) as record:
-            g2.pulse(source="d2", dest="d3", time=T, proportion=0.1)
-        assert len(record) == 0
-
-        # Different pulse times shouldn't warn for: d0 -> d1; d1 -> d2.
-        g2 = copy.deepcopy(g1)
-        g2.pulse(source="d0", dest="d1", time=T, proportion=0.1)
-        with pytest.warns(None) as record:
-            g2.pulse(source="d1", dest="d2", time=2 * T, proportion=0.1)
-        assert len(record) == 0
-
-        # Different pulse times shouldn't warn for: d0 -> d2; d1 -> d2.
-        g2 = copy.deepcopy(g1)
-        g2.pulse(source="d0", dest="d2", time=T, proportion=0.1)
-        with pytest.warns(None) as record:
-            g2.pulse(source="d1", dest="d2", time=2 * T, proportion=0.1)
-        assert len(record) == 0
-
-    def test_isclose(self):
-        g1 = Graph(
-            description="test",
-            time_units="generations",
-        )
-        g2 = copy.deepcopy(g1)
-        g1.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        self.assertTrue(g1.isclose(g1))
-        self.assertTrue(g1.isclose(demes.loads(demes.dumps(g1))))
+    def test_isclose(self):
+        b1 = Builder(description="test", time_units="generations")
+        b2 = copy.deepcopy(b1)
+        b1.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        g1 = b1.resolve()
+        self.assertTrue(g1.isclose(g1))
+        self.assertTrue(g1.isclose(demes.loads(demes.dumps(g1))))
 
         # Don't care about description for equality.
-        g3 = Graph(
-            description="some other description",
-            time_units="generations",
-        )
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
+        b3 = Builder(description="some other description", time_units="generations")
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        g3 = b3.resolve()
         self.assertTrue(g1.isclose(g3))
 
         # Don't care about doi for equality.
-        g3 = Graph(
+        b3 = Builder(
             description="test",
             time_units="generations",
             doi=["https://example.com/foo.bar"],
         )
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        g3 = b3.resolve()
         self.assertTrue(g1.isclose(g3))
 
         # The order in which demes are added shouldn't matter.
-        g3 = copy.deepcopy(g2)
-        g4 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
+        b3 = copy.deepcopy(b2)
+        b4 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertTrue(g3.isclose(g4))
 
         # The order in which migrations are added shouldn't matter.
-        g3.migration(source="d1", dest="d2", rate=1e-4, start_time=50, end_time=40)
-        g3.migration(source="d2", dest="d1", rate=1e-5)
-        g4.migration(source="d2", dest="d1", rate=1e-5)
-        g4.migration(source="d1", dest="d2", rate=1e-4, start_time=50, end_time=40)
+        b3.add_migration(source="d1", dest="d2", rate=1e-4, start_time=50, end_time=40)
+        b3.add_migration(source="d2", dest="d1", rate=1e-5)
+        b4.add_migration(source="d2", dest="d1", rate=1e-5)
+        b4.add_migration(source="d1", dest="d2", rate=1e-4, start_time=50, end_time=40)
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertTrue(g3.isclose(g4))
 
         # The order in which pulses are added shouldn't matter.
-        g3.pulse(source="d1", dest="d2", proportion=0.01, time=100)
-        g3.pulse(source="d1", dest="d2", proportion=0.01, time=50)
-        g4.pulse(source="d1", dest="d2", proportion=0.01, time=50)
-        g4.pulse(source="d1", dest="d2", proportion=0.01, time=100)
+        b3.add_pulse(source="d1", dest="d2", proportion=0.01, time=100)
+        b3.add_pulse(source="d1", dest="d2", proportion=0.01, time=50)
+        b4.add_pulse(source="d1", dest="d2", proportion=0.01, time=50)
+        b4.add_pulse(source="d1", dest="d2", proportion=0.01, time=100)
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertTrue(g3.isclose(g4))
 
         #
         # Check inequalities
         #
 
-        self.assertFalse(g1 == g2)
-        g3 = copy.deepcopy(g2)
-        g3.deme("dX", epochs=[Epoch(start_size=1000, end_time=0)])
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("dX", epochs=[dict(start_size=1000, end_time=0)])
+        g3 = b3.resolve()
         self.assertFalse(g1.isclose(g3))
 
-        g3 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1001, end_time=0)])
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1001, end_time=0)])
+        g3 = b3.resolve()
         self.assertFalse(g1.isclose(g3))
 
-        g3 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        g3 = b3.resolve()
         self.assertFalse(g1.isclose(g3))
 
-        g3 = copy.deepcopy(g1)
-        g4 = copy.deepcopy(g1)
-        g3.deme("dX", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme(
+        b3 = copy.deepcopy(b1)
+        b4 = copy.deepcopy(b1)
+        b3.add_deme("dX", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme(
             "d2",
             ancestors=["dX"],
             start_time=50,
-            epochs=[Epoch(start_size=1000, end_time=0)],
+            epochs=[dict(start_size=1000, end_time=0)],
         )
-        g4.deme("dX", epochs=[Epoch(start_size=1000)])
-        g4.deme(
+        b4.add_deme("dX", epochs=[dict(start_size=1000)])
+        b4.add_deme(
             "d2",
             ancestors=["d1"],
             start_time=50,
-            epochs=[Epoch(start_size=1000, end_time=0)],
+            epochs=[dict(start_size=1000, end_time=0)],
         )
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertFalse(g3.isclose(g4))
 
-        g3 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4 = copy.deepcopy(g2)
-        g4.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.migration(source="d2", dest="d1", rate=1e-5)
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4 = copy.deepcopy(b2)
+        b4.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_migration(source="d2", dest="d1", rate=1e-5)
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertFalse(g3.isclose(g4))
 
-        g3 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.migration(source="d1", dest="d2", rate=1e-5)
-        g4 = copy.deepcopy(g2)
-        g4.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.migration(source="d2", dest="d1", rate=1e-5)
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_migration(source="d1", dest="d2", rate=1e-5)
+        b4 = copy.deepcopy(b2)
+        b4.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_migration(source="d2", dest="d1", rate=1e-5)
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertFalse(g3.isclose(g4))
 
-        g3 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.migration(source="d2", dest="d1", rate=1e-5)
-        g4 = copy.deepcopy(g2)
-        g4.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.symmetric_migration(demes=["d2", "d1"], rate=1e-5)
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_migration(source="d2", dest="d1", rate=1e-5)
+        b4 = copy.deepcopy(b2)
+        b4.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_migration(demes=["d2", "d1"], rate=1e-5)
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertFalse(g3.isclose(g4))
 
-        g3 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4 = copy.deepcopy(g2)
-        g4.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.pulse(source="d1", dest="d2", proportion=0.01, time=100)
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4 = copy.deepcopy(b2)
+        b4.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_pulse(source="d1", dest="d2", proportion=0.01, time=100)
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertFalse(g3.isclose(g4))
 
-        g3 = copy.deepcopy(g2)
-        g3.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g3.pulse(source="d2", dest="d1", proportion=0.01, time=100)
-        g4 = copy.deepcopy(g2)
-        g4.deme("d1", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.deme("d2", epochs=[Epoch(start_size=1000, end_time=0)])
-        g4.pulse(source="d1", dest="d2", proportion=0.01, time=100)
+        b3 = copy.deepcopy(b2)
+        b3.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b3.add_pulse(source="d2", dest="d1", proportion=0.01, time=100)
+        b4 = copy.deepcopy(b2)
+        b4.add_deme("d1", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_deme("d2", epochs=[dict(start_size=1000, end_time=0)])
+        b4.add_pulse(source="d1", dest="d2", proportion=0.01, time=100)
+        g3 = b3.resolve()
+        g4 = b4.resolve()
         self.assertFalse(g3.isclose(g4))
 
-    def test_validate(self):
-        g1 = demes.Graph(description="test", time_units="generations")
-        g1.deme("a", epochs=[Epoch(start_size=1, end_time=100)])
-        g1.deme("ancestral", epochs=[Epoch(start_size=1)])
-        g1.deme(
+
+class TestGraphResolution(unittest.TestCase):
+    def test_basic_resolution(self):
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.resolve()
+
+        b = Builder()
+        b.add_deme("a", epochs=[dict(end_size=1)])
+        b.resolve()
+
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1, end_time=100)])
+        b.add_deme("b", ancestors=["a"], epochs=[dict(start_size=1)])
+        b.resolve()
+
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme("b", epochs=[dict(start_size=1)])
+        b.add_migration(source="a", dest="b", rate=1e-5)
+        b.resolve()
+
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme("b", epochs=[dict(start_size=1)])
+        b.add_deme("c", epochs=[dict(start_size=1)])
+        b.add_migration(demes=["a", "b", "c"], rate=1e-5)
+        b.resolve()
+
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme("b", epochs=[dict(start_size=1)])
+        b.add_pulse(source="a", dest="b", proportion=0.1, time=100)
+        b.resolve()
+
+    def test_bad_data_dict(self):
+        # not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.data = data
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # empty dict
+        b = Builder.fromdict(dict())
+        with pytest.raises(KeyError):
+            b.resolve()
+
+    def test_bad_toplevel(self):
+        # no time units
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        del b.data["time_units"]
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # unrecognised toplevel field
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.data["thyme_younerts"] = "generations"
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # unrecognised toplevel fields
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.data["foo"] = [dict(bar=10), dict(baz=20)]
+        b.data["zort"] = "tron"
+        with pytest.raises(KeyError):
+            b.resolve()
+
+    def test_bad_toplevel_defaults(self):
+        # defaults is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.data["defaults"] = data
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # unrecognised defaults fields
+        b = Builder(defaults=dict(rate=0.1, proportion=0.1))
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # unrecognised defaults.deme fields
+        b = Builder(defaults=dict(deme=dict(foo=10, bar=20)))
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # unrecognised defaults.epoch fields
+        b = Builder(defaults=dict(epoch=dict(foo=10)))
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # unrecognised defaults.migration fields
+        b = Builder(defaults=dict(migration=dict(foo=10)))
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # unrecognised defaults.pulse fields
+        b = Builder(defaults=dict(pulse=dict(foo={}, bar=[], baz=None)))
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # defaults field is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            for key in ("deme", "migration", "pulse", "epoch"):
+                b = Builder()
+                b.add_deme("a", epochs=[dict(start_size=1)])
+                b.data["defaults"] = {key: data}
+                with pytest.raises(TypeError):
+                    b.resolve()
+
+    def test_bad_demelevel_defaults(self):
+        # defaults is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.data["demes"][0]["defaults"] = data
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # unrecognised defaults fields
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)], defaults=dict(foo=10, bar=20))
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # defaults.epoch field is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)], defaults=data)
+            b.data["demes"][0]["defaults"] = dict(epoch=data)
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # unrecognised defaults.epoch fields
+        b = Builder()
+        b.add_deme(
+            "a", epochs=[dict(start_size=1)], defaults=dict(epoch=dict(foo=10, bar=20))
+        )
+        with pytest.raises(KeyError):
+            b.resolve()
+
+    def test_bad_demes(self):
+        # no demes
+        b = Builder()
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # demes is not a list
+        for data in (None, {}, "string", 0, 1e-5):
+            b = Builder()
+            b.data["demes"] = data
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # deme is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.data["demes"] = [data]
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # deme has no id
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        del b.data["demes"][0]["id"]
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # no epochs given
+        b = Builder()
+        b.add_deme("a")
+        with self.assertRaises(KeyError):
+            b.resolve()
+
+        # missing start_size or end_size
+        b = Builder()
+        b.add_deme("a", epochs=[dict(end_time=1)])
+        with self.assertRaises(KeyError):
+            b.resolve()
+
+        # ancestors must be a list
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme(
+            "b",
+            ancestors="a",
+            start_time=10,
+            epochs=[dict(start_size=1)],
+        )
+        with self.assertRaises(TypeError):
+            b.resolve()
+
+        # ancestor x doesn't exist
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme(
             "b",
+            ancestors=["a", "x"],
+            proportions=[0.5, 0.5],
+            epochs=[dict(start_size=1)],
+        )
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+    def test_duplicate_deme(self):
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+    def test_duplicate_ancestors(self):
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100, end_time=50)])
+        b.add_deme(
+            "b",
+            ancestors=["a", "a"],
+            proportions=[0.5, 0.5],
+            start_time=100,
+            epochs=[dict(start_size=100)],
+        )
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+    def test_bad_start_time_wrt_ancestors(self):
+        b1 = Builder()
+        b1.add_deme("ancestral", epochs=[dict(start_size=100)])
+        b1.add_deme(
+            "a",
+            start_time=100,
             ancestors=["ancestral"],
-            start_time=50,
-            epochs=[Epoch(start_size=1, end_time=0)],
+            epochs=[dict(start_size=100, end_time=50)],
         )
-        g1.validate()
+        b1.add_deme("b", epochs=[dict(start_size=100)])
 
-        #
-        # bypass the usual API to invalidate the graph
-        #
+        # start_time too old
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "c",
+            ancestors=["a"],
+            start_time=200,
+            epochs=[dict(start_size=100)],
+        )
+        with self.assertRaises(ValueError):
+            b2.resolve()
 
-        # add an ancestor deme that's not in the graph
-        g2 = copy.deepcopy(g1)
-        g2["a"].ancestors = ["x"]
-        g2["a"].proportions = [1]
+        # start_time too young
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "c",
+            ancestors=["a"],
+            start_time=20,
+            epochs=[dict(start_size=100)],
+        )
         with self.assertRaises(ValueError):
-            g2.validate()
+            b2.resolve()
 
-        # add an ancestor deme that's temporally not possible
-        g2 = copy.deepcopy(g1)
-        g2["b"].ancestors = ["a"]
-        g2["b"].proportions = [1]
+        # start_time too old
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "c",
+            ancestors=["a", "b"],
+            proportions=[0.5, 0.5],
+            start_time=200,
+            epochs=[dict(start_size=100)],
+        )
         with self.assertRaises(ValueError):
-            g2.validate()
+            b2.resolve()
 
-        # add an overlapping epoch
-        g2 = copy.deepcopy(g1)
-        g2["a"].epochs.append(Epoch(start_time=300, end_time=200, start_size=1))
+        # start_time too young
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "c",
+            ancestors=["a", "b"],
+            proportions=[0.5, 0.5],
+            start_time=20,
+            epochs=[dict(start_size=100)],
+        )
         with self.assertRaises(ValueError):
-            g2.validate()
+            b2.resolve()
 
-        # add migration between non-temporally overlapping populations
-        g2 = copy.deepcopy(g1)
-        g2.migrations.append(
-            Migration(source="a", dest="b", start_time=200, end_time=0, rate=1e-5)
+        # start_time not provided
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "c",
+            ancestors=["a", "b"],
+            proportions=[0.5, 0.5],
+            epochs=[dict(start_size=100)],
         )
         with self.assertRaises(ValueError):
-            g2.validate()
+            b2.resolve()
 
-    def test_newly_created_objects_return(self):
-        g = demes.Graph(description="test", time_units="generations")
-        d1 = g.deme("a", epochs=[Epoch(start_size=1, end_time=0)])
-        self.assertIsInstance(d1, Deme)
-        d2 = g.deme(
-            "b",
-            start_time=50,
-            ancestors=["a"],
-            epochs=[Epoch(start_size=1, end_time=0)],
+        # finite start time, but no ancestors
+        b2 = copy.deepcopy(b1)
+        b2.add_deme("c", start_time=100, epochs=[dict(start_size=100)])
+        with self.assertRaises(ValueError):
+            b2.resolve()
+
+    def test_proportions_default(self):
+        b1 = Builder()
+        b1.add_deme("a", epochs=[dict(start_size=100, end_time=50)])
+        b1.add_deme("b", epochs=[dict(start_size=100, end_time=50)])
+
+        # proportions missing
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "c",
+            ancestors=["a", "b"],
+            start_time=100,
+            epochs=[dict(start_size=100)],
+        )
+        with self.assertRaises(ValueError):
+            b2.resolve()
+
+        b2 = copy.deepcopy(b1)
+        # proportions wrong length
+        b2.add_deme(
+            "c",
+            ancestors=["a", "b"],
+            proportions=[1],
+            start_time=100,
+            epochs=[dict(start_size=100)],
         )
-        self.assertIsInstance(d2, Deme)
+        with self.assertRaises(ValueError):
+            b2.resolve()
 
-        mig = g.migration(source="a", dest="b", rate=1e-4, end_time=25)
-        self.assertIsInstance(mig, Migration)
-        migs = g.symmetric_migration(demes=["a", "b"], rate=1e-4, start_time=25)
-        self.assertIsInstance(migs, list)
-        for m in migs:
-            self.assertIsInstance(m, Migration)
+        b2 = copy.deepcopy(b1)
+        # proportions wrong length
+        b2.add_deme(
+            "c",
+            ancestors=["a", "b"],
+            proportions=[1 / 3, 1 / 3, 1 / 3],
+            start_time=100,
+            epochs=[dict(start_size=100)],
+        )
+        with self.assertRaises(ValueError):
+            b2.resolve()
 
-        pulse = g.pulse(source="a", dest="b", proportion=0.5, time=25)
-        self.assertIsInstance(pulse, Pulse)
+        b2 = copy.deepcopy(b1)
+        b2.add_deme("c", ancestors=["b"], epochs=[dict(start_size=100)])
+        g2 = b2.resolve()
+        self.assertEqual(len(g2["c"].proportions), 1)
+        self.assertEqual(g2["c"].proportions[0], 1.0)
 
     def test_deme_end_time(self):
-        g = demes.Graph(description="test", time_units="generations")
-        g.deme(
+        b1 = Builder()
+        b1.add_deme(
             "a",
             epochs=[
-                Epoch(end_time=100, start_size=10),
-                Epoch(start_size=20, end_time=0),
+                dict(end_time=100, start_size=10),
+                dict(start_size=20, end_time=0),
             ],
         )
+
+        # can't have epoch end_time == deme start_time
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "b",
+            start_time=100,
+            ancestors=["a"],
+            epochs=[dict(start_size=100, end_time=100)],
+        )
         with self.assertRaises(ValueError):
-            g.deme(
-                "b",
-                start_time=100,
-                ancestors=["a"],
-                epochs=[Epoch(start_size=100, end_time=100)],
-            )
-        assert "b" not in g
+            b2.resolve()
+
+        # can't have epoch end_time > deme start_time
+        b2 = copy.deepcopy(b1)
+        b2.add_deme(
+            "b",
+            start_time=100,
+            ancestors=["a"],
+            epochs=[dict(start_size=100, end_time=200)],
+        )
         with self.assertRaises(ValueError):
-            g.deme(
-                "b",
-                start_time=100,
-                ancestors=["a"],
-                epochs=[Epoch(start_size=100, end_time=200)],
-            )
-        assert "b" not in g
+            b2.resolve()
+
         # Check that end_time can be ommitted for final epoch
-        g.deme("x", start_time=100, ancestors=["a"], epochs=[Epoch(start_size=100)])
-        g.deme("y", epochs=[Epoch(start_size=100)])
-        g.deme(
+        b2 = copy.deepcopy(b1)
+        b2.add_deme("x", start_time=100, ancestors=["a"], epochs=[dict(start_size=100)])
+        b2.add_deme("y", epochs=[dict(start_size=100)])
+        b2.add_deme(
             "z",
             start_time=100,
             ancestors=["a"],
-            epochs=[Epoch(start_size=100, end_time=10), Epoch(start_size=10)],
+            epochs=[dict(start_size=100, end_time=10), dict(start_size=10)],
         )
+        b2.resolve()
 
-    def test_ambiguous_epoch_times(self):
-        g = demes.Graph(description="test", time_units="generations")
+    def test_bad_epochs(self):
+        # deme has no epochs
+        b = Builder()
+        b.add_deme("a")
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # epochs is not a list
+        for data in (None, {}, "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a")
+            b.data["demes"][0]["epochs"] = data
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # epoch is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a")
+            b.data["demes"][0]["epochs"] = [data]
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # epochs out of order
+        b = Builder()
+        b.add_deme(
+            "a",
+            epochs=[
+                dict(start_size=1, end_time=0),
+                dict(start_size=1, end_time=100),
+            ],
+        )
         with self.assertRaises(ValueError):
-            g.deme(
-                "a",
-                epochs=[
-                    Epoch(start_size=10),
-                    Epoch(end_time=0, start_size=100),
-                ],
-            )
+            b.resolve()
+
+        # must have a start_size or end_size
+        b = Builder()
+        b.add_deme("a", epochs=[dict(end_time=0)])
+        with self.assertRaises(KeyError):
+            b.resolve()
+
+        # except for the last epoch, all epochs must have an end_time
+        b = Builder()
+        b.add_deme(
+            "a",
+            epochs=[
+                dict(start_size=1, end_time=100),
+                dict(start_size=1),
+                dict(start_size=1, end_time=0),
+            ],
+        )
+        with self.assertRaises(KeyError):
+            b.resolve()
+
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=10), dict(end_time=0, start_size=100)])
+        with self.assertRaises(KeyError):
+            b.resolve()
+
+    def test_bad_migrations(self):
+        # migrations is not a list
+        for data in (None, {}, "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.data["migrations"] = data
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # migration is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.data["migrations"] = [data]
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # missing required migration field: 'rate' (asymmetric)
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme("b", epochs=[dict(start_size=1)])
+        b.data["migrations"] = [dict(source="a", dest="b")]
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # missing required migration field: 'rate' (symmetric)
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1)])
+        b.add_deme("b", epochs=[dict(start_size=1)])
+        b.data["migrations"] = [dict(demes=["a", "b"])]
+        with pytest.raises(KeyError):
+            b.resolve()
+
+        # unable to determine if symmetric or asymmetric
+        for migration in [
+            dict(source="a", rate=0.1),
+            dict(dest="a", rate=0.1),
+            dict(demes=["a", "b"], dest="a", rate=0.1),
+            dict(demes=["a", "b"], source="a", rate=0.1),
+        ]:
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.add_deme("b", epochs=[dict(start_size=1)])
+            b.data["migrations"] = [migration]
+            with pytest.raises(KeyError):
+                b.resolve()
+
+        # no demes participating in migration
+        b = Builder()
+        b.add_deme("X", epochs=[dict(start_size=100)])
+        b.add_migration(demes=[], rate=0)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+        # only one deme participating in migration
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100)])
+        b.add_migration(demes=["a"], rate=0.1)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+        # source and dest aren't in the graph
+        b = Builder()
+        b.add_deme("X", epochs=[dict(start_size=100)])
+        b.add_migration(source="a", dest="b", rate=0.1)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+        # dest not in graph
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100)])
+        b.add_migration(source="a", dest="b", rate=0.1)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+        # source not in graph
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100)])
+        b.add_migration(source="b", dest="a", rate=0.1)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+    def test_bad_migration_time(self):
+        b = Builder()
+        b.add_deme("deme1", epochs=[dict(start_size=1000, end_time=0)])
+        b.add_deme("deme2", epochs=[dict(end_time=100, start_size=1000)])
+        b.add_migration(
+            source="deme1", dest="deme2", rate=0.01, start_time=1000, end_time=0
+        )
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+    def test_overlapping_migrations(self):
+        b1 = Builder()
+        b1.add_deme("A", epochs=[dict(start_size=1)])
+        b1.add_deme("B", epochs=[dict(start_size=1)])
+        b1.add_migration(source="A", dest="B", rate=0.01)
+
+        b2 = copy.deepcopy(b1)
+        b2.add_migration(source="A", dest="B", start_time=10, rate=0.02)
+        with self.assertRaises(ValueError):
+            b2.resolve()
+
+        b2 = copy.deepcopy(b1)
+        b2.add_migration(demes=["A", "B"], rate=0.02)
+        with self.assertRaises(ValueError):
+            b2.resolve()
+
+        b2 = copy.deepcopy(b1)
+        b2.add_migration(demes=["A", "B"], rate=0.02, end_time=100)
+        with self.assertRaises(ValueError):
+            b2.resolve()
+
+        b2 = copy.deepcopy(b1)
+        b2.add_migration(source="B", dest="A", rate=0.03, start_time=100, end_time=10)
+        b2.add_migration(source="B", dest="A", rate=0.04, start_time=50)
+        with self.assertRaises(ValueError):
+            b2.resolve()
+
+        b2 = copy.deepcopy(b1)
+        b2.add_migration(source="B", dest="A", rate=0.03, start_time=5)
+        b2.add_migration(source="B", dest="A", rate=0.05, start_time=10)
+        with self.assertRaises(ValueError):
+            b2.resolve()
+
+        b2 = copy.deepcopy(b1)
+        b2.add_migration(source="B", dest="A", rate=0.01, start_time=10, end_time=5)
+        b2.resolve()
+
+    def test_bad_pulses(self):
+        # pulses is not a list
+        for data in (None, {}, "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.data["pulses"] = data
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # pulse is not a dict
+        for data in (None, [], "string", 0, 1e-5):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.data["pulses"] = [data]
+            with pytest.raises(TypeError):
+                b.resolve()
+
+        # dest not in graph
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100, end_time=0)])
+        b.add_pulse(source="a", dest="b", proportion=0.1, time=10)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+        # source not in graph
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100, end_time=0)])
+        b.add_pulse(source="b", dest="a", proportion=0.1, time=10)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+        for field in ("source", "dest", "time", "proportion"):
+            b = Builder()
+            b.add_deme("a", epochs=[dict(start_size=1)])
+            b.add_deme("b", epochs=[dict(start_size=1)])
+            b.add_pulse(source="a", dest="b", proportion=0.1, time=100)
+            del b.data["pulses"][0][field]
+            with pytest.raises(KeyError):
+                b.resolve()
+
+    def test_bad_pulse_time(self):
+        b = Builder()
+        b.add_deme("deme1", epochs=[dict(start_size=1000, end_time=0)])
+        b.add_deme("deme2", epochs=[dict(end_time=100, start_size=1000)])
+        b.add_pulse(source="deme1", dest="deme2", proportion=0.1, time=10)
+        with self.assertRaises(ValueError):
+            b.resolve()
+
+    def test_pulse_same_time(self):
+        b1 = Builder()
+        for j in range(4):
+            b1.add_deme(f"d{j}", epochs=[dict(start_size=1000)])
+
+        T = 100  # time of pulses
+
+        # Warn for duplicate pulses
+        b2 = copy.deepcopy(b1)
+        b2.add_pulse(source="d0", dest="d1", time=T, proportion=0.1)
+        b2.add_pulse(source="d0", dest="d1", time=T, proportion=0.1)
+        with pytest.warns(UserWarning):
+            b2.resolve()
+
+        # Warn for: d0 -> d1; d1 -> d2.
+        b2 = copy.deepcopy(b1)
+        b2.add_pulse(source="d0", dest="d1", time=T, proportion=0.1)
+        b2.add_pulse(source="d1", dest="d2", time=T, proportion=0.1)
+        with pytest.warns(UserWarning):
+            b2.resolve()
+
+        # Warn for: d0 -> d2; d1 -> d2.
+        b2 = copy.deepcopy(b1)
+        b2.add_pulse(source="d0", dest="d2", time=T, proportion=0.1)
+        b2.add_pulse(source="d1", dest="d2", time=T, proportion=0.1)
+        with pytest.warns(UserWarning):
+            b2.resolve()
+
+        # Shouldn't warn for: d0 -> d1; d0 -> d2.
+        b2 = copy.deepcopy(b1)
+        b2.add_pulse(source="d0", dest="d1", time=T, proportion=0.1)
+        b2.add_pulse(source="d0", dest="d2", time=T, proportion=0.1)
+        with pytest.warns(None) as record:
+            b2.resolve()
+        assert len(record) == 0
+
+        # Shouldn't warn for: d0 -> d1; d2 -> d3.
+        b2 = copy.deepcopy(b1)
+        b2.add_pulse(source="d0", dest="d1", time=T, proportion=0.1)
+        b2.add_pulse(source="d2", dest="d3", time=T, proportion=0.1)
+        with pytest.warns(None) as record:
+            b2.resolve()
+        assert len(record) == 0
+
+        # Different pulse times shouldn't warn for: d0 -> d1; d1 -> d2.
+        b2 = copy.deepcopy(b1)
+        b2.add_pulse(source="d0", dest="d1", time=T, proportion=0.1)
+        b2.add_pulse(source="d1", dest="d2", time=2 * T, proportion=0.1)
+        with pytest.warns(None) as record:
+            b2.resolve()
+        assert len(record) == 0
+
+        # Different pulse times shouldn't warn for: d0 -> d2; d1 -> d2.
+        b2 = copy.deepcopy(b1)
+        b2.add_pulse(source="d0", dest="d2", time=T, proportion=0.1)
+        b2.add_pulse(source="d1", dest="d2", time=2 * T, proportion=0.1)
+        with pytest.warns(None) as record:
+            b2.resolve()
+        assert len(record) == 0
 
 
 class TestGraphToDict(unittest.TestCase):
     def test_finite_start_time(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("ancestral", epochs=[Epoch(start_size=100)])
-        dg.deme(
+        b = Builder()
+        b.add_deme("ancestral", epochs=[dict(start_size=100)])
+        b.add_deme(
             "a",
             start_time=100,
             ancestors=["ancestral"],
-            epochs=[Epoch(start_size=100, end_time=0)],
+            epochs=[dict(start_size=100, end_time=0)],
         )
-        d = dg.asdict()
-        self.assertTrue(d["demes"][1]["start_time"] == dg["a"].start_time == 100)
+        g = b.resolve()
+        d = g.asdict()
+        self.assertTrue(d["demes"][1]["start_time"] == g["a"].start_time == 100)
 
     def test_deme_selfing_rate(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("a", epochs=[Epoch(start_size=100, selfing_rate=0.1, end_time=0)])
-        d = dg.asdict()
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100, selfing_rate=0.1)])
+        d = b.resolve().asdict()
         self.assertTrue(d["demes"][0]["epochs"][0]["selfing_rate"] == 0.1)
 
     def test_deme_cloning_rate(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("a", epochs=[Epoch(start_size=100, cloning_rate=0.1, end_time=0)])
-        d = dg.asdict()
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100, cloning_rate=0.1)])
+        d = b.resolve().asdict()
         self.assertTrue(d["demes"][0]["epochs"][0]["cloning_rate"] == 0.1)
-        d = dg.asdict_simplified()
+        d = b.resolve().asdict_simplified()
         self.assertTrue(d["demes"][0]["epochs"][0]["cloning_rate"] == 0.1)
-        dg.deme("b", epochs=[Epoch(start_size=200, end_time=0)])
-        d = dg.asdict_simplified()
+
+        b.add_deme("b", epochs=[dict(start_size=200, end_time=0)])
+        d = b.resolve().asdict_simplified()
         self.assertTrue("cloning_rate" not in d["demes"][1])
-        dg.deme(
+
+        b.add_deme(
             "c",
             epochs=[
-                Epoch(start_size=1, end_time=100, cloning_rate=0.3),
-                Epoch(start_size=2, end_time=0),
+                dict(start_size=1, end_time=100, cloning_rate=0.3),
+                dict(start_size=2),
             ],
         )
-        d = dg.asdict_simplified()
+        d = b.resolve().asdict_simplified()
         self.assertTrue(d["demes"][2]["epochs"][0]["cloning_rate"] == 0.3)
-        self.assertTrue("cloning_rate" not in d["demes"][2]["epochs"][1])
+        self.assertTrue("cloning_rate" not in d["demes"][2]["epochs"][1], msg=f"{d}")
 
     def test_fill_nonstandard_size_function(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme(
+        b = Builder()
+        b.add_deme(
             "a",
             epochs=[
-                Epoch(start_size=1, end_time=10),
-                Epoch(end_size=10, size_function="linear", end_time=0),
+                dict(start_size=1, end_time=10),
+                dict(end_size=10, size_function="linear"),
             ],
         )
-        d = dg.asdict()
+        d = b.resolve().asdict()
         self.assertTrue(d["demes"][0]["epochs"][-1]["size_function"] == "linear")
 
     def test_fill_epoch_selfing_rates(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme(
+        b = Builder()
+        b.add_deme(
             "a",
-            defaults={"selfing_rate": 0.3},
+            defaults={"epoch": {"selfing_rate": 0.3}},
             epochs=[
-                Epoch(start_size=10, end_time=10, selfing_rate=0.2),
-                Epoch(end_size=20, end_time=0, selfing_rate=0.1),
+                dict(start_size=10, end_time=10, selfing_rate=0.2),
+                dict(end_size=20, selfing_rate=0.1),
             ],
         )
-        d = dg.asdict()
+        d = b.resolve().asdict()
         self.assertTrue(d["demes"][0]["epochs"][0]["selfing_rate"] == 0.2)
         self.assertTrue(d["demes"][0]["epochs"][1]["selfing_rate"] == 0.1)
 
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme(
+        b = Builder()
+        b.add_deme(
             "a",
             epochs=[
-                Epoch(start_size=10, end_time=10),
-                Epoch(end_size=20, end_time=0, selfing_rate=0.1),
+                dict(start_size=10, end_time=10),
+                dict(end_size=20, selfing_rate=0.1),
             ],
         )
-        d = dg.asdict()
+        d = b.resolve().asdict_simplified()
         self.assertTrue("selfing_rate" not in d["demes"][0]["epochs"][0])
         self.assertTrue(d["demes"][0]["epochs"][1]["selfing_rate"] == 0.1)
 
     def test_fill_epoch_cloning_rates(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme(
+        b = Builder()
+        b.add_deme(
             "a",
-            defaults={"cloning_rate": 0.2},
+            defaults={"epoch": {"cloning_rate": 0.2}},
             epochs=[
-                Epoch(start_size=10, end_time=10),
-                Epoch(end_size=20, end_time=0, cloning_rate=0.1),
+                dict(start_size=10, end_time=10),
+                dict(end_size=20, cloning_rate=0.1),
             ],
         )
-        d = dg.asdict()
+        d = b.resolve().asdict()
         self.assertTrue(d["demes"][0]["epochs"][0]["cloning_rate"] == 0.2)
         self.assertTrue(d["demes"][0]["epochs"][1]["cloning_rate"] == 0.1)
 
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme(
+        b = Builder()
+        b.add_deme(
             "a",
             epochs=[
-                Epoch(start_size=10, end_time=10),
-                Epoch(end_size=20, end_time=0, cloning_rate=0.1),
+                dict(start_size=10, end_time=10),
+                dict(end_size=20, cloning_rate=0.1),
             ],
         )
-        d = dg.asdict()
+        d = b.resolve().asdict()
         self.assertTrue(d["demes"][0]["epochs"][1]["cloning_rate"] == 0.1)
 
     def test_fill_description(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme(
-            "a", description="described", epochs=[Epoch(start_size=100, end_time=0)]
-        )
-        d = dg.asdict()
-        self.assertTrue(d["demes"][0]["description"] == dg["a"].description)
+        b = Builder(description="toplevel-description")
+        b.add_deme("a", description="deme-description", epochs=[dict(start_size=100)])
+        g = b.resolve()
+        d = g.asdict()
+        self.assertTrue(d["description"] == g.description)
+        self.assertTrue(d["demes"][0]["description"] == g["a"].description)
 
     def test_fill_migration_bounds(self):
-        dg = demes.Graph(description="a", time_units="generations")
-        dg.deme("a", epochs=[Epoch(start_size=100, end_time=0)])
-        dg.deme("b", epochs=[Epoch(start_size=100, end_time=0)])
-        dg.migration(source="a", dest="b", rate=0.01, start_time=20, end_time=10)
-        d = dg.asdict()
-        self.assertTrue(d["migrations"]["asymmetric"][0]["start_time"] == 20)
-        self.assertTrue(d["migrations"]["asymmetric"][0]["end_time"] == 10)
-
-    def test_bad_custom_attributes(self):
-        g = demes.Graph(description="a", time_units="generations")
-        with self.assertRaises(TypeError):
-            g.asdict_simplified(custom_attributes="inbreeding")
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100, end_time=0)])
+        b.add_deme("b", epochs=[dict(start_size=100, end_time=0)])
+        b.add_migration(source="a", dest="b", rate=0.01, start_time=20, end_time=10)
+        d = b.resolve().asdict()
+        self.assertTrue(d["migrations"][0]["start_time"] == 20)
+        self.assertTrue(d["migrations"][0]["end_time"] == 10)
 
     def test_multiple_symmetric_migrations(self):
-        g = demes.Graph(description="descr", time_units="generations")
-        g.deme("a", epochs=[Epoch(start_size=100, end_time=0)])
-        g.deme("b", epochs=[Epoch(start_size=200, end_time=0)])
-        g.deme("c", epochs=[Epoch(start_size=300, end_time=0)])
-        g.symmetric_migration(demes=["a", "b", "c"], rate=0.01)
-        d = g.asdict_simplified()
-        self.assertTrue(len(d["migrations"]["symmetric"]) == 1)
-        self.assertTrue("asymmetric" not in d["migrations"])
-
-        g.deme("d", epochs=[Epoch(start_size=400, end_time=0)])
-        g.symmetric_migration(demes=["a", "d"], rate=0.01)
-        d = g.asdict_simplified()
-        self.assertTrue(len(d["migrations"]["symmetric"]) == 2)
-        self.assertTrue(len(d["migrations"]["symmetric"][0]["demes"]) == 3)
-        self.assertTrue(len(d["migrations"]["symmetric"][1]["demes"]) == 2)
-        self.assertTrue("d" not in d["migrations"]["symmetric"][0]["demes"])
-        self.assertTrue("asymmetric" not in d["migrations"])
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=100, end_time=0)])
+        b.add_deme("b", epochs=[dict(start_size=200, end_time=0)])
+        b.add_deme("c", epochs=[dict(start_size=300, end_time=0)])
+        b.add_migration(demes=["a", "b", "c"], rate=0.01)
+        d = b.resolve().asdict_simplified()
+        self.assertTrue(len(d["migrations"]) == 1)
+
+        b.add_deme("d", epochs=[dict(start_size=400, end_time=0)])
+        b.add_migration(demes=["a", "d"], rate=0.01)
+        d = b.resolve().asdict_simplified()
+        self.assertTrue(len(d["migrations"]) == 2)
+        self.assertTrue(len(d["migrations"][0]["demes"]) == 3)
+        self.assertTrue(len(d["migrations"][1]["demes"]) == 2)
+        self.assertTrue("d" not in d["migrations"][0]["demes"])
 
     def test_mix_sym_asym_migrations(self):
-        g = demes.Graph(description="a", time_units="generations")
-        g.deme("a", epochs=[Epoch(start_size=1, end_time=0)])
-        g.deme("b", epochs=[Epoch(start_size=1, end_time=0)])
-        g.deme("c", epochs=[Epoch(start_size=1, end_time=0)])
-        g.deme("d", epochs=[Epoch(start_size=1, end_time=0)])
-        g.symmetric_migration(demes=["a", "b"], rate=0.01)
-        g.symmetric_migration(demes=["b", "c"], rate=0.01)
-        g.symmetric_migration(demes=["a", "c", "d"], rate=0.01)
-        g.migration(source="b", dest="d", rate=0.01)
+        b = Builder()
+        b.add_deme("a", epochs=[dict(start_size=1, end_time=0)])
+        b.add_deme("b", epochs=[dict(start_size=1, end_time=0)])
+        b.add_deme("c", epochs=[dict(start_size=1, end_time=0)])
+        b.add_deme("d", epochs=[dict(start_size=1, end_time=0)])
+        b.add_migration(demes=["a", "b"], rate=0.01)
+        b.add_migration(demes=["b", "c"], rate=0.01)
+        b.add_migration(demes=["a", "c", "d"], rate=0.01)
+        b.add_migration(source="b", dest="d", rate=0.01)
+        g = b.resolve()
         d = g.asdict()
-        self.assertTrue(len(d["migrations"]["asymmetric"]) == 2 + 2 + 6 + 1)
+        self.assertTrue(len(d["migrations"]) == 4)
         d = g.asdict_simplified()
         self.assertTrue(
-            {"source": "b", "dest": "d", "rate": 0.01} in d["migrations"]["asymmetric"]
+            {"source": "b", "dest": "d", "rate": 0.01} == d["migrations"][3]
         )
-        self.assertTrue(len(d["migrations"]["symmetric"]) == 3)
-        self.assertTrue(len(d["migrations"]["symmetric"][0]["demes"]) == 3)
-        self.assertTrue(len(d["migrations"]["symmetric"][1]["demes"]) == 2)
-        self.assertTrue(len(d["migrations"]["symmetric"][2]["demes"]) == 2)
+        self.assertTrue(len(d["migrations"][0]["demes"]) == 2)
+        self.assertTrue(len(d["migrations"][1]["demes"]) == 2)
+        self.assertTrue(len(d["migrations"][2]["demes"]) == 3)
+
+
+class TestBuilder:
+    def test_properties(self):
+        b = Builder()
+        assert hasattr(b, "data")
+        assert isinstance(b.data, typing.MutableMapping)
+
+    @hyp.settings(deadline=None, suppress_health_check=[hyp.HealthCheck.too_slow])
+    @hyp.given(tests.graphs())
+    def test_back_and_forth(self, graph):
+        b = Builder.fromdict(graph.asdict())
+        g = b.resolve()
+        assert g.isclose(graph)
+
+        b = Builder.fromdict(graph.asdict_simplified())
+        g = b.resolve()
+        assert g.isclose(graph)
diff --git a/tests/test_load_dump.py b/tests/test_load_dump.py
index 5481cc44..1fc4c514 100644
--- a/tests/test_load_dump.py
+++ b/tests/test_load_dump.py
@@ -11,7 +11,6 @@
 import numpy as np
 
 import demes
-from demes import Epoch
 import tests
 
 
@@ -27,7 +26,7 @@ def jacobs_papuans():
     N_ghost = 8516
     T_Eu_bottleneck = 1659
 
-    g = demes.Graph(
+    b = demes.Builder(
         description="Jacobs et al. (2019) archaic admixture into Papuans",
         doi=[
             "https://doi.org/10.1016/j.cell.2019.02.035",
@@ -35,131 +34,133 @@ def jacobs_papuans():
         ],
         time_units="generations",
     )
-    g.deme("ancestral_hominin", epochs=[Epoch(end_time=20225, start_size=32671)])
-    g.deme(
+    b.add_deme("ancestral_hominin", epochs=[dict(end_time=20225, start_size=32671)])
+    b.add_deme(
         "archaic",
         ancestors=["ancestral_hominin"],
-        epochs=[Epoch(end_time=15090, start_size=N_archaic)],
+        epochs=[dict(end_time=15090, start_size=N_archaic)],
     )
 
-    g.deme(
+    b.add_deme(
         "Den1",
         ancestors=["archaic"],
-        epochs=[Epoch(end_time=12500, start_size=N_DeniAnc)],
+        epochs=[dict(end_time=12500, start_size=N_DeniAnc)],
     )
-    g.deme(
+    b.add_deme(
         "Den2",
         ancestors=["Den1"],
-        epochs=[Epoch(end_time=9750, start_size=N_DeniAnc)],
+        epochs=[dict(end_time=9750, start_size=N_DeniAnc)],
     )
     # Altai Denisovan (sampling lineage)
-    g.deme("DenAltai", ancestors=["Den2"], epochs=[Epoch(start_size=5083, end_time=0)])
+    b.add_deme(
+        "DenAltai", ancestors=["Den2"], epochs=[dict(start_size=5083, end_time=0)]
+    )
     # Introgressing Denisovan lineages 1 and 2
-    g.deme(
-        "DenI1", ancestors=["Den2"], epochs=[Epoch(start_size=N_archaic, end_time=0)]
+    b.add_deme(
+        "DenI1", ancestors=["Den2"], epochs=[dict(start_size=N_archaic, end_time=0)]
     )
-    g.deme(
-        "DenI2", ancestors=["Den1"], epochs=[Epoch(start_size=N_archaic, end_time=0)]
+    b.add_deme(
+        "DenI2", ancestors=["Den1"], epochs=[dict(start_size=N_archaic, end_time=0)]
     )
 
-    g.deme(
+    b.add_deme(
         "Nea",
         ancestors=["archaic"],
-        epochs=[Epoch(end_time=3375, start_size=N_archaic)],
+        epochs=[dict(end_time=3375, start_size=N_archaic)],
     )
     # Altai Neanderthal (sampling lineage)
-    g.deme("NeaAltai", ancestors=["Nea"], epochs=[Epoch(start_size=826, end_time=0)])
+    b.add_deme("NeaAltai", ancestors=["Nea"], epochs=[dict(start_size=826, end_time=0)])
     # Introgressing Neanderthal lineage
-    g.deme(
-        "NeaI", ancestors=["Nea"], epochs=[Epoch(end_time=883, start_size=N_archaic)]
+    b.add_deme(
+        "NeaI", ancestors=["Nea"], epochs=[dict(end_time=883, start_size=N_archaic)]
     )
 
-    g.deme(
+    b.add_deme(
         "AMH",
         ancestors=["ancestral_hominin"],
-        epochs=[Epoch(end_time=2218, start_size=41563)],
+        epochs=[dict(end_time=2218, start_size=41563)],
     )
-    g.deme("Africa", ancestors=["AMH"], epochs=[Epoch(start_size=48433, end_time=0)])
-    g.deme(
+    b.add_deme("Africa", ancestors=["AMH"], epochs=[dict(start_size=48433, end_time=0)])
+    b.add_deme(
         "Ghost1",
         ancestors=["AMH"],
         epochs=[
             # bottleneck
-            demes.Epoch(end_time=2119, start_size=1394),
-            demes.Epoch(end_time=1784, start_size=N_ghost),
+            dict(end_time=2119, start_size=1394),
+            dict(end_time=1784, start_size=N_ghost),
         ],
     )
-    g.deme(
+    b.add_deme(
         "Ghost2",
         ancestors=["Ghost1"],
-        epochs=[Epoch(end_time=1758, start_size=N_ghost)],
+        epochs=[dict(end_time=1758, start_size=N_ghost)],
     )
-    g.deme(
-        "Ghost3", ancestors=["Ghost2"], epochs=[Epoch(start_size=N_ghost, end_time=0)]
+    b.add_deme(
+        "Ghost3", ancestors=["Ghost2"], epochs=[dict(start_size=N_ghost, end_time=0)]
     )
-    g.deme(
+    b.add_deme(
         "Papua",
         ancestors=["Ghost1"],
         # bottleneck
         epochs=[
-            demes.Epoch(end_time=1685, start_size=243),
-            demes.Epoch(end_time=0, start_size=8834),
+            dict(end_time=1685, start_size=243),
+            dict(end_time=0, start_size=8834),
         ],
     )
-    g.deme(
+    b.add_deme(
         "Eurasia",
         ancestors=["Ghost2"],
         # bottleneck
         epochs=[
-            demes.Epoch(end_time=T_Eu_bottleneck, start_size=2231),
-            demes.Epoch(end_time=1293, start_size=12971),
+            dict(end_time=T_Eu_bottleneck, start_size=2231),
+            dict(end_time=1293, start_size=12971),
         ],
     )
-    g.deme(
+    b.add_deme(
         "WestEurasia",
         ancestors=["Eurasia"],
-        epochs=[Epoch(start_size=6962, end_time=0)],
+        epochs=[dict(start_size=6962, end_time=0)],
     )
-    g.deme(
-        "EastAsia", ancestors=["Eurasia"], epochs=[Epoch(start_size=9025, end_time=0)]
+    b.add_deme(
+        "EastAsia", ancestors=["Eurasia"], epochs=[dict(start_size=9025, end_time=0)]
     )
 
-    g.symmetric_migration(
+    b.add_migration(
         demes=["Africa", "Ghost3"], rate=1.79e-4, start_time=T_Eu_bottleneck
     )
-    g.symmetric_migration(demes=["Ghost3", "WestEurasia"], rate=4.42e-4)
-    g.symmetric_migration(demes=["WestEurasia", "EastAsia"], rate=3.14e-5)
-    g.symmetric_migration(demes=["EastAsia", "Papua"], rate=5.72e-5)
-    g.symmetric_migration(
+    b.add_migration(demes=["Ghost3", "WestEurasia"], rate=4.42e-4)
+    b.add_migration(demes=["WestEurasia", "EastAsia"], rate=3.14e-5)
+    b.add_migration(demes=["EastAsia", "Papua"], rate=5.72e-5)
+    b.add_migration(
         demes=["Eurasia", "Papua"], rate=5.72e-4, start_time=T_Eu_bottleneck
     )
-    g.symmetric_migration(
+    b.add_migration(
         demes=["Ghost3", "Eurasia"], rate=4.42e-4, start_time=T_Eu_bottleneck
     )
 
-    g.pulse(source="NeaI", dest="EastAsia", proportion=0.002, time=883)
-    g.pulse(source="NeaI", dest="Papua", proportion=0.002, time=1412)
-    g.pulse(source="NeaI", dest="Eurasia", proportion=0.011, time=1566)
-    g.pulse(source="NeaI", dest="Ghost1", proportion=0.024, time=1853)
+    b.add_pulse(source="NeaI", dest="EastAsia", proportion=0.002, time=883)
+    b.add_pulse(source="NeaI", dest="Papua", proportion=0.002, time=1412)
+    b.add_pulse(source="NeaI", dest="Eurasia", proportion=0.011, time=1566)
+    b.add_pulse(source="NeaI", dest="Ghost1", proportion=0.024, time=1853)
 
     m_Den_Papuan = 0.04
     p = 0.55  # S10.i p. 31
     T_Den1_Papuan_mig = 29.8e3 / generation_time
     T_Den2_Papuan_mig = 45.7e3 / generation_time
-    g.pulse(
+    b.add_pulse(
         source="DenI1",
         dest="Papua",
         proportion=p * m_Den_Papuan,
         time=T_Den1_Papuan_mig,
     )
-    g.pulse(
+    b.add_pulse(
         source="DenI2",
         dest="Papua",
         proportion=(1 - p) * m_Den_Papuan,
         time=T_Den2_Papuan_mig,
     )
 
-    return g
+    return b.resolve()
 
 
 class TestLoadAndDump:
@@ -184,8 +185,9 @@ def test_bad_format_param(self):
                     demes.dump(g, tmpfile, format="not a format", simplified=simplified)
 
     def test_bad_filename_param(self):
-        g = demes.Graph(description="test", time_units="generations")
-        g.deme("A", epochs=[Epoch(start_size=1000, end_time=0)])
+        b = demes.Builder()
+        b.add_deme("A", epochs=[dict(start_size=1000, end_time=0)])
+        g = b.resolve()
 
         class F:
             pass
@@ -206,20 +208,21 @@ class F:
                 demes.load(bad_file)
 
     def check_dumps_simple(self, *, format, simplified):
-        g = demes.Graph(
+        b1 = demes.Builder(
             description="some very concise descr",
             time_units="years",
             generation_time=42,
         )
         for id, N in zip("ABCD", [100, 200, 300, 400]):
-            g.deme(id, epochs=[Epoch(start_size=N, end_time=0)])
-        string = demes.dumps(g, format=format, simplified=simplified)
+            b1.add_deme(id, epochs=[dict(start_size=N, end_time=0)])
+        g1 = b1.resolve()
+        string = demes.dumps(g1, format=format, simplified=simplified)
         assert "description" in string
-        assert g.description in string
+        assert g1.description in string
         assert "time_units" in string
-        assert g.time_units in string
+        assert g1.time_units in string
         assert "generation_time" in string
-        assert str(g.generation_time) in string
+        assert str(g1.generation_time) in string
         assert "demes" in string
         assert "A" in string
         assert "B" in string
@@ -240,17 +243,19 @@ def check_dumps_simple(self, *, format, simplified):
             assert "selfing_rate" not in string
             assert "cloning_rate" not in string
 
-        g1 = copy.deepcopy(g)
-        g1.deme("E", epochs=[Epoch(start_size=100, selfing_rate=0.1, end_time=0)])
-        string = demes.dumps(g1, format=format, simplified=simplified)
+        b2 = copy.deepcopy(b1)
+        b2.add_deme("E", epochs=[dict(start_size=100, selfing_rate=0.1)])
+        g2 = b2.resolve()
+        string = demes.dumps(g2, format=format, simplified=simplified)
         assert "selfing_rate" in string
         assert "0.1" in string
         if simplified:
             assert "cloning_rate" not in string
 
-        g1 = copy.deepcopy(g)
-        g1.deme("E", epochs=[Epoch(start_size=100, cloning_rate=0.1, end_time=0)])
-        string = demes.dumps(g1, format=format, simplified=simplified)
+        b2 = copy.deepcopy(b1)
+        b2.add_deme("E", epochs=[dict(start_size=100, cloning_rate=0.1)])
+        g2 = b2.resolve()
+        string = demes.dumps(g2, format=format, simplified=simplified)
         if simplified:
             assert "selfing_rate" not in string
         assert "cloning_rate" in string
@@ -273,11 +278,6 @@ def check_dumps_complex(self, *, format, simplified):
             assert "dest" in string
             assert pulse.dest in string
         assert "migrations" in string
-        if simplified:
-            assert "asymmetric" not in string
-            assert "symmetric" in string
-        else:
-            assert "asymmetric" in string
 
     def test_dumps_yaml(self):
         for simplified in [True, False]:
@@ -412,13 +412,14 @@ def test_loads_examples(self):
         assert n > 1
 
     def check_dump_and_load_simple(self, *, format, simplified):
-        g1 = demes.Graph(
+        b1 = demes.Builder(
             description="some very concise description",
             time_units="years",
             generation_time=42,
         )
         for id, N in zip("ABCD", [100, 200, 300, 400]):
-            g1.deme(id, epochs=[Epoch(start_size=N, end_time=0)])
+            b1.add_deme(id, epochs=[dict(start_size=N, end_time=0)])
+        g1 = b1.resolve()
         with tempfile.TemporaryDirectory() as tmpdir:
             tmpfile = pathlib.Path(tmpdir) / "temp.txt"
             demes.dump(g1, tmpfile, format=format, simplified=simplified)
@@ -520,81 +521,81 @@ class Ne(enum.IntEnum):
             NOMINAL = 10000
             HUGE = 100000
 
-        g = demes.Graph(description="test", time_units="generations")
-        g.deme("ancestral", epochs=[demes.Epoch(start_size=100)])
-        g.deme(
+        b = demes.Builder()
+        b.add_deme("ancestral", epochs=[dict(start_size=100)])
+        b.add_deme(
             "A",
             start_time=600,
             ancestors=["ancestral"],
             epochs=[
-                demes.Epoch(end_time=500, start_size=Ne.INITIAL),
-                demes.Epoch(end_time=400, start_size=Ne.BOTTLENECK),
-                demes.Epoch(end_time=300, start_size=Ne.NOMINAL),
-                demes.Epoch(end_time=200, start_size=Ne.HUGE),
+                dict(end_time=500, start_size=Ne.INITIAL),
+                dict(end_time=400, start_size=Ne.BOTTLENECK),
+                dict(end_time=300, start_size=Ne.NOMINAL),
+                dict(end_time=200, start_size=Ne.HUGE),
             ],
         )
-        self.check_dump_load_roundtrip(g)
+        self.check_dump_load_roundtrip(b.resolve())
 
         N = np.array([Ne.INITIAL, Ne.BOTTLENECK, Ne.NOMINAL, Ne.HUGE], dtype=np.int32)
         T = np.array([600, 500, 400, 300, 200], dtype=np.int64)
-        g.deme(
+        b.add_deme(
             "B",
             start_time=T[0],
             ancestors=["ancestral"],
             epochs=[
-                demes.Epoch(end_time=T[1], start_size=N[0]),
-                demes.Epoch(end_time=T[2], start_size=N[1]),
-                demes.Epoch(end_time=T[3], start_size=N[2]),
-                demes.Epoch(end_time=T[4], start_size=N[3]),
+                dict(end_time=T[1], start_size=N[0]),
+                dict(end_time=T[2], start_size=N[1]),
+                dict(end_time=T[3], start_size=N[2]),
+                dict(end_time=T[4], start_size=N[3]),
             ],
         )
-        self.check_dump_load_roundtrip(g)
+        self.check_dump_load_roundtrip(b.resolve())
 
     def test_float_subclass(self):
         # Check that subclasses of float are round-trippable.
         generation_time = np.array([1], dtype=np.float64)
         N = np.array([1000, 500, 10000, 100000], dtype=np.float64)
         T = np.array([600, 500, 400, 300, 200], dtype=np.float32)
-        g = demes.Graph(
+        b = demes.Builder(
             description="test", time_units="years", generation_time=generation_time[0]
         )
-        g.deme("ancestral", epochs=[demes.Epoch(start_size=100)])
-        g.deme(
+        b.add_deme("ancestral", epochs=[dict(start_size=100)])
+        b.add_deme(
             "A",
             start_time=T[0],
             ancestors=["ancestral"],
             epochs=[
-                demes.Epoch(end_time=T[1], start_size=N[0]),
-                demes.Epoch(end_time=T[2], start_size=N[1]),
-                demes.Epoch(end_time=T[3], start_size=N[2]),
-                demes.Epoch(end_time=T[4], start_size=N[3]),
+                dict(end_time=T[1], start_size=N[0]),
+                dict(end_time=T[2], start_size=N[1]),
+                dict(end_time=T[3], start_size=N[2]),
+                dict(end_time=T[4], start_size=N[3]),
             ],
         )
-        self.check_dump_load_roundtrip(g)
+        self.check_dump_load_roundtrip(b.resolve())
 
-        g.deme("B", epochs=[Epoch(start_size=N[0], end_time=0)])
-        g.deme(
+        b.add_deme("B", epochs=[dict(start_size=N[0], end_time=0)])
+        b.add_deme(
             "C",
             ancestors=["A", "B"],
             proportions=[fractions.Fraction(1, 3), fractions.Fraction(2, 3)],
             start_time=T[1],
-            epochs=[Epoch(start_size=N[0], end_time=0)],
+            epochs=[dict(start_size=N[0], end_time=0)],
         )
-        self.check_dump_load_roundtrip(g)
+        self.check_dump_load_roundtrip(b.resolve())
 
-        g.pulse(
+        b.add_pulse(
             source="A",
             dest="B",
             time=T[1],
             proportion=decimal.Decimal("0.0022"),
         )
-        self.check_dump_load_roundtrip(g)
+        self.check_dump_load_roundtrip(b.resolve())
 
-        g.migration(
+        b.add_migration(
             source="A",
             dest="B",
             start_time=T[1],
             end_time=T[2],
             rate=decimal.Decimal("0.000012345"),
         )
-        self.check_dump_load_roundtrip(g)
+        self.check_dump_load_roundtrip(b.resolve())