seqtree 1.0.0__tar.gz → 1.1.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 (69) hide show
  1. {seqtree-1.0.0 → seqtree-1.1.0}/.gitignore +12 -0
  2. {seqtree-1.0.0 → seqtree-1.1.0}/CHANGELOG.md +124 -1
  3. {seqtree-1.0.0 → seqtree-1.1.0}/CMakeLists.txt +1 -0
  4. {seqtree-1.0.0 → seqtree-1.1.0}/PKG-INFO +5 -2
  5. {seqtree-1.0.0 → seqtree-1.1.0}/README.md +4 -1
  6. {seqtree-1.0.0 → seqtree-1.1.0}/include/seqtree/kmer_index.hpp +1 -1
  7. seqtree-1.1.0/include/seqtree/parallel.hpp +134 -0
  8. {seqtree-1.0.0 → seqtree-1.1.0}/include/seqtree/seqtree.hpp +105 -5
  9. {seqtree-1.0.0 → seqtree-1.1.0}/include/seqtree/text_index.hpp +21 -2
  10. {seqtree-1.0.0 → seqtree-1.1.0}/pyproject.toml +4 -3
  11. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/__init__.py +3 -0
  12. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/evalue.py +22 -9
  13. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/gapblock.py +290 -2
  14. seqtree-1.1.0/python/seqtree/text_evalue.py +142 -0
  15. {seqtree-1.0.0 → seqtree-1.1.0}/src/_bindings.cpp +124 -19
  16. seqtree-1.1.0/src/count_unit.hpp +87 -0
  17. seqtree-1.1.0/src/gapblock.cpp +499 -0
  18. {seqtree-1.0.0 → seqtree-1.1.0}/src/index.cpp +25 -0
  19. {seqtree-1.0.0 → seqtree-1.1.0}/src/text_index.cpp +229 -6
  20. seqtree-1.0.0/ROADMAP.md +0 -178
  21. seqtree-1.0.0/SOURCES.md +0 -118
  22. seqtree-1.0.0/appendix/.gitignore +0 -10
  23. seqtree-1.0.0/appendix/.latexmkrc +0 -2
  24. seqtree-1.0.0/appendix/Makefile +0 -17
  25. seqtree-1.0.0/appendix/epitope_detection.pdf +0 -0
  26. seqtree-1.0.0/appendix/evalue.pdf +0 -0
  27. seqtree-1.0.0/appendix/evalue.tex +0 -879
  28. seqtree-1.0.0/appendix/evalue_matrix.pdf +0 -0
  29. seqtree-1.0.0/appendix/mhc1_rocpr.pdf +0 -0
  30. seqtree-1.0.0/appendix/mhc2_rocpr.pdf +0 -0
  31. seqtree-1.0.0/appendix/refs.bib +0 -206
  32. seqtree-1.0.0/examples/01_gapped_search.py +0 -118
  33. seqtree-1.0.0/examples/02_sequence_dendrogram.py +0 -144
  34. seqtree-1.0.0/examples/03_indel_positions.py +0 -169
  35. seqtree-1.0.0/examples/04_island_profile.py +0 -151
  36. seqtree-1.0.0/include/seqtree/parallel.hpp +0 -70
  37. seqtree-1.0.0/skills/seqtree/SKILL.md +0 -328
  38. seqtree-1.0.0/src/gapblock.cpp +0 -123
  39. {seqtree-1.0.0 → seqtree-1.1.0}/.gitattributes +0 -0
  40. {seqtree-1.0.0 → seqtree-1.1.0}/LICENSE +0 -0
  41. {seqtree-1.0.0 → seqtree-1.1.0}/include/seqtree/types.hpp +0 -0
  42. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/control.py +0 -0
  43. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/data/control_human_trb_aa.txt.gz +0 -0
  44. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/distance.py +0 -0
  45. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/layout.py +0 -0
  46. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/pairwise.py +0 -0
  47. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/pmhc.py +0 -0
  48. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/pmhc_evalue.py +0 -0
  49. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/py.typed +0 -0
  50. {seqtree-1.0.0 → seqtree-1.1.0}/python/seqtree/seeds.py +0 -0
  51. {seqtree-1.0.0 → seqtree-1.1.0}/src/atomic_write.hpp +0 -0
  52. {seqtree-1.0.0 → seqtree-1.1.0}/src/blosum45.inc +0 -0
  53. {seqtree-1.0.0 → seqtree-1.1.0}/src/blosum62.inc +0 -0
  54. {seqtree-1.0.0 → seqtree-1.1.0}/src/blosum80.inc +0 -0
  55. {seqtree-1.0.0 → seqtree-1.1.0}/src/codec.cpp +0 -0
  56. {seqtree-1.0.0 → seqtree-1.1.0}/src/distance.cpp +0 -0
  57. {seqtree-1.0.0 → seqtree-1.1.0}/src/engine_seqtm.cpp +0 -0
  58. {seqtree-1.0.0 → seqtree-1.1.0}/src/engine_seqtrie.cpp +0 -0
  59. {seqtree-1.0.0 → seqtree-1.1.0}/src/engines.hpp +0 -0
  60. {seqtree-1.0.0 → seqtree-1.1.0}/src/kmer_index.cpp +0 -0
  61. {seqtree-1.0.0 → seqtree-1.1.0}/src/pairwise.cpp +0 -0
  62. {seqtree-1.0.0 → seqtree-1.1.0}/src/pam100.inc +0 -0
  63. {seqtree-1.0.0 → seqtree-1.1.0}/src/pam250.inc +0 -0
  64. {seqtree-1.0.0 → seqtree-1.1.0}/src/positional_matrix.cpp +0 -0
  65. {seqtree-1.0.0 → seqtree-1.1.0}/src/searcher.cpp +0 -0
  66. {seqtree-1.0.0 → seqtree-1.1.0}/src/structural.inc +0 -0
  67. {seqtree-1.0.0 → seqtree-1.1.0}/src/substitution_matrix.cpp +0 -0
  68. {seqtree-1.0.0 → seqtree-1.1.0}/src/trie.cpp +0 -0
  69. {seqtree-1.0.0 → seqtree-1.1.0}/src/trie.hpp +0 -0
@@ -16,3 +16,15 @@ bench/cache/
16
16
  .coverage
17
17
  .coverage.*
18
18
  htmlcov/
19
+
20
+ # scratch worktrees and probe output
21
+ tmp/
22
+
23
+ # Local agent instructions and planning notes
24
+ /AGENTS.md
25
+ /CLAUDE.md
26
+ /ROADMAP.md
27
+ /SOURCES.md
28
+ /skills/
29
+ /add_bindings.py
30
+ /add_native.py
@@ -3,6 +3,129 @@
3
3
  All notable changes to `seqtree`. Dates are release dates. From 1.0.0 the project follows semantic
4
4
  versioning: breaking changes need a **major** bump.
5
5
 
6
+ ## [1.1.0] — 2026-10-11
7
+
8
+ ### Added
9
+
10
+ - Native batched edit-count histograms, with an exact bit-vector fast path for bounded unit-cost searches.
11
+ - Gap-block length profiles and symmetric per-input positional weights, optional group penalties,
12
+ exhaustive balls, linked sum-distance counts, integer linear mass, and streaming group audits.
13
+ - TextIndex E-values and per-query mismatch cutoffs using distinct-record counts, with native
14
+ reduction of repeated offsets and rejection of truncated results.
15
+
16
+ ### Fixed
17
+
18
+ - Stable small Poisson tails and tiny expectation probabilities.
19
+ - Shared native batches respect detected CPU allocations and reject oversubscribed
20
+ worker counts; serial budgets execute inline and all pools close before returning.
21
+ - Setup combines requested test and benchmark extras and propagates installation errors.
22
+ - Local agent instructions, planning notes and scratch scripts are excluded from distributions.
23
+
24
+ ### API details
25
+
26
+ - **E-values for substring hits.** `text_evalues` and `text_threshold_for_evalue`
27
+ calibrate a `TextIndex` hit against a background text, the way `evalues` already
28
+ does for an `Index`:
29
+
30
+ ```python
31
+ proteome = seqtree.TextIndex.build(records, "aa", k=5)
32
+ decoy = seqtree.TextIndex.build(shuffled, "aa", k=5)
33
+ for q, r in zip(peptides, seqtree.text_evalues(proteome, decoy, peptides, max_subs=2)):
34
+ if r["p_enrichment"] < 0.01:
35
+ ...
36
+ ```
37
+
38
+ The arithmetic is `evalue.evalue_result` unchanged; what is specific to text search
39
+ is two counting choices. **Counts are per distinct record, not per
40
+ offset**: one query typically hits a record at several overlapping offsets, those
41
+ events are strongly dependent, and counting them separately gives the
42
+ over-dispersed compound-Poisson count `appendix/evalue.tex` collapses away by
43
+ counting unique clonotypes rather than reads. `N` and `M` are `num_refs`. **The ball
44
+ is `max_subs`, never `TextResult.score`**: that score is a similarity, `0` at an
45
+ exact match and more negative for a worse one, the opposite sign to the
46
+ non-negative penalty `thetas_from_scores` inverts; `n_subs` is non-negative,
47
+ integer and monotone in the scope, so it nests the way the proof requires.
48
+
49
+ Two limits, both refusals rather than partial answers. A search whose hits were
50
+ capped by `max_hits` raises, because a truncated count is a floor and an E-value
51
+ computed from it is simply wrong. And there is no `load_control` for texts: the
52
+ background must be composition- and length-matched to the target, that assumption
53
+ is what the whole calibration rests on, and nothing in the library can check it, so
54
+ it stays the caller's explicit choice.
55
+
56
+ - **Bounded exhaustive gapblock reductions.** `topk_batch` keeps
57
+ stable `(score, ref_id)` top-k rows without a dense score matrix; `count_batch`
58
+ counts all ties at arbitrary per-query thresholds. Linked two-view variants use
59
+ the maximum lane penalty and preserve both lane scores. Exact identity exclusion
60
+ precedes selection and removes only both-view identity for linked inputs.
61
+ `Index.ref_seqs()` retrieves stored reference strings in ID order in one native
62
+ call. All scoring reuses the existing single-gap-block cell; no calibration,
63
+ domain payload or runtime dependency is introduced.
64
+
65
+ - **`TextIndex` searches with insertions and deletions.** `search_batch` takes `max_indels`
66
+ alongside `max_subs`, capped independently — `max_subs=2, max_indels=1` accepts two
67
+ substitutions *and* one gap, not three edits of any kind.
68
+
69
+ ```python
70
+ res = ix.search_batch(peptides, max_subs=1, max_indels=1, threads=0)
71
+ h = res[0][0]
72
+ ix.ref_seq(h.ref_id)[h.offset : h.offset + h.length] # the matched text
73
+ h.n_subs, h.n_ins, h.n_dels
74
+ ```
75
+
76
+ **The index, the build and the probe enumeration are unchanged.** With
77
+ `b = max_subs + max_indels + 1` disjoint blocks the total edit count cannot reach the block
78
+ count, so the same pigeonhole argument still leaves a **zero-error** block — and a zero-error
79
+ block matches the text exactly whether or not the occurrence carries gaps, so the existing
80
+ exact seed lookup finds it. Verified before any of this was written: 2,800 planted
81
+ occurrences with random insertions and deletions, **recall 1.000** from the unmodified exact
82
+ lookup.
83
+
84
+ What changes is verification. A seed no longer pins the start — the prefix before the block
85
+ may have gained or lost up to `max_indels` residues — so every start in
86
+ `[pos - base - max_indels, pos - base + max_indels]` is aligned by a banded DP over the states
87
+ `(query position, net offset, indels spent)`. An alignment must begin and end on an aligned
88
+ pair: a gap at either edge would stretch the reported interval over a residue that matches
89
+ nothing, and a leading gap is the same occurrence starting one residue over, so allowing it
90
+ would report one match several times.
91
+
92
+ `TextHit` gains `n_ins`, `n_dels` and **`length`** — a gapped match is no longer `len(query)`
93
+ residues wide, and `length` is `len(query) + n_dels - n_ins`. `TextResult.arrays()` gains the
94
+ three matching columns.
95
+
96
+ Two limits, both refusals rather than partial answers. Every seed block must be exact, so a
97
+ query must be at least `(max_subs + max_indels + 1) * k` long — at `k = 4` with
98
+ `max_subs=1, max_indels=1` that is 12 residues — and a shorter one raises with the bound in
99
+ the message. And `mismatches` is empty on this path, with `matrix=` ignored: recovering which
100
+ columns were substituted needs an alignment traceback the verifier does not keep.
101
+
102
+ Measured on a synthetic 5,000,000-residue corpus at `k = 4`, one thread, against the same
103
+ queries with `max_indels = 0`:
104
+
105
+ | query length | `max_subs` | ms/query, no indels | ms/query, `max_indels=1` |
106
+ |--:|--:|--:|--:|
107
+ | 16 | 1 | 0.001 | 0.028 |
108
+ | 16 | 2 | 0.001 | 0.043 |
109
+ | 20 | 3 | 0.002 | 0.065 |
110
+ | 24 | 3 | 0.002 | 0.067 |
111
+
112
+ 25–37× an ungapped search at the same length and `max_subs`, and still under 0.1 ms/query.
113
+ An early exit when every banded state has already breached a cap is worth 2.5–3× of that; the
114
+ DP was otherwise walking all L rows on candidates the Hamming path rejects after two residues.
115
+
116
+ Completeness is held to the same standard as the substitution path: **set equality against an
117
+ independent brute force**, over `k` ∈ {3, 4, 5} × `max_subs` 0–3 × `max_indels` 1–2,
118
+ **6,732 occurrences, zero missing and zero extra**. `max_indels=0` is byte-identical to the
119
+ previous substitution path, hit for hit, and pays none of the cost.
120
+
121
+ ## [1.0.1] — 2026-10-09
122
+
123
+ ### Fixed
124
+
125
+ - A one-thread batch executes on the calling thread instead of launching a worker.
126
+ - Worker scratch-allocation exceptions propagate to the caller rather than terminating the process.
127
+ - A partially started worker pool joins its running workers if thread creation fails.
128
+
6
129
  ## [1.0.0] — 2026-09-06
7
130
 
8
131
  ### Added
@@ -572,7 +695,7 @@ Numbers that constrain the API, all reproducible from `bench/` and the downstrea
572
695
 
573
696
  ### Docs
574
697
 
575
- - `skills/seqtree/SKILL.md` — public API surface, invariants, and the gotchas that have bitten.
698
+ - Public API surface, invariants, and gotchas are documented in `docs/api.rst` and the API guides.
576
699
  - `docs/gapblock.rst` — a worked guide: why one gap block, how to choose its position, why a
577
700
  placement rule is a column frame, and why a fixed score cutoff is not a calibrated one.
578
701
  - README corrected: `seqtrie` is a full-width DP that ignores per-type caps, not a banded one, and
@@ -65,6 +65,7 @@ if(SEQTREE_TESTS)
65
65
  tests/cpp/test_positional.cpp
66
66
  tests/cpp/test_kmer_index.cpp
67
67
  tests/cpp/test_text_index.cpp
68
+ tests/cpp/test_parallel.cpp
68
69
  )
69
70
  target_include_directories(seqtree_tests PRIVATE tests/cpp src)
70
71
  target_link_libraries(seqtree_tests PRIVATE seqtree_core)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: seqtree
3
- Version: 1.0.0
3
+ Version: 1.1.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>
@@ -135,7 +135,10 @@ Beyond search, seqtree ships:
135
135
  completeness pinned by brute-force **set equality** over L 6–30 × `max_subs` 0–3 ×
136
136
  k ∈ {3,4,5}, and by the answer being identical across `k`. On the human proteome
137
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
138
+ 15-mer within 3 in **1.5 ms**. `max_indels` adds insertions and deletions on the same index
139
+ and the same scheme — with `max_subs + max_indels + 1` blocks the edit count is still below
140
+ the block count, so pigeonhole leaves a zero-error block and the *unchanged* exact seed lookup
141
+ finds gapped occurrences too; only verification becomes a banded DP. Results come back as flat CSR arrays with zero-copy
139
142
  `numpy` views, mismatches as `(pos, query_aa, text_aa)` pairs, an optional fold onto
140
143
  caller-supplied group ids that makes a tie explicit, and a cap that is always reported.
141
144
  - **E-values / significance** — calibrate hit counts against a background control repertoire
@@ -103,7 +103,10 @@ Beyond search, seqtree ships:
103
103
  completeness pinned by brute-force **set equality** over L 6–30 × `max_subs` 0–3 ×
104
104
  k ∈ {3,4,5}, and by the answer being identical across `k`. On the human proteome
105
105
  (69,578,135 residues) one thread answers a 9-mer within 2 substitutions in **1.3 ms** and a
106
- 15-mer within 3 in **1.5 ms**. Results come back as flat CSR arrays with zero-copy
106
+ 15-mer within 3 in **1.5 ms**. `max_indels` adds insertions and deletions on the same index
107
+ and the same scheme — with `max_subs + max_indels + 1` blocks the edit count is still below
108
+ the block count, so pigeonhole leaves a zero-error block and the *unchanged* exact seed lookup
109
+ finds gapped occurrences too; only verification becomes a banded DP. Results come back as flat CSR arrays with zero-copy
107
110
  `numpy` views, mismatches as `(pos, query_aa, text_aa)` pairs, an optional fold onto
108
111
  caller-supplied group ids that makes a tie explicit, and a cap that is always reported.
109
112
  - **E-values / significance** — calibrate hit counts against a background control repertoire
@@ -39,7 +39,7 @@ public:
39
39
  // For each query (its k-mer list), return candidates with >= min_shared shared k-mers,
40
40
  // ranked by (shared_kmers desc, best_score asc). `params` drives the per-k-mer fuzzy
41
41
  // match (max_subs / pos_matrix / engine). allele_filter >= 0 keeps only peptides with
42
- // that allele tag; < 0 means no filter. threads <= 0 => hardware_concurrency.
42
+ // that allele tag; < 0 means no filter. threads <= 0 => available CPU budget.
43
43
  std::vector<std::vector<Candidate>> seed_and_gather(
44
44
  const std::vector<std::vector<std::string>>& query_kmers, const SearchParams& params,
45
45
  uint32_t min_shared = 1, int64_t allele_filter = -1, int threads = 0) const;
@@ -0,0 +1,134 @@
1
+ #pragma once
2
+ // One parallel-for scaffold for the whole tree. Every batch entry point in seqtree used to
3
+ // carry its own copy of this: an atomic cursor, an adaptive chunk, an exception_ptr, a vector
4
+ // of threads, and a rethrow after the join. Seven copies drifted -- one of them had no
5
+ // exception plumbing at all.
6
+ #include <algorithm>
7
+ #include <atomic>
8
+ #include <cstddef>
9
+ #include <cstdint>
10
+ #include <cstdlib>
11
+ #include <exception>
12
+ #include <fstream>
13
+ #include <mutex>
14
+ #include <stdexcept>
15
+ #include <thread>
16
+ #include <vector>
17
+
18
+ #ifdef __linux__
19
+ #include <sched.h>
20
+ #elif defined(__APPLE__)
21
+ #include <sys/sysctl.h>
22
+ #elif defined(_WIN32)
23
+ #ifndef NOMINMAX
24
+ #define NOMINMAX
25
+ #endif
26
+ #ifndef WIN32_LEAN_AND_MEAN
27
+ #define WIN32_LEAN_AND_MEAN
28
+ #endif
29
+ #include <windows.h>
30
+ #endif
31
+
32
+ namespace seqtree {
33
+
34
+ // Resolve once per batch; affinity/quota limits can change between calls.
35
+ inline unsigned available_cpus() {
36
+ unsigned count = std::max(1u, std::thread::hardware_concurrency());
37
+ #ifdef __linux__
38
+ cpu_set_t affinity;
39
+ if (sched_getaffinity(0, sizeof(affinity), &affinity) == 0)
40
+ count = std::min(count, unsigned(std::max(1, CPU_COUNT(&affinity))));
41
+ std::ifstream limits("/sys/fs/cgroup/cpu.max");
42
+ uint64_t quota, period;
43
+ if (limits >> quota >> period && period)
44
+ count = unsigned(std::min<uint64_t>(count, std::max<uint64_t>(1, quota / period)));
45
+ for (const char* name : {"SLURM_CPUS_PER_TASK", "SLURM_CPUS_ON_NODE"}) {
46
+ if (const char* value = std::getenv(name)) {
47
+ char* end = nullptr;
48
+ const auto allocated = std::strtoul(value, &end, 10);
49
+ if (end != value && !*end && allocated)
50
+ count = unsigned(std::min<unsigned long>(count, allocated));
51
+ }
52
+ }
53
+ #elif defined(__APPLE__)
54
+ unsigned physical = count;
55
+ size_t size = sizeof(physical);
56
+ if (sysctlbyname("hw.physicalcpu", &physical, &size, nullptr, 0) == 0 && physical)
57
+ count = std::min(count, physical);
58
+ #elif defined(_WIN32)
59
+ DWORD_PTR process_mask, system_mask;
60
+ if (GetProcessAffinityMask(GetCurrentProcess(), &process_mask, &system_mask)) {
61
+ unsigned allowed = 0;
62
+ for (; process_mask; process_mask &= process_mask - 1) ++allowed;
63
+ if (allowed) count = std::min(count, allowed);
64
+ }
65
+ #endif
66
+ return count;
67
+ }
68
+
69
+ // Runs body(i, local) for every i in [0, n), across nt workers pulling contiguous chunks off
70
+ // one atomic cursor. `threads <= 0` means the detected available CPU budget.
71
+ //
72
+ // `make_local()` is called once per worker and its result handed to every body() that worker
73
+ // runs -- that is how a Searcher or a scratch buffer gets allocated once per thread rather
74
+ // than once per item. Return a dummy for kernels that need no state.
75
+ //
76
+ // The chunk is adaptive: large batches keep ~1024 items per grab so the atomic is not the
77
+ // bottleneck, small ones split ~8 chunks per thread so no worker goes idle. Raise `chunk_min`
78
+ // when the per-item output struct is small enough that neighbouring workers would share a
79
+ // cache line (TextIndex's QueryOut is ~80 bytes, so it passes 8).
80
+ //
81
+ // Workers catch, the first exception is kept, and it is rethrown after the join. This is not
82
+ // optional: an exception escaping a std::thread entry function calls std::terminate, which
83
+ // from Python is an uncatchable SIGABRT rather than a raise.
84
+ template <class MakeLocal, class Body>
85
+ void parallel_for(size_t n, int threads, MakeLocal make_local, Body body,
86
+ size_t chunk_min = 1, size_t chunk_max = 1024) {
87
+ const unsigned available = available_cpus();
88
+ unsigned nt = threads > 0 ? unsigned(threads) : available;
89
+ if (nt > available || nt >= 1000)
90
+ throw std::invalid_argument("worker count exceeds the available CPU budget; request fewer threads");
91
+ if (n == 0) return;
92
+ nt = unsigned(std::min<size_t>(nt, n)); // Do not start idle workers for absent work.
93
+
94
+ // A serial budget runs on the calling thread; spawning one worker only adds overhead.
95
+ if (nt == 1) {
96
+ auto local = make_local();
97
+ for (size_t i = 0; i < n; ++i) body(i, local);
98
+ return;
99
+ }
100
+
101
+ std::atomic<size_t> next{0};
102
+ const size_t chunk = std::clamp<size_t>(n / (size_t(nt) * 8), chunk_min, chunk_max);
103
+ std::exception_ptr err;
104
+ std::mutex emu;
105
+
106
+ auto worker = [&] {
107
+ // Scratch construction can allocate and throw too. Nothing may escape the entry point.
108
+ try {
109
+ auto local = make_local();
110
+ for (;;) {
111
+ const size_t start = next.fetch_add(chunk);
112
+ if (start >= n) break;
113
+ const size_t end = std::min(n, start + chunk);
114
+ for (size_t i = start; i < end; ++i) {
115
+ body(i, local);
116
+ }
117
+ }
118
+ } catch (...) {
119
+ std::lock_guard<std::mutex> lk(emu);
120
+ if (!err) err = std::current_exception();
121
+ }
122
+ };
123
+
124
+ // A failed thread launch must also join workers already started before unwinding.
125
+ std::vector<std::jthread> pool;
126
+ pool.reserve(nt);
127
+ // The caller is one compute thread: a budget B creates only B-1 OS workers.
128
+ for (unsigned t = 1; t < nt; ++t) pool.emplace_back(worker);
129
+ worker();
130
+ for (auto& th : pool) th.join();
131
+ if (err) std::rethrow_exception(err);
132
+ }
133
+
134
+ } // namespace seqtree
@@ -5,6 +5,7 @@
5
5
  #include <memory>
6
6
  #include <string>
7
7
  #include <string_view>
8
+ #include <tuple>
8
9
  #include <vector>
9
10
 
10
11
  namespace seqtree {
@@ -120,10 +121,15 @@ public:
120
121
  Alignment align(std::string_view query, uint32_t ref_id, const SearchParams&) const;
121
122
 
122
123
  // Parallel batch: pure C++ (no Python), one Searcher per worker thread.
123
- // threads <= 0 => hardware_concurrency. Results aligned with input order.
124
+ // threads <= 0 => available CPU budget. Results aligned with input order.
124
125
  std::vector<std::vector<Hit>> search_batch(const std::vector<std::string>& queries,
125
126
  const SearchParams&, int threads = 0) const;
126
127
 
128
+ // Exact edit-count bins 0..max_total_edits; one reusable hit buffer per worker.
129
+ std::vector<std::vector<uint64_t>> edit_histogram_batch(
130
+ const std::vector<std::string>& queries, const SearchParams&,
131
+ int threads = 0, bool exclude_exact = false) const;
132
+
127
133
  // Per-query count of seqtm collisions: how often a reference was re-reached via a
128
134
  // different edit path during branch-and-bound (0 for the seqtrie engine / Hamming).
129
135
  std::vector<uint64_t> collisions_batch(const std::vector<std::string>& queries,
@@ -194,7 +200,7 @@ Alignment align_pair(std::string_view query, std::string_view ref, const Substit
194
200
  Alphabet, AlignMode, int32_t gap_open, int32_t gap_extend);
195
201
 
196
202
  // Dense N*K similarity matrix, row-major (row i is queries[i] vs every ref). Parallel;
197
- // threads <= 0 => hardware_concurrency.
203
+ // threads <= 0 => available CPU budget.
198
204
  std::vector<int32_t> align_score_matrix(const std::vector<std::string>& queries,
199
205
  const std::vector<std::string>& refs,
200
206
  const SubstitutionMatrix&, Alphabet, AlignMode,
@@ -214,12 +220,106 @@ std::vector<int32_t> align_dist_matrix(const std::vector<std::string>& queries,
214
220
  // The block of length d = |m-n| is placed at whichever of the min(m,n)+1 positions minimises
215
221
  // substitution cost plus `prior`. `prior` is the gap prior flattened to [m][d][i] with stride
216
222
  // (prior_width+1); pass it empty for no prior. It is read only when d > 0, which is what keeps
217
- // the diagonal zero. `matrix` may be null for unit cost. threads <= 0 => hardware_concurrency.
223
+ // the diagonal zero. `matrix` may be null for unit cost. threads <= 0 => available CPU budget.
224
+ // Optional integer weights are indexed [longer length][longer position], symmetric in q/r.
225
+ // All input frames must be covered; empty tables retain the existing unweighted scorer.
226
+ // Alternatively, per-input rows index each sequence's full coordinates. Aligned
227
+ // substitution costs use max(query weight, reference weight); gaps are unchanged.
228
+ // Both axes are required; these rows cannot combine with length profiles.
229
+ // Optional group IDs index a nonnegative rectangular penalty table added per pair.
230
+ // Exact exclusion remains encoded junction identity, independent of these groups.
218
231
  std::vector<int32_t> gapblock_matrix(const std::vector<std::string>& queries,
219
232
  const std::vector<std::string>& refs, Alphabet,
220
233
  const SubstitutionMatrix* matrix, int32_t gap_open,
221
234
  int32_t gap_extend, const std::vector<int32_t>& prior,
222
- uint32_t prior_width, int threads = 0);
235
+ uint32_t prior_width, int threads = 0,
236
+ const std::vector<std::vector<int32_t>>& weights = {},
237
+ const std::vector<int32_t>& query_groups = {},
238
+ const std::vector<int32_t>& reference_groups = {},
239
+ const std::vector<std::vector<int32_t>>& group_distances = {},
240
+ const std::vector<std::vector<int32_t>>& query_weights = {},
241
+ const std::vector<std::vector<int32_t>>& reference_weights = {});
242
+
243
+ // Exhaustive ball rows in reference order; hit edit fields are unset (zero).
244
+ std::vector<std::vector<Hit>> gapblock_ball_batch(const std::vector<std::string>& queries,
245
+ const std::vector<std::string>& refs, const std::vector<int32_t>& thresholds,
246
+ Alphabet alphabet, const SubstitutionMatrix* matrix, int32_t go, int32_t ge,
247
+ const std::vector<int32_t>& prior, uint32_t width, int threads, bool exclude_exact,
248
+ const std::vector<std::vector<int32_t>>& weights = {},
249
+ const std::vector<int32_t>& query_groups = {},
250
+ const std::vector<int32_t>& reference_groups = {},
251
+ const std::vector<std::vector<int32_t>>& group_distances = {},
252
+ const std::vector<std::vector<int32_t>>& query_weights = {},
253
+ const std::vector<std::vector<int32_t>>& reference_weights = {});
254
+
255
+ // Exhaustive bounded reductions using the same gapblock cell. Top-k ties use
256
+ // (penalty, ref_id); paired penalty is max(lane scores). Exact exclusion uses
257
+ // encoded sequence identity (both lanes for pairs), never zero penalty alone.
258
+ // Count the component ball before/after group offsets; minimum full score within it.
259
+ // One threshold per query; nearest=-1 denotes an empty component ball. O(Q) output.
260
+ std::tuple<std::vector<uint64_t>, std::vector<uint64_t>, std::vector<int32_t>>
261
+ gapblock_group_audit_batch(const std::vector<std::string>& queries,
262
+ const std::vector<std::string>& refs, const std::vector<int32_t>& thresholds,
263
+ Alphabet alphabet, const SubstitutionMatrix* matrix, int32_t go, int32_t ge,
264
+ const std::vector<int32_t>& prior, uint32_t width, int threads, bool exclude_exact,
265
+ const std::vector<std::vector<int32_t>>& weights = {},
266
+ const std::vector<int32_t>& query_groups = {},
267
+ const std::vector<int32_t>& reference_groups = {},
268
+ const std::vector<std::vector<int32_t>>& group_distances = {},
269
+ const std::vector<std::vector<int32_t>>& query_weights = {},
270
+ const std::vector<std::vector<int32_t>>& reference_weights = {});
271
+
272
+ std::vector<std::vector<Hit>> gapblock_topk_batch(const std::vector<std::string>& queries,
273
+ const std::vector<std::string>& refs, uint32_t k, Alphabet alphabet,
274
+ const SubstitutionMatrix* matrix, int32_t go, int32_t ge, const std::vector<int32_t>& prior,
275
+ uint32_t width, int threads, bool exclude_exact);
276
+ std::vector<std::vector<std::tuple<uint32_t, int32_t, int32_t>>> gapblock_paired_topk_batch(
277
+ const std::vector<std::string>& qa, const std::vector<std::string>& qb,
278
+ const std::vector<std::string>& ra, const std::vector<std::string>& rb,
279
+ uint32_t k, Alphabet alphabet, const SubstitutionMatrix* matrix, int32_t go, int32_t ge,
280
+ const std::vector<int32_t>& prior, uint32_t width, int threads, bool exclude_exact);
281
+ std::vector<std::vector<uint64_t>> gapblock_count_batch(const std::vector<std::string>& queries,
282
+ const std::vector<std::string>& refs, const std::vector<std::vector<int32_t>>& thresholds,
283
+ Alphabet alphabet, const SubstitutionMatrix* matrix, int32_t go, int32_t ge,
284
+ const std::vector<int32_t>& prior, uint32_t width, int threads, bool exclude_exact,
285
+ const std::vector<std::vector<int32_t>>& weights = {},
286
+ const std::vector<int32_t>& query_groups = {},
287
+ const std::vector<int32_t>& reference_groups = {},
288
+ const std::vector<std::vector<int32_t>>& group_distances = {},
289
+ const std::vector<std::vector<int32_t>>& query_weights = {},
290
+ const std::vector<std::vector<int32_t>>& reference_weights = {});
291
+ std::pair<std::vector<std::vector<uint64_t>>, std::vector<std::vector<uint64_t>>> gapblock_count_mass_batch(const std::vector<std::string>& queries,
292
+ const std::vector<std::string>& refs, const std::vector<std::vector<int32_t>>& thresholds,
293
+ Alphabet alphabet, const SubstitutionMatrix* matrix, int32_t go, int32_t ge,
294
+ const std::vector<int32_t>& prior, uint32_t width, int threads, bool exclude_exact,
295
+ const std::vector<std::vector<int32_t>>& weights = {},
296
+ const std::vector<int32_t>& query_groups = {},
297
+ const std::vector<int32_t>& reference_groups = {},
298
+ const std::vector<std::vector<int32_t>>& group_distances = {},
299
+ const std::vector<std::vector<int32_t>>& query_weights = {},
300
+ const std::vector<std::vector<int32_t>>& reference_weights = {});
301
+ std::vector<std::vector<uint64_t>> gapblock_paired_count_batch(
302
+ const std::vector<std::string>& qa, const std::vector<std::string>& qb,
303
+ const std::vector<std::string>& ra, const std::vector<std::string>& rb,
304
+ const std::vector<std::vector<int32_t>>& thresholds,
305
+ Alphabet alphabet, const SubstitutionMatrix* matrix, int32_t go, int32_t ge,
306
+ const std::vector<int32_t>& prior, uint32_t width, int threads, bool exclude_exact);
307
+ std::pair<std::vector<std::vector<uint64_t>>, std::vector<std::vector<uint64_t>>> gapblock_paired_sum_count_batch(
308
+ const std::vector<std::string>& qa, const std::vector<std::string>& qb,
309
+ const std::vector<std::string>& ra, const std::vector<std::string>& rb,
310
+ const std::vector<std::vector<int32_t>>& thresholds,
311
+ Alphabet alphabet, const SubstitutionMatrix* matrix, int32_t go, int32_t ge,
312
+ const std::vector<int32_t>& prior, uint32_t width, int threads, bool exclude_exact,
313
+ const std::vector<std::vector<int32_t>>& weights_alpha,
314
+ const std::vector<std::vector<int32_t>>& weights_beta,
315
+ const std::vector<int32_t>& query_groups_alpha, const std::vector<int32_t>& query_groups_beta,
316
+ const std::vector<int32_t>& reference_groups_alpha, const std::vector<int32_t>& reference_groups_beta,
317
+ const std::vector<std::vector<int32_t>>& group_distances_alpha,
318
+ const std::vector<std::vector<int32_t>>& group_distances_beta,
319
+ const std::vector<std::vector<int32_t>>& query_weights_alpha = {},
320
+ const std::vector<std::vector<int32_t>>& query_weights_beta = {},
321
+ const std::vector<std::vector<int32_t>>& reference_weights_alpha = {},
322
+ const std::vector<std::vector<int32_t>>& reference_weights_beta = {});
223
323
 
224
324
  // ---------------------------------------------------------------------------------------
225
325
  // Plain string edit distances on raw characters, unit costs. Deliberately alphabet-agnostic:
@@ -235,7 +335,7 @@ int hamming(std::string_view a, std::string_view b);
235
335
  int levenshtein(std::string_view a, std::string_view b);
236
336
 
237
337
  // Dense N*K distance matrices, row-major (row i is a[i] vs every b[k]). Parallel;
238
- // threads <= 0 => hardware_concurrency. hamming_matrix throws on any length-mismatched pair.
338
+ // threads <= 0 => available CPU budget. hamming_matrix throws on any length-mismatched pair.
239
339
  std::vector<int32_t> hamming_matrix(const std::vector<std::string>& a,
240
340
  const std::vector<std::string>& b, int threads = 0);
241
341
  std::vector<int32_t> levenshtein_matrix(const std::vector<std::string>& a,
@@ -13,12 +13,24 @@ struct TextStore; // internal, src/text_index.cpp
13
13
  // One row of the optional group fold: hits collapsed onto the build-time group ids.
14
14
  struct TextGroup {
15
15
  uint32_t group_id = 0;
16
- uint16_t min_subs = 0; // best (smallest) Hamming distance reaching this group
16
+ uint16_t min_subs = 0; // best (smallest) substitution count reaching this group
17
17
  uint32_t n_hits = 0; // how many hits fell in it, at any distance
18
18
  };
19
19
 
20
20
  struct TextQueryOpts {
21
21
  uint16_t max_subs = 0;
22
+ // Insertions + deletions allowed, capped independently of max_subs. 0 keeps the pure
23
+ // Hamming predicate and its verification path untouched.
24
+ //
25
+ // The seed table does not change: with b = max_subs + max_indels + 1 disjoint blocks the
26
+ // total edit count is below the block count, so by pigeonhole SOME block still matches
27
+ // EXACTLY -- the existing exact lookup finds every occurrence, indels included. What that
28
+ // costs is verification, which becomes a banded alignment over the 2*max_indels+1 possible
29
+ // starts rather than one Hamming scan at a fixed start.
30
+ //
31
+ // Because every block must be exact, this needs L >= (max_subs + max_indels + 1) * k;
32
+ // search_batch refuses a shorter query rather than answering incompletely.
33
+ uint16_t max_indels = 0;
22
34
  bool exclude_exact = false; // drop 0-mismatch hits (a candidate that IS the text)
23
35
  bool best_only = false; // keep only the minimum-n_subs shell (still ALL of it)
24
36
  bool group_by = false; // also fold hits onto the build-time group ids
@@ -40,6 +52,11 @@ struct TextResult {
40
52
  std::vector<uint32_t> query_begin; // n_queries + 1, CSR into the hit arrays
41
53
  std::vector<uint32_t> ref_id, offset;
42
54
  std::vector<uint16_t> n_subs;
55
+ // Indels in the alignment, and the matched substring's length in the text. With
56
+ // max_indels = 0 these are 0, 0 and the query length for every hit. With indels the match
57
+ // is no longer len(query) residues wide, so `length` is what bounds it:
58
+ // ref_seq(ref_id)[offset : offset + length].
59
+ std::vector<uint16_t> n_ins, n_dels, length;
43
60
  std::vector<int32_t> score;
44
61
  std::vector<uint32_t> mm_begin; // n_hits + 1, CSR into the mismatch arrays
45
62
  std::vector<uint16_t> mm_pos;
@@ -53,6 +70,8 @@ struct TextResult {
53
70
 
54
71
  size_t num_queries() const { return query_begin.empty() ? 0 : query_begin.size() - 1; }
55
72
  size_t num_hits() const { return ref_id.size(); }
73
+ // Best substitution distance per distinct record, sorted per query. Reject capped hits.
74
+ std::vector<std::vector<uint16_t>> record_distances() const;
56
75
  };
57
76
 
58
77
  // Exact k-mismatch (Hamming) search over a CONCATENATED reference text.
@@ -91,7 +110,7 @@ public:
91
110
 
92
111
  // Every query must be at least k long: seeds are only indexed where a whole k-mer fits
93
112
  // inside a record, so a shorter query could match within k-L of a record end and be
94
- // missed. Raises rather than answering incompletely. threads <= 0 => hardware_concurrency.
113
+ // missed. Raises rather than answering incompletely. threads <= 0 => available CPU budget.
95
114
  TextResult search_batch(const std::vector<std::string>& queries, const TextQueryOpts&,
96
115
  int threads = 0) const;
97
116
 
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
4
4
 
5
5
  [project]
6
6
  name = "seqtree"
7
- version = "1.0.0"
7
+ version = "1.1.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"
@@ -40,9 +40,10 @@ wheel.packages = ["python/seqtree"]
40
40
  cmake.build-type = "Release"
41
41
  # Keep the sdist lean: ship only what builds the package. Anchor with a leading "/".
42
42
  sdist.exclude = [
43
- "/bench", "/tests", "/docs", "/.github", "/setup.sh", "/build",
43
+ "/bench", "/tests", "/docs", "/.github", "/setup.sh", "/build", "/appendix", "/examples",
44
44
  # Local agent config: machine-specific absolute paths and unrelated repo names.
45
- "/.claude", "/CLAUDE.md",
45
+ "/.claude", "/CLAUDE.md", "/AGENTS.md", "/ROADMAP.md", "/SOURCES.md", "/skills",
46
+ "/tmp", "/add_bindings.py", "/add_native.py", "/appendix/immunogenicity_hooks.md",
46
47
  ]
47
48
  editable.rebuild = false
48
49
 
@@ -34,6 +34,7 @@ from ._core import (
34
34
  )
35
35
  from .control import load_control
36
36
  from .evalue import evalues, thetas_from_scores, threshold_for_evalue
37
+ from .text_evalue import text_evalues, text_threshold_for_evalue
37
38
  from . import distance, gapblock, layout, pairwise, pmhc, seeds
38
39
  from .gapblock import (
39
40
  GapBlockIndex, IslandProfile, ScoreMatrix, central_prior, embed_in_frame, frame_prior,
@@ -87,4 +88,6 @@ __all__ = [
87
88
  "evalues",
88
89
  "thetas_from_scores",
89
90
  "threshold_for_evalue",
91
+ "text_evalues",
92
+ "text_threshold_for_evalue",
90
93
  ]
@@ -22,17 +22,31 @@ import warnings
22
22
 
23
23
 
24
24
  def _poisson_sf(k, lam):
25
- """P(Poisson(lam) >= k) via the complementary CDF (stable for small k)."""
25
+ """P(Poisson(lam) >= k), summing the smaller tail from a log-PMF seed."""
26
26
  if k <= 0:
27
27
  return 1.0
28
28
  if lam <= 0.0:
29
29
  return 0.0
30
- term = math.exp(-lam)
31
- cdf = term
32
- for i in range(1, k):
33
- term *= lam / i
34
- cdf += term
35
- return min(1.0, max(0.0, 1.0 - cdf))
30
+ if lam == math.inf:
31
+ return 1.0
32
+ # Sum the upper tail directly when small: 1-CDF loses significant digits.
33
+ upper = k > lam
34
+ i = k if upper else k - 1
35
+ log_pmf = i * math.log(lam) - lam - math.lgamma(i + 1)
36
+ # Normalize before summation so a subnormal seed cannot erase a representable tail.
37
+ term = total = 1.0
38
+ while term > total * 1e-15:
39
+ if upper:
40
+ i += 1
41
+ term *= lam / i
42
+ else:
43
+ if i == 0:
44
+ break
45
+ term *= i / lam
46
+ i -= 1
47
+ total += term
48
+ probability = math.exp(log_pmf + math.log(total))
49
+ return min(1.0, max(0.0, probability if upper else 1.0 - probability))
36
50
 
37
51
 
38
52
  def evalue_result(n_target, n_control, n_ref, m_control):
@@ -61,8 +75,7 @@ def evalue_result(n_target, n_control, n_ref, m_control):
61
75
  "n_target": n_target,
62
76
  "n_control": n_control,
63
77
  "E": E,
64
- # exp(-E) underflows to 0.0 well before E = 700, but guard the call anyway.
65
- "p_any": 1.0 - math.exp(-E) if E < 700 else 1.0,
78
+ "p_any": -math.expm1(-E),
66
79
  "p_enrichment": _poisson_sf(n_target, E),
67
80
  "rule_of_three": rule3,
68
81
  }