From 867029e281ea4c09b02b22e4878192cba5eccdce Mon Sep 17 00:00:00 2001 From: maccoss Date: Mon, 28 Sep 2026 11:03:49 -0700 Subject: [PATCH] Keep both modifications that share a residue on generated decoy fragments The library loaders put an N-terminal modification on residue 0, where a modification of the first residue also sits ((UniMod:1)M(UniMod:35)), and the target's fragments carry both. recalculate_fragments built its per-position mass map with insert, so the second modification replaced the first and every decoy fragment spanning that residue was one modification light - 42 Da for an N-terminal acetyl. The masses at one position now add. Only libraries holding such an entry change; none of the regression libraries does (Stellar, Astral and stellar-libdecoy scanned). Mirrors ProteoWizard/pwiz (DecoyGenerator.RecalculateFragments, which now sums through PeptideFragmentMass.ModMassesByPosition); test_decoy_fragment_carries_stacked_modifications is the Rust twin of DecoyConstructionTest.DecoyFragmentCarriesStackedModifications, and fails before the fix on b8. Co-Authored-By: Claude --- crates/osprey-scoring/src/lib.rs | 83 ++++++++++++++++++++++++++++- release-notes/RELEASE_NOTES_next.md | 1 + 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/crates/osprey-scoring/src/lib.rs b/crates/osprey-scoring/src/lib.rs index 7ab8190..b0124ea 100644 --- a/crates/osprey-scoring/src/lib.rs +++ b/crates/osprey-scoring/src/lib.rs @@ -3163,12 +3163,14 @@ impl DecoyGenerator { let seq_len = target.sequence.len(); let decoy_chars: Vec = self.get_decoy_sequence_chars(target, position_mapping); - // Build modification mass map for decoy (by position) + // Modification mass by decoy position. Modifications that land on one residue add - + // an N-terminal acetyl and an oxidized first methionine both sit at position 0 - so + // every decoy ion spanning it carries both, as the target's ions do. let mut mod_masses: HashMap = HashMap::new(); for m in &target.modifications { // Find new position for this modification if let Some(new_pos) = position_mapping.iter().position(|&old| old == m.position) { - mod_masses.insert(new_pos, m.mass_delta); + *mod_masses.entry(new_pos).or_insert(0.0) += m.mass_delta; } } @@ -5565,6 +5567,83 @@ mod tests { assert!((decoy.fragments[1].mz - ladder[2 * (4 - 1) + 1]).abs() < 1e-9); } + /// The library loaders put an N-terminal modification on residue 0, where a modification + /// of the first residue also sits (`(UniMod:1)M(UniMod:35)`). Both travel with that residue + /// into the decoy, and every decoy fragment spanning it must carry both mass deltas, as the + /// target's fragments do. Keeping only the last one put those decoy ions 42 Da off. + #[test] + fn test_decoy_fragment_carries_stacked_modifications() { + const ACETYL: f64 = 42.010565; + const OXIDATION: f64 = 15.994915; + let generator = DecoyGenerator::with_enzyme(DecoyMethod::Reverse, Enzyme::Trypsin); + let mut target = LibraryEntry::new( + 1, + "MPEPTIDEK".into(), + "[+42.010565]M[+15.994915]PEPTIDEK".into(), + 2, + 550.0, + 10.0, + ); + for (unimod_id, mass_delta) in [(1, ACETYL), (35, OXIDATION)] { + target.modifications.push(Modification { + position: 0, + unimod_id: Some(unimod_id), + mass_delta, + name: None, + }); + } + let fragment = |ion_type, ordinal| LibraryFragment { + mz: 300.0, + relative_intensity: 1.0, + annotation: FragmentAnnotation { + ion_type, + ordinal, + charge: 1, + neutral_loss: None, + }, + }; + target.fragments = vec![ + fragment(IonType::B, 3), + fragment(IonType::B, 8), + fragment(IonType::Y, 2), + fragment(IonType::Y, 4), + ]; + + let decoy = generator.generate(&target).unwrap(); + // The reversal keeps the C-terminal K and moves the methionine, with both of its + // modifications, to position 7. + assert_eq!(decoy.sequence, "EDITPEPMK"); + assert_eq!( + decoy + .modifications + .iter() + .filter(|m| m.position == 7) + .count(), + 2 + ); + + let ladder = generator.theoretical_ladder("EDITPEPMK"); + let stacked = ACETYL + OXIDATION; + let b = |k: usize| ladder[2 * (k - 1)]; + let y = |k: usize| ladder[2 * (k - 1) + 1]; + assert!( + (decoy.fragments[0].mz - b(3)).abs() < 1e-9, + "b3 does not reach the methionine" + ); + assert!( + (decoy.fragments[1].mz - (b(8) + stacked)).abs() < 1e-9, + "b8" + ); + assert!( + (decoy.fragments[2].mz - (y(2) + stacked)).abs() < 1e-9, + "y2" + ); + assert!( + (decoy.fragments[3].mz - (y(4) + stacked)).abs() < 1e-9, + "y4" + ); + } + #[test] fn test_overlap_gate_rejects_an_isobaric_near_duplicate() { let generator = DecoyGenerator::with_enzyme(DecoyMethod::Reverse, Enzyme::Trypsin); diff --git a/release-notes/RELEASE_NOTES_next.md b/release-notes/RELEASE_NOTES_next.md index 87fafad..1a2e454 100644 --- a/release-notes/RELEASE_NOTES_next.md +++ b/release-notes/RELEASE_NOTES_next.md @@ -10,6 +10,7 @@ Working draft for the next release. Append entries here as features and fixes la - **Carafe's per-peptide `_pepNNNNN` pseudo-protein accessions are stripped at library load**: Carafe emits one synthetic accession PER PEPTIDE (`sp|O95139_pep00019|NDUB6_HUMAN`), so every peptide became its own protein — 759,805 distinct accessions collapsing to 42,592 real ones on the 3-file Stellar entrapment library. The damage was not confined to the protein report: second-pass `protein-compact` q-value estimation builds its stratum from proteins with >= 2 *detected* peptides, and a protein owning exactly one peptide can never qualify, so the stratum collapsed, nothing recompeted, and every reported q-value was the carried-over first-pass value with nothing in the output saying so. The manifest's `proteins` column (see v26.6.0) already carried clean accessions, but the only path that applies it is the library-supplied-decoy path, which also hard-fails when the library holds no decoys — so a Carafe library searched with *generated* decoys had no route back to real accessions at all. Stripping at load closes that for every mode; on a manifest run the manifest still wins where it genuinely disagrees. Applied on both the fresh-parse path (before deduplication) and the `.libcache` path. Entrapment and decoy markers are preserved — only the `_pep` + digits token is removed, so `sp|Q9ULK4_p_target_pep00052|MED23_HUMAN_p_target` becomes `sp|Q9ULK4_p_target|MED23_HUMAN_p_target`. Mirrors the C# Osprey `CarafeProteinIdNormalizer` (ProteoWizard/pwiz#4573); the two implementations are validated in cross-impl parity at 1e-9 on `StellarLibraryDecoy`. **Note**: the strip assumes the default `--pep-suffix-format` (`_pep{:05d}`) of `scripts/build_entrapment_peptide_fasta.py`; a library built with a custom format is not recognized. - **Library-decoy pairing no longer gives two decoys one entry_id**: the manifest pass bucketed library entries by the manifest's `peptide_type`, not by whether the library entry was a decoy. Carafe can merge a decoy with an identical real target into one row whose ProteinID starts `decoy_`; the prefix scan marks that row a decoy, but the manifest lists its sequence as a `target`, so its paired decoy copied an id that already carried the decoy bit. Per-file deduplication then kept one of the two, one entry_id carried two peptides, and first-pass FDR aborted hours into a large run (82 files, SEA-AD 07-27 library: 35 such rows, e.g. `AQLKDTR` z2 against its reversed decoy `TDKLQAR`). A decoy is now never placed on the target side of a manifest pair, and the load fails listing every row where the library and its manifest disagree, so the library can be regenerated. A second check after pairing refuses the library if any two decoys still share an entry_id, listing every group. The `.libcache` stores the library before pairing, and pairing reruns on every load, so no cache version change is needed. Mirrors ProteoWizard/pwiz (`DecoyPairingManifest.ApplyToLibrary`, `LibraryDecoyPairing.FindSharedDecoyIds`). +- **Generated decoys keep both modifications that share a residue**: the library loaders put an N-terminal modification on residue 0, where a modification of the first residue also sits (`(UniMod:1)M(UniMod:35)`), and the target's fragments carry both. `recalculate_fragments` built its per-position mass map with `insert`, so the second modification replaced the first and every decoy fragment spanning that residue was one modification light - 42 Da for an N-terminal acetyl. The masses at one position now add. Only libraries with such entries change; none of the regression libraries has one. Mirrors ProteoWizard/pwiz (`DecoyGenerator.RecalculateFragments`, which now sums through `PeptideFragmentMass.ModMassesByPosition`); the regression test is the Rust twin of `DecoyConstructionTest.DecoyFragmentCarriesStackedModifications`. ## Performance