seqtree 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. {seqtree-0.3.0 → seqtree-0.4.0}/CHANGELOG.md +100 -3
  2. {seqtree-0.3.0 → seqtree-0.4.0}/CMakeLists.txt +1 -0
  3. {seqtree-0.3.0 → seqtree-0.4.0}/PKG-INFO +19 -2
  4. {seqtree-0.3.0 → seqtree-0.4.0}/README.md +17 -1
  5. {seqtree-0.3.0 → seqtree-0.4.0}/include/seqtree/seqtree.hpp +61 -2
  6. {seqtree-0.3.0 → seqtree-0.4.0}/pyproject.toml +4 -2
  7. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/__init__.py +2 -1
  8. seqtree-0.4.0/python/seqtree/control.py +321 -0
  9. seqtree-0.4.0/python/seqtree/pairwise.py +167 -0
  10. {seqtree-0.3.0 → seqtree-0.4.0}/skills/seqtree/SKILL.md +29 -1
  11. {seqtree-0.3.0 → seqtree-0.4.0}/src/_bindings.cpp +103 -3
  12. seqtree-0.4.0/src/atomic_write.hpp +75 -0
  13. seqtree-0.4.0/src/blosum45.inc +32 -0
  14. seqtree-0.4.0/src/blosum80.inc +32 -0
  15. {seqtree-0.3.0 → seqtree-0.4.0}/src/index.cpp +26 -19
  16. {seqtree-0.3.0 → seqtree-0.4.0}/src/kmer_index.cpp +26 -21
  17. seqtree-0.4.0/src/pairwise.cpp +354 -0
  18. {seqtree-0.3.0 → seqtree-0.4.0}/src/substitution_matrix.cpp +22 -0
  19. seqtree-0.3.0/python/seqtree/control.py +0 -185
  20. {seqtree-0.3.0 → seqtree-0.4.0}/.gitattributes +0 -0
  21. {seqtree-0.3.0 → seqtree-0.4.0}/.gitignore +0 -0
  22. {seqtree-0.3.0 → seqtree-0.4.0}/LICENSE +0 -0
  23. {seqtree-0.3.0 → seqtree-0.4.0}/ROADMAP.md +0 -0
  24. {seqtree-0.3.0 → seqtree-0.4.0}/SOURCES.md +0 -0
  25. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/.gitignore +0 -0
  26. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/.latexmkrc +0 -0
  27. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/Makefile +0 -0
  28. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/epitope_detection.pdf +0 -0
  29. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/evalue.pdf +0 -0
  30. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/evalue.tex +0 -0
  31. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/evalue_matrix.pdf +0 -0
  32. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/mhc1_rocpr.pdf +0 -0
  33. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/mhc2_rocpr.pdf +0 -0
  34. {seqtree-0.3.0 → seqtree-0.4.0}/appendix/refs.bib +0 -0
  35. {seqtree-0.3.0 → seqtree-0.4.0}/examples/01_gapped_search.py +0 -0
  36. {seqtree-0.3.0 → seqtree-0.4.0}/examples/02_sequence_dendrogram.py +0 -0
  37. {seqtree-0.3.0 → seqtree-0.4.0}/examples/03_indel_positions.py +0 -0
  38. {seqtree-0.3.0 → seqtree-0.4.0}/examples/04_island_profile.py +0 -0
  39. {seqtree-0.3.0 → seqtree-0.4.0}/include/seqtree/kmer_index.hpp +0 -0
  40. {seqtree-0.3.0 → seqtree-0.4.0}/include/seqtree/types.hpp +0 -0
  41. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  42. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/evalue.py +0 -0
  43. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/gapblock.py +0 -0
  44. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/layout.py +0 -0
  45. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/pmhc.py +0 -0
  46. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/pmhc_evalue.py +0 -0
  47. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/py.typed +0 -0
  48. {seqtree-0.3.0 → seqtree-0.4.0}/python/seqtree/seeds.py +0 -0
  49. {seqtree-0.3.0 → seqtree-0.4.0}/src/blosum62.inc +0 -0
  50. {seqtree-0.3.0 → seqtree-0.4.0}/src/codec.cpp +0 -0
  51. {seqtree-0.3.0 → seqtree-0.4.0}/src/engine_seqtm.cpp +0 -0
  52. {seqtree-0.3.0 → seqtree-0.4.0}/src/engine_seqtrie.cpp +0 -0
  53. {seqtree-0.3.0 → seqtree-0.4.0}/src/engines.hpp +0 -0
  54. {seqtree-0.3.0 → seqtree-0.4.0}/src/gapblock.cpp +0 -0
  55. {seqtree-0.3.0 → seqtree-0.4.0}/src/pam100.inc +0 -0
  56. {seqtree-0.3.0 → seqtree-0.4.0}/src/pam250.inc +0 -0
  57. {seqtree-0.3.0 → seqtree-0.4.0}/src/positional_matrix.cpp +0 -0
  58. {seqtree-0.3.0 → seqtree-0.4.0}/src/searcher.cpp +0 -0
  59. {seqtree-0.3.0 → seqtree-0.4.0}/src/structural.inc +0 -0
  60. {seqtree-0.3.0 → seqtree-0.4.0}/src/trie.cpp +0 -0
  61. {seqtree-0.3.0 → seqtree-0.4.0}/src/trie.hpp +0 -0
@@ -3,6 +3,101 @@
3
3
  All notable changes to `seqtree`. Dates are release dates; the project is pre-1.0, so a **minor**
4
4
  bump may carry breaking changes.
5
5
 
6
+ ## [0.4.0] — 2026-07-11
7
+
8
+ ### Added
9
+
10
+ - **`seqtree.pairwise` — Needleman-Wunsch and Smith-Waterman, so ordinary protein alignment no
11
+ longer needs BioPython.** Everything else in seqtree *minimises a non-negative penalty*, which
12
+ is what a search ball and an E-value need. These **maximise a raw log-odds similarity**, the way
13
+ BLAST and BioPython do, because that is what a pairwise alignment means.
14
+
15
+ | | |
16
+ |---|---|
17
+ | `pairwise.score(q, r, matrix, mode=...)` | optimal score, `O(min(m,n))` memory |
18
+ | `pairwise.align(...)` | plus the aligned strings and ops |
19
+ | `pairwise.score_matrix(queries, refs, ...)` | dense `n × K`, GIL released, zero-copy numpy |
20
+ | `pairwise.dist_matrix(...)` | `d = s(a,a) + s(b,b) − 2·s(a,b)`: non-negative, zero on the diagonal |
21
+
22
+ `mode="global"` is Needleman-Wunsch, `mode="local"` Smith-Waterman, and **`gap_open == gap_extend`
23
+ gives linear gaps** — no separate mode. A gap run of length `L` costs `gap_open + (L-1)·gap_extend`,
24
+ and global charges end gaps (true NW, not semi-global).
25
+
26
+ **It is a drop-in.** `tests/python/test_pairwise.py` runs it against `Bio.Align.PairwiseAligner`
27
+ as an oracle over three matrices × ten gap/mode settings × sixty sequence shapes — **zero
28
+ disagreements**, including on real germline V genes. BioPython is a *test-only* dependency;
29
+ seqtree still has **zero required runtime dependencies** and never imports it.
30
+
31
+ Measured on an M3 (all-against-all, BLOSUM62, global, 11/1):
32
+
33
+ | sequence length | seqtree, 1 thread | seqtree, 16 threads | BioPython | speedup |
34
+ |---|---|---|---|---|
35
+ | 15 (a junction) | 1.7 M pairs/s | **20.1 M pairs/s** | 0.31 M pairs/s | **65×** |
36
+ | 90 (a germline V gene) | 72 k pairs/s | **893 k pairs/s** | 10 k pairs/s | **87×** |
37
+
38
+ - **`SubstitutionMatrix.similarity(a, b)`** — the raw signed log-odds, alongside the existing
39
+ non-negative `penalty(a, b)`. The Gram transform `pen = s(a,a) + s(b,b) − 2·s(a,b)` is **lossy**:
40
+ it forces the diagonal to zero and destroys `s(a,a)`, so a similarity cannot be recovered from a
41
+ penalty. Both views are now stored.
42
+
43
+ - **`SubstitutionMatrix.blosum45()` and `.blosum80()`**, and the names `"BLOSUM45"` / `"BLOSUM80"`
44
+ wherever a matrix name is accepted. Shallower and deeper than BLOSUM62 — for remote and close
45
+ homologs respectively.
46
+
47
+ ## [0.3.1] — 2026-07-10 (never published; folded into 0.4.0)
48
+
49
+ > Tagged but not released to PyPI. Everything below ships in **0.4.0**, so upgrading from 0.3.0
50
+ > straight to 0.4.0 picks it all up. Kept as its own section because it is a distinct set of fixes.
51
+
52
+
53
+ ### Fixed
54
+
55
+ - **A cold cache shared by concurrent processes could hand back a half-written index.**
56
+ `Index::save` wrote straight into the destination, so for the whole duration of the write the
57
+ file existed but was truncated. A second process that checked `os.path.exists(cache)` in that
58
+ window loaded a stub and raised `RuntimeError: truncated or corrupt index`. On a 45 MB control
59
+ index the window is ~55 ms, and a reader racing a writer hit it **10 times out of 10**.
60
+
61
+ This is the first-use-only failure of any multi-process fan-out sharing `~/.cache`: pytest-xdist,
62
+ a Snakemake or Nextflow pipeline calling `load_control` in parallel, a `multiprocessing` pool.
63
+ Once the cache is warm it is read-only and was always safe. CI matrix jobs were never affected —
64
+ separate runners, separate caches.
65
+
66
+ `Index::save` and `KmerIndex::save` now serialize into a uniquely-named temporary beside the
67
+ destination and `rename` it into place. Rename is atomic on the same filesystem, on POSIX and
68
+ Windows alike, so a reader sees either the previous complete file or the new complete file and
69
+ never a partial one. A failed save cleans up its temporary and leaves any pre-existing index
70
+ intact.
71
+
72
+ - **A corrupt or stale cache now rebuilds instead of raising.** A file truncated by a full disk,
73
+ left by a killed process, or written by an older seqtree sent `load_control` into an exception;
74
+ it now falls back to rebuilding. The cache was always best-effort and now behaves that way.
75
+
76
+ - **The control cache is content-addressed, so a stale cache can no longer be served silently.**
77
+ The key was `control_{name}_{size}.sqtree`, which named neither the **alphabet**, nor the
78
+ **seed**, nor the **source data**. Three consequences, all live:
79
+
80
+ - Two calls differing only in `seed` — which must draw *different* reservoir samples — shared one
81
+ cache file, so the second silently received the first's sequences. Same for `alphabet`.
82
+ - An upgrade that changed the bundled control kept the same filename, so a warm cache served the
83
+ **previous release's** control. This is exactly how 0.3.0's corrected (uniform) control could be
84
+ masked by a stale 0.2.0 (abundance-head) cache — and why 0.3.0's notes had to ask people to
85
+ `rm ~/.cache/seqtree/control_*.sqtree` by hand.
86
+
87
+ The key now carries a fingerprint of the bundled asset's own bytes (or, on the download path, the
88
+ source and seed), so a control that changed simply misses the old cache. Superseded caches,
89
+ including pre-fingerprint ones from earlier releases, are deleted on the next build.
90
+
91
+ **You no longer need to clear `~/.cache/seqtree` when upgrading.** Doing so is harmless.
92
+
93
+ ### Added
94
+
95
+ - **`load_control` takes an inter-process lock around build-and-save when `filelock` is available**
96
+ (it arrives with `huggingface_hub`). This is an optimisation, not the fix: correctness comes from
97
+ the atomic rename and holds with no lock at all. What the lock saves is work — without it, a cold
98
+ fan-out of N workers has every worker build the same 250k-clonotype index and discard N−1 of them.
99
+ seqtree still has **zero required runtime dependencies**; the import is guarded.
100
+
6
101
  ## [0.3.0] — 2026-07-10
7
102
 
8
103
  Gap-block alignment, calibrated cutoffs, seed significance — the removal of several engine paths
@@ -20,9 +115,11 @@ E-value.
20
115
  (mean n_C 110.1 vs 35.5). Both are now uniform reservoir samples over unique **productive**
21
116
  clonotypes, seeded and shuffled so any prefix is itself a valid sub-sample.
22
117
 
23
- **Every E-value moves.** Delete `~/.cache/seqtree/control_*.sqtree` after upgrading. Numbers
24
- derived from the control are corrected throughout this file, `seeds.py`, `SKILL.md` and the
25
- appendix.
118
+ **Every E-value moves.** Delete `~/.cache/seqtree/control_*.sqtree` after upgrading — a warm
119
+ cache from 0.2.0 would otherwise be served in place of the corrected control. (Fixed in 0.3.1:
120
+ the cache is now content-addressed and a stale one simply misses. Upgrading straight from 0.2.0
121
+ to ≥0.3.1 needs no manual step.) Numbers derived from the control are corrected throughout this
122
+ file, `seeds.py`, `SKILL.md` and the appendix.
26
123
  - **Controls are filtered to productive clonotypes.** VDJtools marks out-of-frame rearrangements
27
124
  with `_` and in-frame stops with `*`; 13.7% of the mouse TRB table is out of frame. `_` cannot be
28
125
  repaired at the amino-acid level — VDJtools collapses a *run* of untranslatable positions into one
@@ -28,6 +28,7 @@ add_library(seqtree_core STATIC
28
28
  src/engine_seqtrie.cpp
29
29
  src/searcher.cpp
30
30
  src/gapblock.cpp
31
+ src/pairwise.cpp
31
32
  )
32
33
  target_include_directories(seqtree_core PUBLIC include PRIVATE src)
33
34
  target_link_libraries(seqtree_core PUBLIC Threads::Threads)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: seqtree
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Fast fuzzy search over biological sequences (C++ core, Python bindings)
5
5
  Keywords: sequence-search,fuzzy-matching,CDR3,immunology,bioinformatics,trie
6
6
  Author-Email: ISALGO laboratory <mikhail.shugay@gmail.com>
@@ -18,6 +18,7 @@ Requires-Python: >=3.10
18
18
  Provides-Extra: test
19
19
  Requires-Dist: pytest>=7.4; extra == "test"
20
20
  Requires-Dist: pytest-cov>=4.1; extra == "test"
21
+ Requires-Dist: biopython>=1.81; extra == "test"
21
22
  Provides-Extra: bench
22
23
  Requires-Dist: huggingface_hub; extra == "bench"
23
24
  Requires-Dist: psutil; extra == "bench"
@@ -59,7 +60,7 @@ to their own payloads (V gene, MHC, counts) and filter.
59
60
 
60
61
  Beyond search, seqtree ships:
61
62
 
62
- - **Substitution matrices** — built-in `identity`, `BLOSUM62`, `PAM250`, `PAM100`, and `structural`
63
+ - **Substitution matrices** — built-in `identity`, `BLOSUM45`, `BLOSUM62`, `BLOSUM80`, `PAM250`, `PAM100`, and `structural`
63
64
  — a **Miyazawa–Jernigan interaction-strength** matrix: each residue's strength `q(a)=mean_b e(a,b)`
64
65
  is read off the MJ contact potential, so substitutions between residues of like interaction strength
65
66
  are cheap. It separates strong (hydrophobic `F W C L Y M I V`) from weak (polar/charged
@@ -81,6 +82,13 @@ Beyond search, seqtree ships:
81
82
  sequence score alone cannot. `score_matrix` scores a whole query set against a whole reference
82
83
  set in one GIL-released C++ call (**532 M pairs/s** on 16 cores; `numpy.asarray` wraps the
83
84
  result with no copy), the shape a prototype-distance embedding needs.
85
+ - **Pairwise alignment without BioPython** — `seqtree.pairwise` is Needleman–Wunsch
86
+ (`mode="global"`) and Smith–Waterman (`mode="local"`) with affine or linear gaps, on the raw
87
+ log-odds scale. It is a **drop-in for `Bio.Align.PairwiseAligner`** — verified against it as an
88
+ oracle across three matrices, ten gap/mode settings and sixty sequence shapes with **zero
89
+ disagreements** — and **65–87× faster**, since there is no Python in the per-pair loop.
90
+ `dist_matrix` gives `d = s(a,a) + s(b,b) − 2·s(a,b)` directly. BioPython is a *test-only*
91
+ dependency; seqtree still needs nothing at runtime.
84
92
  - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
85
93
  frame-aligned junctions (an *island*) and scores a query column by column against the island
86
94
  consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
@@ -164,6 +172,15 @@ distances = np.asarray(sm) # (len(clonotypes), len(prot
164
172
  # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
165
173
  profile = IslandProfile.fit(island_members)
166
174
  profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
175
+
176
+ # ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
177
+ from seqtree.pairwise import align, score, dist_matrix
178
+ score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
179
+ score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
180
+ score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
181
+ aln = align("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # + aligned strings and ops
182
+
183
+ d = np.asarray(dist_matrix(v_genes, v_genes, mat, threads=0)) # s(a,a)+s(b,b)-2s(a,b), zero diagonal
167
184
  ```
168
185
 
169
186
  ## Tests
@@ -26,7 +26,7 @@ to their own payloads (V gene, MHC, counts) and filter.
26
26
 
27
27
  Beyond search, seqtree ships:
28
28
 
29
- - **Substitution matrices** — built-in `identity`, `BLOSUM62`, `PAM250`, `PAM100`, and `structural`
29
+ - **Substitution matrices** — built-in `identity`, `BLOSUM45`, `BLOSUM62`, `BLOSUM80`, `PAM250`, `PAM100`, and `structural`
30
30
  — a **Miyazawa–Jernigan interaction-strength** matrix: each residue's strength `q(a)=mean_b e(a,b)`
31
31
  is read off the MJ contact potential, so substitutions between residues of like interaction strength
32
32
  are cheap. It separates strong (hydrophobic `F W C L Y M I V`) from weak (polar/charged
@@ -48,6 +48,13 @@ Beyond search, seqtree ships:
48
48
  sequence score alone cannot. `score_matrix` scores a whole query set against a whole reference
49
49
  set in one GIL-released C++ call (**532 M pairs/s** on 16 cores; `numpy.asarray` wraps the
50
50
  result with no copy), the shape a prototype-distance embedding needs.
51
+ - **Pairwise alignment without BioPython** — `seqtree.pairwise` is Needleman–Wunsch
52
+ (`mode="global"`) and Smith–Waterman (`mode="local"`) with affine or linear gaps, on the raw
53
+ log-odds scale. It is a **drop-in for `Bio.Align.PairwiseAligner`** — verified against it as an
54
+ oracle across three matrices, ten gap/mode settings and sixty sequence shapes with **zero
55
+ disagreements** — and **65–87× faster**, since there is no Python in the per-pair loop.
56
+ `dist_matrix` gives `d = s(a,a) + s(b,b) − 2·s(a,b)` directly. BioPython is a *test-only*
57
+ dependency; seqtree still needs nothing at runtime.
51
58
  - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
52
59
  frame-aligned junctions (an *island*) and scores a query column by column against the island
53
60
  consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
@@ -131,6 +138,15 @@ distances = np.asarray(sm) # (len(clonotypes), len(prot
131
138
  # a position weight matrix over an island, still a non-negative penalty (feeds threshold_for_evalue)
132
139
  profile = IslandProfile.fit(island_members)
133
140
  profile.score("CASSLGQAYEQYF") # 0 on the consensus, > 0 for deviations
141
+
142
+ # ordinary pairwise alignment -- Needleman-Wunsch / Smith-Waterman, no BioPython
143
+ from seqtree.pairwise import align, score, dist_matrix
144
+ score("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # global, BLAST defaults (11/1)
145
+ score("WWWAAAWWW", "KKKAAAKKK", mat, mode="local") # Smith-Waterman
146
+ score("AAA", "AAAAA", mat, gap_open=5, gap_extend=5) # linear gaps: open == extend
147
+ aln = align("CASSLGQAYEQYF", "CASSPGQAYEQF", mat) # + aligned strings and ops
148
+
149
+ d = np.asarray(dist_matrix(v_genes, v_genes, mat, threads=0)) # s(a,a)+s(b,b)-2s(a,b), zero diagonal
134
150
  ```
135
151
 
136
152
  ## Tests
@@ -30,31 +30,48 @@ private:
30
30
  char dec_[32];
31
31
  };
32
32
 
33
- // Non-negative substitution penalties indexed by alphabet code; penalty(a,a)==0.
33
+ // A substitution matrix in two views.
34
+ //
35
+ // penalty(a,b) non-negative, penalty(a,a)==0. What search and the E-value ball need:
36
+ // a distance-like cost that is minimised.
37
+ // similarity(a,b) the raw log-odds, signed. What Needleman-Wunsch and Smith-Waterman need:
38
+ // a score that is maximised.
39
+ //
40
+ // The penalty is the Gram / squared-distance transform of the similarity, which is LOSSY --
41
+ // it forces the diagonal to zero, destroying s(a,a) -- so the raw grid is retained rather
42
+ // than reconstructed.
34
43
  class SubstitutionMatrix {
35
44
  public:
36
45
  // Unit cost: 0 for a match, 1 for a mismatch (this is the "identity" matrix).
37
46
  static SubstitutionMatrix unit(uint8_t size);
38
47
  // Built-in amino-acid matrices (valid only for the AminoAcid codec order).
39
48
  static SubstitutionMatrix blosum62();
49
+ static SubstitutionMatrix blosum45(); // shallower: remote homologs
50
+ static SubstitutionMatrix blosum80(); // deeper: close homologs
40
51
  static SubstitutionMatrix pam250(); // EMBOSS EPAM250 (NCBI log-odds)
41
52
  static SubstitutionMatrix pam100(); // EMBOSS EPAM100 (NCBI log-odds)
42
53
  static SubstitutionMatrix structural(); // Miyazawa-Jernigan interaction-strength similarity
43
54
  // Convert a similarity matrix (row-major, size*size) to penalties via the Gram /
44
- // squared-distance transform pen[a][b] = sim[a][a] + sim[b][b] - 2*sim[a][b].
55
+ // squared-distance transform pen[a][b] = sim[a][a] + sim[b][b] - 2*sim[a][b]. The raw
56
+ // similarity is kept too.
45
57
  static SubstitutionMatrix from_similarity(uint8_t size, const int32_t* sim);
46
58
 
47
59
  uint8_t size() const { return size_; }
48
60
  int32_t penalty(uint8_t a, uint8_t b) const { return pen_[a * size_ + b]; }
61
+ int32_t similarity(uint8_t a, uint8_t b) const { return sim_[a * size_ + b]; }
49
62
  // Median penalty over all mismatched symbol pairs -- the natural unit of this matrix.
50
63
  // Gap costs must be on this scale: the Gram transform makes a typical BLOSUM62 mismatch
51
64
  // cost ~15, so the default gap_open of 1 would make gaps ~15x cheaper than substitutions
52
65
  // and the aligner would gap rather than substitute. Use gap_open ~ 1-2 * scale().
66
+ //
67
+ // This is the PENALTY scale. A similarity-scoring aligner lives on the raw log-odds scale
68
+ // instead, where the usual BLAST defaults (gap_open 11, gap_extend 1) apply.
53
69
  int32_t scale() const;
54
70
 
55
71
  private:
56
72
  uint8_t size_ = 0;
57
73
  std::vector<int32_t> pen_;
74
+ std::vector<int32_t> sim_;
58
75
  };
59
76
 
60
77
  // Per-position substitution penalties pen(pos, a, b) over a fixed frame width W.
@@ -149,6 +166,48 @@ std::vector<std::vector<Hit>> pairwise_batch(const std::vector<std::string>& a,
149
166
  const std::vector<std::string>& b,
150
167
  Alphabet, const SearchParams&, int threads = 0);
151
168
 
169
+ // ---------------------------------------------------------------------------------------
170
+ // Pairwise similarity alignment: Needleman-Wunsch (global) and Smith-Waterman (local).
171
+ //
172
+ // These MAXIMISE the raw log-odds similarity, unlike everything else in this header, which
173
+ // minimises a non-negative penalty. They exist so a caller does not need BioPython for an
174
+ // ordinary protein alignment; the conventions match Bio.Align.PairwiseAligner exactly:
175
+ //
176
+ // * a gap of length L costs gap_open + (L-1)*gap_extend (so gap_open is the cost of the
177
+ // first gap column, not an extra charge on top of it);
178
+ // * gap_open == gap_extend gives LINEAR gaps -- no separate mode is needed;
179
+ // * in global mode END gaps are charged like any other (true Needleman-Wunsch, not
180
+ // semi-global / overlap);
181
+ // * in local mode the score never drops below zero and the best local cell wins
182
+ // (Smith-Waterman).
183
+ //
184
+ // Gap costs are given as POSITIVE magnitudes and subtracted. BLAST's protein defaults are
185
+ // gap_open = 11, gap_extend = 1.
186
+ enum class AlignMode { Global, Local };
187
+
188
+ // Optimal score only: O(min(m,n)) memory, no traceback. This is the hot path.
189
+ int32_t align_score(std::string_view query, std::string_view ref, const SubstitutionMatrix&,
190
+ Alphabet, AlignMode, int32_t gap_open, int32_t gap_extend);
191
+
192
+ // Optimal score plus the aligned strings and ops. O(m*n) memory for the traceback.
193
+ Alignment align_pair(std::string_view query, std::string_view ref, const SubstitutionMatrix&,
194
+ Alphabet, AlignMode, int32_t gap_open, int32_t gap_extend);
195
+
196
+ // Dense N*K similarity matrix, row-major (row i is queries[i] vs every ref). Parallel;
197
+ // threads <= 0 => hardware_concurrency.
198
+ std::vector<int32_t> align_score_matrix(const std::vector<std::string>& queries,
199
+ const std::vector<std::string>& refs,
200
+ const SubstitutionMatrix&, Alphabet, AlignMode,
201
+ int32_t gap_open, int32_t gap_extend, int threads = 0);
202
+
203
+ // Dense N*K distance matrix d(a,b) = s(a,a) + s(b,b) - 2*s(a,b) >= 0, the Gram transform
204
+ // applied at the SEQUENCE level to the alignment scores above. This is the distance a
205
+ // prototype-embedding wants, and the one BioPython users hand-roll.
206
+ std::vector<int32_t> align_dist_matrix(const std::vector<std::string>& queries,
207
+ const std::vector<std::string>& refs,
208
+ const SubstitutionMatrix&, Alphabet, AlignMode,
209
+ int32_t gap_open, int32_t gap_extend, int threads = 0);
210
+
152
211
  // Dense N*K single-gap-block penalty matrix, row-major (row i is queries[i] vs every ref).
153
212
  // Unlike pairwise_batch this is exhaustive: no budget, no trie, every cell scored.
154
213
  //
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "seqtree"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Fast fuzzy search over biological sequences (C++ core, Python bindings)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -22,7 +22,9 @@ classifiers = [
22
22
  dependencies = []
23
23
 
24
24
  [project.optional-dependencies]
25
- test = ["pytest>=7.4", "pytest-cov>=4.1"]
25
+ # biopython is the ORACLE for seqtree.pairwise (NW/SW) -- a test-only dependency.
26
+ # seqtree itself has no runtime dependencies and must never import it.
27
+ test = ["pytest>=7.4", "pytest-cov>=4.1", "biopython>=1.81"]
26
28
  bench = ["huggingface_hub", "psutil"]
27
29
  docs = ["sphinx", "pydata-sphinx-theme", "nbsphinx"]
28
30
  control = ["huggingface_hub"] # only needed to download controls larger than the bundled subset
@@ -24,7 +24,7 @@ from ._core import (
24
24
  )
25
25
  from .control import load_control
26
26
  from .evalue import evalues, thetas_from_scores, threshold_for_evalue
27
- from . import gapblock, layout, pmhc, seeds
27
+ from . import gapblock, layout, pairwise, pmhc, seeds
28
28
  from .gapblock import (
29
29
  GapBlockIndex, IslandProfile, ScoreMatrix, central_prior, embed_in_frame, frame_prior,
30
30
  gapblock_score, positions_prior, profile_prior, score_matrix,
@@ -35,6 +35,7 @@ from .pmhc import PMHCStore, find_mimics
35
35
  __all__ = [
36
36
  "gapblock",
37
37
  "layout",
38
+ "pairwise",
38
39
  "pmhc",
39
40
  "seeds",
40
41
  "GapBlockIndex",