Skip to content

2.33.0 — the repair policy, and the D posterior moves to vdjtools - #145

Merged
mikessh merged 7 commits into
masterfrom
feature/kmer-cdr3fix
Sep 30, 2026
Merged

mikessh merged 7 commits into
masterfrom
feature/kmer-cdr3fix

Conversation

@mikessh

@mikessh mikessh commented Sep 30, 2026

Copy link
Copy Markdown
Member

Closes #141, #142, #143, #144.

The repair policy: when it is not certain, flag it — do not change it

cdr3fix admits exactly four edits, in this order, and rewrites nothing else:

  1. re-call the V or J when another allele explains 2 more residues contiguously from its own anchor and does not break an anchor the submission already holds;
  2. trim framework past that allele's anchor;
  3. add back germline the submission was cut inside of;
  4. substitute the first or last residue only, to the anchor, on a solid match.

A residue that disagrees with germline inside the templated run is reported (mismatch) and left as submitted — a curation error and an allele IMGT does not record are indistinguishable from one junction. 2.32.0 rewrote them, which turned CAAAETSYDKV{M,R,T,V}F on TRAJ50 into four copies of CAAAETSYDKVIF.

_RECALL_GAIN = 2 is measured against isalgo/airr_control's nucleotide-called V/J on 3,059 human TRA and 426 human TRB curation keys: the smallest margin at which no re-call moves a call away from the nucleotide answer (at 1 it breaks 20).

Against the authoritative 2026-06-03 release, all 187,488 keys

2.32.0 2.33.0
repaired junction agrees 98.3604 % 99.8352 %
the release's own repairs reproduced 4,267/4,499 4,331/4,499
rewrites the release never ships 2,842 141
— into an already-canonical junction 2,744 35
non-canonical junctions shipped (release: 715) 709 677

arda ships fewer malformed junctions than the release it is measured against. vEnd/jStart concordance falls only on records whose call arda re-assigns, where the release's own boundary is wrong 100 % of the time against nucleotide truth. Reproduce with scripts/compare_vdjdb_release.py.

arda.dpost → vdjtools.model.posterior_d (#144, #142)

A posterior over the D gene marginalises model distributions, so it belongs with the recombination model. Ported, not rewritten: it agrees field-for-field on 3,000 real human TRB junctions, and it gained the batch entry point #142 asked for. arda markup --d-posterior/--d-prior are gone. The prior table, its fitter and now its reader (arda.scenarios.load_prior_table) stay here.

#143 — _align, the pure-Python DP

Gone with the 2.32.0 port; there is no Python alignment DP in cdr3fix any more. Throughput over the same 187,488 keys is 28,900 keys/s including the new per-record re-call.

Also

  • v_alts/j_alts: the alleles a junction cannot separate travel with the answer, for vdjtools' nucleotide stage to settle.
  • map_d_junction(v_end=, j_start=): take the interior instead of re-deriving it from an inferred sequence.
  • arda.hmm deprecated — nothing consumes it, and vdjtools.model.infer_nt_batch answers the same question batched and in C++.

1,312 tests pass.

🤖 Generated with Claude Code

mikessh and others added 7 commits September 30, 2026 02:25
`arda.cdr3fix` replaced VDJdb's `Cdr3Fixer`. On one class of input it detects the
defect, names it exactly, and does not apply the repair, so a malformed junction
reaches the consumer where the retired fixer corrected it.

Measured over VDJdb's corpus, 190,902 distinct (species, cdr3, v, j) keys:

- 382 keys end up with a junction that does not run Cys104 to the anchor its own
  segment encodes (267 J, 115 V);
- 2,165 keys are returned `NoFixNeeded` with a single residue of germline
  agreement, where legacy required a k-mer hit of `min_hit_size = 2` at offset
  zero in both sequences.

The asymmetry is the clearest symptom: `YFCASSQSPGGVAFFGQG` on TRBV14/TRBJ1-1
gives `v_fix=FixTrim` and `j_fix=FailedNoAlignment`, while `YFCAVVGTGLGYTFGSG` on
TRBV9/TRBJ1-2 gives exactly the opposite. Same defect at both ends, decision flips
per end.

The cause is the reference, not the algorithm. `Anchor.templated_aa` runs Cys104
through [FW]118 inclusive and stops, so framework past an anchor has nothing to
align to and the boundary can only be inferred from the junction side. The legacy
fixer sliced `sequence[reference_point - 3:]` for V and
`sequence[:reference_point + 4]` for J, putting one FR3 codon before Cys104 and the
[FW]GXG motif after 118 - with the flanks present the repair is positional and
needs no alignment scoring.

arda already ships what that needs: `alleles.fasta` holds full allele sequences and
`cdr3_anchors.tsv` carries `anchor_nt` as the reference point.

Files:

- `ISSUE-cdr3fix-kmer-regression.md` - the writeup, per class, with V and J calls;
- `cdr3fix-noncanonical-junctions.tsv` - the 382, classified;
- `cdr3fix-nofixneeded-without-alignment.tsv` - the 2,165;
- `reference-legacy-cdr3fixer/` - the original `Cdr3Fixer`, `KmerScanner`, `Utils`
  and `FixerDataModels`, vendored from vdjdb-db before #658 retired them;
- `src/arda/cdr3kmer.py` - scanner and translation ported verbatim, four-outcome
  table documented. **Incomplete**: no flank-extended segment construction from
  `alleles.fasta` + `anchor_nt`, no `fix()`, no batch API, no tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 2.16.0 semi-global Needleman-Wunsch engine is gone. One gapless local alignment
per side (`_markup.d_local_align`, the same C++ the D caller uses) places the germline's
templated run anywhere in the junction, and legacy's positional table decides the
outcome from the two offsets.

Searching every offset is the point: `CAMYLCASSLFGSPLHF` against TRBV9 (`CASSV`) has a
spurious `CA` at offset 0 and the real `CASS` at offset 5, and an engine anchored at
offset 0 scored the first and called the record clean. The anchored placement still
wins a tie, or `CASSQQQQQQQQQF` gains an F it already had.

Measured against the authoritative VDJdb 2026-06-03 release, whose `cdr3fix` column
carries the retired fixer's own cdr3_old -> cdr3. Coverage first: 184,765 of 189,596
distinct (species, junction, V, J) curation keys join (97.45 %).

                                          2.31.0              2.32.0
  repaired junction agrees   180,374 (97.6235 %)  181,892 (98.4451 %)
  vEnd agrees                         98.2013 %            98.3571 %
  jStart agrees                       97.8316 %            99.7242 %
  release's own repairs            584 of 779           623 of 779
  repairs the release did not          4,196                2,717
  good beside an unrepaired           19,436                    0
  throughput, one process        12,900 keys/s        20,400 keys/s

The verdict is now a SET of flags per side (`v_flags`, `j_flags`): ok, allele, sub,
add, trim, shallow, impossible. One worst-wins label could not say both "I trimmed a
flank" and "I found a substitution I will not touch", and collapsing them is how a
declined repair came to read as a clean record — `CAISGEFGSGA` reported `V sub@2 I>S
d=2` and still returned NoFixNeeded and good, as did 19,436 keys. `good` is now
exactly "neither side impossible"; `v_fix`/`j_fix` stay VDJdb's names for the JSON.

A contradicted call changes the ALLELE, not the sequence (`guess_allele`, legacy's
`guess_id` rebuilt on the same scan). Of the anchor-adjacent substitutions 2.31.0
wrote into an already-canonical junction, 74.7 % were records whose own 3' end matched
a different J allele better than the called one, against 1.1 % of untouched records —
a 68x enrichment. `CASSLRGAATDTQYF` is a clean TRBJ2-3 junction called TRBJ2-1 that
2.31.0 rewrote to `CASSLRGAATDTQFF`, a string no germline supports.

arda does not force a junction canonical on a thin match: writing the conserved anchor
takes a hit clearing `_MIN_HIT`, and `_canonicalise` scores against the called allele's
OWN anchor residue, not the [FW] motif — TRBJ2-7*02 templates `SYEQYV`.

A call is also checked against every gene name in the vocabulary, which was the largest
single failure class: 3,067 V and 152 J keys that came back FailedBadSegment. TRAV14
(999) is filed as TRAV14/DV4, TRBV19-1*01 (246) names a suffix IMGT does not use,
TRBJ2.1 (5) is dot nomenclature. A spelling must land on exactly one gene or it is
refused.

Removed `_MAX_TRIM` and `_MAX_FIX`, two bounds layered on top of `max_replace`: the
release trims framework `_MAX_TRIM` refused, and two knobs on one decision meant the
outcome depended on which bit first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repair policy, stated by the author on 2026-09-30 and now the module's rule. Four
edits are admissible, in this order, and nothing else is ever rewritten:

1. re-call the V or J when another allele explains 2 more residues CONTIGUOUSLY from
   its own anchor (`anchor_depth`, `_RECALL_GAIN`), and does not break an anchor the
   submission already holds;
2. trim framework past that allele's anchor;
3. add back germline the submission was cut inside of;
4. substitute the FIRST OR LAST residue only, to the anchor, on a solid match.

A residue that disagrees with germline INSIDE the templated run is reported (new
`mismatch` flag) and left exactly as submitted, because a curation error and an allele
IMGT does not record are indistinguishable from one junction. 2.32.0 rewrote them:
`CAAAETSYDKV{M,R,T,V}F` on TRAJ50 are four junctions that all became `CAAAETSYDKVIF`,
which is four residues observed at one templated position — a suspect J call or an
unrecorded allele, not four independent typos.

Re-calling is now step ONE for every record rather than a rescan triggered by a
substitution, which it can be because `_candidates` indexes the anchor table by
(segment, locus) and dedupes by templated run: the whole-table Python filter cost 19x
the markup itself, 110 s against 18 s over 187,488 keys.

`_RECALL_GAIN = 2` is measured against `isalgo/airr_control`'s nucleotide-called V/J
(junctions seen in >= 3 donors with one V and one J throughout), on the 3,059 human TRA
and 426 human TRB curation keys that appear there. It is the smallest margin at which no
re-call moves a call AWAY from the nucleotide answer, and every larger margin only
leaves errors standing:

                        TRA V            TRA J           TRB V           TRB J
  no re-call          88.17 %          99.18 %         94.13 %         98.12 %
  gain 1     90.68 % (14 wrong) 99.87 % (0 wrong) 96.71 % (4 wrong) 99.77 % (0 wrong)
  gain 2     89.47 %      (0)   99.80 %     (0)   96.71 %     (0)   99.77 %     (0)
  gain 3              88.92 %          99.80 %         94.60 %         99.77 %

Three guards, each because the alternative was measured to re-call a record wrongly.
The hit must start at the germline's OWN anchor (`CSAR` is TRBV20-1's run and it does sit
inside `CGGSARSGELFF`, at offset 3, as N region). It may not place that anchor further
inside the junction than the call does (TRBJ2-2P's `LRGAAG` matches seven residues in and
trimmed `CASRPGAAGGRPELYF` to `CASRPGAAG`). And it may not break an anchor the submission
already explains: `CAARLGNNYKLIW` is a clean TRAJ33 junction whose `WIL` is contiguous
from its own W, which TRAJ12's five residues outscored only by skipping the anchor —
whereupon step 4 turned the W into an F. `_canonicalise` cannot catch that, because BOTH
anchors are canonical. The converse stays allowed: `CASSKRGGYEQYV` on TRBJ2-7*01 does not
hold its anchor, so *02 takes the call at equal depth.

A junction that lost only its conserved anchor is now repaired. Neither legacy nor 2.32.0
did: the survivors agree from position 1 on, so the best placement is a one-residue
coincidence below `_MIN_HIT`. Prepending the anchor is admissible exactly when doing so
makes the germline agree from the anchor outward — `AQGLLTGGGNKLTF` on TRAV29/DV5 (`CAAS`)
becomes `CAQGLLTGGGNKLTF`, where the 2026-06-03 release ships it unrepaired and not good,
while `CALRPA` on TRAJ17 stays refused because prepending its Phe still leaves one residue.

`good` is "neither side impossible", and `mismatch` keeps it: the junction is well formed
and every residue in it is the curator's. `impossible` is now exactly an ANCHOR the rules
cannot restore. `max_replace` is legacy's `max_replace_size` again — junction residues
standing where the anchor should be — not 2.32.0's distance from the anchor.

Measured against the authoritative 2026-06-03 release, all 187,488 distinct
(species, cdr3_old, vId, jId) keys, both engines given the same four inputs
(`scripts/compare_vdjdb_release.py`, new):

                                          2.32.0            this
  repaired junction agrees            98.3604 %       99.7840 %
  the release's own repairs made    4,267/4,499     4,389/4,499
  rewrites the release never ships        2,842             295
    of those, already canonical           2,744             186
    of those, made canonical                 69              98
  non-canonical junctions shipped           709             708   (release: 715)
  good                                  170,218         186,139   (release: 174,407)
  throughput, one process         32,400 keys/s   10,100 keys/s

vEnd concordance moves 99.86 % -> 98.19 % and jStart 99.65 % -> 98.88 %, entirely on
records whose call arda re-assigns. On those the release's own boundary is wrong 100 % of
the time against nucleotide truth and arda's is right 25-100 % (TRA vEnd 0 % -> 25.42 %,
TRA jStart 0 % -> 78.26 %, TRB jStart 0 % -> 100 %); on confirmed calls arda matches or
beats it everywhere.

Also restores CLAUDE.md, which ae112a8 deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… 3.1x, and better

Where the germline run begins is a property of the JUNCTION: a framework flank in front of
Cys104 is there whatever allele is named. Scoring each candidate at a placement of its own
let one win by putting its anchor DEEPER inside the junction, where the trim that follows
deletes everything before it — `TRBJ2-2P`'s `LRGAAG` matches `GAAG` seven residues into
`CASRPGAAGGRPELYF` and trimmed the record to `CASRPGAAG`. Every candidate is now scored at
the called allele's own offset, which is one `anchor_depth` walk instead of a `scan` (two
`_extend` sweeps plus a C++ alignment).

`_extend` was 1.4 M calls over 20,000 records, 36 % of the markup, and a candidate index on
the offset-1 residue — the one position every route to `anchor_depth >= 2` must match — cuts
the ~60 alleles of a locus to three to five. Together: 9,200 -> 28,900 keys/s over 187,488
keys, within 15 % of doing no re-call at all.

Measured against the authoritative 2026-06-03 release (187,488 keys), and every number
moves the right way:

                                          2.32.0   prev commit            this
  repaired junction agrees             98.3604 %     99.7840 %       99.8352 %
  rewrites the release never ships         2,842           295             141
    of those, already canonical            2,744           186              35
    of those, made canonical                  69            98              95
  non-canonical junctions shipped            709           708             677  (release: 715)

arda now ships FEWER malformed junctions than the release it is measured against. The
re-call's accuracy against `isalgo/airr_control`'s nucleotide-called V/J is unchanged to
four places on all four columns (TRA V 89.47 %, TRA J 99.80 %, TRB V 96.71 %, TRB J
99.77 %, zero calls moved the wrong way).

The release's own repairs reproduced goes 4,389 -> 4,331 of 4,499: 58 records where its
repair followed from a placement this no longer considers. Deliberate, and the same trade
that removed 154 rewrites it does not ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion cannot resolve

Closes #144, #142 and antigenomics/vdjtools#183. `arda.dpost` is gone: a posterior over the
D gene marginalises the generative model's insertion-length and D-trimming distributions and
multiplies in P(D | J), which are model marginals, not germline facts. It is now
`vdjtools.model.posterior_d` plus `posterior_d_batch`, the batch entry point #142 asked for.

Ported, not rewritten — over 3,000 real human TRB junctions the new module agrees with this
one on every field of every row, so the measured accuracy travels intact. `arda markup
--d-posterior` / `--d-prior` are gone with it, and a removed flag now FAILS rather than
being accepted and ignored.

What stays here, because arda is the germline reference and the markup against it: the prior
table `database/vdj/<org>/d_prior.tsv` (its other two consumers are `arda.hmm` and
`arda.scenarios`, so a copy in vdjtools would be a second copy of a fitted artifact), the
fitter, and now its READER — `arda.scenarios.load_prior_table`, beside the writer, so the
format has exactly one parser. vdjtools reads the table through it.

Two additions the junction pipeline needs, both of them arda's own job:

`v_alts` / `j_alts` — every allele that explains an end exactly as well as the chosen one.
`CAISE` is the run of TRBV10-3*01, *02 AND *03, so an amino-acid junction cannot separate
them, and resolving that by functionality then by name binds a choice with no evidence
behind it. The set travels instead, chosen allele first, and the stage that CAN separate
them is `vdjtools.model.infer_nt_batch`, which scores a list per row.

`map_d_junction(v_end=, j_start=)` — the interior to search, in nucleotides, instead of
re-deriving it by exact germline prefix match. That derivation is right for a read and wrong
for a junction whose nucleotides were INFERRED under a model keyed on a different allele of
the same gene: the prefix breaks at the first synonymous difference and the interior opens
up inside the V, where a spurious D wins. `CATSIRFTDTQYF` placed a TRBD2 at nucleotide 6.

Deprecated: `arda.hmm`. Nothing consumes it — not the annotation path (two measured
negatives in ROADMAP.md), not another repository, not the new pipeline — and the question it
answers, the most likely nucleotide reading of a junction, is `infer_nt_batch`: batched,
threaded and C++. `arda.scenarios`, which fits the model, is NOT deprecated: it is measured
to still be needed, since the length-and-prior posterior it parameterises answers 1,753 of
4,000 real human TRB rearrangements that the nucleotide alignment declines, and is right on
53 % of them where the alignment reaches 86 % on the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`arda.hmm`'s removal and the `d_prior.tsv` location travel together (loop 1 gates loop 2); the
141 keys where arda edits a junction the authoritative release does not have never been read
record by record; and the consumer migration is tracked in the consumer
(antigenomics/vdjdb-db#713).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The asset still ships `d_prior.tsv` and still must — `arda.hmm` and `arda.scenarios` read it,
and vdjtools reads this copy rather than vendoring a second copy of a fitted artifact. Only the
POSTERIOR moved (#144), so the check is unchanged and the comment beside it was stale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@mikessh
mikessh merged commit 8156273 into master Sep 30, 2026
3 checks passed
@mikessh
mikessh deleted the feature/kmer-cdr3fix branch September 30, 2026 05:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant