seqtree 0.7.0__tar.gz → 1.0.1__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 (67) hide show
  1. {seqtree-0.7.0 → seqtree-1.0.1}/CHANGELOG.md +218 -2
  2. {seqtree-0.7.0 → seqtree-1.0.1}/CMakeLists.txt +17 -3
  3. {seqtree-0.7.0 → seqtree-1.0.1}/PKG-INFO +94 -53
  4. {seqtree-0.7.0 → seqtree-1.0.1}/README.md +92 -49
  5. {seqtree-0.7.0 → seqtree-1.0.1}/SOURCES.md +15 -0
  6. seqtree-1.0.1/include/seqtree/parallel.hpp +78 -0
  7. seqtree-1.0.1/include/seqtree/text_index.hpp +108 -0
  8. {seqtree-0.7.0 → seqtree-1.0.1}/pyproject.toml +7 -5
  9. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/__init__.py +12 -0
  10. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/control.py +2 -11
  11. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/evalue.py +34 -14
  12. seqtree-1.0.1/python/seqtree/pmhc_evalue.py +25 -0
  13. {seqtree-0.7.0 → seqtree-1.0.1}/skills/seqtree/SKILL.md +59 -2
  14. seqtree-1.0.1/src/_bindings.cpp +1084 -0
  15. {seqtree-0.7.0 → seqtree-1.0.1}/src/distance.cpp +5 -29
  16. {seqtree-0.7.0 → seqtree-1.0.1}/src/gapblock.cpp +17 -29
  17. {seqtree-0.7.0 → seqtree-1.0.1}/src/index.cpp +9 -69
  18. {seqtree-0.7.0 → seqtree-1.0.1}/src/kmer_index.cpp +35 -60
  19. {seqtree-0.7.0 → seqtree-1.0.1}/src/pairwise.cpp +12 -36
  20. {seqtree-0.7.0 → seqtree-1.0.1}/src/searcher.cpp +7 -11
  21. seqtree-1.0.1/src/text_index.cpp +734 -0
  22. seqtree-0.7.0/python/seqtree/pmhc_evalue.py +0 -33
  23. seqtree-0.7.0/src/_bindings.cpp +0 -673
  24. {seqtree-0.7.0 → seqtree-1.0.1}/.gitattributes +0 -0
  25. {seqtree-0.7.0 → seqtree-1.0.1}/.gitignore +0 -0
  26. {seqtree-0.7.0 → seqtree-1.0.1}/LICENSE +0 -0
  27. {seqtree-0.7.0 → seqtree-1.0.1}/ROADMAP.md +0 -0
  28. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/.gitignore +0 -0
  29. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/.latexmkrc +0 -0
  30. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/Makefile +0 -0
  31. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/epitope_detection.pdf +0 -0
  32. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/evalue.pdf +0 -0
  33. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/evalue.tex +0 -0
  34. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/evalue_matrix.pdf +0 -0
  35. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/mhc1_rocpr.pdf +0 -0
  36. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/mhc2_rocpr.pdf +0 -0
  37. {seqtree-0.7.0 → seqtree-1.0.1}/appendix/refs.bib +0 -0
  38. {seqtree-0.7.0 → seqtree-1.0.1}/examples/01_gapped_search.py +0 -0
  39. {seqtree-0.7.0 → seqtree-1.0.1}/examples/02_sequence_dendrogram.py +0 -0
  40. {seqtree-0.7.0 → seqtree-1.0.1}/examples/03_indel_positions.py +0 -0
  41. {seqtree-0.7.0 → seqtree-1.0.1}/examples/04_island_profile.py +0 -0
  42. {seqtree-0.7.0 → seqtree-1.0.1}/include/seqtree/kmer_index.hpp +0 -0
  43. {seqtree-0.7.0 → seqtree-1.0.1}/include/seqtree/seqtree.hpp +0 -0
  44. {seqtree-0.7.0 → seqtree-1.0.1}/include/seqtree/types.hpp +0 -0
  45. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  46. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/distance.py +0 -0
  47. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/gapblock.py +0 -0
  48. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/layout.py +0 -0
  49. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/pairwise.py +0 -0
  50. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/pmhc.py +0 -0
  51. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/py.typed +0 -0
  52. {seqtree-0.7.0 → seqtree-1.0.1}/python/seqtree/seeds.py +0 -0
  53. {seqtree-0.7.0 → seqtree-1.0.1}/src/atomic_write.hpp +0 -0
  54. {seqtree-0.7.0 → seqtree-1.0.1}/src/blosum45.inc +0 -0
  55. {seqtree-0.7.0 → seqtree-1.0.1}/src/blosum62.inc +0 -0
  56. {seqtree-0.7.0 → seqtree-1.0.1}/src/blosum80.inc +0 -0
  57. {seqtree-0.7.0 → seqtree-1.0.1}/src/codec.cpp +0 -0
  58. {seqtree-0.7.0 → seqtree-1.0.1}/src/engine_seqtm.cpp +0 -0
  59. {seqtree-0.7.0 → seqtree-1.0.1}/src/engine_seqtrie.cpp +0 -0
  60. {seqtree-0.7.0 → seqtree-1.0.1}/src/engines.hpp +0 -0
  61. {seqtree-0.7.0 → seqtree-1.0.1}/src/pam100.inc +0 -0
  62. {seqtree-0.7.0 → seqtree-1.0.1}/src/pam250.inc +0 -0
  63. {seqtree-0.7.0 → seqtree-1.0.1}/src/positional_matrix.cpp +0 -0
  64. {seqtree-0.7.0 → seqtree-1.0.1}/src/structural.inc +0 -0
  65. {seqtree-0.7.0 → seqtree-1.0.1}/src/substitution_matrix.cpp +0 -0
  66. {seqtree-0.7.0 → seqtree-1.0.1}/src/trie.cpp +0 -0
  67. {seqtree-0.7.0 → seqtree-1.0.1}/src/trie.hpp +0 -0
@@ -1,7 +1,223 @@
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.1] — 2026-10-09
7
+
8
+ ### Fixed
9
+
10
+ - A one-thread batch executes on the calling thread instead of launching a worker.
11
+ - Worker scratch-allocation exceptions propagate to the caller rather than terminating the process.
12
+ - A partially started worker pool joins its running workers if thread creation fails.
13
+
14
+ ## [1.0.0] — 2026-09-06
15
+
16
+ ### Added
17
+
18
+ - **`TextIndex` — exact k-mismatch search over a concatenated reference text, with one index
19
+ for every query length.** `Index` builds a trie over reference *strings*, so asking a text
20
+ question with it means enumerating every length-`L` window of the text as its own string —
21
+ and again for every distinct query length. The human proteome has **68,389,335** nine-mer
22
+ windows, so one such index costs several gigabytes; a query set spanning **45 distinct
23
+ lengths** costs 45 of them. That is what ran **> 2 h 10 m without finishing** and filled
24
+ **225 GB** of index cache downstream. `TextIndex` keys on a k-mer seed table over the flat
25
+ text, so **`k` belongs to the index, not to the query**: one build answers every length and
26
+ every `max_subs`.
27
+
28
+ ```python
29
+ ix = TextIndex.build(records, alphabet="aa", k=4, group_ids=gene_id_per_record)
30
+ res = ix.search_batch(peptides, max_subs=2, best_only=True, group_by=True, threads=0)
31
+ ```
32
+
33
+ **One search scheme**, not a special case per query shape. Split the query into
34
+ `b = min(max_subs + 1, L // k)` disjoint blocks of width `L // b >= k`, and probe block `j`'s
35
+ leading k-mer at radius `c_j` — every k-mer within `c_j` substitutions of it. That is
36
+ **lossless exactly when `sum(c_j) >= max_subs - b + 1`**: the lightest error vector that could
37
+ evade every block is `e_j = c_j + 1`, of weight `sum(c_j) + b`, so nothing within `max_subs`
38
+ can hide below that threshold. Probing at radius `c` costs `N(c) = sum(C(k,i)(A-1)^i, i <= c)`,
39
+ whose increments climb steeply and are the same for every block, so the cheapest legal scheme
40
+ takes as many blocks as fit and spreads `r = max(0, max_subs - b + 1)` budget units evenly.
41
+ `b = max_subs + 1` recovers plain pigeonhole (one exact seed per error) and `b = 1` a single
42
+ substitution ball over `q[0:k]`; **the useful schemes are in between**, which is where short
43
+ queries live: at `L = 8, max_subs = 2, k = 4` two disjoint 4-mers fit, so `c = (0, 1)` probes
44
+ **94** variants where a one-block ball probes **3,267**.
45
+
46
+ Two details are not incidental. Spare budget units go on the **last** blocks, worth a further
47
+ **1.4x**: a candidate already matches its own block's k-mer, so left-to-right verification
48
+ meets the unconstrained prefix first and rejects after a residue or two — and the high-budget
49
+ block contributes nearly all the candidates. And a start that two blocks both reach is
50
+ returned **once**, emitted by the lowest-indexed block that could have produced it, a test read
51
+ straight off the mismatch positions verification has already computed — so deduplication needs
52
+ no sort and no hash set, which also removed the candidate sort from the all-exact path.
53
+
54
+ The framing is the search scheme of Kianfar, Pockrandt, Torkamandi, Luo & Reinert,
55
+ *Optimum Search Schemes for Approximate String Matching Using Bidirectional FM-Index*,
56
+ [arXiv:1711.02035](https://doi.org/10.48550/arXiv.1711.02035). Only the partition-and-budget
57
+ half transfers: a direct-addressed seed table has no bidirectional extension, so there is no
58
+ search *order* to optimise over.
59
+
60
+ **None of this is a heuristic**, and that is checked rather than asserted: `tests/cpp/
61
+ test_text_index.cpp` compares against an independent brute-force scan over query length 6–30
62
+ × `max_subs` 0–3 × `k` ∈ {3, 4, 5}, requiring **set equality** of `(ref_id, offset, n_subs)`
63
+ *and* of the mismatch detail — not merely that hits were found — plus a case straddling every
64
+ block boundary, and a separate case asserting the answer is *identical* across `k`, so a
65
+ dispatch bug cannot pass by agreeing with itself. `bench/bench_text_index.py` repeats the
66
+ `k`-invariance check on the real proteomes, where hit counts match cell for cell. A missed hit
67
+ does not degrade the caller's answer, it changes it, from ambiguous to confidently wrong.
68
+
69
+ Measured on the human proteome (UP000005640, 147,506 records / **69,578,135 residues**), one
70
+ thread, Apple M-series, `bench/bench_text_index.py`. The last column is the two-path dispatch
71
+ this scheme replaced, on the same benchmark:
72
+
73
+ | `L` | `max_subs` | `k = 4` | `k = 5` | two-path dispatch |
74
+ |--:|--:|--:|--:|--:|
75
+ | 8 | 2 | **1.468** | 4.694 | 34.505 |
76
+ | 9 | 2 | **1.334** | 4.526 | 35.453 |
77
+ | 10 | 2 | 1.298 | **0.129** | 40.940 |
78
+ | 11 | 2 | 1.138 | **0.108** | 30.196 |
79
+ | 12 | 2 | **0.097** | 0.126 | 0.148 |
80
+ | 10 | 3 | 3.595 | **0.347** | 423.762 |
81
+ | 12 | 3 | 1.567 | **0.394** | 415.578 |
82
+ | 15 | 3 | 1.514 | **0.138** | 400.484 |
83
+
84
+ ms/query, single-threaded, and hit counts identical across `k` in every cell — the benchmark
85
+ asserts that rather than reporting it. So **24–32x at `L = 8–11, max_subs = 2`, and 87–1,221x
86
+ at `max_subs = 3`**. Against the design document's acceptance targets of <= 0.5 ms/query at
87
+ `L >= 12` and <= 1.0 at `L = 8–11`: with `k` chosen by the `L = 2k` crossover, **every target
88
+ from length 10 up is met with an order of magnitude of margin**, and lengths 8–9 land at
89
+ 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
90
+ `k = 5`, so a corpus spanning both regimes can hold both indexes.
91
+
92
+ Threads, human proteome, `L = 12`, `max_subs = 2`, 50,000 queries: 0.103 ms/query on one,
93
+ 0.013 on eight (**7.8x**), 0.008 on all cores (**12.6x**). The ratio is now bounded by the
94
+ serial CSR flatten rather than by the search, which is why it is short of linear; the flatten
95
+ preallocates from the workers' actual output instead of growing nine vectors by doubling.
96
+
97
+ Results come back as flat CSR arrays rather than one object per hit — a 445 k-query run
98
+ returns a handful of arrays. `res[i]` materialises `TextHit` objects for one query on demand,
99
+ `res.arrays()` gives zero-copy buffer views, and `res.to_numpy()` gives numpy views, with
100
+ numpy imported **on the call** so seqtree keeps its empty dependency list. Order is
101
+ `(n_subs, ref_id, offset)`, stable across runs and across thread counts.
102
+
103
+ Beyond a position: each mismatch is the **pair** `(pos, query_aa, text_aa)`, so a caller
104
+ ranking by chemistry can tell `L→I` from `L→D` without re-fetching the window. `matrix=`
105
+ attaches a substitution score to a hit the Hamming predicate has already accepted and never
106
+ changes which hits return. `best_only=True` walks the distance upward, stops at the first
107
+ non-empty shell and returns **all** of it, from one index. `group_ids` labels records with
108
+ arbitrary integers — gene ids, species, clusters — and `group_by=True` folds hits onto them,
109
+ which makes **a tie between nearest parents a first-class output** rather than something each
110
+ caller re-derives. `max_hits` caps a query *after* sorting, so the best hits survive, and
111
+ always sets `res.truncated[i]`: a cap that is invisible is a recall bug wearing a performance
112
+ costume.
113
+
114
+ Saved files are flat with their arrays at known aligned offsets, so `TextIndex.load(path)`
115
+ **maps** rather than parses and several processes share one copy of the pages — the human
116
+ `k = 4` index is **348.2 MB** and maps in **0.1 ms** regardless (0.12 s to read it instead,
117
+ 0.46 s to save it). That is 5.0x the text, and 79 % of it is `post_ids`: one `uint32` per
118
+ in-record k-mer start, every one of which has to be addressable for the answer to be exact.
119
+ A `.sti` is treated as untrusted input — the header's record, bucket and posting counts are
120
+ cross-checked against each other and against the file length before any of them sizes an
121
+ allocation or indexes into the mapping.
122
+
123
+ - **The alphabet is handled asymmetrically, on purpose.** A residue outside the codec in the
124
+ **text** — 36 `U` in the human proteome, 33 in mouse — becomes a hole that no seed spans and
125
+ no hit crosses, and is counted in `ix.num_unknown`; refusing the build over them would make
126
+ the class unusable on the very reference data it exists for. The same residue in a **query**
127
+ raises, naming the query: `queries[42] ('CASSUGQYF'): symbol 'U' is not in the alphabet`.
128
+ `B`/`Z`/`X`/`*` are real symbols, not wildcards — `X` matches only `X`. The hole marker is
129
+ also what separates records, so a hit spanning a record boundary is structurally impossible
130
+ rather than prevented by a check that could be forgotten.
131
+
132
+ - **Limits that would otherwise be silent are now refused.** A query longer than 65,535 residues
133
+ raises rather than wrapping its 16-bit mismatch positions. A `.sti` whose header disagrees with
134
+ itself or with the file length is rejected before any field sizes an allocation or indexes into
135
+ a mapping — on the `mmap` path there is no short read to catch it, so it has to be explicit.
136
+ `TextIndex.ref_seq` documents that it renders an out-of-alphabet residue as `'X'`, which is
137
+ lossy and where `'X'` is itself a real symbol: it reports what the index holds, not what was
138
+ handed to `build`. And the seed table's two counting-sort passes now assert that they visited
139
+ the same positions, since a disagreement would silently overrun a bucket into its neighbour.
140
+
141
+ - **`TextIndex.search_batch` no longer terminates the process on an allocation failure.** Its
142
+ workers were exempted from the tree's `exception_ptr` plumbing on the grounds that queries are
143
+ validated up front — true, but they still allocate, and a `std::bad_alloc` escaping a
144
+ `std::thread` entry function calls `std::terminate`, which is an uncatchable `SIGABRT` from
145
+ Python. It now captures and rethrows after the join, like every other batch entry point.
146
+
147
+ - **`_core.pyi`.** The package has shipped `py.typed` since the beginning while providing no
148
+ stubs at all, so every C++ symbol resolved to `Any`. nanobind generates them at build time.
149
+
150
+ ### Changed
151
+
152
+ - **The bindings moved from pybind11 to nanobind.** Smaller module, lower per-call overhead,
153
+ and stub generation. Externally visible behaviour is unchanged, including the two things that
154
+ needed work to keep: `ScoreMatrix`'s buffer protocol, which nanobind has no equivalent for
155
+ (`Py_bf_getbuffer` is now wired by hand via `nb::type_slots`, so `numpy.asarray(sm)` and
156
+ `memoryview(sm)` still both wrap it without copying), and the exception mapping that
157
+ `test_pairwise.py` pins by message text. nanobind's implicit-conversion pass was checked
158
+ rather than assumed: `threads=1.0` still raises `TypeError` rather than truncating, so none
159
+ of the ~60 scalar arguments needed `noconvert`.
160
+
161
+ - **`Development Status` is now `5 - Production/Stable`**, and from this release the project
162
+ follows semantic versioning — breaking changes need a major bump. The `docs` extra no longer
163
+ lists `nbsphinx`, which was in neither `docs/conf.py` nor `docs/requirements.txt`.
164
+
165
+ ### Audit pass before release
166
+
167
+ A code and documentation audit run against the finished 1.0.0 tree. Nothing here changes an API:
168
+ it removes duplication, documents what was already public, and corrects two doc claims that no
169
+ longer matched the code.
170
+
171
+ #### Documentation and API surface
172
+
173
+ - **`engine="auto"` is documented correctly.** `docs/engines.rst` claimed `auto` "chooses per
174
+ query" and "routes matrix-weighted budgets to `seqtrie`". It has never done either since the
175
+ dispatch was removed: `auto` always resolves to `seqtm`, because `seqtrie` ignores the
176
+ per-type caps and would silently widen the ball. A reader following the old text and writing
177
+ `SearchParams(matrix="blosum62", max_penalty=12)` got **1 hit where naming `seqtrie` gives 3**,
178
+ with nothing in the result to say why. The page now says so and shows both.
179
+ - **`gap_open` in the first example a reader meets.** `docs/getting-started.rst` and the README
180
+ Quickstart paired BLOSUM62 with `gap_open=8`, against the library's own rule of
181
+ `2 * matrix.scale()` = **28** for BLOSUM62. At 8 a gap is cheaper than a substitution, which is
182
+ the exact failure the rule exists to prevent.
183
+ - **README leads with install and a runnable Quickstart**, then a "Which piece do I need?" routing
184
+ table, then the feature detail — which previously ran ~90 lines before the first `pip install`.
185
+ The Quickstart gained `TextIndex`, which the feature list advertised but no example used.
186
+ - **`docs/api.rst` is grouped by task** rather than one flat section per class: searching a set,
187
+ searching a text, scoring, dense matrices, seed-and-extend, significance. `ScoreMatrix` was
188
+ public, exported and referenced from `docs/distance.rst` but had **no entry on the API page at
189
+ all**; it has one now. Page titles that described a comparison rather than a capability
190
+ ("Pairwise alignment without BioPython") or over-narrowed a payload-agnostic feature
191
+ ("E-values for TCR hits") were renamed, and the toctree order now runs
192
+ install → concepts → examples → reference → capabilities.
193
+
194
+ #### Fixed in the same pass
195
+
196
+ - **59 members of the C++ binding surface had no docstring** — every field of `SearchParams`,
197
+ `Hit`, `Alignment`, `TextHit` and `Candidate`, and most `SubstitutionMatrix` factories. `api.rst`
198
+ autodocs with `:undoc-members:`, so each rendered as a bare name rather than failing the build.
199
+ All 59 are documented, and `tests/python/test_doc_coverage.py` now gates the `_core` classes the
200
+ way it already gated the Python modules — plus a test that executes the README Quickstart
201
+ verbatim.
202
+ - **`ArrayView.format` was a `static thread_local char[2]` scratch buffer** that each `getbuffer`
203
+ overwrote, so two live views of different dtypes on one thread both reported the last format.
204
+ It is a per-instance member now.
205
+
206
+ #### Internal
207
+
208
+ - **One `parallel_for` replaces seven hand-rolled copies** of the atomic cursor / adaptive chunk /
209
+ `exception_ptr` / rethrow-after-join scaffold (`include/seqtree/parallel.hpp`). The copies had
210
+ drifted: `gapblock.cpp` carried no exception plumbing at all, and the chunk cap was 1024 in four
211
+ places and 256 in a fifth. Per-worker scratch is still allocated once per thread, via a
212
+ `make_local` factory. No measured change: perf gate within 1.11x on every metric, gapblock
213
+ `score_matrix` 481–521 M pairs/s across three runs against the 532.7 M on record.
214
+ - **`psutil` is out of the `[bench]` extra.** Two of the six copies of peak-RSS used it, and
215
+ reported *current* RSS rather than peak — a different quantity from the four stdlib `resource`
216
+ copies beside them. One `bench/_common.py` now holds `peak_rss_mb`, `peak_rss_gb` and `mutate`.
217
+ - `seqtree.evalue.evalue_result` is public: the six-key E-value record, shared by `evalues` and
218
+ `pmhc_evalue.homolog_evalue`, which had rebuilt it from the identical formula.
219
+ - `control._NoLock` is `contextlib.nullcontext`; `searcher.cpp`'s single-caller `pick_engine()` is
220
+ inlined to `p.engine != Engine::SeqTrie`.
5
221
 
6
222
  ## [0.7.0] — 2026-08-16
7
223
 
@@ -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,8 @@ 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
68
+ tests/cpp/test_parallel.cpp
55
69
  )
56
70
  target_include_directories(seqtree_tests PRIVATE tests/cpp src)
57
71
  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.7.0
3
+ Version: 1.0.1
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).
@@ -106,51 +176,14 @@ Beyond search, seqtree ships:
106
176
  min-over-members; at a loose cutoff the two are indistinguishable, so it earns its keep only
107
177
  where the cutoff is strict.
108
178
 
109
- ## Install
110
-
111
- ```fish
112
- pip install seqtree # prebuilt wheels for CPython 3.10–3.13
113
- ```
114
-
115
- Prebuilt wheels cover **Linux x86-64**, **macOS arm64 (Apple Silicon)**, and **Windows x86-64**.
116
- There are **no Intel/x86-64 macOS wheels** — Intel Macs build from source (see below), which just
117
- needs a C++17 compiler and CMake (pulled in automatically by the build).
118
-
119
- ## Build from source
120
-
121
- Needs [uv](https://docs.astral.sh/uv/) (`brew install uv`); `setup.sh` uses it for the venv and
122
- the editable install.
123
-
124
- ```fish
125
- bash setup.sh # uv-managed .venv + editable install
126
- bash setup.sh --tests # + pytest
127
- bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
128
- ```
129
-
130
- ## Quickstart
179
+ ## More examples
131
180
 
132
181
  ```python
182
+ import numpy as np
133
183
  import seqtree
184
+ from seqtree.pairwise import align, score, dist_matrix
134
185
 
135
- idx = seqtree.Index.build(["CASSLAPGATNEKLFF", "CASSLELGATNEKLFF"], alphabet="aa")
136
-
137
- p = seqtree.SearchParams(max_subs=2, engine="seqtm")
138
- for hit in idx.search("CASSLAPGATNEKLFF", p):
139
- print(hit.ref_id, hit.score, hit.n_subs)
140
-
141
- # parallel batch (releases the GIL)
142
- results = idx.search_batch(queries, p, threads=0) # 0 = all cores
143
-
144
- # matrix-weighted budget
145
- pm = seqtree.SearchParams(matrix="BLOSUM62", max_penalty=12, engine="seqtrie")
146
- top = idx.search_top("CASSLAPGATNEKLFF", pm, k=5)
147
-
148
- # alignment on demand
149
- aln = idx.align(0, "CASSLELGATNEKLFF", p)
150
- print(aln.aligned_query, aln.aligned_ref, aln.ops)
151
-
152
- # batch-vs-batch (auto-indexes the larger set)
153
- pairs = seqtree.pairwise_batch(query_set, db_set, p, alphabet="aa")
186
+ mat = seqtree.SubstitutionMatrix.blosum62()
154
187
 
155
188
  # E-values against a background control repertoire (TCRNET-style significance)
156
189
  control = seqtree.load_control("human_trb_aa", size=1_000_000)
@@ -167,7 +200,6 @@ thetas = seqtree.threshold_for_evalue(target, control, queries, ceiling, e_targe
167
200
  from seqtree.gapblock import GapBlockIndex, central_prior, embed_in_frame
168
201
 
169
202
  gbi = GapBlockIndex(cdr3s, "aa", d_max=2)
170
- mat = seqtree.SubstitutionMatrix.blosum62()
171
203
  for ref_id, score, block_len, block_pos in gbi.search(
172
204
  "CASSLGQAYEQYF", 40, mat, gap_open=2 * mat.scale(),
173
205
  gap_prior=central_prior(int(1.5 * mat.scale()))):
@@ -179,15 +211,13 @@ embed_in_frame("CASSGQAYEQYF", width=14, c=4) # 'CASS--GQAYEQYF'
179
211
  # a whole query set vs a whole reference set, in one GIL-released C++ call
180
212
  from seqtree.gapblock import score_matrix, IslandProfile
181
213
  sm = score_matrix(clonotypes, prototypes, mat, gap_open=2 * mat.scale(), threads=0)
182
- import numpy as np
183
- distances = np.asarray(sm) # (len(clonotypes), len(prototypes)) int32, zero-copy
214
+ distances = np.asarray(sm) # (n_clonotypes, n_prototypes) int32, zero-copy
184
215
 
185
216
  # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
186
217
  profile = IslandProfile.fit(island_members)
187
218
  profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
188
219
 
189
220
  # ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
190
- from seqtree.pairwise import align, score, dist_matrix
191
221
  score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
192
222
  score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
193
223
  score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
@@ -209,6 +239,17 @@ for variant in neighbourhood_union(junctions, r=1): # each distinct seq
209
239
  ...
210
240
  ```
211
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)
251
+ ```
252
+
212
253
  ## Tests
213
254
 
214
255
  ```fish