seqtree 0.2.0__tar.gz → 0.4.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 (64) hide show
  1. {seqtree-0.2.0 → seqtree-0.4.0}/.gitignore +5 -0
  2. seqtree-0.4.0/CHANGELOG.md +287 -0
  3. {seqtree-0.2.0 → seqtree-0.4.0}/CMakeLists.txt +2 -0
  4. {seqtree-0.2.0 → seqtree-0.4.0}/PKG-INFO +72 -8
  5. {seqtree-0.2.0 → seqtree-0.4.0}/README.md +69 -6
  6. {seqtree-0.2.0 → seqtree-0.4.0}/ROADMAP.md +11 -5
  7. seqtree-0.4.0/SOURCES.md +97 -0
  8. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/evalue.pdf +0 -0
  9. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/evalue.tex +206 -85
  10. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/refs.bib +3 -1
  11. seqtree-0.4.0/examples/01_gapped_search.py +118 -0
  12. seqtree-0.4.0/examples/02_sequence_dendrogram.py +144 -0
  13. seqtree-0.4.0/examples/03_indel_positions.py +169 -0
  14. seqtree-0.4.0/examples/04_island_profile.py +151 -0
  15. {seqtree-0.2.0 → seqtree-0.4.0}/include/seqtree/seqtree.hpp +79 -2
  16. {seqtree-0.2.0 → seqtree-0.4.0}/include/seqtree/types.hpp +16 -7
  17. {seqtree-0.2.0 → seqtree-0.4.0}/pyproject.toml +5 -3
  18. seqtree-0.4.0/python/seqtree/__init__.py +70 -0
  19. seqtree-0.4.0/python/seqtree/control.py +321 -0
  20. seqtree-0.4.0/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  21. seqtree-0.4.0/python/seqtree/evalue.py +178 -0
  22. seqtree-0.4.0/python/seqtree/gapblock.py +673 -0
  23. {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/layout.py +12 -0
  24. seqtree-0.4.0/python/seqtree/pairwise.py +167 -0
  25. {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/pmhc.py +2 -0
  26. seqtree-0.4.0/python/seqtree/seeds.py +149 -0
  27. seqtree-0.4.0/skills/seqtree/SKILL.md +246 -0
  28. {seqtree-0.2.0 → seqtree-0.4.0}/src/_bindings.cpp +186 -8
  29. seqtree-0.4.0/src/atomic_write.hpp +75 -0
  30. seqtree-0.4.0/src/blosum45.inc +32 -0
  31. seqtree-0.4.0/src/blosum80.inc +32 -0
  32. {seqtree-0.2.0 → seqtree-0.4.0}/src/engine_seqtm.cpp +2 -4
  33. {seqtree-0.2.0 → seqtree-0.4.0}/src/engines.hpp +1 -1
  34. seqtree-0.4.0/src/gapblock.cpp +135 -0
  35. {seqtree-0.2.0 → seqtree-0.4.0}/src/index.cpp +71 -43
  36. {seqtree-0.2.0 → seqtree-0.4.0}/src/kmer_index.cpp +26 -21
  37. seqtree-0.4.0/src/pairwise.cpp +354 -0
  38. {seqtree-0.2.0 → seqtree-0.4.0}/src/searcher.cpp +17 -7
  39. {seqtree-0.2.0 → seqtree-0.4.0}/src/substitution_matrix.cpp +37 -0
  40. seqtree-0.2.0/python/seqtree/__init__.py +0 -44
  41. seqtree-0.2.0/python/seqtree/control.py +0 -96
  42. seqtree-0.2.0/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  43. seqtree-0.2.0/python/seqtree/evalue.py +0 -79
  44. {seqtree-0.2.0 → seqtree-0.4.0}/.gitattributes +0 -0
  45. {seqtree-0.2.0 → seqtree-0.4.0}/LICENSE +0 -0
  46. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/.gitignore +0 -0
  47. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/.latexmkrc +0 -0
  48. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/Makefile +0 -0
  49. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/epitope_detection.pdf +0 -0
  50. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/evalue_matrix.pdf +0 -0
  51. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/mhc1_rocpr.pdf +0 -0
  52. {seqtree-0.2.0 → seqtree-0.4.0}/appendix/mhc2_rocpr.pdf +0 -0
  53. {seqtree-0.2.0 → seqtree-0.4.0}/include/seqtree/kmer_index.hpp +0 -0
  54. {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/pmhc_evalue.py +0 -0
  55. {seqtree-0.2.0 → seqtree-0.4.0}/python/seqtree/py.typed +0 -0
  56. {seqtree-0.2.0 → seqtree-0.4.0}/src/blosum62.inc +0 -0
  57. {seqtree-0.2.0 → seqtree-0.4.0}/src/codec.cpp +0 -0
  58. {seqtree-0.2.0 → seqtree-0.4.0}/src/engine_seqtrie.cpp +0 -0
  59. {seqtree-0.2.0 → seqtree-0.4.0}/src/pam100.inc +0 -0
  60. {seqtree-0.2.0 → seqtree-0.4.0}/src/pam250.inc +0 -0
  61. {seqtree-0.2.0 → seqtree-0.4.0}/src/positional_matrix.cpp +0 -0
  62. {seqtree-0.2.0 → seqtree-0.4.0}/src/structural.inc +0 -0
  63. {seqtree-0.2.0 → seqtree-0.4.0}/src/trie.cpp +0 -0
  64. {seqtree-0.2.0 → seqtree-0.4.0}/src/trie.hpp +0 -0
@@ -11,3 +11,8 @@ _skbuild/
11
11
  docs/_build/
12
12
  bench/figures/
13
13
  bench/cache/
14
+
15
+ # coverage artifacts
16
+ .coverage
17
+ .coverage.*
18
+ htmlcov/
@@ -0,0 +1,287 @@
1
+ # Changelog
2
+
3
+ All notable changes to `seqtree`. Dates are release dates; the project is pre-1.0, so a **minor**
4
+ bump may carry breaking changes.
5
+
6
+ ## [0.4.0] — 2026-07-11
7
+
8
+ ### Added
9
+
10
+ - **`seqtree.pairwise` — Needleman-Wunsch and Smith-Waterman, so ordinary protein alignment no
11
+ longer needs BioPython.** Everything else in seqtree *minimises a non-negative penalty*, which
12
+ is what a search ball and an E-value need. These **maximise a raw log-odds similarity**, the way
13
+ BLAST and BioPython do, because that is what a pairwise alignment means.
14
+
15
+ | | |
16
+ |---|---|
17
+ | `pairwise.score(q, r, matrix, mode=...)` | optimal score, `O(min(m,n))` memory |
18
+ | `pairwise.align(...)` | plus the aligned strings and ops |
19
+ | `pairwise.score_matrix(queries, refs, ...)` | dense `n × K`, GIL released, zero-copy numpy |
20
+ | `pairwise.dist_matrix(...)` | `d = s(a,a) + s(b,b) − 2·s(a,b)`: non-negative, zero on the diagonal |
21
+
22
+ `mode="global"` is Needleman-Wunsch, `mode="local"` Smith-Waterman, and **`gap_open == gap_extend`
23
+ gives linear gaps** — no separate mode. A gap run of length `L` costs `gap_open + (L-1)·gap_extend`,
24
+ and global charges end gaps (true NW, not semi-global).
25
+
26
+ **It is a drop-in.** `tests/python/test_pairwise.py` runs it against `Bio.Align.PairwiseAligner`
27
+ as an oracle over three matrices × ten gap/mode settings × sixty sequence shapes — **zero
28
+ disagreements**, including on real germline V genes. BioPython is a *test-only* dependency;
29
+ seqtree still has **zero required runtime dependencies** and never imports it.
30
+
31
+ Measured on an M3 (all-against-all, BLOSUM62, global, 11/1):
32
+
33
+ | sequence length | seqtree, 1 thread | seqtree, 16 threads | BioPython | speedup |
34
+ |---|---|---|---|---|
35
+ | 15 (a junction) | 1.7 M pairs/s | **20.1 M pairs/s** | 0.31 M pairs/s | **65×** |
36
+ | 90 (a germline V gene) | 72 k pairs/s | **893 k pairs/s** | 10 k pairs/s | **87×** |
37
+
38
+ - **`SubstitutionMatrix.similarity(a, b)`** — the raw signed log-odds, alongside the existing
39
+ non-negative `penalty(a, b)`. The Gram transform `pen = s(a,a) + s(b,b) − 2·s(a,b)` is **lossy**:
40
+ it forces the diagonal to zero and destroys `s(a,a)`, so a similarity cannot be recovered from a
41
+ penalty. Both views are now stored.
42
+
43
+ - **`SubstitutionMatrix.blosum45()` and `.blosum80()`**, and the names `"BLOSUM45"` / `"BLOSUM80"`
44
+ wherever a matrix name is accepted. Shallower and deeper than BLOSUM62 — for remote and close
45
+ homologs respectively.
46
+
47
+ ## [0.3.1] — 2026-07-10 (never published; folded into 0.4.0)
48
+
49
+ > Tagged but not released to PyPI. Everything below ships in **0.4.0**, so upgrading from 0.3.0
50
+ > straight to 0.4.0 picks it all up. Kept as its own section because it is a distinct set of fixes.
51
+
52
+
53
+ ### Fixed
54
+
55
+ - **A cold cache shared by concurrent processes could hand back a half-written index.**
56
+ `Index::save` wrote straight into the destination, so for the whole duration of the write the
57
+ file existed but was truncated. A second process that checked `os.path.exists(cache)` in that
58
+ window loaded a stub and raised `RuntimeError: truncated or corrupt index`. On a 45 MB control
59
+ index the window is ~55 ms, and a reader racing a writer hit it **10 times out of 10**.
60
+
61
+ This is the first-use-only failure of any multi-process fan-out sharing `~/.cache`: pytest-xdist,
62
+ a Snakemake or Nextflow pipeline calling `load_control` in parallel, a `multiprocessing` pool.
63
+ Once the cache is warm it is read-only and was always safe. CI matrix jobs were never affected —
64
+ separate runners, separate caches.
65
+
66
+ `Index::save` and `KmerIndex::save` now serialize into a uniquely-named temporary beside the
67
+ destination and `rename` it into place. Rename is atomic on the same filesystem, on POSIX and
68
+ Windows alike, so a reader sees either the previous complete file or the new complete file and
69
+ never a partial one. A failed save cleans up its temporary and leaves any pre-existing index
70
+ intact.
71
+
72
+ - **A corrupt or stale cache now rebuilds instead of raising.** A file truncated by a full disk,
73
+ left by a killed process, or written by an older seqtree sent `load_control` into an exception;
74
+ it now falls back to rebuilding. The cache was always best-effort and now behaves that way.
75
+
76
+ - **The control cache is content-addressed, so a stale cache can no longer be served silently.**
77
+ The key was `control_{name}_{size}.sqtree`, which named neither the **alphabet**, nor the
78
+ **seed**, nor the **source data**. Three consequences, all live:
79
+
80
+ - Two calls differing only in `seed` — which must draw *different* reservoir samples — shared one
81
+ cache file, so the second silently received the first's sequences. Same for `alphabet`.
82
+ - An upgrade that changed the bundled control kept the same filename, so a warm cache served the
83
+ **previous release's** control. This is exactly how 0.3.0's corrected (uniform) control could be
84
+ masked by a stale 0.2.0 (abundance-head) cache — and why 0.3.0's notes had to ask people to
85
+ `rm ~/.cache/seqtree/control_*.sqtree` by hand.
86
+
87
+ The key now carries a fingerprint of the bundled asset's own bytes (or, on the download path, the
88
+ source and seed), so a control that changed simply misses the old cache. Superseded caches,
89
+ including pre-fingerprint ones from earlier releases, are deleted on the next build.
90
+
91
+ **You no longer need to clear `~/.cache/seqtree` when upgrading.** Doing so is harmless.
92
+
93
+ ### Added
94
+
95
+ - **`load_control` takes an inter-process lock around build-and-save when `filelock` is available**
96
+ (it arrives with `huggingface_hub`). This is an optimisation, not the fix: correctness comes from
97
+ the atomic rename and holds with no lock at all. What the lock saves is work — without it, a cold
98
+ fan-out of N workers has every worker build the same 250k-clonotype index and discard N−1 of them.
99
+ seqtree still has **zero required runtime dependencies**; the import is guarded.
100
+
101
+ ## [0.3.0] — 2026-07-10
102
+
103
+ Gap-block alignment, calibrated cutoffs, seed significance — the removal of several engine paths
104
+ that returned confident wrong answers, and a corrected background control that changes every
105
+ E-value.
106
+
107
+ ### Breaking
108
+
109
+ - **The bundled control is a different set of sequences.** It was the *abundance head* of the
110
+ upstream repertoire — the 250,000 most expanded clonotypes — because both `gen_control.py` and
111
+ `_download` took the first `size` unique rows of a count-descending table. `appendix/evalue.tex`
112
+ (`ass:indep`) assumes the control's unique clonotypes are i.i.d. from `P₀`. Measured against a
113
+ uniform sample of the same size, the head is **25.8× more self-similar** (P(Hamming≤2 | equal
114
+ length) 3.11×10⁻³ vs 1.20×10⁻⁴) and carries **3.1× the ball mass** at a BLOSUM62 budget of 40
115
+ (mean n_C 110.1 vs 35.5). Both are now uniform reservoir samples over unique **productive**
116
+ clonotypes, seeded and shuffled so any prefix is itself a valid sub-sample.
117
+
118
+ **Every E-value moves.** Delete `~/.cache/seqtree/control_*.sqtree` after upgrading — a warm
119
+ cache from 0.2.0 would otherwise be served in place of the corrected control. (Fixed in 0.3.1:
120
+ the cache is now content-addressed and a stale one simply misses. Upgrading straight from 0.2.0
121
+ to ≥0.3.1 needs no manual step.) Numbers derived from the control are corrected throughout this
122
+ file, `seeds.py`, `SKILL.md` and the appendix.
123
+ - **Controls are filtered to productive clonotypes.** VDJtools marks out-of-frame rearrangements
124
+ with `_` and in-frame stops with `*`; 13.7% of the mouse TRB table is out of frame. `_` cannot be
125
+ repaired at the amino-acid level — VDJtools collapses a *run* of untranslatable positions into one
126
+ character, so the residue count is already gone — and out-of-frame junctions escape thymic
127
+ selection, making them an estimator of `P_gen`, which `lem:hierarchy` says is not `P₀`.
128
+ `load_control("mouse_trb_aa")` previously raised on `_`; it now yields 694,241 clonotypes.
129
+ - **`engine="auto"` now always resolves to `seqtm`.** It previously routed matrix-plus-indel
130
+ searches to `seqtrie`, whose budget defaults to `INT_MAX/4`, so
131
+ `SearchParams(max_subs=1, max_ins=1, matrix="BLOSUM62")` silently returned **every reference in
132
+ the index**. `seqtrie` ignores per-type edit caps and can never honour them; `auto` will not pick
133
+ it. Passing a matrix to `seqtrie` without an explicit `max_penalty` now raises.
134
+ - **`Mode::Local` / `mode="local"` deleted.** It was a no-op: the field was stored and never read,
135
+ with zero call sites across `seqtree`, `vdjmatch` and `mhcmatch`.
136
+ - **`GapPrior` takes `(block_start, block_length, longer_length)`** instead of
137
+ `(block_start, shorter_length)`. `central_prior`'s output is bit-identical
138
+ (`|2i − (m−d)| == |2i + d − m|`), and a frozen table pins that.
139
+ - **`gap_extend` is now honoured.** `Index.align` is a real Gotoh affine alignment; previously
140
+ `gap_extend=1` and `gap_extend=99` produced identical scores and ops.
141
+
142
+ ### Fixed
143
+
144
+ - `pairwise_batch` silently inverted `n_ins`/`n_dels` when transposing, but only when
145
+ `len(a) >= len(b)` — a size-dependent inversion.
146
+ - `Index.align` did not validate the query alphabet (unlike `search_into`), and accepted negative
147
+ gap costs.
148
+ - `Limits::max_hits` was set and never read; in `mode="all"` it truncated an **unsorted** list.
149
+ - Stale docstring on `SubstitutionMatrix.from_similarity` (claimed `max(s_aa, s_bb) − s_ab`; the
150
+ code is the Gram form `s_aa + s_bb − 2·s_ab`).
151
+
152
+ ### Added
153
+
154
+ - **`SubstitutionMatrix.scale()`** — the median mismatch penalty (BLOSUM62 → 14). Gap costs must
155
+ live on the matrix's scale; the old `gap_open=1` default made gaps ~14× cheaper than
156
+ substitutions, so `align()` would gap an equal-length pair rather than substitute. Use
157
+ `gap_open = 2 * matrix.scale()`.
158
+ - **`seqtree.gapblock`** — single-contiguous-gap-block alignment for anchored junction loops.
159
+ `gapblock_score` is the exact `O(min(m,n))` optimum (0 mismatches in 55,727 pairs against
160
+ brute-force layout enumeration). `GapBlockIndex` reuses the existing Hamming engine over
161
+ deletion variants; no new C++.
162
+ - **Gap priors** — `central_prior(lam)`, `profile_prior(lam, w)`, `frame_prior(lam, c)` and
163
+ `embed_in_frame(seq, width, c)`. A sequence score alone cannot place the block: a hard central
164
+ pin agrees with the flat, score-only choice on only 10.6% of pairs.
165
+ - **`gapblock.score_matrix` and `ScoreMatrix`** — the dense `n × K` counterpart of
166
+ `GapBlockIndex.search`, for prototype-distance embeddings, where nothing can be pruned because
167
+ the distance to every reference *is* the output. C++, GIL released, one thread per core. On an
168
+ M3 against 3,000 prototypes: **51.3 M pairs/s** single-threaded, **532.7 M** on 16 cores, versus
169
+ 0.41 M for pure-Python `gapblock_score` — while evaluating all `L+1` block positions, not a
170
+ fixed shortlist. The prior is flattened once into an `[m][d][i]` cube, so the kernel never
171
+ re-enters Python. `ScoreMatrix` carries the CPython buffer protocol: `numpy.asarray` wraps it
172
+ without copying, and seqtree keeps its zero runtime dependencies.
173
+ - **`gapblock.positions_prior(starts)`** — restrict the block to a fixed set of starts, negative
174
+ values counting from the end, reproducing the `gap_positions=(3, 4, -4, -3)` convention that
175
+ other junction aligners hardcode. Shipped for interoperability, not as a recommendation: at a
176
+ matched false-positive rate on human TRB, candidate starts reach precision 0.156 against 0.414
177
+ for a single hard-pinned centre.
178
+ - **`gapblock.IslandProfile`** — a per-island position weight matrix whose column penalty is
179
+ measured against the column's own consensus, `pen(j, a) = round(lam·log(p_max_j / p_j(a)))`. A
180
+ textbook log-odds score is signed and therefore not a ball; this one is `>= 0`, zero on the
181
+ consensus, and flows through `thetas_from_scores` unchanged. The frame column defaults to the
182
+ entropy-optimal one, which is modal at `c = 6` on real islands — where crystal structures put
183
+ the block.
184
+
185
+ Whether it beats scoring against every member **depends entirely on the cutoff**, which moves
186
+ with `N`: the E-value's `k = floor(e_target·M/N)` is how many control neighbours the cutoff may
187
+ admit, so the FPR is `k/M`. Over 108 calibrated VDJdb islands of ≥10 members (human TRB, three
188
+ held-out splits, paired bootstrap over islands, 250k control negatives):
189
+
190
+ | regime | FPR | min-over-members | `IslandProfile` | difference [95% CI] |
191
+ |---|---|---|---|---|
192
+ | loose reference | 1% | **99.5%** | 99.1% | −0.40 [−1.09, +0.14] |
193
+ | per-epitope islands (`N`= group, median 88) | 0.0568% | 88.3% | **89.3%** | +0.93 [−0.80, +2.79] |
194
+ | repertoire annotation (`N`≈20k) | 0.0012% | 37.6% | **48.5%** | +10.90 [+7.69, +14.21] |
195
+
196
+ So: **no significant difference while building the islands**, a large one when using them to
197
+ annotate a repertoire (on islands ≥50 members, 9.8% vs 22.6%). At `N≈20k` and `e_target=0.05`,
198
+ `k=0` and `thetas_from_scores` returns `-1` — the rule of three certifies no `E` below
199
+ `3N/M = 0.236`, and that is the cutoff the third row uses.
200
+
201
+ It does **not** generalise: same-epitope junctions in a different island are recovered by
202
+ neither representation. Nor is it a compression — 1,176 B against 182 B of member strings,
203
+ break-even at 84 members.
204
+ - **`threshold_for_evalue` / `thetas_from_scores`** — invert `Ê = (N/M)·n_C` into the score cutoff
205
+ that achieves a target E, **per query**. Exact rather than a root-find, because scores are
206
+ integers. Returns `-1` where `e_target < 3N/M`, i.e. where the control is too small to certify
207
+ the bar.
208
+ - **`seqtree.seeds`** — `core_kmers` and `SeedIndex` give control-calibrated E-values for shared
209
+ core k-mers. A shared rare central k-mer is ~4× enriched among co-specific pairs, but covers only
210
+ ~0.5% of them: seeds buy precision, not recall.
211
+ - **`bench/bench_gapblock.py`** — the gap-freedom ladder, from a hard central pin through priors to
212
+ unrestricted affine.
213
+
214
+ ### Measured
215
+
216
+ Numbers that constrain the API, all reproducible from `bench/` and the downstream repos:
217
+
218
+ - **One gap block is enough.** Against a model-independent structural oracle (iterative
219
+ superposition + unrestricted affine DP) over 3,049 crystal junction pairs from 199 unique
220
+ sequences, the true correspondence is a single contiguous block in **95.2–100%** of cases for
221
+ every `d = 1..4`. Forcing one block costs no median CA-RMSD.
222
+ - **The restriction is free where it applies, and protective where it does not.** At
223
+ `gap_open = 2*scale`, gap-block equals unrestricted affine on **98.8%** of related pairs (one
224
+ indel + 0–2 substitutions). On *unrelated* pairs affine undercuts it by a median of **106**
225
+ penalty units — affine inventing an alignment that does not exist. Extra gap freedom buys
226
+ manufactured similarity.
227
+ - **A fixed score cutoff is not a calibrated cutoff.** Building islands on human TRB by union-find
228
+ at `gapblock_score ≤ 60`, **31.7%** of size-matched *random control* junctions land in a component
229
+ of ≥5 — structure invented by the threshold. Per-query E-value edges at `E* = 0.05` cut that to
230
+ **0.000** while raising the real signal: 2.334 edges per node against 0.021 for the control, which
231
+ forms 19,248 singletons, 223 pairs, three components of size 3–4, and nothing larger. The control
232
+ arm's realised edge rate lands on `E*`, which is the check that the calibration is honest.
233
+ - **Mouse replicates it.** Against the mouse TRB control (694,241 productive clonotypes), 5 epitopes
234
+ and 1,692 TCRs give 5.856 calibrated edges per node against 0.019 for the control, which again
235
+ forms nothing larger than a pair. At a fixed θ=40 the control still lands 18.3% of its nodes in
236
+ components of ≥5.
237
+ - **Constraining the block is what buys precision.** Compared at a *matched* false-positive rate —
238
+ each rung given the cutoff at which its own ball admits `E*` chance neighbours, since a freer rung
239
+ finds lower scores and a fixed budget would reward it for that — retrieval precision on the
240
+ length-different fraction of VDJdb human TRB same-epitope pairs (2,000 queries, `E* = 0.1`):
241
+
242
+ | rung | layouts | precision |
243
+ |---|---|---|
244
+ | fixed centre | 1 | **0.414** |
245
+ | central prior λ=21 | ~1–2 effective | 0.336 |
246
+ | flat (score alone) | L+1 | 0.176 |
247
+ | candidates (after 3–4, before last 3–4, centre) | 5 | 0.156 |
248
+
249
+ Trying several plausible positions and keeping the best score is worse than not trying: the score
250
+ picks the structurally correct layout about a tenth of the time, so each extra candidate is mostly
251
+ an opportunity to be wrong. Mouse replicates the ordering at `E* = 1.0` but its length-different
252
+ stratum holds only 24–79 true positives, too few to separate the rungs.
253
+ - **Performance.** 91% of `GapBlockIndex.search` time is the query-deletion-variant branch, 9% the
254
+ 9.8M-entry auxiliary indices. Netting the prior out of each variant's budget cuts that branch
255
+ from ~15 sub-searches to 2.5. Variant dedup (7–10% of variants before pruning, fewer after) and
256
+ length-bucketing are **not** built. At budget 40 over 250k references, `d_max=2` gap-block search
257
+ costs 2,562 µs/query — less than the plain Hamming ball at the same budget (3,051 µs/query).
258
+
259
+ ### Docs
260
+
261
+ - `skills/seqtree/SKILL.md` — public API surface, invariants, and the gotchas that have bitten.
262
+ - `docs/gapblock.rst` — a worked guide: why one gap block, how to choose its position, why a
263
+ placement rule is a column frame, and why a fixed score cutoff is not a calibrated one.
264
+ - README corrected: `seqtrie` is a full-width DP that ignores per-type caps, not a banded one, and
265
+ `auto` does not choose between engines.
266
+ - `appendix/evalue.tex` gains §"The score model: one gap block, placed by a prior" (the appendix
267
+ derived a theory of balls without ever saying what the score was) and a remark inverting the
268
+ E-value into a per-query cutoff. The pMHC section is compacted from ~110 lines of prose to ~50,
269
+ deferring to the `mhcmatch` appendix, which specialises this one rather than repeating it. Its
270
+ empirical tables stay: they are this repo's own `bench/bench_mhc_guess.py` output.
271
+ - Test coverage: `gapblock.py`, `evalue.py` and `seeds.py` at **100%**; package total 88%. A new
272
+ `tests/python/test_doc_coverage.py` fails the build if a public symbol is undocumented, missing
273
+ from `__all__`, or unreachable from any docs page.
274
+
275
+ ## [0.2.0]
276
+
277
+ - `structural` substitution matrix: Miyazawa–Jernigan interaction-strength similarity.
278
+ - Built-in matrix list: `identity`, `BLOSUM62`, `PAM250`, `PAM100`, `structural` (dropped PAM50).
279
+
280
+ ## [0.1.0]
281
+
282
+ - `SubstitutionMatrix.penalty(a, b)` exposed to Python.
283
+
284
+ ## [0.0.3]
285
+
286
+ - Reproducible table→plot benchmark pipeline with oracle + perf regression.
287
+ - pMHC non-binder E-value filter; class-II promiscuity notes.
@@ -27,6 +27,8 @@ add_library(seqtree_core STATIC
27
27
  src/engine_seqtm.cpp
28
28
  src/engine_seqtrie.cpp
29
29
  src/searcher.cpp
30
+ src/gapblock.cpp
31
+ src/pairwise.cpp
30
32
  )
31
33
  target_include_directories(seqtree_core PUBLIC include PRIVATE src)
32
34
  target_link_libraries(seqtree_core PUBLIC Threads::Threads)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: seqtree
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Fast fuzzy search over biological sequences (C++ core, Python bindings)
5
5
  Keywords: sequence-search,fuzzy-matching,CDR3,immunology,bioinformatics,trie
6
6
  Author-Email: ISALGO laboratory <mikhail.shugay@gmail.com>
@@ -17,9 +17,10 @@ Project-URL: Documentation, https://antigenomics.github.io/seqtree/
17
17
  Requires-Python: >=3.10
18
18
  Provides-Extra: test
19
19
  Requires-Dist: pytest>=7.4; extra == "test"
20
+ Requires-Dist: pytest-cov>=4.1; extra == "test"
21
+ Requires-Dist: biopython>=1.81; extra == "test"
20
22
  Provides-Extra: bench
21
23
  Requires-Dist: huggingface_hub; extra == "bench"
22
- Requires-Dist: pandas; extra == "bench"
23
24
  Requires-Dist: psutil; extra == "bench"
24
25
  Provides-Extra: docs
25
26
  Requires-Dist: sphinx; extra == "docs"
@@ -48,17 +49,18 @@ Two search engines over one trie:
48
49
  - **`seqtm`** — branch-and-bound enumeration. Exact per-type edit caps
49
50
  (`max_subs` / `max_ins` / `max_dels`) and a fast Hamming-only path. Best for
50
51
  small edit distances (UMI collapse, error correction, CDR3/epitope matching).
51
- - **`seqtrie`** — banded edit-distance DP. Matrix-weighted score budgets
52
- (BLOSUM62 + gap costs), cost independent of edit count. Best for
53
- similarity-scored searches.
52
+ - **`seqtrie`** — full-width edit-distance DP carried down the trie. Honours the
53
+ `max_penalty` score budget only; it **ignores the per-type edit caps**. Use it
54
+ when the budget is the whole specification.
54
55
 
55
- `engine="auto"` picks one per query. Results are payload-agnostic:
56
+ `engine="auto"` always picks `seqtm`, because it is the only engine that enforces
57
+ the caps you asked for. Results are payload-agnostic:
56
58
  `(ref_id, score, n_subs, n_ins, n_dels)`. Downstream libraries map `ref_id` back
57
59
  to their own payloads (V gene, MHC, counts) and filter.
58
60
 
59
61
  Beyond search, seqtree ships:
60
62
 
61
- - **Substitution matrices** — built-in `identity`, `BLOSUM62`, `PAM250`, `PAM100`, and `structural`
63
+ - **Substitution matrices** — built-in `identity`, `BLOSUM45`, `BLOSUM62`, `BLOSUM80`, `PAM250`, `PAM100`, and `structural`
62
64
  — a **Miyazawa–Jernigan interaction-strength** matrix: each residue's strength `q(a)=mean_b e(a,b)`
63
65
  is read off the MJ contact potential, so substitutions between residues of like interaction strength
64
66
  are cheap. It separates strong (hydrophobic `F W C L Y M I V`) from weak (polar/charged
@@ -69,6 +71,30 @@ Beyond search, seqtree ships:
69
71
  - **E-values / significance** — calibrate hit counts against a background control repertoire
70
72
  (`load_control` + `evalues`), the TCRNET approach on a finite-sample footing. See the
71
73
  [E-value guide](https://antigenomics.github.io/seqtree/evalue.html).
74
+ - **Calibrated cutoffs** — `threshold_for_evalue` inverts the E-value into the score cutoff that
75
+ achieves it, **per query**. A fixed cutoff is not a calibrated one: a control repertoire is
76
+ dense near germline and sparse among rare junctions, so the same threshold buys a common query
77
+ far more chance neighbours than a rare one.
78
+ - **Gap-block alignment** — `gapblock` restricts alignment to one contiguous indel, which is the
79
+ right model for a V(D)J junction and, measured against unrestricted affine alignment, is
80
+ exactly optimal on **98.8%** of genuinely related pairs at a calibrated `gap_open`. A gap
81
+ prior (`central_prior`, `profile_prior`, `frame_prior`) chooses where the block goes — a
82
+ sequence score alone cannot. `score_matrix` scores a whole query set against a whole reference
83
+ set in one GIL-released C++ call (**532 M pairs/s** on 16 cores; `numpy.asarray` wraps the
84
+ result with no copy), the shape a prototype-distance embedding needs.
85
+ - **Pairwise alignment without BioPython** — `seqtree.pairwise` is Needleman–Wunsch
86
+ (`mode="global"`) and Smith–Waterman (`mode="local"`) with affine or linear gaps, on the raw
87
+ log-odds scale. It is a **drop-in for `Bio.Align.PairwiseAligner`** — verified against it as an
88
+ oracle across three matrices, ten gap/mode settings and sixty sequence shapes with **zero
89
+ disagreements** — and **65–87× faster**, since there is no Python in the per-pair loop.
90
+ `dist_matrix` gives `d = s(a,a) + s(b,b) − 2·s(a,b)` directly. BioPython is a *test-only*
91
+ dependency; seqtree still needs nothing at runtime.
92
+ - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
93
+ frame-aligned junctions (an *island*) and scores a query column by column against the island
94
+ consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
95
+ repertoire-scale cutoff it recovers **48.5%** of held-out members against **37.6%** for
96
+ min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
97
+ where the cutoff is strict.
72
98
 
73
99
  ## Install
74
100
 
@@ -85,7 +111,7 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
85
111
  ```fish
86
112
  bash setup.sh # repo-local .venv + editable install
87
113
  bash setup.sh --tests # + pytest
88
- bash setup.sh --bench # + benchmark deps (huggingface_hub, pandas, psutil)
114
+ bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
89
115
  ```
90
116
 
91
117
  ## Quickstart
@@ -119,6 +145,42 @@ target = seqtree.Index.build(vdjdb_cdr3s, alphabet="aa")
119
145
  for q, r in zip(queries, seqtree.evalues(target, control, queries, p)):
120
146
  if r["p_enrichment"] < 1e-3:
121
147
  print(q, r["E"], r["n_target"], r["n_control"])
148
+
149
+ # ...and the cutoff that achieves a target E, per query (-1 = unreachable at this control size)
150
+ ceiling = seqtree.SearchParams(max_subs=14, max_penalty=50, matrix="BLOSUM62", engine="seqtm")
151
+ thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_target=0.05)
152
+
153
+ # one contiguous gap block, placed by a prior rather than by the score alone
154
+ from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
155
+
156
+ gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
157
+ mat = seqtree.SubstitutionMatrix.blosum62()
158
+ for ref_id, score, block_len, block_pos in gbi.search(
159
+ "CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
160
+ gap_prior=central_prior(int(1.5 * mat.scale()))):
161
+ ...
162
+
163
+ # a fixed frame column makes gap placement transitive -- and a column index, hence a PWM, possible
164
+ embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
165
+
166
+ # a whole query set vs a whole reference set, in one GIL-released C++ call
167
+ from seqtree.gapblock import score_matrix, IslandProfile
168
+ sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
169
+ import numpy as np
170
+ distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
171
+
172
+ # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
173
+ profile = IslandProfile.fit(island_members)
174
+ profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
175
+
176
+ # ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
177
+ from seqtree.pairwise import align, score, dist_matrix
178
+ score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
179
+ score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
180
+ score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
181
+ aln = align("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # + aligned strings and ops
182
+
183
+ d = np.asarray(dist_matrix(v_genes, v_genes, mat, threads=0)) # s(a,a)+s(b,b)-2s(a,b), zero diagonal
122
184
  ```
123
185
 
124
186
  ## Tests
@@ -138,6 +200,8 @@ python bench/bench.py # recall vs ground truth (real VDJdb data)
138
200
  python bench/bench_evalue.py # true E-value benchmark (target vs background control)
139
201
  python bench/bench_evalue_matrix.py # significance across reference/control/query/scope grid
140
202
  python bench/bench_epitope.py # epitope detection-complexity (GIL vs NLV)
203
+ python bench/bench_gapblock.py # the gap-freedom ladder: fixed centre → prior → flat → affine
204
+ python bench/bench_score_matrix.py # dense batch gap-block throughput (µs/pair, M pairs/s, RSS)
141
205
  ```
142
206
 
143
207
  Figures (throughput, scaling, matrix-scoring overhead, collisions, E-value matrix, epitope
@@ -15,17 +15,18 @@ Two search engines over one trie:
15
15
  - **`seqtm`** — branch-and-bound enumeration. Exact per-type edit caps
16
16
  (`max_subs` / `max_ins` / `max_dels`) and a fast Hamming-only path. Best for
17
17
  small edit distances (UMI collapse, error correction, CDR3/epitope matching).
18
- - **`seqtrie`** — banded edit-distance DP. Matrix-weighted score budgets
19
- (BLOSUM62 + gap costs), cost independent of edit count. Best for
20
- similarity-scored searches.
18
+ - **`seqtrie`** — full-width edit-distance DP carried down the trie. Honours the
19
+ `max_penalty` score budget only; it **ignores the per-type edit caps**. Use it
20
+ when the budget is the whole specification.
21
21
 
22
- `engine="auto"` picks one per query. Results are payload-agnostic:
22
+ `engine="auto"` always picks `seqtm`, because it is the only engine that enforces
23
+ the caps you asked for. Results are payload-agnostic:
23
24
  `(ref_id, score, n_subs, n_ins, n_dels)`. Downstream libraries map `ref_id` back
24
25
  to their own payloads (V gene, MHC, counts) and filter.
25
26
 
26
27
  Beyond search, seqtree ships:
27
28
 
28
- - **Substitution matrices** — built-in `identity`, `BLOSUM62`, `PAM250`, `PAM100`, and `structural`
29
+ - **Substitution matrices** — built-in `identity`, `BLOSUM45`, `BLOSUM62`, `BLOSUM80`, `PAM250`, `PAM100`, and `structural`
29
30
  — a **Miyazawa–Jernigan interaction-strength** matrix: each residue's strength `q(a)=mean_b e(a,b)`
30
31
  is read off the MJ contact potential, so substitutions between residues of like interaction strength
31
32
  are cheap. It separates strong (hydrophobic `F W C L Y M I V`) from weak (polar/charged
@@ -36,6 +37,30 @@ Beyond search, seqtree ships:
36
37
  - **E-values / significance** — calibrate hit counts against a background control repertoire
37
38
  (`load_control` + `evalues`), the TCRNET approach on a finite-sample footing. See the
38
39
  [E-value guide](https://antigenomics.github.io/seqtree/evalue.html).
40
+ - **Calibrated cutoffs** — `threshold_for_evalue` inverts the E-value into the score cutoff that
41
+ achieves it, **per query**. A fixed cutoff is not a calibrated one: a control repertoire is
42
+ dense near germline and sparse among rare junctions, so the same threshold buys a common query
43
+ far more chance neighbours than a rare one.
44
+ - **Gap-block alignment** — `gapblock` restricts alignment to one contiguous indel, which is the
45
+ right model for a V(D)J junction and, measured against unrestricted affine alignment, is
46
+ exactly optimal on **98.8%** of genuinely related pairs at a calibrated `gap_open`. A gap
47
+ prior (`central_prior`, `profile_prior`, `frame_prior`) chooses where the block goes — a
48
+ sequence score alone cannot. `score_matrix` scores a whole query set against a whole reference
49
+ set in one GIL-released C++ call (**532 M pairs/s** on 16 cores; `numpy.asarray` wraps the
50
+ result with no copy), the shape a prototype-distance embedding needs.
51
+ - **Pairwise alignment without BioPython** — `seqtree.pairwise` is Needleman–Wunsch
52
+ (`mode="global"`) and Smith–Waterman (`mode="local"`) with affine or linear gaps, on the raw
53
+ log-odds scale. It is a **drop-in for `Bio.Align.PairwiseAligner`** — verified against it as an
54
+ oracle across three matrices, ten gap/mode settings and sixty sequence shapes with **zero
55
+ disagreements** — and **65–87× faster**, since there is no Python in the per-pair loop.
56
+ `dist_matrix` gives `d = s(a,a) + s(b,b) − 2·s(a,b)` directly. BioPython is a *test-only*
57
+ dependency; seqtree still needs nothing at runtime.
58
+ - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
59
+ frame-aligned junctions (an *island*) and scores a query column by column against the island
60
+ consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
61
+ repertoire-scale cutoff it recovers **48.5%** of held-out members against **37.6%** for
62
+ min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
63
+ where the cutoff is strict.
39
64
 
40
65
  ## Install
41
66
 
@@ -52,7 +77,7 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
52
77
  ```fish
53
78
  bash setup.sh # repo-local .venv + editable install
54
79
  bash setup.sh --tests # + pytest
55
- bash setup.sh --bench # + benchmark deps (huggingface_hub, pandas, psutil)
80
+ bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
56
81
  ```
57
82
 
58
83
  ## Quickstart
@@ -86,6 +111,42 @@ target = seqtree.Index.build(vdjdb_cdr3s, alphabet="aa")
86
111
  for q, r in zip(queries, seqtree.evalues(target, control, queries, p)):
87
112
  if r["p_enrichment"] < 1e-3:
88
113
  print(q, r["E"], r["n_target"], r["n_control"])
114
+
115
+ # ...and the cutoff that achieves a target E, per query (-1 = unreachable at this control size)
116
+ ceiling = seqtree.SearchParams(max_subs=14, max_penalty=50, matrix="BLOSUM62", engine="seqtm")
117
+ thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_target=0.05)
118
+
119
+ # one contiguous gap block, placed by a prior rather than by the score alone
120
+ from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
121
+
122
+ gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
123
+ mat = seqtree.SubstitutionMatrix.blosum62()
124
+ for ref_id, score, block_len, block_pos in gbi.search(
125
+ "CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
126
+ gap_prior=central_prior(int(1.5 * mat.scale()))):
127
+ ...
128
+
129
+ # a fixed frame column makes gap placement transitive -- and a column index, hence a PWM, possible
130
+ embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
131
+
132
+ # a whole query set vs a whole reference set, in one GIL-released C++ call
133
+ from seqtree.gapblock import score_matrix, IslandProfile
134
+ sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
135
+ import numpy as np
136
+ distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
137
+
138
+ # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
139
+ profile = IslandProfile.fit(island_members)
140
+ profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
141
+
142
+ # ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
143
+ from seqtree.pairwise import align, score, dist_matrix
144
+ score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
145
+ score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
146
+ score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
147
+ aln = align("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # + aligned strings and ops
148
+
149
+ d = np.asarray(dist_matrix(v_genes, v_genes, mat, threads=0)) # s(a,a)+s(b,b)-2s(a,b), zero diagonal
89
150
  ```
90
151
 
91
152
  ## Tests
@@ -105,6 +166,8 @@ python bench/bench.py # recall vs ground truth (real VDJdb data)
105
166
  python bench/bench_evalue.py # true E-value benchmark (target vs background control)
106
167
  python bench/bench_evalue_matrix.py # significance across reference/control/query/scope grid
107
168
  python bench/bench_epitope.py # epitope detection-complexity (GIL vs NLV)
169
+ python bench/bench_gapblock.py # the gap-freedom ladder: fixed centre → prior → flat → affine
170
+ python bench/bench_score_matrix.py # dense batch gap-block throughput (µs/pair, M pairs/s, RSS)
108
171
  ```
109
172
 
110
173
  Figures (throughput, scaling, matrix-scoring overhead, collisions, E-value matrix, epitope
@@ -24,12 +24,18 @@ between them, written so an agent developing either package can pick it up cold:
24
24
 
25
25
  | Capability | Module / symbol | Notes |
26
26
  |---|---|---|
27
- | Fuzzy fixed-length search | `seqtree.Index`, engines `seqtm` / `seqtrie` | per-type edit caps + Hamming fast path; or banded DP |
28
- | Local / best-window mode | `Mode::Local` (C++) | class-II register, general local match |
27
+ | Fuzzy fixed-length search | `seqtree.Index`, engines `seqtm` / `seqtrie` | seqtm: per-type edit caps + Hamming fast path. seqtrie: budget-only full-width DP over the trie (not banded), ignores per-type caps |
28
+ | Single-gap-block loop alignment | `seqtree.gapblock` (Python) | anchored CDR3/junction alignment: one contiguous indel + positional gap prior |
29
+ | Dense batch gap-block scoring | `seqtree.gapblock.score_matrix` → `ScoreMatrix` | every query × every reference in one GIL-released C++ call (~532 M pairs/s on 16 cores); `numpy.asarray` is zero-copy. The shape a prototype-distance embedding needs, where nothing can be pruned |
30
+ | Per-island profile (PWM) | `seqtree.gapblock.IslandProfile` | position weight matrix over a frame-aligned island; `pen(j,a) = round(lam·log(p_max_j/p_j(a)))` is `≥ 0` and `0` on the consensus, so it stays a ball and flows through `thetas_from_scores` unchanged. Beats min-over-members only at a strict cutoff |
29
31
  | Substitution scoring | `SubstitutionMatrix` (Gram → squared-distance penalty `s_aa+s_bb−2·s_ab`) | payload-agnostic |
30
32
  | Per-position scoring | `PositionalMatrix` (`penalty(pos,a,b)` = base × per-position weight; weight 0 = free/anchor) | the hook for PSSMs and anchor masking |
31
33
  | k-mer seed index | `KmerIndex` (C++): unique-k-mer trie + CSR postings + per-peptide allele tag; `seed_and_gather` (GIL-released, parallel) | million-scale candidate generation |
32
34
  | Control-calibrated E-values | `seqtree.evalues`, `seqtree.load_control` | `Ê = (N/M)·n_C`, Poisson tail, `exclude_exact` |
35
+ | Calibrated score cutoffs | `seqtree.threshold_for_evalue`, `thetas_from_scores` | inverts `Ê` to a **per-query** θ. A fixed θ is not calibrated: at `gapblock_score ≤ 60`, 31.7% of *random control* junctions land in a component of ≥5 — structure invented by the threshold, which per-query cutoffs remove entirely |
36
+ | Gap-placement priors | `central_prior`, `profile_prior`, `frame_prior`, `positions_prior`, `embed_in_frame` | `prior(i, d, m) ≥ 0` and `= 0` at `d = 0`. Only a **constant-`i`** rule (`frame_prior`) makes a frame transitive — a column index, hence a PWM, which `IslandProfile` now ships |
37
+ | Seed significance | `seqtree.seeds`: `core_kmers`, `SeedIndex` | precision, not recall: ~0.5% cross-island coverage |
38
+ | Background control | `seqtree.load_control`, `sanitize` | uniform reservoir sample over unique **productive** clonotypes. *Planned:* an out-of-frame control as an empirical `P_gen` sample for the `π̂_gen` fallback — needs the `*.ntvj` tables, since the `.aa` table's `_` has already collapsed a run of positions |
33
39
  | Anchor / layout model | `seqtree.layout`: `AnchorSpec`, `DEFAULTS`, `mask_anchors`, `kmers`, `presentation_features`, `weight_profile` | parametrized anchors; class-II register trick |
34
40
  | pMHC homology + reverse | `seqtree.pmhc`: `PMHCStore`, `search_homologs`, `assign_allele`, `find_mimics`, `build_kmer_index` | reference impl; mhcmatch productionizes |
35
41
  | Presentation-aware E-values | `seqtree.pmhc_evalue.homolog_evalue` | per-allele null |
@@ -37,7 +43,7 @@ between them, written so an agent developing either package can pick it up cold:
37
43
  **E-value theory** lives in `appendix/evalue.tex`: the empirical-control null (§Setup, §Null), the
38
44
  Poisson/Chen–Stein bound (§Poisson), multiple testing (§E-value), the closest-hit Gumbel law
39
45
  (§Gumbel), Karlin–Altschul as the i.i.d. special case (§KA), the pMHC presentation-aware extension
40
- (§Epitopes, §Reverse problem), and elementary applications — UMI/barcode birthday-collision and
46
+ (§Epitopes), and elementary applications — UMI/barcode birthday-collision and
41
47
  CDR3-nt error clustering (§Related applications).
42
48
 
43
49
  **Datasets.** `isalgo/pmhc_data` ships two tiers (see `appendix` Table "Scope of the two pmhc_data
@@ -115,8 +121,8 @@ additions below.
115
121
  presentation-aware E-values via `pmhc_evalue.homolog_evalue`). Positive control: the Dolton et al.
116
122
  A\*02:01 cross-reactive trio.
117
123
  - Allele guessing (reverse problem): `assign_allele` + the vote-fraction ranking / register trick
118
- validated in `bench/bench_mhc_guess.py` (appendix §Reverse problem). ROC-AUC 0.90–0.98.
119
- - **Non-binder filter** (appendix §Reverse problem, "Filtering non-binders"): _binds no MHC_ →
124
+ validated in `bench/bench_mhc_guess.py` (appendix §Epitopes). ROC-AUC 0.90–0.98.
125
+ - **Non-binder filter** (appendix §Epitopes, "Non-binders and class-II promiscuity"): _binds no MHC_ →
120
126
  best-over-panel E-value high / no allele scores above background; _doesn't bind allele a_ →
121
127
  per-allele `E_a > α`. mhcmatch exposes both thresholds.
122
128
  - Tier choice (full vs shortlist, §1 Datasets).