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.
- {seqtree-0.6.1 → seqtree-1.0.0}/CHANGELOG.md +256 -2
- {seqtree-0.6.1 → seqtree-1.0.0}/CMakeLists.txt +16 -3
- {seqtree-0.6.1 → seqtree-1.0.0}/PKG-INFO +106 -54
- {seqtree-0.6.1 → seqtree-1.0.0}/README.md +104 -50
- {seqtree-0.6.1 → seqtree-1.0.0}/SOURCES.md +15 -0
- seqtree-1.0.0/include/seqtree/parallel.hpp +70 -0
- seqtree-1.0.0/include/seqtree/text_index.hpp +108 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/pyproject.toml +7 -5
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/__init__.py +19 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/control.py +2 -11
- seqtree-1.0.0/python/seqtree/distance.py +282 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/evalue.py +34 -14
- seqtree-1.0.0/python/seqtree/pmhc_evalue.py +25 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/skills/seqtree/SKILL.md +84 -2
- seqtree-1.0.0/src/_bindings.cpp +1084 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/distance.cpp +5 -29
- {seqtree-0.6.1 → seqtree-1.0.0}/src/gapblock.cpp +17 -29
- {seqtree-0.6.1 → seqtree-1.0.0}/src/index.cpp +9 -69
- {seqtree-0.6.1 → seqtree-1.0.0}/src/kmer_index.cpp +35 -60
- {seqtree-0.6.1 → seqtree-1.0.0}/src/pairwise.cpp +12 -36
- {seqtree-0.6.1 → seqtree-1.0.0}/src/searcher.cpp +7 -11
- seqtree-1.0.0/src/text_index.cpp +734 -0
- seqtree-0.6.1/python/seqtree/distance.py +0 -119
- seqtree-0.6.1/python/seqtree/pmhc_evalue.py +0 -33
- seqtree-0.6.1/src/_bindings.cpp +0 -673
- {seqtree-0.6.1 → seqtree-1.0.0}/.gitattributes +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/.gitignore +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/LICENSE +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/ROADMAP.md +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/.gitignore +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/.latexmkrc +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/Makefile +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/epitope_detection.pdf +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/evalue.pdf +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/evalue.tex +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/evalue_matrix.pdf +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/mhc1_rocpr.pdf +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/mhc2_rocpr.pdf +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/appendix/refs.bib +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/examples/01_gapped_search.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/examples/02_sequence_dendrogram.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/examples/03_indel_positions.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/examples/04_island_profile.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/include/seqtree/kmer_index.hpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/include/seqtree/seqtree.hpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/include/seqtree/types.hpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/gapblock.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/layout.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/pairwise.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/pmhc.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/py.typed +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/python/seqtree/seeds.py +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/atomic_write.hpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/blosum45.inc +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/blosum62.inc +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/blosum80.inc +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/codec.cpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/engine_seqtm.cpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/engine_seqtrie.cpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/engines.hpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/pam100.inc +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/pam250.inc +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/positional_matrix.cpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/structural.inc +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/substitution_matrix.cpp +0 -0
- {seqtree-0.6.1 → seqtree-1.0.0}/src/trie.cpp +0 -0
- {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
|
|
4
|
-
|
|
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
|
|
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(
|
|
39
|
-
|
|
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.
|
|
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 ::
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|