zombi2.params¶
Every event fires at a rate, and every rate is written the same way at every level:
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)
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
¶
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
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
¶
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
¶
Multiply this rate by a factor read from driver — see verbs.scaled_by.
set_by
¶
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
varying_among
¶
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
changing_at
¶
Let this rate change in time, on a schedule of factors — see verbs.changing_at.
weighted_by
¶
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
with_default_scope
¶
Fill in the level's default scope (per lineage, per copy, …) when none was written.
effective
¶
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
check_one_base
¶
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
carried_modifiers
¶
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
next_change
¶
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
Extent
dataclass
¶
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
¶
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
¶
Multiply the size by a factor read from driver — see verbs.scaled_by.
Source code in zombi2/params/parameter.py
changing_at
¶
Let the size change in time, on a schedule of factors — see verbs.changing_at.
varying_among
¶
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
set_by
¶
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
sample
¶
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
mean
¶
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
check_rate_base
¶
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
as_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
as_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
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
¶
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
¶
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
scaled_by
¶
Refused. A choice has no base to scale.
Source code in zombi2/params/choice.py
set_by
¶
Refused. A choice has no base to replace.
Source code in zombi2/params/choice.py
Distance
dataclass
¶
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
¶
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
¶
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
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 example — OnCrowding, 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
¶
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
initial
¶
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
descend
¶
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
next_change
¶
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
written_call
¶
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
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
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
check_one_memory
¶
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
cell_name
¶
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
describe
¶
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
is_implemented
¶
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
matches_declared
¶
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
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 onlytransfer_totakes: 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
¶
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
Schedule
¶
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
Table
¶
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 form — Table({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
next_change
¶
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
Curve
¶
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
Scalar
¶
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
Between
¶
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
weight
¶
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
groups
¶
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
check_kernel_fires
¶
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
as_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
check_not_a_kernel
¶
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
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
¶
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
as_driver_trajectory
¶
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
resolve
¶
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
written_form
¶
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
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
¶
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
TotalDiversity
dataclass
¶
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
¶
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
¶
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
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
¶
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 (aTraitsResult, discrete or continuous), or a genome result'spresence(...)/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, nowrite/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
factor
¶
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
next_change
¶
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
written_call
¶
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
SetBy
¶
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
written_with
¶
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
scaled_by
¶
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
set_by
¶
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
weighted_by
¶
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
varying_among
¶
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
changing_at
¶
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.