seqtree 0.2.0__tar.gz → 0.3.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 (59) hide show
  1. {seqtree-0.2.0 → seqtree-0.3.0}/.gitignore +5 -0
  2. seqtree-0.3.0/CHANGELOG.md +190 -0
  3. {seqtree-0.2.0 → seqtree-0.3.0}/CMakeLists.txt +1 -0
  4. {seqtree-0.2.0 → seqtree-0.3.0}/PKG-INFO +54 -7
  5. {seqtree-0.2.0 → seqtree-0.3.0}/README.md +52 -5
  6. {seqtree-0.2.0 → seqtree-0.3.0}/ROADMAP.md +11 -5
  7. seqtree-0.3.0/SOURCES.md +97 -0
  8. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/evalue.pdf +0 -0
  9. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/evalue.tex +206 -85
  10. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/refs.bib +3 -1
  11. seqtree-0.3.0/examples/01_gapped_search.py +118 -0
  12. seqtree-0.3.0/examples/02_sequence_dendrogram.py +144 -0
  13. seqtree-0.3.0/examples/03_indel_positions.py +169 -0
  14. seqtree-0.3.0/examples/04_island_profile.py +151 -0
  15. {seqtree-0.2.0 → seqtree-0.3.0}/include/seqtree/seqtree.hpp +18 -0
  16. {seqtree-0.2.0 → seqtree-0.3.0}/include/seqtree/types.hpp +16 -7
  17. {seqtree-0.2.0 → seqtree-0.3.0}/pyproject.toml +3 -3
  18. seqtree-0.3.0/python/seqtree/__init__.py +69 -0
  19. seqtree-0.3.0/python/seqtree/control.py +185 -0
  20. seqtree-0.3.0/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  21. seqtree-0.3.0/python/seqtree/evalue.py +178 -0
  22. seqtree-0.3.0/python/seqtree/gapblock.py +673 -0
  23. {seqtree-0.2.0 → seqtree-0.3.0}/python/seqtree/layout.py +12 -0
  24. {seqtree-0.2.0 → seqtree-0.3.0}/python/seqtree/pmhc.py +2 -0
  25. seqtree-0.3.0/python/seqtree/seeds.py +149 -0
  26. seqtree-0.3.0/skills/seqtree/SKILL.md +218 -0
  27. {seqtree-0.2.0 → seqtree-0.3.0}/src/_bindings.cpp +83 -5
  28. {seqtree-0.2.0 → seqtree-0.3.0}/src/engine_seqtm.cpp +2 -4
  29. {seqtree-0.2.0 → seqtree-0.3.0}/src/engines.hpp +1 -1
  30. seqtree-0.3.0/src/gapblock.cpp +135 -0
  31. {seqtree-0.2.0 → seqtree-0.3.0}/src/index.cpp +45 -24
  32. {seqtree-0.2.0 → seqtree-0.3.0}/src/searcher.cpp +17 -7
  33. {seqtree-0.2.0 → seqtree-0.3.0}/src/substitution_matrix.cpp +15 -0
  34. seqtree-0.2.0/python/seqtree/__init__.py +0 -44
  35. seqtree-0.2.0/python/seqtree/control.py +0 -96
  36. seqtree-0.2.0/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  37. seqtree-0.2.0/python/seqtree/evalue.py +0 -79
  38. {seqtree-0.2.0 → seqtree-0.3.0}/.gitattributes +0 -0
  39. {seqtree-0.2.0 → seqtree-0.3.0}/LICENSE +0 -0
  40. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/.gitignore +0 -0
  41. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/.latexmkrc +0 -0
  42. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/Makefile +0 -0
  43. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/epitope_detection.pdf +0 -0
  44. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/evalue_matrix.pdf +0 -0
  45. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/mhc1_rocpr.pdf +0 -0
  46. {seqtree-0.2.0 → seqtree-0.3.0}/appendix/mhc2_rocpr.pdf +0 -0
  47. {seqtree-0.2.0 → seqtree-0.3.0}/include/seqtree/kmer_index.hpp +0 -0
  48. {seqtree-0.2.0 → seqtree-0.3.0}/python/seqtree/pmhc_evalue.py +0 -0
  49. {seqtree-0.2.0 → seqtree-0.3.0}/python/seqtree/py.typed +0 -0
  50. {seqtree-0.2.0 → seqtree-0.3.0}/src/blosum62.inc +0 -0
  51. {seqtree-0.2.0 → seqtree-0.3.0}/src/codec.cpp +0 -0
  52. {seqtree-0.2.0 → seqtree-0.3.0}/src/engine_seqtrie.cpp +0 -0
  53. {seqtree-0.2.0 → seqtree-0.3.0}/src/kmer_index.cpp +0 -0
  54. {seqtree-0.2.0 → seqtree-0.3.0}/src/pam100.inc +0 -0
  55. {seqtree-0.2.0 → seqtree-0.3.0}/src/pam250.inc +0 -0
  56. {seqtree-0.2.0 → seqtree-0.3.0}/src/positional_matrix.cpp +0 -0
  57. {seqtree-0.2.0 → seqtree-0.3.0}/src/structural.inc +0 -0
  58. {seqtree-0.2.0 → seqtree-0.3.0}/src/trie.cpp +0 -0
  59. {seqtree-0.2.0 → seqtree-0.3.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,190 @@
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.3.0] — 2026-07-10
7
+
8
+ Gap-block alignment, calibrated cutoffs, seed significance — the removal of several engine paths
9
+ that returned confident wrong answers, and a corrected background control that changes every
10
+ E-value.
11
+
12
+ ### Breaking
13
+
14
+ - **The bundled control is a different set of sequences.** It was the *abundance head* of the
15
+ upstream repertoire — the 250,000 most expanded clonotypes — because both `gen_control.py` and
16
+ `_download` took the first `size` unique rows of a count-descending table. `appendix/evalue.tex`
17
+ (`ass:indep`) assumes the control's unique clonotypes are i.i.d. from `P₀`. Measured against a
18
+ uniform sample of the same size, the head is **25.8× more self-similar** (P(Hamming≤2 | equal
19
+ length) 3.11×10⁻³ vs 1.20×10⁻⁴) and carries **3.1× the ball mass** at a BLOSUM62 budget of 40
20
+ (mean n_C 110.1 vs 35.5). Both are now uniform reservoir samples over unique **productive**
21
+ clonotypes, seeded and shuffled so any prefix is itself a valid sub-sample.
22
+
23
+ **Every E-value moves.** Delete `~/.cache/seqtree/control_*.sqtree` after upgrading. Numbers
24
+ derived from the control are corrected throughout this file, `seeds.py`, `SKILL.md` and the
25
+ appendix.
26
+ - **Controls are filtered to productive clonotypes.** VDJtools marks out-of-frame rearrangements
27
+ with `_` and in-frame stops with `*`; 13.7% of the mouse TRB table is out of frame. `_` cannot be
28
+ repaired at the amino-acid level — VDJtools collapses a *run* of untranslatable positions into one
29
+ character, so the residue count is already gone — and out-of-frame junctions escape thymic
30
+ selection, making them an estimator of `P_gen`, which `lem:hierarchy` says is not `P₀`.
31
+ `load_control("mouse_trb_aa")` previously raised on `_`; it now yields 694,241 clonotypes.
32
+ - **`engine="auto"` now always resolves to `seqtm`.** It previously routed matrix-plus-indel
33
+ searches to `seqtrie`, whose budget defaults to `INT_MAX/4`, so
34
+ `SearchParams(max_subs=1, max_ins=1, matrix="BLOSUM62")` silently returned **every reference in
35
+ the index**. `seqtrie` ignores per-type edit caps and can never honour them; `auto` will not pick
36
+ it. Passing a matrix to `seqtrie` without an explicit `max_penalty` now raises.
37
+ - **`Mode::Local` / `mode="local"` deleted.** It was a no-op: the field was stored and never read,
38
+ with zero call sites across `seqtree`, `vdjmatch` and `mhcmatch`.
39
+ - **`GapPrior` takes `(block_start, block_length, longer_length)`** instead of
40
+ `(block_start, shorter_length)`. `central_prior`'s output is bit-identical
41
+ (`|2i − (m−d)| == |2i + d − m|`), and a frozen table pins that.
42
+ - **`gap_extend` is now honoured.** `Index.align` is a real Gotoh affine alignment; previously
43
+ `gap_extend=1` and `gap_extend=99` produced identical scores and ops.
44
+
45
+ ### Fixed
46
+
47
+ - `pairwise_batch` silently inverted `n_ins`/`n_dels` when transposing, but only when
48
+ `len(a) >= len(b)` — a size-dependent inversion.
49
+ - `Index.align` did not validate the query alphabet (unlike `search_into`), and accepted negative
50
+ gap costs.
51
+ - `Limits::max_hits` was set and never read; in `mode="all"` it truncated an **unsorted** list.
52
+ - Stale docstring on `SubstitutionMatrix.from_similarity` (claimed `max(s_aa, s_bb) − s_ab`; the
53
+ code is the Gram form `s_aa + s_bb − 2·s_ab`).
54
+
55
+ ### Added
56
+
57
+ - **`SubstitutionMatrix.scale()`** — the median mismatch penalty (BLOSUM62 → 14). Gap costs must
58
+ live on the matrix's scale; the old `gap_open=1` default made gaps ~14× cheaper than
59
+ substitutions, so `align()` would gap an equal-length pair rather than substitute. Use
60
+ `gap_open = 2 * matrix.scale()`.
61
+ - **`seqtree.gapblock`** — single-contiguous-gap-block alignment for anchored junction loops.
62
+ `gapblock_score` is the exact `O(min(m,n))` optimum (0 mismatches in 55,727 pairs against
63
+ brute-force layout enumeration). `GapBlockIndex` reuses the existing Hamming engine over
64
+ deletion variants; no new C++.
65
+ - **Gap priors** — `central_prior(lam)`, `profile_prior(lam, w)`, `frame_prior(lam, c)` and
66
+ `embed_in_frame(seq, width, c)`. A sequence score alone cannot place the block: a hard central
67
+ pin agrees with the flat, score-only choice on only 10.6% of pairs.
68
+ - **`gapblock.score_matrix` and `ScoreMatrix`** — the dense `n × K` counterpart of
69
+ `GapBlockIndex.search`, for prototype-distance embeddings, where nothing can be pruned because
70
+ the distance to every reference *is* the output. C++, GIL released, one thread per core. On an
71
+ M3 against 3,000 prototypes: **51.3 M pairs/s** single-threaded, **532.7 M** on 16 cores, versus
72
+ 0.41 M for pure-Python `gapblock_score` — while evaluating all `L+1` block positions, not a
73
+ fixed shortlist. The prior is flattened once into an `[m][d][i]` cube, so the kernel never
74
+ re-enters Python. `ScoreMatrix` carries the CPython buffer protocol: `numpy.asarray` wraps it
75
+ without copying, and seqtree keeps its zero runtime dependencies.
76
+ - **`gapblock.positions_prior(starts)`** — restrict the block to a fixed set of starts, negative
77
+ values counting from the end, reproducing the `gap_positions=(3, 4, -4, -3)` convention that
78
+ other junction aligners hardcode. Shipped for interoperability, not as a recommendation: at a
79
+ matched false-positive rate on human TRB, candidate starts reach precision 0.156 against 0.414
80
+ for a single hard-pinned centre.
81
+ - **`gapblock.IslandProfile`** — a per-island position weight matrix whose column penalty is
82
+ measured against the column's own consensus, `pen(j, a) = round(lam·log(p_max_j / p_j(a)))`. A
83
+ textbook log-odds score is signed and therefore not a ball; this one is `>= 0`, zero on the
84
+ consensus, and flows through `thetas_from_scores` unchanged. The frame column defaults to the
85
+ entropy-optimal one, which is modal at `c = 6` on real islands — where crystal structures put
86
+ the block.
87
+
88
+ Whether it beats scoring against every member **depends entirely on the cutoff**, which moves
89
+ with `N`: the E-value's `k = floor(e_target·M/N)` is how many control neighbours the cutoff may
90
+ admit, so the FPR is `k/M`. Over 108 calibrated VDJdb islands of ≥10 members (human TRB, three
91
+ held-out splits, paired bootstrap over islands, 250k control negatives):
92
+
93
+ | regime | FPR | min-over-members | `IslandProfile` | difference [95% CI] |
94
+ |---|---|---|---|---|
95
+ | loose reference | 1% | **99.5%** | 99.1% | −0.40 [−1.09, +0.14] |
96
+ | per-epitope islands (`N`= group, median 88) | 0.0568% | 88.3% | **89.3%** | +0.93 [−0.80, +2.79] |
97
+ | repertoire annotation (`N`≈20k) | 0.0012% | 37.6% | **48.5%** | +10.90 [+7.69, +14.21] |
98
+
99
+ So: **no significant difference while building the islands**, a large one when using them to
100
+ annotate a repertoire (on islands ≥50 members, 9.8% vs 22.6%). At `N≈20k` and `e_target=0.05`,
101
+ `k=0` and `thetas_from_scores` returns `-1` — the rule of three certifies no `E` below
102
+ `3N/M = 0.236`, and that is the cutoff the third row uses.
103
+
104
+ It does **not** generalise: same-epitope junctions in a different island are recovered by
105
+ neither representation. Nor is it a compression — 1,176 B against 182 B of member strings,
106
+ break-even at 84 members.
107
+ - **`threshold_for_evalue` / `thetas_from_scores`** — invert `Ê = (N/M)·n_C` into the score cutoff
108
+ that achieves a target E, **per query**. Exact rather than a root-find, because scores are
109
+ integers. Returns `-1` where `e_target < 3N/M`, i.e. where the control is too small to certify
110
+ the bar.
111
+ - **`seqtree.seeds`** — `core_kmers` and `SeedIndex` give control-calibrated E-values for shared
112
+ core k-mers. A shared rare central k-mer is ~4× enriched among co-specific pairs, but covers only
113
+ ~0.5% of them: seeds buy precision, not recall.
114
+ - **`bench/bench_gapblock.py`** — the gap-freedom ladder, from a hard central pin through priors to
115
+ unrestricted affine.
116
+
117
+ ### Measured
118
+
119
+ Numbers that constrain the API, all reproducible from `bench/` and the downstream repos:
120
+
121
+ - **One gap block is enough.** Against a model-independent structural oracle (iterative
122
+ superposition + unrestricted affine DP) over 3,049 crystal junction pairs from 199 unique
123
+ sequences, the true correspondence is a single contiguous block in **95.2–100%** of cases for
124
+ every `d = 1..4`. Forcing one block costs no median CA-RMSD.
125
+ - **The restriction is free where it applies, and protective where it does not.** At
126
+ `gap_open = 2*scale`, gap-block equals unrestricted affine on **98.8%** of related pairs (one
127
+ indel + 0–2 substitutions). On *unrelated* pairs affine undercuts it by a median of **106**
128
+ penalty units — affine inventing an alignment that does not exist. Extra gap freedom buys
129
+ manufactured similarity.
130
+ - **A fixed score cutoff is not a calibrated cutoff.** Building islands on human TRB by union-find
131
+ at `gapblock_score ≤ 60`, **31.7%** of size-matched *random control* junctions land in a component
132
+ of ≥5 — structure invented by the threshold. Per-query E-value edges at `E* = 0.05` cut that to
133
+ **0.000** while raising the real signal: 2.334 edges per node against 0.021 for the control, which
134
+ forms 19,248 singletons, 223 pairs, three components of size 3–4, and nothing larger. The control
135
+ arm's realised edge rate lands on `E*`, which is the check that the calibration is honest.
136
+ - **Mouse replicates it.** Against the mouse TRB control (694,241 productive clonotypes), 5 epitopes
137
+ and 1,692 TCRs give 5.856 calibrated edges per node against 0.019 for the control, which again
138
+ forms nothing larger than a pair. At a fixed θ=40 the control still lands 18.3% of its nodes in
139
+ components of ≥5.
140
+ - **Constraining the block is what buys precision.** Compared at a *matched* false-positive rate —
141
+ each rung given the cutoff at which its own ball admits `E*` chance neighbours, since a freer rung
142
+ finds lower scores and a fixed budget would reward it for that — retrieval precision on the
143
+ length-different fraction of VDJdb human TRB same-epitope pairs (2,000 queries, `E* = 0.1`):
144
+
145
+ | rung | layouts | precision |
146
+ |---|---|---|
147
+ | fixed centre | 1 | **0.414** |
148
+ | central prior λ=21 | ~1–2 effective | 0.336 |
149
+ | flat (score alone) | L+1 | 0.176 |
150
+ | candidates (after 3–4, before last 3–4, centre) | 5 | 0.156 |
151
+
152
+ Trying several plausible positions and keeping the best score is worse than not trying: the score
153
+ picks the structurally correct layout about a tenth of the time, so each extra candidate is mostly
154
+ an opportunity to be wrong. Mouse replicates the ordering at `E* = 1.0` but its length-different
155
+ stratum holds only 24–79 true positives, too few to separate the rungs.
156
+ - **Performance.** 91% of `GapBlockIndex.search` time is the query-deletion-variant branch, 9% the
157
+ 9.8M-entry auxiliary indices. Netting the prior out of each variant's budget cuts that branch
158
+ from ~15 sub-searches to 2.5. Variant dedup (7–10% of variants before pruning, fewer after) and
159
+ length-bucketing are **not** built. At budget 40 over 250k references, `d_max=2` gap-block search
160
+ costs 2,562 µs/query — less than the plain Hamming ball at the same budget (3,051 µs/query).
161
+
162
+ ### Docs
163
+
164
+ - `skills/seqtree/SKILL.md` — public API surface, invariants, and the gotchas that have bitten.
165
+ - `docs/gapblock.rst` — a worked guide: why one gap block, how to choose its position, why a
166
+ placement rule is a column frame, and why a fixed score cutoff is not a calibrated one.
167
+ - README corrected: `seqtrie` is a full-width DP that ignores per-type caps, not a banded one, and
168
+ `auto` does not choose between engines.
169
+ - `appendix/evalue.tex` gains §"The score model: one gap block, placed by a prior" (the appendix
170
+ derived a theory of balls without ever saying what the score was) and a remark inverting the
171
+ E-value into a per-query cutoff. The pMHC section is compacted from ~110 lines of prose to ~50,
172
+ deferring to the `mhcmatch` appendix, which specialises this one rather than repeating it. Its
173
+ empirical tables stay: they are this repo's own `bench/bench_mhc_guess.py` output.
174
+ - Test coverage: `gapblock.py`, `evalue.py` and `seeds.py` at **100%**; package total 88%. A new
175
+ `tests/python/test_doc_coverage.py` fails the build if a public symbol is undocumented, missing
176
+ from `__all__`, or unreachable from any docs page.
177
+
178
+ ## [0.2.0]
179
+
180
+ - `structural` substitution matrix: Miyazawa–Jernigan interaction-strength similarity.
181
+ - Built-in matrix list: `identity`, `BLOSUM62`, `PAM250`, `PAM100`, `structural` (dropped PAM50).
182
+
183
+ ## [0.1.0]
184
+
185
+ - `SubstitutionMatrix.penalty(a, b)` exposed to Python.
186
+
187
+ ## [0.0.3]
188
+
189
+ - Reproducible table→plot benchmark pipeline with oracle + perf regression.
190
+ - pMHC non-binder E-value filter; class-II promiscuity notes.
@@ -27,6 +27,7 @@ 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
30
31
  )
31
32
  target_include_directories(seqtree_core PUBLIC include PRIVATE src)
32
33
  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.3.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,9 @@ 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"
20
21
  Provides-Extra: bench
21
22
  Requires-Dist: huggingface_hub; extra == "bench"
22
- Requires-Dist: pandas; extra == "bench"
23
23
  Requires-Dist: psutil; extra == "bench"
24
24
  Provides-Extra: docs
25
25
  Requires-Dist: sphinx; extra == "docs"
@@ -48,11 +48,12 @@ Two search engines over one trie:
48
48
  - **`seqtm`** — branch-and-bound enumeration. Exact per-type edit caps
49
49
  (`max_subs` / `max_ins` / `max_dels`) and a fast Hamming-only path. Best for
50
50
  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.
51
+ - **`seqtrie`** — full-width edit-distance DP carried down the trie. Honours the
52
+ `max_penalty` score budget only; it **ignores the per-type edit caps**. Use it
53
+ when the budget is the whole specification.
54
54
 
55
- `engine="auto"` picks one per query. Results are payload-agnostic:
55
+ `engine="auto"` always picks `seqtm`, because it is the only engine that enforces
56
+ the caps you asked for. Results are payload-agnostic:
56
57
  `(ref_id, score, n_subs, n_ins, n_dels)`. Downstream libraries map `ref_id` back
57
58
  to their own payloads (V gene, MHC, counts) and filter.
58
59
 
@@ -69,6 +70,23 @@ Beyond search, seqtree ships:
69
70
  - **E-values / significance** — calibrate hit counts against a background control repertoire
70
71
  (`load_control` + `evalues`), the TCRNET approach on a finite-sample footing. See the
71
72
  [E-value guide](https://antigenomics.github.io/seqtree/evalue.html).
73
+ - **Calibrated cutoffs** — `threshold_for_evalue` inverts the E-value into the score cutoff that
74
+ achieves it, **per query**. A fixed cutoff is not a calibrated one: a control repertoire is
75
+ dense near germline and sparse among rare junctions, so the same threshold buys a common query
76
+ far more chance neighbours than a rare one.
77
+ - **Gap-block alignment** — `gapblock` restricts alignment to one contiguous indel, which is the
78
+ right model for a V(D)J junction and, measured against unrestricted affine alignment, is
79
+ exactly optimal on **98.8%** of genuinely related pairs at a calibrated `gap_open`. A gap
80
+ prior (`central_prior`, `profile_prior`, `frame_prior`) chooses where the block goes — a
81
+ sequence score alone cannot. `score_matrix` scores a whole query set against a whole reference
82
+ set in one GIL-released C++ call (**532 M pairs/s** on 16 cores; `numpy.asarray` wraps the
83
+ result with no copy), the shape a prototype-distance embedding needs.
84
+ - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
85
+ frame-aligned junctions (an *island*) and scores a query column by column against the island
86
+ consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
87
+ repertoire-scale cutoff it recovers **48.5%** of held-out members against **37.6%** for
88
+ min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
89
+ where the cutoff is strict.
72
90
 
73
91
  ## Install
74
92
 
@@ -85,7 +103,7 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
85
103
  ```fish
86
104
  bash setup.sh # repo-local .venv + editable install
87
105
  bash setup.sh --tests # + pytest
88
- bash setup.sh --bench # + benchmark deps (huggingface_hub, pandas, psutil)
106
+ bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
89
107
  ```
90
108
 
91
109
  ## Quickstart
@@ -119,6 +137,33 @@ target = seqtree.Index.build(vdjdb_cdr3s, alphabet="aa")
119
137
  for q, r in zip(queries, seqtree.evalues(target, control, queries, p)):
120
138
  if r["p_enrichment"] < 1e-3:
121
139
  print(q, r["E"], r["n_target"], r["n_control"])
140
+
141
+ # ...and the cutoff that achieves a target E, per query (-1 = unreachable at this control size)
142
+ ceiling = seqtree.SearchParams(max_subs=14, max_penalty=50, matrix="BLOSUM62", engine="seqtm")
143
+ thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_target=0.05)
144
+
145
+ # one contiguous gap block, placed by a prior rather than by the score alone
146
+ from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
147
+
148
+ gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
149
+ mat = seqtree.SubstitutionMatrix.blosum62()
150
+ for ref_id, score, block_len, block_pos in gbi.search(
151
+ "CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
152
+ gap_prior=central_prior(int(1.5 * mat.scale()))):
153
+ ...
154
+
155
+ # a fixed frame column makes gap placement transitive -- and a column index, hence a PWM, possible
156
+ embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
157
+
158
+ # a whole query set vs a whole reference set, in one GIL-released C++ call
159
+ from seqtree.gapblock import score_matrix, IslandProfile
160
+ sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
161
+ import numpy as np
162
+ distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
163
+
164
+ # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
165
+ profile = IslandProfile.fit(island_members)
166
+ profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
122
167
  ```
123
168
 
124
169
  ## Tests
@@ -138,6 +183,8 @@ python bench/bench.py # recall vs ground truth (real VDJdb data)
138
183
  python bench/bench_evalue.py # true E-value benchmark (target vs background control)
139
184
  python bench/bench_evalue_matrix.py # significance across reference/control/query/scope grid
140
185
  python bench/bench_epitope.py # epitope detection-complexity (GIL vs NLV)
186
+ python bench/bench_gapblock.py # the gap-freedom ladder: fixed centre → prior → flat → affine
187
+ python bench/bench_score_matrix.py # dense batch gap-block throughput (µs/pair, M pairs/s, RSS)
141
188
  ```
142
189
 
143
190
  Figures (throughput, scaling, matrix-scoring overhead, collisions, E-value matrix, epitope
@@ -15,11 +15,12 @@ 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
 
@@ -36,6 +37,23 @@ 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
+ - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
52
+ frame-aligned junctions (an *island*) and scores a query column by column against the island
53
+ consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
54
+ repertoire-scale cutoff it recovers **48.5%** of held-out members against **37.6%** for
55
+ min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
56
+ where the cutoff is strict.
39
57
 
40
58
  ## Install
41
59
 
@@ -52,7 +70,7 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
52
70
  ```fish
53
71
  bash setup.sh # repo-local .venv + editable install
54
72
  bash setup.sh --tests # + pytest
55
- bash setup.sh --bench # + benchmark deps (huggingface_hub, pandas, psutil)
73
+ bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
56
74
  ```
57
75
 
58
76
  ## Quickstart
@@ -86,6 +104,33 @@ target = seqtree.Index.build(vdjdb_cdr3s, alphabet="aa")
86
104
  for q, r in zip(queries, seqtree.evalues(target, control, queries, p)):
87
105
  if r["p_enrichment"] < 1e-3:
88
106
  print(q, r["E"], r["n_target"], r["n_control"])
107
+
108
+ # ...and the cutoff that achieves a target E, per query (-1 = unreachable at this control size)
109
+ ceiling = seqtree.SearchParams(max_subs=14, max_penalty=50, matrix="BLOSUM62", engine="seqtm")
110
+ thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_target=0.05)
111
+
112
+ # one contiguous gap block, placed by a prior rather than by the score alone
113
+ from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
114
+
115
+ gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
116
+ mat = seqtree.SubstitutionMatrix.blosum62()
117
+ for ref_id, score, block_len, block_pos in gbi.search(
118
+ "CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
119
+ gap_prior=central_prior(int(1.5 * mat.scale()))):
120
+ ...
121
+
122
+ # a fixed frame column makes gap placement transitive -- and a column index, hence a PWM, possible
123
+ embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
124
+
125
+ # a whole query set vs a whole reference set, in one GIL-released C++ call
126
+ from seqtree.gapblock import score_matrix, IslandProfile
127
+ sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
128
+ import numpy as np
129
+ distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
130
+
131
+ # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
132
+ profile = IslandProfile.fit(island_members)
133
+ profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
89
134
  ```
90
135
 
91
136
  ## Tests
@@ -105,6 +150,8 @@ python bench/bench.py # recall vs ground truth (real VDJdb data)
105
150
  python bench/bench_evalue.py # true E-value benchmark (target vs background control)
106
151
  python bench/bench_evalue_matrix.py # significance across reference/control/query/scope grid
107
152
  python bench/bench_epitope.py # epitope detection-complexity (GIL vs NLV)
153
+ python bench/bench_gapblock.py # the gap-freedom ladder: fixed centre → prior → flat → affine
154
+ python bench/bench_score_matrix.py # dense batch gap-block throughput (µs/pair, M pairs/s, RSS)
108
155
  ```
109
156
 
110
157
  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).
@@ -0,0 +1,97 @@
1
+ # Data sources
2
+
3
+ Every dataset seqtree ships, downloads, or reads in a benchmark. Provenance is stated as
4
+ **experimental** (measured from a biological sample), **derived** (computed from experimental data),
5
+ or **synthetic** (generated by a model). Never conflate the three.
6
+
7
+ ## Bundled with the wheel
8
+
9
+ ### `python/seqtree/data/control_human_trb_aa.txt`
10
+
11
+ | | |
12
+ |---|---|
13
+ | what | 250,000 human TRB junction amino-acid sequences, the null distribution `P₀` |
14
+ | provenance | **experimental**, then **derived** by uniform subsampling |
15
+ | origin | HuggingFace `antigenomics/vdjdb-cdr3-frequencies`, human TRB pooled clonotype table |
16
+ | format | one junction per line, productive only (20 standard amino acids) |
17
+ | regenerate | `python bench/gen_control.py --species human --chain trb --size 250000 --seed 0` |
18
+
19
+ The upstream table is sorted by clonotype count, descending. The sample is a **seeded reservoir
20
+ sample over unique clonotypes**, then shuffled, so any prefix `bundled[:k]` is itself a uniform
21
+ sub-sample. Taking the first *k* rows of the upstream table instead yields the abundance head, which
22
+ is 25.8× more self-similar and carries 3.1× the ball mass — it violates `ass:indep` in
23
+ `appendix/evalue.tex` and corrupts every E-value. See `CHANGELOG.md` [0.3.0] Breaking.
24
+
25
+ Out-of-frame clonotypes (VDJtools `_`) and in-frame stops (`*`) are dropped. `_` marks a *collapsed
26
+ run* of untranslatable positions, so the residue count is already lost and no amino-acid repair is
27
+ possible; and out-of-frame junctions escape thymic selection, making them a sample of `P_gen`, which
28
+ `lem:hierarchy` proves is not `P₀`.
29
+
30
+ ### Substitution matrices — `src/blosum62.inc`, `pam250.inc`, `pam100.inc`, `structural.inc`
31
+
32
+ | | |
33
+ |---|---|
34
+ | provenance | **derived** (published log-odds / interaction-strength matrices) |
35
+ | origin | BLOSUM62 (NCBI); EMBOSS EPAM250, EPAM100; Miyazawa–Jernigan interaction strengths |
36
+ | regenerate | `python bench/gen_matrices.py` |
37
+
38
+ Stored as non-negative penalties via the Gram transform `pen[a][b] = s[a][a] + s[b][b] − 2·s[a][b]`,
39
+ clamped at zero, so `penalty(a, a) == 0` and the score defines a ball.
40
+
41
+ ## Downloaded on demand
42
+
43
+ ### `load_control(name)` → `~/.cache/seqtree/control_*.sqtree`
44
+
45
+ | | |
46
+ |---|---|
47
+ | what | larger control repertoires, e.g. `mouse_trb_aa` (694,241 productive clonotypes) |
48
+ | provenance | **experimental**, then **derived** by uniform subsampling |
49
+ | origin | HuggingFace `antigenomics/vdjdb-cdr3-frequencies` |
50
+ | fetch | `seqtree.load_control("mouse_trb_aa")` (uses `huggingface_hub`, an optional dependency) |
51
+
52
+ The cached index is keyed by name only, **not** by content. Delete `~/.cache/seqtree/control_*.sqtree`
53
+ after upgrading across a control change.
54
+
55
+ ## Benchmark inputs — not shipped, passed by path
56
+
57
+ These live in sibling repositories. seqtree does not depend on either; the benchmarks that read them
58
+ take `--prototypes` / `--vdjdb` arguments and parse the files with the standard library.
59
+
60
+ ### VDJdb slim dump
61
+
62
+ | | |
63
+ |---|---|
64
+ | what | TCR↔epitope pairs with `v.end` / `j.start` templated-flank annotations |
65
+ | provenance | **experimental** (curated from published studies) |
66
+ | origin | `../mirpy/airr_benchmark/vdjdb/vdjdb-2025-12-29/vdjdb.slim.txt.gz` |
67
+ | upstream | https://github.com/antigenomics/vdjdb-db — release 2025-12-29 |
68
+ | covers | human TRA/TRB, mouse TRA/TRB, macaque TRB. Dotted column names (`v.segm`, `antigen.epitope`) |
69
+ | used by | `bench/gap_rules_by_locus.py` (criterion A) |
70
+
71
+ ### Per-locus prototype sets
72
+
73
+ | | |
74
+ |---|---|
75
+ | what | 10,000 clonotypes per (species, locus): `v_call`, `j_call`, `junction_aa` |
76
+ | provenance | **experimental** where a real control repertoire exists, otherwise **synthetic** (OLGA) |
77
+ | origin | `../mirpy/mir/resources/prototypes/{species}_{locus}.tsv` |
78
+ | covers | human TRA TRB TRG TRD IGH IGK IGL; mouse TRA TRB |
79
+ | regenerate | `python ../mirpy/mir/resources/prototypes/generate_prototypes.py` (committed; never rebuilt at test time) |
80
+ | used by | `bench/gap_rules_by_locus.py` (criteria B and C) |
81
+
82
+ Junctions, not IMGT CDR3: they include both anchors (`C…F` or `C…W`). Median lengths run from 12
83
+ (IGK) to 20 (IGH), with IGH reaching 50 — which is why a fixed gap-position set calibrated on
84
+ length-14 TRB does not transfer.
85
+
86
+ ### IMGT gene library
87
+
88
+ | | |
89
+ |---|---|
90
+ | what | germline V/D/J nucleotide sequences, 2,710 (species, allele) entries |
91
+ | provenance | **experimental** (IMGT reference directory) |
92
+ | origin | `../mirpy/mir/resources/gene_library/imgt_gene_library.txt` |
93
+ | caveat | **V genes translate in frame 0; J genes do not** (TRBJ2-7 is frame 1, TRBJ1-1 frame 2). The companion `region_annotations.txt` carries `jcdr3_aa`, which excludes the conserved F/W — it is CDR3 convention, not junction, and it disagrees with the translated library on some alleles (TRBV1\*01, IGHV1-2\*01). |
94
+
95
+ Not currently read by any committed benchmark. Recorded because a germline-templated-flank prior was
96
+ built against it and **falsified**: it recovers the structurally correct block position 0.4% of the
97
+ time on TRA against 42.4% for a central prior.