From 206d05fa5d4bfeb4470b215d55582d65bb7aefbe Mon Sep 17 00:00:00 2001 From: Roman Young Date: Sun, 12 Jul 2026 10:41:11 -0700 Subject: [PATCH 1/2] feat: support max_indels=2 for homogeneous insertions or deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 2-indel match uses two insertions or two deletions, never one of each — a mixed pair is a substitution, which mismatch search already covers. The two edits need not be adjacent. dfs gains allow_del/allow_ins branch masks; extend_bidirectional runs two homogeneous passes (deletions-only, then insertions-only) and unions them via the existing dedup. Fixing the mask across both extensions of a seed is what keeps an alignment homogeneous — masking per-side would still let a left-deletion pair with a right-insertion. No prev_was_* substitution guards are needed because mixing is structurally impossible per pass. 1-indel output is unchanged: at budget 1 only one edit can fire, so the deletion pass unioned with the insertion pass reproduces the previous behavior. Validated against the committed brute-force oracle (pepmatch/tests/indel2_brute_force.py). --- README.md | 10 +-- pepmatch/matcher.py | 116 ++++++++++++++++++------- pepmatch/rs-engine/src/match.rs | 76 +++++++++------- pepmatch/tests/indel2_brute_force.py | 75 ++++++++++++++++ pepmatch/tests/test_indel2_property.py | 84 ++++++++++++++++++ pepmatch/tests/test_indel_search.py | 75 +++++++++++++++- 6 files changed, 365 insertions(+), 71 deletions(-) create mode 100644 pepmatch/tests/indel2_brute_force.py create mode 100644 pepmatch/tests/test_indel2_property.py diff --git a/README.md b/README.md index bec0279..8e94dd9 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ **Author:** Daniel Marrama -`PEPMatch` is a high-performance peptide search tool for finding short peptide sequences within a reference proteome. It handles three kinds of search out of the box: **exact matches**, **mismatch (substitution)** searches, and **single-indel (insertion or deletion)** matching. Powered by a Rust engine with Python bindings, it delivers sub-second search times across entire proteomes while maintaining a simple Python API. +`PEPMatch` is a high-performance peptide search tool for finding short peptide sequences within a reference proteome. It handles three kinds of search out of the box: **exact matches**, **mismatch (substitution)** searches, and **indel (insertion or deletion)** matching. Powered by a Rust engine with Python bindings, it delivers sub-second search times across entire proteomes while maintaining a simple Python API. ### Key Features * **Blazing Fast**: Rust-powered search engine with automatic multi-core parallelization via Rayon. Search thousands of peptides against the entire human proteome in seconds. * **Unified Index Format**: Single `.pepidx` binary format stores sequences, metadata, and k-mer index in one memory-mapped file. Preprocess once, search repeatedly. -* **Versatile Searching**: Exact matches, mismatch (substitution) searches, single-indel (insertion/deletion) matching, best match mode, and discontinuous epitope support. +* **Versatile Searching**: Exact matches, mismatch (substitution) searches, indel (insertion/deletion) matching, best match mode, and discontinuous epitope support. * **Counts-Only Mode**: Get aggregate hit counts per peptide with `O(unique queries)` memory instead of `O(hits)` — built for massive query sets and dense reference matching. * **Simple API**: Two classes — `Preprocessor` and `Matcher` — handle everything. * **Flexible I/O**: Accepts queries from FASTA files, text files, or Python lists. Outputs to CSV, TSV, XLSX, JSON, or Polars DataFrame. @@ -103,9 +103,9 @@ df = Matcher( max_indels=1 ).match() ``` -Each indel hit is annotated in an **`Indel Positions`** column with the edit type, residue, and 1-based query position — e.g. `d: A[6]` (deletion of `A` at query position 6) or `i: X[6]` (insertion of `X` before query position 6); an exact match reports `[]`. In a repeat the exact position is ambiguous, so the inclusive range of all valid positions is reported, e.g. `d: A[2,4]`. +Each indel hit is annotated in an **`Indel Positions`** column with the edit type, residue, and 1-based query position — e.g. `d: A[6]` (deletion of `A` at query position 6) or `i: X[6]` (insertion of `X` before query position 6); an exact match reports `[]`. In a repeat the exact position is ambiguous, so the inclusive range of all valid positions is reported, e.g. `d: A[2,4]`. When two edits fall at one site they are reported as a single chunk (`d: YA[2]`); when they fall apart they are reported separately (`d: C[3], d: E[5]`). -Currently limited to `max_indels=1`; mutually exclusive with `max_mismatches`. +Supports `max_indels=1` and `max_indels=2`; mutually exclusive with `max_mismatches`. At `max_indels=2` the search is **homogeneous** — a match may use two insertions or two deletions, but never one of each (a mixed pair is a substitution, which mismatch search already covers). The two edits need not be adjacent. #### Best Match @@ -177,7 +177,7 @@ pepmatch-match -q peptides.fasta -p human.fasta -m 0 -k 5 * `-q`, `--query` (Required): Path to the query file. * `-p`, `--proteome_file` (Required): Path to the proteome FASTA file. * `-m`, `--max_mismatches`: Maximum mismatches allowed (default: 0). -* `-i`, `--max_indels`: Maximum indels allowed (default: 0). Currently limited to 1, and mutually exclusive with `-m`. +* `-i`, `--max_indels`: Maximum indels allowed (default: 0). Supports 1 or 2, and mutually exclusive with `-m`. At 2, a match uses two insertions or two deletions, never one of each. * `-k`, `--kmer_size`: K-mer size (default: 5). * `-P`, `--preprocessed_files_path`: Directory containing preprocessed files. * `-b`, `--best_match`: Enable best match mode. diff --git a/pepmatch/matcher.py b/pepmatch/matcher.py index 9282a84..ab5b6b3 100755 --- a/pepmatch/matcher.py +++ b/pepmatch/matcher.py @@ -1,5 +1,6 @@ import os import polars as pl +from itertools import combinations from pathlib import Path from Bio import SeqIO from ._rs import rs_preprocess, rs_match, rs_discontinuous, rs_metadata, rs_match_counts, rs_indel_match @@ -23,41 +24,88 @@ def output_matches(df: pl.DataFrame, output_format: str, output_name: str) -> No df.write_json(path) -def _indel_edit(query, matched): - """Return (kind, residues, low, high) for a single-indel match, or None for an - exact match: kind 'd'/'i', the deleted query or inserted protein residue(s), and - [low, high] the inclusive 1-based range of valid positions (a run of equivalent - positions in a repeat).""" - if matched == query: - return None - L = len(query) - if len(matched) < L: - # interior positions only — query-terminal deletions are barred - positions = [i for i in range(2, L) if query[:i - 1] + query[i:] == matched] - if not positions: - return None - return ('d', query[positions[0] - 1], positions[0], positions[-1]) - # interior matched residues only — boundary insertions are barred - positions, residue = [], None - for k in range(1, len(matched) - 1): - if matched[:k] + matched[k + 1:] == query: - positions.append(k + 1) # 1-based query position the inserted residue precedes - residue = matched[k] - if not positions: - return None - return ('i', residue, positions[0], positions[-1]) +def _indel_placements(query, matched): + """Every valid way the edits could be placed, as a tuple of (1-based query + position, residue) per edit. A repeat makes several placements equivalent.""" + L, M = len(query), len(matched) + n = abs(M - L) + if n == 0: + return [] + out = [] + if M < L: + # deletions: interior query positions only — query-terminal deletions are barred + for combo in combinations(range(1, L - 1), n): + if ''.join(query[i] for i in range(L) if i not in combo) == matched: + out.append(tuple((i + 1, query[i]) for i in combo)) + else: + # insertions: the residue comes from the protein; its query position is the + # number of query residues preceding it. Boundary insertions are barred. + for combo in combinations(range(M), n): + if ''.join(matched[i] for i in range(M) if i not in combo) != query: + continue + placement = [] + for k in combo: + p = sum(1 for x in range(k) if x not in combo) + 1 + if p == 1 or p == L + 1: + placement = None + break + placement.append((p, matched[k])) + if placement: + out.append(tuple(placement)) + return out + + +def _indel_edits(query, matched): + """Entries for the Indel Positions column as (kind, residues, low, high).""" + placements = _indel_placements(query, matched) + if not placements: + return [] + kind = 'd' if len(matched) < len(query) else 'i' + + if len(placements[0]) == 1: + positions = sorted(p for ((p, _),) in placements) + residue = placements[0][0][1] + return [(kind, residue, positions[0], positions[-1])] + + # Two edits at one site form a chunk: adjacent query positions for a deletion, + # the same query gap for an insertion. + chunks = sorted({ + (p1, r1 + r2) for (p1, r1), (p2, r2) in placements + if (p2 == p1 + 1 if kind == 'd' else p2 == p1) + }) + if chunks: + entries = [] + for start, residues in chunks: + # Only range contiguous starts that remove the SAME residues — in a periodic + # repeat (ABABAB) the removed pair changes along the range, and ranging them + # would misreport which residues went missing. + if entries and entries[-1][1] == residues and entries[-1][3] + 1 == start: + k, r, low, _ = entries[-1] + entries[-1] = (k, r, low, start) + else: + entries.append((kind, residues, start, start)) + return entries + + # No chunk: two independent edits. Their positions decouple, and each edit's own + # positions form a run of one repeated residue, so a per-edit range is exact. + entries = [] + for slot in (0, 1): + positions = sorted({pl[slot][0] for pl in placements}) + entries.append((kind, placements[0][slot][1], positions[0], positions[-1])) + return entries def format_indel_positions(query, matched): - """Render the Indel Positions column, e.g. 'd: A[6]', 'i: X[2,4]', or '[]' for - an exact match. Positions are 1-based; a range [low,high] collapses to [n] when - the position is unambiguous.""" - edit = _indel_edit(query, matched) - if edit is None: + """Render the Indel Positions column, e.g. 'd: A[6]', 'd: AA[2,4]', + 'd: C[3], d: E[5]', or '[]' for an exact match. Positions are 1-based; a range + collapses to a single number when the placement is unambiguous.""" + edits = _indel_edits(query, matched) + if not edits: return '[]' - kind, residues, low, high = edit - span = f'[{low}]' if low == high else f'[{low},{high}]' - return f'{kind}: {residues}{span}' + return ', '.join( + f'{kind}: {residues}[{low}]' if low == high else f'{kind}: {residues}[{low},{high}]' + for kind, residues, low, high in edits + ) class Matcher: @@ -89,8 +137,10 @@ def __init__( if k != 0 and k < 2: raise ValueError('k must be >= 2.') - if max_indels > 1: - raise ValueError('max_indels > 1 is not yet supported. Only max_indels=1 has been validated.') + # max_indels=2 searches homogeneous edits only: two insertions OR two deletions, + # never one of each (a mixed pair is a substitution, which mismatch search covers). + if max_indels > 2: + raise ValueError('max_indels > 2 is not yet supported. Only max_indels<=2 has been validated.') if max_indels > 0 and max_mismatches > 0: raise ValueError('max_indels and max_mismatches are mutually exclusive.') diff --git a/pepmatch/rs-engine/src/match.rs b/pepmatch/rs-engine/src/match.rs index 8c830db..d3ff800 100644 --- a/pepmatch/rs-engine/src/match.rs +++ b/pepmatch/rs-engine/src/match.rs @@ -557,6 +557,9 @@ fn is_terminal_deletion(q_idx: isize, query_len: usize, p_idx: isize, protein_le false } +// `allow_del` / `allow_ins` mask the edit branches so a single alignment uses deletions +// OR insertions, never both. extend_bidirectional fixes the mask for a seed's two +// extensions, which is what stops a left-deletion mixing with a right-insertion. fn dfs( query: &[u8], q_idx: isize, @@ -564,6 +567,8 @@ fn dfs( p_idx: isize, indels_left: usize, direction: isize, + allow_del: bool, + allow_ins: bool, ) -> Vec { if (direction == 1 && q_idx >= query.len() as isize) || (direction == -1 && q_idx < 0) @@ -580,22 +585,27 @@ fn dfs( && (p_idx as usize) < protein_len && query[q_idx as usize] == protein[p_idx as usize] { - for consumed in dfs(query, q_idx + direction, protein, p_idx + direction, indels_left, direction) { + for consumed in dfs( + query, q_idx + direction, protein, p_idx + direction, indels_left, direction, + allow_del, allow_ins, + ) { all_paths.push(consumed + 1); } } if indels_left > 0 { // Deletion branch: query pointer advances, protein stays, 0 protein chars consumed. - if !is_terminal_deletion(q_idx, query_len, p_idx, protein_len) { + if allow_del && !is_terminal_deletion(q_idx, query_len, p_idx, protein_len) { all_paths.extend(dfs( query, q_idx + direction, protein, p_idx, indels_left - 1, direction, + allow_del, allow_ins, )); } // Insertion branch: protein pointer advances, query stays, 1 protein char consumed. - if p_idx >= 0 && (p_idx as usize) < protein_len { + if allow_ins && p_idx >= 0 && (p_idx as usize) < protein_len { for consumed in dfs( query, q_idx, protein, p_idx + direction, indels_left - 1, direction, + allow_del, allow_ins, ) { all_paths.push(consumed + 1); } @@ -616,33 +626,39 @@ fn extend_bidirectional( let mut seen: HashSet<(usize, Vec)> = HashSet::new(); let mut results: Vec<(usize, Vec)> = Vec::new(); - for r_budget in 0..=max_indels { - let l_budget = max_indels - r_budget; - - let r_paths = dfs( - query, (q_seed_start + k) as isize, protein, (p_hit_idx + k) as isize, - r_budget, 1, - ); - let l_paths = dfs( - query, q_seed_start as isize - 1, protein, p_hit_idx as isize - 1, - l_budget, -1, - ); - - for &r_consumed in &r_paths { - for &l_consumed in &l_paths { - if l_consumed > p_hit_idx { - continue; - } - let start = p_hit_idx - l_consumed; - let end = p_hit_idx + k + r_consumed; - if end > protein.len() { - continue; - } - let matched = protein[start..end].to_vec(); - let key = (start, matched.clone()); - if !seen.contains(&key) { - seen.insert(key); - results.push((start, matched)); + // Two homogeneous passes: deletions-only, then insertions-only. Fixing the edit type + // across BOTH extensions of the seed is what keeps an alignment homogeneous — masking + // per-side would still let a left-deletion pair with a right-insertion. `seen` + // collapses the exact match, which both passes find. + for &(allow_del, allow_ins) in &[(true, false), (false, true)] { + for r_budget in 0..=max_indels { + let l_budget = max_indels - r_budget; + + let r_paths = dfs( + query, (q_seed_start + k) as isize, protein, (p_hit_idx + k) as isize, + r_budget, 1, allow_del, allow_ins, + ); + let l_paths = dfs( + query, q_seed_start as isize - 1, protein, p_hit_idx as isize - 1, + l_budget, -1, allow_del, allow_ins, + ); + + for &r_consumed in &r_paths { + for &l_consumed in &l_paths { + if l_consumed > p_hit_idx { + continue; + } + let start = p_hit_idx - l_consumed; + let end = p_hit_idx + k + r_consumed; + if end > protein.len() { + continue; + } + let matched = protein[start..end].to_vec(); + let key = (start, matched.clone()); + if !seen.contains(&key) { + seen.insert(key); + results.push((start, matched)); + } } } } diff --git a/pepmatch/tests/indel2_brute_force.py b/pepmatch/tests/indel2_brute_force.py new file mode 100644 index 0000000..2bb27f2 --- /dev/null +++ b/pepmatch/tests/indel2_brute_force.py @@ -0,0 +1,75 @@ +def _out_of_bounds_deletion(p_idx, protein_len): + """Blocks a deletion whose protein index is truly out of bounds — a database edge + effect rather than an indel. p_idx 0 and protein_len-1 are real residues, allowed.""" + return p_idx < 0 or p_idx >= protein_len + + +def _align_deletions(query, q_idx, window, w_idx, dels_left, protein, w_offset): + """query[q_idx:] aligns to window[w_idx:] using only deletions — no insertions, no + mismatches. Keeping the two edit kinds in separate passes is what makes a match + homogeneous: an alignment can never mix a deletion with an insertion.""" + if q_idx == len(query): + return w_idx == len(window) + + if w_idx < len(window) and query[q_idx] == window[w_idx]: + if _align_deletions(query, q_idx + 1, window, w_idx + 1, dels_left, protein, w_offset): + return True + + if dels_left > 0: + # A deletion at the query's own first/last residue has no query-side context to + # confirm the residue is genuinely absent, so it is barred. + query_terminal = q_idx == 0 or q_idx == len(query) - 1 + if not query_terminal and not _out_of_bounds_deletion(w_offset + w_idx, len(protein)): + if _align_deletions(query, q_idx + 1, window, w_idx, dels_left - 1, protein, w_offset): + return True + + return False + + +def _align_insertions(query, q_idx, window, w_idx, ins_left): + """query[q_idx:] aligns to window[w_idx:] using only insertions.""" + if q_idx == len(query): + return w_idx == len(window) + + if w_idx < len(window) and query[q_idx] == window[w_idx]: + if _align_insertions(query, q_idx + 1, window, w_idx + 1, ins_left): + return True + + # q_idx > 0 bars an insertion before the first query residue; one after the last is + # unreachable because the base case returns as soon as the query is consumed. Both + # would only pad an exact match with an arbitrary flanking residue. + if ins_left > 0 and q_idx > 0 and w_idx < len(window): + if _align_insertions(query, q_idx, window, w_idx + 1, ins_left - 1): + return True + + return False + + +def brute_force_search(query, protein, max_indels=2): + """Enumerate every (start, matched_sequence) where query aligns to a window of + protein using at most max_indels homogeneous indels — insertions only or deletions + only, never both. + + Checks each candidate window directly against the definition of a valid match (no + seeding, no bidirectional extension), so it can serve as an independent oracle for + the Rust engine. + """ + query_len = len(query) + protein_len = len(protein) + results = set() + + for start in range(protein_len): + for window_len in range(max(0, query_len - max_indels), query_len + max_indels + 1): + end = start + window_len + if end > protein_len: + continue + window = protein[start:end] + if window_len <= query_len: + if _align_deletions( + query, 0, window, 0, query_len - window_len, protein, start + ): + results.add((start, window)) + elif _align_insertions(query, 0, window, 0, window_len - query_len): + results.add((start, window)) + + return results diff --git a/pepmatch/tests/test_indel2_property.py b/pepmatch/tests/test_indel2_property.py new file mode 100644 index 0000000..45b057a --- /dev/null +++ b/pepmatch/tests/test_indel2_property.py @@ -0,0 +1,84 @@ +import random +import pytest +from pepmatch import Matcher +from indel2_brute_force import brute_force_search + +AMINO_ACIDS = 'ACDEFGHIKLMNPQRSTVWY' + + +def _random_sequence(rng, length): + return ''.join(rng.choice(AMINO_ACIDS) for _ in range(length)) + + +# Named for the edit applied to the query string, not the resulting Indels label — e.g. +# _with_deletions removes query residues, so the protein has "extra" content there +# relative to the query, which the search reports as an insertion match. +def _with_deletions(rng, seq, n): + for _ in range(n): + d = rng.randrange(len(seq)) + seq = seq[:d] + seq[d + 1:] + return seq + + +def _with_insertions(rng, seq, n): + for _ in range(n): + i = rng.randrange(1, len(seq)) + seq = seq[:i] + rng.choice(AMINO_ACIDS) + seq[i:] + return seq + + +def _random_query(rng, proteins): + protein = proteins[rng.choice(list(proteins))] + qlen = rng.randint(9, 14) + start = rng.randrange(0, len(protein) - qlen + 1) + base = protein[start:start + qlen] + mode = rng.choice(['exact', 'del1', 'ins1', 'del2', 'ins2', 'random']) + if mode == 'del1': + return _with_deletions(rng, base, 1) + if mode == 'ins1': + return _with_insertions(rng, base, 1) + if mode == 'del2': + return _with_deletions(rng, base, 2) + if mode == 'ins2': + return _with_insertions(rng, base, 2) + if mode == 'random': + return _random_sequence(rng, qlen) + return base + + +@pytest.mark.parametrize('seed', range(15)) +def test_indel2_search_matches_brute_force_oracle(tmp_path, seed): + rng = random.Random(seed) + + proteins = {f'P{i}': _random_sequence(rng, rng.randint(20, 40)) for i in range(4)} + proteome_path = tmp_path / 'proteome.fasta' + proteome_path.write_text( + ''.join(f'>{pid}\n{seq}\n' for pid, seq in proteins.items()) + ) + + queries = [(f'q{i}', _random_query(rng, proteins)) for i in range(8)] + query_path = tmp_path / 'queries.fasta' + query_path.write_text( + ''.join(f'>{qid}\n{seq}\n' for qid, seq in queries) + ) + + df = Matcher( + query=str(query_path), + proteome_file=str(proteome_path), + max_indels=2, + preprocessed_files_path=str(tmp_path), + output_format='dataframe' + ).match() + + actual = set() + for row in df.iter_rows(named=True): + if row['Matched Sequence'] is not None: + actual.add((row['Query Sequence'], row['Protein ID'], row['Matched Sequence'])) + + expected = set() + for _, qseq in queries: + for pid, pseq in proteins.items(): + for _, matched in brute_force_search(qseq, pseq, 2): + expected.add((qseq, f'{pid}.1', matched)) + + assert actual == expected diff --git a/pepmatch/tests/test_indel_search.py b/pepmatch/tests/test_indel_search.py index d1d674f..220b8ae 100644 --- a/pepmatch/tests/test_indel_search.py +++ b/pepmatch/tests/test_indel_search.py @@ -110,9 +110,9 @@ def test_insertion_at_second_to_last_position_found(tmp_path): assert ('P.1', 'LPDGVWEESS') in hits -def test_max_indels_greater_than_one_raises(): - with pytest.raises(ValueError, match='max_indels > 1 is not yet supported'): - Matcher(query=['NALVEATRFC'], proteome_file='unused.fasta', max_indels=2) +def test_max_indels_greater_than_two_raises(): + with pytest.raises(ValueError, match='max_indels > 2 is not yet supported'): + Matcher(query=['NALVEATRFC'], proteome_file='unused.fasta', max_indels=3) def test_indels_and_mismatches_mutually_exclusive_raises(): @@ -239,6 +239,75 @@ def test_indel_positions_deletion_end_to_end(tmp_path): assert row['Indel Positions'].item() == 'd: A[6]' +def test_two_consecutive_deletions_found(tmp_path): + # YYADGY loses the contiguous YA -> YDGY: one 2-residue deletion event. + proteome_path = tmp_path / 'proteome.fasta' + proteome_path.write_text('>P\nMKYDGYWW\n') + df = Matcher( + query=['YYADGY'], proteome_file=str(proteome_path), max_indels=2, + preprocessed_files_path=str(tmp_path), output_format='dataframe' + ).match() + row = df.filter(pl.col('Matched Sequence') == 'YDGY') + assert row.height == 1 + assert row['Indels'].item() == 2 + assert row['Indel Positions'].item() == 'd: YA[2]' + + +def test_two_non_consecutive_deletions_found(tmp_path): + # ABCDEF loses C (3) and E (5) -> ABDF: two independent deletion events, so the + # annotation reports them separately rather than as one chunk. + proteome_path = tmp_path / 'proteome.fasta' + proteome_path.write_text('>P\nMKABDFWW\n') + df = Matcher( + query=['ABCDEF'], proteome_file=str(proteome_path), max_indels=2, + preprocessed_files_path=str(tmp_path), output_format='dataframe' + ).match() + row = df.filter(pl.col('Matched Sequence') == 'ABDF') + assert row.height == 1 + assert row['Indels'].item() == 2 + assert row['Indel Positions'].item() == 'd: C[3], d: E[5]' + + +def test_two_insertions_found(tmp_path): + proteome_path = tmp_path / 'proteome.fasta' + proteome_path.write_text('>P\nMKABXCDYEFWW\n') + df = Matcher( + query=['ABCDEF'], proteome_file=str(proteome_path), max_indels=2, + preprocessed_files_path=str(tmp_path), output_format='dataframe' + ).match() + row = df.filter(pl.col('Matched Sequence') == 'ABXCDYEF') + assert row.height == 1 + assert row['Indels'].item() == 2 + assert row['Indel Positions'].item() == 'i: X[3], i: Y[5]' + + +def test_mixed_insertion_and_deletion_not_found(tmp_path): + # max_indels=2 is HOMOGENEOUS: two insertions or two deletions, never one of each. + # ABXCDF would need an inserted X plus a deleted E — that pair is a substitution, + # which mismatch search covers, so the indel engine must not report it. + proteome_path = tmp_path / 'proteome.fasta' + proteome_path.write_text('>P\nMKABXCDFWW\n') + df = Matcher( + query=['ABCDEF'], proteome_file=str(proteome_path), max_indels=2, + preprocessed_files_path=str(tmp_path), output_format='dataframe' + ).match() + matched = [m for m in df['Matched Sequence'].to_list() if m] + assert 'ABXCDF' not in matched + + +def test_indel2_positions_annotation_unit(): + # Two edits at one site collapse to a chunk; otherwise they report separately. In a + # repeat the position is a range — but only where the removed residues stay the same. + assert format_indel_positions('YYADGY', 'YDGY') == 'd: YA[2]' # consecutive chunk + assert format_indel_positions('AAAAAA', 'AAAA') == 'd: AA[2,4]' # homopolymer chunk + assert format_indel_positions('AAAA', 'AAAAAA') == 'i: AA[2,4]' # insertion chunk + assert format_indel_positions('ABCDEF', 'ABDF') == 'd: C[3], d: E[5]' + assert format_indel_positions('AAABAA', 'AABA') == 'd: A[2,3], d: A[5]' + # Periodic repeat: the removed pair changes along the range (BA/AB/BA), so the range + # must NOT be collapsed or it would misreport which residues went missing. + assert format_indel_positions('ABABAB', 'ABAB') == 'd: BA[2], d: AB[3], d: BA[4]' + + def test_indel_positions_insertion_end_to_end(tmp_path): # Query NALVEATRFC vs a protein with an extra X after E -> matched NALVEXATRFC, # annotated as an insertion of X before query position 6. From 5fb940fe1a854bac7980b8cf07978b8651fa9a4f Mon Sep 17 00:00:00 2001 From: Roman Young Date: Sun, 12 Jul 2026 10:41:17 -0700 Subject: [PATCH 2/2] ci: run 2-indel property tests in the workflow matrix The matrix is an explicit per-file allowlist, so test_indel2_property.py would never execute in CI without a row of its own. --- .github/workflows/tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8e3e57a..075cfa5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,8 @@ jobs: test-file: pepmatch/tests/test_indel_search.py - name: Indel Property test-file: pepmatch/tests/test_indel_property.py + - name: 2-Indel Property + test-file: pepmatch/tests/test_indel2_property.py steps: - name: Checkout Code