Skip to content

feat: add 1-indel search via bidirectional DFS in Rust#24

Merged
dmx2 merged 15 commits into
IEDB:masterfrom
Roman-Young:feature/indel-matching
Jul 8, 2026
Merged

feat: add 1-indel search via bidirectional DFS in Rust#24
dmx2 merged 15 commits into
IEDB:masterfrom
Roman-Young:feature/indel-matching

Conversation

@Roman-Young

@Roman-Young Roman-Young commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Implements max_indels=1 search in the Rust engine via exhaustive bidirectional DFS anchored on pigeonhole-guaranteed k-mer seeds
  • Adds run_indel in match.rs with the indel_search_peptideextend_bidirectionaldfs call chain
  • Terminal-deletion guard blocks deletions at the query's own first/last residue (no query-side context to confirm the residue is genuinely absent) and at true protein-sequence boundaries (out-of-bounds references); interior positions with ≥1 residue of protein context on each side are allowed
  • Each search mode carries a single edit-count column — Indels for indel search, Mismatches for every other mode — never both; indel rows leave Mutated Positions empty
  • Adds the -i/--max_indels CLI flag and a README "Indel Searching" section

Key design decisions

  • Pigeonhole seeding: for a query of length L with max_indels=1, k = max(2, L // 2); at least one non-overlapping seed must hit exactly, anchoring the alignment
  • Terminal-deletion guard: blocks deletions at the query's first/last residue and at protein indices that are truly out of bounds; protein positions 0 and protein_len-1 are real residues and are allowed
  • Terminal insertions: unreachable by construction — the DFS base case returns before a leading/trailing padding insertion can fire, so no guard code is needed
  • Deduplication: cross-seed duplicate hits collapsed by (protein_number, start_position, matched_sequence)
  • One edit-count column per mode: avoids showing an always-zero Indels column on a mismatch run (and vice versa); max_indels is rejected together with discontinuous epitopes, which have no contiguous window to align

Test plan

  • test_indel_search: 1-insertion match (NALVEARFC → NALVEATRFC) and 1-deletion match (EQLVPSYQQA → EQLVSYQQA) against Q8V336
  • test_query_terminal_deletion_blocked: confirms QNALVEATRFC does not match NALVEATRFC (deleting Q at query position 0 is a barred query-terminal deletion)
  • Terminal-insertion cases, mutual-exclusion ValueError paths, peptide_len < k, multi-hit across proteins, and per-mode column tests
  • 15-seed randomized property-test differential against the independent brute-force oracle (tests/indel_brute_force.py)
  • All 41 tests passing

@dmx2

dmx2 commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Really nice work, Roman. It looks good for the most part. I traced both test cases by hand and the logic is correct for 1 indel. Not mergeable yet though. Here is what needs to happen before it lands, roughly in priority order.

1. Rebase onto current master.
This branched before two merged features: the columnar match output (#25) and
counts-only (#26). As-is, merging would revert both. The only real work is re-pointing the
output layer:

  • run_indel should return the columnar Columns type like run does, not
    Vec<Vec<String>>.
  • indel_search_peptide should build HitRecords instead of hand-assembling
    17-field rows. That also removes the per-row metadata cloning for free, since
    metadata now comes from the rs_metadata join, not from each row.
  • rs_indel_match return type follows; indel_search()'s _to_dataframe(results)
    call then works unchanged.

2. Remove indel.py from the package.
It's a full Python reimplementation of the Rust algorithm and isn't imported
anywhere. It shouldn't ship in the codebase. If we keep it, it belongs under
tests/ as the brute-force oracle (see #4), not in pepmatch/. Or in benchmarking/

3. Give indels their own column.
Right now the indel count goes into the Mismatches slot and Mutated Positions
is hardcoded []. Add an Indels column and keep Mutated Positions empty for
this mode. (Heads up: on master, the shared mutated_positions() zips two
different-length strings, so don't reuse it for indel rows.)

4. Strengthen the tests + add a brute-force oracle in tests.
Current coverage is two hand-built queries plus one guard test. Add: the
ValueError paths (max_indels>1, indel+mismatch together), peptide_len < k,
and multi-hit cases. Most importantly, turn your indel brute force into a
property test in tests/ that checks the Rust against the brute force over
randomized peptides/proteomes. And both peptides that are in there come from the
same protein, we need more variance in the test to cover multiple cases. Especially
the ones we had discovered before. And since the protein and peptide counts will be
small, running this per master branch commit, is not an issue.

5. k-selection should log loudly.
indel_search derives k from the shortest query and silently preprocesses if the
pepidx is missing. For large proteomes that's a surprise index build. Print
something explicit, e.g. no preprocessed file found for k=X, creating .pepidx for k=X now.

6. CLI + docs.
shell.py needs a --max_indels flag, and the README needs a short section on the
indel mode.

@dmx2 dmx2 self-requested a review June 30, 2026 18:27

@dmx2 dmx2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comments above.

Roman Young and others added 4 commits July 1, 2026 11:36
Implements insertion/deletion matching as a new search path in the Rust
engine. Uses the pigeonhole principle for seed selection, bidirectional
DFS with full budget splitting, and two-level deduplication across seeds.
Adds max_indels parameter to Matcher with mutual exclusion against
max_mismatches. max_indels > 1 raises ValueError pending validation.
Tests verified by hand against Q8V336 (NCAP_DUGBA) in the test proteome.
QNALVEATRFC is constructed so the only match in the test proteome requires
deleting Q at query position 0 (terminal). Verified that removing the guard
produces a spurious NALVEATRFC hit; the guard correctly blocks it.
Also adds *.pepidx to .gitignore to prevent index files from being staged.
Deletions at query position 0 or query_len-1 are now permitted when the
protein position is not at a sequence boundary, allowing valid biological
indel matches that were previously blocked.
@Roman-Young Roman-Young force-pushed the feature/indel-matching branch from b90ce8a to 09a6757 Compare July 1, 2026 18:46
Not imported anywhere in the package; relocating per PR IEDB#24 review
(item 2) since its role is validating the Rust indel implementation,
not shipping with it.
Previously the indel count rode in the Mismatches slot. Split it into
its own column (right after Mismatches in FINAL_COLUMNS) so indel rows
report Mismatches=0/Indels=N and non-indel rows report Indels=0,
without disturbing existing Mismatches semantics for exact/mismatch/
discontinuous search. Per PR IEDB#24 review item 3.
is_terminal_deletion treated p_idx == 0 and p_idx == protein_len - 1 as
boundary artifacts, but both are real, existing residues, not
out-of-bounds. This required 2 residues of protein context around a
deletion's gap when 1 is sufficient (a real adjacent residue already
confirms the query residue is genuinely absent, not just missing
because the recorded sequence happens to end there). Only strictly
out-of-bounds references (p_idx < 0 or p_idx >= protein_len) are
actually ambiguous.
- Replace indel.py's seed/DFS port with a genuinely independent
  brute-force oracle (direct window enumeration, no seeding/recursion)
  so it can catch bugs the DFS and its Python port would share
- Add ValueError path tests for max_indels>1 and indel+mismatch combos
- Add peptide_len < k edge case test
- Add multi-hit test covering two different proteins in one query
- Add randomized property test comparing Rust output against the
  brute-force oracle across 25 seeded random peptide/proteome pairs
Silently kicking off a full-proteome preprocessing pass with a terse
message is a surprise for large proteomes. Per PR IEDB#24 review item 5.
Trailing constraint note (after the code block) rather than folded
into the intro sentence, mirroring how Counts-Only Mode documents its
best_match incompatibility.
…ts_only

Both combinations previously produced silent wrong behavior: best_match
was ignored (indel_search() results were never passed through
_best_match_filter), and counts_only silently returned exact-match
counts (max_mismatches=0) rather than indel counts. Neither is
implemented yet, so both now fail loudly like the existing
max_indels/max_mismatches mutual-exclusivity check.
_with_deletion/_with_insertion are named for the edit applied to the
query string, which is the opposite of the Indels label the search
engine reports for the resulting match (removing a query residue
means the protein has extra content there, an insertion match, and
vice versa). Noted during audit to prevent misreading.
A deletion at the query's own first or last residue has no query-side
context to confirm the residue is genuinely absent, as opposed to the
alignment simply starting/ending one residue short -- so it's now
blocked regardless of protein-side buffer, reverting the earlier
relaxation. Applied identically to match.rs's is_terminal_deletion and
the brute-force oracle, so both stay in lockstep.

Added test_terminal_insertion_blocked and
test_insertion_at_second_to_last_position_found to cover the analogous
insertion case: a padded leading/trailing insertion is unverifiable
and blocked, while an interior insertion one position before the
query's own boundary (e.g. LPDGVWEESS) has real query-side context on
both sides and remains valid.

Renamed indel.py to indel_brute_force.py for clarity ahead of future
2-indel brute-force work.
@dmx2 dmx2 self-requested a review July 8, 2026 18:27

@dmx2 dmx2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good Roman, thank you. We are so close to merging. One extremely minor point and then one change in pepmatch/matcher.py and we're good to go.

  1. Fix the PR description
    PR summary still says “deletions at query position 0 or query_len-1 are now permitted” but we decided on this already. So just fix the description in case anyone is following this repo and they don't get confused on what we changed.

  2. Separate Mismatches and Indels columns in each mode
    Right now, a mismatch search returns an Indels column (always 0) and an indel search returns a Mismatches column (always 0). I think that will confuse users, as they will see an Indels column on a normal mismatch run and wonder if it also searched for indels. match() method concatenates the linear result with the discontinuous result for mixed queries but I believe this is disabled for indel searches (we do not do discontinuous indel searches). Check for that and the easiest would be to leave mismatching and discontinuous functions alone, but just replaced "Mismatches" with "Indels" and populate it correctly. This should be easy since the column will stay in one place and alternate for either mode.

I'll do one last review and then merge if these changes are made. Thanks!!!

Comment thread pepmatch/matcher.py
Comment thread pepmatch/matcher.py Outdated
FINAL_COLUMNS = [
'Query ID','Query Sequence','Matched Sequence','Protein ID','Protein Name','Species',
'Taxon ID','Gene','Mismatches','Mutated Positions','Index start','Index end',
'Taxon ID','Gene','Mismatches','Indels','Mutated Positions','Index start','Index end',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this might be annoying for the code readability, but we should split the output when doing indel vs mismatching mode. See my final comments.

Comment thread pepmatch/matcher.py
]

def _to_dataframe(self, cols):
def _to_dataframe(self, cols, is_indels=False):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we already have a flag here, so popping the indel column in should be easy.

…ntinuous

Each mode now carries a single edit-count column instead of always showing
both: Indels for indel search, Mismatches for every other mode. The prior
schema surfaced an all-zero Indels column on mismatch runs (and an all-zero
Mismatches column on indel runs), which reads as a search that did not happen;
dropping the twin also restores the original schema for the mismatch, exact,
discontinuous, and best_match outputs. _to_dataframe now names the single
column by mode via _final_columns(is_indels), replacing the fixed
FINAL_COLUMNS list.

Since a single match() call can concat a linear result frame with a
discontinuous one, and the two must share columns, max_indels combined with
discontinuous epitopes is now rejected in __init__ -- a discontinuous indel
search is undefined anyway (a position-anchored epitope has no contiguous
window to seed or extend).

Adds test_indel_mode_emits_indels_column_only,
test_mismatch_mode_emits_mismatches_column_only, and
test_max_indels_and_discontinuous_raises.
@dmx2

dmx2 commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Merged!

@dmx2 dmx2 merged commit baf800e into IEDB:master Jul 8, 2026
6 checks passed
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.

2 participants