seqtree 0.6.0__tar.gz → 0.7.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 (63) hide show
  1. {seqtree-0.6.0 → seqtree-0.7.0}/CHANGELOG.md +57 -0
  2. {seqtree-0.6.0 → seqtree-0.7.0}/PKG-INFO +17 -3
  3. {seqtree-0.6.0 → seqtree-0.7.0}/README.md +16 -2
  4. {seqtree-0.6.0 → seqtree-0.7.0}/pyproject.toml +1 -1
  5. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/__init__.py +7 -0
  6. seqtree-0.7.0/python/seqtree/distance.py +282 -0
  7. {seqtree-0.6.0 → seqtree-0.7.0}/skills/seqtree/SKILL.md +26 -1
  8. {seqtree-0.6.0 → seqtree-0.7.0}/src/gapblock.cpp +5 -5
  9. seqtree-0.6.0/python/seqtree/distance.py +0 -119
  10. {seqtree-0.6.0 → seqtree-0.7.0}/.gitattributes +0 -0
  11. {seqtree-0.6.0 → seqtree-0.7.0}/.gitignore +0 -0
  12. {seqtree-0.6.0 → seqtree-0.7.0}/CMakeLists.txt +0 -0
  13. {seqtree-0.6.0 → seqtree-0.7.0}/LICENSE +0 -0
  14. {seqtree-0.6.0 → seqtree-0.7.0}/ROADMAP.md +0 -0
  15. {seqtree-0.6.0 → seqtree-0.7.0}/SOURCES.md +0 -0
  16. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/.gitignore +0 -0
  17. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/.latexmkrc +0 -0
  18. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/Makefile +0 -0
  19. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/epitope_detection.pdf +0 -0
  20. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/evalue.pdf +0 -0
  21. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/evalue.tex +0 -0
  22. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/evalue_matrix.pdf +0 -0
  23. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/mhc1_rocpr.pdf +0 -0
  24. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/mhc2_rocpr.pdf +0 -0
  25. {seqtree-0.6.0 → seqtree-0.7.0}/appendix/refs.bib +0 -0
  26. {seqtree-0.6.0 → seqtree-0.7.0}/examples/01_gapped_search.py +0 -0
  27. {seqtree-0.6.0 → seqtree-0.7.0}/examples/02_sequence_dendrogram.py +0 -0
  28. {seqtree-0.6.0 → seqtree-0.7.0}/examples/03_indel_positions.py +0 -0
  29. {seqtree-0.6.0 → seqtree-0.7.0}/examples/04_island_profile.py +0 -0
  30. {seqtree-0.6.0 → seqtree-0.7.0}/include/seqtree/kmer_index.hpp +0 -0
  31. {seqtree-0.6.0 → seqtree-0.7.0}/include/seqtree/seqtree.hpp +0 -0
  32. {seqtree-0.6.0 → seqtree-0.7.0}/include/seqtree/types.hpp +0 -0
  33. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/control.py +0 -0
  34. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  35. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/evalue.py +0 -0
  36. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/gapblock.py +0 -0
  37. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/layout.py +0 -0
  38. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/pairwise.py +0 -0
  39. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/pmhc.py +0 -0
  40. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/pmhc_evalue.py +0 -0
  41. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/py.typed +0 -0
  42. {seqtree-0.6.0 → seqtree-0.7.0}/python/seqtree/seeds.py +0 -0
  43. {seqtree-0.6.0 → seqtree-0.7.0}/src/_bindings.cpp +0 -0
  44. {seqtree-0.6.0 → seqtree-0.7.0}/src/atomic_write.hpp +0 -0
  45. {seqtree-0.6.0 → seqtree-0.7.0}/src/blosum45.inc +0 -0
  46. {seqtree-0.6.0 → seqtree-0.7.0}/src/blosum62.inc +0 -0
  47. {seqtree-0.6.0 → seqtree-0.7.0}/src/blosum80.inc +0 -0
  48. {seqtree-0.6.0 → seqtree-0.7.0}/src/codec.cpp +0 -0
  49. {seqtree-0.6.0 → seqtree-0.7.0}/src/distance.cpp +0 -0
  50. {seqtree-0.6.0 → seqtree-0.7.0}/src/engine_seqtm.cpp +0 -0
  51. {seqtree-0.6.0 → seqtree-0.7.0}/src/engine_seqtrie.cpp +0 -0
  52. {seqtree-0.6.0 → seqtree-0.7.0}/src/engines.hpp +0 -0
  53. {seqtree-0.6.0 → seqtree-0.7.0}/src/index.cpp +0 -0
  54. {seqtree-0.6.0 → seqtree-0.7.0}/src/kmer_index.cpp +0 -0
  55. {seqtree-0.6.0 → seqtree-0.7.0}/src/pairwise.cpp +0 -0
  56. {seqtree-0.6.0 → seqtree-0.7.0}/src/pam100.inc +0 -0
  57. {seqtree-0.6.0 → seqtree-0.7.0}/src/pam250.inc +0 -0
  58. {seqtree-0.6.0 → seqtree-0.7.0}/src/positional_matrix.cpp +0 -0
  59. {seqtree-0.6.0 → seqtree-0.7.0}/src/searcher.cpp +0 -0
  60. {seqtree-0.6.0 → seqtree-0.7.0}/src/structural.inc +0 -0
  61. {seqtree-0.6.0 → seqtree-0.7.0}/src/substitution_matrix.cpp +0 -0
  62. {seqtree-0.6.0 → seqtree-0.7.0}/src/trie.cpp +0 -0
  63. {seqtree-0.6.0 → seqtree-0.7.0}/src/trie.hpp +0 -0
@@ -3,6 +3,63 @@
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.7.0] — 2026-08-16
7
+
8
+ ### Added
9
+
10
+ - **`seqtree.distance` now enumerates a Hamming ball, not just scores one.**
11
+ `neighbourhood(seq, r=1, alphabet=None, include_self=True, shell=False)` lists the members of
12
+ the closed ball (`19·L + 1` at `r = 1` over the 20 standard residues);
13
+ `neighbourhood_union(seqs, ...)` takes the union over many centres, emitting each distinct
14
+ sequence **once**; `union_size(seqs, ...)` returns the cardinality without building or sorting
15
+ the result list. `shell=True` returns the sphere at exactly `r` from the *nearest* centre —
16
+ a per-shell quantity has to be estimated per shell.
17
+
18
+ Substitution only, fixed length: Hamming distance is undefined across lengths (`hamming`
19
+ already raises), and indels are `gapblock`'s problem. Deduplication happens during a
20
+ multi-source breadth-first walk, so the `Σ 19·L_i` multiset is never materialised — which is
21
+ the point, since near-duplicate centres overlap heavily. For 200 length-14 junctions all within
22
+ distance 1 of a common centre, the per-sequence balls double-count **41.7%** (53,400 → 31,122);
23
+ at spread 2, 4.5%; at spread ≥ 3, nothing.
24
+
25
+ Pure Python, measured before being written in C++: 300 junctions of length 14 at `r = 1` is
26
+ 80,100 distinct sequences in **23 ms** on one M3 core (11 ms for `union_size`). `r = 2` over
27
+ the same input is 9.9 M sequences, 6.8 s and ~1.8 GB.
28
+
29
+ `alphabet=None` defaults to the 20 standard amino acids — `amino_acids()` **minus** the
30
+ ambiguity codes `B`/`Z`/`X` and the stop `*`, which that function does include.
31
+
32
+ Shells **partition** the closed ball exactly: shells `0..r` are pairwise disjoint, their union
33
+ is the ball, and every member sits at distance exactly `d` from its *nearest* centre. A
34
+ per-shell quantity therefore sums back to the per-ball one. Pinned by a test that checks the
35
+ distance with the C++ `hamming`, not with the generator's own bookkeeping.
36
+
37
+ `neighbourhood_union` and `union_size` **raise `TypeError` on a single string** rather than
38
+ iterating it. A `str` is iterable, so `union_size("CASSLGQYF")` would otherwise take the
39
+ sequence's 8 distinct *characters* as the centres and answer `20` instead of `172` — a
40
+ plausible integer, no error, no way to notice. Pass `["CASSLGQYF"]`, or call `neighbourhood`.
41
+
42
+ - **`seqtree.__version__`.** Read from the installed distribution metadata, so `pyproject.toml`'s
43
+ `project.version` is the single source and a release that bumps it cannot leave a stale literal
44
+ behind. Asserted against the distribution metadata *and* against `pyproject.toml` by a test.
45
+
46
+ ### Fixed
47
+
48
+ - **The documentation build stamped every page `0.6.1` while the package was `0.7.0`.**
49
+ `docs/conf.py` hand-copied the version into `release`/`version` and the 0.7.0 bump touched only
50
+ `pyproject.toml`. Both now read `seqtree.__version__`, which the docs job already installs.
51
+
52
+ ## [0.6.1] — 2026-07-30
53
+
54
+ ### Fixed
55
+
56
+ - **`gapblock_matrix`'s out-of-alphabet error named only the bad symbol**, not which sequence it
57
+ came from (`"symbol '_' is not in the alphabet"`) — unhelpful when a single malformed row (e.g.
58
+ a `junction_aa` containing a legacy out-of-frame marker) is buried in a batch of hundreds of
59
+ thousands. The message now names the side, index, and offending string:
60
+ `"queries[42] ('CASSIRS_YEQYF'): symbol '_' is not in the alphabet"`. No change to which symbols
61
+ are accepted (still the standard 20 + B/Z/X/* for `alphabet="aa"`).
62
+
6
63
  ## [0.6.0] — 2026-07-17
7
64
 
8
65
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: seqtree
3
- Version: 0.6.0
3
+ Version: 0.7.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>
@@ -94,7 +94,11 @@ Beyond search, seqtree ships:
94
94
  `hamming_matrix` / `levenshtein_matrix` score a whole set against a whole set in one GIL-released
95
95
  C++ call (`numpy.asarray` wraps the result with no copy) — no `python-Levenshtein` or `rapidfuzz`
96
96
  dependency needed. Hamming requires equal lengths (it raises otherwise); comparison is
97
- case-sensitive.
97
+ case-sensitive. The same module *enumerates* a Hamming ball as well as scoring one:
98
+ `neighbourhood(seq, r)` lists its `19·L + 1` members, and `neighbourhood_union(seqs, r)` takes
99
+ the union over many centres with each distinct sequence emitted **once** — deduplicated during
100
+ the walk, so the `Σ 19·L_i` multiset never exists. For a tight specificity group that is a 41.7%
101
+ saving, not a rounding correction.
98
102
  - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
99
103
  frame-aligned junctions (an *island*) and scores a query column by column against the island
100
104
  consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
@@ -114,8 +118,11 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
114
118
 
115
119
  ## Build from source
116
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
+
117
124
  ```fish
118
- bash setup.sh # repo-local .venv + editable install
125
+ bash setup.sh # uv-managed .venv + editable install
119
126
  bash setup.sh --tests # + pytest
120
127
  bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
121
128
  ```
@@ -193,6 +200,13 @@ from seqtree.distance import hamming, levenshtein, hamming_matrix, levenshtein_m
193
200
  hamming("CASSLGQYF", "CASSPGQYF") # 1 (equal length only)
194
201
  levenshtein("kitten", "sitting") # 3
195
202
  h = np.asarray(hamming_matrix(umis, umis, threads=0)) # (len, len) int32, zero-copy
203
+
204
+ # enumerate the ball, deduplicated across centres (substitution only, fixed length)
205
+ from seqtree.distance import neighbourhood, neighbourhood_union, union_size
206
+ neighbourhood("CASSLGQYF") # 172 = 19*9 + 1
207
+ union_size(junctions) # size the job before running it
208
+ for variant in neighbourhood_union(junctions, r=1): # each distinct sequence once
209
+ ...
196
210
  ```
197
211
 
198
212
  ## Tests
@@ -60,7 +60,11 @@ Beyond search, seqtree ships:
60
60
  `hamming_matrix` / `levenshtein_matrix` score a whole set against a whole set in one GIL-released
61
61
  C++ call (`numpy.asarray` wraps the result with no copy) — no `python-Levenshtein` or `rapidfuzz`
62
62
  dependency needed. Hamming requires equal lengths (it raises otherwise); comparison is
63
- case-sensitive.
63
+ case-sensitive. The same module *enumerates* a Hamming ball as well as scoring one:
64
+ `neighbourhood(seq, r)` lists its `19·L + 1` members, and `neighbourhood_union(seqs, r)` takes
65
+ the union over many centres with each distinct sequence emitted **once** — deduplicated during
66
+ the walk, so the `Σ 19·L_i` multiset never exists. For a tight specificity group that is a 41.7%
67
+ saving, not a rounding correction.
64
68
  - **Island profiles** — `IslandProfile.fit` builds a position weight matrix over a set of
65
69
  frame-aligned junctions (an *island*) and scores a query column by column against the island
66
70
  consensus, as a non-negative penalty that flows through `threshold_for_evalue` unchanged. At a
@@ -80,8 +84,11 @@ needs a C++17 compiler and CMake (pulled in automatically by the build).
80
84
 
81
85
  ## Build from source
82
86
 
87
+ Needs [uv](https://docs.astral.sh/uv/) (`brew install uv`); `setup.sh` uses it for the venv and
88
+ the editable install.
89
+
83
90
  ```fish
84
- bash setup.sh # repo-local .venv + editable install
91
+ bash setup.sh # uv-managed .venv + editable install
85
92
  bash setup.sh --tests # + pytest
86
93
  bash setup.sh --bench # + benchmark deps (huggingface_hub, psutil)
87
94
  ```
@@ -159,6 +166,13 @@ from seqtree.distance import hamming, levenshtein, hamming_matrix, levenshtein_m
159
166
  hamming("CASSLGQYF", "CASSPGQYF") # 1 (equal length only)
160
167
  levenshtein("kitten", "sitting") # 3
161
168
  h = np.asarray(hamming_matrix(umis, umis, threads=0)) # (len, len) int32, zero-copy
169
+
170
+ # enumerate the ball, deduplicated across centres (substitution only, fixed length)
171
+ from seqtree.distance import neighbourhood, neighbourhood_union, union_size
172
+ neighbourhood("CASSLGQYF") # 172 = 19*9 + 1
173
+ union_size(junctions) # size the job before running it
174
+ for variant in neighbourhood_union(junctions, r=1): # each distinct sequence once
175
+ ...
162
176
  ```
163
177
 
164
178
  ## Tests
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "seqtree"
7
- version = "0.6.0"
7
+ version = "0.7.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"
@@ -9,6 +9,8 @@ it ignores the per-type caps). Payload-agnostic: results are
9
9
  For anchored loops (CDR3 / junction), :mod:`seqtree.gapblock` restricts the alignment
10
10
  to one contiguous indel and picks its position with a gap prior.
11
11
  """
12
+ from importlib.metadata import version as _distribution_version
13
+
12
14
  from ._core import (
13
15
  Index,
14
16
  SearchParams,
@@ -32,6 +34,11 @@ from .gapblock import (
32
34
  from .seeds import SeedIndex, core_kmers
33
35
  from .pmhc import PMHCStore, find_mimics
34
36
 
37
+ #: Read from the installed distribution metadata, so ``pyproject.toml``'s ``project.version``
38
+ #: is the single source. A hand-copied literal here would silently drift from the version
39
+ #: actually published to PyPI.
40
+ __version__ = _distribution_version("seqtree")
41
+
35
42
  __all__ = [
36
43
  "distance",
37
44
  "gapblock",
@@ -0,0 +1,282 @@
1
+ """Plain string edit distances: Hamming and Levenshtein, in C++, without a dependency.
2
+
3
+ These are the *unweighted* distances -- unit costs, no substitution matrix, no gap model, no
4
+ alphabet. That is the whole point: when all you need is "how many edits apart are these two
5
+ strings", you should not have to build an :class:`~seqtree.SubstitutionMatrix` or reach for
6
+ ``python-Levenshtein`` / ``rapidfuzz``. seqtree still needs nothing at runtime.
7
+
8
+ * :func:`hamming` -- number of differing positions; defined only for **equal-length** sequences,
9
+ and raises :class:`ValueError` otherwise;
10
+ * :func:`levenshtein` -- the classic insertion / deletion / substitution edit distance, each edit
11
+ costing 1;
12
+ * :func:`hamming_matrix` / :func:`levenshtein_matrix` -- every ``a`` against every ``b`` in one
13
+ GIL-released, multi-threaded C++ call, returned as a zero-copy :class:`~seqtree.ScoreMatrix`.
14
+
15
+ The same module also *enumerates* a Hamming ball rather than scoring a pair you already hold:
16
+
17
+ * :func:`neighbourhood` -- every sequence within ``r`` substitutions of one centre;
18
+ * :func:`neighbourhood_union` -- the same over **many** centres, each distinct sequence emitted
19
+ once. Deduplication happens during generation, never as a pass over the ``sum(19*L_i)``
20
+ multiset;
21
+ * :func:`union_size` -- the cardinality alone, for sizing a job before running it.
22
+
23
+ Comparison is **case-sensitive**, byte for byte -- unlike the search engines, which fold case.
24
+ For a *weighted* alignment (a substitution matrix, affine gaps, local mode), use
25
+ :mod:`seqtree.pairwise` instead.
26
+
27
+ Example:
28
+ >>> from seqtree.distance import hamming, levenshtein, union_size
29
+ >>> hamming("CASSLGQYF", "CASSPGQYF")
30
+ 1
31
+ >>> levenshtein("kitten", "sitting")
32
+ 3
33
+ >>> union_size(["CASSLGQYF", "CASSPGQYF"]) # 2 * 172 balls, 20 sequences shared
34
+ 324
35
+ """
36
+ from __future__ import annotations
37
+
38
+ from collections.abc import Iterable, Sequence
39
+
40
+ from ._core import ScoreMatrix
41
+ from ._core import amino_acids as _amino_acids
42
+ from ._core import hamming as _hamming
43
+ from ._core import hamming_matrix as _hamming_matrix
44
+ from ._core import levenshtein as _levenshtein
45
+ from ._core import levenshtein_matrix as _levenshtein_matrix
46
+
47
+ __all__ = ["hamming", "levenshtein", "hamming_matrix", "levenshtein_matrix",
48
+ "neighbourhood", "neighbourhood_union", "union_size"]
49
+
50
+ #: The 20 standard residues: :func:`~seqtree.amino_acids` minus the ambiguity codes ``B``/``Z``/
51
+ #: ``X`` and the stop ``*``, none of which is a substitution anybody means.
52
+ _STANDARD_AA = "".join(c for c in _amino_acids() if c not in "BZX*")
53
+
54
+
55
+ def hamming(a: str, b: str) -> int:
56
+ """Number of positions at which ``a`` and ``b`` differ.
57
+
58
+ Args:
59
+ a: First sequence.
60
+ b: Second sequence, of the **same length** as ``a``.
61
+
62
+ Returns:
63
+ The count of differing positions (0 when identical).
64
+
65
+ Raises:
66
+ ValueError: If ``a`` and ``b`` have different lengths -- Hamming distance is undefined
67
+ for unequal lengths; use :func:`levenshtein` for that.
68
+
69
+ Example:
70
+ >>> hamming("AAAA", "AAAA")
71
+ 0
72
+ >>> hamming("AAAA", "ATAT")
73
+ 2
74
+ """
75
+ return _hamming(a, b)
76
+
77
+
78
+ def levenshtein(a: str, b: str) -> int:
79
+ """Edit distance: fewest single-character insert / delete / substitute steps from ``a`` to ``b``.
80
+
81
+ Args:
82
+ a: First sequence.
83
+ b: Second sequence; may be any length.
84
+
85
+ Returns:
86
+ The edit distance (0 when identical, ``max(len(a), len(b))`` at most).
87
+
88
+ Example:
89
+ >>> levenshtein("flaw", "lawn")
90
+ 2
91
+ >>> levenshtein("CASSLGQAYEQYF", "CASSPGQAYEQF")
92
+ 2
93
+ """
94
+ return _levenshtein(a, b)
95
+
96
+
97
+ def hamming_matrix(a: Sequence[str], b: Sequence[str], threads: int = 0) -> ScoreMatrix:
98
+ """Hamming distance of every ``a`` against every ``b``, in parallel C++.
99
+
100
+ Args:
101
+ a: Query sequences (the rows).
102
+ b: Reference sequences (the columns).
103
+ threads: Worker threads; ``0`` uses all cores.
104
+
105
+ Returns:
106
+ A :class:`~seqtree.ScoreMatrix` of shape ``(len(a), len(b))`` of int32 distances.
107
+ ``numpy.asarray`` wraps it without copying.
108
+
109
+ Raises:
110
+ ValueError: If any ``(a[i], b[k])`` pair has mismatched lengths.
111
+
112
+ Example:
113
+ >>> import numpy as np
114
+ >>> d = np.asarray(hamming_matrix(["AAAA", "AAAT"], ["AAAA", "TTTT"]))
115
+ >>> d.tolist()
116
+ [[0, 4], [1, 3]]
117
+ """
118
+ return _hamming_matrix(list(a), list(b), threads=threads)
119
+
120
+
121
+ def levenshtein_matrix(a: Sequence[str], b: Sequence[str], threads: int = 0) -> ScoreMatrix:
122
+ """Levenshtein distance of every ``a`` against every ``b``, in parallel C++.
123
+
124
+ Unlike :func:`hamming_matrix`, sequences may differ in length freely.
125
+
126
+ Args:
127
+ a: Query sequences (the rows).
128
+ b: Reference sequences (the columns).
129
+ threads: Worker threads; ``0`` uses all cores.
130
+
131
+ Returns:
132
+ A :class:`~seqtree.ScoreMatrix` of shape ``(len(a), len(b))`` of int32 distances,
133
+ zero-copy through ``numpy.asarray``.
134
+ """
135
+ return _levenshtein_matrix(list(a), list(b), threads=threads)
136
+
137
+
138
+ def _shells(seqs: Iterable[str], r: int, alphabet: str | None,
139
+ include_self: bool, shell: bool) -> list[set[str]]:
140
+ """The selected shells of the union, as sets: shell ``r`` alone, or shells ``0..r``.
141
+
142
+ Multi-source breadth-first walk with one shared ``seen`` set, so a sequence lands in the
143
+ shell of its *nearest* centre and is generated at most once -- the ``sum(19*L_i)`` multiset
144
+ is never materialised. Substitution only, so centres of different lengths never meet.
145
+ """
146
+ if r < 0:
147
+ raise ValueError(f"radius must be non-negative, got r={r}")
148
+ if isinstance(seqs, str):
149
+ # A str is iterable, so set(seqs) would silently take its CHARACTERS as the centres:
150
+ # union_size("CASSLGQYF") would answer 20 instead of 172 -- a plausible number, no error.
151
+ raise TypeError(f"seqs must be a collection of sequences, not one string; "
152
+ f"pass [{seqs!r}] or call neighbourhood({seqs!r})")
153
+ alpha = _STANDARD_AA if alphabet is None else alphabet
154
+ frontier = set(seqs)
155
+ seen = set(frontier)
156
+ levels = [frontier]
157
+ for _ in range(r):
158
+ nxt: set[str] = set()
159
+ for s in frontier:
160
+ for i, c in enumerate(s):
161
+ head, tail = s[:i], s[i + 1:]
162
+ for a in alpha:
163
+ if a != c:
164
+ t = head + a + tail
165
+ if t not in seen:
166
+ seen.add(t)
167
+ nxt.add(t)
168
+ levels.append(nxt)
169
+ frontier = nxt
170
+ if not include_self:
171
+ levels[0] = set() # the centres are exactly the r = 0 shell
172
+ return [levels[r]] if shell else levels
173
+
174
+
175
+ def neighbourhood_union(seqs: Iterable[str], r: int = 1, alphabet: str | None = None,
176
+ include_self: bool = True, shell: bool = False) -> list[str]:
177
+ """Every sequence within ``r`` substitutions of **any** of ``seqs``, each listed once.
178
+
179
+ This is the union of the per-sequence Hamming balls, not their concatenation. Near-duplicate
180
+ centres -- co-specific TCR junctions, an error family around one UMI -- have balls that
181
+ overlap heavily, so the difference is the dominant term rather than a correction. The
182
+ dedup happens as the ball is walked, so the ``sum(19*L_i)`` multiset never exists.
183
+
184
+ Args:
185
+ seqs: The centres. Duplicates and mixed lengths are fine; substitution-only means
186
+ balls of different lengths cannot overlap.
187
+ r: Radius in substitutions.
188
+ alphabet: Substituting alphabet; ``None`` is the 20 standard amino acids. Characters of
189
+ ``seqs`` outside it are still substitutable, so a centre containing ``X`` has
190
+ ``|alphabet|`` neighbours at that position rather than ``|alphabet| - 1``.
191
+ include_self: Keep the centres themselves. ``False`` drops **every** centre, including
192
+ one that happens to be a neighbour of another -- the ``r = 0`` shell of a union is
193
+ exactly ``set(seqs)``.
194
+ shell: Return only the members at distance exactly ``r`` from the *nearest* centre,
195
+ instead of the whole closed ball.
196
+
197
+ Returns:
198
+ The distinct members, ordered by distance and sorted within each shell (so the centres
199
+ come first). Empty input gives ``[]``.
200
+
201
+ Raises:
202
+ ValueError: If ``r`` is negative.
203
+ TypeError: If ``seqs`` is a single string -- its characters would otherwise become the
204
+ centres, silently. Use :func:`neighbourhood` for one sequence.
205
+
206
+ Example:
207
+ >>> neighbourhood_union(["AA", "AC"], 1, alphabet="AC")
208
+ ['AA', 'AC', 'CA', 'CC']
209
+ >>> a = neighbourhood("CASSLGQYF")
210
+ >>> b = neighbourhood("CASSPGQYF") # one substitution away
211
+ >>> len(a) + len(b), len(neighbourhood_union(["CASSLGQYF", "CASSPGQYF"]))
212
+ (344, 324)
213
+ """
214
+ return [s for level in _shells(seqs, r, alphabet, include_self, shell) for s in sorted(level)]
215
+
216
+
217
+ def neighbourhood(seq: str, r: int = 1, alphabet: str | None = None,
218
+ include_self: bool = True, shell: bool = False) -> list[str]:
219
+ """Every sequence within ``r`` substitutions of ``seq``: its closed Hamming ball.
220
+
221
+ Substitution only, so every member has ``len(seq)`` characters -- Hamming distance is
222
+ undefined across lengths (see :func:`hamming`), and an indel is a different question. Over a
223
+ ``k``-letter alphabet the ball holds ``sum((k-1)^d * C(L, d) for d in 0..r)`` sequences;
224
+ at ``r = 1`` that is ``19*L + 1``.
225
+
226
+ Args:
227
+ seq: The centre.
228
+ r: Radius in substitutions.
229
+ alphabet: Substituting alphabet; ``None`` is the 20 standard amino acids.
230
+ include_self: Keep ``seq`` itself (the ``d = 0`` member).
231
+ shell: Return only the members at distance exactly ``r``, instead of the closed ball.
232
+ The cognacy-retention profile is estimated per shell, not per ball.
233
+
234
+ Returns:
235
+ The distinct members, ordered by distance and sorted within each shell, so ``seq``
236
+ itself comes first when ``include_self``.
237
+
238
+ Raises:
239
+ ValueError: If ``r`` is negative.
240
+
241
+ Example:
242
+ >>> len(neighbourhood("CASSLGQYF")) # 19 * 9 + 1
243
+ 172
244
+ >>> len(neighbourhood("CASSLGQYF", include_self=False))
245
+ 171
246
+ >>> neighbourhood("A", 1, alphabet="ACGT")
247
+ ['A', 'C', 'G', 'T']
248
+ >>> len(neighbourhood("CASSLGQYF", 2, shell=True)) # 19^2 * C(9, 2)
249
+ 12996
250
+ """
251
+ return neighbourhood_union([seq], r, alphabet, include_self, shell)
252
+
253
+
254
+ def union_size(seqs: Iterable[str], r: int = 1, alphabet: str | None = None,
255
+ include_self: bool = True, shell: bool = False) -> int:
256
+ """How many distinct sequences :func:`neighbourhood_union` would return.
257
+
258
+ Same walk, but the result list is never built or sorted -- use it to size a job (an OLGA
259
+ ``P_gen`` pass over the union, say) before committing to it. The dedup set is still held,
260
+ so this bounds the output, not the peak memory.
261
+
262
+ Args:
263
+ seqs: The centres.
264
+ r: Radius in substitutions.
265
+ alphabet: Substituting alphabet; ``None`` is the 20 standard amino acids.
266
+ include_self: Count the centres themselves.
267
+ shell: Count only distance-exactly-``r`` members.
268
+
269
+ Returns:
270
+ ``len(neighbourhood_union(seqs, r, alphabet, include_self, shell))``.
271
+
272
+ Raises:
273
+ ValueError: If ``r`` is negative.
274
+ TypeError: If ``seqs`` is a single string, as for :func:`neighbourhood_union`.
275
+
276
+ Example:
277
+ >>> union_size(["CASSLGQYF", "CASSPGQYF"]) # 344 with double-counting
278
+ 324
279
+ >>> union_size(["CASSLGQYF", "CASSLGQYF"]) # a repeat adds nothing
280
+ 172
281
+ """
282
+ return sum(len(level) for level in _shells(seqs, r, alphabet, include_self, shell))
@@ -111,6 +111,31 @@ dist_matrix(queries, refs, ...) -> ScoreMatrix # d = s(a,a)+s(b,b)-2s(a,
111
111
  `similarity()` (signed log-odds, for these aligners). The Gram transform is lossy -- it zeroes
112
112
  the diagonal -- so a similarity cannot be recovered from a penalty.
113
113
 
114
+ ## Plain distances and Hamming balls — `seqtree.distance`
115
+
116
+ ```python
117
+ hamming(a, b) -> int # equal length only; RAISES otherwise. Case-SENSITIVE.
118
+ levenshtein(a, b) -> int
119
+ hamming_matrix(a, b, threads=0) / levenshtein_matrix(...) -> ScoreMatrix # GIL released
120
+
121
+ neighbourhood(seq, r=1, alphabet=None, include_self=True, shell=False) -> list[str]
122
+ neighbourhood_union(seqs, r=1, ...) -> list[str] # dedup DURING the walk
123
+ union_size(seqs, r=1, ...) -> int # size the job first
124
+ ```
125
+
126
+ Enumeration is **substitution only, fixed length** — no indels, because Hamming is undefined
127
+ across lengths and `P_gen` is length-conditioned. `alphabet=None` is the 20 standard residues,
128
+ which is `amino_acids()` **minus** `B`/`Z`/`X`/`*` — passing `amino_acids()` itself gives 23·L
129
+ neighbours, not 19·L. `shell=True` is the sphere at exactly `r` from the *nearest* centre;
130
+ `include_self=False` drops the whole `r = 0` shell, i.e. every centre.
131
+
132
+ **Take the union, never the sum of the balls.** 200 length-14 junctions drawn within distance `d`
133
+ of a common centre, `r = 1`: at `d = 1` the per-sequence balls double-count **41.7%**
134
+ (53,400 → 31,122), at `d = 2` 4.5%, at `d ≥ 3` nothing. 300 junctions of length 14 at `r = 1` is
135
+ 80,100 distinct sequences in **23 ms** on one M3 core (`union_size` 11 ms), so this is pure
136
+ Python and does not need the C++ core. `r = 2` over the same 300 is 9.9 M sequences, 6.8 s,
137
+ ~1.8 GB — there the dedup is what keeps it to one copy.
138
+
114
139
  ## Gap-block alignment — `seqtree.gapblock`
115
140
 
116
141
  A V(D)J junction's length variation is **one** contiguous indel event, so restrict alignment to
@@ -237,7 +262,7 @@ Count it in the control.
237
262
  4. OLGA generates from `P_gen`, which is **not** the post-selection background `P_0`. Do not use
238
263
  an OLGA sample as the null for an operational cutoff.
239
264
  5. Rebuild after pulling: a stale `_core.so` has silently shadowed new Python-visible C++ methods
240
- more than once (`pip install -e .`).
265
+ more than once (`bash setup.sh`, or `uv pip install -e .`).
241
266
 
242
267
  ## Layering
243
268
 
@@ -9,13 +9,13 @@
9
9
  namespace seqtree {
10
10
  namespace {
11
11
 
12
- std::vector<uint8_t> encode(const Codec& c, const std::string& s) {
12
+ std::vector<uint8_t> encode(const Codec& c, const std::string& s, const char* label, size_t idx) {
13
13
  std::vector<uint8_t> out(s.size());
14
14
  for (size_t i = 0; i < s.size(); ++i) {
15
15
  uint8_t v = c.encode(s[i]);
16
16
  if (v == Codec::kInvalid)
17
- throw std::invalid_argument(std::string("symbol '") + s[i] +
18
- "' is not in the alphabet");
17
+ throw std::invalid_argument(label + std::string("[") + std::to_string(idx) + "] ('" + s +
18
+ "'): symbol '" + s[i] + "' is not in the alphabet");
19
19
  out[i] = v;
20
20
  }
21
21
  return out;
@@ -85,11 +85,11 @@ std::vector<int32_t> gapblock_matrix(const std::vector<std::string>& queries,
85
85
  std::vector<std::vector<uint8_t>> qc(N), rc(K);
86
86
  uint32_t longest = 0;
87
87
  for (size_t i = 0; i < N; ++i) {
88
- qc[i] = encode(codec, queries[i]);
88
+ qc[i] = encode(codec, queries[i], "queries", i);
89
89
  longest = std::max<uint32_t>(longest, uint32_t(qc[i].size()));
90
90
  }
91
91
  for (size_t k = 0; k < K; ++k) {
92
- rc[k] = encode(codec, refs[k]);
92
+ rc[k] = encode(codec, refs[k], "refs", k);
93
93
  longest = std::max<uint32_t>(longest, uint32_t(rc[k].size()));
94
94
  }
95
95
 
@@ -1,119 +0,0 @@
1
- """Plain string edit distances: Hamming and Levenshtein, in C++, without a dependency.
2
-
3
- These are the *unweighted* distances -- unit costs, no substitution matrix, no gap model, no
4
- alphabet. That is the whole point: when all you need is "how many edits apart are these two
5
- strings", you should not have to build an :class:`~seqtree.SubstitutionMatrix` or reach for
6
- ``python-Levenshtein`` / ``rapidfuzz``. seqtree still needs nothing at runtime.
7
-
8
- * :func:`hamming` -- number of differing positions; defined only for **equal-length** sequences,
9
- and raises :class:`ValueError` otherwise;
10
- * :func:`levenshtein` -- the classic insertion / deletion / substitution edit distance, each edit
11
- costing 1;
12
- * :func:`hamming_matrix` / :func:`levenshtein_matrix` -- every ``a`` against every ``b`` in one
13
- GIL-released, multi-threaded C++ call, returned as a zero-copy :class:`~seqtree.ScoreMatrix`.
14
-
15
- Comparison is **case-sensitive**, byte for byte -- unlike the search engines, which fold case.
16
- For a *weighted* alignment (a substitution matrix, affine gaps, local mode), use
17
- :mod:`seqtree.pairwise` instead.
18
-
19
- Example:
20
- >>> from seqtree.distance import hamming, levenshtein
21
- >>> hamming("CASSLGQYF", "CASSPGQYF")
22
- 1
23
- >>> levenshtein("kitten", "sitting")
24
- 3
25
- """
26
- from __future__ import annotations
27
-
28
- from collections.abc import Sequence
29
-
30
- from ._core import ScoreMatrix
31
- from ._core import hamming as _hamming
32
- from ._core import hamming_matrix as _hamming_matrix
33
- from ._core import levenshtein as _levenshtein
34
- from ._core import levenshtein_matrix as _levenshtein_matrix
35
-
36
- __all__ = ["hamming", "levenshtein", "hamming_matrix", "levenshtein_matrix"]
37
-
38
-
39
- def hamming(a: str, b: str) -> int:
40
- """Number of positions at which ``a`` and ``b`` differ.
41
-
42
- Args:
43
- a: First sequence.
44
- b: Second sequence, of the **same length** as ``a``.
45
-
46
- Returns:
47
- The count of differing positions (0 when identical).
48
-
49
- Raises:
50
- ValueError: If ``a`` and ``b`` have different lengths -- Hamming distance is undefined
51
- for unequal lengths; use :func:`levenshtein` for that.
52
-
53
- Example:
54
- >>> hamming("AAAA", "AAAA")
55
- 0
56
- >>> hamming("AAAA", "ATAT")
57
- 2
58
- """
59
- return _hamming(a, b)
60
-
61
-
62
- def levenshtein(a: str, b: str) -> int:
63
- """Edit distance: fewest single-character insert / delete / substitute steps from ``a`` to ``b``.
64
-
65
- Args:
66
- a: First sequence.
67
- b: Second sequence; may be any length.
68
-
69
- Returns:
70
- The edit distance (0 when identical, ``max(len(a), len(b))`` at most).
71
-
72
- Example:
73
- >>> levenshtein("flaw", "lawn")
74
- 2
75
- >>> levenshtein("CASSLGQAYEQYF", "CASSPGQAYEQF")
76
- 2
77
- """
78
- return _levenshtein(a, b)
79
-
80
-
81
- def hamming_matrix(a: Sequence[str], b: Sequence[str], threads: int = 0) -> ScoreMatrix:
82
- """Hamming distance of every ``a`` against every ``b``, in parallel C++.
83
-
84
- Args:
85
- a: Query sequences (the rows).
86
- b: Reference sequences (the columns).
87
- threads: Worker threads; ``0`` uses all cores.
88
-
89
- Returns:
90
- A :class:`~seqtree.ScoreMatrix` of shape ``(len(a), len(b))`` of int32 distances.
91
- ``numpy.asarray`` wraps it without copying.
92
-
93
- Raises:
94
- ValueError: If any ``(a[i], b[k])`` pair has mismatched lengths.
95
-
96
- Example:
97
- >>> import numpy as np
98
- >>> d = np.asarray(hamming_matrix(["AAAA", "AAAT"], ["AAAA", "TTTT"]))
99
- >>> d.tolist()
100
- [[0, 4], [1, 3]]
101
- """
102
- return _hamming_matrix(list(a), list(b), threads=threads)
103
-
104
-
105
- def levenshtein_matrix(a: Sequence[str], b: Sequence[str], threads: int = 0) -> ScoreMatrix:
106
- """Levenshtein distance of every ``a`` against every ``b``, in parallel C++.
107
-
108
- Unlike :func:`hamming_matrix`, sequences may differ in length freely.
109
-
110
- Args:
111
- a: Query sequences (the rows).
112
- b: Reference sequences (the columns).
113
- threads: Worker threads; ``0`` uses all cores.
114
-
115
- Returns:
116
- A :class:`~seqtree.ScoreMatrix` of shape ``(len(a), len(b))`` of int32 distances,
117
- zero-copy through ``numpy.asarray``.
118
- """
119
- return _levenshtein_matrix(list(a), list(b), threads=threads)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes