zombi2 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. zombi2/__init__.py +148 -0
  2. zombi2/__main__.py +6 -0
  3. zombi2/_rust.py +457 -0
  4. zombi2/_sampling.py +81 -0
  5. zombi2/cli.py +2195 -0
  6. zombi2/coevolve/__init__.py +51 -0
  7. zombi2/coevolve/cladogenetic_genome.py +183 -0
  8. zombi2/coevolve/gene_conditioned_trait.py +169 -0
  9. zombi2/coevolve/gene_diversification.py +416 -0
  10. zombi2/coevolve/sse.py +513 -0
  11. zombi2/coevolve/trait_coupling.py +443 -0
  12. zombi2/coevolve/trait_gene_feedback.py +224 -0
  13. zombi2/distributions.py +117 -0
  14. zombi2/experimental/__init__.py +72 -0
  15. zombi2/experimental/gene_conversion.py +123 -0
  16. zombi2/genomes/__init__.py +44 -0
  17. zombi2/genomes/events.py +212 -0
  18. zombi2/genomes/genome.py +471 -0
  19. zombi2/genomes/genome_sim.py +455 -0
  20. zombi2/genomes/gff.py +149 -0
  21. zombi2/genomes/nucleotide_genome.py +843 -0
  22. zombi2/genomes/nucleotide_sim.py +638 -0
  23. zombi2/genomes/profiles.py +225 -0
  24. zombi2/genomes/rates.py +325 -0
  25. zombi2/genomes/reconciliation.py +459 -0
  26. zombi2/genomes/simulation.py +510 -0
  27. zombi2/genomes/transfers.py +43 -0
  28. zombi2/parallel.py +122 -0
  29. zombi2/sequences/__init__.py +43 -0
  30. zombi2/sequences/_aa_models.py +134 -0
  31. zombi2/sequences/clocks.py +438 -0
  32. zombi2/sequences/evolution.py +252 -0
  33. zombi2/sequences/models.py +462 -0
  34. zombi2/species/__init__.py +28 -0
  35. zombi2/species/forward.py +513 -0
  36. zombi2/species/ghosts.py +310 -0
  37. zombi2/species/model.py +407 -0
  38. zombi2/species/sim.py +161 -0
  39. zombi2/tools/__init__.py +59 -0
  40. zombi2/tools/reconciliation/__init__.py +159 -0
  41. zombi2/tools/reconciliation/_rust.py +122 -0
  42. zombi2/tools/reconciliation/dated.py +295 -0
  43. zombi2/tools/reconciliation/genetree.py +104 -0
  44. zombi2/tools/reconciliation/scoring.py +89 -0
  45. zombi2/tools/reconciliation/species.py +96 -0
  46. zombi2/tools/reconciliation/undated.py +272 -0
  47. zombi2/traits/__init__.py +39 -0
  48. zombi2/traits/biogeography.py +208 -0
  49. zombi2/traits/models.py +1328 -0
  50. zombi2/tree.py +223 -0
  51. zombi2-0.2.0.dist-info/METADATA +156 -0
  52. zombi2-0.2.0.dist-info/RECORD +55 -0
  53. zombi2-0.2.0.dist-info/WHEEL +4 -0
  54. zombi2-0.2.0.dist-info/entry_points.txt +2 -0
  55. zombi2-0.2.0.dist-info/licenses/LICENSE +21 -0
zombi2/__init__.py ADDED
@@ -0,0 +1,148 @@
1
+ """ZOMBI2 — simulation of species trees (backward) and gene families (forward).
2
+
3
+ Public API::
4
+
5
+ import zombi2 as z
6
+
7
+ tree = z.simulate_species_tree(z.BirthDeath(birth=1.0, death=0.3),
8
+ n_tips=20, age=5.0, seed=1)
9
+
10
+ # every family the same rates:
11
+ genomes = z.simulate_genomes(tree, duplication=0.2, transfer=0.1, loss=0.25,
12
+ origination=0.5, seed=42)
13
+
14
+ # every family its own rates, sampled from distributions:
15
+ genomes = z.simulate_genomes(tree, z.FamilySampledRates(
16
+ duplication=z.Gamma(2, 0.1), transfer=z.Exponential(0.1),
17
+ loss=z.Gamma(2, 0.12), origination=0.5), seed=42)
18
+
19
+ genomes.profiles.matrix # families x extant-species copy numbers
20
+ genomes.write("out/")
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ __version__ = "0.2.0"
26
+
27
+ # Low-level primitives (kept at top level only; no scikit-style namespace).
28
+ from zombi2.genomes.events import EventType, GeneOp, EventRecord, Selection, Region, TargetParams
29
+ from zombi2._rust import available as rust_available
30
+
31
+ # scikit-learn-style namespaces are the single source of truth for the
32
+ # public API: importing from them here guarantees that, e.g.,
33
+ # ``zombi2.BirthDeath is zombi2.species.BirthDeath`` (same object). The
34
+ # namespace modules are thin re-exports over the implementation modules and do
35
+ # not redefine anything; see zombi2/species.py, zombi2/genomes.py, etc.
36
+ from zombi2.species import (
37
+ Tree, TreeNode, read_newick, prune,
38
+ BirthDeath, Yule, EpisodicBirthDeath, ClaDS, DiversityDependent,
39
+ CladeShiftBirthDeath, simulate_species_tree, add_ghost_lineages,
40
+ )
41
+ from zombi2.genomes import (
42
+ Gene, Genome, UnorderedGenome, OrderedGene, OrderedGenome,
43
+ NucleotideGenome, Segment, simulate_nucleotide_genomes, NucleotideResult, Block,
44
+ read_gff, GffGenome,
45
+ RateModel, SharedRates, PerGenomeRates, FamilySampledRates, BranchRates,
46
+ EventWeight, TransferModel,
47
+ GenomeSimulator, GenomeResult, ProfileMatrix,
48
+ simulate_genomes, Genomes, GenomeTrace, read_events_trace,
49
+ build_gene_trees, run_replicates,
50
+ )
51
+ from zombi2.distributions import (
52
+ Distribution, Fixed, Exponential, Gamma, LogNormal, Uniform, as_distribution,
53
+ )
54
+ from zombi2.sequences import (
55
+ # relaxed molecular clocks (chronogram -> phylogram; the shared lineage clock family)
56
+ Clock, RateScaledTree, StrictClock, UncorrelatedLogNormalClock,
57
+ UncorrelatedGammaClock, WhiteNoiseClock, AutocorrelatedLogNormalClock,
58
+ CIRClock, RateVariation,
59
+ # substitution models + the gene x lineage substitution clock
60
+ SequenceEvolution, GenePhylograms,
61
+ SubstitutionModel, GammaRates, jc69, k80, hky85, gtr,
62
+ poisson, lg, wag, jtt, dayhoff, make_model, is_protein_model,
63
+ DNA_MODELS, PROTEIN_MODELS, AMINO_ACIDS,
64
+ evolve_on_tree, read_fasta, write_fasta,
65
+ )
66
+ from zombi2.traits import (
67
+ BrownianMotion, OrnsteinUhlenbeck, MultivariateBrownian, MultivariateOU,
68
+ MultiOptimumOU, ThresholdModel, EarlyBurst, Mk, CorrelatedBinary,
69
+ CorrelatedBinaryK, HiddenStateMk, Cladogenesis, simulate_traits,
70
+ replicate_traits, TraitResult,
71
+ pagel_lambda, pagel_delta, pagel_kappa,
72
+ DEC, simulate_biogeography,
73
+ )
74
+ from zombi2.coevolve import (
75
+ TraitGeneCoupling, TraitTrajectory, TraitLinkedRates, TraitLinkedResult,
76
+ simulate_trait_linked_genomes,
77
+ GeneDiversification, GeneDiversificationResult, simulate_gene_diversification,
78
+ simulate_co_diversification,
79
+ CladogeneticGenome, CladogeneticGenomeResult, simulate_cladogenetic_genome,
80
+ GeneConditionedTrait, GeneConditionedTraitResult, simulate_gene_conditioned_trait,
81
+ TraitGeneFeedback, TraitGeneFeedbackResult, simulate_trait_gene_feedback,
82
+ BiSSE, MuSSE, HiSSE, QuaSSE, simulate_sse,
83
+ )
84
+ # NOTE: ABC profile-matching inference has moved out of the core to
85
+ # ``ZOMBI2_FUTURE/abc-inference/`` — inference is a Phase-3 Extension, not a core simulation
86
+ # level. See that folder's README to revive it as an Extension.
87
+
88
+ __all__ = [
89
+ "__version__",
90
+ # events
91
+ "EventType", "GeneOp", "EventRecord", "Selection", "Region", "TargetParams",
92
+ # tree
93
+ "Tree", "TreeNode", "read_newick", "prune",
94
+ # species tree
95
+ "BirthDeath", "Yule", "EpisodicBirthDeath", "ClaDS", "DiversityDependent",
96
+ "CladeShiftBirthDeath", "simulate_species_tree", "add_ghost_lineages",
97
+ # genome
98
+ "Gene", "Genome", "UnorderedGenome", "OrderedGene", "OrderedGenome",
99
+ # nucleotide genome (structural events at nucleotide resolution)
100
+ "NucleotideGenome", "Segment", "simulate_nucleotide_genomes", "NucleotideResult", "Block",
101
+ "read_gff", "GffGenome",
102
+ # rates & transfers
103
+ "RateModel", "SharedRates", "PerGenomeRates", "FamilySampledRates",
104
+ "BranchRates", "EventWeight", "TransferModel",
105
+ # distributions
106
+ "Distribution", "Fixed", "Exponential", "Gamma", "LogNormal", "Uniform", "as_distribution",
107
+ # simulation
108
+ "GenomeSimulator", "GenomeResult", "ProfileMatrix", "simulate_genomes", "Genomes", "GenomeTrace",
109
+ "read_events_trace", "build_gene_trees",
110
+ # trait-conditioned gene families (trait <-> gene-family coupling)
111
+ "TraitGeneCoupling", "TraitTrajectory", "TraitLinkedRates", "TraitLinkedResult",
112
+ "simulate_trait_linked_genomes",
113
+ # relaxed molecular clocks (chronogram -> phylogram; the shared lineage clock family)
114
+ "Clock", "RateScaledTree", "StrictClock", "UncorrelatedLogNormalClock",
115
+ "UncorrelatedGammaClock", "WhiteNoiseClock", "AutocorrelatedLogNormalClock",
116
+ "CIRClock", "RateVariation",
117
+ # family sequence evolution (gene x lineage substitution clock)
118
+ "SequenceEvolution", "GenePhylograms",
119
+ # sequence simulation (evolve DNA or protein along a gene tree; ancestral genome reconstruction)
120
+ "SubstitutionModel", "GammaRates", "jc69", "k80", "hky85", "gtr",
121
+ "poisson", "lg", "wag", "jtt", "dayhoff", "make_model", "is_protein_model",
122
+ "DNA_MODELS", "PROTEIN_MODELS", "AMINO_ACIDS",
123
+ "evolve_on_tree", "read_fasta", "write_fasta",
124
+ # trait evolution (phylogenetic comparative models)
125
+ "BrownianMotion", "OrnsteinUhlenbeck", "MultivariateBrownian", "MultivariateOU",
126
+ "MultiOptimumOU", "ThresholdModel", "EarlyBurst", "Mk", "CorrelatedBinary",
127
+ "CorrelatedBinaryK", "HiddenStateMk", "Cladogenesis", "simulate_traits",
128
+ "replicate_traits", "TraitResult",
129
+ "pagel_lambda", "pagel_delta", "pagel_kappa",
130
+ # historical biogeography (range evolution)
131
+ "DEC", "simulate_biogeography",
132
+ # state-dependent diversification (trait drives the tree — coevolve traits:species)
133
+ "BiSSE", "MuSSE", "HiSSE", "QuaSSE", "simulate_sse",
134
+ # gene-content-dependent diversification (genes drive the tree — coevolve genes:species,
135
+ # + species:genes = the co-diversification joint model)
136
+ "GeneDiversification", "GeneDiversificationResult", "simulate_gene_diversification",
137
+ "simulate_co_diversification",
138
+ # cladogenetic genome evolution (speciation drives gene content — coevolve species:genes)
139
+ "CladogeneticGenome", "CladogeneticGenomeResult", "simulate_cladogenetic_genome",
140
+ # gene-conditioned trait (gene content drives a trait — coevolve genes:traits)
141
+ "GeneConditionedTrait", "GeneConditionedTraitResult", "simulate_gene_conditioned_trait",
142
+ # trait<->genes joint model (trait-gene feedback — coevolve traits:genes + genes:traits)
143
+ "TraitGeneFeedback", "TraitGeneFeedbackResult", "simulate_trait_gene_feedback",
144
+ # parallelism
145
+ "run_replicates",
146
+ # Rust engine diagnostic (the built-in model requires the compiled extension)
147
+ "rust_available",
148
+ ]
zombi2/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Enable ``python -m zombi2 ...``."""
2
+
3
+ from zombi2.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
zombi2/_rust.py ADDED
@@ -0,0 +1,457 @@
1
+ """Internal bridge to the compiled ``zombi2_core`` Rust engine.
2
+
3
+ This module is **not** part of the public API. It is called by
4
+ :func:`~zombi2.simulate_genomes` and :func:`~zombi2.simulate_nucleotide_genomes`, which
5
+ route the *built-in* model (order-free ``UnorderedGenome`` + ``SharedRates``) to Rust
6
+ automatically — there is no separate "fast" function and no engine switch. Flexible models
7
+ (family/genome-wise/branch rates, ordered genomes, carrying capacity, custom samplers) run
8
+ on the pure-Python engine instead.
9
+
10
+ The extension must be compiled. Build it once from the repo::
11
+
12
+ pip install maturin
13
+ cd rust && maturin build --release -i python3
14
+ pip install --force-reinstall rust/target/wheels/*.whl
15
+
16
+ If it isn't built, :func:`available` returns ``False`` and the built-in model raises a clear
17
+ error telling the user to build it (there is deliberately no Python fallback for the
18
+ built-in model, so a given ``seed`` is reproducible against a single engine).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import numpy as np
24
+
25
+ from collections import Counter
26
+
27
+ from zombi2.genomes.events import EventLog, EventRecord, EventType, GeneOp
28
+ from zombi2.genomes.genome_sim import resolve_max_family_size
29
+ from zombi2.genomes.nucleotide_sim import NucleotideResult
30
+ from zombi2.genomes.profiles import ProfileMatrix
31
+ from zombi2.genomes.rates import SharedRates
32
+ from zombi2.genomes.simulation import Genomes
33
+
34
+ try: # optional native extension
35
+ import zombi2_core as _core
36
+ except ImportError: # pragma: no cover - depends on whether the wheel is built
37
+ _core = None
38
+
39
+
40
+ _BUILD_HINT = (
41
+ "the built-in gene-family model runs on the compiled zombi2_core extension, which is not "
42
+ "built. Build it with:\n"
43
+ " pip install maturin\n"
44
+ " cd rust && maturin build --release -i python3\n"
45
+ " pip install --force-reinstall rust/target/wheels/*.whl\n"
46
+ "or use a flexible rate model / ordered genome (which run on the Python engine)."
47
+ )
48
+
49
+
50
+ def available() -> bool:
51
+ """True if the compiled ``zombi2_core`` extension is importable."""
52
+ return _core is not None
53
+
54
+
55
+ def require() -> None:
56
+ """Raise a clear build error if the extension is missing."""
57
+ if _core is None:
58
+ raise RuntimeError(_BUILD_HINT)
59
+
60
+
61
+ def eligible(rates, genome_factory, sampler) -> bool:
62
+ """True if this model is the built-in one the Rust engine implements: the default
63
+ ``UnorderedGenome``, a plain ``SharedRates`` (no soft carrying capacity, no
64
+ rearrangements), and no custom event sampler. Everything else runs on Python."""
65
+ from zombi2.genomes.genome import UnorderedGenome
66
+
67
+ return (
68
+ genome_factory is UnorderedGenome
69
+ and sampler is None
70
+ and type(rates) is SharedRates
71
+ and rates.carrying_capacity is None
72
+ and not rates.inversion
73
+ and not rates.transposition
74
+ )
75
+
76
+
77
+ class _FastNucleotideResult(NucleotideResult):
78
+ """A NucleotideResult from the Rust profile path (no event log → no gene trees)."""
79
+
80
+ def block_gene_trees(self):
81
+ raise NotImplementedError(
82
+ "the Rust nucleotide profile path emits only leaf segments (profile / mosaic / "
83
+ "trace-back); per-block gene trees need the event log — use "
84
+ "simulate_nucleotide_genomes(...) (the default) for those."
85
+ )
86
+
87
+ def block_histories(self):
88
+ raise NotImplementedError(
89
+ "block_histories needs the event log; use simulate_nucleotide_genomes(...)."
90
+ )
91
+
92
+
93
+ # --- rate / tree / cap / transfer plumbing ---------------------------------------
94
+
95
+ def _resolve_rates(rates):
96
+ """Return (d, t, l, o) from a SharedRates, rejecting features Rust does not implement."""
97
+ if type(rates) is not SharedRates:
98
+ raise TypeError(
99
+ f"the Rust engine only supports SharedRates, not {type(rates).__name__}; "
100
+ f"use simulate_genomes for that model"
101
+ )
102
+ unsupported = []
103
+ if rates.carrying_capacity is not None:
104
+ unsupported.append("carrying_capacity")
105
+ if rates.inversion:
106
+ unsupported.append("inversion")
107
+ if rates.transposition:
108
+ unsupported.append("transposition")
109
+ if unsupported:
110
+ raise ValueError(
111
+ f"the Rust engine does not support {', '.join(unsupported)}; "
112
+ f"use a flexible model on the Python engine"
113
+ )
114
+ return (float(rates.duplication), float(rates.transfer),
115
+ float(rates.loss), float(rates.origination))
116
+
117
+
118
+ def _tree_arrays(species_tree):
119
+ """Flatten a Tree into the index-based arrays the Rust engines consume.
120
+
121
+ A single pre-order pass: because a node is always visited before its children, each parent's
122
+ index is already assigned when its children are reached, so the parent pointers, times and
123
+ extant-leaf mask are filled in one sweep. The previous version made ~six passes over the ~2N
124
+ nodes (materialise, build an object-keyed index dict, then a comprehension each for parents /
125
+ times / mask / root) — this is ~1.6x faster at a million tips. It stays Python-bound because
126
+ it walks the ``TreeNode`` object graph; a Rust-speed tree would have to be parsed/built as
127
+ arrays in Rust rather than materialised as Python objects first."""
128
+ nodes = []
129
+ parent = []
130
+ times = []
131
+ extant_leaf = []
132
+ pos: dict[int, int] = {} # id(node) -> pre-order index (parent seen before child)
133
+ root = -1
134
+ for i, n in enumerate(species_tree.nodes_preorder()):
135
+ nodes.append(n)
136
+ pos[id(n)] = i
137
+ p = n.parent
138
+ if p is None:
139
+ parent.append(-1)
140
+ root = i
141
+ else:
142
+ parent.append(pos[id(p)])
143
+ times.append(float(n.time))
144
+ extant_leaf.append(n.is_extant and not n.children)
145
+ return nodes, parent, times, extant_leaf, root
146
+
147
+
148
+ def _cap_and_seed(max_family_size, n_extant, seed):
149
+ cap = -1 if max_family_size is None else int(resolve_max_family_size(max_family_size, n_extant))
150
+ seed_val = (int(np.random.SeedSequence(seed).generate_state(1)[0])
151
+ if seed is None else int(seed))
152
+ return cap, seed_val
153
+
154
+
155
+ def _transfer_params(transfers):
156
+ """(replacement, distance_decay, allow_self) for Rust; decay = -1.0 means uniform."""
157
+ if transfers is None:
158
+ return 0.0, -1.0, False
159
+ decay = transfers.distance_decay
160
+ return (float(transfers.replacement),
161
+ -1.0 if decay is None else float(decay),
162
+ bool(transfers.allow_self))
163
+
164
+
165
+ # --- profiles (counts only; the fast ABC / σ-dataset path) -----------------------
166
+
167
+ def profiles(species_tree, rates, *, initial_size, transfers, max_family_size, seed):
168
+ """Simulate gene families in Rust over per-family *counts* only and return the profile
169
+ matrix — no gene ids, event log or trees. This is the path behind
170
+ ``simulate_genomes(..., output="profiles")`` (the fast route for ABC / large σ datasets)."""
171
+ require()
172
+ d, t, l, o = _resolve_rates(rates)
173
+ nodes, parent, times, extant_leaf, root = _tree_arrays(species_tree)
174
+ cap, seed_val = _cap_and_seed(max_family_size, sum(extant_leaf), seed)
175
+ rep, dec, aself = _transfer_params(transfers)
176
+
177
+ result = _core.simulate_profiles(
178
+ len(nodes), parent, times, extant_leaf, root,
179
+ d, t, l, o, int(initial_size), cap, seed_val, rep, dec, aself,
180
+ )
181
+ return _assemble_profiles(result, nodes)
182
+
183
+
184
+ def profiles_parallel(species_tree, rates, *, initial_size, transfers, max_family_size, seed,
185
+ threads):
186
+ """Parallel counts-only path (behind ``simulate_genomes(..., output="profiles", threads=N)``).
187
+
188
+ Runs ``threads`` **independent** copies of the engine, each with origination rate ``o/threads``
189
+ and a ``1/threads`` share of the founding families, and sums the profiles. Gene families are
190
+ independent and a Poisson process splits, so this is distributionally identical to one serial
191
+ run — a different but equivalent realization. The result depends on ``(seed, threads)`` but not
192
+ on scheduling (thread-count-independent given the copy count), and every transfer mode is
193
+ supported (the recipient choice depends only on the tree + donor, so it decomposes unchanged)."""
194
+ require()
195
+ d, t, l, o = _resolve_rates(rates)
196
+ nodes, parent, times, extant_leaf, root = _tree_arrays(species_tree)
197
+ cap, seed_val = _cap_and_seed(max_family_size, sum(extant_leaf), seed)
198
+ rep, dec, aself = _transfer_params(transfers)
199
+
200
+ # copies = threads (one balanced Poisson-thinned copy per worker); pool size = threads
201
+ result = _core.simulate_profiles_parallel(
202
+ len(nodes), parent, times, extant_leaf, root,
203
+ d, t, l, o, int(initial_size), cap, seed_val, rep, dec, aself,
204
+ int(threads), int(threads),
205
+ )
206
+ return _assemble_profiles(result, nodes)
207
+
208
+
209
+ def _assemble_profiles(result, nodes) -> ProfileMatrix:
210
+ """Build a ProfileMatrix from the engine's flat COO byte buffers (``simulate_profiles`` /
211
+ ``simulate_trace``).
212
+
213
+ ``result`` is ``(leaf_nodes, col, fam, cnt)`` of packed native-endian bytes: ``leaf_nodes[c]``
214
+ is the node index of column ``c`` (columns in the engine's emission order), then one cell per
215
+ present family as parallel ``u32`` column / ``u64`` family id / ``u32`` count. Everything is
216
+ ``np.frombuffer``-d (one memcpy, no per-cell Python objects) and mapped to sorted family rows
217
+ and natkey-ordered species columns with vectorised numpy — the per-cell Python loop this
218
+ replaces was ~40% of the wall-clock at millions of tips. Stays sparse COO throughout, so the
219
+ dense O(N²) array is never built."""
220
+ from zombi2.genomes.profiles import _natkey
221
+
222
+ leaf_nodes_b, col_b, fam_b, cnt_b = result
223
+ leaf_idx = np.frombuffer(leaf_nodes_b, dtype=np.uint32)
224
+ col = np.frombuffer(col_b, dtype=np.uint32)
225
+ fam = np.frombuffer(fam_b, dtype=np.uint64)
226
+ cnt = np.frombuffer(cnt_b, dtype=np.uint32)
227
+
228
+ # species (columns): extant-leaf names in natkey order; remap the engine's emission-order
229
+ # column index to that order via the inverse permutation.
230
+ leaf_names = [nodes[i].name for i in leaf_idx.tolist()]
231
+ order = sorted(range(len(leaf_names)), key=lambda j: _natkey(leaf_names[j]))
232
+ species = [leaf_names[j] for j in order]
233
+ inv = np.empty(len(order), dtype=np.int64)
234
+ inv[np.asarray(order, dtype=np.int64)] = np.arange(len(order), dtype=np.int64)
235
+ cols = inv[col.astype(np.int64)] if col.size else np.zeros(0, dtype=np.int64)
236
+
237
+ # families (rows): sorted unique family ids, 1-based ZOMBI-style labels.
238
+ uniq = np.unique(fam) if fam.size else np.zeros(0, dtype=np.uint64)
239
+ families = [str(int(f) + 1) for f in uniq.tolist()]
240
+ rows = np.searchsorted(uniq, fam).astype(np.int64) if fam.size else np.zeros(0, dtype=np.int64)
241
+
242
+ return ProfileMatrix(families=families, species=species,
243
+ coo=(rows, cols, cnt.astype(np.int64)))
244
+
245
+
246
+ # --- full genealogy (event log + gene trees), materialized as a Genomes ----------
247
+
248
+ class _RustGene:
249
+ """Minimal Gene stand-in for gid->species lookup (str gid, str family)."""
250
+ __slots__ = ("gid", "family")
251
+
252
+ def __init__(self, gid, family):
253
+ self.gid = gid
254
+ self.family = family
255
+
256
+
257
+ class _RustGenome:
258
+ """Minimal leaf genome exposing just what Genomes/ProfileMatrix need."""
259
+ __slots__ = ("_pairs", "_counts")
260
+
261
+ def __init__(self, pairs): # pairs: list[(gid:int, family:int)]
262
+ self._pairs = pairs
263
+ self._counts = Counter(str(f + 1) for _, f in pairs) # 1-based labels, ZOMBI-style
264
+
265
+ def genes(self):
266
+ # g-prefixed gids so the schema matches the Python engine (only the RNG differs)
267
+ return [_RustGene(f"g{gid}", str(fam + 1)) for gid, fam in self._pairs]
268
+
269
+ def families(self):
270
+ return list(self._counts.keys())
271
+
272
+ def copy_number(self, family):
273
+ return self._counts.get(family, 0)
274
+
275
+ def size(self):
276
+ return len(self._pairs)
277
+
278
+
279
+ # event code (Rust) -> EventType, and per-event GeneOp roles for genes[0], genes[1], genes[2]
280
+ _EV = (EventType.ORIGINATION, EventType.DUPLICATION, EventType.TRANSFER,
281
+ EventType.LOSS, EventType.SPECIATION)
282
+ _ROLES = (
283
+ ("origin",), # O
284
+ ("parent", "left", "right"), # D
285
+ ("parent", "donor_copy", "transfer_copy"), # T
286
+ ("lost",), # L
287
+ ("parent", "child", "child"), # S
288
+ )
289
+
290
+
291
+ def _build_event_log(cols, nodes) -> EventLog:
292
+ ev, br, tm, dn, rc, fm, g0, g1, g2 = cols
293
+ names = [n.name for n in nodes]
294
+ log = EventLog()
295
+ add = log.add
296
+ for k in range(len(ev)):
297
+ code = ev[k]
298
+ family = str(fm[k] + 1) # 1-based labels, ZOMBI-style
299
+ roles = _ROLES[code]
300
+ genes = [GeneOp(f"g{g0[k]}", family, roles[0])] # g-prefixed gids (schema parity)
301
+ if len(roles) == 3:
302
+ genes.append(GeneOp(f"g{g1[k]}", family, roles[1]))
303
+ genes.append(GeneOp(f"g{g2[k]}", family, roles[2]))
304
+ donor = names[dn[k]] if dn[k] >= 0 else None
305
+ recipient = names[rc[k]] if rc[k] >= 0 else None
306
+ add(EventRecord(_EV[code], names[br[k]], tm[k], genes,
307
+ donor=donor, recipient=recipient))
308
+ return log
309
+
310
+
311
+ def events_trace_tsv(columns, nodes) -> str:
312
+ """Serialise the raw Rust event columns as the compact ``Events_trace.tsv`` text — the fast
313
+ path behind ``output="trace"``. This never builds an :class:`~zombi2.events.EventRecord`;
314
+ it formats the columns directly, which is the whole point (object construction is the wall
315
+ on large trees). The format matches the record-based
316
+ :func:`zombi2.simulation.events_trace_from_log` exactly (g-prefixed gids, 1-based families)."""
317
+ from zombi2.genomes.simulation import EVENTS_TRACE_HEADER
318
+
319
+ ev, br, tm, dn, rc, fm, g0, g1, g2 = columns
320
+ n = len(br)
321
+ names = np.array([nd.name for nd in nodes], dtype=object)
322
+ ev_a = np.frombuffer(ev, dtype=np.uint8)
323
+ br_a = np.asarray(br, dtype=np.int64)
324
+ dn_a = np.asarray(dn, dtype=np.int64)
325
+ rc_a = np.asarray(rc, dtype=np.int64)
326
+ # resolve names / event chars vectorially; -1 (no donor/recipient) becomes ""
327
+ evchar = np.array([e.value for e in _EV], dtype=object)[ev_a].tolist()
328
+ brn = names[br_a].tolist()
329
+ dnn = np.where(dn_a >= 0, names[np.clip(dn_a, 0, None)], "").tolist()
330
+ rcn = np.where(rc_a >= 0, names[np.clip(rc_a, 0, None)], "").tolist()
331
+ tm_l = list(tm)
332
+ fm_l = (np.asarray(fm, dtype=np.int64) + 1).tolist() # 1-based family labels
333
+ g0_l, g1_l, g2_l = list(g0), list(g1), list(g2)
334
+
335
+ def gid(x):
336
+ return f"g{x}" if x >= 0 else ""
337
+
338
+ rows = [EVENTS_TRACE_HEADER]
339
+ rows.extend(
340
+ f"{tm_l[k]:.10g}\t{evchar[k]}\t{brn[k]}\t{dnn[k]}\t{rcn[k]}\t{fm_l[k]}\t"
341
+ f"g{g0_l[k]}\t{gid(g1_l[k])}\t{gid(g2_l[k])}"
342
+ for k in range(n)
343
+ )
344
+ return "\n".join(rows) + "\n"
345
+
346
+
347
+ def trace(species_tree, rates, *, initial_size, transfers, max_family_size, seed):
348
+ """Simulate the compact **event trace** in Rust and return a :class:`~zombi2.GenomeTrace`.
349
+
350
+ Uses the ``simulate_trace`` engine: identical D/T/L/O dynamics to :func:`genomes`, but
351
+ speciations neither re-mint gene ids nor emit records (a lineage keeps its id across
352
+ speciations). The returned columns are therefore O/D/T/L only — ~6x smaller than the full
353
+ log in the low-rate regime — and the per-event Python objects are never built. The
354
+ genealogy is reconstructed on demand by replaying the trace against the species tree
355
+ (:func:`zombi2.reconciliation.expand_trace`). This is the path behind
356
+ ``simulate_genomes(..., output="trace")``."""
357
+ require()
358
+ d, t, l, o = _resolve_rates(rates)
359
+ nodes, parent, times, extant_leaf, root = _tree_arrays(species_tree)
360
+ cap, seed_val = _cap_and_seed(max_family_size, sum(extant_leaf), seed)
361
+ rep, dec, aself = _transfer_params(transfers)
362
+
363
+ cols, leaf_coo = _core.simulate_trace(
364
+ len(nodes), parent, times, extant_leaf, root,
365
+ d, t, l, o, int(initial_size), cap, seed_val, rep, dec, aself,
366
+ )
367
+ # leaf_coo is the flat COO profile buffers (per-family counts, no per-gene objects); assemble
368
+ # straight from it. The compact trace's leaf identities are recovered by replaying against the
369
+ # species tree, not read off leaf genomes, so leaf_genomes stays empty for this path.
370
+ profs = _assemble_profiles(leaf_coo, nodes)
371
+
372
+ from zombi2.genomes.simulation import GenomeTrace
373
+ return GenomeTrace(species_tree=species_tree, leaf_genomes={},
374
+ profiles=profs, _columns=cols, _nodes=nodes)
375
+
376
+
377
+ def genomes(species_tree, rates, *, initial_size, transfers, max_family_size, seed):
378
+ """Simulate the full genealogy in Rust and return a materialized :class:`~zombi2.Genomes`
379
+ (event log, gene trees, profiles, write). This is the built-in path behind the default
380
+ ``simulate_genomes(...)`` (``output="genomes"``)."""
381
+ require()
382
+ d, t, l, o = _resolve_rates(rates)
383
+ nodes, parent, times, extant_leaf, root = _tree_arrays(species_tree)
384
+ cap, seed_val = _cap_and_seed(max_family_size, sum(extant_leaf), seed)
385
+ rep, dec, aself = _transfer_params(transfers)
386
+
387
+ cols, leaves = _core.simulate_log(
388
+ len(nodes), parent, times, extant_leaf, root,
389
+ d, t, l, o, int(initial_size), cap, seed_val, rep, dec, aself,
390
+ )
391
+
392
+ leaf_genomes = {nodes[li]: _RustGenome(pairs) for li, pairs in leaves}
393
+ event_log = _build_event_log(cols, nodes)
394
+ profs = ProfileMatrix.from_leaf_genomes(leaf_genomes)
395
+ return Genomes(species_tree=species_tree, leaf_genomes=leaf_genomes,
396
+ event_log=event_log, profiles=profs)
397
+
398
+
399
+ # --- nucleotide-genome profile path (leaf segments -> blocks/profile/mosaics) ------
400
+
401
+ class _FastNucGenome:
402
+ """Minimal leaf genome exposing just what NucleotideResult reads (segments + to_cells)."""
403
+ __slots__ = ("_segments", "_length")
404
+
405
+ def __init__(self, segments):
406
+ self._segments = segments
407
+ self._length = sum(s.length for s in segments)
408
+
409
+ def n_segments(self):
410
+ return len(self._segments)
411
+
412
+ def size(self):
413
+ return self._length
414
+
415
+ def to_cells(self):
416
+ out = []
417
+ for seg in self._segments:
418
+ if seg.strand == 1:
419
+ out.extend((seg.source, p, 1) for p in range(seg.src_start, seg.src_end))
420
+ else:
421
+ out.extend((seg.source, p, -1) for p in range(seg.src_end - 1, seg.src_start - 1, -1))
422
+ return out
423
+
424
+
425
+ def nucleotide(species_tree, *, inversion, loss, duplication, transfer, transposition,
426
+ origination, root_length, extension, initial_size, transfers, seed):
427
+ """Simulate the nucleotide structural model in Rust (leaf segments only) and return a
428
+ :class:`NucleotideResult` supporting ``profile_matrix()`` / ``leaf_mosaic()`` /
429
+ ``trace_back()`` but not the event-log products. This is the path behind
430
+ ``simulate_nucleotide_genomes(..., output="profiles")``."""
431
+ require()
432
+ from zombi2.genomes.events import EventLog
433
+ from zombi2.genomes.nucleotide_genome import Segment, SegmentRegistry
434
+ from zombi2.genomes.nucleotide_sim import _build_blocks
435
+
436
+ nodes, parent, times, extant_leaf, root = _tree_arrays(species_tree)
437
+ _, seed_val = _cap_and_seed(None, sum(extant_leaf), seed)
438
+ rep, dec, aself = _transfer_params(transfers)
439
+
440
+ leaves = _core.simulate_nucleotide(
441
+ len(nodes), parent, times, extant_leaf, root,
442
+ float(inversion), float(loss), float(duplication), float(transfer),
443
+ float(transposition), float(origination), int(root_length), float(extension),
444
+ int(initial_size), seed_val, rep, dec, aself,
445
+ )
446
+
447
+ leaf_genomes = {}
448
+ for leaf_idx, segs in leaves:
449
+ objs = [Segment("", str(src + 1), start, end, strand) # 1-based source labels
450
+ for (src, start, end, strand) in segs]
451
+ leaf_genomes[nodes[leaf_idx]] = _FastNucGenome(objs)
452
+
453
+ blocks = _build_blocks(leaf_genomes, root_length)
454
+ return _FastNucleotideResult(
455
+ species_tree=species_tree, leaf_genomes=leaf_genomes,
456
+ event_log=EventLog(), registry=SegmentRegistry(), blocks=blocks, root_length=root_length,
457
+ )