Skip to content

Commit 2d0e610

Browse files
authored
fix: honor explicit k in indel search, clamp above the pigeonhole optimal (#29)
indel_search ignored the user's k and always used the optimized value, so an explicit k was silently swapped and its k-mer table rebuilt. It now honors an explicit k up to the pigeonhole optimal (reusing an existing table rather than rebuilding) and clamps a larger k down to the optimal with a warning, preserving complete recall. _auto_k is generalized to take the edit count so the mismatch and indel paths share one pigeonhole formula. Fixes #28.
1 parent 0438755 commit 2d0e610

2 files changed

Lines changed: 79 additions & 11 deletions

File tree

pepmatch/matcher.py

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ def match(self):
182182
discontinuous_df = pl.DataFrame()
183183

184184
if self.counts_only:
185-
k = self.k if self.k_specified else self._auto_k()
185+
k = self.k if self.k_specified else self._auto_k(self.max_mismatches)
186186
df = self._counts_to_dataframe(self._search_counts(k, self.max_mismatches))
187187
if self.output_format == 'dataframe':
188188
return df
@@ -197,7 +197,7 @@ def match(self):
197197
elif self.best_match:
198198
linear_df = self.best_match_search()
199199
else:
200-
k = self.k if self.k_specified else self._auto_k()
200+
k = self.k if self.k_specified else self._auto_k(self.max_mismatches)
201201
results = self._search(k, self.max_mismatches)
202202
linear_df = self._to_dataframe(results)
203203

@@ -220,8 +220,20 @@ def match(self):
220220
output_matches(df, self.output_format, self.output_name)
221221

222222
def indel_search(self):
223-
min_len = min(len(seq) for _, seq in self.query)
224-
k = max(2, min_len // (self.max_indels + 1))
223+
# Honor an explicitly-passed k, but never above the pigeonhole ceiling: a k
224+
# larger than the optimal drops below max_indels+1 disjoint seeds and forfeits
225+
# complete recall, so clamp it down. (The constructor already rejects
226+
# k > shortest query length, so an explicit k reaching here is always <= min_len.)
227+
optimized_k = self._auto_k(self.max_indels)
228+
if self.k_specified:
229+
k = self.k
230+
if k > optimized_k:
231+
print(f"Requested k={k} exceeds k={optimized_k}, the largest k that "
232+
f"guarantees complete recall for these queries (pigeonhole); "
233+
f"using k={optimized_k}.")
234+
k = optimized_k
235+
else:
236+
k = optimized_k
225237
pepidx_path = self._pepidx_path(k)
226238
if not os.path.isfile(pepidx_path):
227239
print(f"No preprocessed file found for k={k}, building index now "
@@ -232,14 +244,14 @@ def indel_search(self):
232244
results = rs_indel_match(pepidx_path, self.query, self.max_indels)
233245
return self._to_dataframe(results, is_indels=True)
234246

235-
def _auto_k(self):
236-
"""Optimal k for a seed-based mismatch search (PEPMatch paper / pigeonhole):
237-
a length-L peptide with m mismatches must split into at least m+1 disjoint
238-
k-mers so one is guaranteed mismatch-free, i.e. k = floor(L / (m + 1)). Uses
239-
the shortest query peptide so the guarantee holds for every peptide; floored
240-
at 2 (the preprocessor minimum)."""
247+
def _auto_k(self, edits):
248+
"""Optimal k for a seed-based search (PEPMatch paper / pigeonhole): a length-L
249+
peptide with `edits` allowed edits (mismatches or indels) must split into at
250+
least edits+1 disjoint k-mers so one is guaranteed edit-free, i.e.
251+
k = floor(L / (edits + 1)). Uses the shortest query peptide so the guarantee
252+
holds for every peptide; floored at 2 (the preprocessor minimum)."""
241253
min_len = min(len(seq) for _, seq in self.query)
242-
return max(2, min_len // (self.max_mismatches + 1))
254+
return max(2, min_len // (edits + 1))
243255

244256
def _pepidx_path(self, k):
245257
return os.path.join(

pepmatch/tests/test_indel_search.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,59 @@ def test_indel_positions_insertion_end_to_end(tmp_path):
254254
row = df.filter(pl.col('Matched Sequence') == 'NALVEXATRFC')
255255
assert row.height == 1
256256
assert row['Indel Positions'].item() == 'i: X[6]'
257+
258+
259+
def test_indel_honors_explicit_k_up_to_optimal_and_reuses_table(tmp_path, capsys):
260+
# Issue #28: a user who already built a k-mer table (e.g. for a mismatch run) and
261+
# passes that same k to indel search should have it reused, not silently swapped
262+
# for the optimized k and rebuilt. Query len 10, max_indels=1 -> optimized k = 5;
263+
# k=3 <= 5 so it is honored, and the pre-built 3-mer index is used as-is.
264+
proteome_path = tmp_path / 'proteome.fasta'
265+
proteome_path.write_text('>ProtDel\nMKVNALVETRFCGHI\n')
266+
# the user "already has" a 3-mer table from an earlier run
267+
Matcher(query=['NALVEATRFC'], proteome_file=str(proteome_path), max_mismatches=0,
268+
k=3, preprocessed_files_path=str(tmp_path), output_format='dataframe').match()
269+
assert (tmp_path / 'proteome_3mers.pepidx').exists()
270+
capsys.readouterr() # discard the pre-build output
271+
df = Matcher(query=['NALVEATRFC'], proteome_file=str(proteome_path), max_indels=1,
272+
k=3, preprocessed_files_path=str(tmp_path), output_format='dataframe').match()
273+
out = capsys.readouterr().out
274+
assert '(k=3,' in out # honored the user's k, not the optimal 5
275+
assert 'No preprocessed file' not in out # reused the existing table, no rebuild
276+
assert 'NALVETRFC' in df['Matched Sequence'].to_list() # recall preserved at k=3
277+
278+
279+
def test_indel_clamps_k_above_optimal_and_warns(tmp_path, capsys):
280+
# An explicit k above the pigeonhole optimal drops below max_indels+1 disjoint
281+
# seeds and would forfeit complete recall, so indel search clamps it to the
282+
# optimal and warns. Query len 10, max_indels=1 -> optimized k = 5; k=7 (still
283+
# <= min_len, so it passes the constructor guard) is clamped down to 5.
284+
proteome_path = tmp_path / 'proteome.fasta'
285+
proteome_path.write_text('>ProtDel\nMKVNALVETRFCGHI\n')
286+
df = Matcher(query=['NALVEATRFC'], proteome_file=str(proteome_path), max_indels=1,
287+
k=7, preprocessed_files_path=str(tmp_path), output_format='dataframe').match()
288+
out = capsys.readouterr().out
289+
assert 'Requested k=7 exceeds k=5' in out # warned about the clamp
290+
assert '(k=5,' in out # searched at the clamped optimal
291+
assert 'NALVETRFC' in df['Matched Sequence'].to_list() # recall still complete
292+
assert (tmp_path / 'proteome_5mers.pepidx').exists() # built the optimal index
293+
assert not (tmp_path / 'proteome_7mers.pepidx').exists() # never the too-large one
294+
295+
296+
def test_indel_unspecified_k_derives_optimal(tmp_path, capsys):
297+
# With no k passed, indel search derives the pigeonhole optimal: max(2, 10//2) = 5.
298+
proteome_path = tmp_path / 'proteome.fasta'
299+
proteome_path.write_text('>ProtDel\nMKVNALVETRFCGHI\n')
300+
Matcher(query=['NALVEATRFC'], proteome_file=str(proteome_path), max_indels=1,
301+
preprocessed_files_path=str(tmp_path), output_format='dataframe').match()
302+
out = capsys.readouterr().out
303+
assert '(k=5,' in out
304+
assert (tmp_path / 'proteome_5mers.pepidx').exists()
305+
306+
307+
def test_indel_k_exceeding_shortest_query_raises():
308+
# The constructor rejects an explicit k larger than the shortest query peptide
309+
# (shorter peptides would be silently skipped). The guard is global, so it also
310+
# governs indel search -- an explicit k reaching indel_search is always <= min_len.
311+
with pytest.raises(ValueError, match='cannot exceed the shortest query peptide'):
312+
Matcher(query=['NALVEATRFC'], proteome_file='unused.fasta', max_indels=1, k=11)

0 commit comments

Comments
 (0)