Skip to content

Commit baf800e

Browse files
authored
feat: add 1-indel search via bidirectional DFS in Rust (#24)
- Adds max_indels=1 search to the Rust engine: pigeonhole-seeded bidirectional DFS (run_indel -> indel_search_peptide -> extend_bidirectional). - End-position deletions and terminal insertions are disallowed. - Output carries an Indels column in indel mode (Mismatches elsewhere, never both); indel is rejected with mismatches/best_match/counts_only/discontinuous. CLI -i/--max_indels flag + README section. 41 tests incl. a brute-force property oracle.
1 parent 2585d67 commit baf800e

12 files changed

Lines changed: 626 additions & 13 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
.~*
22
*.db
3+
*.pepidx
34
*.pickle
45
*.pkl
56
*.pyc

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,18 @@ df = Matcher(
9393
).match()
9494
```
9595

96+
#### Indel Searching
97+
98+
Search allowing insertions and deletions (indels) instead of substitution mismatches. The k-mer size is chosen automatically from your query lengths — no manual preprocessing needed.
99+
```python
100+
df = Matcher(
101+
query='neoepitopes.fasta',
102+
proteome_file='human.fasta',
103+
max_indels=1
104+
).match()
105+
```
106+
Currently limited to `max_indels=1`; mutually exclusive with `max_mismatches`.
107+
96108
#### Best Match
97109

98110
Automatically finds the optimal match for each peptide by trying different k-mer sizes and mismatch thresholds. No manual preprocessing required.
@@ -163,6 +175,7 @@ pepmatch-match -q peptides.fasta -p human.fasta -m 0 -k 5
163175
* `-q`, `--query` (Required): Path to the query file.
164176
* `-p`, `--proteome_file` (Required): Path to the proteome FASTA file.
165177
* `-m`, `--max_mismatches`: Maximum mismatches allowed (default: 0).
178+
* `-i`, `--max_indels`: Maximum indels allowed (default: 0). Currently limited to 1, and mutually exclusive with `-m`.
166179
* `-k`, `--kmer_size`: K-mer size (default: 5).
167180
* `-P`, `--preprocessed_files_path`: Directory containing preprocessed files.
168181
* `-b`, `--best_match`: Enable best match mode.

pepmatch/matcher.py

Lines changed: 55 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import polars as pl
33
from pathlib import Path
44
from Bio import SeqIO
5-
from ._rs import rs_preprocess, rs_match, rs_discontinuous, rs_metadata, rs_match_counts
5+
from ._rs import rs_preprocess, rs_match, rs_discontinuous, rs_metadata, rs_match_counts, rs_indel_match
66

77
VALID_OUTPUT_FORMATS = ['dataframe', 'csv', 'tsv', 'xlsx', 'json']
88
FASTA_EXTENSIONS = {
@@ -31,6 +31,7 @@ def __init__(
3131
query,
3232
proteome_file,
3333
max_mismatches=0,
34+
max_indels=0,
3435
k=0,
3536
preprocessed_files_path='.',
3637
best_match=False,
@@ -53,6 +54,20 @@ def __init__(
5354
if k != 0 and k < 2:
5455
raise ValueError('k must be >= 2.')
5556

57+
if max_indels > 1:
58+
raise ValueError('max_indels > 1 is not yet supported. Only max_indels=1 has been validated.')
59+
60+
if max_indels > 0 and max_mismatches > 0:
61+
raise ValueError('max_indels and max_mismatches are mutually exclusive.')
62+
63+
if max_indels > 0 and best_match:
64+
raise ValueError('max_indels and best_match are not yet supported together.')
65+
66+
if max_indels > 0 and counts_only:
67+
raise ValueError('max_indels and counts_only are not yet supported together.')
68+
69+
self.max_indels = max_indels
70+
5671
if output_format not in VALID_OUTPUT_FORMATS:
5772
raise ValueError(
5873
f'Invalid output format. Choose from: {VALID_OUTPUT_FORMATS}'
@@ -73,6 +88,8 @@ def __init__(
7388

7489
self.query = self._parse_query(query)
7590
self.discontinuous_epitopes = self._find_discontinuous_epitopes()
91+
if self.max_indels > 0 and self.discontinuous_epitopes:
92+
raise ValueError('max_indels and discontinuous epitopes are not supported together.')
7693
self.query = self._clean_query()
7794
if not self.query and not self.discontinuous_epitopes:
7895
raise ValueError('Query is empty.')
@@ -128,7 +145,9 @@ def match(self):
128145
return output_matches(df, self.output_format, self.output_name)
129146

130147
if self.query:
131-
if self.best_match and self.k_specified:
148+
if self.max_indels > 0:
149+
linear_df = self.indel_search()
150+
elif self.best_match and self.k_specified:
132151
results = self._search(self.k, self.max_mismatches)
133152
linear_df = self._best_match_filter(self._to_dataframe(results))
134153
elif self.best_match:
@@ -155,6 +174,19 @@ def match(self):
155174
else:
156175
output_matches(df, self.output_format, self.output_name)
157176

177+
def indel_search(self):
178+
min_len = min(len(seq) for _, seq in self.query)
179+
k = max(2, min_len // (self.max_indels + 1))
180+
pepidx_path = self._pepidx_path(k)
181+
if not os.path.isfile(pepidx_path):
182+
print(f"No preprocessed file found for k={k}, building index now "
183+
f"(this may take a moment)...")
184+
rs_preprocess(self.proteome_file, k, pepidx_path)
185+
print(f"Searching {len(self.query)} peptides against {self.proteome_name} "
186+
f"(k={k}, max_indels={self.max_indels})...")
187+
results = rs_indel_match(pepidx_path, self.query, self.max_indels)
188+
return self._to_dataframe(results, is_indels=True)
189+
158190
def _pepidx_path(self, k):
159191
return os.path.join(
160192
self.preprocessed_files_path, f'{self.proteome_name}_{k}mers.pepidx'
@@ -327,20 +359,31 @@ def _metadata_table(self) -> pl.DataFrame:
327359
pl.col('SwissProt Reviewed').cast(pl.Int64).cast(pl.Boolean),
328360
])
329361

330-
FINAL_COLUMNS = [
331-
'Query ID','Query Sequence','Matched Sequence','Protein ID','Protein Name','Species',
332-
'Taxon ID','Gene','Mismatches','Mutated Positions','Index start','Index end',
333-
'Protein Existence Level','Gene Priority','SwissProt Reviewed',
334-
]
362+
def _final_columns(self, is_indels):
363+
# One edit-count column per mode, in a fixed position: Indels for indel search,
364+
# Mismatches for every other mode. Never both — an all-zero twin column would
365+
# imply a search that didn't run.
366+
edit_col = 'Indels' if is_indels else 'Mismatches'
367+
return [
368+
'Query ID','Query Sequence','Matched Sequence','Protein ID','Protein Name','Species',
369+
'Taxon ID','Gene', edit_col, 'Mutated Positions','Index start','Index end',
370+
'Protein Existence Level','Gene Priority','SwissProt Reviewed',
371+
]
335372

336-
def _to_dataframe(self, cols):
373+
def _to_dataframe(self, cols, is_indels=False):
337374
"""Build the result DataFrame from columnar Rust output, reconstructing protein
338375
metadata via a single join (instead of cloning it into every hit row)."""
339376
qid, qseq, matched, pnum, mm, mutated, istart, iend = cols
340377

378+
# rs_indel_match packs the indel count into the same slot rs_match/rs_discontinuous
379+
# use for mismatches, so the values are identical in shape — only the column name
380+
# differs by mode (Indels vs Mismatches), and only one is ever emitted.
381+
edit_col = 'Indels' if is_indels else 'Mismatches'
382+
final_columns = self._final_columns(is_indels)
383+
341384
if not qid:
342-
schema = {c: pl.Utf8 for c in self.FINAL_COLUMNS}
343-
for c in ('Mismatches','Index start','Index end','Protein Existence Level','Gene Priority'):
385+
schema = {c: pl.Utf8 for c in final_columns}
386+
for c in (edit_col, 'Index start', 'Index end', 'Protein Existence Level', 'Gene Priority'):
344387
schema[c] = pl.Int64
345388
schema['SwissProt Reviewed'] = pl.Boolean
346389
return pl.DataFrame(schema=schema)
@@ -350,7 +393,7 @@ def _to_dataframe(self, cols):
350393
'Query Sequence': qseq,
351394
'Matched Sequence': matched,
352395
'protein_num': pl.Series(pnum, dtype=pl.UInt32),
353-
'Mismatches': pl.Series(mm, dtype=pl.Int64),
396+
edit_col: pl.Series(mm, dtype=pl.Int64),
354397
'Mutated Positions': mutated,
355398
'Index start': pl.Series(istart, dtype=pl.Int64),
356399
'Index end': pl.Series(iend, dtype=pl.Int64),
@@ -366,4 +409,4 @@ def _to_dataframe(self, cols):
366409
.alias('Protein ID')
367410
)
368411

369-
return df.drop('Sequence Version').select(self.FINAL_COLUMNS)
412+
return df.drop('Sequence Version').select(final_columns)

pepmatch/rs-engine/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pepmatch/rs-engine/src/lib.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ fn rs_match_counts(pepidx_path: &str, peptides: Vec<(String, String)>, k: usize,
3434
matching::run_counts(pepidx_path, peptides, k, max_mismatches)
3535
}
3636

37+
#[pyfunction]
38+
fn rs_indel_match(pepidx_path: &str, peptides: Vec<(String, String)>, indels_allowed: usize) -> matching::Columns {
39+
matching::run_indel(pepidx_path, peptides, indels_allowed)
40+
}
41+
3742
#[pymodule]
3843
fn _rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
3944
m.add_function(wrap_pyfunction!(rs_version, m)?)?;
@@ -42,5 +47,6 @@ fn _rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
4247
m.add_function(wrap_pyfunction!(rs_discontinuous, m)?)?;
4348
m.add_function(wrap_pyfunction!(rs_metadata, m)?)?;
4449
m.add_function(wrap_pyfunction!(rs_match_counts, m)?)?;
50+
m.add_function(wrap_pyfunction!(rs_indel_match, m)?)?;
4551
Ok(())
4652
}

0 commit comments

Comments
 (0)