from typing import List, Union, Optional, Dict, Mapping, MutableMapping, Any, Set
import itertools
import math
import numbers
import copy
import operator
import warnings

import attr

Number = Union[int, float]
ID = str
Time = Number
Size = Number
Rate = float
Proportion = float

_ISCLOSE_REL_TOL = 1e-9
_ISCLOSE_ABS_TOL = 1e-12

# Validator functions.


def int_or_float(self, attribute, value):
    if (
        not isinstance(value, numbers.Real) and not hasattr(value, "__float__")
    ) or value != value:  # type-agnostic test for NaN
        raise TypeError(f"{attribute.name} must be a number")


def positive(self, attribute, value):
    if value <= 0:
        raise ValueError(f"{attribute.name} must be greater than zero")


def non_negative(self, attribute, value):
    if value < 0:
        raise ValueError(f"{attribute.name} must be non-negative")


def finite(self, attribute, value):
    if math.isinf(value):
        raise ValueError(f"{attribute.name} must be finite")


def unit_interval(self, attribute, value):
    if not (0 <= value <= 1):
        raise ValueError(f"must have 0 <= {attribute.name} <= 1")


def nonzero_len(self, attribute, value):
    if len(value) == 0:
        if isinstance(value, str):
            raise ValueError(f"{attribute.name} must be a non-empty string")
        else:
            raise ValueError(f"{attribute.name} must have non-zero length")


def valid_deme_id(self, attribute, value):
    if not value.isidentifier():
        raise ValueError(
            "Invalid deme ID `{self.id}`. IDs must be valid python identifiers. "
            "We recommend choosing a deme ID that starts with a letter or "
            "underscore, and is followed by one or more letters, numbers, "
            "or underscores."
        )


def isclose_deme_proportions(
    a_ids: List[ID],
    a_proportions: List[Proportion],
    b_ids: List[ID],
    b_proportions: List[Proportion],
    *,
    rel_tol=_ISCLOSE_REL_TOL,
    abs_tol=_ISCLOSE_ABS_TOL,
) -> bool:
    """
    Returns true if (a_ids, a_proportions) and (b_ids, b_proportions)
    are semantically equivalent. The order of ids is ignored, and proportions
    are checked for numerical closeness.
    """
    if len(a_ids) != len(b_ids) or len(a_proportions) != len(b_proportions):
        return False
    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 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:
    """
    Population size parameters for a deme in a specified time period.
    Times follow the forwards-in-time convention (time values increase
    from the present towards the past). The start time of the epoch is
    the more ancient time, and the end time is more recent, so that the
    start time must be greater than the end time

    :ivar start_time: The start time of the epoch.
    :ivar end_time: The end time of the epoch (must be specified).
    :ivar start_size: Population size at ``start_time``.
    :ivar end_size: Population size at ``end_time``.
        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: The selfing rate for this epoch.
    :ivar cloning_rate: The cloning rate for this epoch.
    """

    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: Proportion = attr.ib(
        default=0, validator=[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_time <= self.end_time:
            raise ValueError("must have start_time > end_time")
        # 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
    def time_span(self):
        """
        The time span of the epoch.
        """
        return self.start_time - self.end_time

    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:
        ``start_time``, ``end_time``, ``start_size``, ``end_size``,
        ``size_function``, ``selfing_rate``, ``cloning_rate``.

        :param other: The epoch to compare against.
        :type other: :class:`.Epoch`
        :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
        """
        assert (
            self.__class__ is other.__class__
        ), f"Failed as other epoch is not instance of {self.__class__} type."
        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 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 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 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 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 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)."
        )

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the epoch and ``other`` epoch implement essentially
        the same epoch. For more information see :meth:`assert_close`.

        :param other: The epoch to compare against.
        :type other: :class:`.Epoch`
        :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


        :return: True if the two epochs are equivalent, False otherwise.
        :rtype: bool
        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False


@attr.s(auto_attribs=True, kw_only=True)
class Migration:
    """
    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 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.
    """

    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 not (self.start_time > self.end_time):
            raise ValueError("must have start_time > end_time")

    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:
        `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
        """
        assert (
            self.__class__ is other.__class__
        ), f"Failed as other migration is not instance of {self.__class__} type."
        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 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 math.isclose(
            self.rate, other.rate, rel_tol=rel_tol, abs_tol=abs_tol
        ), f"Failed for rate {self.rate} != {other.rate} (other)."

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the migration is equal to the ``other`` migration.
        For more information see :meth:`assert_close`.

        :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

        :return: True if the two epochs are equivalent, False otherwise.
        :rtype: bool

        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            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:
    """
    Parameters for a pulse of 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.

    :ivar source: The source deme.
    :ivar dest: The destination deme.
    :ivar time: The time of migration.
    :ivar proportion: At the instant after migration, this is the proportion
        of individuals in the destination deme made up of individuals from
        the source deme.
    """

    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])
    time: Time = attr.ib(validator=[int_or_float, non_negative, finite])
    proportion: Proportion = attr.ib(validator=[int_or_float, unit_interval])

    def __attrs_post_init__(self):
        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``, ``time``, ``proportion``.

        :param other: The pulse to compare against.
        :type other: :class:`.Pulse`
        :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
        """
        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 math.isclose(
            self.time, other.time, rel_tol=rel_tol, abs_tol=abs_tol
        ), f"Failed for time {self.time} != {other.time} (other)."
        assert math.isclose(
            self.proportion, other.proportion, rel_tol=rel_tol, abs_tol=abs_tol
        ), f"Failed for proportion {self.proportion} != {other.proportion} (other)."

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the pulse is equal to the ``other`` pulse.
        For more information see :meth:`assert_close`.

        :param other: The pulse to compare against.
        :type other: :class:`.Pulse`
        :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

        :return: True if the two pulses are equivalent, False otherwise.
        :rtype: bool

        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False


@attr.s(auto_attribs=True, kw_only=True)
class Split:
    """
    Parameters for a split event, in which a deme ends at a given time and
    contributes ancestry to an arbitrary number of descendant demes. Note
    that there could be just a single descendant deme, in which case ``split``
    is a bit of a misnomer...

    :ivar parent: The parental deme.
    :ivar children: A list of descendant demes.
    :ivar time: The split time.
    """

    parent: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
    children: List[ID] = attr.ib(
        validator=attr.validators.and_(
            attr.validators.deep_iterable(
                member_validator=attr.validators.and_(
                    attr.validators.instance_of(str), valid_deme_id
                ),
                iterable_validator=attr.validators.instance_of(list),
            ),
            nonzero_len,
        )
    )
    time: Time = attr.ib(validator=[int_or_float, non_negative, finite])

    def __attrs_post_init__(self):
        if self.parent in self.children:
            raise ValueError("child and parent cannot be the same deme")
        if len(set(self.children)) != len(self.children):
            raise ValueError("cannot repeat children in split")

    def assert_close(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Raises AssertionError if the object is not equal to ``other``,
        up to a numerical tolerance.
        Compares values of the following attributes:
        ``parent``, ``children``, ``time``.

        :param other: The split to compare against.
        :type other: :class:`.Split`
        :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
        """
        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 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,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the split is equal to the ``other`` split.
        Compares values of the following attributes:
        ``parent``, ``children``, ``time``.

        :param other: The split to compare against.
        :type other: :class:`.Split`
        :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

        :return: True if the two splits are equivalent, False otherwise.
        :rtype: bool
        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False


@attr.s(auto_attribs=True, kw_only=True)
class Branch:
    """
    Parameters for a branch event, where a new deme branches off from a parental
    deme. The parental deme need not end at that time.

    :ivar parent: The parental deme.
    :ivar child: The descendant deme.
    :ivar time: The branch time.
    """

    parent: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
    child: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
    time: Time = attr.ib(validator=[int_or_float, non_negative, finite])

    def __attrs_post_init__(self):
        if self.child == self.parent:
            raise ValueError("child and parent 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:
        ``parent``, ``child``, ``time``.

        :param other: The branch to compare against.
        :type other: :class:`.Branch`
        :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
        """
        assert (
            self.__class__ is other.__class__
        ), f"failed as other branch is not instance of {self.__class__} type."
        assert self.parent == other.parent
        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)."

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the branch is equal to the ``other`` branch.
        For more information see :meth:`assert_close`.

        :param other: The branch to compare against.
        :type other: :class:`.Branch`
        :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

        :return: True if the two branches are equivalent, False otherwise.
        :rtype: bool

        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False


@attr.s(auto_attribs=True, kw_only=True)
class Merge:
    """
    Parameters for a merge event, in which two or more demes end at some time and
    contribute to a descendant deme.

    :ivar parents: A list of parental demes.
    :ivar proportions: A list of ancestry proportions, in order of `parents`.
    :ivar child: The descendant deme.
    :ivar time: The merge time.
    """

    parents: 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),
        )
    )
    proportions: List[Proportion] = attr.ib(
        validator=attr.validators.deep_iterable(
            member_validator=int_or_float,
            iterable_validator=attr.validators.instance_of(list),
        )
    )
    child: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
    time: Time = attr.ib(validator=[int_or_float, non_negative, finite])

    @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")
        for proportion in self.proportions:
            unit_interval(self, attribute, proportion)
            positive(self, attribute, proportion)

    def __attrs_post_init__(self):
        if len(self.parents) < 2:
            raise ValueError("merge must involve at least two ancestors")
        if len(self.parents) != len(self.proportions):
            raise ValueError("parents and proportions must have same length")
        if self.child in self.parents:
            raise ValueError("merged deme cannot be its own ancestor")
        if len(set(self.parents)) != len(self.parents):
            raise ValueError("cannot repeat parents in merge")

    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:
        ``parents``, ``proportions``, ``child``, ``time``.

        :param other: The merge to compare against.
        :type other: :class:`.Merge`
        :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
        """
        assert (
            self.__class__ is other.__class__
        ), f"Failed as other merge is not instance of {self.__class__} type."
        assert isclose_deme_proportions(
            self.parents,
            self.proportions,
            other.parents,
            other.proportions,
            rel_tol=rel_tol,
            abs_tol=abs_tol,
        ), (
            f"Parents or corresponding proportions are different: "
            f"parents: {self.parents}, {other.parents} (other), "
            f"proportions: {self.proportions}, {other.proportions} (other)."
        )
        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)."

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the merge is equal to the ``other`` merge.
        For more information see :meth:`assert_close`.

        :param other: The merge to compare against.
        :type other: :class:`.Merge`
        :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

        :return: True if the two merges are equivalent, False otherwise.
        :rtype: bool

        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False


@attr.s(auto_attribs=True, kw_only=True)
class Admix:
    """
    Parameters for an admixture event, where two or more demes contribute ancestry
    to a new deme.

    :ivar parents: A list of source demes.
    :ivar proportions: A list of ancestry proportions, in order of `parents`.
    :ivar child: The admixed deme.
    :ivar time: The admixture time.
    """

    parents: 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),
        )
    )
    proportions: List[Proportion] = attr.ib(
        validator=attr.validators.deep_iterable(
            member_validator=int_or_float,
            iterable_validator=attr.validators.instance_of(list),
        )
    )
    child: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
    time: Time = attr.ib(validator=[int_or_float, non_negative, finite])

    @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")
        for proportion in self.proportions:
            unit_interval(self, attribute, proportion)
            positive(self, attribute, proportion)

    def __attrs_post_init__(self):
        if len(self.parents) < 2:
            raise ValueError("admixture must involve at least two ancestors")
        if len(self.parents) != len(self.proportions):
            raise ValueError("parents and proportions must have same length")
        if self.child in self.parents:
            raise ValueError("admixed deme cannot be its own ancestor")
        if len(set(self.parents)) != len(self.parents):
            raise ValueError("cannot repeat parents in admixure")

    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:
        ``parents``, ``proportions``, ``child``, ``time``.

        :param other: The admixture to compare against.
        :type other: :class:`.Admix`
        :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
        """
        assert (
            self.__class__ is other.__class__
        ), f"Failed as other admixture is not instance of {self.__class__} type."
        assert isclose_deme_proportions(
            self.parents,
            self.proportions,
            other.parents,
            other.proportions,
            rel_tol=rel_tol,
            abs_tol=abs_tol,
        ), (
            f"Parents or corresponding proportions are different: "
            f"parents: {self.parents}, {other.parents} (other), "
            f"proportions: {self.proportions}, {other.proportions} (other)."
        )
        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)."

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the admixture is equal to the ``other`` admixture.
        For more information see :meth:`assert_close`.

        :param other: The admixture to compare against.
        :type other: :class:`.Admix`
        :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

        :return: True if the two admixtures are equivalent, False otherwise.
        :rtype: bool
        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False


@attr.s(auto_attribs=True, kw_only=True)
class Deme:
    """
    A collection of individuals that are exchangeable at any fixed time.

    :ivar str id: A string identifier for the deme.
    :ivar str description: A description of the deme. May be ``None``.
    :ivar 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
    :ivar 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
    :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`
    """

    id: ID = attr.ib(validator=[attr.validators.instance_of(str), valid_deme_id])
    description: Optional[str] = attr.ib(
        validator=attr.validators.optional(
            [attr.validators.instance_of(str), nonzero_len]
        )
    )
    ancestors: 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),
        )
    )
    proportions: List[Proportion] = attr.ib(
        validator=attr.validators.deep_iterable(
            member_validator=int_or_float,
            iterable_validator=attr.validators.instance_of(list),
        )
    )
    epochs: List[Epoch] = attr.ib(
        validator=attr.validators.deep_iterable(
            member_validator=attr.validators.instance_of(Epoch),
            iterable_validator=attr.validators.instance_of(list),
        )
    )
    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"deme {self.id}: duplicate ancestors in {self.ancestors}")
        if self.id in self.ancestors:
            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(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):
        # 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(
                        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(
                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,
        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 objects:
        ``id``, ``ancestors``, ``proportions``, epochs.

        :param other: The deme to compare against.
        :type other: :class:`.Deme`
        :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
        """
        assert (
            self.__class__ is other.__class__
        ), f"Failed as other deme is not instance of {self.__class__} type."
        assert self.id == other.id
        assert isclose_deme_proportions(
            self.ancestors,
            self.proportions,
            other.ancestors,
            other.proportions,
            rel_tol=rel_tol,
            abs_tol=abs_tol,
        ), (
            f"Ancestors or corresponding proportions are different: "
            f"ancestors: {self.ancestors}, {other.ancestors} (other), "
            f"proportions: {self.proportions}, {other.proportions} (other)."
        )
        for i, (e1, e2) in enumerate(zip(self.epochs, other.epochs)):
            try:
                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

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the deme is equal to the ``other`` deme.
        For more information see :meth:`assert_close`.

        :param other: The deme to compare against.
        :type other: :class:`.Deme`
        :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

        :return: True if the two demes are equivalent, False otherwise.
        :rtype: bool
        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False

    @property
    def end_time(self):
        """
        The end time of the deme's existence.
        """
        return self.epochs[-1].end_time

    @property
    def time_span(self):
        """
        The time span over which the deme exists.
        """
        return self.start_time - self.end_time


@attr.s(auto_attribs=True, kw_only=True)
class Graph:
    """
    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``.
    :ivar str time_units: The units of time used for the demography. This is
        commonly ``years`` or ``generations``, but can be any string.
        This field is intended to be useful for documenting a demography,
        but the actual value provided here should not be relied upon.
    :ivar 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.
        See also: :meth:`.in_generations`.
    :ivar doi: If the graph describes a published demography, the DOI(s)
        should be be given here as a list.
    :vartype doi: list of str
    :ivar demes: A list of demes in the demography.
    :vartype demes: list of :class:`.Deme`
    :ivar migrations: A list of continuous migrations for the demography.
    :vartype migrations: list of :class:`.Migration`
    :ivar pulses: A list of migration pulses for the demography.
    :vartype pulses: list of :class:`.Pulse`
    """

    description: Optional[str] = attr.ib(
        default=None,
        validator=attr.validators.optional(
            [attr.validators.instance_of(str), nonzero_len]
        ),
    )
    time_units: str = attr.ib(validator=[attr.validators.instance_of(str), nonzero_len])
    generation_time: Optional[Time] = attr.ib(
        default=None,
        validator=attr.validators.optional([int_or_float, positive, finite]),
    )
    doi: List[str] = attr.ib(
        factory=list,
        validator=attr.validators.deep_iterable(
            member_validator=attr.validators.and_(
                attr.validators.instance_of(str), nonzero_len
            ),
            iterable_validator=attr.validators.instance_of(list),
        ),
    )
    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)

    def __attrs_post_init__(self):
        self._deme_map: Dict[ID, Deme] = dict()

        if self.time_units != "generations" and self.generation_time is None:
            raise ValueError(
                'if time_units!="generations", generation_time must be specified'
            )

    def __getitem__(self, deme_id):
        """
        Return the :class:`.Deme` with the specified id.
        """
        return self._deme_map[deme_id]

    def __contains__(self, deme_id):
        """
        Check if the graph contains a deme with the specified id.
        """
        return deme_id in self._deme_map

    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.
        Furthermore, the following implementation details are ignored during
        the comparison:

            - The graphs' ``description`` and ``doi`` attributes.
            - The order in which ``migrations`` were specified.
            - 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.

        :param other: The graph to compare against.
        :type other: :class:`.Graph`
        :param float rel_tol: The relative tolerance permitted for numerical
            comparisons. See documentation for :func:`math.isclose`.
        :param float abs_tol: The absolute tolerance permitted for numerical
            comparisons. See documentation for :func:`math.isclose`.
        """

        def assert_sorted_eq(aa, bb, *, rel_tol, abs_tol, name):
            # Order-agnostic equality check.
            assert len(aa) == len(bb)
            for (a, b) in zip(sorted(aa), sorted(bb)):
                try:
                    a.assert_close(b, rel_tol=rel_tol, abs_tol=abs_tol)
                except AssertionError as e:
                    if isinstance(a, Deme) and isinstance(b, Deme):
                        raise AssertionError(
                            f"Failed for {name} {a.id} and {b.id}"
                        ) from e
                    raise AssertionError(f"Failed for {name}") from e

        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
        assert_sorted_eq(
            self.demes, other.demes, rel_tol=rel_tol, abs_tol=abs_tol, name="demes"
        )
        # Compare asymmetric and symmetric migrations separately.
        assert_sorted_eq(
            [m for m in self.migrations if isinstance(m, AsymmetricMigration)],
            [m for m in other.migrations if isinstance(m, AsymmetricMigration)],
            rel_tol=rel_tol,
            abs_tol=abs_tol,
            name="asymmetric migrations",
        )
        # Symmetric migrations are special, in the sense that they contain lists
        # of demes, and we must first sort the list of demes before sorting the
        # list of SymmetricMigration objects.
        self_migrations_sym = []
        other_migrations_sym = []
        for m in self.migrations:
            if isinstance(m, SymmetricMigration):
                m = copy.deepcopy(m)
                m.demes.sort()
                self_migrations_sym.append(m)
        for m in other.migrations:
            if isinstance(m, SymmetricMigration):
                m = copy.deepcopy(m)
                m.demes.sort()
                other_migrations_sym.append(m)
        assert_sorted_eq(
            self_migrations_sym,
            other_migrations_sym,
            rel_tol=rel_tol,
            abs_tol=abs_tol,
            name="symmetric migrations",
        )
        assert_sorted_eq(
            self.pulses,
            other.pulses,
            rel_tol=rel_tol,
            abs_tol=abs_tol,
            name="pulses",
        )

    def isclose(
        self,
        other,
        *,
        rel_tol=_ISCLOSE_REL_TOL,
        abs_tol=_ISCLOSE_ABS_TOL,
    ) -> bool:
        """
        Returns true if the graph and ``other`` implement essentially
        the same demographic model. Numerical values are compared using the
        :func:`math.isclose` function, from which this method takes its name.
        Furthermore, the following implementation details are ignored during
        the comparison:

            - The graphs' ``description`` and ``doi`` attributes.
            - The order in which ``migrations`` were specified.
            - 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.

        :param other: The graph to compare against.
        :type other: :class:`.Graph`
        :param float rel_tol: The relative tolerance permitted for numerical
            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, False otherwise.
        :rtype: bool
        """
        try:
            self.assert_close(other, rel_tol=rel_tol, abs_tol=abs_tol)
            return True
        except AssertionError:
            return False

    def _add_deme(
        self,
        *,
        id,
        description=None,
        ancestors=None,
        proportions=None,
        start_time=None,
    ) -> Deme:
        """
        Add a deme to the graph.

        :param str id: A string identifier for the deme.
        :param ancestors: List of string identifiers for the deme's ancestors.
            This may be ``None``, indicating the deme has no ancestors.
            If the deme has multiple ancestors, the ``proportions`` parameter
            must also be provided.
        :type ancestors: list of str
        :param list proportions: A list of ancestry proportions for ``ancestors``.
            This list has the same length as ``ancestors``, and must sum to ``1.0``.
            May be omitted if the deme has only one, or zero, ancestors.
        :type proportions: list of float
        :param float start_time: The time at which this deme begins existing,
            in units of ``time_units`` before the present.

            - If the deme has zero ancestors, and ``start_time`` is not specified,
              the start time will be set to ``inf``.
            - If the deme has one ancestor, and ``start_time`` is not specified,
              the ``start_time`` will be set to the ancestor's ``end_time``.
            - If the deme has multiple ancestors, the ``start_time`` must be
              provided.

        :return: Newly created deme.
        :rtype: :class:`.Deme`
        """
        # some basic deme property checks
        if id in self:
            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"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"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 inf or to the ancestor's end time
        if start_time is None:
            if len(ancestors) > 0:
                if len(ancestors) > 1:
                    raise ValueError(
                        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 = math.inf

        if len(ancestors) == 0 and not math.isinf(start_time):
            raise ValueError(
                f"deme[{len(self.demes)}] {id}: field 'ancestors' not found, "
                "but is required for demes with a finite 'start_time'"
            )

        # 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"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}]"
                )

        deme = Deme(
            id=id,
            description=description,
            ancestors=ancestors,
            proportions=proportions,
            start_time=start_time,
            epochs=[],
        )
        self._deme_map[deme.id] = deme
        self.demes.append(deme)
        return deme

    def _check_time_intersection(self, deme1, deme2, time):
        deme1 = self[deme1]
        deme2 = self[deme2]
        time_lo = max(deme1.end_time, deme2.end_time)
        time_hi = min(deme1.start_time, deme2.start_time)
        if time is not None:
            if not (time_lo <= time <= time_hi):
                raise ValueError(
                    f"{time} not in interval [{time_lo}, {time_hi}], "
                    f"as defined by the time-intersection of {deme1.id} "
                    f"(start_time={deme1.start_time}, end_time={deme1.end_time}) "
                    f"and {deme2.id} (start_time={deme2.start_time}, "
                    f"end_time={deme2.end_time})."
                )
        return time_lo, time_hi

    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
    ) -> SymmetricMigration:
        """
        Add continuous symmetric migrations between all pairs of demes in a list.

        :param demes: list of deme IDs. Migration is symmetric between all
            pairs of demes in this list.
        :param rate: The rate of migration per generation.
        :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:`.SymmetricMigration`
        """
        if not isinstance(demes, list) or len(demes) < 2:
            raise ValueError("must specify a list of two or more deme IDs")
        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):
            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
            )
        migration = SymmetricMigration(
            demes=demes,
            start_time=start_time,
            end_time=end_time,
            rate=rate,
        )
        self.migrations.append(migration)
        return migration

    def _add_asymmetric_migration(
        self, *, source, dest, rate, start_time=None, end_time=None
    ) -> AsymmetricMigration:
        """
        Add continuous 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 source: The ID of the source deme.
        :param dest: The ID of the destination deme.
        :param rate: The rate of migration per generation.
        :param 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 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.
        :return: Newly created migration.
        :rtype: :class:`.AsymmetricMigration`
        """
        for deme_id in (source, dest):
            if deme_id not in self:
                raise ValueError(f"{deme_id} not in graph")
        time_lo, time_hi = self._check_time_intersection(source, dest, start_time)
        if start_time is None:
            start_time = time_hi
        else:
            self._check_time_intersection(source, dest, start_time)
        if end_time is None:
            end_time = time_lo
        else:
            self._check_time_intersection(source, dest, end_time)
        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,
            end_time=end_time,
            rate=rate,
        )

        self.migrations.append(migration)
        return migration

    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.

        :param source: The ID of the source deme.
        :param dest: The ID of the destination deme.
        :param 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 time: The time at which migrations occur.
        :return: Newly created pulse.
        :rtype: :class:`.Pulse`
        """
        for deme_id in (source, dest):
            if deme_id not in self:
                raise ValueError(f"{deme_id} not in graph")
        self._check_time_intersection(source, dest, time)

        # Check for models that have multiple pulses defined at the same time.
        # E.g. chains of pulses like: deme0 -> deme1; deme1 -> deme2,
        # where reversing the order of the pulse definitions changes the
        # interpretation of the model. Such models are valid, but the behaviour
        # may not be what the user expects.
        # See https://github.com/popsim-consortium/demes-python/issues/46
        sources = set()
        dests = set()
        for pulse in self.pulses:
            if pulse.time == time:
                sources.add(pulse.source)
                dests.add(pulse.dest)
        if source in dests or dest in (sources | dests):
            warnings.warn(
                "Multiple pulses are defined for the same deme(s) at time "
                f"{time}. The ancestry proportions after this time will thus "
                "depend on the order in which the pulses have been specified. "
                "To avoid unexpected behaviour, the graph can instead "
                "be structured to introduce a new deme at this time with "
                "the desired ancestry proportions."
            )

        pulse = Pulse(
            source=source,
            dest=dest,
            time=time,
            proportion=proportion,
        )
        self.pulses.append(pulse)
        return pulse

    def successors(self) -> Dict[ID, List[ID]]:
        """
        Returns the successors (child demes) for all demes in the graph.
        If ``graph`` is a :class:`Graph`, a `NetworkX <https://networkx.org/>`_
        digraph of successors can be obtained as follows.

        .. code::

            import networkx as nx
            succ = nx.from_dict_of_lists(graph.successors(), create_using=nx.DiGraph)

        .. warning::

            The successors do not include information about migrations or pulses.

        :return: A NetworkX compatible dict-of-lists graph of the demes' successors.
        :rtype: dict of lists
        """
        succ: Dict[ID, List[ID]] = {}
        for deme_info in self.demes:
            succ.setdefault(deme_info.id, [])
            if deme_info.ancestors is not None:
                for a in deme_info.ancestors:
                    succ.setdefault(a, [])
                    succ[a].append(deme_info.id)
        return succ

    def predecessors(self) -> Dict[ID, List[ID]]:
        """
        Returns the predecessors (ancestors) for all demes in the graph.
        If ``graph`` is a :class:`Graph`, a `NetworkX <https://networkx.org/>`_
        digraph of predecessors can be obtained as follows.

        .. code::

            import networkx as nx
            pred = nx.from_dict_of_lists(graph.predecessors(), create_using=nx.DiGraph)

        .. warning::

            The predecessors do not include information about migrations or pulses.

        :return: A NetworkX compatible dict-of-lists graph of the demes' predecessors.
        :rtype: dict of lists
        """
        pred: Dict[ID, List[ID]] = {}
        for deme_info in self.demes:
            pred.setdefault(deme_info.id, [])
            if deme_info.ancestors is not None:
                for a in deme_info.ancestors:
                    pred[deme_info.id].append(a)
        return pred

    def discrete_demographic_events(self) -> Dict[str, List[Any]]:
        """
        Classify each discrete demographic event as one of the following:
        :class:`Pulse`, :class:`Split`, :class:`Branch`, :class:`Merge`,
        or :class:`Admix`.
        If a deme has more than one ancestor, then that deme is created by a
        merger or an admixture event, which are differentiated by end and
        start times of those demes. If a deme has a single predecessor, we check
        whether it is created by a branch (start time != predecessor's end time),
        or split.

        .. note::

            By definition, the discrete demographic events do not include
            migrations, as they are continuous events.

        :return: A dictionary of lists of discrete demographic events.
            The following keys are defined: "pulses", "splits", "branches",
            "mergers", "admixtures", and their values are the corresponding
            lists of :class:`Pulse`, :class:`Split`, :class:`Branch`,
            :class:`Merge`, and :class:`Admix` objects.
        :rtype: dict of lists
        """
        demo_events: Dict[str, List[Any]] = {
            "pulses": self.pulses,
            "splits": [],
            "branches": [],
            "mergers": [],
            "admixtures": [],
        }
        splits_to_add: Dict[ID, Set[ID]] = {}
        for c, p in self.predecessors().items():
            if len(p) == 0:
                continue
            elif len(p) == 1:
                if self[c].start_time == self[p[0]].end_time:
                    splits_to_add.setdefault(p[0], set())
                    splits_to_add[p[0]].add(c)
                else:
                    demo_events["branches"].append(
                        Branch(parent=p[0], child=c, time=self[c].start_time)
                    )
            else:
                time_aligned = True
                for deme_from in p:
                    if self[c].start_time != self[deme_from].end_time:
                        time_aligned = False
                if time_aligned is True:
                    demo_events["mergers"].append(
                        Merge(
                            parents=self[c].ancestors,
                            proportions=self[c].proportions,
                            child=c,
                            time=self[c].start_time,
                        )
                    )
                else:
                    demo_events["admixtures"].append(
                        Admix(
                            parents=self[c].ancestors,
                            proportions=self[c].proportions,
                            child=c,
                            time=self[c].start_time,
                        )
                    )
        for deme_from, demes_to in splits_to_add.items():
            demo_events["splits"].append(
                Split(
                    parent=deme_from,
                    children=list(demes_to),
                    time=self[deme_from].end_time,
                )
            )
        return demo_events

    def in_generations(self):
        """
        Return a copy of the graph with times in units of generations.
        """
        graph = copy.deepcopy(self)
        graph.time_units = "generations"
        generation_time = self.generation_time
        if generation_time is not None:
            graph.generation_time = None
            for deme in graph.demes:
                for epoch in deme.epochs:
                    epoch.start_time /= generation_time
                    epoch.end_time /= generation_time
            for migration in graph.migrations:
                migration.start_time /= generation_time
                migration.end_time /= generation_time
            for pulse in graph.pulses:
                pulse.time /= generation_time
        return graph

    @classmethod
    def fromdict(cls, data: MutableMapping[str, Any]):
        """
        Return a graph from a dict representation. The inverse of asdict().
        """
        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",
            )

            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")
            demes = migration_data.pop("demes", None)
            source = migration_data.pop("source", None)
            dest = migration_data.pop("dest", None)
            if not (
                # symmetric
                (demes is not None and source is None and dest is None)
                # asymmetric
                or (demes is None and source is not None and dest is not None)
            ):
                raise KeyError(
                    f"migration[{i}]: must be symmetric (specify 'demes' list) "
                    "*or* asymmetric (specify both 'source' and 'dest')."
                )
            try:
                if demes is not None:
                    graph._add_symmetric_migration(
                        demes=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=source,
                        dest=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):
        """
        Return a dict representation of the graph.
        """

        def filt(_attrib, val):
            return val is not None and not (hasattr(val, "__len__") and len(val) == 0)

        def coerce_numbers(inst, attribute, value):
            # Explicitly convert numeric types to int or float, so that they
            # don't cause problems for the YAML and JSON serialisers.
            # E.g. numpy int32/int64 are part of Python's numeric tower as
            # subclasses of numbers.Integral, similarly numpy's float32/float64
            # are subclasses of numbers.Real. There are yet other numeric types,
            # such as the standard library's decimal.Decimal, which are not part
            # of the numeric tower, but provide a __float__() method.
            if isinstance(value, numbers.Integral):
                value = int(value)
            elif isinstance(value, numbers.Real) or hasattr(value, "__float__"):
                value = float(value)
            return value

        data = attr.asdict(self, filter=filt, value_serializer=coerce_numbers)
        # translate to spec data model
        for deme in data["demes"]:
            deme["start_time"] = deme["epochs"][0]["start_time"]
            for epoch in deme["epochs"]:
                del epoch["start_time"]
                if epoch["selfing_rate"] == 0:
                    del epoch["selfing_rate"]
                if epoch["cloning_rate"] == 0:
                    del epoch["cloning_rate"]
        return data

    def asdict_simplified(self):
        """
        Return a simplified dict representation of the graph.
        """

        def simplify_epochs(data):
            """
            Remove epoch start times. Also remove deme start time
            if implied by the deme ancestor(s)'s end time(s).
            """
            for deme in data["demes"]:
                for j, epoch in enumerate(deme["epochs"]):
                    if epoch["size_function"] in ("constant", "exponential"):
                        del epoch["size_function"]
                    if epoch["start_size"] == epoch["end_size"]:
                        del epoch["end_size"]

            for deme in data["demes"]:
                # remove implied start times
                if math.isinf(deme["start_time"]):
                    del deme["start_time"]
                if "ancestors" in deme and len(deme["ancestors"]) == 1:
                    del deme["proportions"]
                    # start time needed for more than 1 ancestor
                    if self[deme["ancestors"][0]].end_time == deme["start_time"]:
                        del deme["start_time"]

        def simplify_migration_rates(data):
            """
            Remove redundant information
            about start and end times if they are implied by the time overlap
            interval of the demes involved.
            """

            for migration in data["migrations"]:
                demes = migration.get("demes", [])
                if len(demes) == 0:
                    demes = [migration["source"], migration["dest"]]

                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()

        if "migrations" in data:
            simplify_migration_rates(data)
        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 = None,
        # We use a special NO_DEFAULT value here, to distinguish between the user
        # not specifying anything, and specifying the value None (which may be
        # necessary to override a 'defaults' value set in the data dictionary).
        demes: list = NO_DEFAULT,  # type: ignore
        source: str = NO_DEFAULT,  # type: ignore
        dest: str = NO_DEFAULT,  # type: ignore
        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()
        if rate is not None:
            migration["rate"] = rate
        if demes is not NO_DEFAULT:
            migration["demes"] = demes
        if source is not NO_DEFAULT:
            migration["source"] = source
        if dest is not NO_DEFAULT:
            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 = None,
        dest: str = None,
        proportion: float = None,
        time: float = None,
    ):
        """
        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: MutableMapping[str, Any] = dict()
        if source is not None:
            pulse["source"] = source
        if dest is not None:
            pulse["dest"] = dest
        if proportion is not None:
            pulse["proportion"] = proportion
        if time is not None:
            pulse["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
