All notable changes to seqtree. Dates are release dates; the project is pre-1.0, so a minor
bump may carry breaking changes.
gapblock_matrix's out-of-alphabet error named only the bad symbol, not which sequence it came from ("symbol '_' is not in the alphabet") — unhelpful when a single malformed row (e.g. ajunction_aacontaining a legacy out-of-frame marker) is buried in a batch of hundreds of thousands. The message now names the side, index, and offending string:"queries[42] ('CASSIRS_YEQYF'): symbol '_' is not in the alphabet". No change to which symbols are accepted (still the standard 20 + B/Z/X/* foralphabet="aa").
-
structuralscored A–N contacts as if they did not interact, because the source table is corrupted there. The Miyazawa–Jernigan A–N contact energy was transcribed as0.00— the generator's comment excused it as a pair the source left unlisted. It is not unlisted. InMJ_Keskin_potentials.csvthe lower triangle runsA-A,R-A,R-R,[N-A],N-R, …, and theN-Aslot readsV,1(mirrored1,V), where1is a mangled residue symbol. The true value is 0.15. It is not a stray duplicate ofV-Neither — the V row separately listsN = 0.12.Substituting
0.00for0.15understated A's interaction strength and overstated N's (q(A)−0.04100 → −0.03350,q(N)+0.04350 → +0.05100). Sincestructuralis rank-1 by construction — every cell is a function of the 20 per-residue strengths — this moved the strong→weak ordering that the matrix exists to encode, swapping N and P:FWCLYMIVHGATNPRSQDEK→FWCLYMIVHGATPNRSQDEK.Four cells of the 24×24 grid change:
A-W/W-A6 → 5, andB-C/C-B4 → 3 (Bismean(N, D), so N's shift carries into it). Scores fromstructural()change for sequences containing A, W, or B; all other matrices are untouched. N and P were near-tied, which is why a real correction to the ordering moves so few cells. -
The same comment claimed the source is "near- but not perfectly symmetric". It is perfectly symmetric — 0 asymmetric directed pairs across all 400. The symmetrisation in
structural_grid()is a no-op guard, not a repair, and is now documented as such.
-
seqtree.distance— plain Hamming and Levenshtein distances, in C++, without a dependency. The unweighted corner of the library: unit costs, no substitution matrix, no gap model, no alphabet. When all you need is "how many edits apart are these two strings", you should not have to build aSubstitutionMatrixor addpython-Levenshtein/rapidfuzz. seqtree still needs nothing at runtime.distance.hamming(a, b)differing positions; equal length only (raises ValueErrorotherwise)distance.levenshtein(a, b)insertions + deletions + substitutions, each cost 1, O(min(m,n))memorydistance.hamming_matrix(a, b, threads=0)dense len(a) × len(b)int32, GIL released, zero-copy numpydistance.levenshtein_matrix(a, b, threads=0)same, for mixed-length sequences Comparison is case-sensitive, byte for byte — the one place the library does not fold case, since a generic string distance should report the difference it is asked about. For a weighted alignment (a matrix, affine gaps, local mode) use
seqtree.pairwiseinstead. Checked against pure-Python oracles over random data intests/python/test_distance.py.
-
seqtree.pairwise— Needleman-Wunsch and Smith-Waterman, so ordinary protein alignment no longer needs BioPython. Everything else in seqtree minimises a non-negative penalty, which is what a search ball and an E-value need. These maximise a raw log-odds similarity, the way BLAST and BioPython do, because that is what a pairwise alignment means.pairwise.score(q, r, matrix, mode=...)optimal score, O(min(m,n))memorypairwise.align(...)plus the aligned strings and ops pairwise.score_matrix(queries, refs, ...)dense n × K, GIL released, zero-copy numpypairwise.dist_matrix(...)d = s(a,a) + s(b,b) − 2·s(a,b): non-negative, zero on the diagonalmode="global"is Needleman-Wunsch,mode="local"Smith-Waterman, andgap_open == gap_extendgives linear gaps — no separate mode. A gap run of lengthLcostsgap_open + (L-1)·gap_extend, and global charges end gaps (true NW, not semi-global).It is a drop-in.
tests/python/test_pairwise.pyruns it againstBio.Align.PairwiseAligneras an oracle over three matrices × ten gap/mode settings × sixty sequence shapes — zero disagreements, including on real germline V genes. BioPython is a test-only dependency; seqtree still has zero required runtime dependencies and never imports it.Measured on an M3 (all-against-all, BLOSUM62, global, 11/1):
sequence length seqtree, 1 thread seqtree, 16 threads BioPython speedup 15 (a junction) 1.7 M pairs/s 20.1 M pairs/s 0.31 M pairs/s 65× 90 (a germline V gene) 72 k pairs/s 893 k pairs/s 10 k pairs/s 87× -
SubstitutionMatrix.similarity(a, b)— the raw signed log-odds, alongside the existing non-negativepenalty(a, b). The Gram transformpen = s(a,a) + s(b,b) − 2·s(a,b)is lossy: it forces the diagonal to zero and destroyss(a,a), so a similarity cannot be recovered from a penalty. Both views are now stored. -
SubstitutionMatrix.blosum45()and.blosum80(), and the names"BLOSUM45"/"BLOSUM80"wherever a matrix name is accepted. Shallower and deeper than BLOSUM62 — for remote and close homologs respectively.
Tagged but not released to PyPI. Everything below ships in 0.4.0, so upgrading from 0.3.0 straight to 0.4.0 picks it all up. Kept as its own section because it is a distinct set of fixes.
-
A cold cache shared by concurrent processes could hand back a half-written index.
Index::savewrote straight into the destination, so for the whole duration of the write the file existed but was truncated. A second process that checkedos.path.exists(cache)in that window loaded a stub and raisedRuntimeError: truncated or corrupt index. On a 45 MB control index the window is ~55 ms, and a reader racing a writer hit it 10 times out of 10.This is the first-use-only failure of any multi-process fan-out sharing
~/.cache: pytest-xdist, a Snakemake or Nextflow pipeline callingload_controlin parallel, amultiprocessingpool. Once the cache is warm it is read-only and was always safe. CI matrix jobs were never affected — separate runners, separate caches.Index::saveandKmerIndex::savenow serialize into a uniquely-named temporary beside the destination andrenameit into place. Rename is atomic on the same filesystem, on POSIX and Windows alike, so a reader sees either the previous complete file or the new complete file and never a partial one. A failed save cleans up its temporary and leaves any pre-existing index intact. -
A corrupt or stale cache now rebuilds instead of raising. A file truncated by a full disk, left by a killed process, or written by an older seqtree sent
load_controlinto an exception; it now falls back to rebuilding. The cache was always best-effort and now behaves that way. -
The control cache is content-addressed, so a stale cache can no longer be served silently. The key was
control_{name}_{size}.sqtree, which named neither the alphabet, nor the seed, nor the source data. Three consequences, all live:- Two calls differing only in
seed— which must draw different reservoir samples — shared one cache file, so the second silently received the first's sequences. Same foralphabet. - An upgrade that changed the bundled control kept the same filename, so a warm cache served the
previous release's control. This is exactly how 0.3.0's corrected (uniform) control could be
masked by a stale 0.2.0 (abundance-head) cache — and why 0.3.0's notes had to ask people to
rm ~/.cache/seqtree/control_*.sqtreeby hand.
The key now carries a fingerprint of the bundled asset's own bytes (or, on the download path, the source and seed), so a control that changed simply misses the old cache. Superseded caches, including pre-fingerprint ones from earlier releases, are deleted on the next build.
You no longer need to clear
~/.cache/seqtreewhen upgrading. Doing so is harmless. - Two calls differing only in
load_controltakes an inter-process lock around build-and-save whenfilelockis available (it arrives withhuggingface_hub). This is an optimisation, not the fix: correctness comes from the atomic rename and holds with no lock at all. What the lock saves is work — without it, a cold fan-out of N workers has every worker build the same 250k-clonotype index and discard N−1 of them. seqtree still has zero required runtime dependencies; the import is guarded.
Gap-block alignment, calibrated cutoffs, seed significance — the removal of several engine paths that returned confident wrong answers, and a corrected background control that changes every E-value.
-
The bundled control is a different set of sequences. It was the abundance head of the upstream repertoire — the 250,000 most expanded clonotypes — because both
gen_control.pyand_downloadtook the firstsizeunique rows of a count-descending table.appendix/evalue.tex(ass:indep) assumes the control's unique clonotypes are i.i.d. fromP₀. Measured against a uniform sample of the same size, the head is 25.8× more self-similar (P(Hamming≤2 | equal length) 3.11×10⁻³ vs 1.20×10⁻⁴) and carries 3.1× the ball mass at a BLOSUM62 budget of 40 (mean n_C 110.1 vs 35.5). Both are now uniform reservoir samples over unique productive clonotypes, seeded and shuffled so any prefix is itself a valid sub-sample.Every E-value moves. Delete
~/.cache/seqtree/control_*.sqtreeafter upgrading — a warm cache from 0.2.0 would otherwise be served in place of the corrected control. (Fixed in 0.3.1: the cache is now content-addressed and a stale one simply misses. Upgrading straight from 0.2.0 to ≥0.3.1 needs no manual step.) Numbers derived from the control are corrected throughout this file,seeds.py,SKILL.mdand the appendix. -
Controls are filtered to productive clonotypes. VDJtools marks out-of-frame rearrangements with
_and in-frame stops with*; 13.7% of the mouse TRB table is out of frame._cannot be repaired at the amino-acid level — VDJtools collapses a run of untranslatable positions into one character, so the residue count is already gone — and out-of-frame junctions escape thymic selection, making them an estimator ofP_gen, whichlem:hierarchysays is notP₀.load_control("mouse_trb_aa")previously raised on_; it now yields 694,241 clonotypes. -
engine="auto"now always resolves toseqtm. It previously routed matrix-plus-indel searches toseqtrie, whose budget defaults toINT_MAX/4, soSearchParams(max_subs=1, max_ins=1, matrix="BLOSUM62")silently returned every reference in the index.seqtrieignores per-type edit caps and can never honour them;autowill not pick it. Passing a matrix toseqtriewithout an explicitmax_penaltynow raises. -
Mode::Local/mode="local"deleted. It was a no-op: the field was stored and never read, with zero call sites acrossseqtree,vdjmatchandmhcmatch. -
GapPriortakes(block_start, block_length, longer_length)instead of(block_start, shorter_length).central_prior's output is bit-identical (|2i − (m−d)| == |2i + d − m|), and a frozen table pins that. -
gap_extendis now honoured.Index.alignis a real Gotoh affine alignment; previouslygap_extend=1andgap_extend=99produced identical scores and ops.
pairwise_batchsilently invertedn_ins/n_delswhen transposing, but only whenlen(a) >= len(b)— a size-dependent inversion.Index.aligndid not validate the query alphabet (unlikesearch_into), and accepted negative gap costs.Limits::max_hitswas set and never read; inmode="all"it truncated an unsorted list.- Stale docstring on
SubstitutionMatrix.from_similarity(claimedmax(s_aa, s_bb) − s_ab; the code is the Gram forms_aa + s_bb − 2·s_ab).
-
SubstitutionMatrix.scale()— the median mismatch penalty (BLOSUM62 → 14). Gap costs must live on the matrix's scale; the oldgap_open=1default made gaps ~14× cheaper than substitutions, soalign()would gap an equal-length pair rather than substitute. Usegap_open = 2 * matrix.scale(). -
seqtree.gapblock— single-contiguous-gap-block alignment for anchored junction loops.gapblock_scoreis the exactO(min(m,n))optimum (0 mismatches in 55,727 pairs against brute-force layout enumeration).GapBlockIndexreuses the existing Hamming engine over deletion variants; no new C++. -
Gap priors —
central_prior(lam),profile_prior(lam, w),frame_prior(lam, c)andembed_in_frame(seq, width, c). A sequence score alone cannot place the block: a hard central pin agrees with the flat, score-only choice on only 10.6% of pairs. -
gapblock.score_matrixandScoreMatrix— the densen × Kcounterpart ofGapBlockIndex.search, for prototype-distance embeddings, where nothing can be pruned because the distance to every reference is the output. C++, GIL released, one thread per core. On an M3 against 3,000 prototypes: 51.3 M pairs/s single-threaded, 532.7 M on 16 cores, versus 0.41 M for pure-Pythongapblock_score— while evaluating allL+1block positions, not a fixed shortlist. The prior is flattened once into an[m][d][i]cube, so the kernel never re-enters Python.ScoreMatrixcarries the CPython buffer protocol:numpy.asarraywraps it without copying, and seqtree keeps its zero runtime dependencies. -
gapblock.positions_prior(starts)— restrict the block to a fixed set of starts, negative values counting from the end, reproducing thegap_positions=(3, 4, -4, -3)convention that other junction aligners hardcode. Shipped for interoperability, not as a recommendation: at a matched false-positive rate on human TRB, candidate starts reach precision 0.156 against 0.414 for a single hard-pinned centre. -
gapblock.IslandProfile— a per-island position weight matrix whose column penalty is measured against the column's own consensus,pen(j, a) = round(lam·log(p_max_j / p_j(a))). A textbook log-odds score is signed and therefore not a ball; this one is>= 0, zero on the consensus, and flows throughthetas_from_scoresunchanged. The frame column defaults to the entropy-optimal one, which is modal atc = 6on real islands — where crystal structures put the block.Whether it beats scoring against every member depends entirely on the cutoff, which moves with
N: the E-value'sk = floor(e_target·M/N)is how many control neighbours the cutoff may admit, so the FPR isk/M. Over 108 calibrated VDJdb islands of ≥10 members (human TRB, three held-out splits, paired bootstrap over islands, 250k control negatives):regime FPR min-over-members IslandProfiledifference [95% CI] loose reference 1% 99.5% 99.1% −0.40 [−1.09, +0.14] per-epitope islands ( N= group, median 88)0.0568% 88.3% 89.3% +0.93 [−0.80, +2.79] repertoire annotation ( N≈20k)0.0012% 37.6% 48.5% +10.90 [+7.69, +14.21] So: no significant difference while building the islands, a large one when using them to annotate a repertoire (on islands ≥50 members, 9.8% vs 22.6%). At
N≈20kande_target=0.05,k=0andthetas_from_scoresreturns-1— the rule of three certifies noEbelow3N/M = 0.236, and that is the cutoff the third row uses.It does not generalise: same-epitope junctions in a different island are recovered by neither representation. Nor is it a compression — 1,176 B against 182 B of member strings, break-even at 84 members.
-
threshold_for_evalue/thetas_from_scores— invertÊ = (N/M)·n_Cinto the score cutoff that achieves a target E, per query. Exact rather than a root-find, because scores are integers. Returns-1wheree_target < 3N/M, i.e. where the control is too small to certify the bar. -
seqtree.seeds—core_kmersandSeedIndexgive control-calibrated E-values for shared core k-mers. A shared rare central k-mer is ~4× enriched among co-specific pairs, but covers only ~0.5% of them: seeds buy precision, not recall. -
bench/bench_gapblock.py— the gap-freedom ladder, from a hard central pin through priors to unrestricted affine.
Numbers that constrain the API, all reproducible from bench/ and the downstream repos:
-
One gap block is enough. Against a model-independent structural oracle (iterative superposition + unrestricted affine DP) over 3,049 crystal junction pairs from 199 unique sequences, the true correspondence is a single contiguous block in 95.2–100% of cases for every
d = 1..4. Forcing one block costs no median CA-RMSD. -
The restriction is free where it applies, and protective where it does not. At
gap_open = 2*scale, gap-block equals unrestricted affine on 98.8% of related pairs (one indel + 0–2 substitutions). On unrelated pairs affine undercuts it by a median of 106 penalty units — affine inventing an alignment that does not exist. Extra gap freedom buys manufactured similarity. -
A fixed score cutoff is not a calibrated cutoff. Building islands on human TRB by union-find at
gapblock_score ≤ 60, 31.7% of size-matched random control junctions land in a component of ≥5 — structure invented by the threshold. Per-query E-value edges atE* = 0.05cut that to 0.000 while raising the real signal: 2.334 edges per node against 0.021 for the control, which forms 19,248 singletons, 223 pairs, three components of size 3–4, and nothing larger. The control arm's realised edge rate lands onE*, which is the check that the calibration is honest. -
Mouse replicates it. Against the mouse TRB control (694,241 productive clonotypes), 5 epitopes and 1,692 TCRs give 5.856 calibrated edges per node against 0.019 for the control, which again forms nothing larger than a pair. At a fixed θ=40 the control still lands 18.3% of its nodes in components of ≥5.
-
Constraining the block is what buys precision. Compared at a matched false-positive rate — each rung given the cutoff at which its own ball admits
E*chance neighbours, since a freer rung finds lower scores and a fixed budget would reward it for that — retrieval precision on the length-different fraction of VDJdb human TRB same-epitope pairs (2,000 queries,E* = 0.1):rung layouts precision fixed centre 1 0.414 central prior λ=21 ~1–2 effective 0.336 flat (score alone) L+1 0.176 candidates (after 3–4, before last 3–4, centre) 5 0.156 Trying several plausible positions and keeping the best score is worse than not trying: the score picks the structurally correct layout about a tenth of the time, so each extra candidate is mostly an opportunity to be wrong. Mouse replicates the ordering at
E* = 1.0but its length-different stratum holds only 24–79 true positives, too few to separate the rungs. -
Performance. 91% of
GapBlockIndex.searchtime is the query-deletion-variant branch, 9% the 9.8M-entry auxiliary indices. Netting the prior out of each variant's budget cuts that branch from ~15 sub-searches to 2.5. Variant dedup (7–10% of variants before pruning, fewer after) and length-bucketing are not built. At budget 40 over 250k references,d_max=2gap-block search costs 2,562 µs/query — less than the plain Hamming ball at the same budget (3,051 µs/query).
skills/seqtree/SKILL.md— public API surface, invariants, and the gotchas that have bitten.docs/gapblock.rst— a worked guide: why one gap block, how to choose its position, why a placement rule is a column frame, and why a fixed score cutoff is not a calibrated one.- README corrected:
seqtrieis a full-width DP that ignores per-type caps, not a banded one, andautodoes not choose between engines. appendix/evalue.texgains §"The score model: one gap block, placed by a prior" (the appendix derived a theory of balls without ever saying what the score was) and a remark inverting the E-value into a per-query cutoff. The pMHC section is compacted from ~110 lines of prose to ~50, deferring to themhcmatchappendix, which specialises this one rather than repeating it. Its empirical tables stay: they are this repo's ownbench/bench_mhc_guess.pyoutput.- Test coverage:
gapblock.py,evalue.pyandseeds.pyat 100%; package total 88%. A newtests/python/test_doc_coverage.pyfails the build if a public symbol is undocumented, missing from__all__, or unreachable from any docs page.
structuralsubstitution matrix: Miyazawa–Jernigan interaction-strength similarity.- Built-in matrix list:
identity,BLOSUM62,PAM250,PAM100,structural(dropped PAM50).
SubstitutionMatrix.penalty(a, b)exposed to Python.
- Reproducible table→plot benchmark pipeline with oracle + perf regression.
- pMHC non-binder E-value filter; class-II promiscuity notes.