Skip to content

zombi2.params

Every event fires at a rate, and every rate is written the same way at every level:

effective rate  =  scope(base) × modifiers

The base is the speed of one event, in inverse time. The scope answers "per what?" — how many copies, lineages or sites the event applies to right now — and is the entry point the rate is written from. The modifiers are dimensionless context multipliers, and they multiply. There is no per= argument: the scope lives on each rate.

A modifier is not a class you call: it is what a verb records. scaled_by multiplies the base, set_by replaces it, and weighted_by compares the candidates of a choice. Two drivers are written so often that each has a verb of its own, which is the only spelling for it: varying_among for a value drawn for each unit of one kind, changing_at for the run's clock. Verbs chain, and their factors multiply.

That expression is not Python syntax the CLI translates; it is the way a rate is written, and the command line and a --params file take it verbatim. The same text, three places:

from zombi2 import species
from zombi2.params import PerLineage

birth = PerLineage(1.0).changing_at({0: 1.0, 3: 0.3})
species.simulate_species_tree(birth=birth, n_extant=10, seed=1)
# params.toml
birth = "PerLineage(1.0).changing_at({0: 1.0, 3: 0.3})"
n-extant = 10
zombi2 species out/ --birth "PerLineage(1.0).changing_at({0: 1.0, 3: 0.3})" --n-extant 10 --seed 1
zombi2 species out/ --params params.toml --seed 1

Every name the written form may call is importable from zombi2.params, so a snippet pastes across unchanged. Two qualifiers are tolerated where Python needs one — scope. and scopes. — and nothing else, so rates.PerCopy(...) or a dotted zombi2.params.PerCopy(...) is refused: the parser reads a whitelist of names, not attribute paths. A bare number stays a bare number everywhere (birth = 1.0, --birth 1.0).

A level rejects the modifiers it does not support rather than ignoring them, so a run is never quietly not the model you asked for.

Scopes

zombi2.params.scope

Scopes — the "per what?" of a rate (SPEC §5).

Every rate is written from its scope, so per what? is answered on the page rather than by a default nobody types::

birth        = PerLineage(0.5)   # each lineage speciates at 0.5 -> total = 0.5 × (lineages alive)
birth        = Global(0.5)       # one shared budget for the whole tree -> total = 0.5 (constant)
loss         = PerCopy(0.25)     # each gene copy is lost at 0.25 -> total = 0.25 × (copies present)
substitution = PerSite(0.01)

Calling one is the rate: PerCopy(0.25) evaluates to a Rate, and the verbs chain onto it (PerCopy(0.25).varying_among('families', LogNormal(0.0, 0.5))). There is no intermediate object.

A scope carries no number of its own. It is a marker for the unit the base is counted per, and Rate.scope holds the class rather than an instance — because while a scope carried a base the same number lived in two places (Rate.base and Rate.scope.base) and nothing made them agree.

PerCopy() — with no number — is what a Rate.set_by is written from: replacing how fast says nothing about per what, so the scope still stands while the base does not.

The word "per" is reserved for these. A driver never starts with "per", and the unit a value varies among is written with varying_among (SPEC §5).

There is deliberately no PerGenome: one genome lives in one lineage, so "per genome" is PerLineage.

A bare number (birth = 1.0) is coerced by each level to its natural default scope — species birth/death and gene origination per lineage, duplication/transfer/loss per copy, substitution per site. The scopes here are the explicit override, and the only spelling where more than one is legal.

Scope

The unit a rate's base is counted per, as a marker class.

Abstract: use one of Global, PerLineage, PerCopy, PerSite, PerChromosome. Calling one builds a Rate; a Scope instance never exists, which is why total_of is a classmethod and Rate.scope holds the class.

total_of classmethod

total_of(base: float, **counts: Any) -> float

The total rate for base, given the current counts.

counts supplies the units in scope right now (lineages, copies, sites, chromosomes); each scope reads only the one it needs and ignores the rest. Global reads none.

The base is an argument rather than a field because a SetBy supplies one: a scope answers per what?, and that question is unchanged when a driver replaces the number — "0.5 per copy in cave lineages" still multiplies by the copies present.

Source code in zombi2/params/scope.py
@classmethod
def total_of(cls, base: float, **counts: Any) -> float:
    """The total rate for ``base``, given the current counts.

    ``counts`` supplies the units in scope right now (``lineages``, ``copies``, ``sites``,
    ``chromosomes``); each scope reads only the one it needs and ignores the rest. `Global`
    reads none.

    The base is an argument rather than a field because a `SetBy` supplies one: a scope answers
    *per what?*, and that question is unchanged when a driver replaces the number — "0.5 per
    copy in cave lineages" still multiplies by the copies present.
    """
    if cls.unit is None:
        return base
    try:
        return base * counts[cls.unit]
    except KeyError:
        raise KeyError(
            f"{cls.__name__} needs a {cls.unit!r} count; got {sorted(counts)}"
        ) from None

Global

Bases: Scope

One shared budget for the whole system: the total does not scale with anything.

Global (capitalised — global is a Python keyword) makes a process run at a constant total rate: linear growth, not exponential.

PerLineage

Bases: Scope

Per lineage — the total scales with the number of lineages present.

The default for species birth/death and gene origination. Within a single genome there is one lineage, so this reads as a constant per-genome budget; across the species tree it is base × (lineages alive) (exponential diversification).

PerCopy

Bases: Scope

Per gene copy — the total scales with family/genome size (duplication, transfer, loss).

A large family therefore turns over faster: base × (copies present).

PerSite

Bases: Scope

Per sequence site — the total scales with the number of sites (substitutions).

PerChromosome

Bases: Scope

Per chromosome — the total scales with the number of chromosomes (fission/fusion/loss).

Rates, extents and choices

The three chainable parameters, and the verbs that live on them. A rate is written from its scope, an extent from Extent(...), and a choice from Recipients() — the last two only when a verb is chained onto them, since a bare distribution is already an extent and "uniform" is already a choice.

zombi2.params.parameter

The parameters — the three things you set, and the only things a verb attaches to.

rate    how often an event fires  — written from its scope: ``PerCopy(0.25)``
extent  how much it takes         — ``Extent(Gamma(2.0, 250.0))``: no scope, already absolute
choice  which candidate receives  — ``Recipients()``: no base, only ratios are read

Each opens with its own constructor and takes verbs from there. What differs between them is which verbs are legal, and that is a fact about the parameter rather than about the driver: a rate can be scaled or replaced, an extent only scaled, a choice only weighted. Choice is in choice, with the two topological rules that are written the same way.

RateCompositionError

Bases: TypeError

A composition the grammar itself refuses, with a message written for that mistake.

A TypeError so nothing that catches one stops catching it, and its own class so the written form can tell it from CPython's unsupported operand type(s): the parser re-raises ours verbatim, because the sentence says what to write instead, and answers CPython's generically, because "Rate and float" is about types rather than about the rate.

Rate dataclass

Rate(base: float | None = None, scope: type[Scope] | None = None, modifiers: tuple[Modifier, ...] = ())

base × scope × modifiers, not yet evaluated.

base is None for a rate whose number comes from a driver — PerCopy().set_by(...) — where there is no base to write and the scope still stands.

scaled_by

scaled_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Rate'

Multiply this rate by a factor read from driver — see verbs.scaled_by.

Source code in zombi2/params/parameter.py
def scaled_by(self, driver: object, mapping: object = None, *,
              step: float | None = None) -> "Rate":
    """Multiply this rate by a factor read from ``driver`` — see `verbs.scaled_by`."""
    return self._and(verbs.scaled_by(driver, mapping, step=step))

set_by

set_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Rate'

Replace this rate's base with a number read from driver — see verbs.set_by.

Written first, on a scope with no number in front of it, because everything to its left is a base it would silently discard. That is the one rule, stated here rather than at the two operand positions the retired * needed it at.

Source code in zombi2/params/parameter.py
def set_by(self, driver: object, mapping: object = None, *,
           step: float | None = None) -> "Rate":
    """Replace this rate's base with a number read from ``driver`` — see `verbs.set_by`.

    Written first, on a scope with no number in front of it, because everything to its left is
    a base it would silently discard. That is the one rule, stated here rather than at the two
    operand positions the retired ``*`` needed it at.
    """
    if self.base is not None or self.modifiers:
        raise RateCompositionError(
            f"set_by replaces the base, so it cannot follow one: everything to its left — the "
            f"number, any factors — is a base it would silently discard. Write it first, on the "
            f"bare scope: {self._scope_name()}().set_by(driver, mapping).scaled_by(...). "
            f"Got {self!r}.")
    m = verbs.set_by(driver, mapping, step=step)
    # `set_by(Time(), ...)` builds an `OnTime`, whose schedule holds the rates themselves; that
    # is a base of 1.0 times those factors, which is the same run and needs no new machinery.
    base = None if getattr(m, "replaces_base", False) else 1.0
    return Rate(base, self.scope, (m,))

varying_among

varying_among(among: object = None, law: object = None, **retired: object) -> 'Rate'

Let this rate vary at random among the units of one kind — see verbs.varying_among.

**retired is passed straight through, so the keywords this verb replaced (per=, spread=) are answered by the one table rather than by "unexpected keyword argument".

Source code in zombi2/params/parameter.py
def varying_among(self, among: object = None, law: object = None, **retired: object) -> "Rate":
    """Let this rate vary at random among the units of one kind — see `verbs.varying_among`.

    ``**retired`` is passed straight through, so the keywords this verb replaced (``per=``,
    ``spread=``) are answered by the one table rather than by "unexpected keyword argument"."""
    return self._and(verbs.varying_among(among, law, **retired))

changing_at

changing_at(schedule: object) -> 'Rate'

Let this rate change in time, on a schedule of factors — see verbs.changing_at.

Source code in zombi2/params/parameter.py
def changing_at(self, schedule: object) -> "Rate":
    """Let this rate change in time, on a schedule of factors — see `verbs.changing_at`."""
    return self._and(verbs.changing_at(schedule))

weighted_by

weighted_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Rate'

Refused. Weights are compared against each other and normalised across candidates, which only a choice does — transfer_to is the only one.

Source code in zombi2/params/parameter.py
def weighted_by(self, driver: object, mapping: object = None, *,
                step: float | None = None) -> "Rate":
    """Refused. Weights are compared against each other and normalised across candidates, which
    only a **choice** does — ``transfer_to`` is the only one."""
    raise RateCompositionError(
        "weighted_by weights the candidates of a choice against each other — transfer_to is "
        "the only one, written from Recipients(). On a rate the number multiplies a base, so "
        "the verb is scaled_by: the same driver and the same mapping, read as a factor.")

with_default_scope

with_default_scope(default: type[Scope]) -> 'Rate'

Fill in the level's default scope (per lineage, per copy, …) when none was written.

Source code in zombi2/params/parameter.py
def with_default_scope(self, default: type[Scope]) -> "Rate":
    """Fill in the level's default scope (per lineage, per copy, …) when none was written."""
    if self.scope is not None:
        return self
    return Rate(self.base, default, self.modifiers)

effective

effective(*, carried_factor: float = 1.0, **context: Any) -> float

The rate right now: the scope-applied base times the product of the modifier factors.

context carries the current state (time, diversity, the counts lineages / copies / …); the scope reads the count it needs and each modifier the keys it needs. Requires a scope — resolve a bare-number rate with with_default_scope() first.

carried_factor is the product of the values the engine drew and kept for the unit being evaluated — among lineages, among families (carried_modifiers). Those modifiers are skipped in the loop below, because their number does not come from the context: the engine already holds it and hands it in here, multiplied out. One float rather than a value per modifier, so a rate carrying several costs no more to evaluate than one carrying one.

Source code in zombi2/params/parameter.py
def effective(self, *, carried_factor: float = 1.0, **context: Any) -> float:
    """The rate *right now*: the scope-applied base times the product of the modifier factors.

    ``context`` carries the current state (``time``, ``diversity``, the counts ``lineages`` /
    ``copies`` / …); the scope reads the count it needs and each modifier the keys it needs.
    Requires a scope — resolve a bare-number rate with `with_default_scope()` first.

    ``carried_factor`` is the product of the values the engine drew and kept for the unit being
    evaluated — among lineages, among families (`carried_modifiers`). Those modifiers
    are skipped in the loop below, because their number does not come from the context: the
    engine already holds it and hands it in here, multiplied out. One float rather than a value
    per modifier, so a rate carrying several costs no more to evaluate than one carrying one.
    """
    if self.scope is None:
        raise ValueError("this rate has no scope yet; resolve it with with_default_scope(...)")
    base = self.base
    for m in self.modifiers:
        if getattr(m, "replaces_base", False):
            base = m.factor(**context)   # the driver supplies the number itself, not a factor
    if base is None:
        raise ValueError(
            "this rate has no number: a scope written on its own is only half a rate, and the "
            "set_by that would supply the rest is missing.")
    value = self.scope.total_of(base, **context)
    for m in self.modifiers:
        if getattr(m, "replaces_base", False):
            continue  # already used, as the base
        reads = getattr(m, "reads", None)
        if reads is not None and reads[0] in CARRIED_KINDS:
            continue  # its factor arrives through `carried_factor`, drawn and kept by the engine
        value *= m.factor(**context)
    return value * carried_factor

check_one_base

check_one_base(label: str = 'this rate') -> None

A rate has one base: a number written in front of the scope, or one SetBy supplying it. Two SetBys would each claim to be the base, and no order of application is more right than another, so this raises rather than letting the last one written win in silence; none at all leaves a scope that says per what but not how fast.

Every level coerces through as_rate, which calls this, so the rule cannot be strict in one place and lax in another.

Source code in zombi2/params/parameter.py
def check_one_base(self, label: str = "this rate") -> None:
    """A rate has **one** base: a number written in front of the scope, or one `SetBy` supplying
    it. Two `SetBy`s would each claim to *be* the base, and no order of application is more
    right than another, so this raises rather than letting the last one written win in silence;
    none at all leaves a scope that says per what but not how fast.

    Every level coerces through `as_rate`, which calls this, so the rule cannot be strict in one
    place and lax in another."""
    set_by = [m for m in self.modifiers if getattr(m, "replaces_base", False)]
    if len(set_by) > 1:
        raise ValueError(
            f"{label} carries {len(set_by)} set_by verbs, and a base can only be replaced "
            f"once — each of them claims to be the whole number. Keep one; if you meant to scale "
            f"the result, that is scaled_by, which multiplies and composes freely.")
    if self.base is None and not set_by:
        raise ValueError(
            f"{label} is a scope with no number: {self._scope_name()}() says per what but not "
            f"how fast. Write the number — {self._scope_name()}(0.25) — or the driver that "
            f"supplies it, {self._scope_name()}().set_by(driver, mapping).")
    if any(verbs.written_with(m, verbs.WEIGHTED_BY) for m in self.modifiers):
        raise ValueError(
            f"{label} carries weighted_by, which weights the candidates of a choice against "
            f"each other — transfer_to is the only one. On a rate the number multiplies a base, "
            f"so the verb is scaled_by: the same driver and the same mapping, read as a factor.")

carried_modifiers

carried_modifiers(unit: str | None = None) -> tuple[tuple[Modifier, str], ...]

Every modifier on this rate that reads a value the engine has to draw and carry, paired with the unit it is carried among (see Modifier.reads).

A modifier reading a measured value computes its own factor from the context and needs nothing from the engine. A drawn or inherited one does: its number is produced once when a unit is born, kept for that unit's life, and handed back at every evaluation, and only the engine can do that. This is the one query for finding them, so a level does not have to know which modifier classes exist to thread them.

unit narrows the answer to one kind of unit ("lineages", "families"). The result keeps the order the modifiers were written in, and it keeps all of them — a rate carrying two drawn values answers with two.

Source code in zombi2/params/parameter.py
def carried_modifiers(self, unit: str | None = None) -> tuple[tuple[Modifier, str], ...]:
    """Every modifier on this rate that reads a value the **engine** has to draw and carry,
    paired with the unit it is carried among (see `Modifier.reads`).

    A modifier reading a *measured* value computes its own factor from the context and needs
    nothing from the engine. A *drawn* or *inherited* one does: its number is produced once
    when a unit is born, kept for that unit's life, and handed back at every evaluation, and
    only the engine can do that. This is the one query for finding them, so a level does not
    have to know which modifier classes exist to thread them.

    ``unit`` narrows the answer to one kind of unit (``"lineages"``, ``"families"``). The result
    keeps the order the modifiers were written in, and it keeps **all** of them — a rate
    carrying two drawn values answers with two.
    """
    found = []
    for m in self.modifiers:
        reads = getattr(m, "reads", None)
        if reads is None or reads[0] not in CARRIED_KINDS:
            continue
        if unit is None or reads[1] == unit:
            found.append((m, reads[1]))
    return tuple(found)

next_change

next_change(time: float) -> float

The next time a component of this rate changes on its own — the earliest skyline breakpoint across its modifiers. inf if the rate never changes with time.

Source code in zombi2/params/parameter.py
def next_change(self, time: float) -> float:
    """The next time a component of this rate changes on its own — the earliest skyline
    breakpoint across its modifiers. ``inf`` if the rate never changes with time."""
    nc = math.inf
    for m in self.modifiers:
        nc = min(nc, m.next_change(time))
    return nc

Extent dataclass

Extent(base: Distribution = None, modifiers: tuple[Modifier, ...] = ())

base × modifiers, not yet drawn. Extent(...) is the entry point a verb chains onto; a bare number or distribution is coerced by as_extent().

has_modifiers property

has_modifiers: bool

Whether anything about this extent varies with context. False is the common case, and lets an engine skip building a context it would not read.

Not called is_driven: driven is one of the four modifier kinds (SPEC §5), and this is true of a schedule too, which is measured.

scaled_by

scaled_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Extent'

Multiply the size by a factor read from driver — see verbs.scaled_by.

Source code in zombi2/params/parameter.py
def scaled_by(self, driver: object, mapping: object = None, *,
              step: float | None = None) -> "Extent":
    """Multiply the size by a factor read from ``driver`` — see `verbs.scaled_by`."""
    return self._and(verbs.scaled_by(driver, mapping, step=step))

changing_at

changing_at(schedule: object) -> 'Extent'

Let the size change in time, on a schedule of factors — see verbs.changing_at.

Source code in zombi2/params/parameter.py
def changing_at(self, schedule: object) -> "Extent":
    """Let the size change in time, on a schedule of factors — see `verbs.changing_at`."""
    return self._and(verbs.changing_at(schedule))

varying_among

varying_among(among: object = None, law: object = None, **retired: object) -> 'Extent'

Refused. An extent carries no drawn or inherited value.

Such a value is not computed from the context: it is drawn by the engine when a unit is born, kept for that unit's life, and handed back at every reading — and no level does that for an extent. There is nowhere for it to arrive, either, since sample and mean read their modifiers through factor(), which a carried one deliberately has none of. Every level's gate refuses one, so this only ever built an object whose every read path raised mid-run; the refusal belongs where it is written. It takes the retired keywords too, so per= reaches this sentence rather than a complaint about an unexpected argument — an extent that cannot vary at all is the more useful thing to be told.

Source code in zombi2/params/parameter.py
def varying_among(self, among: object = None, law: object = None,
                  **retired: object) -> "Extent":
    """Refused. An extent carries no drawn or inherited value.

    Such a value is not computed from the context: it is drawn by the engine when a unit is
    born, kept for that unit's life, and handed back at every reading — and no level does that
    for an extent. There is nowhere for it to arrive, either, since `sample` and `mean` read
    their modifiers through ``factor()``, which a carried one deliberately has none of. Every
    level's gate refuses one, so this only ever built an object whose every read path raised
    mid-run; the refusal belongs where it is written. It takes the retired keywords too, so
    ``per=`` reaches this sentence rather than a complaint about an unexpected argument — an
    extent that cannot vary at all is the more useful thing to be told."""
    raise ValueError(
        "an extent cannot vary at random among units: such a value is drawn by the engine when "
        "a unit is born and handed back at each reading, and no level carries one for an "
        "extent — an extent takes scaled_by and changing_at. Two things say what this usually "
        "means — vary the RATE, so events start more often in some units: "
        "PerCopy(0.25).varying_among('families', LogNormal(0.0, 0.5)); or scale the extent by "
        "a driver, so events take more when they do start: "
        "Extent(500).scaled_by(driver, {...}).")

set_by

set_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Extent'

Refused. An extent's base is a distribution over sizes, so one scalar cannot replace it — the replacement would fix every event to the same size, which is not what any of the sizes here mean.

Source code in zombi2/params/parameter.py
def set_by(self, driver: object, mapping: object = None, *,
           step: float | None = None) -> "Extent":
    """Refused. An extent's base is a *distribution* over sizes, so one scalar cannot replace
    it — the replacement would fix every event to the same size, which is not what any of the
    sizes here mean."""
    raise _cannot_be_set_by()

sample

sample(rng, **context: object) -> float

One drawn size, scaled by the modifiers in this context.

Scaling the draw rather than the distribution's parameter is what lets any base work — Fixed, a scipy frozen distribution, a bare callable — while still meaning what it says: the expected size is the base's mean times the factor.

Source code in zombi2/params/parameter.py
def sample(self, rng, **context: object) -> float:
    """One drawn size, scaled by the modifiers in this context.

    Scaling the **draw** rather than the distribution's parameter is what lets any base work —
    ``Fixed``, a scipy frozen distribution, a bare callable — while still meaning what it says:
    the expected size is the base's mean times the factor."""
    return float(self.base.sample(rng)) * self._factor(**context)

mean

mean(**context: object) -> float

The mean size in this context, for an engine parameterised by the mean rather than by a drawn value (the nucleotide one samples an arc's far end from a geometric of this mean).

Requires a Geometric base, which is the only shape that engine supports; scaling its mean is the same statement in expectation as scaling a draw.

Source code in zombi2/params/parameter.py
def mean(self, **context: object) -> float:
    """The mean size in this context, for an engine parameterised by the mean rather than by a
    drawn value (the nucleotide one samples an arc's far end from a geometric of this mean).

    Requires a `Geometric` base, which is the only shape that
    engine supports; scaling its mean is the same statement in expectation as scaling a draw."""
    if not isinstance(self.base, Geometric):
        raise ValueError(
            f"this extent's base is {type(self.base).__name__}, which has no mean to scale — an "
            f"engine parameterised by the mean takes a geometric extent only.")
    return self.base.mean() * self._factor(**context)

check_rate_base

check_rate_base(base: object) -> float

The one gate every rate's number passes, wherever it was written.

Rate calls it from its constructor, and parse.parse_rate calls it for a rate written as a bare number — which is not a Rate and so reaches no constructor. That was the hole: a negative written on a scope (--death "Global(-0.3)") was refused by the parser, where argparse still knows which flag it came from and prints its name, while the same mistake written plainly (--death -0.3) travelled on as a float and was refused much later by the engine, with the flag long gone — so the user was told a rate was negative but not which of the four they had just typed. One function, so both spellings raise the same sentence in the same place.

Source code in zombi2/params/parameter.py
def check_rate_base(base: object) -> float:
    """The one gate every rate's number passes, wherever it was written.

    `Rate` calls it from its constructor, and `parse.parse_rate` calls it for a rate written as a
    **bare number** — which is not a `Rate` and so reaches no constructor. That was the hole: a
    negative written on a scope (``--death "Global(-0.3)"``) was refused by the parser, where
    argparse still knows which flag it came from and prints its name, while the same mistake written
    plainly (``--death -0.3``) travelled on as a float and was refused much later by the engine, with
    the flag long gone — so the user was told a rate was negative but not which of the four they had
    just typed. One function, so both spellings raise the same sentence in the same place.
    """
    if isinstance(base, bool) or not isinstance(base, (int, float)):
        raise TypeError(f"a rate base must be a real number, got {base!r}")
    if not math.isfinite(base) or base < 0:
        raise ValueError(f"a rate base must be finite and non-negative, got {base!r}")
    return float(base)

as_rate

as_rate(spec: object, *, default_scope: type[Scope], label: str = 'this rate') -> Rate

Coerce a user rate spec into a resolved Rate, filling the level's default scope.

Two cases, and there is no third: a bare number, which gets the level's default scope, and an already-built Rate — because a scope constructor returns one of those and so does every verb.

Every level coerces its rates through here, which is why the one-base rule is checked here rather than in each level's own validation: a rule enforced by whoever remembers to call it is a rule three levels did not have.

Source code in zombi2/params/parameter.py
def as_rate(spec: object, *, default_scope: type[Scope], label: str = "this rate") -> Rate:
    """Coerce a user rate spec into a resolved `Rate`, filling the level's default scope.

    Two cases, and there is no third: a bare number, which gets the level's default scope, and an
    already-built ``Rate`` — because a scope constructor returns one of those and so does every
    verb.

    Every level coerces its rates through here, which is why the one-base rule is checked here
    rather than in each level's own validation: a rule enforced by whoever remembers to call it is a
    rule three levels did not have.
    """
    if isinstance(spec, Rate):
        spec.check_one_base(label)
        return spec.with_default_scope(default_scope)
    if isinstance(spec, bool) or not isinstance(spec, (int, float)):
        raise TypeError(
            f"a rate is a number, or a scope with verbs chained onto it — {default_scope.__name__}"
            f"(0.25), PerLineage(0.5).changing_at({{0: 1.0, 3: 0.3}}) — got {spec!r}")
    return Rate(float(spec)).with_default_scope(default_scope)

as_extent

as_extent(spec) -> Extent

Coerce an extent spec (SPEC §6) — a number, a distribution, or an Extent with verbs.

A bare number is the mean, not an exact size: 3 is Geometric(mean=3), so runs vary around three. Write Fixed(3) for exactly three every time. None is Geometric(mean=1), a single unit, the default wherever an extent is optional.

This is where an extent parts company with as_distribution(), where a bare number is a fixed value. The readings differ because the quantities do: a sampled per-family rate given as 0.1 means that rate, whereas an extent given as 500 means runs of about 500 — nobody wants every inversion to be exactly the same size.

A rate is refused. PerLineage(500) asks "per what?", and an extent has no answer: it is already an absolute size.

Source code in zombi2/params/parameter.py
def as_extent(spec) -> Extent:
    """Coerce an extent spec (SPEC §6) — a number, a distribution, or an ``Extent`` with verbs.

    A bare number is the **mean**, not an exact size: ``3`` is ``Geometric(mean=3)``, so runs vary
    around three. Write ``Fixed(3)`` for exactly three every time. ``None`` is ``Geometric(mean=1)``,
    a single unit, the default wherever an extent is optional.

    This is where an extent parts company with `as_distribution()`,
    where a bare number is a *fixed* value. The readings differ because the quantities do: a sampled
    per-family rate given as ``0.1`` means that rate, whereas an extent given as ``500`` means runs of
    about 500 — nobody wants every inversion to be exactly the same size.

    A **rate** is refused. ``PerLineage(500)`` asks "per what?", and an extent has no answer: it is
    already an absolute size.
    """
    from .parameter import Rate

    if isinstance(spec, Extent):
        return spec
    if getattr(spec, "replaces_base", False):
        raise _cannot_be_set_by()
    if isinstance(spec, Rate):
        if any(getattr(m, "replaces_base", False) for m in spec.modifiers):
            raise _cannot_be_set_by()
        raise ValueError(
            "an extent takes no scope — it is already an absolute size, and there is no 'per what?' "
            "to answer (SPEC §6). Write the size alone, or Extent(500).scaled_by(...) when a verb "
            "is chained onto it.")
    return Extent(spec)

zombi2.params.choice

The choice — which candidate receives, rather than how often or how much (SPEC §5).

A rate says how often an event fires and an extent says how much it takes. A choice says who gets it, and transfer_to is the only one. Its numbers are per-candidate weights, compared against each other and normalised across the contemporaneous lineages, so they change neither how fast nor how many transfers happen — only which lineage receives.

Four rules, and the two written here as classes are the topological ones: they read a fact about the species tree rather than a value another level evolved.

  • "uniform" — every contemporaneous lineage equally likely;
  • Distance — closer relatives likelier, in units of tree depth;
  • Clades — weight by the pair (donor's named clade, recipient's named clade);
  • Recipients().weighted_by(driver, mapping) — weight by another level, which is the rest of the grammar.

They live beside the rate grammar and not with the transfer engine because they are things a user writes, and everything a user writes belongs to one vocabulary: the same expression has to read in Python, on the command line and in a --params file (SPEC §5, one written form). While they sat at the genome level the parser could not see them, so Distance(decay=…) was Python-only and a non-default decay could not be typed into a flag at all. Turning the tree into group membership still belongs to the engine — that needs a tree, which a written rule does not.

Choice dataclass

Choice(weights: tuple[Driven, ...] = ())

Which candidate receives — the parameter Recipients() opens and weighted_by fills in.

A choice has no base: only the ratios between candidates are read, so there is nothing to write in front of the first verb. Recipients() on its own is the uniform rule, every contemporaneous lineage equally likely, which is what a choice with no weights says.

weighted_by

weighted_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Choice'

Weight the candidates by driver — see verbs.weighted_by.

Weights multiply and are then normalised across the candidates, so chaining two is meaningful: prefer close relatives and run a highway between two distant clades. Whether a given engine reads more than one is that level's declaration.

Source code in zombi2/params/choice.py
def weighted_by(self, driver: object, mapping: object = None, *,
                step: float | None = None) -> "Choice":
    """Weight the candidates by ``driver`` — see `verbs.weighted_by`.

    Weights **multiply and are then normalised across the candidates**, so chaining two is
    meaningful: prefer close relatives *and* run a highway between two distant clades. Whether a
    given engine reads more than one is that level's declaration.
    """
    return Choice(self.weights + (verbs.weighted_by(driver, mapping, step=step),))

scaled_by

scaled_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Choice'

Refused. A choice has no base to scale.

Source code in zombi2/params/choice.py
def scaled_by(self, driver: object, mapping: object = None, *,
              step: float | None = None) -> "Choice":
    """Refused. A choice has no base to scale."""
    from .parameter import RateCompositionError
    raise RateCompositionError(
        "a choice has no base to scale: its numbers are weights, compared against each other "
        "and normalised across the candidates. The verb is weighted_by — the same driver and "
        "the same mapping, read as a weight.")

set_by

set_by(driver: object, mapping: object = None, *, step: float | None = None) -> 'Choice'

Refused. A choice has no base to replace.

Source code in zombi2/params/choice.py
def set_by(self, driver: object, mapping: object = None, *,
           step: float | None = None) -> "Choice":
    """Refused. A choice has no base to replace."""
    from .parameter import RateCompositionError
    raise RateCompositionError(
        "a choice has no base to replace: its numbers are weights, compared against each other "
        "and normalised across the candidates. The verb is weighted_by.")

Distance dataclass

Distance(decay: float = 1.0)

A transfer_to weighting by relatedness: a recipient at patristic distance d from the donor gets weight exp(-decay × d / depth), where depth is the tree's mean root-to-tip time — so decay is scale-free (in units of tree depth), meaning the same across trees of different absolute timescales. transfer_to="distance" is Distance(decay=1.0).

Clades dataclass

Clades(groups: dict, between: object)

A transfer_to weighting by named clades — the topological, donor-conditioned sibling of Distance. Each group is a clade of the species tree, and a Between kernel weights a candidate recipient by the pair (donor's clade, recipient's clade), so a transfer can be steered to run between two clades rather than within them — which the per-recipient weight of a Driven cannot express::

transfer_to = Clades({"A": ["n12", "n27"], "B": 40},
                     Between({("A", "B"): 1.0, ("B", "A"): 1.0}, default=0.0))

A clade is named either by a set of tips (a list — the clade is the subtree below their MRCA) or by a single node id (an int, or an "n<id>" label — the clade is that node's whole subtree). Groups must be disjoint; a lineage in none of them is in the implicit group "rest", usable as a kernel key. Membership is read from the tree (a clade is a fact about the tree, not another level), so this is a topological rule like "distance", resolved once per run — not a weighted_by reading another level, and needing no driver file.

Recipients

Recipients() -> Choice

Open a transfer_to rule: the candidates that could receive a transfer::

transfer_to = Recipients().weighted_by(competence, {"competent": 3.0, "normal": 1.0})

On its own it is the uniform rule. It is a function rather than a class for the same reason a scope constructor returns a rate: what you get back is the parameter, ready for a verb.

Source code in zombi2/params/choice.py
def Recipients() -> Choice:
    """Open a ``transfer_to`` rule: the candidates that could receive a transfer::

        transfer_to = Recipients().weighted_by(competence, {"competent": 3.0, "normal": 1.0})

    On its own it is the uniform rule. It is a function rather than a class for the same reason a
    scope constructor returns a rate: what you get back is the parameter, ready for a verb.
    """
    return Choice()

Modifiers

A modifier's kind says who produces its number, and there are four:

Kind The factor is… Written
covariate a deterministic function of a measured quantity changing_at({…}), scaled_by(TotalDiversity(cap=…))
drawn an i.i.d. draw, one per unit — no memory varying_among(unit, dist)
inherited the parent's, perturbed — continuous memory varying_among(unit, Drift(dist))
driven the state of another simulated thing, taken as the run walks the tree scaled_by, set_by, weighted_by

A driven value comes from a level grown before this run, a level growing beside it, another object at the same level (a trait can drive a second trait), or the tree itself (Clade). Which of the three verbs you write follows from what you attach it to: on a rate the number multiplies the base (scaled_by) or replaces it (set_by); on an extent it multiplies only, an extent being an absolute size with no base to replace. On transfer_to it is a weight normalised across the candidates (weighted_by), which is why that one is written from Recipients() with no base — transfer_to = PerLineage(1.0).weighted_by(...) is an error.

The unit a drawn or inherited value is attached to is an argument, not a class, so a draw among families and a draw among lineages are one class and two cells of a grid. The unit is plural, because a value varies among families rather than being counted per one.

Writing your own

Every engine takes a fixed set of modifiers and refuses the rest, because one it never reads would return its default factor of 1.0 and give a run that is quietly not the model you asked for. A modifier of your own opens that gate by naming the engines you implemented it for:

from zombi2 import species
from zombi2.params import PerLineage
from zombi2.params.evaluate import Modifier
from zombi2.params.parameter import Rate

class OnLogTime(Modifier):
    implemented_for = ("species",)
    def factor(self, *, time: float = 0.0, **_) -> float:
        return 1.0 / (1.0 + time)

birth = Rate(2.0, PerLineage, (OnLogTime(),))
species.simulate_species_tree(birth=birth, n_extant=20, seed=1)

The verbs build the built-in modifiers, so there is no verb that attaches one of yours; a rate carrying it is built from Rate directly, with the scope class and the modifiers in the order they should be drawn in.

Each engine supplies a different context, and sequences does not take a modifier of your own at all — it reads its modifiers itself rather than through the rate, so one it did not ship could never be called, and it refuses rather than ignoring you:

Engine Context passed to factor
species time, lineages, diversity
genomes.family time, lineages, copies, drivers
genomes.ordered time, lineages, copies, chromosomes, drivers
genomes.nucleotide time, lineages, copies, chromosomes, drivers
traits.continuous time, lineages, diversity, drivers
traits.discrete time, lineages, drivers
joint time, lineages, diversity, drivers

Take **_ and default every keyword you read. Naming an engine is a claim you are making, not a check the library can do for you — everything you have not named still refuses your modifier, by name.

If your factor varies continuously with time, override next_change to return the next point at which it should be re-evaluated. The engine holds a rate constant between events, so without it the curve is frozen at whatever it was when the last event fired.

The rate text grammar (a --birth flag, a --params file) knows only the built-in names, so a modifier of your own is Python-only, as an object you construct has to be.

A worked exampleOnCrowding, a death rate that rises as the tree fills — is in Appendix A, "Writing your own", with the two things a modifier of your own has to provide and the one it may.

zombi2.params.evaluate

What an engine calls, and the base every built modifier shares.

A user writes a parameter and chains verbs onto it (parameter, connection). What those verbs build is a Modifier, and what an engine does with one is here: draw and carry its value per unit, ask what it reads, ask whether this level supports it, and name it in a message.

The split is by audience rather than by subject. Nothing here is a thing anyone writes; everything here is something an engine calls — which is why this module can be read without knowing the grammar, and the grammar can be read without knowing this module.

Modifier

Base for rate modifiers.

A modifier reads the context keys it cares about (time, lineages, diversity, copies, chromosomes, drivers, …) and returns a dimensionless, non-negative multiplier; it ignores the rest. Abstract — use a subclass, and write a verb rather than a subclass.

draw

draw(rng) -> float

One value for a newly created unit — what a modifier reading a DRAWN value provides.

A modifier reading an INHERITED value implements initial and descend instead, because a daughter's number starts from its parent's rather than from nothing. Everything else needs neither, so the default says so rather than returning a plausible 1.0.

Source code in zombi2/params/evaluate.py
def draw(self, rng) -> float:
    """One value for a newly created unit — what a modifier reading a `DRAWN` value provides.

    A modifier reading an `INHERITED` value implements `initial` and `descend` instead, because a
    daughter's number starts from its parent's rather than from nothing. Everything else needs
    neither, so the default says so rather than returning a plausible 1.0."""
    raise NotImplementedError(
        f"{type(self).__name__} does not draw a value per unit; it reads {self.reads!r}")

initial

initial() -> float

The value a root unit starts with, for a modifier reading an INHERITED value — where the walk down the tree begins. A DRAWN one has draw instead.

Source code in zombi2/params/evaluate.py
def initial(self) -> float:
    """The value a **root** unit starts with, for a modifier reading an `INHERITED` value —
    where the walk down the tree begins. A `DRAWN` one has `draw` instead."""
    raise NotImplementedError(
        f"{type(self).__name__} does not inherit a value per unit; it reads {self.reads!r}")

descend

descend(parent_value: float, rng) -> float

A daughter's value from its parent's, for a modifier reading an INHERITED value. This is the whole autocorrelated / uncorrelated split: an inherited value starts here, a drawn one ignores its parent entirely.

Source code in zombi2/params/evaluate.py
def descend(self, parent_value: float, rng) -> float:
    """A daughter's value from its parent's, for a modifier reading an `INHERITED` value. This is
    the whole autocorrelated / uncorrelated split: an inherited value starts here, a drawn one
    ignores its parent entirely."""
    raise NotImplementedError(
        f"{type(self).__name__} does not inherit a value per unit; it reads {self.reads!r}")

next_change

next_change(time: float) -> float

The next time strictly after time at which this modifier's factor changes on its own — a skyline breakpoint. inf if it never changes with time (the default; most modifiers change only at events, not autonomously).

Source code in zombi2/params/evaluate.py
def next_change(self, time: float) -> float:
    """The next time strictly after ``time`` at which this modifier's factor changes on
    its own — a skyline breakpoint. ``inf`` if it never changes with time (the default;
    most modifiers change only at events, not autonomously)."""
    return math.inf

written_call

written_call() -> str

How this modifier is written as a verb call on a parameter — scaled_by(...), varying_among(...), changing_at(...). Rate.__repr__ joins these onto the scope to render the whole expression, so this is the one place each connection says how it is typed.

A modifier of someone else's cannot be written at all — the text grammar whitelists names and knows only the built-in ones — so the default is a placeholder that fails loudly if pasted back, rather than an expression that looks reproducible and is not.

The placeholder is built from the class name, and never from repr(self): __repr__ below calls this, so a subclass overriding neither — which is exactly the third-party modifier implemented_for invites — sent the two into each other, and every log line, every --params record and every error message that named the rate died of a RecursionError while the run itself carried on fine. It is the same shape _driver_form uses for a driver that cannot be written, for the same reason.

Source code in zombi2/params/evaluate.py
def written_call(self) -> str:
    """How this modifier is written as a **verb call** on a parameter — ``scaled_by(...)``,
    ``varying_among(...)``, ``changing_at(...)``. `Rate.__repr__` joins these onto the scope to
    render the whole expression, so this is the one place each connection says how it is typed.

    A modifier of someone else's cannot be written at all — the text grammar whitelists names
    and knows only the built-in ones — so the default is a placeholder that fails loudly if
    pasted back, rather than an expression that looks reproducible and is not.

    The placeholder is built from the **class name**, and never from ``repr(self)``: `__repr__`
    below calls this, so a subclass overriding neither — which is exactly the third-party
    modifier `implemented_for` invites — sent the two into each other, and every log line, every
    ``--params`` record and every error message that named the rate died of a RecursionError
    while the run itself carried on fine. It is the same shape `_driver_form` uses for a driver
    that cannot be written, for the same reason."""
    return f"<{type(self).__name__}>"

values_at_birth

values_at_birth(mods: 'tuple[Modifier, ...]', rng, shared: 'dict[int, float] | None' = None) -> tuple[float, ...]

The value a newly created unit carries, one per modifier, in written order.

An INHERITED value starts from its own beginning (Inherited.initial); a DRAWN one is drawn. The dispatch reads Modifier.reads, not the class, so a carried modifier an engine has never heard of is drawn like the ones it has. Drawing in written order is what keeps a run reproducible, and drawing from every modifier is the point: taking only the first was how a second one silently left the model.

shared makes one value shared between the rates of a single unit. It is a cache keyed by modifier identity: pass the same dict while producing each of that unit's rates, and a modifier written on two of them is drawn once and both rates get the same number. That is how "a family that loses fast also duplicates fast" is said — one object, read twice — against "fast at losing only", which is two objects. Two modifiers that merely compare equal are still two values, because the question is whether you wrote one thing or two. Omit the cache and each draws for itself.

Callers wanting the combined factor take math.prod of the result; a unit that never splits (a gene family) needs only that, while one that does keeps the values apart, because an inherited value has to perturb its parent's own number rather than a product.

Source code in zombi2/params/evaluate.py
def values_at_birth(mods: "tuple[Modifier, ...]", rng,
                    shared: "dict[int, float] | None" = None) -> tuple[float, ...]:
    """The value a newly created unit carries, one per modifier, in written order.

    An `INHERITED` value starts from its own beginning (`Inherited.initial`); a `DRAWN` one is drawn.
    The dispatch reads `Modifier.reads`, not the class, so a carried modifier an engine has never
    heard of is drawn like the ones it has. Drawing in written order is what keeps a run
    reproducible, and drawing from **every** modifier is the point: taking only the first was how a
    second one silently left the model.

    ``shared`` makes one value shared between the rates of a **single unit**. It is a cache keyed by
    modifier identity: pass the same dict while producing each of that unit's rates, and a modifier
    written on two of them is drawn once and both rates get the same number. That is how "a family
    that loses fast also duplicates fast" is said — one object, read twice — against "fast at losing
    only", which is two objects. Two modifiers that merely compare equal are still two values,
    because the question is whether you wrote one thing or two. Omit the cache and each draws for
    itself.

    Callers wanting the combined factor take ``math.prod`` of the result; a unit that never splits
    (a gene family) needs only that, while one that does keeps the values apart, because an
    inherited value has to perturb its parent's own number rather than a product."""
    out = []
    for m in mods:
        key = id(m)
        if shared is None or key not in shared:
            value = m.initial() if m.reads and m.reads[0] == INHERITED else m.draw(rng)
            if shared is None:
                out.append(value)
                continue
            shared[key] = value
        out.append(shared[key])
    return tuple(out)

values_at_split

values_at_split(mods: 'tuple[Modifier, ...]', parent_values: tuple[float, ...], rng, shared: 'dict[int, float] | None' = None) -> tuple[float, ...]

A daughter's carried values: its parent's, perturbed (INHERITED), or a fresh independent draw that ignores the parent (DRAWN). That one line is the whole autocorrelated / uncorrelated split (SPEC §5). shared works as in values_at_birth.

Source code in zombi2/params/evaluate.py
def values_at_split(mods: "tuple[Modifier, ...]", parent_values: tuple[float, ...], rng,
                    shared: "dict[int, float] | None" = None) -> tuple[float, ...]:
    """A daughter's carried values: its parent's, perturbed (`INHERITED`), or a fresh independent
    draw that ignores the parent (`DRAWN`). That one line is the whole autocorrelated / uncorrelated
    split (SPEC §5). ``shared`` works as in `values_at_birth`."""
    out = []
    for i, m in enumerate(mods):
        key = id(m)
        if shared is None or key not in shared:
            value = (m.descend(parent_values[i], rng)
                     if m.reads and m.reads[0] == INHERITED else m.draw(rng))
            if shared is None:
                out.append(value)
                continue
            shared[key] = value
        out.append(shared[key])
    return tuple(out)

check_one_memory

check_one_memory(mods: 'tuple[Modifier, ...]', *, label: str, unit: str) -> None

SPEC §5's one memory structure per axis: a value on one unit is either drawn afresh each time (no memory) or inherited and perturbed (continuous memory), and those are two accounts of the same thing rather than a composition.

So mixing the two kinds on one unit raises. Several of the same kind do not: two drawn factors multiply to one drawn factor, which is an ordinary composition and is what modifiers do. Every level calls this rather than writing its own count, so the rule cannot be strict in one place and lax in another — it used to be three different rules in three engines.

Source code in zombi2/params/evaluate.py
def check_one_memory(mods: "tuple[Modifier, ...]", *, label: str, unit: str) -> None:
    """SPEC §5's **one memory structure per axis**: a value on one unit is either drawn afresh each
    time (no memory) or inherited and perturbed (continuous memory), and those are two accounts of
    the same thing rather than a composition.

    So mixing the two kinds on one unit raises. Several of the **same** kind do not: two drawn
    factors multiply to one drawn factor, which is an ordinary composition and is what modifiers do.
    Every level calls this rather than writing its own count, so the rule cannot be strict in one
    place and lax in another — it used to be three different rules in three engines."""
    kinds = {m.reads[0] for m in mods if m.reads}
    if DRAWN in kinds and INHERITED in kinds:
        names = ", ".join(sorted(describe(m) for m in mods))
        raise ValueError(
            f"{label} carries both a drawn and an inherited value among {unit} ({names}), which are "
            f"the two answers to the same question — where that unit's factor comes from. An "
            f"inherited one starts from its parent's and is perturbed (autocorrelated); a drawn one "
            f"starts afresh with no memory of the parent (uncorrelated). Pick one — a law is either "
            f"a bare distribution or a Drift, never both. Several of the same kind are fine and "
            f"multiply.")

cell_name

cell_name(entry) -> str

What to call one entry of a level's IMPLEMENTED_MODIFIERS in a message — how that class is written, or how a (kind, unit) cell is written. Shared so an error and the CLI's help cannot describe the same declaration two different ways.

Every entry is named by the expression that writes it, with ... where the argument the user chooses goes: varying_among('families', ...), scaled_by(TotalDiversity(...)). A declaration is a promise about what you may write, and this list is what an engine's refusal and zombi2 <command> -h both print, so a name nobody can type sends the reader to a syntax error. The earlier wording for a cell, drawn among families, did exactly that.

One verb writes both cells and the law is what differs: a bare distribution is drawn afresh for each unit, a Drift starts from the parent's value (SPEC §5). Only those two kinds reach here, because a cell is the grain for exactly the carried ones (CARRIED_KINDS) and everything else is declared by class.

Source code in zombi2/params/evaluate.py
def cell_name(entry) -> str:
    """What to call one entry of a level's ``IMPLEMENTED_MODIFIERS`` in a message — how that class is
    written, or how a ``(kind, unit)`` cell is written. Shared so an error and the CLI's help cannot
    describe the same declaration two different ways.

    Every entry is named by the **expression that writes it**, with ``...`` where the argument the
    user chooses goes: ``varying_among('families', ...)``, ``scaled_by(TotalDiversity(...))``. A
    declaration is a promise about what you may write, and this list is what an engine's refusal and
    ``zombi2 <command> -h`` both print, so a name nobody can type sends the reader to a syntax error.
    The earlier wording for a cell, ``drawn among families``, did exactly that.

    One verb writes both cells and the **law** is what differs: a bare distribution is drawn afresh
    for each unit, a `Drift` starts from the parent's value (SPEC §5). Only those two kinds reach
    here, because a cell is the grain for exactly the carried ones (`CARRIED_KINDS`) and everything
    else is declared by class."""
    if not isinstance(entry, tuple):
        return _WRITTEN_AS.get(entry, entry.__name__)
    kind, unit = entry
    return f"{VARYING_AMONG}({unit!r}, {'Drift(...)' if kind == INHERITED else '...'})"

describe

describe(m: 'Modifier') -> str

What to call one modifier instance in a message.

A carried value covers a whole row of the grid and is named by its cell — varying_among('families', ...), varying_among('lineages', Drift(...)) — because "carries a Random" would be true and useless when the whole question is among what, and the law is what separates the two. Anything else is named by the verb that built it. Either way the name is the spelling that writes it, so a refusal and the list of what the level does take are in one vocabulary.

Carried-ness is read off Modifier.reads rather than off the two classes that have it, so this module needs no import from law — which is what lets law import this one.

Source code in zombi2/params/evaluate.py
def describe(m: "Modifier") -> str:
    """What to call one modifier **instance** in a message.

    A **carried** value covers a whole row of the grid and is named by its cell —
    ``varying_among('families', ...)``, ``varying_among('lineages', Drift(...))`` — because "carries
    a Random" would be true and useless when the whole question is *among what*, and the law is what
    separates the two. Anything else is named by the **verb** that built it. Either way the name is
    the spelling that writes it, so a refusal and the list of what the level does take are in one
    vocabulary.

    Carried-ness is read off `Modifier.reads` rather than off the two classes that have
    it, so this module needs no import from `law` — which is what lets `law` import
    this one."""
    reads = getattr(m, "reads", None)
    if reads is not None and reads[0] in CARRIED_KINDS:
        return cell_name(reads)
    verb = getattr(m, "verb", None)
    if verb is not None:
        return verb
    return _WRITTEN_AS.get(type(m), type(m).__name__)

is_implemented

is_implemented(m: 'Modifier', engines: tuple, engine: str) -> bool

Whether engine may run modifier m: it matches one entry of that level's IMPLEMENTED_MODIFIERS, or it names that engine in its own Modifier.implemented_for. Every engine gate goes through here, so the escape hatch cannot be honoured in one level and forgotten in another.

An entry is a class or a cell. A class is the right grain for OnTime against OnTotalDiversity: both read a measured value on the run, yet an engine can thread a schedule's breakpoints without threading the standing diversity, so the two are separately declarable. A cell — (DRAWN, "families") — is the right grain for Drawn and Inherited, which cover every unit, where what an engine supports is the unit it can carry a number for.

Source code in zombi2/params/evaluate.py
def is_implemented(m: "Modifier", engines: tuple, engine: str) -> bool:
    """Whether ``engine`` may run modifier ``m``: it matches one entry of that level's
    ``IMPLEMENTED_MODIFIERS``, or it names that engine in its own `Modifier.implemented_for`. Every
    engine gate goes through here, so the escape hatch cannot be honoured in one level and forgotten
    in another.

    An entry is **a class** or **a cell**. A class is the right grain for `OnTime` against
    `OnTotalDiversity`: both read a measured value on the run, yet an engine can thread a schedule's
    breakpoints without threading the standing diversity, so the two are separately declarable. A
    cell — ``(DRAWN, "families")`` — is the right grain for `Drawn` and `Inherited`, which cover
    every unit, where what an engine supports is the *unit* it can carry a number for."""
    if matches_declared(m, engines):
        return True
    if engine not in getattr(m, "implemented_for", ()):
        return False
    # The hatch lets a modifier of your own vouch for itself, and it can — for a factor it *computes*
    # from the context, which is a promise only the modifier has to keep. It cannot vouch for a
    # **carried** value: that number has to be drawn when a unit is born, kept, and handed back, and
    # only the engine can do those, for the units it declares. Accepting one on a unit the level does
    # not carry would draw nothing and skip its factor, so the rate would run undriven in silence —
    # the exact failure this whole gate exists to prevent, so the hatch stops here.
    # A `SetBy` is refused here for the same reason wearing a different hat: replacing a base is a
    # capability an engine has or has not, and only three declare it. A subclass of `SetBy` vouching
    # for itself would be admitted at the four that cannot honour one.
    reads = getattr(m, "reads", None)
    return not (getattr(m, "replaces_base", False)
                or (reads is not None and reads[0] in CARRIED_KINDS))

matches_declared

matches_declared(m: 'Modifier', entries: tuple) -> bool

Whether m is one of the entries a level declares — without the third-party escape hatch of Modifier.implemented_for.

The sequences level needs this rather than is_implemented, and the reason is worth keeping: it is the one engine that reads its modifiers itself instead of evaluating them through Rate.effective, because its clock is drawn per lineage before any site evolves. A modifier of someone else's could therefore be accepted by the hatch and then never called, which is exactly the silence the whole declaration mechanism exists to prevent.

Source code in zombi2/params/evaluate.py
def matches_declared(m: "Modifier", entries: tuple) -> bool:
    """Whether ``m`` is one of the entries a level declares — **without** the third-party escape
    hatch of `Modifier.implemented_for`.

    The sequences level needs this rather than `is_implemented`, and the reason is worth keeping: it
    is the one engine that reads its modifiers itself instead of evaluating them through
    `Rate.effective`, because its clock is drawn per lineage before any site
    evolves. A modifier of someone else's could therefore be accepted by the hatch and then never
    called, which is exactly the silence the whole declaration mechanism exists to prevent."""
    for entry in entries:
        if isinstance(entry, tuple):
            if m.reads == entry:
                return True
        elif getattr(m, "replaces_base", False) or getattr(entry, "replaces_base", False):
            # A `SetBy` is a `Driven`, so a plain isinstance would let it in wherever a driver is
            # allowed — and replacing a base is a capability an engine has or has not, which
            # Driven's declaration says nothing about. Four levels admitted it that way and then
            # could not honour it: three overwrote the base in a loop so the last one written won,
            # and the sequence level multiplied them together. Match it by exact type instead, so a
            # level has to name `SetBy` to accept one.
            if type(m) is entry:
                return True
        elif isinstance(m, entry):
            return True
    return False

Mappings

What a driven parameter carries — the shape that turns the driver's value into a number.

zombi2.params.mapping

The mapping of a Driven — what turns the driver's value into a number (SPEC §5).

A verb reads the driver's value on a lineage; the mapping turns that value into the number the verb contributes — a dimensionless multiplier on a rate or an extent (scaled_by), the rate itself (set_by), a normalised weight on transfer_to (weighted_by). There are four shapes:

  • Table — a discrete driver → a dict of factors: {"aquatic": 3.0, "terrestrial": 1.0}.
  • Curve — a continuous driver → a function: lambda x: math.exp(0.5 * x).
  • Scalar — a single log-link coefficient: multiplier = exp(strength · value).
  • Between — a weight per (donor group, recipient group) pair, which only transfer_to takes: it reads the driver at both ends, so a rate or an extent refuses it (check_not_a_kernel()).

You rarely name the first three — pass a raw dict / callable / number as mapping= and as_mapping() coerces it (a dict → Table, a callable → Curve, a number → Scalar), exactly as as_rate() coerces a rate spec.

Jump (a burst fired at an event, e.g. a pulse of gene change at each split) is not a mapping: it changes a state at a moment rather than scaling a number, so it does not live here and is not reachable through any verb (SPEC §5).

Mapping

Base for a driver-value → factor mapping. Abstract — use Table, Curve, or Scalar (or pass a raw dict / callable / number, which as_mapping() coerces). A mapping returns a dimensionless, non-negative factor — a multiplier on a rate or an extent, a weight on transfer_to.

next_change

next_change(time: float) -> float

The next time strictly after time at which this mapping's factor changes on its own. inf unless some entry is a Schedule — a mapping reads a driver, and a driver's own switches are the engine's business, not the mapping's.

Source code in zombi2/params/mapping.py
def next_change(self, time: float) -> float:
    """The next time strictly after ``time`` at which this mapping's factor changes on its own.
    ``inf`` unless some entry is a `Schedule` — a mapping reads a driver, and a driver's own
    switches are the engine's business, not the mapping's."""
    return math.inf

Schedule

Schedule(spec)

One factor that changes with time — a Table entry written as a schedule::

Table({"endo": {0: 1.0, 6.0: 20.0}, "rest": 1.0})

reads: the endo group's factor is 1 until t=6 and 20 from then on, while rest is 1 throughout. It is the one way to say this driver state, but only after t. Chaining two verbs cannot: scaled_by(clade, {...}).changing_at({...}) multiplies two factors that each apply to every lineage, so the time window would fall on the whole tree rather than on the clade.

The notation is changing_at's, and it means the same thing — a factor from each breakpoint on, the earliest one applying before the first. The breakpoints reach the engine's horizon through Table.next_change, so a Gillespie loop steps to them rather than past them.

Source code in zombi2/params/mapping.py
def __init__(self, spec) -> None:
    self.steps = _steps_from(spec, "Schedule")

Table

Table(per_state, default: float = 1.0)

Bases: Mapping

A discrete driver → a lookup of factors, one per driver state::

Table({"aquatic": 3.0, "terrestrial": 1.0})   # 3× the rate in aquatic lineages

default (1.0) is the factor for any state not named — so an unlisted state leaves the rate unchanged. This is the primary scaled_by mapping (MuSSE-style per-state rates).

States are matched by their string formTable({0: 3.0, 1: 1.0}) and Table({"0": 3.0, "1": 1.0}) behave identically, and both match a driver whose value is 0 or "0". A conditioned driver arrives from a text file (always a string), and a live joint driver arrives as its native label; string-matching makes the two agree, so an int-labelled trait does not silently miss its mapping.

Source code in zombi2/params/mapping.py
def __init__(self, per_state, default: float = 1.0) -> None:
    if not isinstance(per_state, dict) or not per_state:
        raise ValueError(f"Table needs a non-empty {{state: factor}} dict, got {per_state!r}")
    table = {}
    for state, factor in per_state.items():
        key = str(state)  # states matched by string form (a driver file is text); see the class docstring
        if key in table:
            raise ValueError(
                f"Table states collide as strings: {state!r} and an earlier key both map to {key!r}"
            )
        # a dict entry is a time schedule for that state — the one way to write "this driver
        # state, but only after t" (`Schedule`). Anything else is a plain factor.
        table[key] = _as_entry(factor, f"Table factor for {state!r}")
    self.per_state = table
    self.default = _as_entry(default, "Table default")

next_change

next_change(time: float) -> float

The earliest breakpoint across this table's scheduled entries — every state's, not only the one in force, because the engine sets one horizon for the whole live set and a lineage in another state must not be stepped past its own switch.

Source code in zombi2/params/mapping.py
def next_change(self, time: float) -> float:
    """The earliest breakpoint across this table's scheduled entries — every state's, not only
    the one in force, because the engine sets one horizon for the whole live set and a lineage
    in another state must not be stepped past its own switch."""
    nc = math.inf
    for f in (*self.per_state.values(), self.default):
        if isinstance(f, Schedule):
            nc = min(nc, f.next_change(time))
    return nc

Curve

Curve(fn, bound: float | None = None)

Bases: Mapping

A continuous driver → an arbitrary function of the value, optionally capped::

Curve(lambda x: math.exp(0.5 * x))          # exponential response
Curve(lambda x: 1.0 + x, bound=5.0)          # linear, capped at 5×

bound (a ceiling on the factor) is what an exact Gillespie thinner needs when the driver is unbounded; omit it for a naturally-bounded fn. The function must return a finite, non-negative number for every driver value it sees (a rate cannot go negative).

Source code in zombi2/params/mapping.py
def __init__(self, fn, bound: float | None = None) -> None:
    if not callable(fn):
        raise TypeError(f"Curve needs a callable value→factor function, got {fn!r}")
    if bound is not None:
        if isinstance(bound, bool) or not isinstance(bound, (int, float)) \
                or not math.isfinite(bound) or bound < 0:
            raise ValueError(f"Curve bound must be a finite non-negative number, got {bound!r}")
        bound = float(bound)
    self.fn = fn
    self.bound = bound

Scalar

Scalar(strength: float)

Bases: Mapping

A single log-link coefficient — multiplier = exp(strength · value)::

Scalar(0.0)    # null: factor 1 for every value
Scalar(0.7)    # a binary 0/1 driver gives factor 1 (off) or exp(0.7) ≈ 2.0 (on)

The natural response when the driver is already a 0/1 indicator or a single continuous covariate: one knob, strength (0 ⇒ the driver does not change the rate). The exponent is clamped so a large value cannot overflow.

Source code in zombi2/params/mapping.py
def __init__(self, strength: float) -> None:
    if isinstance(strength, bool) or not isinstance(strength, (int, float)) \
            or not math.isfinite(strength):
        raise ValueError(f"Scalar strength must be a finite number, got {strength!r}")
    self.strength = float(strength)

Between

Between(per_pair, default: float = 1.0)

A weight over ordered (donor-group, recipient-group) pairs — the 2-D kernel of the transfer choice of who receives (SPEC §5), the donor-conditioned sibling of Table::

Between({("A", "B"): 1.0, ("B", "A"): 1.0}, default=0.0)   # A↔B only, nothing else receives
Between({("A", "B"): 3.0})                                 # A→B 3× baseline, every other pair 1×

A Table weights a candidate recipient by that candidate's state alone; a Between weights it by the pair — the donor's group and the recipient's — which is what lets a transfer be steered to run between two groups rather than within them. It is therefore not a Mapping (a Mapping.multiplier reads one value): its weight() reads two, and the engine passes both. It is used in transfer_to — on its own as the kernel of a Clades rule (groups from the tree), or as the mapping of a Recipients().weighted_by(...) (groups from a trait). It is not a rate multiplier: a rate has no donor to condition on, so a Between on a rate is refused.

Keys are (from_group, to_group) pairs matched by string form, exactly like Table's states, so an integer-labelled group still finds its entry. default (1.0) is the weight for any pair not named — default=0.0 gives the "only the flows I name can happen" idiom, reusing the rule that a weight of 0 means the donor cannot send to that recipient group; when every candidate weighs 0 the transfer has nowhere to land and does not fire.

Source code in zombi2/params/mapping.py
def __init__(self, per_pair, default: float = 1.0) -> None:
    if not isinstance(per_pair, dict) or not per_pair:
        raise ValueError(
            f"Between needs a non-empty {{(from_group, to_group): weight}} dict, got {per_pair!r}")
    table: dict[tuple[str, str], float] = {}
    for pair, weight in per_pair.items():
        if not (isinstance(pair, tuple) and len(pair) == 2):
            raise ValueError(
                f"Between keys are (from_group, to_group) pairs, got {pair!r} — write "
                f"Between({{('A', 'B'): 1.0}}), the donor group first, the recipient group second")
        key = (str(pair[0]), str(pair[1]))  # groups matched by string form, like Table's states
        if key in table:
            raise ValueError(
                f"Between pairs collide as strings: {pair!r} and an earlier key both map to {key!r}")
        table[key] = _check_factor(weight, f"Between weight for {pair!r}")
    self.per_pair = table
    self.default = _check_factor(default, "Between default")

weight

weight(from_group: object, to_group: object) -> float

The weight for a transfer from a from_group donor to a to_group recipient — the named pair's weight, or default if the pair is unnamed.

Source code in zombi2/params/mapping.py
def weight(self, from_group: object, to_group: object) -> float:
    """The weight for a transfer from a ``from_group`` donor to a ``to_group`` recipient — the
    named pair's weight, or `default` if the pair is unnamed."""
    return self.per_pair.get((str(from_group), str(to_group)), self.default)

groups

groups() -> set

Every group named on either side of a pair — what a fires-check tests against the groups that actually occur, so a kernel naming only absent groups (a typo) can be caught.

Source code in zombi2/params/mapping.py
def groups(self) -> set:
    """Every group named on either side of a pair — what a fires-check tests against the groups
    that actually occur, so a kernel naming only absent groups (a typo) can be caught."""
    return {g for pair in self.per_pair for g in pair}

check_kernel_fires

check_kernel_fires(kernel: Between, available_groups, *, driver_label: str) -> None

Raise if a Between names no pair whose two groups both occur among available_groups — the recipient-weight twin of check_mapping_fires(). Such a kernel weights every candidate at its default, so the recipient choice is secretly uniform while the run records it as steered — almost always a typo in a group name or a stale driver. A kernel may still name a pair this realisation never realises (a legitimate partial kernel), so only an empty overlap is refused.

Source code in zombi2/params/mapping.py
def check_kernel_fires(kernel: Between, available_groups, *, driver_label: str) -> None:
    """Raise if a `Between` names **no pair whose two groups both occur** among
    ``available_groups`` — the recipient-weight twin of
    `check_mapping_fires()`. Such a kernel weights every candidate at its
    ``default``, so the recipient choice is secretly *uniform* while the run records it as steered —
    almost always a typo in a group name or a stale driver. A kernel may still name a pair this
    realisation never realises (a legitimate partial kernel), so only an *empty* overlap is refused."""
    have = {str(g) for g in available_groups}
    if not any(a in have and b in have for a, b in kernel.per_pair):
        raise ValueError(
            f"Between on {driver_label}: the kernel's groups {sorted(kernel.groups())} include no pair "
            f"whose two groups both occur in {sorted(have)}, so the weighting would silently do nothing "
            f"— every candidate falls to the default weight and the recipient is drawn uniformly. Check "
            f"for a typo in the group names, or a stale or mismatched driver.")

as_mapping

as_mapping(spec: object) -> Mapping

Coerce a Driven mapping spec into a Mapping.

Accepts an already-built mapping (returned unchanged), a dict (→ Table), a callable (→ Curve), or a number (→ Scalar). Mirrors as_rate() / as_distribution().

Source code in zombi2/params/mapping.py
def as_mapping(spec: object) -> Mapping:
    """Coerce a ``Driven`` mapping spec into a `Mapping`.

    Accepts an already-built mapping (returned unchanged), a ``dict`` (→ `Table`), a
    callable (→ `Curve`), or a number (→ `Scalar`). Mirrors
    `as_rate()` / `as_distribution()`.
    """
    if isinstance(spec, (Mapping, Between)):
        # a Between is a choice's kernel, not a rate multiplier; carried through here so
        # weighted_by(driver, Between(...)) works, and refused on a rate or an extent by the engine —
        # which is why the declared return type is the one every *rate* caller may rely on.
        return cast(Mapping, spec)
    if isinstance(spec, dict):
        return Table(spec)
    if isinstance(spec, bool):
        raise TypeError(f"a Driven mapping cannot be a bool, got {spec!r}")
    if isinstance(spec, (int, float)):
        return Scalar(float(spec))
    if callable(spec):
        return Curve(spec)
    raise TypeError(
        f"a Driven mapping must be a dict (Table), a callable (Curve), a number (Scalar), a "
        f"Table/Curve/Scalar, or a Between (a transfer_to kernel), got {spec!r}"
    )

check_not_a_kernel

check_not_a_kernel(mapping, *, label: str) -> None

Raise if a rate (or an extent) is driven through a Between kernel.

A Between weights a recipient by the (donor, recipient) group pair, so it answers who receives and belongs in transfer_to. A rate is read on one lineage and has no donor to condition on, so a kernel there has nothing to be a pair with: Between deliberately implements no multiplier, and an engine that does not check first dies part-way through a run with AttributeError: 'Between' object has no attribute 'multiplier' — a traceback from inside the engine, naming neither the rate nor the mistake.

Every engine that accepts a driven rate or extent calls this, so the message is the same one wherever the kernel was put.

Source code in zombi2/params/mapping.py
def check_not_a_kernel(mapping, *, label: str) -> None:
    """Raise if a **rate** (or an extent) is driven through a `Between` kernel.

    A ``Between`` weights a recipient by the ``(donor, recipient)`` group pair, so it answers *who
    receives* and belongs in ``transfer_to``. A rate is read on one lineage and has no donor to
    condition on, so a kernel there has nothing to be a pair with: `Between` deliberately implements
    no ``multiplier``, and an engine that does not check first dies part-way through a run with
    ``AttributeError: 'Between' object has no attribute 'multiplier'`` — a traceback from inside the
    engine, naming neither the rate nor the mistake.

    Every engine that accepts a driven rate or extent calls this, so the message is the same one
    wherever the kernel was put."""
    if isinstance(mapping, Between):
        raise ValueError(
            f"{label} carries scaled_by(…, Between(…)); a Between kernel is donor-conditioned — it "
            f"weights a recipient by the (donor, recipient) group pair — so it belongs in transfer_to "
            f"(who RECEIVES) and never in a rate or an extent, which are read on one lineage and have "
            f"no donor to condition on. Drive this with a Table (a plain dict) or a Curve, and put the "
            f"kernel in transfer_to=Recipients().weighted_by(driver, Between({{...}})).")

Drivers

zombi2.params.driver

The drivers — everything a parameter can read (SPEC §5).

A parameter that is not a constant reads a driver, and a driver is two facts: what it is attached to (its unit — the run, a lineage, a gene family) and how its number is made. Those are independent, and keeping them apart is what stops the grammar needing a new class per model::

Random("families", LogNormal(0.0, 0.5))          # made by a draw,      among families
Random("lineages", Drift(LogNormal(0.0, 0.2)))   # made by inheritance, among lineages
Time()                                           # measured from the run
Clade({"fast": [...]})                           # read off the tree itself

connection is the other half — what a parameter does with the number.

The unit is an argument, not a class. A draw among families and a draw among lineages are one model at two attachments, so Random("families", …) and Random("lineages", …) are one class and two cells of a grid rather than two names to remember. The cells are models the field already has names for — rate heterogeneity across gene families, the relaxed clock, ClaDS — and the prose uses those.

What the grid buys is the next cell. A per-chromosome draw needs no new class and no invented name: it is Random("chromosomes", …), which constructs, and a cell no engine carries refuses at that level's gate naming itself rather than reading as a typo.

Drawn and Inherited, the two objects Random builds, are in law beside the law that chooses between them. Time is here because it is a driver that is not a modifier: it is read through a verb, where a draw is already a factor and needs none.

Clade

Clade(groups: dict)

Which named clade a lineage belongs to, as a categorical value on that lineage.

A conditioned driver in the ordinary sense — its value is known before the run — but with no file and no earlier run behind it, because the tree is already an input. It therefore works at every level that reads a driver at all, and on a growing tree it does not: a clade is only defined once the tree exists, which is why a joint run refuses it.

Source code in zombi2/params/driver.py
def __init__(self, groups: dict) -> None:
    if not isinstance(groups, dict) or not groups:
        raise ValueError(
            "Clade needs a non-empty {label: clade} dict, where a clade is a list of tips (the "
            "subtree below their MRCA) or a single node id — e.g. Clade({'A': ['n1', 'n2']})")
    for label in groups:
        if not isinstance(label, str) or not label.strip():
            raise ValueError(f"clade labels must be non-empty strings, got {label!r}")
    self.groups = groups

as_driver_trajectory

as_driver_trajectory(tree, *, step: float | None = None)

The per-lineage lookup a driven rate reads — one stretch per lineage, starting at its birth, because membership never changes along a branch.

step is the resolution a continuous driver is read at and is meaningless here: a clade label is categorical and its stretches are already exact, so nothing is approximated and nothing is gained by cutting the branch finer.

Source code in zombi2/params/driver.py
def as_driver_trajectory(self, tree, *, step: float | None = None):
    """The per-lineage lookup a driven rate reads — one stretch per lineage, starting at its
    birth, because membership never changes along a branch.

    ``step`` is the resolution a *continuous* driver is read at and is meaningless here: a clade
    label is categorical and its stretches are already exact, so nothing is approximated and
    nothing is gained by cutting the branch finer.
    """
    from ..genomes._transfer import resolve_groups
    from .conditioned import DriverTrajectory

    painted = resolve_groups(tree, self.groups)
    return DriverTrajectory({i: [(tree.nodes[i].birth_time, painted[i])] for i in tree.nodes})

resolve

resolve(tree) -> dict[str, list[int]]

Which lineages each named group covers on tree{label: [node ids]}, in the order the groups were written, with "rest" last when some lineage is in no named clade::

Clade({"fast": ["n27", "n51"]}).resolve(tree)
# {'fast': [24, 27, 28, 51, 52], 'rest': [0, 1, 2, ...]}

A read-back, for checking a clade before trusting a run that reads it. Three things it makes visible, none of them derivable from the tip names alone: the MRCA's own branch is inside the clade (n24 above, which nobody named), a clade holds the extinct and internal lineages of its subtree as well as its tips, and a lineage in no named clade is in "rest".

It cannot disagree with the run, because it paints with resolve_groups — the one function the engine paints membership with, for this driver and for the Clades transfer rule alike. zombi2 tools tree TREE --clades is the other half: it lists the clades a tree offers to name (Appendix C).

Source code in zombi2/params/driver.py
def resolve(self, tree) -> dict[str, list[int]]:
    """Which lineages each named group covers on ``tree`` — ``{label: [node ids]}``, in the order
    the groups were written, with ``"rest"`` last when some lineage is in no named clade::

        Clade({"fast": ["n27", "n51"]}).resolve(tree)
        # {'fast': [24, 27, 28, 51, 52], 'rest': [0, 1, 2, ...]}

    A read-back, for checking a clade **before** trusting a run that reads it. Three things it
    makes visible, none of them derivable from the tip names alone: the **MRCA's own branch is
    inside the clade** (``n24`` above, which nobody named), a clade holds the extinct and
    internal lineages of its subtree as well as its tips, and a lineage in no named clade is in
    ``"rest"``.

    It cannot disagree with the run, because it paints with `resolve_groups` — the one function
    the engine paints membership with, for this driver and for the `Clades` transfer rule alike.
    ``zombi2 tools tree TREE --clades`` is the other half: it lists the clades a tree offers to
    name (Appendix C)."""
    from ..genomes._transfer import resolve_groups
    from ..tree import as_tree

    painted = resolve_groups(as_tree(tree, level="clade"), self.groups)
    covers: dict[str, list[int]] = {label: [] for label in self.groups}
    for i in sorted(painted):
        covers.setdefault(painted[i], []).append(i)
    return covers

written_form

written_form() -> str

A clade is built from literals — labels, node ids, tip names — so unlike every other driver it can be written into a run's log and pasted back. Driven.written_call asks for this when recording the rate.

Source code in zombi2/params/driver.py
def written_form(self) -> str:
    """A clade is built from literals — labels, node ids, tip names — so unlike every other
    driver it can be written into a run's log and pasted back. `Driven.written_call` asks for
    this when recording the rate."""
    return repr(self)

Measured

Base for a value the run already knows and can be asked for at any moment — no draw, no inheritance, nothing carried per unit. Abstract: use Time.

A measured value is not a Modifier, and that is the distinction the two halves of the grammar rest on. A drawn value is already a dimensionless factor, so it multiplies a base on its own. A measured one is a time, a count, an age — a number in its own units — so it reaches a rate only through a verb and a mapping.

Time

Bases: Measured

The run's clock, as a value::

birth = PerLineage(0.5).changing_at({0: 1.0, 3: 0.3})    # a skyline, in multiples of 0.5
birth = PerLineage().set_by(Time(), {0: 0.5, 3: 0.15})   # the rates themselves

Attached to the whole run, so every parameter may read it — the run is the coarsest unit, and a parameter's units always include it.

The clock is written with changing_at when the schedule holds factors, and it is the one driver set_by also takes, when the schedule holds the numbers themselves. Those two are the only spellings: scaled_by(Time(), …) is refused and names the shortcut, so there is one way to write each reading rather than two.

OnTime

OnTime(schedule: Mapping[float, float], *, verb: str | None = None)

Bases: Modifier

The rate changes in time — a skyline / episodic schedule. Written changing_at::

birth = PerLineage(0.5).changing_at({0: 1.0, 3: 0.3})    # 1.0 on [0, 3), then 0.3 on

schedule maps each interval's start time to a relative factor, dimensionless: on a base of 2.0 the schedule scales it. Before the earliest breakpoint the earliest factor applies (define the schedule from time 0 to avoid surprise).

The same object carries the other half of Time, set_by(Time(), ...), where the schedule holds the rates themselves rather than multiples of a base::

birth = PerLineage(0.5).changing_at({0: 1.0, 3: 0.3})    # 30% of what it was
birth = PerLineage().set_by(Time(), {0: 0.5, 3: 0.15})   # the rates themselves

Those two are the same model, because a schedule on a base of 1.0 is the rate — which is why Rate.set_by builds this rather than a SetBy and there is nothing for an engine to learn. What differs is the sentence the reader typed, so verb records which, exactly as a Driven records its own: a run's log has to say back what was written, not an equivalent.

Source code in zombi2/params/driver.py
def __init__(self, schedule: Mapping[float, float], *, verb: str | None = None) -> None:
    steps = tuple(sorted((float(t), float(f)) for t, f in schedule.items()))
    if not steps:
        raise ValueError(
            "a time schedule cannot be empty, e.g. .changing_at({0: 1.0, 3: 0.3})")
    for t, f in steps:
        if not math.isfinite(t):
            raise ValueError(f"schedule times must be finite, got {t!r}")
        if not math.isfinite(f) or f < 0:
            raise ValueError(f"schedule values must be finite and non-negative, got {f!r}")
    self._steps = steps
    if verb is not None:
        self.verb = verb

TotalDiversity dataclass

TotalDiversity(cap: float | None = None)

The lineages standing right now, as a driver a rate can be scaled by::

birth = PerLineage(1.0).scaled_by(TotalDiversity(cap=100))

The carrying capacity is written on the driver rather than in a mapping beside it, and that is a limit rather than a design: the factor an engine reads is the linear fall to a cap, and a general curve of diversity would have to be integrated rather than read at a point, exactly as a smooth function of time would (SPEC §5). So there is one shape, and it takes its one number here. scaled_by refuses a mapping alongside, rather than accepting one and ignoring it.

OnTotalDiversity dataclass

OnTotalDiversity(cap: float)

Bases: Modifier

The rate slows as standing diversity grows — diversity-dependence. Built by scaled_by(TotalDiversity(cap=100)).

The factor falls linearly from 1 toward 0 as diversity rises to cap (a carrying capacity), and stays 0 beyond it: a cap of 100 halves the rate at 50 lineages and stops it at 100.

Random

Random(unit: str | None = None, law: object = None, **retired: Any) -> Modifier

A value drawn for each unit of that kind — the one driver that is not measured anywhere.

unit is plural ('lineages', 'families', 'copies', 'sites', 'chromosomes'). The law says what happens to the value afterwards, which is a separate question from what it starts as::

Random('families', LogNormal(0.0, 0.5))          # drawn once, held for that family's life
Random('lineages', Drift(LogNormal(0.0, 0.3)))   # the parent's, perturbed at each split
Random('lineages', Drift(LogNormal(0.0, 0.3), bins=8))    # the rate-category clock

A bare distribution is deliberate, not an oversight: it follows the convention the grammar already uses everywhere — a bare dict is a table, a bare function a curve — where the plain case is written plainly and anything else is named.

Usually written through the verb, rate.varying_among('families', law), which builds one of these and attaches it. Building it by name is how two rates share one draw::

family_speed = Random('families', LogNormal(0.0, 0.5))
duplication  = PerCopy(0.20).varying_among(family_speed)
loss         = PerCopy(0.10).varying_among(family_speed)   # exactly half, in every family

because the engine caches a unit's draw by object identity. Two separately built Random objects are two draws even with identical arguments: the question is whether you wrote one thing or two.

**retired catches spread= and per=, the two keywords this replaced, so Python answers them with the same sentence a flag does rather than with "unexpected keyword argument". The unit has a default for that reason alone: Random(per='family', …) writes it into a keyword, and a required positional would make Python complain about the missing argument before anything here could say what per= became.

Source code in zombi2/params/driver.py
def Random(unit: str | None = None, law: object = None, **retired: Any) -> Modifier:
    """A value drawn for each unit of that kind — the one driver that is not measured anywhere.

    ``unit`` is plural (``'lineages'``, ``'families'``, ``'copies'``, ``'sites'``,
    ``'chromosomes'``). The **law** says what happens to the value afterwards, which is a separate
    question from what it starts as::

        Random('families', LogNormal(0.0, 0.5))          # drawn once, held for that family's life
        Random('lineages', Drift(LogNormal(0.0, 0.3)))   # the parent's, perturbed at each split
        Random('lineages', Drift(LogNormal(0.0, 0.3), bins=8))    # the rate-category clock

    A bare distribution is deliberate, not an oversight: it follows the convention the grammar
    already uses everywhere — a bare dict is a table, a bare function a curve — where the plain case
    is written plainly and anything else is named.

    Usually written through the verb, ``rate.varying_among('families', law)``, which builds one of
    these and attaches it. Building it **by name** is how two rates share one draw::

        family_speed = Random('families', LogNormal(0.0, 0.5))
        duplication  = PerCopy(0.20).varying_among(family_speed)
        loss         = PerCopy(0.10).varying_among(family_speed)   # exactly half, in every family

    because the engine caches a unit's draw by object identity. Two separately built ``Random``
    objects are two draws even with identical arguments: the question is whether you wrote one
    thing or two.

    ``**retired`` catches ``spread=`` and ``per=``, the two keywords this replaced, so Python
    answers them with the same sentence a flag does rather than with "unexpected keyword argument".
    The unit has a default for that reason alone: ``Random(per='family', …)`` writes it into a
    keyword, and a required positional would make Python complain about the missing argument before
    anything here could say what ``per=`` became.
    """
    check_no_retired_keywords(retired, where="Random")
    if unit is None:
        raise TypeError(
            f"a Random needs the plural unit its value varies among — Random('families', "
            f"LogNormal(0.0, 0.5)); one of {list(VARYING_UNITS)}.")
    if isinstance(law, Drift):
        return Inherited(unit, law.dist, law.bins)
    return Drawn(unit, law)

Connections

zombi2.params.connection

The link — what joins a driver to the parameter that reads it (SPEC §5, §7).

The manual calls the whole written dependency a connection (driver, link, target); this module is the link, the verb-and-mapping part. The filename predates that vocabulary.

A parameter is written from its scope and a driver says what it reads. What sits between them is this: a verb saying what the number does, and the object that verb builds.

scaled_by    multiplies the base                      -> Driven
set_by       replaces it, in the parameter's own units -> SetBy
weighted_by  compares the candidates of a choice       -> Driven, recorded as a weight

Two drivers keep a verb of their own because they are written constantly — varying_among for a Random and changing_at for the clock — and scaled_by refuses both by name, so there is exactly one spelling for each.

Which verb is legal is a fact about the parameter, not about the driver: a rate can be scaled or replaced, an extent only scaled, a choice only weighted. That is why the verbs are methods over in parameter, and why what they build lives here rather than there — one file for the joining, one for the things being joined.

Driven

Driven(driver: object, mapping: object, step: float | None = None, *, verb: str | None = None)

Bases: Modifier

The factor is read from another evolved value — the one mechanism behind both conditioning and joining (SPEC §2).

You do not write this class; you write a verb. Which verb says what the number does, and is decided by what you are attaching it to: scaled_by multiplies a rate or an extent, weighted_by compares the candidates of a choice, set_by replaces a base. All three build this::

loss        = PerCopy(0.25).scaled_by("habitat.tsv", {"aquatic": 3.0, "terrestrial": 1.0})
birth       = PerLineage(1.0).scaled_by("trait", {"small": 1.0, "large": 2.0})  # joint
transfer_to = Recipients().weighted_by("competence.tsv", {"competent": 3.0})

It is Chapter 8's definition made literal: a rate that reads a value which varies from lineage to lineage, rather than a fixed number. It reads the driver's value on each lineage and the mapping turns it into a number.

driver says where the driven value comes from, and that single choice splits conditioned from joint — the chapter's spine, can the driver be grown first?:

  • a filename ("habitat.tsv"), a grown driver result (a TraitsResult, discrete or continuous), or a genome result's presence(...) / completion(...) — the driver was grown first and handed over (conditioned): two ordinary runs. The result object is the file's in-memory shortcut — same conditioning, no write/read step;
  • a level name ("trait", "genomes:count") — the driver co-evolves in one run (joint): neither level can be grown first.

mapping says how the driver's value becomes the factor — a Table (a dict, for a discrete driver), a Curve (a callable, continuous), a Scalar (a log-link coefficient), or a Between (a weight per donor/recipient pair, which only transfer_to takes); a raw dict / callable / number is coerced (as_mapping()).

What a Driven can be attached to comes in three kinds, and only the first is a rate: how often an event fires (a rate, e.g. loss), how much it takes (an extent, e.g. loss_extent, at the ordered and nucleotide resolutions), and a choice of who receives it (transfer_to, a weight per candidate rather than a multiplier). It always maps a value to a number; it never drives a value, such as an OU optimum.

Like a carried modifier, a Driven reads a value the engine threads per lineage — here a drivers mapping {key: value} — and is otherwise dumb: it just maps the value to a factor. The engine owns where the value comes from (a file it loaded, or the live level growing beside the tree) and when it changes (a discrete driver switches mid-branch, so the engine steps its Gillespie at each switch); a rate reaching an engine that has not threaded its driver gets a factor of 1.0 (inert).

Source code in zombi2/params/connection.py
def __init__(self, driver: object, mapping: object, step: float | None = None, *,
             verb: str | None = None) -> None:
    from .mapping import as_mapping

    if isinstance(driver, str):
        if not driver.strip():
            raise ValueError("a driver must be a non-empty string (a filename or level name)")
        base: object = driver                    # a string driver is its own context key
    else:
        base = id(driver)                        # an in-memory driver result (conditioning): key by identity
    if step is not None:
        step = float(step)
        if not (step > 0.0) or step == float("inf"):
            raise ValueError(
                f"step is the resolution a CONTINUOUS driver is read at, in the tree's own "
                f"time units, so it must be finite and positive; got {step!r}.")
    # the step is part of the key: the same driver read at two resolutions is two trajectories, and
    # keying on the driver alone would silently resolve it once and share the first one
    self.key: object = base if step is None else (base, step)
    self.driver = driver
    self.step = step
    self.mapping = as_mapping(mapping)
    if verb is not None:
        self.verb = verb

factor

factor(*, drivers: Mapping | None = None, time: float | None = None, **_: Any) -> float

The mapped multiplier for this lineage's driver value — the engine threads the value under drivers[key] (key is the driver string, or the identity of an in-memory driver). No drivers (or this driver absent) ⇒ 1.0, so an unthreaded rate is inert (the engine is responsible for supplying the value where a driven rate is supported).

time rides in from the same context every engine already passes, and is used only where the mapping has a Schedule entry — this driver state, but only after t.

Source code in zombi2/params/connection.py
def factor(self, *, drivers: Mapping | None = None, time: float | None = None,
           **_: Any) -> float:
    """The mapped multiplier for this lineage's driver value — the engine threads the value under
    ``drivers[key]`` (``key`` is the driver string, or the identity of an in-memory driver). No
    ``drivers`` (or this driver absent) ⇒ 1.0, so an unthreaded rate is inert (the engine is
    responsible for supplying the value where a driven rate is supported).

    ``time`` rides in from the same context every engine already passes, and is used only where
    the mapping has a `Schedule` entry — this driver state, but only after t."""
    if drivers is None:
        return 1.0
    value = drivers.get(self.key)
    if value is None:
        return 1.0
    return self.mapping.multiplier(value, time=time)

next_change

next_change(time: float) -> float

A scheduled mapping entry changes on its own, so its breakpoints have to reach the engine's horizon or the Gillespie steps straight past them. Everything else answers inf, which is Modifier.next_change's default and what every mapping but Table returns.

Source code in zombi2/params/connection.py
def next_change(self, time: float) -> float:
    """A scheduled mapping entry changes on its own, so its breakpoints have to reach the
    engine's horizon or the Gillespie steps straight past them. Everything else answers ``inf``,
    which is `Modifier.next_change`'s default and what every mapping but `Table` returns."""
    nc = getattr(self.mapping, "next_change", None)
    return math.inf if nc is None else nc(time)

written_call

written_call() -> str

step is written whenever it is set, because a written form that omits an argument records a different model. Leaving it out meant a driven rate with a step rendered as one without, reparsed as one without, and compared equal to one without — so a run's log said something the run had not done, and every round-trip check agreed.

Source code in zombi2/params/connection.py
def written_call(self) -> str:
    """``step`` is written whenever it is set, because a written form that omits an argument
    records a different model. Leaving it out meant a driven rate with a step rendered as one
    without, reparsed as one without, and compared equal to one without — so a run's log said
    something the run had not done, and every round-trip check agreed."""
    step = f", step={self.step!r}" if self.step is not None else ""
    return f"{self.verb}({_driver_form(self.driver)}, {self.mapping!r}{step})"

SetBy

SetBy(driver: object, mapping: object, step: float | None = None, *, verb: str | None = None)

Bases: Driven

Replace the parameter's base with a value read from a driver, rather than multiplying it. What set_by builds::

loss = PerCopy().set_by(habitat, {"cave": 1.0, "surface": 0.25})   # the rate itself

Written with no base in front, because there is none to write: the driver supplies the whole number, in the parameter's own units rather than as a dimensionless factor. That is what the literature usually means — "the loss rate is 1.0 in caves", not "four times a background nobody stated" — and spelling an absolute statement as a multiple of an invented background is the kind of quiet mismatch this grammar exists to avoid.

The scope still applies. set_by replaces the base, not the per what?: a per-copy rate set to 1.0 is still 1.0 per copy, so it is multiplied by the copies present exactly as a written base would be. Only the number changes, which is why the scope is still written in front: PerCopy().

It is a Driven, so every engine that resolves drivers resolves this one too — the trajectory, the mid-branch switches, the mapping checks are all the same machinery. What differs is one line in Rate.effective, which asks a SetBy for the base and every other modifier for a factor. The two compose: a replaced base may still be scaled.

loss = PerCopy().set_by(habitat, {...}).scaled_by(size, Scalar(0.5))

A rate may carry one SetBy, written first. Two would be two answers to the same question, and neither order of application is more right than the other, so it raises rather than picking.

Source code in zombi2/params/connection.py
def __init__(self, driver: object, mapping: object, step: float | None = None, *,
             verb: str | None = None) -> None:
    from .mapping import as_mapping

    if isinstance(driver, str):
        if not driver.strip():
            raise ValueError("a driver must be a non-empty string (a filename or level name)")
        base: object = driver                    # a string driver is its own context key
    else:
        base = id(driver)                        # an in-memory driver result (conditioning): key by identity
    if step is not None:
        step = float(step)
        if not (step > 0.0) or step == float("inf"):
            raise ValueError(
                f"step is the resolution a CONTINUOUS driver is read at, in the tree's own "
                f"time units, so it must be finite and positive; got {step!r}.")
    # the step is part of the key: the same driver read at two resolutions is two trajectories, and
    # keying on the driver alone would silently resolve it once and share the first one
    self.key: object = base if step is None else (base, step)
    self.driver = driver
    self.step = step
    self.mapping = as_mapping(mapping)
    if verb is not None:
        self.verb = verb

written_with

written_with(m: object, verb: str) -> bool

Whether m records verb as the verb that wrote it.

The same object serves several verbs — a Driven is what scaled_by and weighted_by both build — so which one was typed is a fact only the object remembers, and it is what tells a mismatched verb from a right one. Asking through here rather than by reaching for .verb keeps the reading in one place and works for a modifier that records none.

Source code in zombi2/params/connection.py
def written_with(m: object, verb: str) -> bool:
    """Whether ``m`` records ``verb`` as the verb that wrote it.

    The same object serves several verbs — a `Driven` is what `scaled_by` and `weighted_by` both
    build — so which one was typed is a fact only the object remembers, and it is what tells a
    mismatched verb from a right one. Asking through here rather than by reaching for ``.verb``
    keeps the reading in one place and works for a modifier that records none.
    """
    return getattr(m, "verb", None) == verb

scaled_by

scaled_by(driver: object, mapping: object = None, *, step: float | None = None) -> Modifier

Multiply the parameter's base by a factor read from driver.

The factor is dimensionless, and almost every parameter takes one::

loss  = PerCopy(0.25).scaled_by(habitat, {"cave": 4.0, "surface": 1.0})   # a grown trait
birth = PerLineage(1.0).scaled_by(TotalDiversity(cap=100))                # the standing LTT

mapping turns the driver's value into that factor, and its shape follows the driver's type: a categorical driver takes a table (a dict), a numerical one a curve (a callable) or a Scalar log-link.

step is the resolution a continuous driver is read at, in the tree's own time units. A categorical driver switches at moments the engine can step to exactly and ignores it.

Source code in zombi2/params/connection.py
def scaled_by(driver: object, mapping: object = None, *, step: float | None = None) -> Modifier:
    """Multiply the parameter's base by a factor read from ``driver``.

    The factor is dimensionless, and almost every parameter takes one::

        loss  = PerCopy(0.25).scaled_by(habitat, {"cave": 4.0, "surface": 1.0})   # a grown trait
        birth = PerLineage(1.0).scaled_by(TotalDiversity(cap=100))                # the standing LTT

    ``mapping`` turns the driver's value into that factor, and its shape follows the driver's
    **type**: a categorical driver takes a table (a dict), a numerical one a curve (a callable) or a
    ``Scalar`` log-link.

    ``step`` is the resolution a **continuous** driver is read at, in the tree's own time units. A
    categorical driver switches at moments the engine can step to exactly and ignores it.
    """
    _refuse_time(driver, "scaled_by")
    _refuse_a_factor(driver, "scaled_by")
    if isinstance(driver, TotalDiversity):
        if mapping is not None:
            raise ValueError(
                "TotalDiversity carries its own shape — the linear fall to a cap — so there is no "
                "mapping to write beside it: scaled_by(TotalDiversity(cap=100)). A general curve "
                "of standing diversity is not implemented (SPEC §5).")
        assert driver.cap is not None            # TotalDiversity refuses a driver without its cap
        return OnTotalDiversity(driver.cap)
    if isinstance(driver, Measured):
        raise ValueError(
            f"scaled_by({type(driver).__name__}(), ...) is not implemented — that driver exists in "
            f"the grammar but no engine supplies it yet.")
    if mapping is None:
        raise ValueError(
            "scaled_by(driver, mapping) needs a mapping: a dict for a categorical driver, a "
            "callable for a numerical one.")
    return Driven(driver, mapping, step, verb=SCALED_BY)

set_by

set_by(driver: object, mapping: object = None, *, step: float | None = None) -> Modifier

Replace the parameter's base with a number read from driver, in the parameter's own units::

loss  = PerCopy().set_by("habitat.tsv", {"aquatic": 1.0, "terrestrial": 0.25})
birth = PerLineage().set_by(Time(), {0: 0.5, 3: 0.15})

Written with no base in front, because the driver supplies the whole number; the scope still stands, because replacing how fast says nothing about per what.

The clock is the one driver that can replace as well as scale, and it needs no new machinery to: a schedule on a base of 1.0 is the rate, so this builds the same OnTime changing_at does and records which verb wrote it (OnTime.verb), so a run's log says back what was typed.

Source code in zombi2/params/connection.py
def set_by(driver: object, mapping: object = None, *, step: float | None = None) -> Modifier:
    """Replace the parameter's base with a number read from ``driver``, in the parameter's own
    units::

        loss  = PerCopy().set_by("habitat.tsv", {"aquatic": 1.0, "terrestrial": 0.25})
        birth = PerLineage().set_by(Time(), {0: 0.5, 3: 0.15})

    Written with no base in front, because the driver supplies the whole number; the scope still
    stands, because replacing *how fast* says nothing about *per what*.

    The clock is the one driver that can replace as well as scale, and it needs no new machinery to:
    a schedule on a base of 1.0 *is* the rate, so this builds the same `OnTime` `changing_at` does
    and records which verb wrote it (`OnTime.verb`), so a run's log says back what was typed.
    """
    if isinstance(driver, Time):
        return OnTime(_schedule(mapping, "set_by(Time(), ...)"), verb=SET_BY)
    _refuse_a_factor(driver, "set_by")
    if isinstance(driver, (TotalDiversity, Measured)):
        raise ValueError(
            f"set_by({type(driver).__name__}(), ...) is not implemented — no engine can be handed "
            f"a base from that driver. Scale a base you write yourself instead: "
            f"scaled_by({type(driver).__name__}(...)).")
    if mapping is None:
        raise ValueError(
            "set_by(driver, mapping) needs a mapping: a dict for a categorical driver, a callable "
            "for a numerical one. Its numbers are the rate itself, not factors.")
    return SetBy(driver, mapping, step, verb=SET_BY)

weighted_by

weighted_by(driver: object, mapping: object = None, *, step: float | None = None) -> Driven

Weight the candidates of a choice — an argument that decides who, not how fast.

transfer_to, the recipient of a horizontal transfer, is the only choice today. A choice has no base, because only the ratios between candidates are read, and a weight of zero means that candidate cannot be chosen::

transfer_to = Recipients().weighted_by(competence, {"competent": 3.0, "normal": 1.0})

A weight may read both ends — the donor's group and the recipient's — through a Between kernel, which is the mapping for a driver that sits on a pair rather than on one lineage.

Source code in zombi2/params/connection.py
def weighted_by(driver: object, mapping: object = None, *, step: float | None = None) -> Driven:
    """Weight the candidates of a **choice** — an argument that decides *who*, not how fast.

    ``transfer_to``, the recipient of a horizontal transfer, is the only choice today. A choice has
    no base, because only the ratios between candidates are read, and a weight of zero means that
    candidate cannot be chosen::

        transfer_to = Recipients().weighted_by(competence, {"competent": 3.0, "normal": 1.0})

    A weight may read **both ends** — the donor's group and the recipient's — through a ``Between``
    kernel, which is the mapping for a driver that sits on a pair rather than on one lineage.
    """
    _refuse_time(driver, "weighted_by")
    _refuse_a_factor(driver, "weighted_by")
    _refuse_a_whole_rule(driver)
    if isinstance(driver, (TotalDiversity, Measured)):
        raise ValueError(
            f"weighted_by({type(driver).__name__}(), ...) is not implemented — a choice weights "
            f"each candidate by something that candidate has, and that driver is a property of the "
            f"run rather than of a lineage, so every candidate would weigh the same.")
    if mapping is None:
        raise ValueError(
            "weighted_by(driver, mapping) needs a mapping: a dict of per-candidate weights, a "
            "callable, or a Between kernel to weight the (donor, recipient) pair.")
    return Driven(driver, mapping, step, verb=WEIGHTED_BY)

varying_among

varying_among(among: object = None, law: object = None, **retired: object) -> Modifier

Let the parameter vary at random among the units of one kind (SPEC §5)::

loss = PerCopy(0.25).varying_among('families', LogNormal(0.0, 0.5))
rate = PerLineage(1.0).varying_among('lineages', Drift(LogNormal(0.0, 0.2)))

among is the plural unit name and law says what happens to the drawn value afterwards — a bare distribution for a value drawn and held, a Drift for one carried down the tree and perturbed at each split.

It also takes a named Random, with no second argument, which is how two rates share one draw: the engine caches a unit's value by object identity, so one object read twice is one number and two objects are two.

**retired catches per= and spread=, the keywords this verb replaced, so Python answers them with the same sentence a flag does. Every parameter's varying_among passes its own through to here, so the answer cannot be good at one level and absent at another. The unit has a default for that reason alone: varying_among(per='families', …) writes the unit into a keyword, and a required positional would make Python complain about the missing argument before anything here could say what per= became.

Source code in zombi2/params/connection.py
def varying_among(among: object = None, law: object = None, **retired: object) -> Modifier:
    """Let the parameter vary at random among the units of one kind (SPEC §5)::

        loss = PerCopy(0.25).varying_among('families', LogNormal(0.0, 0.5))
        rate = PerLineage(1.0).varying_among('lineages', Drift(LogNormal(0.0, 0.2)))

    ``among`` is the plural unit name and ``law`` says what happens to the drawn value afterwards —
    a bare distribution for a value drawn and held, a `Drift` for one carried down the tree and
    perturbed at each split.

    It also takes a **named** `Random`, with no second argument, which is how two rates share one
    draw: the engine caches a unit's value by object identity, so one object read twice is one
    number and two objects are two.

    ``**retired`` catches ``per=`` and ``spread=``, the keywords this verb replaced, so Python
    answers them with the same sentence a flag does. Every parameter's ``varying_among`` passes its
    own through to here, so the answer cannot be good at one level and absent at another. The unit
    has a default for that reason alone: ``varying_among(per='families', …)`` writes the unit into a
    keyword, and a required positional would make Python complain about the missing argument before
    anything here could say what ``per=`` became.
    """
    check_no_retired_keywords(retired, where="varying_among")
    if among is None:
        raise TypeError(
            "varying_among takes the plural unit to vary among and the law it follows — "
            "varying_among('families', LogNormal(0.0, 0.5)) — or a Random built by name, "
            "varying_among(family_speed), which is how two rates share one draw.")
    if isinstance(among, (Drawn, Inherited)):
        if law is not None:
            raise TypeError(
                "a named Random already carries its law, so varying_among takes it alone: "
                "varying_among(family_speed). Sharing one object is what makes two rates share one "
                "draw — build a second one and there is nothing to share.")
        return among
    if isinstance(among, Modifier):
        raise TypeError(
            f"varying_among takes a Random — a unit and a law — and {describe(among)} is not one.")
    if not isinstance(among, str):
        raise TypeError(
            f"varying_among takes the plural unit to vary among and the law it follows — "
            f"varying_among('families', LogNormal(0.0, 0.5)) — or a Random built by name. "
            f"Got {among!r}.")
    return Random(among, law)

changing_at

changing_at(schedule: object) -> OnTime

Let the parameter change in time — a skyline, the run's clock read as a schedule of factors::

birth = PerLineage(0.5).changing_at({0: 1.0, 3: 0.3})   # 1.0, then 30% of it from time 3

The numbers are multiples of the base. For the other reading — the schedule holding the rates themselves — write set_by(Time(), ...), which builds the same thing on a base of 1.0.

Source code in zombi2/params/connection.py
def changing_at(schedule: object) -> OnTime:
    """Let the parameter change in time — a skyline, the run's clock read as a schedule of factors::

        birth = PerLineage(0.5).changing_at({0: 1.0, 3: 0.3})   # 1.0, then 30% of it from time 3

    The numbers are multiples of the base. For the other reading — the schedule holding the rates
    themselves — write ``set_by(Time(), ...)``, which builds the same thing on a base of 1.0.
    """
    return OnTime(_schedule(schedule, "changing_at"))