seqtree 0.6.1__tar.gz → 1.0.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 (68) hide show
  1. {seqtree-0.6.1 → seqtree-1.0.0}/CHANGELOG.md +256 -2
  2. {seqtree-0.6.1 → seqtree-1.0.0}/CMakeLists.txt +16 -3
  3. {seqtree-0.6.1 → seqtree-1.0.0}/PKG-INFO +106 -54
  4. {seqtree-0.6.1 → seqtree-1.0.0}/README.md +104 -50
  5. {seqtree-0.6.1 → seqtree-1.0.0}/SOURCES.md +15 -0
  6. seqtree-1.0.0/include/seqtree/parallel.hpp +70 -0
  7. seqtree-1.0.0/include/seqtree/text_index.hpp +108 -0
  8. {seqtree-0.6.1 → seqtree-1.0.0}/pyproject.toml +7 -5
  9. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/__init__.py +19 -0
  10. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/control.py +2 -11
  11. seqtree-1.0.0/python/seqtree/distance.py +282 -0
  12. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/evalue.py +34 -14
  13. seqtree-1.0.0/python/seqtree/pmhc_evalue.py +25 -0
  14. {seqtree-0.6.1 → seqtree-1.0.0}/skills/seqtree/SKILL.md +84 -2
  15. seqtree-1.0.0/src/_bindings.cpp +1084 -0
  16. {seqtree-0.6.1 → seqtree-1.0.0}/src/distance.cpp +5 -29
  17. {seqtree-0.6.1 → seqtree-1.0.0}/src/gapblock.cpp +17 -29
  18. {seqtree-0.6.1 → seqtree-1.0.0}/src/index.cpp +9 -69
  19. {seqtree-0.6.1 → seqtree-1.0.0}/src/kmer_index.cpp +35 -60
  20. {seqtree-0.6.1 → seqtree-1.0.0}/src/pairwise.cpp +12 -36
  21. {seqtree-0.6.1 → seqtree-1.0.0}/src/searcher.cpp +7 -11
  22. seqtree-1.0.0/src/text_index.cpp +734 -0
  23. seqtree-0.6.1/python/seqtree/distance.py +0 -119
  24. seqtree-0.6.1/python/seqtree/pmhc_evalue.py +0 -33
  25. seqtree-0.6.1/src/_bindings.cpp +0 -673
  26. {seqtree-0.6.1 → seqtree-1.0.0}/.gitattributes +0 -0
  27. {seqtree-0.6.1 → seqtree-1.0.0}/.gitignore +0 -0
  28. {seqtree-0.6.1 → seqtree-1.0.0}/LICENSE +0 -0
  29. {seqtree-0.6.1 → seqtree-1.0.0}/ROADMAP.md +0 -0
  30. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/.gitignore +0 -0
  31. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/.latexmkrc +0 -0
  32. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/Makefile +0 -0
  33. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/epitope_detection.pdf +0 -0
  34. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/evalue.pdf +0 -0
  35. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/evalue.tex +0 -0
  36. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/evalue_matrix.pdf +0 -0
  37. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/mhc1_rocpr.pdf +0 -0
  38. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/mhc2_rocpr.pdf +0 -0
  39. {seqtree-0.6.1 → seqtree-1.0.0}/appendix/refs.bib +0 -0
  40. {seqtree-0.6.1 → seqtree-1.0.0}/examples/01_gapped_search.py +0 -0
  41. {seqtree-0.6.1 → seqtree-1.0.0}/examples/02_sequence_dendrogram.py +0 -0
  42. {seqtree-0.6.1 → seqtree-1.0.0}/examples/03_indel_positions.py +0 -0
  43. {seqtree-0.6.1 → seqtree-1.0.0}/examples/04_island_profile.py +0 -0
  44. {seqtree-0.6.1 → seqtree-1.0.0}/include/seqtree/kmer_index.hpp +0 -0
  45. {seqtree-0.6.1 → seqtree-1.0.0}/include/seqtree/seqtree.hpp +0 -0
  46. {seqtree-0.6.1 → seqtree-1.0.0}/include/seqtree/types.hpp +0 -0
  47. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  48. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/gapblock.py +0 -0
  49. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/layout.py +0 -0
  50. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/pairwise.py +0 -0
  51. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/pmhc.py +0 -0
  52. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/py.typed +0 -0
  53. {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/seeds.py +0 -0
  54. {seqtree-0.6.1 → seqtree-1.0.0}/src/atomic_write.hpp +0 -0
  55. {seqtree-0.6.1 → seqtree-1.0.0}/src/blosum45.inc +0 -0
  56. {seqtree-0.6.1 → seqtree-1.0.0}/src/blosum62.inc +0 -0
  57. {seqtree-0.6.1 → seqtree-1.0.0}/src/blosum80.inc +0 -0
  58. {seqtree-0.6.1 → seqtree-1.0.0}/src/codec.cpp +0 -0
  59. {seqtree-0.6.1 → seqtree-1.0.0}/src/engine_seqtm.cpp +0 -0
  60. {seqtree-0.6.1 → seqtree-1.0.0}/src/engine_seqtrie.cpp +0 -0
  61. {seqtree-0.6.1 → seqtree-1.0.0}/src/engines.hpp +0 -0
  62. {seqtree-0.6.1 → seqtree-1.0.0}/src/pam100.inc +0 -0
  63. {seqtree-0.6.1 → seqtree-1.0.0}/src/pam250.inc +0 -0
  64. {seqtree-0.6.1 → seqtree-1.0.0}/src/positional_matrix.cpp +0 -0
  65. {seqtree-0.6.1 → seqtree-1.0.0}/src/structural.inc +0 -0
  66. {seqtree-0.6.1 → seqtree-1.0.0}/src/substitution_matrix.cpp +0 -0
  67. {seqtree-0.6.1 → seqtree-1.0.0}/src/trie.cpp +0 -0
  68. {seqtree-0.6.1 → seqtree-1.0.0}/src/trie.hpp +0 -0
@@ -1,7 +1,261 @@
1
1
  # Changelog
2
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.
3
+ All notable changes to `seqtree`. Dates are release dates. From 1.0.0 the project follows semantic
4
+ versioning: breaking changes need a **major** bump.
5
+
6
+ ## [1.0.0] — 2026-09-06
7
+
8
+ ### Added
9
+
10
+ - **`TextIndex` — exact k-mismatch search over a concatenated reference text, with one index
11
+ for every query length.** `Index` builds a trie over reference *strings*, so asking a text
12
+ question with it means enumerating every length-`L` window of the text as its own string —
13
+ and again for every distinct query length. The human proteome has **68,389,335** nine-mer
14
+ windows, so one such index costs several gigabytes; a query set spanning **45 distinct
15
+ lengths** costs 45 of them. That is what ran **> 2 h 10 m without finishing** and filled
16
+ **225 GB** of index cache downstream. `TextIndex` keys on a k-mer seed table over the flat
17
+ text, so **`k` belongs to the index, not to the query**: one build answers every length and
18
+ every `max_subs`.
19
+
20
+ ```python
21
+ ix = TextIndex.build(records, alphabet="aa", k=4, group_ids=gene_id_per_record)
22
+ res = ix.search_batch(peptides, max_subs=2, best_only=True, group_by=True, threads=0)
23
+ ```
24
+
25
+ **One search scheme**, not a special case per query shape. Split the query into
26
+ `b = min(max_subs + 1, L // k)` disjoint blocks of width `L // b >= k`, and probe block `j`'s
27
+ leading k-mer at radius `c_j` — every k-mer within `c_j` substitutions of it. That is
28
+ **lossless exactly when `sum(c_j) >= max_subs - b + 1`**: the lightest error vector that could
29
+ evade every block is `e_j = c_j + 1`, of weight `sum(c_j) + b`, so nothing within `max_subs`
30
+ can hide below that threshold. Probing at radius `c` costs `N(c) = sum(C(k,i)(A-1)^i, i <= c)`,
31
+ whose increments climb steeply and are the same for every block, so the cheapest legal scheme
32
+ takes as many blocks as fit and spreads `r = max(0, max_subs - b + 1)` budget units evenly.
33
+ `b = max_subs + 1` recovers plain pigeonhole (one exact seed per error) and `b = 1` a single
34
+ substitution ball over `q[0:k]`; **the useful schemes are in between**, which is where short
35
+ queries live: at `L = 8, max_subs = 2, k = 4` two disjoint 4-mers fit, so `c = (0, 1)` probes
36
+ **94** variants where a one-block ball probes **3,267**.
37
+
38
+ Two details are not incidental. Spare budget units go on the **last** blocks, worth a further
39
+ **1.4x**: a candidate already matches its own block's k-mer, so left-to-right verification
40
+ meets the unconstrained prefix first and rejects after a residue or two — and the high-budget
41
+ block contributes nearly all the candidates. And a start that two blocks both reach is
42
+ returned **once**, emitted by the lowest-indexed block that could have produced it, a test read
43
+ straight off the mismatch positions verification has already computed — so deduplication needs
44
+ no sort and no hash set, which also removed the candidate sort from the all-exact path.
45
+
46
+ The framing is the search scheme of Kianfar, Pockrandt, Torkamandi, Luo & Reinert,
47
+ *Optimum Search Schemes for Approximate String Matching Using Bidirectional FM-Index*,
48
+ [arXiv:1711.02035](https://doi.org/10.48550/arXiv.1711.02035). Only the partition-and-budget
49
+ half transfers: a direct-addressed seed table has no bidirectional extension, so there is no
50
+ search *order* to optimise over.
51
+
52
+ **None of this is a heuristic**, and that is checked rather than asserted: `tests/cpp/
53
+ test_text_index.cpp` compares against an independent brute-force scan over query length 6–30
54
+ × `max_subs` 0–3 × `k` ∈ {3, 4, 5}, requiring **set equality** of `(ref_id, offset, n_subs)`
55
+ *and* of the mismatch detail — not merely that hits were found — plus a case straddling every
56
+ block boundary, and a separate case asserting the answer is *identical* across `k`, so a
57
+ dispatch bug cannot pass by agreeing with itself. `bench/bench_text_index.py` repeats the
58
+ `k`-invariance check on the real proteomes, where hit counts match cell for cell. A missed hit
59
+ does not degrade the caller's answer, it changes it, from ambiguous to confidently wrong.
60
+
61
+ Measured on the human proteome (UP000005640, 147,506 records / **69,578,135 residues**), one
62
+ thread, Apple M-series, `bench/bench_text_index.py`. The last column is the two-path dispatch
63
+ this scheme replaced, on the same benchmark:
64
+
65
+ | `L` | `max_subs` | `k = 4` | `k = 5` | two-path dispatch |
66
+ |--:|--:|--:|--:|--:|
67
+ | 8 | 2 | **1.468** | 4.694 | 34.505 |
68
+ | 9 | 2 | **1.334** | 4.526 | 35.453 |
69
+ | 10 | 2 | 1.298 | **0.129** | 40.940 |
70
+ | 11 | 2 | 1.138 | **0.108** | 30.196 |
71
+ | 12 | 2 | **0.097** | 0.126 | 0.148 |
72
+ | 10 | 3 | 3.595 | **0.347** | 423.762 |
73
+ | 12 | 3 | 1.567 | **0.394** | 415.578 |
74
+ | 15 | 3 | 1.514 | **0.138** | 400.484 |
75
+
76
+ ms/query, single-threaded, and hit counts identical across `k` in every cell — the benchmark
77
+ asserts that rather than reporting it. So **24–32x at `L = 8–11, max_subs = 2`, and 87–1,221x
78
+ at `max_subs = 3`**. Against the design document's acceptance targets of <= 0.5 ms/query at
79
+ `L >= 12` and <= 1.0 at `L = 8–11`: with `k` chosen by the `L = 2k` crossover, **every target
80
+ from length 10 up is met with an order of magnitude of margin**, and lengths 8–9 land at
81
+ 1.33–1.47 ms — 1.4x over, down from 35x over. Build is 0.55 s at `k = 4` and 0.59 s at
82
+ `k = 5`, so a corpus spanning both regimes can hold both indexes.
83
+
84
+ Threads, human proteome, `L = 12`, `max_subs = 2`, 50,000 queries: 0.103 ms/query on one,
85
+ 0.013 on eight (**7.8x**), 0.008 on all cores (**12.6x**). The ratio is now bounded by the
86
+ serial CSR flatten rather than by the search, which is why it is short of linear; the flatten
87
+ preallocates from the workers' actual output instead of growing nine vectors by doubling.
88
+
89
+ Results come back as flat CSR arrays rather than one object per hit — a 445 k-query run
90
+ returns a handful of arrays. `res[i]` materialises `TextHit` objects for one query on demand,
91
+ `res.arrays()` gives zero-copy buffer views, and `res.to_numpy()` gives numpy views, with
92
+ numpy imported **on the call** so seqtree keeps its empty dependency list. Order is
93
+ `(n_subs, ref_id, offset)`, stable across runs and across thread counts.
94
+
95
+ Beyond a position: each mismatch is the **pair** `(pos, query_aa, text_aa)`, so a caller
96
+ ranking by chemistry can tell `L→I` from `L→D` without re-fetching the window. `matrix=`
97
+ attaches a substitution score to a hit the Hamming predicate has already accepted and never
98
+ changes which hits return. `best_only=True` walks the distance upward, stops at the first
99
+ non-empty shell and returns **all** of it, from one index. `group_ids` labels records with
100
+ arbitrary integers — gene ids, species, clusters — and `group_by=True` folds hits onto them,
101
+ which makes **a tie between nearest parents a first-class output** rather than something each
102
+ caller re-derives. `max_hits` caps a query *after* sorting, so the best hits survive, and
103
+ always sets `res.truncated[i]`: a cap that is invisible is a recall bug wearing a performance
104
+ costume.
105
+
106
+ Saved files are flat with their arrays at known aligned offsets, so `TextIndex.load(path)`
107
+ **maps** rather than parses and several processes share one copy of the pages — the human
108
+ `k = 4` index is **348.2 MB** and maps in **0.1 ms** regardless (0.12 s to read it instead,
109
+ 0.46 s to save it). That is 5.0x the text, and 79 % of it is `post_ids`: one `uint32` per
110
+ in-record k-mer start, every one of which has to be addressable for the answer to be exact.
111
+ A `.sti` is treated as untrusted input — the header's record, bucket and posting counts are
112
+ cross-checked against each other and against the file length before any of them sizes an
113
+ allocation or indexes into the mapping.
114
+
115
+ - **The alphabet is handled asymmetrically, on purpose.** A residue outside the codec in the
116
+ **text** — 36 `U` in the human proteome, 33 in mouse — becomes a hole that no seed spans and
117
+ no hit crosses, and is counted in `ix.num_unknown`; refusing the build over them would make
118
+ the class unusable on the very reference data it exists for. The same residue in a **query**
119
+ raises, naming the query: `queries[42] ('CASSUGQYF'): symbol 'U' is not in the alphabet`.
120
+ `B`/`Z`/`X`/`*` are real symbols, not wildcards — `X` matches only `X`. The hole marker is
121
+ also what separates records, so a hit spanning a record boundary is structurally impossible
122
+ rather than prevented by a check that could be forgotten.
123
+
124
+ - **Limits that would otherwise be silent are now refused.** A query longer than 65,535 residues
125
+ raises rather than wrapping its 16-bit mismatch positions. A `.sti` whose header disagrees with
126
+ itself or with the file length is rejected before any field sizes an allocation or indexes into
127
+ a mapping — on the `mmap` path there is no short read to catch it, so it has to be explicit.
128
+ `TextIndex.ref_seq` documents that it renders an out-of-alphabet residue as `'X'`, which is
129
+ lossy and where `'X'` is itself a real symbol: it reports what the index holds, not what was
130
+ handed to `build`. And the seed table's two counting-sort passes now assert that they visited
131
+ the same positions, since a disagreement would silently overrun a bucket into its neighbour.
132
+
133
+ - **`TextIndex.search_batch` no longer terminates the process on an allocation failure.** Its
134
+ workers were exempted from the tree's `exception_ptr` plumbing on the grounds that queries are
135
+ validated up front — true, but they still allocate, and a `std::bad_alloc` escaping a
136
+ `std::thread` entry function calls `std::terminate`, which is an uncatchable `SIGABRT` from
137
+ Python. It now captures and rethrows after the join, like every other batch entry point.
138
+
139
+ - **`_core.pyi`.** The package has shipped `py.typed` since the beginning while providing no
140
+ stubs at all, so every C++ symbol resolved to `Any`. nanobind generates them at build time.
141
+
142
+ ### Changed
143
+
144
+ - **The bindings moved from pybind11 to nanobind.** Smaller module, lower per-call overhead,
145
+ and stub generation. Externally visible behaviour is unchanged, including the two things that
146
+ needed work to keep: `ScoreMatrix`'s buffer protocol, which nanobind has no equivalent for
147
+ (`Py_bf_getbuffer` is now wired by hand via `nb::type_slots`, so `numpy.asarray(sm)` and
148
+ `memoryview(sm)` still both wrap it without copying), and the exception mapping that
149
+ `test_pairwise.py` pins by message text. nanobind's implicit-conversion pass was checked
150
+ rather than assumed: `threads=1.0` still raises `TypeError` rather than truncating, so none
151
+ of the ~60 scalar arguments needed `noconvert`.
152
+
153
+ - **`Development Status` is now `5 - Production/Stable`**, and from this release the project
154
+ follows semantic versioning — breaking changes need a major bump. The `docs` extra no longer
155
+ lists `nbsphinx`, which was in neither `docs/conf.py` nor `docs/requirements.txt`.
156
+
157
+ ### Audit pass before release
158
+
159
+ A code and documentation audit run against the finished 1.0.0 tree. Nothing here changes an API:
160
+ it removes duplication, documents what was already public, and corrects two doc claims that no
161
+ longer matched the code.
162
+
163
+ #### Documentation and API surface
164
+
165
+ - **`engine="auto"` is documented correctly.** `docs/engines.rst` claimed `auto` "chooses per
166
+ query" and "routes matrix-weighted budgets to `seqtrie`". It has never done either since the
167
+ dispatch was removed: `auto` always resolves to `seqtm`, because `seqtrie` ignores the
168
+ per-type caps and would silently widen the ball. A reader following the old text and writing
169
+ `SearchParams(matrix="blosum62", max_penalty=12)` got **1 hit where naming `seqtrie` gives 3**,
170
+ with nothing in the result to say why. The page now says so and shows both.
171
+ - **`gap_open` in the first example a reader meets.** `docs/getting-started.rst` and the README
172
+ Quickstart paired BLOSUM62 with `gap_open=8`, against the library's own rule of
173
+ `2 * matrix.scale()` = **28** for BLOSUM62. At 8 a gap is cheaper than a substitution, which is
174
+ the exact failure the rule exists to prevent.
175
+ - **README leads with install and a runnable Quickstart**, then a "Which piece do I need?" routing
176
+ table, then the feature detail — which previously ran ~90 lines before the first `pip install`.
177
+ The Quickstart gained `TextIndex`, which the feature list advertised but no example used.
178
+ - **`docs/api.rst` is grouped by task** rather than one flat section per class: searching a set,
179
+ searching a text, scoring, dense matrices, seed-and-extend, significance. `ScoreMatrix` was
180
+ public, exported and referenced from `docs/distance.rst` but had **no entry on the API page at
181
+ all**; it has one now. Page titles that described a comparison rather than a capability
182
+ ("Pairwise alignment without BioPython") or over-narrowed a payload-agnostic feature
183
+ ("E-values for TCR hits") were renamed, and the toctree order now runs
184
+ install → concepts → examples → reference → capabilities.
185
+
186
+ #### Fixed in the same pass
187
+
188
+ - **59 members of the C++ binding surface had no docstring** — every field of `SearchParams`,
189
+ `Hit`, `Alignment`, `TextHit` and `Candidate`, and most `SubstitutionMatrix` factories. `api.rst`
190
+ autodocs with `:undoc-members:`, so each rendered as a bare name rather than failing the build.
191
+ All 59 are documented, and `tests/python/test_doc_coverage.py` now gates the `_core` classes the
192
+ way it already gated the Python modules — plus a test that executes the README Quickstart
193
+ verbatim.
194
+ - **`ArrayView.format` was a `static thread_local char[2]` scratch buffer** that each `getbuffer`
195
+ overwrote, so two live views of different dtypes on one thread both reported the last format.
196
+ It is a per-instance member now.
197
+
198
+ #### Internal
199
+
200
+ - **One `parallel_for` replaces seven hand-rolled copies** of the atomic cursor / adaptive chunk /
201
+ `exception_ptr` / rethrow-after-join scaffold (`include/seqtree/parallel.hpp`). The copies had
202
+ drifted: `gapblock.cpp` carried no exception plumbing at all, and the chunk cap was 1024 in four
203
+ places and 256 in a fifth. Per-worker scratch is still allocated once per thread, via a
204
+ `make_local` factory. No measured change: perf gate within 1.11x on every metric, gapblock
205
+ `score_matrix` 481–521 M pairs/s across three runs against the 532.7 M on record.
206
+ - **`psutil` is out of the `[bench]` extra.** Two of the six copies of peak-RSS used it, and
207
+ reported *current* RSS rather than peak — a different quantity from the four stdlib `resource`
208
+ copies beside them. One `bench/_common.py` now holds `peak_rss_mb`, `peak_rss_gb` and `mutate`.
209
+ - `seqtree.evalue.evalue_result` is public: the six-key E-value record, shared by `evalues` and
210
+ `pmhc_evalue.homolog_evalue`, which had rebuilt it from the identical formula.
211
+ - `control._NoLock` is `contextlib.nullcontext`; `searcher.cpp`'s single-caller `pick_engine()` is
212
+ inlined to `p.engine != Engine::SeqTrie`.
213
+
214
+ ## [0.7.0] — 2026-08-16
215
+
216
+ ### Added
217
+
218
+ - **`seqtree.distance` now enumerates a Hamming ball, not just scores one.**
219
+ `neighbourhood(seq, r=1, alphabet=None, include_self=True, shell=False)` lists the members of
220
+ the closed ball (`19·L + 1` at `r = 1` over the 20 standard residues);
221
+ `neighbourhood_union(seqs, ...)` takes the union over many centres, emitting each distinct
222
+ sequence **once**; `union_size(seqs, ...)` returns the cardinality without building or sorting
223
+ the result list. `shell=True` returns the sphere at exactly `r` from the *nearest* centre —
224
+ a per-shell quantity has to be estimated per shell.
225
+
226
+ Substitution only, fixed length: Hamming distance is undefined across lengths (`hamming`
227
+ already raises), and indels are `gapblock`'s problem. Deduplication happens during a
228
+ multi-source breadth-first walk, so the `Σ 19·L_i` multiset is never materialised — which is
229
+ the point, since near-duplicate centres overlap heavily. For 200 length-14 junctions all within
230
+ distance 1 of a common centre, the per-sequence balls double-count **41.7%** (53,400 → 31,122);
231
+ at spread 2, 4.5%; at spread ≥ 3, nothing.
232
+
233
+ Pure Python, measured before being written in C++: 300 junctions of length 14 at `r = 1` is
234
+ 80,100 distinct sequences in **23 ms** on one M3 core (11 ms for `union_size`). `r = 2` over
235
+ the same input is 9.9 M sequences, 6.8 s and ~1.8 GB.
236
+
237
+ `alphabet=None` defaults to the 20 standard amino acids — `amino_acids()` **minus** the
238
+ ambiguity codes `B`/`Z`/`X` and the stop `*`, which that function does include.
239
+
240
+ Shells **partition** the closed ball exactly: shells `0..r` are pairwise disjoint, their union
241
+ is the ball, and every member sits at distance exactly `d` from its *nearest* centre. A
242
+ per-shell quantity therefore sums back to the per-ball one. Pinned by a test that checks the
243
+ distance with the C++ `hamming`, not with the generator's own bookkeeping.
244
+
245
+ `neighbourhood_union` and `union_size` **raise `TypeError` on a single string** rather than
246
+ iterating it. A `str` is iterable, so `union_size("CASSLGQYF")` would otherwise take the
247
+ sequence's 8 distinct *characters* as the centres and answer `20` instead of `172` — a
248
+ plausible integer, no error, no way to notice. Pass `["CASSLGQYF"]`, or call `neighbourhood`.
249
+
250
+ - **`seqtree.__version__`.** Read from the installed distribution metadata, so `pyproject.toml`'s
251
+ `project.version` is the single source and a release that bumps it cannot leave a stale literal
252
+ behind. Asserted against the distribution metadata *and* against `pyproject.toml` by a test.
253
+
254
+ ### Fixed
255
+
256
+ - **The documentation build stamped every page `0.6.1` while the package was `0.7.0`.**
257
+ `docs/conf.py` hand-copied the version into `release`/`version` and the 0.7.0 bump touched only
258
+ `pyproject.toml`. Both now read `seqtree.__version__`, which the docs job already installs.
5
259
 
6
260
  ## [0.6.1] — 2026-07-30
7
261
 
@@ -13,7 +13,7 @@ endif() # MSVC Release already uses /O2; -O3 is not a valid MSVC flag
13
13
 
14
14
  option(SEQTREE_TESTS "Build C++ tests" OFF)
15
15
  option(SEQTREE_BENCH "Build C++ benchmarks" OFF)
16
- option(SEQTREE_PYTHON "Build the pybind11 module" OFF)
16
+ option(SEQTREE_PYTHON "Build the nanobind module" OFF)
17
17
 
18
18
  find_package(Threads REQUIRED)
19
19
 
@@ -22,6 +22,7 @@ add_library(seqtree_core STATIC
22
22
  src/substitution_matrix.cpp
23
23
  src/positional_matrix.cpp
24
24
  src/kmer_index.cpp
25
+ src/text_index.cpp
25
26
  src/trie.cpp
26
27
  src/index.cpp
27
28
  src/engine_seqtm.cpp
@@ -35,10 +36,21 @@ target_include_directories(seqtree_core PUBLIC include PRIVATE src)
35
36
  target_link_libraries(seqtree_core PUBLIC Threads::Threads)
36
37
 
37
38
  if(SEQTREE_PYTHON)
38
- find_package(pybind11 CONFIG REQUIRED)
39
- pybind11_add_module(_core src/_bindings.cpp)
39
+ find_package(Python 3.10 REQUIRED COMPONENTS Interpreter Development.Module)
40
+ # nanobind ships its CMake package inside the wheel rather than on CMAKE_PREFIX_PATH.
41
+ execute_process(COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
42
+ OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE nanobind_ROOT)
43
+ find_package(nanobind CONFIG REQUIRED)
44
+ # NB_STATIC: one extension module, so link nanobind's runtime into it rather than
45
+ # building the shared libnanobind nobody else would use.
46
+ nanobind_add_module(_core NB_STATIC src/_bindings.cpp)
40
47
  target_link_libraries(_core PRIVATE seqtree_core)
41
48
  install(TARGETS _core DESTINATION seqtree)
49
+
50
+ # py.typed has always promised types the package never shipped; nanobind generates them.
51
+ nanobind_add_stub(_core_stub MODULE _core OUTPUT _core.pyi
52
+ PYTHON_PATH $<TARGET_FILE_DIR:_core> DEPENDS _core)
53
+ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/_core.pyi DESTINATION seqtree)
42
54
  endif()
43
55
 
44
56
  if(SEQTREE_TESTS)
@@ -52,6 +64,7 @@ if(SEQTREE_TESTS)
52
64
  tests/cpp/test_serialize.cpp
53
65
  tests/cpp/test_positional.cpp
54
66
  tests/cpp/test_kmer_index.cpp
67
+ tests/cpp/test_text_index.cpp
55
68
  )
56
69
  target_include_directories(seqtree_tests PRIVATE tests/cpp src)
57
70
  target_link_libraries(seqtree_tests PRIVATE seqtree_core)
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: seqtree
3
- Version: 0.6.1
3
+ Version: 1.0.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>
7
7
  License: GPL-3.0-or-later
8
- Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Development Status :: 5 - Production/Stable
9
9
  Classifier: Intended Audience :: Science/Research
10
10
  Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
11
11
  Classifier: Programming Language :: Python :: 3
@@ -21,11 +21,9 @@ Requires-Dist: pytest-cov>=4.1; extra == "test"
21
21
  Requires-Dist: biopython>=1.81; extra == "test"
22
22
  Provides-Extra: bench
23
23
  Requires-Dist: huggingface_hub; extra == "bench"
24
- Requires-Dist: psutil; extra == "bench"
25
24
  Provides-Extra: docs
26
25
  Requires-Dist: sphinx; extra == "docs"
27
26
  Requires-Dist: pydata-sphinx-theme; extra == "docs"
28
- Requires-Dist: nbsphinx; extra == "docs"
29
27
  Provides-Extra: control
30
28
  Requires-Dist: huggingface_hub; extra == "control"
31
29
  Provides-Extra: pmhc
@@ -44,6 +42,68 @@ Fast fuzzy search over biological sequences (amino-acid or nucleotide), as a C++
44
42
  core with a minimal Python binding. Build an immutable index once, then search
45
43
  single queries or massive batches in parallel.
46
44
 
45
+ ## Install
46
+
47
+ ```fish
48
+ pip install seqtree # prebuilt wheels for CPython 3.10–3.13
49
+ ```
50
+
51
+ Prebuilt wheels cover **Linux x86-64**, **macOS arm64 (Apple Silicon)**, and **Windows x86-64**.
52
+ There are **no Intel/x86-64 macOS wheels** — Intel Macs build from source (see below), which just
53
+ needs a C++20 compiler and CMake (pulled in automatically by the build).
54
+
55
+ ## Quickstart
56
+
57
+ ```python
58
+ import seqtree
59
+
60
+ idx = seqtree.Index.build(["CASSLAPGATNEKLFF", "CASSLELGATNEKLFF"], alphabet="aa")
61
+
62
+ p = seqtree.SearchParams(max_subs=2, engine="seqtm")
63
+ for hit in idx.search("CASSLAPGATNEKLFF", p):
64
+ print(hit.ref_id, hit.score, hit.n_subs)
65
+
66
+ # parallel batch (releases the GIL)
67
+ results = idx.search_batch(queries, p, threads=0) # 0 = all cores
68
+
69
+ # matrix-weighted budget. Name seqtrie -- engine="auto" always means seqtm.
70
+ # gap_open must follow the matrix: 2 * blosum62.scale() == 28, not the default 1.
71
+ pm = seqtree.SearchParams(matrix="blosum62", max_penalty=12, engine="seqtrie", gap_open=28)
72
+ top = idx.search_top("CASSLAPGATNEKLFF", pm, k=5)
73
+
74
+ # alignment on demand
75
+ aln = idx.align(0, "CASSLELGATNEKLFF", p)
76
+ print(aln.aligned_query, aln.aligned_ref, aln.ops)
77
+
78
+ # batch-vs-batch (auto-indexes the larger set)
79
+ pairs = seqtree.pairwise_batch(query_set, db_set, p, alphabet="aa")
80
+
81
+ # a short query against a long TEXT (a proteome), one index for every query length
82
+ tix = seqtree.TextIndex.build(proteome_records, alphabet="aa", k=4)
83
+ res = tix.search_batch(peptides, max_subs=2, threads=0)
84
+ for hit in res[0]:
85
+ print(hit.ref_id, hit.offset, hit.n_subs, hit.mismatches)
86
+ ```
87
+
88
+ That is the whole core loop. Significance, gap blocks, alignment and distances are in
89
+ [More examples](#more-examples) below, and the
90
+ [docs](https://antigenomics.github.io/seqtree/) explain the why.
91
+
92
+ ## Which piece do I need?
93
+
94
+ | You have | You want | Use |
95
+ |---|---|---|
96
+ | a set of sequences | the ones within *k* edits of a query | `Index` + `SearchParams` |
97
+ | a long text (proteome, genome) | where a short query occurs within *k* mismatches | `TextIndex` |
98
+ | two sequences | an alignment, or a similarity score | `seqtree.pairwise` |
99
+ | two whole sets | every pairwise distance, densely | `hamming_matrix` / `dist_matrix` / `gapblock.score_matrix` |
100
+ | hits and a background repertoire | whether a hit is more than chance | `load_control` + `evalues` |
101
+ | a V(D)J junction pair | an alignment with one contiguous indel | `seqtree.gapblock` |
102
+ | one sequence | every substitution within radius *r* | `distance.neighbourhood` |
103
+
104
+ Results are payload-agnostic — `(ref_id, score, n_subs, n_ins, n_dels)`. Downstream libraries map
105
+ `ref_id` back to their own payloads (V gene, MHC, counts) and filter there.
106
+
47
107
  Two search engines over one trie:
48
108
 
49
109
  - **`seqtm`** — branch-and-bound enumeration. Exact per-type edit caps
@@ -53,10 +113,8 @@ Two search engines over one trie:
53
113
  `max_penalty` score budget only; it **ignores the per-type edit caps**. Use it
54
114
  when the budget is the whole specification.
55
115
 
56
- `engine="auto"` always picks `seqtm`, because it is the only engine that enforces
57
- the caps you asked for. Results are payload-agnostic:
58
- `(ref_id, score, n_subs, n_ins, n_dels)`. Downstream libraries map `ref_id` back
59
- to their own payloads (V gene, MHC, counts) and filter.
116
+ `engine="auto"` always picks `seqtm`, because it is the only engine that enforces the caps you
117
+ asked for — `seqtrie` runs only when you name it.
60
118
 
61
119
  Beyond search, seqtree ships:
62
120
 
@@ -68,6 +126,18 @@ Beyond search, seqtree ships:
68
126
  ([Košmrlj et al., *PNAS* 2008](https://doi.org/10.1073/pnas.0808081105); MJ contact energies from
69
127
  Miyazawa & Jernigan, *J Mol Biol* 1996) — letting dissimilar-but-chemically-equivalent loops align.
70
128
  Plus custom matrices via `SubstitutionMatrix.from_similarity` (Gram penalty `s(a,a)+s(b,b)−2·s(a,b)`).
129
+ - **Text search** — `TextIndex` does exact k-mismatch (Hamming) search over a *concatenated text*
130
+ — a proteome, a genome — where `Index` would need one build per query length. The human proteome
131
+ has **68,389,335** nine-mer windows, so a query set spanning **45 distinct lengths** costs 45
132
+ multi-gigabyte builds; here `k` belongs to the index and **one build answers every length and
133
+ every `max_subs`**. Exact, not heuristic: one search scheme — `b = min(m+1, L/k)` disjoint
134
+ blocks, block `j` probed at radius `c_j`, lossless exactly when `Σc_j ≥ m − b + 1` — with
135
+ completeness pinned by brute-force **set equality** over L 6–30 × `max_subs` 0–3 ×
136
+ k ∈ {3,4,5}, and by the answer being identical across `k`. On the human proteome
137
+ (69,578,135 residues) one thread answers a 9-mer within 2 substitutions in **1.3 ms** and a
138
+ 15-mer within 3 in **1.5 ms**. Results come back as flat CSR arrays with zero-copy
139
+ `numpy` views, mismatches as `(pos, query_aa, text_aa)` pairs, an optional fold onto
140
+ caller-supplied group ids that makes a tie explicit, and a cap that is always reported.
71
141
  - **E-values / significance** — calibrate hit counts against a background control repertoire
72
142
  (`load_control` + `evalues`), the TCRNET approach on a finite-sample footing. See the
73
143
  [E-value guide](https://antigenomics.github.io/seqtree/evalue.html).
@@ -94,7 +164,11 @@ Beyond search, seqtree ships:
94
164
  `hamming_matrix` / `levenshtein_matrix` score a whole set against a whole set in one GIL-released
95
165
  C++ call (`numpy.asarray` wraps the result with no copy) — no `python-Levenshtein` or `rapidfuzz`
96
166
  dependency needed. Hamming requires equal lengths (it raises otherwise); comparison is
97
- case-sensitive.
167
+ case-sensitive. The same module *enumerates* a Hamming ball as well as scoring one:
168
+ `neighbourhood(seq, r)` lists its `19·L + 1` members, and `neighbourhood_union(seqs, r)` takes
169
+ the union over many centres with each distinct sequence emitted **once** — deduplicated during
170
+ the walk, so the `Σ 19·L_i` multiset never exists. For a tight specificity group that is a 41.7%
171
+ saving, not a rounding correction.
98
172
  - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
99
173
  frame-aligned junctions (an *island*) and scores a query column by column against the island
100
174
  consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
@@ -102,51 +176,14 @@ Beyond search, seqtree ships:
102
176
  min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
103
177
  where the cutoff is strict.
104
178
 
105
- ## Install
106
-
107
- ```fish
108
- pip install seqtree # prebuilt wheels for CPython 3.10–3.13
109
- ```
110
-
111
- Prebuilt wheels cover **Linux x86-64**, **macOS arm64 (Apple Silicon)**, and **Windows x86-64**.
112
- There are **no Intel/x86-64 macOS wheels** — Intel Macs build from source (see below), which just
113
- needs a C++17 compiler and CMake (pulled in automatically by the build).
114
-
115
- ## Build from source
116
-
117
- Needs [uv](https://docs.astral.sh/uv/) (`brew install uv`); `setup.sh` uses it for the venv and
118
- the editable install.
119
-
120
- ```fish
121
- bash setup.sh # uv-managed .venv + editable install
122
- bash setup.sh --tests # + pytest
123
- bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
124
- ```
125
-
126
- ## Quickstart
179
+ ## More examples
127
180
 
128
181
  ```python
182
+ import numpy as np
129
183
  import seqtree
184
+ from seqtree.pairwise import align, score, dist_matrix
130
185
 
131
- idx = seqtree.Index.build(["CASSLAPGATNEKLFF", "CASSLELGATNEKLFF"], alphabet="aa")
132
-
133
- p = seqtree.SearchParams(max_subs=2, engine="seqtm")
134
- for hit in idx.search("CASSLAPGATNEKLFF", p):
135
- print(hit.ref_id, hit.score, hit.n_subs)
136
-
137
- # parallel batch (releases the GIL)
138
- results = idx.search_batch(queries, p, threads=0) # 0 = all cores
139
-
140
- # matrix-weighted budget
141
- pm = seqtree.SearchParams(matrix="BLOSUM62", max_penalty=12, engine="seqtrie")
142
- top = idx.search_top("CASSLAPGATNEKLFF", pm, k=5)
143
-
144
- # alignment on demand
145
- aln = idx.align(0, "CASSLELGATNEKLFF", p)
146
- print(aln.aligned_query, aln.aligned_ref, aln.ops)
147
-
148
- # batch-vs-batch (auto-indexes the larger set)
149
- pairs = seqtree.pairwise_batch(query_set, db_set, p, alphabet="aa")
186
+ mat = seqtree.SubstitutionMatrix.blosum62()
150
187
 
151
188
  # E-values against a background control repertoire (TCRNET-style significance)
152
189
  control = seqtree.load_control("human_trb_aa", size=1_000_000)
@@ -163,7 +200,6 @@ thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_targe
163
200
  from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
164
201
 
165
202
  gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
166
- mat = seqtree.SubstitutionMatrix.blosum62()
167
203
  for ref_id, score, block_len, block_pos in gbi.search(
168
204
  "CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
169
205
  gap_prior=central_prior(int(1.5 * mat.scale()))):
@@ -175,15 +211,13 @@ embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
175
211
  # a whole query set vs a whole reference set, in one GIL-released C++ call
176
212
  from seqtree.gapblock import score_matrix, IslandProfile
177
213
  sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
178
- import numpy as np
179
- distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
214
+ distances = np.asarray(sm) # (n_clonotypes, n_prototypes) int32, zero-copy
180
215
 
181
216
  # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
182
217
  profile = IslandProfile.fit(island_members)
183
218
  profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
184
219
 
185
220
  # ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
186
- from seqtree.pairwise import align, score, dist_matrix
187
221
  score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
188
222
  score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
189
223
  score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
@@ -196,6 +230,24 @@ from seqtree.distance import hamming, levenshtein, hamming_matrix, levenshtein_m
196
230
  hamming("CASSLGQYF", "CASSPGQYF") # 1 (equal length only)
197
231
  levenshtein("kitten", "sitting") # 3
198
232
  h = np.asarray(hamming_matrix(umis, umis, threads=0)) # (len, len) int32, zero-copy
233
+
234
+ # enumerate the ball, deduplicated across centres (substitution only, fixed length)
235
+ from seqtree.distance import neighbourhood, neighbourhood_union, union_size
236
+ neighbourhood("CASSLGQYF") # 172 = 19*9 + 1
237
+ union_size(junctions) # size the job before running it
238
+ for variant in neighbourhood_union(junctions, r=1): # each distinct sequence once
239
+ ...
240
+ ```
241
+
242
+ ## Build from source
243
+
244
+ Needs [uv](https://docs.astral.sh/uv/) (`brew install uv`); `setup.sh` uses it for the venv and
245
+ the editable install.
246
+
247
+ ```fish
248
+ bash setup.sh # uv-managed .venv + editable install
249
+ bash setup.sh --tests # + pytest
250
+ bash setup.sh --bench # + benchmark deps (huggingface_hub)
199
251
  ```
200
252
 
201
253
  ## Tests