|
| 1 | +import random |
| 2 | +from pathlib import Path |
| 3 | + |
| 4 | +import pytest |
| 5 | +from Bio import SeqIO |
| 6 | +from pepmatch import Matcher |
| 7 | + |
| 8 | +# Guards the auto-k mismatch fix in matcher.py: when k is UNSPECIFIED, Matcher must |
| 9 | +# derive the pigeonhole-optimal k = max(2, min_len // (m + 1)) so that a seed-based |
| 10 | +# search returns the COMPLETE set of matches. The old code hardcoded k=5, which is |
| 11 | +# too large for short peptides / high m and silently dropped real matches. These |
| 12 | +# tests pin completeness (and soundness) against an independent brute-force oracle. |
| 13 | + |
| 14 | +AMINO_ACIDS = 'ACDEFGHIKLMNPQRSTVWY' |
| 15 | +LENGTHS = (8, 9, 10, 11, 12, 15, 20) |
| 16 | +MISMATCHES = (1, 2, 3) |
| 17 | + |
| 18 | + |
| 19 | +@pytest.fixture |
| 20 | +def proteome_path() -> Path: |
| 21 | + return Path(__file__).parent / 'data' / 'proteome.fasta' |
| 22 | + |
| 23 | + |
| 24 | +def _load_proteins(proteome_path): |
| 25 | + """Load (accession, sequence) pairs. The accession is exactly how PEPMatch labels |
| 26 | + a protein in its output ('<accession>.<version>' in the Protein ID column), so |
| 27 | + keying the oracle by accession lets us compare match LOCATIONS, not just sequences.""" |
| 28 | + proteins = [] |
| 29 | + for record in SeqIO.parse(str(proteome_path), 'fasta'): |
| 30 | + accession = record.id.split('|')[1] if '|' in record.id else record.id |
| 31 | + proteins.append((accession, str(record.seq).upper())) |
| 32 | + return proteins |
| 33 | + |
| 34 | + |
| 35 | +def _hamming(a, b): |
| 36 | + return sum(1 for x, y in zip(a, b) if x != y) |
| 37 | + |
| 38 | + |
| 39 | +def brute_force_mismatch(peptide, proteins, max_mismatches): |
| 40 | + """Independent oracle: every proteome window of len(peptide) whose Hamming |
| 41 | + distance to the peptide is <= max_mismatches. No seeding, no k-mers -- just the |
| 42 | + raw definition of a mismatch match, so it cannot share a blind spot with the |
| 43 | + seed-based implementation. Returns {(accession, start_0based, window)}.""" |
| 44 | + length = len(peptide) |
| 45 | + hits = set() |
| 46 | + for accession, seq in proteins: |
| 47 | + for i in range(len(seq) - length + 1): |
| 48 | + window = seq[i:i + length] |
| 49 | + if _hamming(peptide, window) <= max_mismatches: |
| 50 | + hits.add((accession, i, window)) |
| 51 | + return hits |
| 52 | + |
| 53 | + |
| 54 | +def _matcher_rows(df): |
| 55 | + """Accepted (non-null) matches from Matcher output, canonicalized into the |
| 56 | + oracle's key space: strip the sequence-version suffix from Protein ID to recover |
| 57 | + the accession, and convert the 1-based Index start to a 0-based offset. Returns a |
| 58 | + list (not a set) so callers can detect duplicate emission of the same match.""" |
| 59 | + rows = [] |
| 60 | + for row in df.iter_rows(named=True): |
| 61 | + if row['Matched Sequence'] is None: |
| 62 | + continue |
| 63 | + accession = row['Protein ID'].rsplit('.', 1)[0] |
| 64 | + rows.append((accession, row['Index start'] - 1, row['Matched Sequence'])) |
| 65 | + return rows |
| 66 | + |
| 67 | + |
| 68 | +def _make_query(rng, proteins, length, m): |
| 69 | + """Take a random proteome window of the given length and inject EXACTLY m |
| 70 | + mismatches at distinct positions, each replaced by a different amino acid. The |
| 71 | + source window is therefore always a real match at Hamming distance m.""" |
| 72 | + candidates = [(acc, seq) for acc, seq in proteins if len(seq) >= length] |
| 73 | + accession, seq = rng.choice(candidates) |
| 74 | + start = rng.randrange(0, len(seq) - length + 1) |
| 75 | + window = list(seq[start:start + length]) |
| 76 | + for p in rng.sample(range(length), m): |
| 77 | + window[p] = rng.choice([a for a in AMINO_ACIDS if a != window[p]]) |
| 78 | + return ''.join(window) |
| 79 | + |
| 80 | + |
| 81 | +@pytest.mark.parametrize('m', MISMATCHES) |
| 82 | +def test_auto_k_search_is_complete_vs_brute_force(proteome_path, tmp_path, m): |
| 83 | + # Seeded so a red run reproduces exactly; seed differs per m to vary the corpus. |
| 84 | + rng = random.Random(20240717 + m) |
| 85 | + proteins = _load_proteins(proteome_path) |
| 86 | + seq_by_acc = dict(proteins) |
| 87 | + |
| 88 | + with_matches = 0 |
| 89 | + for length in LENGTHS: |
| 90 | + if length <= m: |
| 91 | + continue |
| 92 | + for _ in range(12): |
| 93 | + peptide = _make_query(rng, proteins, length, m) |
| 94 | + expected = brute_force_mismatch(peptide, proteins, m) |
| 95 | + if not expected: |
| 96 | + continue # a query with no oracle match cannot exercise completeness |
| 97 | + |
| 98 | + df = Matcher( |
| 99 | + query=[peptide], |
| 100 | + proteome_file=str(proteome_path), |
| 101 | + max_mismatches=m, |
| 102 | + preprocessed_files_path=str(tmp_path), |
| 103 | + ).match() |
| 104 | + |
| 105 | + length = len(peptide) |
| 106 | + rows = _matcher_rows(df) |
| 107 | + # A reported hit whose Matched Sequence does not equal the protein's own |
| 108 | + # window at that offset is the pre-existing concatenated-index boundary |
| 109 | + # artifact (a window running off the end of one protein into the next). That |
| 110 | + # is a soundness quirk unrelated to k selection, so it is excluded from the |
| 111 | + # in-protein comparison below; it can only ever ADD matches, never drop one. |
| 112 | + in_protein = [ |
| 113 | + t for t in rows if seq_by_acc[t[0]][t[1]:t[1] + length] == t[2] |
| 114 | + ] |
| 115 | + actual = set(in_protein) |
| 116 | + |
| 117 | + # COMPLETENESS (the contract the fix guards): every real proteome window |
| 118 | + # within m mismatches must be reported -- nothing silently dropped. Set |
| 119 | + # equality also pins SOUNDNESS: no genuine window is over-reported. |
| 120 | + assert actual == expected, ( |
| 121 | + f'auto-k disagreed with brute force for {peptide!r} (m={m}); ' |
| 122 | + f'dropped={sorted(expected - actual)} spurious={sorted(actual - expected)}' |
| 123 | + ) |
| 124 | + # No duplicate emission of a genuine match (a bare set would absorb dupes). |
| 125 | + assert len(in_protein) == len(expected), ( |
| 126 | + f'auto-k emitted {len(in_protein)} in-protein rows but oracle has ' |
| 127 | + f'{len(expected)} distinct matches for {peptide!r} (m={m})' |
| 128 | + ) |
| 129 | + with_matches += 1 |
| 130 | + |
| 131 | + # The suite is only meaningful on peptides that actually have matches; refuse to |
| 132 | + # pass vacuously if the corpus somehow degenerated to all-misses. |
| 133 | + assert with_matches >= 50 |
| 134 | + |
| 135 | + |
| 136 | +def test_auto_k_recovers_match_that_hardcoded_k5_dropped(proteome_path, tmp_path): |
| 137 | + # Focused regression for the exact failure mode the matcher.py fix repairs. |
| 138 | + # |
| 139 | + # 'MSKRALIIA' is the proteome window 'MSKRVLIIA' (accession Q02004, index 0) |
| 140 | + # carrying a single mismatch at the centre. A length-9 / 1-mismatch query needs |
| 141 | + # two disjoint seeds -- i.e. k <= 4 -- for the pigeonhole guarantee to hold. |
| 142 | + # The OLD matcher hardcoded k=5 whenever k was unspecified: with a 5-mer seed no |
| 143 | + # sub-window of the query avoids the central mismatch, so the seed lookup finds |
| 144 | + # no candidate and this real match is SILENTLY DROPPED. The fix derives |
| 145 | + # k = max(2, 9 // (1 + 1)) = 4, which recovers it. |
| 146 | + proteins = _load_proteins(proteome_path) |
| 147 | + peptide = 'MSKRALIIA' |
| 148 | + source_match = ('Q02004', 0, 'MSKRVLIIA') |
| 149 | + |
| 150 | + expected = brute_force_mismatch(peptide, proteins, 1) |
| 151 | + assert source_match in expected # the oracle confirms this is a genuine match |
| 152 | + |
| 153 | + auto_k = set(_matcher_rows( |
| 154 | + Matcher( |
| 155 | + query=[peptide], |
| 156 | + proteome_file=str(proteome_path), |
| 157 | + max_mismatches=1, |
| 158 | + preprocessed_files_path=str(tmp_path), |
| 159 | + ).match() |
| 160 | + )) |
| 161 | + hardcoded_k5 = set(_matcher_rows( |
| 162 | + Matcher( |
| 163 | + query=[peptide], |
| 164 | + proteome_file=str(proteome_path), |
| 165 | + max_mismatches=1, |
| 166 | + k=5, |
| 167 | + preprocessed_files_path=str(tmp_path), |
| 168 | + ).match() |
| 169 | + )) |
| 170 | + |
| 171 | + # auto-k is complete and finds the match; the old hardcoded k=5 drops it. |
| 172 | + assert source_match in auto_k |
| 173 | + assert auto_k == expected |
| 174 | + assert source_match not in hardcoded_k5 |
0 commit comments