Skip to content

Correct sequence extraction, contig-boundary handling and pairing orientation; migrate docs to Zensical - #82

Merged
Adamtaranto merged 13 commits into
mainfrom
infrastructure_updates
Aug 1, 2026
Merged

Correct sequence extraction, contig-boundary handling and pairing orientation; migrate docs to Zensical#82
Adamtaranto merged 13 commits into
mainfrom
infrastructure_updates

Conversation

@Adamtaranto

@Adamtaranto Adamtaranto commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Three related pieces of work, plus infrastructure updates. The headline items are correctness fixes in sequence extraction and terminus pairing, several of which were producing wrong sequences or silently discarding valid hits.

See the 1.5.0 CHANGELOG section for the full list.

Sequence extraction was backend-dependent

--genome and --blastdb are presented as interchangeable, but returned different sequences for the same coordinates. Each of ~10 call sites reimplemented coordinate conversion, clamping and strand handling in its own if blastdb: branch, and the two had drifted.

All sites now route through a single primitive (tirmite/utils/extract.py) with one documented contract. Notable fixes:

  • With --padlen on a minus-strand hit, lowercase pad markers were applied to a sequence blastdbcmd had already reverse-complemented, marking the wrong end and mislabelling which bases were the hit.
  • blastdbcmd silently returns the entire sequence, exit 0, when a range starts past a contig end. Now rejected by a returned-length check.
  • Soft-masking is discarded by blastdbcmd but preserved by pyfaidx; both are now uppercased.
  • 1-based nhmmer coordinates were fed into a 0-based pyfaidx slice in tirmite seed, dropping the first base of every hit.

Before this change 8 of 9 output files differed between backends; they are now byte-identical, proven by a differential test suite that builds a FASTA and a BLAST database from the same file.

Contig boundaries

Regions overrunning a contig were silently truncated. They are now N-padded to the requested width and marked padded:<l>,<r>; --no-pad-flanks restores the old behaviour.

The consequential fix here: a TSD truncated at a contig end was reported as tsd_hamming=0 — the length mismatch made the comparison be skipped, leaving a truncated, unverifiable TSD indistinguishable from a verified perfect duplication. This silently corrupted the signal tirmite validate exists to check. Unverifiable TSDs now report NA.

Pairing orientation

parseHitsGeneral accepts both strand combinations per orientation, so a single run pairs elements inserted either way round. For a reverse-inserted asymmetric element the config's left model then sits at the higher coordinate, and two subsystems disagreed about what "left terminus" meant.

  • Symmetric F,F never produced any pairs — the documented LTR use case returned nothing.
  • Symmetric R,R paired hits with themselves; a lone hit produced a "pair" of one.
  • --max-offset measured the wrong model edge for reverse insertions, silently discarding valid hits before pairing.
  • Flanks for unpaired asymmetric hits were taken from inside the element.

Paired flank geometry was already correct and is unchanged, now with tests pinning it.

Behaviour changes to review

  • --maxdist now measures the gap between the facing inner edges of the two hits. The pairing path previously measured to the partner's far edge, adding a whole terminus length, and disagreed with the legacy path. Runs leaving it unset are unaffected; an explicit value now admits a slightly narrower set of pairs.
  • Extracted sequences are always uppercase — soft-masking is no longer carried through.
  • For asymmetric elements, output files and _L/_R suffixes follow terminus role rather than genomic order.
  • reconstruct_target_site returns None instead of 0 for an uncomparable TSD.

Docs

Migrated from Material for MkDocs (now in maintenance mode) to Zensical. mkdocs.ymlzensical.toml; Markdown sources unchanged, site appearance unchanged.

Also corrected the tutorial usage examples, which had drifted from the CLI — tirmite search was documented with an older interface throughout, and several --insertion-site examples omitted the required --flanks/--flanks-paired and would have failed at runtime.

Testing

455 tests pass (320 before this branch). Every fix is mutation-checked: reintroducing the defect fails the suite.

Verified end-to-end by running the pipeline both ways and diffing outputs, on real Sanctuary/BMP2768_Av data and on synthetic genomes covering contig edges, soft-masking, and the same asymmetric element in both orientations.

Action required before merge

  • Set the repository's Pages source to "GitHub Actions" (Settings → Pages). The docs workflow now publishes via the Pages artifact instead of pushing a gh-pages branch; until this is changed the site will keep serving the stale branch.

🤖 Generated with Claude Code

https://claude.ai/code/session_014Vs6ZCghyMszqBMZ2jKJa3

Adamtaranto and others added 13 commits August 1, 2026 07:44
ruff v0.14.11 -> v0.16.1, numpydoc v1.10.0 -> v1.11.0rc0,
actionlint v1.7.8 -> v1.7.12, yamlfmt v0.20.0 -> v0.21.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vs6ZCghyMszqBMZ2jKJa3
Sequence extraction from an indexed FASTA (--genome) and from a BLAST
database (--blastdb) are presented as interchangeable, but produced
different sequences for the same coordinates. Each of ~10 call sites
reimplemented coordinate conversion, clamping and strand handling in its
own `if blastdb:` branch, and the two branches had drifted. No test
exercised blastdbcmd at all.

Add src/tirmite/utils/extract.py as the single extraction primitive:
1-based inclusive plus-strand coordinates in, uppercase plus-strand
sequence out, clamped and length-verified. All call sites route through
it, removing the duplication that allowed the drift.

Backend divergences fixed:

* With --padlen on a minus-strand hit, the lowercase pad markers were
  computed in forward-genomic space but applied to a string blastdbcmd
  had already reverse-complemented, so they landed on the wrong end and
  mislabelled which bases were the hit. Case marking now happens on the
  plus strand, before reverse complement.
* Elements and left TIRs came back reverse-complemented from the BLAST
  path but plus-strand from the FASTA path. Plus strand is now canonical,
  matching the GFF coordinates and the flank/TSD convention.
* blastdbcmd silently returns the ENTIRE sequence, exit 0, when a range
  starts past the contig end. Clamping plus a returned-length check now
  rejects that rather than substituting a whole contig for a region.
* blastdbcmd discards soft-masking while pyfaidx preserves it; both are
  now uppercased, so masked genomes cannot make the backends disagree.
* Record IDs, descriptions and failure modes are now identical: '_rc'
  suffixes, genome descriptions (read from the BLAST title when needed),
  and a missing contig returning None from both.

Contig-boundary handling, previously silent truncation:

* Add fetch_region_padded: partial overlap is padded with N to the
  requested width and reports the pad counts; a region with no overlap
  still returns None rather than an all-N record. Applied to flanks,
  in-model TSDs and the --padlen window; padded records carry padded:L,R
  in their description. --no-pad-flanks restores truncation.
* Deliberately NOT applied to hmm_build seed extraction, whose output
  becomes HMM training data where Ns would become model columns.
* A TSD truncated at a contig end came back short, the length mismatch
  made the comparison be skipped, and the result was written as
  tsd_hamming=0 - indistinguishable from a verified duplication. Add
  compare_tsds, which excludes padded positions and reports NA when there
  is no informative position.
* Clamp model-coverage deficits to >= 0. A model_len below hmm_end (wrong
  --lengths-file, mismatched HMM) made the offset negative and shifted the
  flank window into the hit, extracting element sequence as flanking; the
  flank_max_offset guard could not catch it.
* Reject inverted hit windows in fetch_padded_hit instead of emitting a
  nonsense arrangement of flanks around nothing.
* Fix 1-based nhmmer coordinates being fed into a 0-based pyfaidx slice in
  hmm_build, which dropped the first base of every extracted hit.

Add --extend-hits-to-model to emit partial hits at full model length,
using the deficits already computed for flank projection.

The offset correction for hits that only partially cover the model was
audited and is correct on the appropriate side in all four
strand/terminus combinations; tests now pin that, including hits
incomplete at both ends.

Verified with 405 tests, including a differential suite that builds a
FASTA and a BLAST database from the same file and asserts byte-identical
results, and end-to-end runs of the pipeline both ways whose outputs now
diff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vs6ZCghyMszqBMZ2jKJa3
Material for MkDocs has entered maintenance mode. Zensical is the successor
from the same team, renders the same HTML, and reads mkdocs.yml natively - so
the Markdown sources needed no changes.

Replace mkdocs.yml with zensical.toml. Everything carries over: the teal/cyan
palette in both schemes, all eight theme features, logo, favicon, nav, repo and
edit links, and the social icon. theme.variant is set to "classic" so the site
keeps the familiar Material appearance rather than silently switching readers
to Zensical's newer default look.

Two simplifications fall out of the move: search is built in, so the
`plugins: [search]` entry is gone, and mermaid's custom fence takes the format
as a plain string instead of the `!!python/name:` YAML tag.

The deploy workflow now builds with `zensical build --clean --strict` and
publishes via the Pages artifact rather than pushing a gh-pages branch, so it
no longer needs write access to the repository. This requires the repository's
Pages source to be set to "GitHub Actions".

Also correct usage examples in the tutorials, which had drifted from the CLI.
Every command in docs/ and README.md was checked against the actual argparse
definitions; these two files carried the bulk of the errors:

* `tirmite search` was documented with an older interface throughout:
  --hmm-file is --hmm, --blast-query is --fasta, --nhmmer-file is
  --nhmmer-results, --maxeval is --max-evalue/--hmm-max-evalue/--blast-max-evalue,
  and --query-len (an int) is --lengths-file (a file). --mincov does not exist
  on search at all.
* `tirmite seed` was shown with --max-gap, removed in 1.4.0, and --blast-file,
  which is --blast-hits. The pre-computed-hits example piped raw blastn output
  into it, but that option takes TIRmite's own format from --save-blast-hits.
* The HMM-update example omitted --update, without which --hmm-file does
  nothing.
* Asymmetric output files are named `_left.hmm`/`_right.hmm`, not
  `_LEFT`/`_RIGHT`.
* The search-to-pair handoff examples omitted --lengths-file, which --blast-file
  and asymmetric BLAST pairing both require.

Verified by running the corrected commands: they succeed and produce exactly
the filenames now documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vs6ZCghyMszqBMZ2jKJa3
Audit of the pairing logic across all four --orientation settings, for both
symmetric and asymmetric elements, checking that the external side of an
element is correctly identified before flanking regions are extracted.

parseHitsGeneral enumerates two strand combinations per orientation - the
canonical one and its reverse complement - so a single run legitimately pairs
elements inserted in either genomic direction. For a reverse-inserted
asymmetric element the config's left model then sits at the HIGHER coordinate.
Two subsystems disagreed about what "left terminus" meant: flipTIRs is purely
positional and drives every paired consumer, while _determine_terminus_type and
filter_hits_by_anchor derive it from model identity, ignoring strand. They
coincide only for forward insertions, and nothing reconciled them.

Paired flank and TSD geometry was already correct - is_left is positional and
combines with the hit's own strand in compute_flank_coordinates - and is left
unchanged, now with tests pinning it.

Add resolve_terminus(), which separates terminus ROLE (which end of the
element, from the model) from is_lower (which genomic side its outer edge
faces, from the strand), and route the fixes through it:

* filter_hits_by_anchor fed a role where a genomic side was expected, so for a
  reverse insertion --max-offset was measured against the hit's INNER edge and
  valid hits were silently discarded before pairing. Highest impact, since the
  loss is invisible downstream.
* Unpaired asymmetric flanks were taken from the lower-coordinate side
  regardless of strand, i.e. from inside the element for a reverse insertion.
  Wrong sequence, not just a wrong label.
* A pairing-map row naming the same feature twice had 'left' immediately
  overwritten by 'right', so symmetric models never reached the both-ends
  anchor test.
* Asymmetric output now routes by terminus role, so each model's termini stay
  in that model's file; previously a reverse insertion wrote the right model's
  terminus into the left model's file. Sequence and coordinates are unchanged.
  Reconstructed target sites gain an element_orientation field.
* --orientation is parsed and validated once, in PairingConfig, and reused by
  filter_hits_by_anchor. 'f,r' previously set both strands to '-' in pairing
  while the anchor filter read it correctly; a malformed value left the strand
  table empty and produced zero pairs with no error.

Two further defects found while investigating, both confirmed by test first:

* Symmetric F,F never paired at all. The if/elif meant every hit took only the
  left branch when both strands match, so no hit collected an upstream
  candidate and reciprocity could never be established. F,F is the documented
  LTR case and returned zero pairs; the existing F,F tests pass because they
  drive the asymmetric path via two model names.
* Symmetric R,R paired hits with themselves. _find_candidates never excluded
  the reference hit, and on the minus strand the 5'/3' swap makes the
  self-distance positive, so a lone hit produced a "pair" of one.

Align the two meanings of --maxdist. The legacy path measured the literal gap
between hits; the current path measured to the partner's strand-relative 5'
end, which for a minus-strand partner is its far edge, adding the whole length
of that terminus. Both now use one shared inter_hit_distance(): the gap between
the facing inner edges, independent of strand and model length. Candidate
ranking uses the same measure as the filter. This also rejects overlapping
termini, which the old measure accepted.

Runs that leave --maxdist unset are unaffected; real Sanctuary output is
byte-identical. An existing --maxdist admits a slightly narrower set of pairs,
short by roughly one terminus length.

Every fix is mutation-checked. Verified end-to-end on a genome carrying the
same asymmetric element in both orientations: both pair, all four flanks come
from outside the element, each model lands in its own file, and --genome and
--blastdb outputs stay byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vs6ZCghyMszqBMZ2jKJa3
Coverage was enabled by addopts in pyproject.toml, so every matrix job
instrumented the code and wrote coverage.xml even though only the 3.14 job
uploads to Codecov. Pass --no-cov on the other versions.

The extraction equivalence tests shell out to makeblastdb and blastdbcmd and
are skipif-gated on those binaries, which CI never installed. That meant 69
tests - the whole FASTA-vs-BLAST-database equivalence suite - skipped silently
while the run reported green. Install BLAST+ for the 3.14 job only, so the
other five stay fast, and verify the binaries in a separate step so a broken
install fails loudly instead of quietly reverting to skips.

Locally, without BLAST+ on PATH: 386 passed, 69 skipped. With it: 455 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vs6ZCghyMszqBMZ2jKJa3
Covers the sequence-extraction unification, contig-boundary padding, pairing
orientation fixes, the --maxdist alignment, and the move to Zensical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Vs6ZCghyMszqBMZ2jKJa3
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 58.88889% with 111 lines in your changes missing coverage. Please review.
✅ Project coverage is 41.75%. Comparing base (6a557b7) to head (c9d1a37).
⚠️ Report is 48 commits behind head on main.

Files with missing lines Patch % Lines
src/tirmite/cli/hmm_build.py 16.00% 60 Missing and 3 partials ⚠️
src/tirmite/utils/extract.py 78.04% 26 Missing and 10 partials ⚠️
src/tirmite/cli/hmm_pair.py 72.00% 7 Missing ⚠️
src/tirmite/cli/validate.py 16.66% 5 Missing ⚠️

❗ There is a different number of reports uploaded between BASE (6a557b7) and HEAD (c9d1a37). Click for more details.

HEAD has 5 uploads less than BASE
Flag BASE (6a557b7) HEAD (c9d1a37)
6 1
Additional details and impacted files
@@             Coverage Diff              @@
##              main      #82       +/-   ##
============================================
- Coverage   100.00%   41.75%   -58.25%     
============================================
  Files            1       11       +10     
  Lines            5     5276     +5271     
  Branches         0     1095     +1095     
============================================
+ Hits             5     2203     +2198     
- Misses           0     2866     +2866     
- Partials         0      207      +207     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Adamtaranto
Adamtaranto merged commit 569ac14 into main Aug 1, 2026
8 of 10 checks passed
@Adamtaranto
Adamtaranto deleted the infrastructure_updates branch August 1, 2026 03:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant