Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions crates/osprey-scoring/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3163,12 +3163,14 @@ impl DecoyGenerator {
let seq_len = target.sequence.len();
let decoy_chars: Vec<char> = 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<usize, f64> = 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;
}
}

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions release-notes/RELEASE_NOTES_next.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading