Skip to content

zombi2.sequences

Level 3: a sequence evolving inside a gene, along its gene tree. The level takes a whole genome run, not bare gene trees — a sequence sees the species tree only through its gene tree, but its rate is set by the species branch the gene sits on.

zombi2.sequences.simulate_sequences

simulate_sequences(genomes, *, model: SubstitutionModel | None = None, length: int | None = None, partitions=None, profiles=None, families=None, intergene_model: SubstitutionModel | None = None, intergene_speed=3.0, insertion=0.0, deletion=0.0, insertion_extent=3.0, deletion_extent=3.0, substitution=None, divergence=None, seed=None, parallel=False, stream_to=None, outputs=None, flat: bool = False, genes=None, joint: bool = False, record: bool = False, progress=False) -> 'SequencesResult | StreamedSequences'

Evolve one sequence down each family's gene tree under a substitution model.

genomes is a genome run — the FamilyGenomesResult that genomes.simulate_genomes_family(...) returned. Its gene_trees are what the sequences evolve along and its complete_tree is the species tree the lineage clock rides; bare gene trees are rejected (they would run, but with no clock and no species phylogram — a silent degradation). Each family's complete gene tree is evolved, so the true history is complete and ancestral sequences exist for extinct/lost lineages too; the observable alignments are the extant tips.

model is a substitution model from the menu (substitution_models) — nucleotide jc69 · k80 · hky85 · gtr, or protein poisson · jtt · dayhoff · wag · lg; its alphabet is what the sequences are written in (ACGT or the 20 amino acids). length is the number of sites. substitution is the per-site substitution rate (default 1.0): a branch of Δt time accrues substitution · Δt substitutions/site. The founding sequence of each family is drawn from the model's stationary frequencies. Deterministic given seed.

insertion and deletion give the sequences indels, so an alignment gains gaps: a lineage loses sites it had, or gains sites the others never did. They are the family and ordered resolutions' indels — a nucleotide genome owns its own, because there a base pair has a position, and passing these alongside one is refused rather than merged. The rates are relative to substitution: deletion=0.05 is five deletions for every hundred substitutions a site expects, so a lineage's clock reaches its indels too and the number means the same on a tree of any height. insertion_extent / deletion_extent are how many sites an event takes, a bare number being the mean of a geometric draw; any distribution works, so Fixed(1) is a single-site indel and a power law is the shape indel lengths really have.

The columns are the union of every site any lineage ever had, and a lineage that carries none of one shows a gap there — the same alignment a nucleotide run writes, arrived at by the level that knows the sites. Refused beside partitions or profiles, which are written against a site count an indel changes.

substitution may carry a lineage clock — one factor per species branch, shared across families, computed once before evolving, rescaling each gene-tree branch by the clock of the species branch it sits on: PerSite(1.0).varying_among('lineages', LogNormal(0.0, σ)) is the uncorrelated clock (each branch drawn i.i.d.), and PerSite(1.0).varying_among('lineages', Drift(LogNormal(0.0, σ))) is the autocorrelated clock (the factor drifts parent→child down the species tree).

It may also carry a driverPerSite(1.0).scaled_by(habitat, {"cave": 0.5, "surface": 1.0}), where habitat is a trait grown first (the ~zombi2.traits.TraitsResult, or the path to the trait_events.tsv it wrote). That is conditioning, not a joint run: SPEC §3 allows the pair Traits–Sequences to be conditioned and never joined, so naming a live level ("trait") raises rather than starting one. A clock and a driver compose — the factors multiply (SPEC §5), so a lineage's dealt tempo and its state both count — and several drivers on one rate multiply too. A discrete driver switches mid-branch, and the branch length is the driver integrated across the branch rather than one sample of it (clock), so the phylograms are the trees the alignments were actually drawn along. Any other modifier (the Markov clock, a draw among families), a second lineage clock, or a non-PerSite scope raises.

record=True keeps this level's own history — every substitution, and every site gained or lost — in result.events, written as sequence_events.tsv. It is off by default and is the only level's log that is, because it is the one log bigger than the output it explains: three hundred sites over thirty time units at rate 1.0 is nine thousand rows for one family. Recording also changes the sampler: an ordinary run draws each branch's end from exp(Q·bl) and never simulates the path between the two ends, while a recorded run walks that path site by site. The two are the same process and the same distribution at a branch's end, so a recorded run is a valid run — but it is a different realisation for the same seed, which is the price of asking what happened rather than only where it ended. A site is named by an id rather than a position, because a position moves with every insertion above it; see zombi2.sequences._record.

families restricts the run to named families — families=["chaperone"] — instead of evolving every one. The names are the genome run's, declared there with families=[family("chaperone")]. Two uses: a run of one family, whose pooled composition is then that family's composition, and a pair of runs that let one gene's sequence drive another's rate. That pair needs it: without it the second run re-evolves the driver alongside its target, so the rate reads one history of the driver while the file on disk holds a different one.

Rate variation across sites rides on model, not on substitution: model=hky85(2.0).across_sites(gamma_shape=0.5, invariant=0.1) sorts the sites into a discretised-Gamma set of rate classes plus a class that never changes. The two axes are orthogonal and compose — the clock says which lineages run fast, the model which sites do.

partitions splits a family's sites into blocks each under its own model, in place of model= and length=::

partitions=[(hky85(kappa=2.0), 600), (jc69(), 400)]

— a 1000-site gene whose first 600 sites evolve under HKY85 and whose last 400 evolve under JC69, concatenated in that order into one sequence per gene copy. Each partition's model may carry its own across_sites classes, which is how the field usually spells a codon-position split. Giving model= or length= alongside is refused rather than merged: the partitions already answer both, and the length is their site counts summed. Every partition must be over the same alphabet, because they concatenate into one sequence.

All the partitions share the run's one substitution rate, and so the family keeps its one phylogram — exactly, not approximately: every model is normalised to one expected substitution per site per unit branch length, and every set of rate classes to a mean of 1, so a branch of Δt accrues rate · Δt substitutions per site in each partition alike. There is deliberately no per-partition speed; it would make one phylogram a weighted average of the trees the partitions were really drawn along. Family and ordered runs only — a nucleotide run's blocks already carry their own lengths and their own gene/spacer models, so it refuses partitions. Experimental (SPEC §9): Python API, no CLI flag yet.

profiles gives chosen families a site profile — an (L, K) array whose row i is the equilibrium frequencies at position i, in the model's own alphabet order::

profiles={4: array_of_shape_300_by_20}

Every model on the menu gives a gene one set of frequencies shared by all its sites; a profile says which residues belong at each position instead, which is what a buried hydrophobic site and the loop beside it differ by. The base model's exchangeabilities are kept — which pairs interchange easily is chemistry, and the profile's business is only where each residue belongs — so the site's matrix is the run's model over the site's own frequencies. Families you leave out evolve under model untouched, and a family's profile changes nothing about any other family.

A row of exact zeros is refused: it makes that site's matrix degenerate, and a real profile says a residue is unlikely at a position rather than impossible — add a pseudocount. A flat profile, every row the model's own frequencies, is the model without one; it is statistically identical rather than byte-identical, because L single-site models consume the random stream differently from one L-site model. Profiles compose with across_sites — a profile says which residues, says how fast — and are refused alongside partitions (both decide a family's per-site models) and alongside parallel (which ships one shared partition set to every worker).

An amino-acid profile needs a protein model and so belongs to a family or ordered run: a nucleotide genome is measured in base pairs and read on either strand, so it refuses protein models outright. Profiles still apply there, over the four bases — a row per base pair — and the row count must equal that block's length, which the genome run already fixed. Experimental (SPEC §9): Python API, no CLI flag yet.

On a nucleotide genome run every root block is evolved — spacer as well as genes — each at its own length in bp, so length does not apply and is rejected. model evolves the genes and intergene_model (default jc69) the spacer, at intergene_speed times the rate (default 3.0). Each carries its own across-site variation: decorating model with across_sites does not reach the spacer, whose default jc69() stays flat — the spacer's job is to be the unconstrained null, and silently giving it the genes' Gamma would make it something else. Give intergene_model a decorated model to vary the spacer too. Because the whole genome is covered, the run also puts the genomes back together: .node_genomes holds every node's chromosomes, blocks concatenated in physical order — the complete tree, reconstructed — and .initial_genome the one the run started with.

The result carries the phylograms the sequences were drawn along — each gene tree and the species tree, with branch lengths converted from time to substitutions/site by the same base × clock × Δt.

parallel opts into evolving the gene trees concurrently — one gene tree per worker process, which is where a run's time goes when the sequences are long or a nucleotide genome has thousands of blocks. False (the default) runs the serial engine above. True uses every core; a positive int sets the worker count. It is a separate engine: each family draws from its own RNG stream (spawned from seed), so every worker count returns the same bytes, but that realisation differs from the serial one for a given seed — parallel is a speed choice, made once, not a drop-in for the default. Threads would not help here (numpy releases the GIL too little for these array sizes), so this is process-backed and only pays off above a work threshold. Because it spawns processes, a script that calls it with parallel set must guard its entry with if __name__ == "__main__": (the standard multiprocessing requirement); the zombi2 CLI already does, so --parallel there needs nothing extra.

stream_to=DIR writes each family's files as it finishes and keeps nothing, returning a StreamedSequences handle instead of a SequencesResult. This is the level where a run's memory actually goes — every alignment and every ancestral sequence live at once — so it is the dial for a run whose result would not fit. The files are the same ones .write(DIR) would leave, so a streamed run and an in-memory one at the same seed are the same dataset; outputs picks which, exactly as .write does, and flat is passed through the same way. It composes with parallel. A nucleotide run cannot stream: it puts whole genomes back together, and that needs every block's sequence at once, which is the opposite of keeping nothing.

Source code in zombi2/sequences/__init__.py
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
def simulate_sequences(genomes, *, model: SubstitutionModel | None = None,
                       length: int | None = None, partitions=None, profiles=None, families=None,
                       intergene_model: SubstitutionModel | None = None, intergene_speed=3.0,
                       insertion=0.0, deletion=0.0, insertion_extent=3.0, deletion_extent=3.0,
                       substitution=None, divergence=None, seed=None, parallel=False,
                       stream_to=None, outputs=None, flat: bool = False, genes=None,
                       joint: bool = False, record: bool = False,
                       progress=False) -> "SequencesResult | StreamedSequences":
    """Evolve one sequence down each family's gene tree under a substitution ``model``.

    ``genomes`` is a **genome run** — the `FamilyGenomesResult` that
    ``genomes.simulate_genomes_family(...)`` returned. Its ``gene_trees`` are what the sequences
    evolve along and its ``complete_tree`` is the species tree the lineage clock rides; bare gene
    trees are rejected (they would run, but with no clock and no species phylogram — a silent
    degradation). Each family's *complete* gene tree is evolved, so the true history is complete and
    ancestral sequences exist for extinct/lost lineages too; the observable ``alignments`` are the
    extant tips.

    ``model`` is a substitution model from the menu (`substitution_models`) — nucleotide
    ``jc69`` · ``k80`` · ``hky85`` · ``gtr``, or protein ``poisson`` · ``jtt`` · ``dayhoff`` ·
    ``wag`` · ``lg``; its alphabet is what the sequences are written in (``ACGT`` or the 20 amino
    acids). ``length`` is the number of sites. ``substitution`` is the per-site substitution
    rate (default ``1.0``): a branch of ``Δt`` time accrues ``substitution · Δt`` substitutions/site.
    The founding sequence of each family is drawn from the model's stationary frequencies. Deterministic
    given ``seed``.

    ``insertion`` and ``deletion`` give the sequences **indels**, so an alignment gains gaps: a
    lineage loses sites it had, or gains sites the others never did. They are the family and ordered
    resolutions' indels — a **nucleotide** genome owns its own, because there a base pair has a
    position, and passing these alongside one is refused rather than merged. The rates are
    **relative to substitution**: ``deletion=0.05`` is five deletions for every hundred substitutions
    a site expects, so a lineage's clock reaches its indels too and the number means the same on a
    tree of any height. ``insertion_extent`` / ``deletion_extent`` are how many sites an event takes,
    a bare number being the mean of a geometric draw; any distribution works, so ``Fixed(1)`` is a
    single-site indel and a power law is the shape indel lengths really have.

    The columns are the union of every site any lineage ever had, and a lineage that carries none of
    one shows a gap there — the same alignment a nucleotide run writes, arrived at by the level that
    knows the sites. Refused beside ``partitions`` or ``profiles``, which are written against a site
    count an indel changes.

    ``substitution`` may carry a **lineage clock** — one factor per species branch, shared across
    families, computed once before evolving, rescaling each gene-tree branch by the clock of the species
    branch it sits on: ``PerSite(1.0).varying_among('lineages', LogNormal(0.0, σ))`` is the
    uncorrelated clock (each branch drawn
    i.i.d.), and ``PerSite(1.0).varying_among('lineages', Drift(LogNormal(0.0, σ)))`` is the
    autocorrelated clock (the factor drifts
    parent→child down the species tree).

    It may also carry a **driver** — ``PerSite(1.0).scaled_by(habitat, {"cave": 0.5, "surface": 1.0})``,
    where ``habitat`` is a trait grown first (the `~zombi2.traits.TraitsResult`, or the path to the
    ``trait_events.tsv`` it wrote). That is conditioning, not a joint run: SPEC §3 allows the pair
    Traits–Sequences to be conditioned and never joined, so naming a live level (``"trait"``) raises
    rather than starting one. A clock and a driver **compose** — the factors multiply (SPEC §5), so a
    lineage's dealt tempo and its state both count — and several drivers on one rate multiply too.
    A discrete driver switches *mid-branch*, and the branch length is the driver **integrated** across
    the branch rather than one sample of it (`clock`), so the phylograms are the trees the alignments
    were actually drawn along. Any other modifier (the ``Markov`` clock, a draw among
    families), a second lineage clock, or a non-``PerSite`` scope raises.

    ``record=True`` keeps this level's own **history** — every substitution, and every site gained
    or lost — in ``result.events``, written as ``sequence_events.tsv``. It is off by default and is
    the only level's log that is, because it is the one log bigger than the output it explains: three
    hundred sites over thirty time units at rate 1.0 is nine thousand rows for one family. Recording
    also changes the *sampler*: an ordinary run draws each branch's end from ``exp(Q·bl)`` and never
    simulates the path between the two ends, while a recorded run walks that path site by site. The
    two are the same process and the same distribution at a branch's end, so a recorded run is a
    valid run — but it is a **different realisation** for the same seed, which is the price of asking
    what happened rather than only where it ended. A site is named by an id rather than a position,
    because a position moves with every insertion above it; see `zombi2.sequences._record`.

    ``families`` restricts the run to **named** families — ``families=["chaperone"]`` — instead of
    evolving every one. The names are the genome run's, declared there with
    ``families=[family("chaperone")]``. Two uses: a run of one family, whose pooled `composition` is
    then that family's composition, and a pair of runs that let one gene's sequence drive another's
    rate. That pair needs it: without it the second run re-evolves the driver alongside its target,
    so the rate reads one history of the driver while the file on disk holds a different one.

    Rate variation **across sites** rides on ``model``, not on ``substitution``:
    ``model=hky85(2.0).across_sites(gamma_shape=0.5, invariant=0.1)`` sorts the sites into a
    discretised-Gamma set of rate classes plus a class that never changes. The two axes are
    orthogonal and compose — the clock says which *lineages* run fast, the model which *sites* do.

    ``partitions`` splits a family's sites into blocks each under **its own** model, in place of
    ``model=`` and ``length=``::

        partitions=[(hky85(kappa=2.0), 600), (jc69(), 400)]

    — a 1000-site gene whose first 600 sites evolve under HKY85 and whose last 400 evolve under
    JC69, concatenated in that order into one sequence per gene copy. Each partition's model may
    carry its own ``across_sites`` classes, which is how the field usually spells a codon-position
    split. Giving ``model=`` or ``length=`` alongside is refused rather than merged: the partitions
    already answer both, and the length is their site counts summed. Every partition must be over the
    **same alphabet**, because they concatenate into one sequence.

    All the partitions share the run's one substitution rate, and so the family keeps its one
    phylogram — exactly, not approximately: every model is normalised to one expected substitution
    per site per unit branch length, and every set of rate classes to a mean of 1, so a branch of
    ``Δt`` accrues ``rate · Δt`` substitutions per site in each partition alike. There is
    deliberately no per-partition speed; it would make one phylogram a weighted average of the trees
    the partitions were really drawn along. Family and ordered runs only — a **nucleotide** run's
    blocks already carry their own lengths and their own gene/spacer models, so it refuses
    ``partitions``. Experimental (SPEC §9): Python API, no CLI flag yet.

    ``profiles`` gives chosen families a **site profile** — an ``(L, K)`` array whose row *i* is the
    equilibrium frequencies at position *i*, in the model's own ``alphabet`` order::

        profiles={4: array_of_shape_300_by_20}

    Every model on the menu gives a gene one set of frequencies shared by all its sites; a profile
    says which residues belong at *each* position instead, which is what a buried hydrophobic site
    and the loop beside it differ by. The base model's **exchangeabilities are kept** — which pairs
    interchange easily is chemistry, and the profile's business is only where each residue belongs —
    so the site's matrix is the run's model over the site's own frequencies. Families you leave out
    evolve under ``model`` untouched, and a family's profile changes nothing about any other family.

    A row of exact zeros is refused: it makes that site's matrix degenerate, and a real profile says
    a residue is unlikely at a position rather than impossible — add a pseudocount. A **flat**
    profile, every row the model's own frequencies, is the model without one; it is statistically
    identical rather than byte-identical, because L single-site models consume the random stream
    differently from one L-site model. Profiles compose with ``across_sites`` — a profile says *which*
    residues, ``+Γ`` says *how fast* — and are refused alongside ``partitions`` (both decide a
    family's per-site models) and alongside ``parallel`` (which ships one shared partition set to
    every worker).

    An **amino-acid** profile needs a protein model and so belongs to a family or ordered run: a
    nucleotide genome is measured in base pairs and read on either strand, so it refuses protein
    models outright. Profiles still apply there, over the four bases — a row per base pair — and the
    row count must equal that block's length, which the genome run already fixed. Experimental
    (SPEC §9): Python API, no CLI flag yet.

    On a **nucleotide** genome run every root block is evolved — spacer as well as genes — each at its
    own length in bp, so ``length`` does not apply and is rejected. ``model`` evolves the genes and
    ``intergene_model`` (default ``jc69``) the spacer, at ``intergene_speed`` times the rate (default
    ``3.0``). Each carries **its own** across-site variation: decorating ``model`` with
    ``across_sites`` does not reach the spacer, whose default ``jc69()`` stays flat — the spacer's
    job is to be the unconstrained null, and silently giving it the genes' Gamma would make it
    something else. Give ``intergene_model`` a decorated model to vary the spacer too. Because the whole genome is covered, the run also **puts the genomes back together**:
    ``.node_genomes`` holds every node's chromosomes, blocks concatenated in physical order — the complete
    tree, reconstructed — and ``.initial_genome`` the one the run started with.

    The result carries the **phylograms** the sequences were drawn along — each gene tree and the
    species tree, with branch lengths converted from time to substitutions/site by the same
    ``base × clock × Δt``.

    ``parallel`` opts into evolving the gene trees **concurrently** — one gene tree per worker
    process, which is where a run's time goes when the sequences are long or a nucleotide genome has
    thousands of blocks. ``False`` (the default) runs the serial engine above. ``True`` uses every
    core; a positive ``int`` sets the worker count. It is a **separate engine**: each family draws
    from its own RNG stream (spawned from ``seed``), so every worker count returns the *same* bytes,
    but that realisation differs from the serial one for a given seed — parallel is a speed choice,
    made once, not a drop-in for the default. Threads would not help here (numpy releases the GIL too
    little for these array sizes), so this is process-backed and only pays off above a work threshold.
    Because it spawns processes, a script that calls it with ``parallel`` set must guard its entry with
    ``if __name__ == "__main__":`` (the standard multiprocessing requirement); the ``zombi2`` CLI
    already does, so ``--parallel`` there needs nothing extra.

    ``stream_to=DIR`` writes each family's files as it finishes and keeps nothing, returning a
    `StreamedSequences` handle instead of a `SequencesResult`. This is the level where a run's memory
    actually goes — every alignment and every ancestral sequence live at once — so it is the dial for
    a run whose result would not fit. The files are the same ones ``.write(DIR)`` would leave, so a
    streamed run and an in-memory one at the same seed are the same dataset; ``outputs`` picks which,
    exactly as ``.write`` does, and ``flat`` is passed through the same way. It composes with
    ``parallel``. A **nucleotide** run cannot stream: it puts whole genomes back together, and that
    needs every block's sequence at once, which is the opposite of keeping nothing.
    """
    from ..genomes import NucleotideGenomesResult, OrderedGenomesResult, StreamedRun, read_run

    # A written run is a genome run too. `zombi2 sequences --from DIR` has always reopened one; from
    # Python the same handoff was a dead end, which mattered most for `stream_to=` — the feature
    # whose whole point is that the run does not fit in memory, and whose handle then could not be
    # passed on. A path or a StreamedRun reads back here, so both front doors take the same step.
    if isinstance(genomes, (str, os.PathLike, StreamedRun)):
        genomes = read_run(genomes)

    if joint or genes is not None:
        # The joint path is its own engine and takes its own arguments, so everything the ordinary
        # one reads is refused here rather than accepted and ignored (SPEC §5). A gene carries its
        # own model, length and rate, which is what leaves nothing for the run-wide ones to mean.
        offered = [n for n, v in (("model", model), ("length", length), ("partitions", partitions),
                                  ("profiles", profiles), ("families", families),
                                  ("substitution", substitution), ("divergence", divergence),
                                  ("intergene_model", intergene_model), ("stream_to", stream_to),
                                  ("outputs", outputs))
                   if v is not None]
        offered += [n for n, v in (("insertion", insertion), ("deletion", deletion),
                                   ("parallel", parallel)) if v]
        if offered:
            raise ValueError(
                f"a joint sequence run is written gene by gene, so {', '.join(sorted(offered))} "
                f"{'have' if len(offered) > 1 else 'has'} nothing to apply to: each gene carries its "
                f"own model, length and substitution rate. Write them on "
                f"sequences.gene(name=..., model=..., length=..., substitution=...).")
        return _simulate_loop(genomes, genes, joint=joint, seed=seed, record=record,
                              progress=progress)

    nucleotide = isinstance(genomes, NucleotideGenomesResult)
    # Indels here are the family and ordered resolutions' — the nucleotide one has its own, on the
    # genome, where a base pair has a position. One word, one meaning, at
    # whichever level owns the sites; two levels drawing them in one run would be two models.
    if (insertion or deletion) and nucleotide:
        raise ValueError(
            "insertion= / deletion= here are the family and ordered resolutions' indels. A "
            "nucleotide genome owns its own, because there a base pair has a position — it can fall "
            "inside a gene and move a coordinate. Pass them to simulate_genomes_nucleotide(...) "
            "instead, and leave them off the sequence run.")
    if (insertion or deletion) and (partitions is not None or profiles is not None):
        raise ValueError(
            "indels change how many sites a lineage has, and partitions/profiles are written "
            "against a fixed site count — so a site's partition or profile would stop meaning "
            "anything the moment one fired. Use one or the other.")
    if record:
        # `record=` runs a different sampler — a forward Gillespie down each branch — so what it
        # cannot walk it refuses rather than quietly recording something else (SPEC §5).
        bad = [n for n, v in (("partitions", partitions), ("profiles", profiles),
                              ("stream_to", stream_to)) if v is not None]
        bad += [n for n, v in (("parallel", parallel),) if v]
        if nucleotide:
            bad.append("a nucleotide genome run")
        if bad:
            raise ValueError(
                f"record=True walks every branch site by site, and {', '.join(bad)} is not on that "
                f"path: partitions and profiles give a family several models, a nucleotide run "
                f"evolves blocks rather than one sequence per family, and the parallel and "
                f"streaming engines hand a family off before its rows could be collected. Record a "
                f"serial, in-memory, single-model run.")
    ins_extent = _bp_extent(insertion_extent, "insertion_extent")
    del_extent = _bp_extent(deletion_extent, "deletion_extent")
    # An ordered run is admitted here as a family one: this level reads a genome run's `gene_trees`
    # and its `complete_tree`, and the ordered result carries both — the coordinates it adds are
    # simply not something a sequence needs. It used to be refused, because `OrderedGenomesResult`
    # is not a subclass of `FamilyGenomesResult` and the gate tested the class rather than what it
    # had to supply. That contradicted the documentation everywhere ("a family or ordered run", in
    # this module's own docstring four times, in Chapter 7 and in Appendix B), and the CLI never hit
    # it because its directory handoff rebuilds a FamilyGenomesResult from `genome_events.tsv` on
    # the way in — so `zombi2 genomes --resolution ordered` into `zombi2 sequences` worked while the
    # same two calls in Python did not.
    if not nucleotide and not isinstance(genomes, (FamilyGenomesResult, OrderedGenomesResult)):
        raise TypeError(
            f"the sequence level runs on a genome run, got {type(genomes).__name__} — pass the "
            "result that genomes.simulate_genomes_family(...), simulate_genomes_ordered(...) or "
            "simulate_genomes_nucleotide(...) returned: the whole run, not its .gene_trees. A "
            "sequence lives inside a gene, but its clock rides the *species* branch that gene sits "
            "on — one draw per lineage, shared by every family — so the run needs the species tree "
            "too."
        )
    if stream_to is not None and nucleotide:
        raise ValueError(
            "a nucleotide run cannot stream: it reassembles every node's genome, which needs every "
            "block's sequence in memory at once — the opposite of what streaming does. Run it "
            "in memory, or use --resolution family at the genome level.")
    if outputs is not None and stream_to is None:
        raise ValueError(
            "outputs applies to a streamed run (stream_to=DIR), which writes the files itself; for "
            "an in-memory run choose them when you call result.write(outputs=...).")
    species_tree = genomes.complete_tree
    # With partitions the models arrive inside them, and `_resolve_partitions` checks each one; this
    # is the plain path, where `model` is the whole answer and the common mistake is worth naming
    # exactly as it always was.
    if partitions is None and not isinstance(model, (SubstitutionModel, Models)):
        if model is None:
            raise ValueError(
                "no model: give model=… (one substitution model for every site of every family) "
                "together with length=…, or partitions=[(model, sites), …] to split a family's "
                "sites into blocks each under its own model, or "
                "Models().set_by(Clade({…}), {…}) to give each clade its own.")
        raise TypeError(f"model must be a SubstitutionModel (e.g. hky85(kappa=2.0)) or a "
                        f"per-lineage set (Models().set_by(Clade({{}}), {{}})), got {model!r}")
    if intergene_model is not None and not isinstance(intergene_model, (SubstitutionModel, Models)):
        raise TypeError(f"intergene_model must be a SubstitutionModel or a per-lineage set "
                        f"(Models().set_by(Clade({{}}), {{}})), got {intergene_model!r}")

    if profiles is not None and partitions is not None:
        raise ValueError(
            "profiles and partitions both decide which model each site of a family evolves under, so "
            "giving both leaves no rule for which wins. A profile already gives every site its own "
            "model — if you want blocks of sites to differ in something a profile cannot say, that is "
            "partitions; if you want per-site frequencies, that is profiles. Not both.")
    if profiles is not None and parallel:
        raise ValueError(
            "profiles are not implemented for the parallel engine yet: it ships one shared partition set to "
            "every worker, and a profile is per family. Run this level serially (drop `parallel`), or "
            "drop `profiles`.")
    site_profiles = {} if profiles is None else _resolve_profiles(profiles, model, length)

    if nucleotide and families is not None:
        raise ValueError(
            "families= restricts the run to named gene families, and a nucleotide genome run "
            "evolves blocks — genes and the spacer between them — rather than a family's gene "
            "trees. Run the family or ordered resolution at the genome level if one family's "
            "sequences are what you want.")
    if nucleotide:
        # Every recovered root block evolves — spacer as well as genes — so the run reconstructs the
        # whole genome rather than the declared loci. Each block brings its own length in bp, which
        # is why a single `length` would contradict the coordinates the genome recorded.
        if partitions is not None:
            raise ValueError(
                "partitions do not apply to a nucleotide genome run: every block already carries "
                "its own length in bp and its own model — `model` for a gene, `intergene_model` for "
                "the spacer — so the genome has already said which stretch takes which. Drop "
                "partitions; the genome sets both the lengths and the split. Partitions are for a "
                "family or ordered run, where a family is one undivided sequence until you divide "
                "it.")
        # `partitions is None` here, so the guard above already refused a missing or non-model
        # `model` — from this point it is the gene model, not an optional one.
        for label, m in (("model", model), ("intergene_model", intergene_model)):
            if isinstance(m, Models):
                raise ValueError(
                    f"{label} is a per-lineage model set, which a nucleotide genome run does not "
                    f"read yet: this path evolves blocks of a whole genome rather than a family's "
                    f"gene trees, and a block's stretch of a species branch is chosen by the "
                    f"genome's coordinates rather than by the gene tree walk the set is applied on. "
                    f"Use one model here, or run the family / ordered resolution.")
        assert isinstance(model, SubstitutionModel)
        if length is not None:
            raise ValueError(
                "length does not apply to a nucleotide genome run: every block carries its own "
                "length in bp, so one number here would contradict the coordinates the genomes run "
                "wrote. Drop it — the genome sets the lengths.")
        for name, m in (("model", model), ("intergene_model", intergene_model)):
            if m is not None and m.alphabet != BASES:
                raise ValueError(
                    f"{name}={m.name} is a protein model, but a nucleotide genome is measured in base "
                    "pairs and its blocks are read on either strand — amino acids have no complement "
                    "to read back. Use a nucleotide model (jc69 / k80 / hky85 / gtr).")
        # flat and parameterless: the null for unconstrained DNA. Named separately so the
        # argument's optionality ends here rather than trailing through the loops below.
        spacer: SubstitutionModel = intergene_model if intergene_model is not None else jc69()
        if isinstance(intergene_speed, bool) or not isinstance(intergene_speed, (int, float)) \
                or intergene_speed <= 0:
            raise ValueError(f"intergene_speed must be a positive number, got {intergene_speed!r}")
        gene_trees = genomes.block_trees
        blocks = genomes.root_blocks
        genic = {span: fam for fam, span in genomes.gene_spans.items()}
        # per block: its length, whether it is genic, the model it evolves under and its speed
        per_block = {}
        for i, (src, a, b) in enumerate(blocks):
            is_gene = (src, a, b) in genic
            per_block[i] = (b - a, model if is_gene else spacer,
                            1.0 if is_gene else float(intergene_speed))
        # Founded from a real FASTA: a block's founding sequence is the supplied DNA at its own root
        # coordinates, encoded to states, rather than a stationary draw. A de-novo source is not in
        # initial_sequence (it arose mid-run), so its blocks still draw from the model. `None` per block
        # ⇒ draw, exactly as before, so a run without one is unchanged.
        founding_seed: dict[int, "np.ndarray | None"] = {}
        for i, (src, a, b) in enumerate(blocks):
            root = genomes.initial_sequence.get(src)
            if root is None:
                founding_seed[i] = None
                continue
            f_model = model if (src, a, b) in genic else spacer
            if f_model.alphabet != BASES:
                raise ValueError(
                    f"the run was founded from a FASTA (DNA), but {f_model.name} is a protein model — "
                    "a nucleotide sequence cannot found an amino-acid alignment")
            founding_seed[i] = encode(root[a:b], f_model.alphabet)
        parts = None                # a nucleotide block's model and length come from `per_block`
    else:
        gene_trees = genomes.gene_trees
        if families is not None:
            gene_trees = {i: gene_trees[i] for i in _restrict_to(families, genomes)}
        parts = _resolve_partitions(model, partitions, length)
        # A per-lineage model set is painted against THIS run's tree, once, before any family is
        # evolved — the same shape as a driven rate resolving its trajectory above. It is checked
        # here too: a label that names no lineage, or a lineage with no model, is a run that would
        # otherwise quietly evolve part of the tree under the wrong matrix.
        parts = tuple((m.resolve(species_tree) if isinstance(m, Models) else m, n) for m, n in parts)
        if intergene_model is not None:
            raise ValueError(
                "intergene_model applies to a nucleotide genome run, where blocks are genes or "
                "spacer. A family or ordered run has gene families only, so there is nothing "
                "for a second model to evolve.")
        per_block = None
    if divergence is not None:
        rate = _calibrate(substitution, divergence, genomes.complete_tree)
    else:
        rate = as_rate(1.0 if substitution is None else substitution, default_scope=PerSite)
    if rate.scope is not PerSite:
        assert rate.scope is not None      # `as_rate` and `_calibrate` both fill the scope in
        raise ValueError(
            f"substitution has a {rate.scope.__name__} scope, but the sequence engine reads the "
            f"substitution rate per site and cannot read it any other way. Write PerSite(...), or "
            f"drop the scope and let the level fill in its own."
        )
    # The rate's modifiers, sorted into the two things this level reads. SPEC §5: modifiers multiply,
    # so a clock and a driver compose — one says which lineages were dealt a fast tempo, the other
    # what their state makes of it — and the gate below rejects only what the level cannot honour.
    clocks = tuple(m for m, _ in rate.carried_modifiers(unit='lineages'))
    drivers = driven_mods(rate)
    # This level is the one that does NOT take a third-party modifier, so the gate is a plain
    # isinstance rather than `is_implemented`. Every other engine evaluates its rate through
    # `Rate.effective`, which multiplies in whatever `factor()` returns; this one reads its two kinds
    # of modifier itself — the clock is *drawn among lineages* before any site evolves, not evaluated
    # at an event — so a modifier declaring itself implemented here would be accepted and then never
    # called. Silently returning the undriven answer is precisely what SPEC §5 forbids, so it is
    # refused by name instead, and `Modifier.implemented_for` documents the omission.
    unimplemented = sorted({describe(m) for m in rate.modifiers
                            if not matches_declared(m, IMPLEMENTED_MODIFIERS)})
    if unimplemented:
        raise ValueError(
            f"substitution carries {', '.join(unimplemented)}, which the sequence engine does not read. It "
            "takes a lineage clock — varying_among('lineages', LogNormal(0.0, 0.3)) (uncorrelated) or "
            "varying_among('lineages', Drift(LogNormal(0.0, 0.3))) (autocorrelated), and "
            "several of one kind compose — and any number of scaled_by drivers, which multiply. "
            "set_by is not read here (a replaced base has nowhere to go: this level draws its clock "
            "among lineages rather than evaluating a rate), and neither is the Markov clock, a draw "
            "among families, or a modifier of your own: this "
            "level reads its modifiers directly rather than through the rate, so one it did not ship "
            "could not be honoured. Rate variation across sites is not a modifier "
            "at all — it belongs to the model: model=hky85(...).across_sites(gamma_shape=0.5), or "
            "--gamma-shape."
        )
    # SPEC §5's one-memory-structure-per-axis rule, in the one place every level calls: a lineage's
    # factor is either drawn afresh or inherited and perturbed, and those are two accounts of the same
    # thing. Several of one kind compose, as any two modifiers do; a Driven is a different axis and
    # composes with either.
    check_one_memory(clocks, label="substitution", unit="lineages")
    for m in drivers:
        if isinstance(m.mapping, Between):
            raise ValueError(
                "substitution carries scaled_by(..., Between(...)), and a donor/recipient kernel is "
                "meaningless in a rate: a rate is read on one lineage, and there is no second lineage "
                "for the pair's first half to name. A Between belongs in the genome level's "
                "transfer_to weight, where the two ends of a transfer exist. Weight the "
                "substitution rate by the lineage's own state instead — scaled_by(driver, {state: "
                "factor})."
            )
        if names_a_live_level(m.driver):
            raise ValueError(
                f"substitution is driven by {m.driver!r}, which names a level growing beside the run "
                "— the joint spelling of a driver (SPEC §5) — and this function reads only a driver "
                "grown first. A trait and a gene's sequence driving each other is one joint run, "
                "joint.simulate(traits.discrete(...), sequences.gene(...), tree=..., genomes=...), "
                "and genes of one run reading each other stay on this function, declared as "
                "genes=[sequences.gene(...), ...] with joint=True. To condition instead, grow the "
                "driver first and pass its result — the TraitsResult, a composition — or the path "
                "to the file it wrote."
            )
    rate_base = rate.base
    # The only rate with no base of its own is one whose base a `set_by` replaces, and the gate above
    # refuses a `set_by` here — this level draws its clock rather than evaluating a rate, so there is
    # nowhere for a replaced base to go.
    assert rate_base is not None

    # Conditioning: resolve each driver ONCE into a DriverTrajectory (value + next-switch lookups,
    # keyed by the shared species node id), before choosing an engine — this is shared validation and
    # shared input, not one engine's business, exactly as the genome level does it. A mapping whose
    # states never occur in the driver would leave every branch at the default factor, so the run
    # would be the undriven model wearing a driven rate; that is refused here, naming the driver.
    # No driven rate ⇒ `driven` is empty and everything below is what it was.
    driven: list = []
    if drivers:
        by_key: dict = {}
        for m in drivers:
            by_key.setdefault(m.key, m)
        trajs = {key: resolve_driver(m.driver, species_tree, step=m.step, level="sequences")
                 for key, m in by_key.items()}
        for m in drivers:
            label = m.driver if isinstance(m.driver, str) else f"<{type(m.driver).__name__}>"
            check_mapping_fires(m.mapping, trajs[m.key].states(), driver_label=label)
            # A scheduled mapping entry makes the factor a function of TIME, and this level does not
            # read one: `IMPLEMENTED_MODIFIERS` leaves out OnTime on purpose, so `changing_at` is
            # refused here and a schedule reaching in through a mapping would be the same model by
            # another door. It has to be refused rather than run, because a rate this level cannot
            # step would silently hold the schedule's opening factor for the whole run — the branch
            # lengths would come out as if the schedule were a plain number, and nothing would say so.
            if m.mapping.next_change(0.0) != math.inf:
                raise ValueError(
                    "a time schedule inside a mapping is not read at the sequences level: this level "
                    "walks each gene tree branch by branch and does not step at a wall-clock time, "
                    "so the schedule's first factor would stand for the whole run. Give this state "
                    "one factor, or put the schedule on the level that grows along the tree — a "
                    "genome or a trait rate — and drive the substitution rate from what that "
                    "produced.")
        driven = [(m, trajs[m.key]) for m in drivers]

    names = species_tree.labels()   # e<id> for a lineage that died; n<id> for the rest
    sink = None
    if stream_to is not None:
        chosen = tuple(outputs) if outputs is not None else _DEFAULT_STREAM_OUTPUTS
        unknown = [o for o in chosen if o not in _WRITE_OUTPUTS]
        if unknown:
            raise ValueError(f"unknown stream outputs {unknown}; choose from {list(_WRITE_OUTPUTS)}")
        sink = _Sink(stream_to, chosen, "family", flat)
    alignments: dict[int, dict[str, str]] = {}
    ancestral: dict[int, dict[str, str]] = {}
    founding: dict[int, str] = {}
    phylograms: dict[int, dict[str, str | None]] = {}
    events: list = []                      # empty unless record=True; see `_record`
    seed = resolve_seed(seed)      # drawn if none was given, so either engine below records it
    if not parallel:
        # Serial reference engine — the default, left exactly as it was. One shared generator draws the
        # clock, then each family is walked in turn. `parallel` selects a *separate* engine (decision A),
        # so turning it on gives a different-but-valid realisation for a seed; this path never changes.
        rng, _ = stream("sequences", seed)
        clock = resolve_clock(clocks, driven, species_tree, gene_trees, rng)
        # One transition-CDF cache per model, shared across every block that model evolves. Branch lengths
        # recur across blocks (a block passing straight through a species branch reuses its length), so a
        # run-wide cache builds a few hundred matrices where a per-block cache rebuilt tens of thousands.
        # Keyed by model identity — genes and spacer are different models and must not share a cache.
        # A profile keyed to a family this run does not have is a typo that would otherwise apply
        # to nobody and say nothing — the same silence the Driven mapping guard exists to break.
        stray = sorted(set(site_profiles) - set(gene_trees), key=str)
        if stray:
            raise ValueError(
                f"profiles names {len(stray)} family/families this run does not have: "
                f"{', '.join(map(str, stray[:5]))}{' …' if len(stray) > 5 else ''}. The run has "
                f"{len(gene_trees)} of them, keyed {min(gene_trees, key=str)}{max(gene_trees, key=str)}. "
                f"A profile for a family that is not here applies to nothing.")
        cdf_caches: dict[int, dict[float, np.ndarray]] = {}
        n_insertions = n_deletions = 0
        bar = progress_bar(len(gene_trees), "sequences", unit="family", enabled=progress)
        for family in sorted(gene_trees):  # sorted for reproducibility given the seed
            bar.update()
            gt = gene_trees[family]
            if per_block is None:
                f_parts, f_rate = parts, rate_base
            else:                       # a nucleotide block: its own length, and spacer runs faster
                f_len, f_model, speed = per_block[family]
                f_parts, f_rate = ((f_model, f_len),), rate_base * speed
            if family in site_profiles:          # a profile replaces the family's model, site by site
                f_parts = site_profiles[family]
                # `length` is rejected on a nucleotide run — every block carries its own — so the
                # row-count check in `_resolve_profiles` had nothing to compare against and this is
                # where it lands. Without it a short profile silently shortens the sequence, and the
                # alignment stops agreeing with the coordinates the genome run wrote.
                if per_block is not None and len(f_parts) != per_block[family][0]:
                    raise ValueError(
                        f"the profile for block {family!r} has {len(f_parts)} rows but that block is "
                        f"{per_block[family][0]} bp. A profile carries one row per site, and on a "
                        f"nucleotide run the genome already fixed the length.")
            seed_states = None if per_block is None else founding_seed[family]
            # Geometry first, letters second: which columns exist and who carries them is drawn over
            # the tree, and then the existing engine evolves that fixed width down it. Exact rather
            # than convenient — see `indels.draw_indel_history`.
            history = None
            recorder = None
            if record:
                from ._record import Recorder
                recorder = Recorder(events, names)
            if insertion or deletion:
                assert f_parts is not None    # None means a nucleotide run, which refuses these
                sites = sum(int(n) for _model, n in f_parts)
                history = draw_indel_history(
                    gt.complete, sites, insertion=insertion, deletion=deletion,
                    insertion_extent=ins_extent, deletion_extent=del_extent,
                    rate_base=f_rate, clock=clock, rng=rng, record=recorder)
            if history is not None:
                assert f_parts is not None
                f_parts = ((f_parts[0][0], history.width),)
                n_insertions += history.insertions
                n_deletions += history.deletions
                if recorder is not None:      # a column is no longer its own id once sites move
                    recorder.site_ids, recorder.present = history.order, history.present
            aln, anc, fnd = _evolve_partitions(gt, f_parts, f_rate, clock, rng, cdf_caches, names,
                                               founding=seed_states,
                                               present=None if history is None else history.present,
                                               record=recorder)
            scaled = _scaled_gene_tree(gt, f_rate, clock)  # branch lengths in subs/site
            ext = scaled.extant
            phylo = {"complete": _gene_newick(scaled.complete, names),
                     "extant": _gene_newick(ext, names) if ext is not None else None}
            if sink is None:
                alignments[family], ancestral[family] = aln, anc
                founding[family], phylograms[family] = fnd, phylo
            else:
                sink.family(family, aln, anc, fnd, phylo)   # written and forgotten
        bar.close()
    else:
        # Parallel engine (opt-in): one gene tree per process, each under its own spawned RNG stream, so
        # any worker count is bit-identical to any other. The clock is a shared read-only draw, so it is
        # taken once here from a reserved stream (index 0) and shipped to every worker.
        from .._runtime.parallel import guard_pool_workers, resolve_workers
        from ._pergenetree import evolve_families
        workers = guard_pool_workers(resolve_workers(parallel))
        spawned = seed_sequence("sequences", seed)[0].spawn(1 + len(gene_trees))
        clock = resolve_clock(clocks, driven, species_tree, gene_trees,
                              np.random.default_rng(spawned[0]))
        alignments, ancestral, founding, phylograms = evolve_families(
            gene_trees, per_block, model, intergene_model, length, rate_base, clock,
            founding_seed if nucleotide else None, spawned[1:], workers, progress, names,
            sink=None if sink is None else sink.family, partitions=parts)

    events.sort(key=lambda e: e.time)      # one log, in time order, as every other level writes one
    sp_scaled = _scaled_species_tree(species_tree, rate_base, clock)   # the clock made visible
    sp_extant = prune(sp_scaled, keep="extant")
    species_phylogram = {"complete": sp_scaled.to_newick(),
                         "extant": sp_extant.to_newick() if sp_extant is not None else None}
    # A nucleotide run evolved every block, so **every** node's genome can be put back together —
    # one map, as at the genome level. Which sequences each node reads is the split the level already
    # makes: an extant tip's genes are tips of their block trees, everything else's are not. The
    # concatenation is deferred to read-time (see `_AssembledGenomes`): only the cheap per-node
    # layout is captured now, so hundreds of megabases of genome do not all sit in memory beside the
    # per-block sequences they are built from.
    assembled: "Mapping[str, dict[int, str]]" = {}
    initial_genome: dict[int, str] = {}
    insertion_plan: dict = {}                  # only a nucleotide run with insertions has one
    if nucleotide:
        # Capture the layouts in the same order the eager build used — extant nodes (read from
        # `alignments`) sorted first, then the rest (read from `ancestral`) — so the map iterates,
        # and `write` emits its files, in exactly the previous order.
        extant_ids = sorted(species_tree.extant_leaves())
        extant_id_set = set(extant_ids)
        extant_labels = {names[i] for i in extant_ids}
        ordered_ids = extant_ids + [i for i in sorted(species_tree.nodes) if i not in extant_id_set]
        layouts = {names[i]: genomes.assembly(i) for i in ordered_ids}
        _gap_what_is_not_carried(layouts, alignments, ancestral, genomes.root_blocks)
        insertion_plan = _insertion_plan(genomes, layouts, names)
        # `alignments` / `ancestral` become the spliced view; the per-block rows underneath stay as
        # they are, because that is what a genome is assembled from — the assembly slices a block's
        # OWN coordinates, and the spliced columns are not in that frame.
        block_rows, block_ancestral = alignments, ancestral
        widths = {i: b - a for i, (_s, a, b) in enumerate(genomes.root_blocks)}
        folded = frozenset(b for _host, (runs, _c) in insertion_plan.items()
                           for (_off, b, _w) in runs)
        spliced: "Mapping[int, dict[str, str]]" = _SplicedAlignments(
            block_rows, insertion_plan, widths, folded)
        spliced_anc: "Mapping[int, dict[str, str]]" = _SplicedAlignments(
            block_ancestral, insertion_plan, widths, folded)
        alignments, ancestral = spliced, spliced_anc            # type: ignore[assignment]
        assembled = _AssembledGenomes(layouts, block_rows, block_ancestral, extant_labels)
        # The genome the run started with. Its blocks were all laid down at the start, so each one's
        # sequence there is its `founding` draw — the state the stem leads *from*. It is not a node,
        # so it is in neither map above; the same reason `founding` is not in `ancestral`.
        for cid, pieces in genomes.initial_assembly().items():
            initial_genome[cid] = "".join(
                piece if strand == 1 else piece.translate(_COMPLEMENT)[::-1]
                for (block, strand, lo, hi) in pieces
                for piece in (founding[block][lo:hi],))

    if sink is not None:
        sink.finish(species_phylogram)
        handle = StreamedSequences(str(stream_to), seed, sink.n_families, sink.n_sequences,
                                   sink.outputs, sink.identity, sink.sites, sink.n_ancestral)
        if "summary" in sink.outputs:
            # same rule as the in-memory write: the file describes the directory it sits in, so it
            # counts ancestral sequences only where they were actually written. A streamed run and an
            # in-memory one at the same seed are the same dataset, summary included.
            written = handle.summary()
            if "ancestral" not in sink.outputs:
                written.pop("ancestral_sequences", None)
            write_summary(pathlib.Path(stream_to) / "sequences_summary.json", written)
        return handle
    return SequencesResult(alignments, ancestral, founding, phylograms, species_phylogram, seed,
                           assembled, initial_genome, "block" if nucleotide else "family",
                           # a nucleotide run's models are all forced to DNA above (and its `parts` is
                           # None — each block brings its own); elsewhere every partition shares one
                           # alphabet, so the first one speaks for the run
                           BASES if parts is None else parts[0][0].alphabet,
                           tuple(names[i] for i in sorted(species_tree.extant_leaves())),
                           () if families is None else tuple(families),
                           events,
                           insertion_plan,
                           alignments._raw if isinstance(alignments, _SplicedAlignments) else alignments,
                           ancestral._raw if isinstance(ancestral, _SplicedAlignments) else ancestral)

zombi2.sequences.SequencesResult dataclass

SequencesResult(alignments: 'Mapping[int, dict[str, str]]', ancestral: 'Mapping[int, dict[str, str]]', founding: dict[int, str], phylograms: dict[int, dict[str, str | None]], species_phylogram: dict[str, str | None], seed: int | None, node_genomes: 'Mapping[str, dict[int, str]]' = dict(), initial_genome: dict[int, str] = dict(), unit: str = 'family', alphabet: str = '', extant_tips: tuple[str, ...] = (), families: tuple[str, ...] = (), events: list = list(), _insertions: dict = dict(), _raw_rows: dict = dict(), _raw_ancestral: dict = dict())

What simulate_sequences() returns.

  • alignments{family: {n<species>_g<copy>: sequence}}: the observable gene alignment, one entry per extant gene-tree tip, keyed by its (unique, per-segment) gene id — the same labels as the gene tree's / phylogram's Newick leaves. Empty for a family with no surviving copy.
  • ancestral{family: {n<species>_g<copy>: sequence}}: the true sequence at every node that is not an extant tip — internal nodes (the family's root gene included) and the dead tips, where a copy was lost or its species went extinct. With alignments it accounts for every node of the tree exactly once, so every label in the complete phylogram names a sequence.
  • founding{family: sequence}: the sequence the family started with, at its origination — the state the phylogram's root branch leads from. It is drawn from the model's stationary frequencies and then evolves across the stem into the root gene's sequence, so it is not the same string as ancestral[family]["g<root copy>"] unless the stem is empty. Kept out of ancestral on purpose: those keys pair one-to-one with phylogram nodes, and the origination is a point on a branch, not a node.
  • phylograms{family: {"complete": newick, "extant": newick | None}}: each gene tree with branch lengths in substitutions/site (base × lineage-clock × Δt) — the ground-truth tree behind each alignment. Every node is labelled n<species>_g<copy>, so the tips match the alignments keys and the internal nodes match the ancestral keys (the phylogram pairs one-to-one with the sequences). "extant" is None for a family with no survivor.
  • species_phylogram{"complete": newick, "extant": newick | None}: the species tree with branch lengths in substitutions/site — the molecular clock made visible (which lineages ran hot / cold). Always present: a run always comes from a genome run, which carries its tree.
  • node_genomes{lineage: {chromosome id: sequence}}: every node's assembled genome, its blocks concatenated in physical order (reverse-complemented where the genome carries them inverted) — extant tips, ancestors and the lineages that went extinct alike. The same coverage, and the same name, as the genome level's node_genomes. genomes is the extant tips alone — the observed genomes — exactly as it is one level down. Only a nucleotide genome run has any: a family or ordered run has gene families, not coordinates, so there is no genome to lay out and both are empty.
  • initial_genome{chromosome id: sequence}: the genome the run started with, at the root lineage's origination. Not in genomes, because it belongs to no node: the root branch is real simulated time, so the root node's genome is this one plus whatever happened along the stem. It stands to genomes as founding stands to ancestral.
  • seed — the run's seed.
  • unit — what the integer key of alignments / ancestral / founding / phylograms names: "family" (a gene family id) on a family or ordered run, "block" (an index into the genome run's root_blocks) on a nucleotide one, where every block evolves and spacer has no family. They are different numbering schemes over the same ints, so a gene family id is not a key here on a nucleotide run — go through block_of(). It is also what the filenames say.
  • alphabet — what the sequences are written in: "ACGT", or the 20 amino acids. One per run (every partition of a family shares it), and what composition checks the letters it is asked to count against.

genomes property

genomes: dict[str, dict[int, str]]

The observed genomes — the assembled genome of each extant tip, keyed by tip name (n5): what you would have sequenced. Empty unless the run came from a nucleotide genome run, which is the only kind that has coordinates to lay out.

node_genomes is the whole record — every node, ancestors and extinct lineages included. The pair is the genome level's ~zombi2.genomes.FamilyGenomesResult.genomes / node_genomes, and it reads here the way alignments reads against ancestral: what you observe, and the truth behind it.

composition

composition(letters: str, *, absent: float | None = None)

The share of a lineage's sequence that is one of letters, at every instant, as a conditioning driver (~zombi2.sequences._composition.Composition)::

proteins = simulate_sequences(g, model=lg(), length=300, seed=1)
simulate_discrete(tree, states=["mesophile", "thermophile"], start="mesophile", seed=2,
                  switch=PerLineage(0.2).scaled_by(proteins.composition("KR"),
                                                   Curve(lambda x: 40.0 ** (x - 0.1))))

This is how an amino-acid frequency is asked for: one residue ("K") or a set of them ("KR", "AVLIMFWP"). The letters must be in this run's alphabet; gc is the same driver over "GC", named because it is the one people ask for by name.

A number, so it takes a ~zombi2.params.mapping.Curve or a ~zombi2.params.mapping.Scalar, and it drives what comes after a sequence — a trait, or a further sequence run — never the genome the gene trees came from.

absent is what a branch reads where the run has no sequence at all on that lineage, and it is what makes one family's composition a usable driver: restrict the run with families=["chaperone"] and this statistic is that family's, but the family is missing on some branches and a driver has to answer for every branch the target walks::

chaperone = simulate_sequences(g, families=["chaperone"], model=lg(), length=300, seed=3)
chaperone.composition("KR", absent=0.08)

On a restricted run with a gap and no absent, resolving the driver raises rather than carrying the parent's value forward, which would be a different model from the one asked for.

Source code in zombi2/sequences/__init__.py
def composition(self, letters: str, *, absent: float | None = None):
    """The share of a lineage's sequence that is one of ``letters``, at every instant, as a
    conditioning driver (`~zombi2.sequences._composition.Composition`)::

        proteins = simulate_sequences(g, model=lg(), length=300, seed=1)
        simulate_discrete(tree, states=["mesophile", "thermophile"], start="mesophile", seed=2,
                          switch=PerLineage(0.2).scaled_by(proteins.composition("KR"),
                                                           Curve(lambda x: 40.0 ** (x - 0.1))))

    This is how an **amino-acid frequency** is asked for: one residue (``"K"``) or a set of them
    (``"KR"``, ``"AVLIMFWP"``). The letters must be in this run's `alphabet`; `gc` is the same
    driver over ``"GC"``, named because it is the one people ask for by name.

    A number, so it takes a `~zombi2.params.mapping.Curve` or a `~zombi2.params.mapping.Scalar`, and
    it drives what comes **after** a sequence — a trait, or a further sequence run — never the
    genome the gene trees came from.

    ``absent`` is what a branch reads where the run has **no sequence at all** on that lineage,
    and it is what makes **one family's** composition a usable driver: restrict the run with
    ``families=["chaperone"]`` and this statistic is that family's, but the family is missing on
    some branches and a driver has to answer for every branch the target walks::

        chaperone = simulate_sequences(g, families=["chaperone"], model=lg(), length=300, seed=3)
        chaperone.composition("KR", absent=0.08)

    On a restricted run with a gap and no ``absent``, resolving the driver raises rather than
    carrying the parent's value forward, which would be a different model from the one asked
    for."""
    from ._composition import Composition
    if not isinstance(letters, str):
        raise TypeError(
            f"composition() takes the letters to count as a string — composition('KR'), not "
            f"{letters!r}.")
    if absent is not None and (isinstance(absent, bool)
                               or not isinstance(absent, (int, float))
                               or not 0.0 <= absent <= 1.0):
        raise ValueError(
            f"absent is the share this statistic reads where the run has no sequence, so it is "
            f"a fraction in [0, 1]; got {absent!r}.")
    return Composition(self, letters.upper(), None if absent is None else float(absent))

gc

gc(family: object = None, *, absent: float | None = None)

This run's GC content as a conditioning driver: composition over "GC", the fraction of a lineage's DNA that is G or C, pooled over every family the run evolved::

seqs = simulate_sequences(g, model=hky85(2.0), length=300, seed=1)
simulate_continuous(tree, rate=PerLineage(1.0).scaled_by(seqs.gc(),
                                                         Curve(lambda x: 4.0 * x)),
                    seed=2)

Nucleotide runs only, because G and C are also glycine and cysteine: on a protein run the call is ambiguous rather than wrong, so it is refused and composition asked for instead. family is here only to refuse one — one family's GC is asked for by restricting the run to it with families=, and absent then says what a branch without it reads (composition).

Source code in zombi2/sequences/__init__.py
def gc(self, family: object = None, *, absent: float | None = None):
    """This run's **GC content** as a conditioning driver: `composition` over ``"GC"``, the
    fraction of a lineage's DNA that is G or C, pooled over every family the run evolved::

        seqs = simulate_sequences(g, model=hky85(2.0), length=300, seed=1)
        simulate_continuous(tree, rate=PerLineage(1.0).scaled_by(seqs.gc(),
                                                                 Curve(lambda x: 4.0 * x)),
                            seed=2)

    Nucleotide runs only, because G and C are also glycine and cysteine: on a protein run the
    call is ambiguous rather than wrong, so it is refused and `composition` asked for instead.
    ``family`` is here only to refuse one — one family's GC is asked for by restricting the run
    to it with ``families=``, and ``absent`` then says what a branch without it reads
    (`composition`)."""
    if family is not None:
        raise ValueError(
            f"gc() is pooled over whatever this run evolved, so it takes no family — got "
            f"{family!r}. To read one family's GC, restrict the run to it instead — "
            f"simulate_sequences(g, families=[{family!r}], ...) — and say what a branch reads "
            f"where that family is absent: .gc() then needs an absent=, or "
            f"composition('GC', absent=...) directly.")
    if set(self.alphabet) != set(BASES):
        raise ValueError(
            f"gc() is GC content, so it needs DNA; this run's alphabet is {self.alphabet!r}. A "
            f"protein run's G and C are glycine and cysteine — ask for those by name with "
            f"composition('GC') if that is what you meant.")
    return self.composition("GC", absent=absent)

summary

summary() -> dict

What this run produced, as a plain dict — the payload of sequences_summary.json.

The identity is the number worth having: it is what says whether the alignments carry signal, and it depends on the height of the tree the run went down, which no flag shows you. The run prints it and warns when it is near the floor; this is the machine-readable copy of that.

Source code in zombi2/sequences/__init__.py
def summary(self) -> dict:
    """What this run produced, as a plain dict — the payload of ``sequences_summary.json``.

    The identity is the number worth having: it is what says whether the alignments carry signal,
    and it depends on the height of the tree the run went down, which no flag shows you. The run
    prints it and warns when it is near the floor; this is the machine-readable copy of that."""
    aligned = {k: v for k, v in self.alignments.items() if v}
    sites = sorted({len(s) for aln in aligned.values() for s in aln.values()})
    # the recorded history is reported only when there is one: a run that did not ask for it
    # writes the summary it always wrote, so a streamed run and an in-memory one still match
    recorded = ({"events": {kind: sum(1 for e in self.events if e.kind == kind)
                            for kind in ("substitution", "insertion", "deletion")}}
                if self.events else {})
    return {
        "level": "sequences",
        "seed": self.seed,
        "unit": self.unit,
        {"family": "families", "block": "blocks"}[self.unit] + "_with_sequences": len(aligned),
        "sequences": sum(len(a) for a in aligned.values()),
        "ancestral_sequences": sum(len(a) for a in self.ancestral.values()),
        # one length on a family run; a nucleotide run gives every block its own, so report the span
        "sites": {"min": sites[0], "max": sites[-1]} if sites else {"min": None, "max": None},
        "mean_pairwise_identity": mean_pairwise_identity(aligned),
        "assembled_genomes": len(self.node_genomes),
        **recorded,
    }

write

write(directory, outputs=('alignments', 'phylograms', 'species_phylogram', 'genomes', 'initial_genome', 'summary'), *, flat: bool = False) -> None

Write chosen outputs to directory (created if needed). <u> below is fam<family> on a family or ordered run and block<index> on a nucleotide one — the integer keys mean different things, so the files say which (see unit):

  • "summary"sequences_summary.json: what came out (summary); the ancestral count is dropped when "ancestral" was not asked for.
  • "alignments"<u>.fasta under alignments/ (skipped for empty families).
  • "ancestral"sequences_ancestral_<u>.fasta under ancestral/.
  • "founding"sequences_founding.fasta, one record <u> apiece: the sequence each family originated with, before its stem.
  • "phylograms"phylogram_<u>_{complete,extant}.nwk (subs/site) under phylograms/.
  • "species_phylogram"clock_species_tree_{complete,extant}.nwk: the species tree with its branches in substitutions/site — the molecular clock made visible.
  • "genomes"genome_<lineage>.fasta under genomes/, one file per node — extant, extinct and ancestral alike — with one record per chromosome. Nucleotide runs only; nothing is written otherwise. The big one: a real genome times every node in the tree.
  • "initial_genome"genome_initial.fasta, in genomes/ with the rest: it is a whole-genome FASTA like they are, and it belongs beside them.

Everything that is one file per family or per node gets a subdirectory, or the two trees and the one founding FASTA would be lost among thousands; flat=True writes everything into directory instead. Nothing is created for an output this run has none of, so a family run leaves no empty genomes/ behind.

Source code in zombi2/sequences/__init__.py
def write(self, directory, outputs=("alignments", "phylograms", "species_phylogram", "genomes",
                                    "initial_genome", "summary"), *, flat: bool = False) -> None:
    """Write chosen ``outputs`` to ``directory`` (created if needed). ``<u>`` below is
    ``fam<family>`` on a family or ordered run and ``block<index>`` on a nucleotide one — the
    integer keys mean different things, so the files say which (see `unit`):

    - ``"summary"`` → ``sequences_summary.json``: what came out (`summary`); the ancestral count
      is dropped when ``"ancestral"`` was not asked for.
    - ``"alignments"`` → ``<u>.fasta`` under ``alignments/`` (skipped for empty families).
    - ``"ancestral"`` → ``sequences_ancestral_<u>.fasta`` under ``ancestral/``.
    - ``"founding"`` → ``sequences_founding.fasta``, one record ``<u>`` apiece: the sequence each
      family originated with, before its stem.
    - ``"phylograms"`` → ``phylogram_<u>_{complete,extant}.nwk`` (subs/site) under ``phylograms/``.
    - ``"species_phylogram"`` → ``clock_species_tree_{complete,extant}.nwk``: the species tree
      with its branches in substitutions/site — the molecular clock made visible.
    - ``"genomes"`` → ``genome_<lineage>.fasta`` under ``genomes/``, one file per node — extant,
      extinct and ancestral alike — with one record per chromosome. Nucleotide runs only; nothing
      is written otherwise. The big one: a real genome times every node in the tree.
    - ``"initial_genome"`` → ``genome_initial.fasta``, in ``genomes/`` with the rest: it is a
      whole-genome FASTA like they are, and it belongs beside them.

    Everything that is one file per family or per node gets a subdirectory, or the two trees and
    the one founding FASTA would be lost among thousands; ``flat=True`` writes everything into
    ``directory`` instead. Nothing is created for an output this run has none of, so a family run
    leaves no empty ``genomes/`` behind.
    """
    unknown = [o for o in outputs if o not in _WRITE_OUTPUTS]
    if unknown:
        raise ValueError(f"unknown write outputs {unknown}; choose from {list(_WRITE_OUTPUTS)}")
    d = pathlib.Path(directory)
    d.mkdir(parents=True, exist_ok=True)
    # a run's directory describes that run: clear the per-unit directories this write is
    # about to fill, so nothing from a previous run survives inside them (see fresh_dirs)
    fresh_dirs(d, ("alignments", "ancestral", "phylograms", "genomes"), flat)
    u = self._stem
    if "alignments" in outputs and any(self.alignments.values()):
        into = grouped_dir(d, "alignments", flat)
        for fam, aln in self.alignments.items():
            if aln:
                _write_fasta(into / f"{u}{fam}.fasta", aln)
    if "ancestral" in outputs and any(self.ancestral.values()):
        into = grouped_dir(d, "ancestral", flat)
        for fam, anc in self.ancestral.items():
            if anc:
                _write_fasta(into / f"sequences_ancestral_{u}{fam}.fasta", anc)
    if "founding" in outputs and self.founding:
        _write_fasta(d / "sequences_founding.fasta",
                     {f"{u}{fam}": seq for fam, seq in sorted(self.founding.items())})
    if "phylograms" in outputs and self.phylograms:
        into = grouped_dir(d, "phylograms", flat)
        for fam, ph in self.phylograms.items():
            complete = ph["complete"]
            assert complete is not None        # only the extant member of a pair can be absent
            (into / f"phylogram_{u}{fam}_complete.nwk").write_text(complete + "\n", encoding="utf-8")
            if ph["extant"] is not None:
                (into / f"phylogram_{u}{fam}_extant.nwk").write_text(ph["extant"] + "\n", encoding="utf-8")
    if "summary" in outputs:
        # The written summary describes the run *as written*, which is not quite what `summary()`
        # describes. Ancestral sequences are reconstructed in memory either way but only land on
        # disk when asked for, so a default run reported a count of ancestral sequences beside a
        # directory that had none — and someone parsing the JSON, which is the point of shipping
        # JSON, concluded the dataset held reconstructions and then could not find them. When you
        # inherit a folder you cannot tell "never written" from "lost in transfer". Dropping the
        # key says there are none here, which is true; a 0 would claim none were reconstructed,
        # which is not.
        written = self.summary()
        if "ancestral" not in outputs:
            written.pop("ancestral_sequences", None)
        write_summary(d / "sequences_summary.json", written)
    # This level's own log, and the one output that is empty unless the run asked for it: a
    # substitution log is bigger than the alignment it explains, so `record=True` is what turns
    # it on and nothing else does (`_record`).
    if "events" in outputs and self.events:
        from ._record import HEADER
        rows = "\n".join("\t".join(str(x) for x in e.row()) for e in self.events)
        (d / "sequence_events.tsv").write_text("\t".join(HEADER) + "\n" + rows + "\n",
                                               encoding="utf-8")
    # A joint run has no species phylogram and says so with None, rather than with a tree that
    # would be one gene's: the species phylogram is the clock made visible, and there each gene's
    # rate reads the other's composition, so no single set of branch lengths is the run's.
    complete = self.species_phylogram["complete"]
    if "species_phylogram" in outputs and complete is not None:
        sp = self.species_phylogram
        (d / "clock_species_tree_complete.nwk").write_text(complete + "\n", encoding="utf-8")
        if sp["extant"] is not None:
            (d / "clock_species_tree_extant.nwk").write_text(sp["extant"] + "\n", encoding="utf-8")
    # every genome is written the same way and named by whose it is — a node label, or "initial"
    for token, genomes in (("genomes", self.node_genomes),
                           ("initial_genome",
                            {"initial": self.initial_genome} if self.initial_genome else {})):
        if token in outputs and genomes:   # both land in genomes/: an assembled genome either way
            into = grouped_dir(d, "genomes", flat)
            for lineage, chroms in genomes.items():
                _write_fasta(into / f"genome_{lineage}.fasta",
                             {f"{lineage}_chr{cid}": seq for cid, seq in chroms.items()})

zombi2.sequences.StreamedSequences dataclass

StreamedSequences(directory: str, seed: int | None, n_families: int, n_sequences: int, outputs: tuple, identity: 'float | None' = None, sites: 'int | None' = None, n_ancestral: int = 0, unit: str = 'family')

A sequence run written straight to disk, family by family — what stream_to= returns.

Thin on purpose: the outputs are the files, so this carries where they are and how big the run was, not the run itself. The sequence level is where a run's memory actually goes — every family's alignment plus its ancestral sequences, all live at once — so this is the handle for the size at which a SequencesResult would not fit.

summary

summary() -> dict

The same payload SequencesResult.summary() builds, from what the sink counted as it wrote.

A streamed run has to produce the same files as an in-memory one at the same seed — that is the whole contract — so it produces this one too. Two fields it cannot know: the ancestral count and the assembled genomes, neither of which a streamed family run writes.

Source code in zombi2/sequences/__init__.py
def summary(self) -> dict:
    """The same payload `SequencesResult.summary()` builds, from what the sink counted as it wrote.

    A streamed run has to produce the same files as an in-memory one at the same seed — that is
    the whole contract — so it produces this one too. Two fields it cannot know: the ancestral
    count and the assembled genomes, neither of which a streamed family run writes."""
    return {
        "level": "sequences",
        "seed": self.seed,
        "unit": self.unit,
        {"family": "families", "block": "blocks"}[self.unit] + "_with_sequences":
            self.n_families,
        "sequences": self.n_sequences,
        "ancestral_sequences": self.n_ancestral,
        "sites": {"min": self.sites, "max": self.sites},
        "mean_pairwise_identity": self.identity,
        "assembled_genomes": 0,
    }

zombi2.sequences.mean_pairwise_identity

mean_pairwise_identity(alignments) -> float | None

Mean identity over every within-family sequence pair, or None when no family holds two sequences to compare.

Exhaustive, and the same quantity however the run was written: it used to be a bounded random sample, and the streamed path sampled differently, so one flag that only chooses how much memory a run uses moved a number in the report while leaving every alignment byte-identical. Nothing said the number was an estimate, so it read as a fingerprint two matching runs could disagree on. _identity_counts makes counting them all linear in the alignment, so there is no longer a reason to estimate.

Source code in zombi2/sequences/__init__.py
def mean_pairwise_identity(alignments) -> float | None:
    """Mean identity over **every** within-family sequence pair, or ``None`` when no family holds two
    sequences to compare.

    Exhaustive, and the same quantity however the run was written: it used to be a bounded random
    sample, and the streamed path sampled differently, so one flag that only chooses how much memory
    a run uses moved a number in the report while leaving every alignment byte-identical. Nothing
    said the number was an estimate, so it read as a fingerprint two matching runs could disagree on.
    `_identity_counts` makes counting them all linear in the alignment, so there is no longer a reason
    to estimate."""
    matched = compared = 0
    for a in alignments.values():
        m, c = _identity_counts(list(a.values()))
        matched += m
        compared += c
    return matched / compared if compared else None

The substitution-model menu

A substitution model is a K×K rate matrix Q, normalised to one expected substitution per site per unit branch length, and its stationary frequencies. Different models are genuinely different matrices, so they stay a menu of constructors rather than one grammar.

zombi2.sequences.substitution_models

Substitution models — the menu.

A substitution model is the chemistry of a sequence: a K×K rate matrix Q (normalised to one expected substitution per site per unit branch length) and its stationary frequencies π. Different models are genuinely different matrices — Jukes–Cantor, K80, HKY85, GTR differ in their transition/transversion structure and base composition — so, unlike the clock, they do not collapse to one grammar: they stay a menu of constructors, each taking its own physical parameters.

Two alphabets are on the menu: the four nucleotide models (4 states, ACGTjc69() · k80() · hky85() · gtr()) and the five protein models (20 states, AMINO_ACIDSpoisson() · jtt() · dayhoff() · wag() · lg()). The protein models are empirical: their exchangeabilities and frequencies were estimated once from large alignments and are read off the published matrices (_aa_matrices), so they take no free parameters — you pick one, you do not tune it. Codon models are not in the menu; adding one is a pure extension of it, no refactor.

A matrix of your own goes through reversible(): a symmetric exchangeability matrix S and frequencies π, normalised the same way, over any alphabet. It is the door the menu itself uses, so gtr() is its four-state case and lg() its twenty-state one. It takes S and π rather than Q because the engine requires a time-reversible model and S·π cannot be anything else; a general Q is refused rather than evaluated wrongly (see SubstitutionModel).

Across-site rate variation decorates any model on the menu rather than adding entries to it: SubstitutionModel.across_sites() returns the same chemistry with its sites sorted into rate classes — a discretised Gamma (), a class that never changes (+I), or both. That is where the field puts it (HKY85+I+G4) and, more to the point, it is not a rate modifier: SPEC §5's modifiers multiply one rate by a context factor, while this splits the sites of one rate into classes. Putting it on the model keeps the rate grammar to one job and lets the spacer of a nucleotide run carry its own (or no) variation.

Every model here is time-reversible, so the transition matrix over a branch of length t (in substitutions/site), P(t) = exp(Q·t), is computed by eigendecomposition of the symmetric matrix B = diag(√π)·Q·diag(1/√π) (numpy only, no scipy): P(t) = diag(1/√π)·V·exp(Λt)·Vᵀ·diag(√π).

SubstitutionModel dataclass

SubstitutionModel(name: str, Q: ndarray, stationary: ndarray, alphabet: str = BASES, site_rates: tuple[float, ...] = (1.0,), site_shares: tuple[float, ...] = (1.0,))

A K-state reversible model: a normalised K×K rate matrix Q, its stationary frequencies, and the ordered alphabet whose order Q / stationary follow.

Built through the menu constructors (jc69(), k80(), hky85(), gtr()) or, for a matrix of your own, through reversible() — which takes a symmetric exchangeability matrix and the frequencies rather than Q itself, so a non-reversible model cannot be spelled. The reversible eigendecomposition behind p_matrix() is precomputed once in __post_init__, and so is the check that the matrix it is being applied to is reversible at all: the transform is only a similarity transform of Q under detailed balance, so a matrix that violates it would give transition probabilities that are wrong without looking wrong. That check lives here rather than only in the constructors because this class is public, and every route into it has to end up in a state the engine can honour.

site_rates and site_shares carry across-site rate variation: the sites of a sequence are sorted into classes, site_rates[c] multiplying the branch length for the sites in class c and site_shares[c] giving the proportion of sites that fall there. A plain model has the one class (1.0,) / (1.0,) and every site evolves at the same speed; across_sites() builds the rest. The two are dimensionless — a class scales a branch length, it is not a rate in SPEC §5's time⁻¹ sense — and they obey one invariant:

``sum(rate × share) == 1``

which is what makes a branch length the mean substitutions per site. Every phylogram in the result is drawn with that meaning, so the invariant is checked here rather than trusted.

k property

k: int

Number of states in the alphabet.

p_matrix

p_matrix(t: float) -> np.ndarray

Transition probabilities over branch length t (substitutions/site).

P(t) = exp(Qt) via the reversible eigendecomposition; clipped to [0, ∞) to scrub tiny negative round-off so every row is a valid probability distribution.

Source code in zombi2/sequences/substitution_models.py
def p_matrix(self, t: float) -> np.ndarray:
    """Transition probabilities over branch length ``t`` (substitutions/site).

    ``P(t) = exp(Qt)`` via the reversible eigendecomposition; clipped to ``[0, ∞)`` to scrub tiny
    negative round-off so every row is a valid probability distribution.
    """
    # The BLAS matmul kernel can raise spurious FP flags on larger matrices even when every input
    # is finite and the result is a valid stochastic matrix; silence them — the clip is the guard.
    with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
        P = (self._left * np.exp(self._eigvals * t)) @ self._right
    return np.clip(P, 0.0, None)

across_sites

across_sites(*, gamma_shape: float | None = None, invariant: float = 0.0, rate_categories: int = 4) -> 'SubstitutionModel'

The same model with its sites sorted into rate classes, +I, or both.

Every site of a gene evolving at exactly one speed is a model no real gene obeys: some positions are held nearly fixed by function, others race ahead. Two standard knobs say so, and they compose:

  • gamma_shape (α) draws each site's rate from a Gamma with mean 1, discretised into rate_categories equal-probability classes (Yang 1994; see _site_rates). A small shape is strong variation — 0.5 gives a few fast sites among many slow ones — and a large one is nearly flat. There is no default: asking for means choosing how unequal the sites are.
  • invariant (0 by default) is the proportion of sites that never change: one more class, at rate 0. Real alignments have columns that are constant because the site cannot change, not because it happened not to, and a Gamma alone fits those badly.

A site's class is drawn once and holds for the whole tree — that is what across-site variation means, as against a rate that varies along a branch. Sites stay independent, so this is a knob on the existing engine and not a new one (SPEC §9).

The classes are normalised to mean 1, invariant sites included: the Gamma classes are scaled by 1 / (1 - invariant) to make room for the sites that never change. So a branch length stays the mean substitutions per site, a run with variation and one without have the same phylogram at the same rate, and only the spread of change across columns differs.

This is not a rate modifier (SPEC §5), and simulate_sequences will still refuse one on substitution: a modifier multiplies one rate by a context factor, while this splits one rate's sites into classes. It belongs to the model, which is where the field puts it — the decorated name reads HKY85+I+G4, and it is what the run reports.

Returns a new model; the original is unchanged (they are frozen).

Source code in zombi2/sequences/substitution_models.py
def across_sites(self, *, gamma_shape: float | None = None, invariant: float = 0.0,
                 rate_categories: int = 4) -> "SubstitutionModel":
    """The same model with its sites sorted into **rate classes** — ``+Γ``, ``+I``, or both.

    Every site of a gene evolving at exactly one speed is a model no real gene obeys: some
    positions are held nearly fixed by function, others race ahead. Two standard knobs say so,
    and they compose:

    - ``gamma_shape`` (α) draws each site's rate from a **Gamma** with mean 1, discretised into
      ``rate_categories`` equal-probability classes (Yang 1994; see `_site_rates`). A small shape
      is strong variation — 0.5 gives a few fast sites among many slow ones — and a large one is
      nearly flat. There is no default: asking for ``+Γ`` means choosing how unequal the sites are.
    - ``invariant`` (0 by default) is the proportion of sites that **never** change: one more
      class, at rate 0. Real alignments have columns that are constant because the site cannot
      change, not because it happened not to, and a Gamma alone fits those badly.

    A site's class is drawn once and holds for the whole tree — that is what across-site
    variation means, as against a rate that varies along a branch. Sites stay independent, so
    this is a knob on the existing engine and not a new one (``SPEC §9``).

    **The classes are normalised to mean 1**, invariant sites included: the Gamma classes are
    scaled by ``1 / (1 - invariant)`` to make room for the sites that never change. So a branch
    length stays the **mean** substitutions per site, a run with variation and one without have
    the same phylogram at the same rate, and only the spread of change across columns differs.

    This is **not a rate modifier** (``SPEC §5``), and `simulate_sequences` will still refuse one
    on ``substitution``: a modifier multiplies one rate by a context factor, while this splits
    one rate's sites into classes. It belongs to the model, which is where the field puts it —
    the decorated ``name`` reads ``HKY85+I+G4``, and it is what the run reports.

    Returns a new model; the original is unchanged (they are frozen).
    """
    if len(self.site_rates) > 1:
        # Composing two calls would have to decide what a Gamma over an already-classed model
        # means, and there is exactly one sensible spelling of every combination this offers —
        # both knobs in one call. Refusing is the cheap half of that; the expensive half would be
        # a second set of classes replacing the first while the name claimed both.
        raise ValueError(
            f"{self.name} already varies across sites, and across_sites() replaces the classes "
            f"rather than layering on them. Give every knob in one call — "
            f"across_sites(gamma_shape=…, invariant=…) — starting from the plain model.")
    if isinstance(invariant, bool) or not isinstance(invariant, (int, float)):
        raise TypeError(f"invariant must be a real proportion of sites, got {invariant!r}")
    if not 0.0 <= invariant < 1.0:
        raise ValueError(
            f"invariant is the proportion of sites that never change, so it must be in [0, 1), "
            f"got {invariant!r}" + (" — at 1.0 no site could ever change and there would be no "
                                    "sequence evolution left to simulate" if invariant == 1.0 else ""))
    # named before the nothing-to-vary case below: someone who typed a category count meant to
    # ask for a Gamma, and the useful reply names the argument they left out
    if gamma_shape is None and rate_categories != 4:
        raise ValueError(
            f"rate_categories={rate_categories} counts the classes of the Gamma, but no "
            f"gamma_shape was given — add gamma_shape=…, or drop rate_categories (invariant= "
            f"on its own is a single never-changing class and takes no count).")
    if gamma_shape is None and not invariant:
        raise ValueError(
            "across_sites() has nothing to vary — give gamma_shape=… for a Gamma of rates "
            "across sites, invariant=… for a class of sites that never change, or both.")

    rates: list[float] = []
    shares: list[float] = []
    suffix = ""
    if invariant:
        rates.append(0.0)
        shares.append(float(invariant))
        suffix += "+I"
    # Everything not in the invariant class carries the whole mean of 1 between it, because the
    # invariant sites contribute nothing — hence the 1/(1 - invariant) scaling. Without it, +I
    # would quietly slow the whole sequence down instead of concentrating its change.
    varying = 1.0 - invariant
    scale = 1.0 / varying
    if gamma_shape is not None:
        rates.extend(r * scale for r in discrete_gamma(gamma_shape, rate_categories))
        shares.extend([varying / rate_categories] * rate_categories)
        suffix += f"+G{rate_categories}"
    else:
        # +I on its own: one class for every site that can change at all
        rates.append(scale)
        shares.append(varying)
    # replace() re-runs __post_init__, so the eigendecomposition is rebuilt (once) and the
    # mean-1 invariant above is checked on the way out rather than assumed
    return replace(self, name=self.name + suffix,
                   site_rates=tuple(rates), site_shares=tuple(shares))

jc69

jc69() -> SubstitutionModel

Jukes–Cantor (1969): equal rates, equal base frequencies. No free parameters.

Source code in zombi2/sequences/substitution_models.py
def jc69() -> SubstitutionModel:
    """Jukes–Cantor (1969): equal rates, equal base frequencies. No free parameters."""
    return _gtr_model("JC69", [1, 1, 1, 1, 1, 1], [0.25] * 4)

k80

k80(kappa: float = 2.0) -> SubstitutionModel

Kimura 2-parameter (1980): transition/transversion ratio kappa, equal frequencies.

Source code in zombi2/sequences/substitution_models.py
def k80(kappa: float = 2.0) -> SubstitutionModel:
    """Kimura 2-parameter (1980): transition/transversion ratio ``kappa``, equal frequencies."""
    return _gtr_model("K80", [1, kappa, 1, 1, kappa, 1], [0.25] * 4)

hky85

hky85(kappa: float = 2.0, frequencies=(0.25, 0.25, 0.25, 0.25)) -> SubstitutionModel

HKY85 (Hasegawa–Kishino–Yano 1985): transition bias kappa with unequal base frequencies (A,C,G,T).

Source code in zombi2/sequences/substitution_models.py
def hky85(kappa: float = 2.0, frequencies=(0.25, 0.25, 0.25, 0.25)) -> SubstitutionModel:
    """HKY85 (Hasegawa–Kishino–Yano 1985): transition bias ``kappa`` with unequal base ``frequencies`` (A,C,G,T)."""
    return _gtr_model("HKY85", [1, kappa, 1, 1, kappa, 1], frequencies)

gtr

gtr(exchangeabilities=(1, 1, 1, 1, 1, 1), frequencies=(0.25, 0.25, 0.25, 0.25)) -> SubstitutionModel

General time-reversible: 6 exchangeabilities [AC,AG,AT,CG,CT,GT] and frequencies (A,C,G,T). This is reversible() restricted to four states, and the two name the numbers the same thing on purpose — they are the same numbers.

Source code in zombi2/sequences/substitution_models.py
def gtr(exchangeabilities=(1, 1, 1, 1, 1, 1),
        frequencies=(0.25, 0.25, 0.25, 0.25)) -> SubstitutionModel:
    """General time-reversible: 6 ``exchangeabilities`` ``[AC,AG,AT,CG,CT,GT]`` and ``frequencies``
    (A,C,G,T). This is `reversible()` restricted to four states, and the two name the numbers the
    same thing on purpose — they are the same numbers."""
    return _gtr_model("GTR", exchangeabilities, frequencies)

reversible

reversible(exchangeabilities, frequencies, *, name: str = 'Custom', alphabet: str = BASES) -> SubstitutionModel

A model of your own matrix: a symmetric K×K exchangeability matrix and K stationary frequencies, over any alphabet.

Q_ij = S_ij · π_j for i ≠ j, the diagonal set from the row sums, and the whole matrix scaled so that -Σ π_i Q_ii = 1 — one expected substitution per site per unit branch length, exactly as every model on the menu is scaled. That normalisation is what keeps a branch length meaning substitutions per site whatever matrix produced it, so a phylogram from a model built here is comparable with one from hky85() without a conversion.

This is the same door the menu already goes through, so the menu is its special cases: gtr() is this with K = 4 and the six nucleotide exchangeabilities spelled out, and lg() is this with K = 20 and a published triangle. Give alphabet=AMINO_ACIDS for a 20-state protein matrix of your own, or any K distinct ASCII characters for an alphabet that is neither — though the rest of the level only makes sense for those two: a nucleotide genome run reads its blocks on both strands and so refuses anything but ACGT, and the FASTA it writes is whatever letters you chose.

You give S and π rather than Q, and that is the point. The engine's p_matrix() computes exp(Qt) by eigendecomposing diag(√π)·Q·diag(1/√π), which is a symmetric matrix similar to Q only when π_i Q_ij = π_j Q_ji — detailed balance. A symmetric S times π obeys that by construction, so there is no way to spell a model here that the engine would evaluate wrongly. A general Q handed straight to SubstitutionModel could be, which is why the dataclass checks the same identity and refuses. Non-reversible models (UNREST and its relatives) are not implemented — a statement about this code, not about the models (SPEC §5).

S must be symmetric, non-negative, and zero on the diagonal; π strictly positive and summing to 1. name is what the run prints and what error messages quote, so it is worth setting to whatever the matrix actually is.

There is deliberately no command-line flag for this. SPEC §5 asks for one written form everywhere, and a K×K matrix has no written form a command line can carry: at K = 20 it is 190 numbers, which is a file format, not an argument. It stays a Python constructor; the CLI's --model offers the menu, which is what a flag can honestly hold.

Experimental (SPEC §9): Python API only, and the spelling may still move.

Source code in zombi2/sequences/substitution_models.py
def reversible(exchangeabilities, frequencies, *, name: str = "Custom",
               alphabet: str = BASES) -> SubstitutionModel:
    """A model of **your own matrix**: a symmetric ``K×K`` exchangeability matrix and ``K``
    stationary frequencies, over any alphabet.

    ``Q_ij = S_ij · π_j`` for ``i ≠ j``, the diagonal set from the row sums, and the whole matrix
    scaled so that ``-Σ π_i Q_ii = 1`` — one expected substitution per site per unit branch length,
    exactly as every model on the menu is scaled. That normalisation is what keeps a branch length
    meaning **substitutions per site** whatever matrix produced it, so a phylogram from a model built
    here is comparable with one from `hky85()` without a conversion.

    This is the same door the menu already goes through, so the menu is its special cases: `gtr()` is
    this with ``K = 4`` and the six nucleotide exchangeabilities spelled out, and `lg()` is this with
    ``K = 20`` and a published triangle. Give ``alphabet=AMINO_ACIDS`` for a 20-state protein matrix
    of your own, or any ``K`` distinct ASCII characters for an alphabet that is neither — though the
    rest of the level only makes sense for those two: a **nucleotide** genome run reads its blocks on
    both strands and so refuses anything but ``ACGT``, and the FASTA it writes is whatever letters
    you chose.

    **You give S and π rather than Q, and that is the point.** The engine's `p_matrix()` computes
    ``exp(Qt)`` by eigendecomposing ``diag(√π)·Q·diag(1/√π)``, which is a symmetric matrix similar to
    ``Q`` only when ``π_i Q_ij = π_j Q_ji`` — detailed balance. A symmetric ``S`` times ``π`` obeys
    that by construction, so there is no way to spell a model here that the engine would evaluate
    wrongly. A general ``Q`` handed straight to `SubstitutionModel` could be, which is why the
    dataclass checks the same identity and refuses. Non-reversible models (UNREST and its relatives)
    are **not implemented** — a statement about this code, not about the models (SPEC §5).

    ``S`` must be symmetric, non-negative, and zero on the diagonal; ``π`` strictly positive and
    summing to 1. ``name`` is what the run prints and what error messages quote, so it is worth
    setting to whatever the matrix actually is.

    There is deliberately **no command-line flag** for this. SPEC §5 asks for one written form
    everywhere, and a ``K×K`` matrix has no written form a command line can carry: at ``K = 20`` it
    is 190 numbers, which is a file format, not an argument. It stays a Python constructor; the CLI's
    ``--model`` offers the menu, which is what a flag can honestly hold.

    Experimental (SPEC §9): Python API only, and the spelling may still move.
    """
    return _reversible_model(name, exchangeabilities, frequencies, alphabet)

poisson

poisson() -> SubstitutionModel

Poisson: equal exchangeabilities, equal frequencies — the JC69 of proteins. No free parameters.

Source code in zombi2/sequences/substitution_models.py
def poisson() -> SubstitutionModel:
    """Poisson: equal exchangeabilities, equal frequencies — the JC69 of proteins. No free parameters."""
    S = np.ones((20, 20)) - np.eye(20)
    return _reversible_model("Poisson", S, np.full(20, 1.0 / 20.0), AMINO_ACIDS)

jtt

jtt() -> SubstitutionModel

JTT (Jones, Taylor & Thornton 1992): the empirical matrix from close protein homologues.

Source code in zombi2/sequences/substitution_models.py
def jtt() -> SubstitutionModel:
    """JTT (Jones, Taylor & Thornton 1992): the empirical matrix from close protein homologues."""
    return _empirical_protein("JTT", _JTT_EXCH, _JTT_PI)

dayhoff

dayhoff() -> SubstitutionModel

Dayhoff (Dayhoff, Schwartz & Orcutt 1978): the original PAM matrix, in PAML's values.

Source code in zombi2/sequences/substitution_models.py
def dayhoff() -> SubstitutionModel:
    """Dayhoff (Dayhoff, Schwartz & Orcutt 1978): the original PAM matrix, in PAML's values."""
    return _empirical_protein("Dayhoff", _DAYHOFF_EXCH, _DAYHOFF_PI)

wag

wag() -> SubstitutionModel

WAG (Whelan & Goldman 2001): estimated by maximum likelihood over a wide protein database.

Source code in zombi2/sequences/substitution_models.py
def wag() -> SubstitutionModel:
    """WAG (Whelan & Goldman 2001): estimated by maximum likelihood over a wide protein database."""
    return _empirical_protein("WAG", _WAG_EXCH, _WAG_PI)

lg

lg() -> SubstitutionModel

LG (Le & Gascuel 2008): WAG's successor, fitted with across-site rate variation — the default protein model of modern phylogenetics.

Source code in zombi2/sequences/substitution_models.py
def lg() -> SubstitutionModel:
    """LG (Le & Gascuel 2008): WAG's successor, fitted with across-site rate variation — the
    default protein model of modern phylogenetics."""
    return _empirical_protein("LG", _LG_EXCH, _LG_PI)

decode

decode(states: ndarray, alphabet: str = BASES) -> str

Map an array of integer states back to a string over alphabetACGT by default, or AMINO_ACIDS for a protein model (callers pass model.alphabet).

states are indices into alphabet, so the whole array is one numpy gather into an ASCII lookup table — lut[states] — read out in a single .tobytes().decode() rather than one Python step per site. This is called once per node of every gene tree, so the per-site loop it replaces was the dominant cost of a sequence run; the result is byte-for-byte the same string. The lookup table itself is cached per alphabet (_DECODE_LUT), built once instead of per call.

states may be multi-dimensional: a 2-D (rows, length) array decodes to the rows' strings concatenated back to back (row-major), which lets a caller decode a whole gene tree's nodes in one gather + one ASCII decode and slice the fixed-length rows out — see _split().

Source code in zombi2/sequences/substitution_models.py
def decode(states: np.ndarray, alphabet: str = BASES) -> str:
    """Map an array of integer states back to a string over ``alphabet`` — ``ACGT`` by default, or
    `AMINO_ACIDS` for a protein model (callers pass ``model.alphabet``).

    ``states`` are indices into ``alphabet``, so the whole array is one numpy gather into an ASCII
    lookup table — ``lut[states]`` — read out in a single ``.tobytes().decode()`` rather than one
    Python step per site. This is called once per node of every gene tree, so the per-site loop it
    replaces was the dominant cost of a sequence run; the result is byte-for-byte the same string. The
    lookup table itself is cached per alphabet (`_DECODE_LUT`), built once instead of per call.

    ``states`` may be multi-dimensional: a 2-D ``(rows, length)`` array decodes to the rows' strings
    concatenated back to back (row-major), which lets a caller decode a whole gene tree's nodes in one
    gather + one ASCII decode and slice the fixed-length rows out — see `_split()`."""
    lut = _DECODE_LUT.get(alphabet)
    if lut is None:
        lut = np.frombuffer(alphabet.encode("ascii"), dtype=np.uint8)
        _DECODE_LUT[alphabet] = lut
    return lut[np.asarray(states)].tobytes().decode("ascii")

encode

encode(seq: str, alphabet: str = BASES) -> np.ndarray

The inverse of decode(): a string over alphabet to its integer states. Used to found a run's blocks from a real fasta= — the supplied DNA becomes a block's founding states. A character not in alphabet raises (the FASTA reader already rejects non-ACGT, so this is a second guard).

Source code in zombi2/sequences/substitution_models.py
def encode(seq: str, alphabet: str = BASES) -> np.ndarray:
    """The inverse of `decode()`: a string over ``alphabet`` to its integer states. Used to found a
    run's blocks from a real ``fasta=`` — the supplied DNA becomes a block's founding states. A character not in
    ``alphabet`` raises (the FASTA reader already rejects non-``ACGT``, so this is a second guard)."""
    index = {c: i for i, c in enumerate(alphabet)}
    try:
        return np.fromiter((index[c] for c in seq), dtype=np.int8, count=len(seq))
    except KeyError as e:
        raise ValueError(f"sequence has {e.args[0]!r}, not in the model's alphabet {alphabet!r}") from None