mirpy-lib 3.4.0__tar.gz → 3.7.0__tar.gz

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 (105) hide show
  1. mirpy_lib-3.7.0/CHANGELOG.md +317 -0
  2. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/CLAUDE.md +33 -4
  3. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/PKG-INFO +125 -23
  4. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/README.md +124 -18
  5. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/SOURCES.md +11 -9
  6. mirpy_lib-3.7.0/assets/mirpy_dark.png +0 -0
  7. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/assets/mirpy_dark.svg +1 -1
  8. mirpy_lib-3.7.0/assets/mirpy_dark_bg.svg +1519 -0
  9. mirpy_lib-3.7.0/assets/mirpy_light.png +0 -0
  10. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/assets/mirpy_light.svg +1 -1
  11. mirpy_lib-3.7.0/assets/mirpy_light_bg.svg +1519 -0
  12. mirpy_lib-3.7.0/docs/_static/mirpy_dark.png +0 -0
  13. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_dark.svg +1 -1
  14. mirpy_lib-3.7.0/docs/_static/mirpy_light.png +0 -0
  15. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/_static/mirpy_light.svg +1 -1
  16. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/api.rst +58 -5
  17. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/conf.py +4 -8
  18. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/examples.rst +11 -2
  19. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/usage.rst +66 -12
  20. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/pyproject.toml +4 -5
  21. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/requirements.txt +1 -1
  22. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/setup.sh +36 -17
  23. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/__init__.py +1 -1
  24. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/bench/__init__.py +2 -1
  25. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/bench/theory.py +3 -2
  26. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/bench/vdjdb.py +3 -1
  27. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/cli.py +47 -5
  28. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/cohort.py +22 -2
  29. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/density.py +111 -13
  30. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/distances/germline.py +21 -3
  31. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/distances/junction.py +18 -7
  32. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/embedding/presets.py +2 -1
  33. mirpy_lib-3.7.0/src/mir/embedding/prototypes.py +173 -0
  34. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/embedding/tcremp.py +45 -12
  35. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/explain.py +6 -4
  36. mirpy_lib-3.7.0/src/mir/generate.py +233 -0
  37. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/bundle.py +16 -7
  38. mirpy_lib-3.7.0/src/mir/ml/diffusion.py +378 -0
  39. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/set_encoder.py +2 -1
  40. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/repertoire.py +70 -25
  41. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/generate_prototypes.py +3 -1
  42. mirpy_lib-3.7.0/src/mir/track.py +331 -0
  43. mirpy_lib-3.7.0/src/mir/twin.py +179 -0
  44. mirpy_lib-3.4.0/CHANGELOG.md +0 -147
  45. mirpy_lib-3.4.0/assets/mirpy_dark.png +0 -0
  46. mirpy_lib-3.4.0/assets/mirpy_light.png +0 -0
  47. mirpy_lib-3.4.0/docs/_static/mirpy_dark.png +0 -0
  48. mirpy_lib-3.4.0/docs/_static/mirpy_light.png +0 -0
  49. mirpy_lib-3.4.0/docs/requirements.txt +0 -3
  50. mirpy_lib-3.4.0/src/mir/embedding/prototypes.py +0 -109
  51. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/.gitignore +0 -0
  52. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/LICENSE +0 -0
  53. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/Makefile +0 -0
  54. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/_static/custom.css +0 -0
  55. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/_static/depth_robustness.png +0 -0
  56. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/_static/sample_embedding_diagram.png +0 -0
  57. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/docs/index.rst +0 -0
  58. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/aliases.py +0 -0
  59. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/alleles.py +0 -0
  60. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/bench/eval.py +0 -0
  61. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/bench/metrics.py +0 -0
  62. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/distances/__init__.py +0 -0
  63. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/embedding/__init__.py +0 -0
  64. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/embedding/pca.py +0 -0
  65. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/__init__.py +0 -0
  66. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/codec.py +0 -0
  67. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/decoder.py +0 -0
  68. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/encoder.py +0 -0
  69. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/tokenize.py +0 -0
  70. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/ml/train.py +0 -0
  71. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/py.typed +0 -0
  72. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/__init__.py +0 -0
  73. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/README.md +0 -0
  74. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/__init__.py +0 -0
  75. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/build_gene_library.py +0 -0
  76. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/build_region_annotations.py +0 -0
  77. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/imgt_gene_library.txt +0 -0
  78. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/olga_gene_library.txt +0 -0
  79. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/gene_library/region_annotations.txt +0 -0
  80. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/build_germline_dist.py +0 -0
  81. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGH.npz +0 -0
  82. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGK.npz +0 -0
  83. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_IGL.npz +0 -0
  84. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRA.npz +0 -0
  85. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRB.npz +0 -0
  86. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRD.npz +0 -0
  87. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/human_TRG.npz +0 -0
  88. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGH.npz +0 -0
  89. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGK.npz +0 -0
  90. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_IGL.npz +0 -0
  91. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRA.npz +0 -0
  92. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRB.npz +0 -0
  93. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRD.npz +0 -0
  94. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/germline_dist/mouse_TRG.npz +0 -0
  95. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/.gitkeep +0 -0
  96. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGH.tsv +0 -0
  97. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGK.tsv +0 -0
  98. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_IGL.tsv +0 -0
  99. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRA.tsv +0 -0
  100. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRB.tsv +0 -0
  101. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRD.tsv +0 -0
  102. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/human_TRG.tsv +0 -0
  103. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/manifest.json +0 -0
  104. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/mouse_TRA.tsv +0 -0
  105. {mirpy_lib-3.4.0 → mirpy_lib-3.7.0}/src/mir/resources/prototypes/mouse_TRB.tsv +0 -0
@@ -0,0 +1,317 @@
1
+ # Changelog
2
+
3
+ All notable changes to `mirpy-lib` (import `mir`). This project follows semantic versioning; the v3 line is a
4
+ greenfield ML/embedding rewrite (the classical v1.x/v2 toolkit is frozen on branch `legacy-v2`).
5
+
6
+ ## 3.7.0 — 2026-07-30
7
+
8
+ Four new modules: a PhenoPath-style exposure trajectory, the generative loop's mechanical and
9
+ research halves, and the digital twin that glues them to the digital donor — plus a repertoire-level
10
+ exposure channel. No public API removed; a minor bump.
11
+
12
+ ### Added
13
+
14
+ - **`mir.track.fit_exposure_trajectory`** — a covariate-disentangled latent trajectory over any
15
+ per-sample channel matrix (PhenoPath, Campbell & Yau 2018, *Nat. Commun.* 9:2442,
16
+ doi:10.1038/s41467-018-04696-6, adapted from genes×cells to repertoire-channels×samples): infers a
17
+ shared exposure/progression pseudotime `tau` while separating out which channels respond to it
18
+ differently by a known covariate (`TrajectoryFit.top_interactions`). A simplified closed-form
19
+ alternating fit (per-channel ridge + GLS trajectory update + iteratively-reweighted ARD shrinkage
20
+ on the interaction term), not a literal reimplementation of PhenoPath's CAVI engine. Torch-free.
21
+ - **`mir.generate`** — the generative loop's mechanical half: `DescriptorDensity` (optionally
22
+ class-conditional Gaussian, Ledoit-Wolf shrinkage) over `RepertoireDescriptor` vectors;
23
+ `sample`/`evolve` (perturb one coordinate, propagate the coupled shift via the fitted covariance's
24
+ conditional mean) promote the ad-hoc `benchmark_repertoire_tcga_insilico.py` `np.cov`-slope pattern
25
+ into a reusable library object. Torch-free.
26
+ - **`mir.ml.diffusion`** (needs `[ml]`) — the generative loop's research half: a compact conditional
27
+ DDPM/DDIM generator (classifier-free guidance) over a compact descriptor/code space, sharing
28
+ `DescriptorDensity`'s `sample(n, condition=, seed=)` call shape so either generator drops in
29
+ unchanged. `DiffusionModel.save`/`load` mirrors `CodecBundle`'s shape.
30
+ - **`mir.twin.DonorTwin`/`make_twins`** — the digital twin: glue one donor's `RepertoireDescriptor` +
31
+ an optional `mir.track` trajectory position + covariate into one object; `.perturb()` and
32
+ `.simulate()` accept either generator.
33
+ - **`mir.density.exposure_score`/`exposure_channel`** — aggregate a per-clonotype
34
+ `neighbor_enrichment` result into repertoire-level exposure scalars (breadth, abundance-weighted
35
+ mass fraction, mean log2 fold), ready for `ChannelBuilder`/`DonorCohort extra_channels` — exposure
36
+ detection promoted from clone-level to a first-class cohort channel.
37
+
38
+ ## 3.6.0 — 2026-07-30
39
+
40
+ Default-on functional filtering, a new default clone-size weight for repertoire embedding, and a
41
+ clearer crash message — a minor bump since the repertoire-embedding default changes the numeric
42
+ output for callers who didn't pass `weight=` explicitly.
43
+
44
+ ### Added
45
+
46
+ - **`mir embed clonotypes` / `mir embed repertoires` drop non-coding clonotypes by default**
47
+ (`--filter-functional`, default on; `--no-filter-functional` to disable) via
48
+ `vdjtools.preprocess.filter_functional` — a stop codon or legacy out-of-frame marker
49
+ (`[*atgc#~_?]`) in `junction_aa` otherwise either crashes (`_`) or silently produces a
50
+ numerically meaningless embedding (`*`). `mir embed repertoires` skips (with a warning) any
51
+ sample/locus left empty after filtering, instead of crashing the whole batch.
52
+ - **`"duplicate_count"` (linear, `g(a)=a`) and `"log2p1"` (`g(a)=log2(1+a)`) clone-size weights**
53
+ for repertoire embedding, alongside the existing `"distinct"` (`g≡1`) and `"log1p"`/`"anscombe"`.
54
+ `"log2p1"` is the **new default** — see Changed.
55
+
56
+ ### Fixed
57
+
58
+ - **`TCREmp.embed` raises a clear error when `junction_aa` contains `'_'`** (the legacy vdjtools
59
+ out-of-frame marker), naming the value count and pointing at `filter_functional`, instead of
60
+ letting seqtree's opaque `"symbol '_' is not in the alphabet"` propagate uncaught. `'*'` (stop
61
+ codon) is unaffected by this check — it doesn't crash, so filter it via `filter_functional` if
62
+ you don't want it silently embedded.
63
+
64
+ ### Changed
65
+
66
+ - **Default clone-size weight for repertoire embedding is now `"log2p1"`** (`g=log2(1+a)`),
67
+ changed from `"log1p"` (natural log). Affects `mir.repertoire.sample_embedding`,
68
+ `RepertoireSpace.sample_cloud`, `sample_descriptor`, `class_witness`, `mir.explain.channel_drivers`,
69
+ and `mir embed repertoires --weight`. This changes the numeric values of `Φ(S)`'s mean/second
70
+ blocks (and any MMD computed from them) for callers who relied on the implicit default — pass
71
+ `weight="log1p"` to reproduce the old default exactly.
72
+
73
+ ## 3.5.0 — 2026-07-28
74
+
75
+ A documentation-accuracy pass plus the fixes found by the accompanying code audit. No public API
76
+ removed; two new keyword arguments, one runtime dependency dropped, and three paths that used to
77
+ fail silently now fail loudly — hence a minor bump rather than a patch.
78
+
79
+ ### Added
80
+
81
+ - **Prototype replicates — `load_prototypes(..., replicate=r)`, `n_replicates()`, and
82
+ `replicate=` on `TCREmp`/`PairedTCREmp.from_defaults` and both `mir embed` commands.** Each
83
+ bundled chain ships 10 000 real receptors whose row order is already a uniform shuffle, so a
84
+ disjoint block of `n` rows is an independent draw from the same pool: `replicate=r` returns block
85
+ `r`, giving `10000 // n` replicates (**10** at the common `n=1000`) with no new data and no RNG.
86
+ This answers "is my result an artefact of *which* prototypes I drew?" — a question a nested
87
+ `n_prototypes` sweep cannot answer, since those draws are prefixes of one another.
88
+
89
+ `replicate=0` is unchanged and remains *the* prototype set behind every preset, bundled codec and
90
+ published number. `prototype_hash` now covers the replicate index, so `CodecBundle`,
91
+ `RepertoireSpace`, `DonorCohort` and `SetEncoderBundle` refuse to mix draws (artifacts written
92
+ before this release read as draw 0). README and the user guide state the default, the provenance
93
+ (real repertoires, `seed=42`), and that cross-replicate embeddings are incomparable.
94
+ - **`generate_background(..., species=, source="arda")`** — the P_gen background can now be drawn
95
+ in the **arda IMGT allele namespace** (the same frame as the prototypes and baked germline
96
+ distances, so generated V/J calls resolve exactly instead of taking the allele cascade) and for
97
+ **mouse**. Both need a `vdjtools` shipping the bundled `arda` model set: those 9 models were
98
+ already in the wheel but unreachable, fixed upstream in `vdjtools.model.load_bundled`. The human
99
+ `learned`/`olga` path is unchanged and still works on older vdjtools; asking for arda/mouse
100
+ without it raises a message naming the requirement.
101
+ - **`neighbor_enrichment(..., k_max=, seed=)`** — forwarded to the `backend="ann"` engine, which
102
+ already warned "raise `k_max`" for a saturated neighbour ball but gave no way to do it.
103
+
104
+ ### Fixed
105
+
106
+ - **Germline distances silently maxed out for 42 human and 71 mouse alleles**, including
107
+ **`IGHV3-23*01` and `IGHV1-69*01` — the two most-used human IGHV genes.** arda emits an
108
+ *ambiguity group* (`"IGHV1-69*01,IGHV1-69D*01"`) as one row when the alleles share an identical
109
+ germline region, and the allele index registered only the joined string, so a query naming a
110
+ member matched nothing and took the max-distance fallback — quietly, since a fallback is also the
111
+ legitimate answer for a genuinely unknown gene. Members are now indexed to their group's row (a
112
+ standalone row still wins). Across every bundled locus, 333 of 457 group-member names resolved to
113
+ the fallback before; now none do.
114
+ - **`mir embed repertoires --blocks diversity` crashed** with `ValueError: zero-dimensional arrays
115
+ cannot be concatenated`: `SampleEmbedding.vector` assumed the kernel-mean block was always
116
+ present. A mean-less Φ is now a valid vector, and `mmd_distance` / `mmd_matrix` say *why* MMD
117
+ needs the mean block instead of raising a `NoneType` `TypeError`.
118
+
119
+ ### Changed
120
+
121
+ - **`GermlineDistances.matrix` resolves each *distinct* allele once** instead of once per row, then
122
+ gathers a small `(n_distinct, K)` table and expands it. A repertoire has ~10⁵–10⁶ rows but ~10²
123
+ distinct alleles, so this is the dominant cost of `TCREmp.embed`: measured **2.32 s → 1.04 s** for
124
+ 200k clonotypes × 1000 prototypes (the germline block itself 0.765 s → 0.072 s), bit-identical
125
+ output, nulls still take the allele fallback.
126
+ - **`RandomFourierFeatures.transform` computes in place** after the matmul — peak memory for a deep
127
+ sample drops ~3× (measured 9.2 → 3.2 GiB at n=200k, D=2048), output identical.
128
+ - **`RepertoireSpace.save` / `DonorCohort.save` refuse a model built with a custom `matrix=` or
129
+ `alignment=`.** Neither knob is recorded in `meta`, so `load` rebuilt the default
130
+ gapblock/BLOSUM62 space — a *different* coordinate system that still passed the prototype-hash
131
+ check. Failing at save time keeps the comparability invariant honest.
132
+ - **`TCREmp.embed` rejects null `junction_aa`** with a message naming the column and the row count,
133
+ instead of an opaque `TypeError: object of type 'NoneType' has no len()` from inside seqtree.
134
+ - **`fit_donor_embeddings` warns when a locus has too few donors to fit the identity PCA** — that
135
+ block was silently emitted as all-NaN, imputed to a constant, information-free channel.
136
+ - **`alignment="sw"` raises a directed `ImportError`** ("pip install biopython") instead of a bare
137
+ `ModuleNotFoundError`; `python -m mir.embedding.tcremp` now skips the SW leg when BioPython is
138
+ absent, so the self-check passes in the default `[dev,bench]` environment.
139
+ - **Determinism**: `bench.theory._mutate` no longer "mutates" a residue to itself (5.3% of `k=1`
140
+ substitutions were no-ops, and it disagreed with `density._mutate1`); `load_vdjdb` and
141
+ `generate_prototypes` no longer depend on `.unique()`'s arbitrary row order.
142
+ - **Single-sourced the version**: `pyproject.toml` takes it from `src/mir/__init__.py`
143
+ (`[tool.hatch.version]`), `docs/conf.py` imports `mir.__version__`, and `publish.yml` validates
144
+ the release tag against the built wheel — one copy instead of three.
145
+ - **Corrected the documented gap-block ↔ Smith-Waterman relationship.** `mir.distances.junction`
146
+ claimed the two "agree for equal-length CDR3s and rank-correlate ≥0.99 on gapped pairs". Measured
147
+ over all 44 850 pairs of 300 bundled human-TRB prototypes: close pairs do agree exactly, but SW is
148
+ a *local* alignment, so distant pairs diverge (equal-length outliers 181 vs 77) and the overall
149
+ rank correlation is ρ≈0.78. The docstring now says that, and that the two are different coordinate
150
+ systems rather than interchangeable scales.
151
+ - **Tests**: the survival-scorer test is no longer marked `integration` (lifelines ships in
152
+ `[bench]`, which CI installs), lifting `mir.bench.eval` from 23% to 64% coverage in CI;
153
+ `test_set_encoder.py` skips instead of failing on a torch-free install, so the documented
154
+ `pytest tests/` is green again; the VDJdb loader test no longer depends on a gitignored fixture it
155
+ could never find in CI (`mir.bench.vdjdb` 44% → 100%); and `TCREmp`'s public coordinate knobs
156
+ (`metric="sqrt"`, custom `matrix=`, `alignment="sw"`, null rejection) have real tests instead of
157
+ assertions buried in a `__main__` block. Fast-tier coverage 64% → 66%.
158
+ - **CI gained an `optional-tiers` job** (`[dev,bench,ml]` + BioPython, `-m "not benchmark"`): all of
159
+ `mir.ml` — ~20% of the library, including both prototype-hash comparability guards — previously
160
+ ran in no CI configuration, because the only job installs `[dev,bench]` and every torch test skips.
161
+ - Docs corrections: the density `backend=` default is `"kdtree"` (all-core exact), not `"exact"`;
162
+ the `cv_cindex` snippets in the user guide and in `mir.explain` now match the real
163
+ `(durations, events, *, base, block)` signature (the old form scored every channel `NaN`);
164
+ `CodecBundle.load` is documented as *not* verifying (its `forward_encoder` does); `encoder.code`
165
+ vs `encoder.encode`; the `mir embed repertoires` flag list is complete; `SOURCES.md` records the
166
+ prototypes as `arda-real` (experimental) with `src/`-prefixed regenerate commands; the examples'
167
+ run lines point at `examples/`, not the old `notebooks/`.
168
+
169
+ ### Removed
170
+
171
+ - **`tqdm`** from the runtime dependencies — nothing in the package imports it.
172
+ - **`nbsphinx` / `nbsphinx-link` / `ipython`** from the `[docs]` extra, and the unreferenced
173
+ `docs/requirements.txt`: the docs have contained no notebooks since the examples became marimo
174
+ scripts. The Sphinx build stays zero-warning under `-W`.
175
+
176
+ ## 3.4.0 — 2026-07-18
177
+
178
+ Minor: a command-line interface, a uv-based dev setup, and a documentation overhaul. No public
179
+ Python API removed; one optional-dependency group split out.
180
+
181
+ ### Added
182
+
183
+ - **`mir` command-line interface** (`[project.scripts]`, also `python -m mir.cli`) — the two
184
+ embedding scales without writing Python:
185
+ - `mir embed clonotypes SAMPLE` → a per-clonotype TCREMP embedding table (`e0…`).
186
+ - `mir embed repertoires SAMPLE…` → one repertoire vector `Φ(S)` per sample **per chain** on one
187
+ shared basis (`phi0…`), with optional `--mmd` pairwise-distance output.
188
+
189
+ Inputs are any format `vdjtools.io` reads (AIRR/vdjtools/MiXCR/immunoSEQ/parquet); output is TSV
190
+ or Parquet. See `mir embed <cmd> -h`. Tests in `tests/test_cli.py`.
191
+ - **`mir.repertoire.correct_batch`** — Harmony-like cluster-aware batch correction on a stacked
192
+ sample×feature Φ matrix. Removes the batch offset *per soft cluster* (batch-diversity-penalised),
193
+ so a batch confounded with a biological cluster is corrected without erasing that biology; reduces
194
+ exactly to `mir.cohort.residualize` at `n_clusters=1` / `theta=0` (`prop:batch`).
195
+ - **`[ann]` optional-dependency group** for the approximate-NN density backend (`pynndescent`).
196
+
197
+ ### Changed
198
+
199
+ - **Development now uses a repo-local `.venv` via [uv](https://docs.astral.sh/uv/)** instead of
200
+ conda. `setup.sh` is rewritten (bash/zsh portable; `--dev-parents`, `--docs`, `--tests`); the
201
+ conda `environment.yml` is removed. Runtime is unchanged — still a pure-Python `py3-none-any` wheel.
202
+ - **`pynndescent` moved from `[bench]` to the new `[ann]` extra.** `[bench]` is now all pure-wheel
203
+ (no numba/llvmlite), so `pip install "mirpy-lib[bench]"` resolves cleanly on any Python. Users of
204
+ `density.neighbor_enrichment(backend="ann")` should install `"mirpy-lib[ann]"`.
205
+ - **`vdjtools>=3.0.0`** (was `>=2.3.0`).
206
+ - Documentation overhaul: a use-case-driven user guide, the two CLI commands documented, an
207
+ examples/notebooks page, `mir.cohort` and `mir.bench.eval` added to the API reference, a logo, and
208
+ the sample-embedding schematic + real depth-robustness figure. Zero-warning Sphinx build.
209
+ - **Repo layout** (no effect on the installed package): adopted the **src-layout** (`mir/` →
210
+ `src/mir/`); renamed `notebooks/` → `examples/`; and moved the working result/plan markdown out of
211
+ the repo root — `THEORY.md` to the manuscript repo, `BENCHMARKS.md` / `REPERTOIRE_{EMBEDDING,LESSONS}.md`
212
+ / `SQRT_D_MIGRATION.md` / `ROADMAP.md` to `2026-mirpy-analysis/benchmarks/`. Root keeps
213
+ README / CHANGELOG / CLAUDE / SOURCES.
214
+
215
+ ### Fixed
216
+
217
+ - `DEFAULT_GAP_POSITIONS = (3, 4, -4, -3)` had three independent definitions
218
+ (`distances/junction`, `embedding/tcremp`, `ml/bundle`); now defined once in `distances.junction`
219
+ and imported, so the coordinate constant cannot drift.
220
+ - `cohort.cluster_samples` docstring described itself; `AntigenMetric` and `mir.bench.eval` gained
221
+ the docstrings/module-map entries they were missing.
222
+
223
+ ## 3.3.0 — 2026-07-17
224
+
225
+ Minor: one new public parameter, nothing removed or changed.
226
+
227
+ ### Added
228
+
229
+ - **`fit_density_space(chunk_size=)`** — embed and project in batches so the full raw matrix is never
230
+ resident. Peak memory becomes `max(pca_fit_cap, chunk_size) × n_features` instead of
231
+ `len(df) × n_features`: measured **10.60 GB → 1.81 GB** at 450k pooled clonotypes, and flat in `N`
232
+ (vs linear), at no wall-clock cost. This is what makes whole-cohort density arms runnable on a
233
+ laptop — the 4.2M-clonotype pooled arm is ~51 GB raw and ~102 GB once `scaler.transform` upcasts to
234
+ float64. Chunking is bit-exact at the embedding level (`_embed` of a slice == the slice of
235
+ `_embed`); the projected coordinates agree to float noise (~1e-7 relative), since BLAS summation
236
+ order depends on batch shape.
237
+
238
+ ### Fixed
239
+
240
+ - `fit_density_space`'s `pca_fit_cap` docstring claimed it "lets whole repertoires be embedded without
241
+ a full-matrix PCA". It caps the **fit**, not the memory — both raw matrices were already
242
+ materialized before the PCA was fitted. Documented, and `chunk_size=` is the actual remedy.
243
+ - **`mir.__version__` was stale** — it read `3.1.1` on the published 3.2.0, because the release bump
244
+ moved `pyproject.toml` but not `mir/__init__.py`, and `publish.yml` only validates *pyproject ==
245
+ tag*. Both now read 3.3.0. (`__version__` is still hand-maintained; deriving it from
246
+ `importlib.metadata` would retire this failure mode for good.)
247
+ - `tests/assets/olga_humanTRB_1000.txt.gz` was a slice of the alphabetically sorted VDJdb TRB dump,
248
+ not OLGA output as its name and `SOURCES.md` claimed — so it was **not** an antigen-naive null (12%
249
+ of rows had a Hamming-1 neighbour vs 0.2% for real OLGA). Regenerated from `olga-generate_sequences`;
250
+ provenance and a byte-reproducible regenerate command recorded in `SOURCES.md`. No test was
251
+ invalidated (they use it only as a generic TRB junction pool), but external calibrations that treated
252
+ it as a synthetic negative control were comparing VDJdb against itself. Not shipped (tests are
253
+ excluded from the sdist); listed here because it invalidates results, not code.
254
+
255
+ ## 3.2.0 — 2026-07-17
256
+
257
+ Minor: one new public module, nothing removed or changed.
258
+
259
+ ### Added
260
+
261
+ - **`mir.explain`** (T7) — explainable readouts over any repertoire feature matrix.
262
+ `ChannelSpec` / `ChannelBuilder` / `stack_embeddings` attach the name→column map that `Φ.vector`
263
+ does not carry (`stack_embeddings` is exact: `X[i] == embs[i].vector`, names only, no transform);
264
+ `channel_report` ablates each named channel under a caller-supplied scorer (leave-one-in by
265
+ default; `mode="both"` adds the conditional half that exposes *redundant* channels — high `delta`,
266
+ `delta_out≈0`; optional row-permutation p-values); `channel_drivers` hops from a winning
267
+ **kernel-mean** channel to the clonotypes driving it via `class_witness`, and refuses channels with
268
+ no clonotype pre-image (a Hill number's "drivers" are a category error, not an open question).
269
+ Scorer-agnostic by design — the library never sees the labels and ships no scorers, so a Cox
270
+ C-index and a CV AUC both plug in. **No existing module changed.**
271
+
272
+ ## 3.1.1 — 2026-07-14
273
+
274
+ Maintenance re-release of 3.1.0 with no functional changes — 3.1.0 was withdrawn from PyPI, and
275
+ this version restores the package under a fresh, clean version. The API, coordinates, and behaviour
276
+ are identical to 3.1.0 (see below).
277
+
278
+ ## 3.1.0 — 2026-07-14
279
+
280
+ The Part-2 feature tier on top of the 3.0 consolidated embedding core: neural codecs, continuous-density
281
+ methods, and the sample-level (repertoire) embedding, all on arda-native coordinates.
282
+
283
+ ### Added
284
+ - **`mir.density`** (T6) — graph-free continuous-density TCRNET/ALICE: balloon adaptive-radius enrichment
285
+ (Poisson/binomial + BH q, water-level calibration), abundance-aware weighted mass, backends
286
+ `exact`(BallTree) / `kdtree`(multicore) / `ann`(pynndescent).
287
+ - **`mir.repertoire`** (T7) — sample-level embedding `Φ(S)` = RFF kernel mean ‖ coverage-standardized Hill ‖
288
+ second-moment Fisher; `mmd_distance`/`mmd_matrix` (now with **`unbiased=True`** diagonal-removed MMD²),
289
+ `hla_stratified_mmd`, `class_witness` motif finder.
290
+ - **`mir.ml`** (Part 2, `[ml]` extra) — forward/inverse/pgen/unified neural codecs + `CodecBundle`
291
+ (prototype-hash-verified shipping); learned repertoire track `set_encoder` (Set-Transformer/DeepRC).
292
+ - **Bench** — clustering `method=` (dbscan/hdbscan/optics); `bench.theory` T6 `tcrnet_convergence`,
293
+ `codec_losslessness`.
294
+ - **Repertoire benchmarks** — `experiments/benchmark_repertoire_{aging,depth,cmvhla,hla,yfv,spikein,
295
+ agediverge}.py` and the COVID cohort suite `{covidbatch,covidhla,covidstatus,covidpaired}.py`
296
+ (`airr_covid19`, local-first + HF fallback). Recorded baselines in `BENCHMARKS.md`.
297
+
298
+ ### Changed
299
+ - **Coordinate system re-pinned to arda-native** germline + real-repertoire prototypes (versioned; any model
300
+ trained on the old coords must be retrained).
301
+ - **`mir.ml` device selection** now **CUDA → MPS → CPU** (was MPS-only) with a `MIR_DEVICE` env override and
302
+ CUDA seeding — GPU support beyond Apple silicon.
303
+ - Documented all parallelism knobs (README "Performance & parallelism"): `TCREmp(threads=0)` all-core,
304
+ density `backend="kdtree"` multicore-exact, `cluster(n_jobs=…)`, BLAS env.
305
+
306
+ ### Fixed
307
+ - **Unbiased repertoire MMD** — the biased V-statistic's `1/n_eff` self-term inflated low-diversity samples and
308
+ faked a divergence signal; `unbiased=True` removes it. Aging-divergence re-evaluated at depth: real but
309
+ diversity-coupled, not an independent axis.
310
+
311
+ ### Notes
312
+ - New lessons for the theory appendix in `REPERTOIRE_LESSONS.md`; full findings in `THEORY.md` T7.
313
+ - Pure-Python `py3-none-any` wheel; native code comes from `seqtree`/`vdjtools` wheels.
314
+
315
+ ## 3.0.0 — greenfield v3 embedding core (unreleased)
316
+ - Prototype (TCREMP) embedding on `seqtree.gapblock` + baked arda germline distances; vdjtools reuse (no AIRR
317
+ data-model layer of its own); bench harness (VDJdb Table S1); theory scaffold (`THEORY.md` S1–S3, T1–T5).
@@ -114,8 +114,28 @@ fix were added to `vdjtools` under the owner's direction (this is that owner's e
114
114
  high `delta` + `delta_out≈0` is the **redundancy** signature. `channel_drivers` hops channel→clonotypes
115
115
  via `class_witness`, but **only** for a channel declared `attributable` (a kernel mean); a Hill number
116
116
  has no clonotype pre-image and it raises. Depends one-way on `repertoire.py`; nothing there changed.
117
+ - `track.py` — **exposure trajectory** (repertoire-level exposure detection, complementing
118
+ `density.py`'s clone-level TCRNET/ALICE): `fit_exposure_trajectory(X, covariates, ...)`, a
119
+ PhenoPath-style (Campbell & Yau 2018) covariate-disentangled latent factor model —
120
+ `Y[n,g] = c_g + alpha_g.x_n + (kappa_g + gamma_g.x_n)*tau_n + eps` — fit by alternating closed-form
121
+ per-channel ridge regression and a GLS trajectory update (a simplified, non-CAVI approximation to
122
+ PhenoPath's inference; ARD-style iteratively-reweighted shrinkage on `gamma`, the covariate x
123
+ trajectory interaction). `TrajectoryFit.top_interactions()` ranks channels. Fills the
124
+ analysis-repo ROADMAP's Phase 5 (embedding trajectory). Torch-free.
125
+ - `generate.py` — the **generative loop, mechanical half** (ROADMAP Phase 2): `DescriptorDensity`
126
+ (optionally class-conditional Gaussian, Ledoit-Wolf shrinkage) over `RepertoireDescriptor` vectors;
127
+ `sample` draws new synthetic donor states, `evolve` perturbs one coordinate and propagates the
128
+ coupled shift via the fitted covariance's conditional mean — promotes the ad-hoc
129
+ `benchmark_repertoire_tcga_insilico.py` `np.cov`-slope pattern into a reusable library object.
130
+ Torch-free.
131
+ - `twin.py` — the **digital twin**: `DonorTwin`/`make_twins` glue one donor's `RepertoireDescriptor`
132
+ + an optional `track.py` trajectory position + covariate into one object; `.perturb()` (via
133
+ `generate.evolve`) and `.simulate()` (via `generate.DescriptorDensity` or `ml.diffusion.DiffusionModel`
134
+ — both share the `sample(n, condition=, seed=)` shape, so either drops in). Torch-free itself.
117
135
  - `ml/` — Part 2 (torch), neural codecs + `set_encoder.py` (learned repertoire track: Set-Transformer/DeepRC
118
- attention pooling + `SetEncoderBundle`).
136
+ attention pooling + `SetEncoderBundle`) + `diffusion.py` (**generative loop, research half**: compact
137
+ conditional DDPM/DDIM with classifier-free guidance over a compact descriptor/code space, standardized
138
+ internally with `x0`-clipping for DDIM stability; `DiffusionModel.save`/`load` mirrors `CodecBundle`).
119
139
  - `cli.py` — the `mir` console script (argparse, stdlib): `embed clonotypes` (TCREmp table) /
120
140
  `embed repertoires` (per-locus `fit_repertoire_space`→`sample_embedding` Φ(S), optional `--mmd`).
121
141
  Reads via `vdjtools.io.read`; writes TSV/parquet.
@@ -155,9 +175,18 @@ fix were added to `vdjtools` under the owner's direction (this is that owner's e
155
175
  cleanup) — the "vdjtools at the embedding level" audit +
156
176
  plan (three verbs: make / measure / generate-decode). **Phase 0** (robustness + optimization quick wins)
157
177
  and **Phase 1** (cohort tier: `bench/eval.py`, `repertoire.{fit_repertoire_spaces,centroid_atypicality}`,
158
- `cohort.py` digital donor) are **DONE**. Next: **Phase 2** generative loop (`generate.py` `DescriptorDensity`
159
- + `evolve`/`sample`, `CodecBundle.from_unified/from_decoder`), then multimodal encoders + embedding
160
- trajectory. **Analysis-repo follow-up:** refactor `_tcga_embedding.build_embedding` onto
178
+ `cohort.py` digital donor) are **DONE**. **Phase 2 (generative loop, mechanical half) — DONE**:
179
+ `generate.py` `DescriptorDensity` (`sample`/`evolve`) ships, promoting the ad-hoc
180
+ `benchmark_repertoire_tcga_insilico.py` `np.cov`-slope pattern; `CodecBundle.from_unified/from_decoder`
181
+ is still open. **Phase 5 (embedding trajectory) — partially addressed**: `track.py`
182
+ `fit_exposure_trajectory` (a PhenoPath-style covariate-disentangled latent trajectory, Campbell &
183
+ Yau 2018) fills this rather than the originally-sketched `track.repertoire_trajectory`
184
+ Φ-velocity idea — a different, arguably more principled approach to the same phase; `clonotype_flux`
185
+ (differential enrichment) is still open. Also new: `mir.ml.diffusion` (generative loop, **research
186
+ half** — conditional DDPM/DDIM, complementing the linear `DescriptorDensity`) and `twin.py`
187
+ (`DonorTwin`/`make_twins` — glues descriptor + trajectory + generator into one perturb/simulate
188
+ object). Next: Phase 3 (embedding inversion / generation), Phase 4 (multimodal encoders).
189
+ **Analysis-repo follow-up:** refactor `_tcga_embedding.build_embedding` onto
161
190
  `cohort.fit_donor_embeddings` (+ `extra_channels` for isotype/composition/atypicality) and re-verify the
162
191
  pan-cancer ΔC numbers. NB Phase 0 flipped the density default to `backend="kdtree"` — re-verify any
163
192
  recorded balloon-mode baselines (±1 boundary counts).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mirpy-lib
3
- Version: 3.4.0
3
+ Version: 3.7.0
4
4
  Summary: ML-oriented embeddings for immune receptor repertoires (TCR/BCR)
5
5
  Project-URL: Homepage, https://github.com/antigenomics/mirpy
6
6
  Project-URL: Repository, https://github.com/antigenomics/mirpy
@@ -24,7 +24,6 @@ Requires-Dist: polars
24
24
  Requires-Dist: scikit-learn
25
25
  Requires-Dist: scipy
26
26
  Requires-Dist: seqtree>=0.3.0
27
- Requires-Dist: tqdm
28
27
  Requires-Dist: vdjtools>=3.0.0
29
28
  Provides-Extra: ann
30
29
  Requires-Dist: pynndescent; extra == 'ann'
@@ -44,9 +43,6 @@ Requires-Dist: psutil>=5; extra == 'dev'
44
43
  Requires-Dist: pytest-cov; extra == 'dev'
45
44
  Requires-Dist: pytest>=8; extra == 'dev'
46
45
  Provides-Extra: docs
47
- Requires-Dist: ipython<10,>=8; extra == 'docs'
48
- Requires-Dist: nbsphinx-link<2,>=1.3; extra == 'docs'
49
- Requires-Dist: nbsphinx<1,>=0.9; extra == 'docs'
50
46
  Requires-Dist: pydata-sphinx-theme<1,>=0.15; extra == 'docs'
51
47
  Requires-Dist: sphinx<9,>=7.2; extra == 'docs'
52
48
  Provides-Extra: examples
@@ -108,12 +104,13 @@ df = pl.DataFrame({
108
104
  X = model.embed(df) # (2, 9000) float32 — 3 distances × 3000 prototypes
109
105
  ```
110
106
 
111
- Downstream (cluster antigen-specific TCRs):
107
+ Downstream (cluster antigen-specific TCRs — needs `[bench]` for the kneedle `eps`, and a real
108
+ set of clonotypes rather than the two above):
112
109
 
113
110
  ```python
114
111
  from mir.embedding.pca import pca_denoise
115
112
  from mir.bench.metrics import cluster, cluster_metrics
116
- labels = cluster(pca_denoise(X, n_components=50))
113
+ labels = cluster(pca_denoise(model.embed(vdjdb_df), n_components=50))
117
114
  ```
118
115
 
119
116
  Paired chains concatenate per-chain embeddings via `PairedTCREmp`. Input/output are AIRR polars
@@ -137,6 +134,11 @@ mir embed repertoires cohort/*.tsv.gz -o phi.tsv --mmd mmd.tsv
137
134
  prototype count, weight, Φ blocks, …). Sample id defaults to the filename stem; the locus is
138
135
  inferred per file (or restrict with `--locus`).
139
136
 
137
+ Both commands drop non-coding clonotypes (stop codon / legacy out-of-frame markers in
138
+ `junction_aa`) before embedding by default — pass `--no-filter-functional` to skip this. Without
139
+ it, a stop codon silently produces a numerically meaningless embedding and an out-of-frame `_`
140
+ marker crashes the run outright (neither is a valid amino acid).
141
+
140
142
  ## Recommended presets
141
143
 
142
144
  `TCREmp.from_defaults(species, locus)` uses the per-chain preset when `n_prototypes` is
@@ -167,6 +169,53 @@ Xc = pca_denoise(X, n_components=p.n_components) # clustering
167
169
  Xr = pca_denoise(X, n_components=p.n_components_recon) # codec reconstruction
168
170
  ```
169
171
 
172
+ ## Prototypes — which receptors, and how much do they matter?
173
+
174
+ Every embedding is *distances to prototypes*, so the prototype set **is** the coordinate system.
175
+ mirpy ships one per chain, and you get them without downloading anything:
176
+
177
+ | | |
178
+ |---|---|
179
+ | What | **10 000 real receptors** per chain — a uniform random sample (fixed `seed=42`) of unique, productive, germline-resolvable clonotypes from arda-annotated real repertoires |
180
+ | Why real | Model-generated junctions have degenerate lengths and embed measurably worse (negative self-prototype distance correlation); real repertoires give a tight, well-behaved manifold |
181
+ | The default | `replicate=0` — the first `n` rows. This is *the* set: every preset, bundled codec, and published number uses it. Don't change it unless you're deliberately testing sensitivity |
182
+ | Chains | human TRA/TRB/TRG/TRD/IGH/IGK/IGL, mouse TRA/TRB (`list_available_prototypes()`) |
183
+
184
+ **Is my result an artefact of which prototypes I drew?** Take a replicate. The file order is itself a
185
+ uniform shuffle, so each disjoint block of `n` rows is an independent draw from the same pool —
186
+ `n_replicates()` of them, **10 at `n=1000`**, 5 at `n=2000`:
187
+
188
+ ```python
189
+ from mir.embedding.prototypes import n_replicates
190
+ from mir.embedding.tcremp import TCREmp
191
+
192
+ scores = [my_metric(TCREmp.from_defaults("human", "TRB", 1000, replicate=r).embed(df))
193
+ for r in range(n_replicates("human", "TRB", 1000))] # 10 draws; spread = sensitivity
194
+ ```
195
+
196
+ Same from the shell: `mir embed clonotypes sample.tsv --n-prototypes 1000 --replicate 3`.
197
+
198
+ **How much *does* it matter?** Usually very little, and you can check for yourself —
199
+ `bench.theory.prototype_source_correlation(queries, protos_a, protos_b)` correlates the pairwise
200
+ junction-distance geometry under two prototype sets. Two independent draws, 400 held-out human-TRB
201
+ queries:
202
+
203
+ | prototypes `n` | 100 | 250 | 500 | 1000 (default) | 2000 |
204
+ |---|--:|--:|--:|--:|--:|
205
+ | R between two draws | 0.922 | 0.971 | 0.990 | 0.993 | **0.997** |
206
+
207
+ So the geometry is essentially draw-independent from `n≈500` up: at the default counts, *which*
208
+ prototypes you drew is not what your result rests on. Below `n≈250` it starts to be.
209
+
210
+ > Each replicate is a **different coordinate system**. Distances *within* one are comparable;
211
+ > distances *across* two are not. The prototype hash covers the replicate index, so codecs,
212
+ > `RepertoireSpace` and `DonorCohort` all refuse to mix them — compare summary statistics across
213
+ > replicates (AUC, F1, cluster counts), never raw embeddings. Sweeping `n_prototypes` instead is a
214
+ > *nested* comparison (draw `r=0` at `n=500` is a prefix of `n=1000`), which answers "how many do I
215
+ > need", not "does it matter which".
216
+
217
+ Provenance and the regenerate command are in [`SOURCES.md`](SOURCES.md).
218
+
170
219
  ## What's inside
171
220
 
172
221
  | Module | Purpose |
@@ -179,7 +228,10 @@ Xr = pca_denoise(X, n_components=p.n_components_recon) # codec reconstruction
179
228
  | `mir.repertoire` | sample-level (repertoire) embedding — RFF kernel mean ‖ Hill diversity ‖ second moment; MMD / HLA-stratified distance; motif witness; `centroid_atypicality`, multi-locus `fit_repertoire_spaces` (Theory §T.7) |
180
229
  | `mir.explain` | named-channel fusion (`ChannelBuilder`) + scorer-agnostic ablation (`channel_report`/`channel_drivers`) — which part of Φ carries the signal (§T.7) |
181
230
  | `mir.cohort` | the **digital donor** — multi-chain `fit_donor_embeddings`/`DonorCohort` (+ `transform`/`save`/`load`) + `residualize` / `cluster_samples` / `incidence_biomarkers` (§T.7) |
182
- | `mir.ml` | neural codecs (forward/inverse/Pgen/unified) + learned repertoire `set_encoder` (Set-Transformer/DeepRC) Part 2, experimental; `[ml]` extra |
231
+ | `mir.track` | **exposure trajectory** PhenoPath-style covariate-disentangled latent progression axis (`fit_exposure_trajectory`) over any channel matrix; repertoire-level exposure detection |
232
+ | `mir.generate` | the **generative loop** (mechanical half) — `DescriptorDensity`: sample new synthetic donor states / `evolve` one along a coordinate, over `RepertoireDescriptor` |
233
+ | `mir.twin` | the **digital twin** — `DonorTwin`/`make_twins`: perturb or resample one donor's state through a `mir.generate`/`mir.ml.diffusion` generator |
234
+ | `mir.ml` | neural codecs (forward/inverse/Pgen/unified) + learned repertoire `set_encoder` (Set-Transformer/DeepRC) + conditional **diffusion generator** (`mir.ml.diffusion`, the generative loop's research half) — Part 2, experimental; `[ml]` extra |
183
235
 
184
236
  ## Background subtraction & clustering (`mir.density`)
185
237
 
@@ -203,24 +255,32 @@ labels, mask = denoise_and_cluster(obs_emb, res) # noise-filter +
203
255
 
204
256
  Use a **biological control** as the background when you have one (e.g. pre- vs post-vaccination,
205
257
  patient vs healthy) — differential enrichment cancels generic public convergence and isolates the
206
- antigen-specific response. With no control, `generate_background(locus, n)` samples the vdjtools
207
- P_gen model (the ALICE regime); the "water level" of a naive repertoire is handled by the
208
- empirical-null calibration. The density benchmarks (YFV, ankylosing-spondylitis B27, TCRNET)
258
+ antigen-specific response. No control of your own? Pooled healthy-donor repertoires are one fetch
259
+ away (HF `isalgo/airr_control`, read with `vdjtools.io.read` see [`SOURCES.md`](SOURCES.md)).
260
+ Failing that, `generate_background(locus, n)` samples the vdjtools P_gen model (the ALICE regime);
261
+ the "water level" of a naive repertoire is handled by the empirical-null calibration. Pass
262
+ `source="arda"` there when your data is arda-annotated (same allele namespace as the prototypes),
263
+ and `species="mouse"` for mouse — both need a vdjtools shipping the bundled `arda` model set. The density benchmarks (YFV, ankylosing-spondylitis B27, TCRNET)
209
264
  live in the companion [`2026-mirpy-analysis`](https://github.com/antigenomics) repo.
210
265
 
211
- At whole-repertoire scale, pass `neighbor_enrichment(..., backend="kdtree")` (exact scipy cKDTree,
212
- 5–9× faster than the default BallTree) or `backend="ann"` (approximate pynndescent, ~30× faster
213
- past ~10⁵ clones, trading a small conservative undercount; `pip install "mirpy-lib[ann]"`).
266
+ The default backend is `"kdtree"` (exact scipy cKDTree, all cores). At whole-repertoire scale pass
267
+ `backend="ann"` (approximate pynndescent, ~30× faster past ~10⁵ clones, trading a small conservative
268
+ undercount; `pip install "mirpy-lib[ann]"`).
214
269
 
215
270
  ## Sample-level (repertoire) embedding (`mir.repertoire`)
216
271
 
217
272
  One fixed vector `Φ(S)` per **repertoire** — an order-invariant multiset of clonotypes with clone
218
273
  sizes — depth-robust into the low-coverage bulk-RNA-seq regime (Theory §T.7). `Φ(S)` sketches the
219
- empirical measure `ρ_S = Σ_σ w_σ δ_{φ(σ)}` (concave frequency weights, so one hyperexpanded clone
220
- can't dominate) in three blocks: an RFF **kernel mean** (depth-robust, codebook-free — no `K`, no
221
- clustering), a coverage-standardized **Hill diversity** profile, and a **second-moment** Fisher
222
- vector carrying clonotype co-occurrence (HLA-linked public structure). Repertoire distance is the
223
- **MMD** `‖Φ₁(S) − Φ₁(S')‖`.
274
+ empirical measure `ρ_S = Σ_σ w_σ δ_{φ(σ)}` in three blocks: an RFF **kernel mean** (depth-robust,
275
+ codebook-free no `K`, no clustering), a coverage-standardized **Hill diversity** profile, and a
276
+ **second-moment** Fisher vector carrying clonotype co-occurrence (HLA-linked public structure).
277
+ Repertoire distance is the **MMD** `‖Φ₁(S) Φ₁(S')‖`.
278
+
279
+ The per-clonotype weights `w_σ = g(a_σ)/Σ_τ g(a_τ)` come from a clone-size transform `g` (`weight=`
280
+ on `sample_embedding`/`fit_repertoire_space`/`mir embed repertoires --weight`): `"log2p1"` —
281
+ `g=log2(1+a)` — is the **default**, concave so one hyperexpanded clone can't dominate;
282
+ `"duplicate_count"` weights linearly by clone size (`g=a`); `"distinct"` ignores size entirely
283
+ (`g≡1`, presence only). `"log1p"` (natural log) and `"anscombe"` remain available.
224
284
 
225
285
  ```python
226
286
  from mir.repertoire import fit_repertoire_space, sample_embedding, mmd_matrix, class_witness
@@ -249,6 +309,47 @@ TRA and class II) lives in the second moment / witness. A learned co-equal set e
249
309
  (T7) live in the companion [`2026-mirpy-analysis`](https://github.com/antigenomics) repo
250
310
  (`benchmarks/{BENCHMARKS,THEORY}.md`) alongside the benchmark scripts.
251
311
 
312
+ ## Exposure trajectory, generative loop, digital twin
313
+
314
+ A repertoire cohort often has a **known covariate** (HLA, batch, vaccine arm) but an **unknown or
315
+ noisy progression axis** (days since exposure, response severity). `mir.track.fit_exposure_trajectory`
316
+ recovers that latent trajectory `tau` while disentangling it from the covariate — a PhenoPath-style
317
+ model (Campbell & Yau 2018, *Nat. Commun.*
318
+ [10.1038/s41467-018-04696-6](https://doi.org/10.1038/s41467-018-04696-6), adapted from genes×cells to
319
+ repertoire-channels×samples) over *any* per-sample channel matrix (a stacked `Φ`, a `ChannelBuilder`
320
+ build, or a raw embedding block):
321
+
322
+ ```python
323
+ from mir.explain import stack_embeddings
324
+ from mir.track import fit_exposure_trajectory
325
+
326
+ X, spec = stack_embeddings(embs) # channels: mean ‖ diversity ‖ second
327
+ fit = fit_exposure_trajectory(X, hla_indicator, channel_names=spec.names)
328
+ fit.tau # inferred exposure/progression pseudotime, one per sample
329
+ fit.top_interactions(5) # which channels respond to progression differently by covariate
330
+ ```
331
+
332
+ Complementing that, `mir.generate.DescriptorDensity` fits a (optionally class-conditional) density
333
+ over `RepertoireDescriptor` vectors — `sample` draws brand-new synthetic donor states, `evolve`
334
+ perturbs one donor along a coordinate ("what if hotter") and propagates the coupled shift through
335
+ every other coordinate via the fitted covariance's conditional mean. `mir.ml.diffusion` (`[ml]`
336
+ extra) is the non-linear alternative — a compact conditional DDPM/DDIM generator with
337
+ classifier-free guidance, sharing the same `sample(n, condition=…)` call shape so it drops in
338
+ unchanged. `mir.twin.DonorTwin` glues a donor's descriptor + trajectory position + covariate into
339
+ one object you perturb or resample through, instead of threading the three APIs together by hand:
340
+
341
+ ```python
342
+ from mir.generate import fit_descriptor_density
343
+ from mir.twin import make_twins
344
+
345
+ density = fit_descriptor_density(descriptors, labels=tumor_type)
346
+ twins = make_twins(descriptors, conditions=tumor_type, donor_ids=sample_ids)
347
+ hotter = twins[0].perturb(density, coordinate="infiltration", delta=2.0)
348
+ synthetic = twins[0].simulate(density, n=20) # 20 new synthetic peers of donor 0
349
+ ```
350
+
351
+ See `examples/trajectory_and_twin.py` for a runnable end-to-end demo.
352
+
252
353
  ## Reproduce the paper
253
354
 
254
355
  The self-contained theory notebooks run on bundled data:
@@ -256,7 +357,7 @@ The self-contained theory notebooks run on bundled data:
256
357
  ```bash
257
358
  pip install "mirpy-lib[examples]"
258
359
  marimo edit examples/theory.py # supplementary S1–S3 (distance laws, D↔d, prototype robustness)
259
- marimo edit examples/quickstart.py # embed + cluster VDJdb antigens
360
+ marimo edit examples/quickstart.py # embed + cluster (epitope colours need a local VDJdb dump)
260
361
  ```
261
362
 
262
363
  The full benchmark suite (VDJdb Table S1, density, repertoire/TCGA) and result docs live in the
@@ -273,13 +374,14 @@ mirpy is CPU-parallel by default and uses the GPU for the neural codecs. Knobs,
273
374
  | Stage | Knob | Default | Notes |
274
375
  |---|---|---|---|
275
376
  | Embedding (junction distance) | `TCREmp(..., threads=N)` | `0` = **all cores** | The C++ `seqtree.gapblock` scorer; releases the GIL, ~530 M pairs/s @16 cores. `threads=1` for a serial run. |
276
- | Density kNN / balloon | `neighbor_enrichment(..., backend=…)` | `"exact"` (BallTree, **1 core**) | `backend="kdtree"` = exact scipy cKDTree, **all cores** (`workers=-1`), 5–9× faster; `backend="ann"` = pynndescent, auto all-core, ~30× at ≥1e5. Prefer `kdtree` for multicore exact. |
377
+ | Density kNN / balloon | `neighbor_enrichment(..., backend=…)` | `"kdtree"` (scipy cKDTree, **all cores**) | Exact and multithreaded (`workers=-1`), 5–9× faster than the BallTree baseline. `backend="ann"` = pynndescent, auto all-core, ~30× at ≥1e5; `backend="exact"` = the 1-core BallTree baseline, for reproducing older runs. |
277
378
  | Clustering | `cluster(..., n_jobs=-1)` | sklearn default (1) | forwarded to DBSCAN/OPTICS/HDBSCAN via `**kwargs`; parallelizes the neighbour search. |
278
379
  | BLAS (PCA, RFF, matmul) | `OMP_NUM_THREADS` / `OPENBLAS_NUM_THREADS` env | all cores | numpy/sklearn use the platform BLAS; cap via env if oversubscribed. |
279
380
  | Neural codecs (`mir.ml`) | `pick_device()` / `device=` / `MIR_DEVICE` env | **CUDA → MPS → CPU**, auto | every `train_*` / codec / bundle takes `device=`; e.g. `MIR_DEVICE=cuda:1` pins the second GPU. Torch-free paths (`density`, `repertoire`) never touch the GPU. |
280
381
 
281
- Rule of thumb: leave `threads=0` (all cores) for embedding; switch density to `backend="kdtree"`
282
- for exact multicore or `"ann"` at whole-repertoire scale; the GPU is used only by `mir.ml`.
382
+ Rule of thumb: leave `threads=0` (all cores) for embedding; leave density on the default
383
+ `backend="kdtree"` (exact, multicore) and switch to `"ann"` only at whole-repertoire scale; the GPU
384
+ is used only by `mir.ml`.
283
385
 
284
386
  ## Development
285
387