peppy-sage 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. peppy_sage-0.1.0/PKG-INFO +10 -0
  2. peppy_sage-0.1.0/peppy_sage/Cargo.lock +7 -0
  3. peppy_sage-0.1.0/peppy_sage/Cargo.toml +22 -0
  4. peppy_sage-0.1.0/peppy_sage/peppy_sage/__init__.py +43 -0
  5. peppy_sage-0.1.0/peppy_sage/peppy_sage/core.py +235 -0
  6. peppy_sage-0.1.0/peppy_sage/peppy_sage/indexing.py +124 -0
  7. peppy_sage-0.1.0/peppy_sage/peppy_sage/scoring.py +59 -0
  8. peppy_sage-0.1.0/peppy_sage/src/index_logic.rs +143 -0
  9. peppy_sage-0.1.0/peppy_sage/src/lib.rs +988 -0
  10. peppy_sage-0.1.0/peppy_sage/src/scoring_logic.rs +56 -0
  11. peppy_sage-0.1.0/peppy_sage/tests/python_test_search.py +111 -0
  12. peppy_sage-0.1.0/peppy_sage/tests/test_search.py +138 -0
  13. peppy_sage-0.1.0/pyproject.toml +22 -0
  14. peppy_sage-0.1.0/sage_core/Cargo.toml +23 -0
  15. peppy_sage-0.1.0/sage_core/src/database.rs +684 -0
  16. peppy_sage-0.1.0/sage_core/src/enzyme.rs +819 -0
  17. peppy_sage-0.1.0/sage_core/src/fasta.rs +90 -0
  18. peppy_sage-0.1.0/sage_core/src/fdr.rs +242 -0
  19. peppy_sage-0.1.0/sage_core/src/heap.rs +101 -0
  20. peppy_sage-0.1.0/sage_core/src/ion_series.rs +329 -0
  21. peppy_sage-0.1.0/sage_core/src/isotopes.rs +66 -0
  22. peppy_sage-0.1.0/sage_core/src/lfq.rs +699 -0
  23. peppy_sage-0.1.0/sage_core/src/lib.rs +15 -0
  24. peppy_sage-0.1.0/sage_core/src/mass.rs +158 -0
  25. peppy_sage-0.1.0/sage_core/src/ml/gauss.rs +165 -0
  26. peppy_sage-0.1.0/sage_core/src/ml/kde.rs +169 -0
  27. peppy_sage-0.1.0/sage_core/src/ml/linear_discriminant.rs +284 -0
  28. peppy_sage-0.1.0/sage_core/src/ml/matrix.rs +460 -0
  29. peppy_sage-0.1.0/sage_core/src/ml/mobility_model.rs +295 -0
  30. peppy_sage-0.1.0/sage_core/src/ml/mod.rs +31 -0
  31. peppy_sage-0.1.0/sage_core/src/ml/qvalue.rs +36 -0
  32. peppy_sage-0.1.0/sage_core/src/ml/retention_alignment.rs +173 -0
  33. peppy_sage-0.1.0/sage_core/src/ml/retention_model.rs +118 -0
  34. peppy_sage-0.1.0/sage_core/src/modification.rs +180 -0
  35. peppy_sage-0.1.0/sage_core/src/peptide.rs +721 -0
  36. peppy_sage-0.1.0/sage_core/src/scoring.rs +804 -0
  37. peppy_sage-0.1.0/sage_core/src/spectrum.rs +591 -0
  38. peppy_sage-0.1.0/sage_core/src/tmt.rs +344 -0
  39. peppy_sage-0.1.0/sage_core/tests/integration.rs +70 -0
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: peppy_sage
3
+ Version: 0.1.0
4
+ Classifier: Programming Language :: Python :: 3
5
+ Classifier: Programming Language :: Rust
6
+ Classifier: Operating System :: OS Independent
7
+ Summary: Hybrid Rust/Python peptide scoring library
8
+ Author: Daniel Geiszler
9
+ License: MIT
10
+ Requires-Python: >=3.8
@@ -0,0 +1,7 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "peppy_sage"
7
+ version = "0.1.0"
@@ -0,0 +1,22 @@
1
+ [package]
2
+ name = "peppy_sage"
3
+ version = "0.1.0"
4
+ edition = "2024"
5
+
6
+ [lib]
7
+ name = "peppy_sage"
8
+ crate-type = ["cdylib"]
9
+
10
+ [dependencies]
11
+ sage_core = { path = "../sage_core", package = "sage-core" }
12
+ rayon = "1.5"
13
+ pyo3 = { version = "0.22", features = ["extension-module", "abi3-py311"] }
14
+ dashmap = "5.5.3"
15
+ fnv = "1.0.7"
16
+
17
+ [profile.release]
18
+ opt-level = 3
19
+ lto = "fat"
20
+ codegen-units = 1
21
+ debug = true # ← KEEP THIS
22
+ strip = "none" # ← KEEP THIS
@@ -0,0 +1,43 @@
1
+ # peppy_sage/__init__.py
2
+
3
+ # Import the compiled PyO3 extension module
4
+ # (the same name as your PyO3 crate, built by maturin)
5
+ from .peppy_sage import (
6
+ PyPeptide,
7
+ PyIndexedDatabase,
8
+ PyProcessedSpectrum,
9
+ PyScorer,
10
+ PyTolerance,
11
+ PyPrecursor,
12
+ PyKind,
13
+ )
14
+
15
+ # Import the high-level Python wrappers
16
+ from .core import Peptide, Spectrum, Precursor
17
+ from .indexing import IndexedDatabase
18
+ from .scoring import Scorer
19
+
20
+ __all__ = [
21
+ # Low-level bindings
22
+ "PyPeptide",
23
+ "PyIndexedDatabase",
24
+ "PyProcessedSpectrum",
25
+ "PyScorer",
26
+ "PyTolerance",
27
+ "PyPrecursor",
28
+ "PyKind",
29
+ # High-level Python API
30
+ "Peptide",
31
+ "IndexedDatabase",
32
+ "Spectrum",
33
+ "Precursor",
34
+ "Scorer",
35
+ ]
36
+
37
+ from importlib.metadata import version, PackageNotFoundError
38
+
39
+ try:
40
+ __version__ = version("peppy_sage")
41
+ except PackageNotFoundError:
42
+ # Package is not installed (e.g. running from source tree)
43
+ __version__ = "0.0.0"
@@ -0,0 +1,235 @@
1
+ from typing import List, Optional, Tuple, Union
2
+ import numpy as np
3
+ from pyteomics import mass
4
+ import peppy_sage as _rust # your compiled PyO3 module
5
+
6
+
7
+ PROTON_MASS = 1.0072764
8
+
9
+
10
+ class Precursor:
11
+ """
12
+ Lightweight Python wrapper for PyPrecursor (MS1 parent ion).
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ mz: float,
18
+ charge: int,
19
+ isolation_window: Union[_rust.PyTolerance, Tuple[float, float]] = (0.5, 0.5),
20
+ ):
21
+ """
22
+ Args:
23
+ mz: The m/z of the precursor ion.
24
+ charge: The precursor charge state.
25
+ isolation_window: Either a PyTolerance object or a (low, high) Da tuple.
26
+ """
27
+ if isinstance(isolation_window, tuple):
28
+ isolation_window = _rust.PyTolerance.Da(*isolation_window)
29
+
30
+ self._inner = _rust.PyPrecursor(
31
+ mz=mz,
32
+ charge=charge,
33
+ isolation_window=isolation_window,
34
+ )
35
+
36
+ @property
37
+ def mz(self) -> float:
38
+ return self._inner.mz
39
+
40
+ @property
41
+ def charge(self) -> int:
42
+ return self._inner.charge
43
+
44
+ @property
45
+ def isolation_window(self) -> _rust.PyTolerance:
46
+ return self._inner.isolation_window
47
+
48
+ def __repr__(self) -> str:
49
+ return f"<Precursor mz={self.mz:.4f}, z={self.charge}>"
50
+
51
+
52
+ class Spectrum:
53
+ """
54
+ High-level Python wrapper around Rust's PyProcessedSpectrum.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ id: str,
60
+ file_id: int,
61
+ scan_start_time: float,
62
+ mz_array: Union[List[float], np.ndarray],
63
+ intensity_array: Union[List[float], np.ndarray],
64
+ precursors: List[Precursor],
65
+ total_ion_current: Optional[float] = None,
66
+ ):
67
+ """
68
+ Create a spectrum from arrays of m/z and intensities.
69
+
70
+ Args:
71
+ id: Spectrum identifier (e.g. 'Scan_1234')
72
+ file_id: Numeric file index (if multiple files are loaded)
73
+ scan_start_time: Retention time (in minutes)
74
+ mz_array: List or numpy array of fragment m/z values
75
+ intensity_array: List or numpy array of corresponding intensities
76
+ precursors: List of Precursor objects
77
+ total_ion_current: Optional total ion current; computed automatically if None
78
+ """
79
+
80
+ mz_array = np.asarray(mz_array, dtype=float)
81
+ intensity_array = np.asarray(intensity_array, dtype=float)
82
+
83
+ if mz_array.shape != intensity_array.shape:
84
+ raise ValueError("mz_array and intensity_array must be the same length.")
85
+
86
+ if total_ion_current is None:
87
+ total_ion_current = float(np.sum(intensity_array))
88
+
89
+ mz_array = np.asarray(mz_array, dtype=np.float32, order="C")
90
+ intensity_array = np.asarray(intensity_array, dtype=np.float32, order="C")
91
+
92
+ self._inner = _rust.PyProcessedSpectrum(
93
+ id,
94
+ file_id,
95
+ scan_start_time,
96
+ mz_array,
97
+ intensity_array,
98
+ [p._inner for p in precursors],
99
+ total_ion_current,
100
+ )
101
+
102
+ # -------------------------------------------------------------------------
103
+ # Properties
104
+ # -------------------------------------------------------------------------
105
+ @property
106
+ def id(self) -> str:
107
+ return self._inner.id
108
+
109
+ @property
110
+ def scan_start_time(self) -> float:
111
+ return self._inner.scan_start_time
112
+
113
+ @property
114
+ def precursors(self) -> List[Precursor]:
115
+ """Return a list of precursor ions."""
116
+ return [Precursor(p.mz, p.charge, p.isolation_window) for p in self._inner.precursors]
117
+
118
+ @property
119
+ def peaks(self) -> List[Tuple[float, float]]:
120
+ """Return list of (m/z, intensity) tuples."""
121
+ return [(m, i) for (m, i) in self._inner.peaks]
122
+
123
+ @property
124
+ def total_ion_current(self) -> float:
125
+ return self._inner.total_ion_current
126
+
127
+ # -------------------------------------------------------------------------
128
+ # Utilities
129
+ # -------------------------------------------------------------------------
130
+ def sort_peaks(self, inplace: bool = True) -> "Spectrum":
131
+ """Sort peaks by m/z value."""
132
+ peaks = sorted(self.peaks, key=lambda x: x[0])
133
+ if inplace:
134
+ mz, ints = zip(*peaks)
135
+ self._inner = _rust.PyProcessedSpectrum(
136
+ self.id,
137
+ self._inner.file_id,
138
+ self.scan_start_time,
139
+ list(mz),
140
+ list(ints),
141
+ [p._inner for p in self.precursors],
142
+ self.total_ion_current,
143
+ )
144
+ return self
145
+ else:
146
+ mz, ints = zip(*peaks)
147
+ return Spectrum(
148
+ id=self.id,
149
+ file_id=self._inner.file_id,
150
+ scan_start_time=self.scan_start_time,
151
+ mz_array=mz,
152
+ intensity_array=ints,
153
+ precursors=self.precursors,
154
+ total_ion_current=self.total_ion_current,
155
+ )
156
+
157
+ def __repr__(self):
158
+ n_peaks = len(self._inner.peaks)
159
+ return f"<Spectrum id={self.id!r}, peaks={n_peaks}, precursors={len(self.precursors)}>"
160
+
161
+
162
+ class Peptide:
163
+ """
164
+ High-level Python wrapper for a Rust PyPeptide.
165
+ Handles sequence, modifications, and mass calculation.
166
+ """
167
+
168
+ def __init__(self, sequence: str, mods: List[float] | None = None): #TODO handle n-term and c-term
169
+ """
170
+ Parameters
171
+ ----------
172
+ sequence : str
173
+ Peptide amino acid sequence (e.g., "PEPTIDEK")
174
+ mods : List[float], optional
175
+ Array of modification masses (length of sequence + 2).
176
+ Default is zeros (no mods).
177
+ """
178
+ self.sequence = sequence
179
+
180
+ if mods is None:
181
+ mods = [0.0] * (len(sequence) + 2)
182
+ elif len(mods) != (len(sequence) + 2):
183
+ raise ValueError(
184
+ f"Modification array must have length sequence ({len(sequence)}) + 2."
185
+ )
186
+
187
+ self.mods = mods
188
+
189
+ # --- Calculate precursor mass using pyteomics ---
190
+ self.monoisotopic_mass = mass.calculate_mass(sequence=sequence)
191
+ self.monoisotopic_mass += sum(mods)
192
+
193
+ # --- Create the Rust-side PyPeptide object ---
194
+ self._inner = _rust.PyPeptide(sequence, self.monoisotopic_mass, mods, mods[0], mods[-1])
195
+
196
+ @classmethod
197
+ def from_rust(cls, rust_peptide: _rust.PyPeptide) -> "Peptide":
198
+ """Construct a Python Peptide from a Rust PyPeptide object."""
199
+ sequence = rust_peptide.sequence
200
+ # Rust mods are floats
201
+ mods = rust_peptide.modifications.copy()
202
+ return cls(sequence, mods)
203
+
204
+ def calculate_theoretical_mz(self, z=1):
205
+ return (self.monoisotopic_mass + (PROTON_MASS * z)) / z
206
+
207
+ @staticmethod
208
+ def calculate_theoretical_mz(seq, mods, z) -> float:
209
+ """
210
+ Compute the theoretical m/z for a peptide.
211
+
212
+ Parameters
213
+ ----------
214
+ monoisotopic_mass : float
215
+ Mass of the peptide (including modifications if any)
216
+ charge : int
217
+ Charge state
218
+
219
+ Returns
220
+ -------
221
+ float
222
+ Theoretical m/z
223
+ """
224
+ monoisotopic_mass = mass.calculate_mass(sequence=seq)
225
+ monoisotopic_mass += sum(mods)
226
+
227
+ return (monoisotopic_mass + PROTON_MASS * z) / z
228
+
229
+ def __repr__(self):
230
+ mods_str = ", ".join(f"{m:.2f}" for m in self.mods)
231
+ return (
232
+ f"<Peptide seq='{self.sequence}' "
233
+ f"mass={self.monoisotopic_mass:.4f} "
234
+ f"mods=[{mods_str}]>"
235
+ )
@@ -0,0 +1,124 @@
1
+ # indexed_database.py
2
+
3
+ from . import peppy_sage as _rust # your compiled PyO3 module
4
+ from typing import List, Optional, Tuple, Union
5
+ from .core import Peptide
6
+
7
+ ION_KIND_MAP = {
8
+ #"a": _rust.PyKind.A, #TODO other ion types
9
+ "b": _rust.PyKind.B,
10
+ #"c": _rust.PyKind.C,
11
+ #"x": _rust.PyKind.X,
12
+ "y": _rust.PyKind.Y,
13
+ #"z": _rust.PyKind.Z,
14
+ }
15
+
16
+ def _to_rust_peptide(x: Union[Peptide, _rust.PyPeptide, str]) -> _rust.PyPeptide:
17
+ if isinstance(x, _rust.PyPeptide):
18
+ return x
19
+ if isinstance(x, Peptide):
20
+ # assuming your wrapper keeps the raw object on ._inner (or similar)
21
+ return x._inner
22
+ if isinstance(x, str):
23
+ return _rust.PyPeptide(x)
24
+ raise TypeError(
25
+ f"Expected Peptide | PyPeptide | str, got {type(x).__name__}"
26
+ )
27
+
28
+ class IndexedDatabase:
29
+ """
30
+ Pythonic wrapper around the Rust-backed PyIndexedDatabase.
31
+
32
+ Example:
33
+ >>> db = IndexedDatabase.from_peptides(
34
+ ... peptides=my_peptides,
35
+ ... ion_kinds=["b", "y"],
36
+ ... bucket_size=50,
37
+ ... generate_decoys=True,
38
+ ... decoy_tag="DECOY_"
39
+ ... )
40
+ >>> print(len(db.peptides))
41
+ >>> print(db.fragment_count)
42
+ """
43
+
44
+ def __init__(self, _inner: _rust.PyIndexedDatabase):
45
+ self._inner = _inner
46
+
47
+ # -------------------------------------------------------------------------
48
+ # Constructors
49
+ # -------------------------------------------------------------------------
50
+ @classmethod
51
+ def from_peptides(
52
+ cls,
53
+ peptides: List[Union[Peptide, "_rust.PyPeptide"]],
54
+ ion_kinds: List[str],
55
+ bucket_size: int = 8192,
56
+ min_ion_index: int = 1,
57
+ generate_decoys: bool = False,
58
+ decoy_tag: str = "rev_",
59
+ peptide_min_mass: float = 500,
60
+ peptide_max_mass: float = 5000.0,
61
+ ) -> "IndexedDatabase":
62
+ """
63
+ Build an indexed database from a list of peptides and configuration.
64
+
65
+ Args:
66
+ peptides: List of PyPeptide objects (from Rust layer).
67
+ ion_kinds: List of ion types to include, e.g. ["b", "y"].
68
+ bucket_size: Number of fragments per mass bucket.
69
+ min_ion_index: Minimum ion index to include.
70
+ generate_decoys: Whether to generate decoy peptides.
71
+ decoy_tag: Prefix used to label decoy peptides.
72
+ peptide_min_mass: Minimum peptide monoisotopic mass to include.
73
+ peptide_max_mass: Maximum peptide monoisotopic mass to include.
74
+ """
75
+
76
+ try:
77
+ ion_enum_list = [ION_KIND_MAP[k.lower()]() for k in ion_kinds]
78
+ except KeyError as e:
79
+ raise ValueError(f"Invalid ion kind: {e.args[0]}. Must be one of {list(ION_KIND_MAP)}")
80
+
81
+ rust_peptides = [_to_rust_peptide(p) for p in peptides]
82
+
83
+ inner = _rust.PyIndexedDatabase.from_peptides_and_config(
84
+ peptides=rust_peptides,
85
+ bucket_size=bucket_size,
86
+ ion_kinds=ion_enum_list,
87
+ min_ion_index=min_ion_index,
88
+ generate_decoys=generate_decoys,
89
+ decoy_tag=decoy_tag,
90
+ peptide_min_mass=peptide_min_mass,
91
+ peptide_max_mass=peptide_max_mass,
92
+ )
93
+
94
+ return cls(inner)
95
+
96
+ # -------------------------------------------------------------------------
97
+ # Properties
98
+ # -------------------------------------------------------------------------
99
+ @property
100
+ def peptides(self) -> List["_rust.PyPeptide"]:
101
+ """Return the list of peptides stored in this indexed database."""
102
+ return self._inner.peptides
103
+
104
+ @property
105
+ def fragment_count(self) -> int:
106
+ """Return the total number of fragments indexed."""
107
+ return self._inner.fragment_count()
108
+
109
+ # -------------------------------------------------------------------------
110
+ # Debug / Inspection
111
+ # -------------------------------------------------------------------------
112
+ def debug_fragment_summary(self) -> List[Tuple[float, int]]:
113
+ """
114
+ Return a summary of (fragment_mz, peptide_index) tuples for debugging.
115
+ """
116
+ return self._inner.debug_fragment_summary()
117
+
118
+ def summary(self, limit: Optional[int] = 10) -> None:
119
+ """Print a short summary of fragments for inspection."""
120
+ summary = self.debug_fragment_summary()
121
+ print(f"Indexed fragments: {self.fragment_count}")
122
+ print("Example fragments:")
123
+ for mz, pep_idx in summary[:limit]:
124
+ print(f" - m/z={mz:.4f}, peptide_index={pep_idx}")
@@ -0,0 +1,59 @@
1
+ from peppy_sage import PyScorer, PyTolerance, Peptide
2
+
3
+ class Scorer:
4
+ """
5
+ High-level scoring interface for peppy_sage.
6
+ Wraps the underlying Rust-based PyScorer for convenience.
7
+ """
8
+
9
+ def __init__(
10
+ self,
11
+ precursor_tol_da: tuple[float, float] = (-1.0, 1.0),
12
+ fragment_tol_ppm: tuple[float, float] = (-5.0, 5.0),
13
+ wide_window: bool = False,
14
+ chimera: bool = True,
15
+ report_psms: int = 10,
16
+ min_isotope_err: int = -1,
17
+ max_isotope_err: int = 3,
18
+ min_precursor_charge: int = 1,
19
+ max_precursor_charge: int = 4,
20
+ min_matched_peaks: int = 0,
21
+ annotate_matches: bool = True,
22
+ max_fragment_charge: int = 1,
23
+ ):
24
+ """Initialize the high-level scoring object."""
25
+ precursor_tol = PyTolerance.Da(*precursor_tol_da)
26
+ fragment_tol = PyTolerance.Ppm(*fragment_tol_ppm)
27
+
28
+ self._scorer = PyScorer(
29
+ precursor_tol,
30
+ fragment_tol,
31
+ wide_window,
32
+ chimera,
33
+ report_psms,
34
+ min_isotope_err,
35
+ max_isotope_err,
36
+ min_precursor_charge,
37
+ max_precursor_charge,
38
+ min_matched_peaks,
39
+ annotate_matches,
40
+ max_fragment_charge,
41
+ )
42
+
43
+ def score(self, db, spectrum):
44
+ """
45
+ Score a single spectrum (or a list of spectra) against the given database.
46
+ Returns a list of features (PSMs).
47
+ """
48
+ features = self._scorer.score_spectra(db._inner, spectrum._inner)
49
+
50
+ return features
51
+
52
+ def score_many(self, db, spectra):
53
+ """
54
+ Score many spectra and convert Rust peptides to Python Peptide.
55
+ """
56
+ rust_spectra = [s._inner for s in spectra]
57
+ all_hits = self._scorer.score_many_spectra(db._inner, rust_spectra)
58
+
59
+ return all_hits
@@ -0,0 +1,143 @@
1
+ use sage_core::database::{IndexedDatabase, PeptideIx, Theoretical};
2
+ use sage_core::peptide::Peptide;
3
+ use sage_core::ion_series::{IonSeries, Kind};
4
+ use sage_core::modification::ModificationSpecificity;
5
+ use std::sync::Arc;
6
+ use dashmap::DashSet;
7
+ use fnv::FnvBuildHasher;
8
+ use rayon::prelude::*;
9
+ // Add imports for types used in the original logic (e.g., DashSet, FnvBuildHasher, etc. if you copied that part)
10
+
11
+ // 1. Define the minimal configuration struct
12
+ pub struct IndexingConfig {
13
+ pub bucket_size: usize,
14
+ pub ion_kinds: Vec<Kind>,
15
+ pub min_ion_index: usize,
16
+ pub generate_decoys: bool,
17
+ pub decoy_tag: String,
18
+ //pub potential_mods: Vec<(ModificationSpecificity, f32)>,
19
+ pub peptide_min_mass: f32,
20
+ pub peptide_max_mass: f32,
21
+ }
22
+
23
+ // 2. Define the core indexing function (pasting the adapted logic)
24
+ pub fn build_indexed_database(config: IndexingConfig, targets: Vec<Peptide>) -> IndexedDatabase {
25
+ // NOTE: This is where you paste the full, adapted logic from
26
+ // the original Parameters::build_from_peptides method.
27
+
28
+ let peptides = if config.generate_decoys {
29
+ // gather target sequences for collision checks
30
+ let target_seqs: DashSet<Arc<[u8]>, FnvBuildHasher> = DashSet::default();
31
+ targets
32
+ .par_iter()
33
+ .filter(|p| !p.decoy)
34
+ .for_each(|p| {
35
+ target_seqs.insert(p.sequence.clone());
36
+ });
37
+
38
+ // build output = targets (+ valid reversed decoys)
39
+ let out: Vec<Peptide> = targets
40
+ .into_par_iter()
41
+ .flat_map_iter(|p| {
42
+ // always keep the target
43
+ let mut v = Vec::with_capacity(2);
44
+ v.push(p.clone());
45
+
46
+ // reversed decoy; skip if reversed equals a real target sequence
47
+ let rev = p.reverse();
48
+ if !target_seqs.contains(&rev.sequence) {
49
+ v.push(rev);
50
+ }
51
+ v.into_iter()
52
+ })
53
+ .collect(); // CHANGE: collect the parallel stream directly
54
+ out
55
+ } else {
56
+ targets
57
+ };
58
+
59
+ // --- Adapted Fragmentation Logic ---
60
+ let mut fragments = peptides
61
+ .par_iter()
62
+ .enumerate()
63
+ .flat_map(|(idx, peptide)| {
64
+ // ... (mass filter logic remains the same) ...
65
+
66
+ // Generate ions (using config fields)
67
+ config.ion_kinds
68
+ .iter()
69
+ .flat_map(|kind| IonSeries::new(peptide, *kind).enumerate())
70
+ .filter(|(ion_idx, ion)| {
71
+ // ... (filtering logic remains the same) ...
72
+ match ion.kind {
73
+ Kind::A | Kind::B | Kind::C => (ion_idx + 1) > config.min_ion_index,
74
+ Kind::X | Kind::Y | Kind::Z => {
75
+ peptide.sequence.len().saturating_sub(1) - ion_idx > config.min_ion_index
76
+ }
77
+ }
78
+ })
79
+ .map(move |(_, ion)| Theoretical {
80
+ peptide_index: PeptideIx(idx as u32),
81
+ fragment_mz: ion.monoisotopic_mass,
82
+ })
83
+ // FIX: Collect into a Vec, and immediately convert that Vec
84
+ // into a parallel iterator (`IntoParallelIterator`) for Rayon.
85
+ .collect::<Vec<Theoretical>>()
86
+ .into_par_iter() // <--- THIS IS THE KEY CHANGE
87
+ })
88
+ // Final collect is now correct because flat_map returns a ParallelIterator
89
+ .collect::<Vec<_>>();
90
+
91
+ // --- Sorting and Bucketing Logic ---
92
+ fragments.par_sort_unstable_by(|a, b| a.fragment_mz.total_cmp(&b.fragment_mz));
93
+
94
+ let min_value = fragments
95
+ .par_chunks_mut(config.bucket_size)
96
+ .map(|chunk| {
97
+ let min = chunk[0].fragment_mz;
98
+ chunk.par_sort_unstable_by(|a, b| a.peptide_index.cmp(&b.peptide_index));
99
+ min
100
+ })
101
+ .collect::<Vec<_>>();
102
+
103
+ // 3. Final Struct Return
104
+ IndexedDatabase {
105
+ peptides: peptides,
106
+ fragments,
107
+ min_value,
108
+ bucket_size: config.bucket_size,
109
+ ion_kinds: config.ion_kinds,
110
+ generate_decoys: config.generate_decoys,
111
+ potential_mods: Vec::new(), // inserting dummy values here since they won't be used
112
+ decoy_tag: config.decoy_tag,
113
+ }
114
+ }
115
+
116
+ /// Local sort+dedup that mirrors `Parameters::reorder_peptides`.
117
+ fn sort_and_dedup(peptides: &mut Vec<Peptide>) {
118
+ peptides.par_sort_unstable_by(|a, b| {
119
+ a.monoisotopic
120
+ .total_cmp(&b.monoisotopic)
121
+ .then_with(|| a.initial_sort(b))
122
+ });
123
+
124
+ peptides.dedup_by(|remove, keep| {
125
+ if remove.monoisotopic == keep.monoisotopic
126
+ && remove.sequence == keep.sequence
127
+ && remove.modifications == keep.modifications
128
+ && remove.nterm == keep.nterm
129
+ && remove.cterm == keep.cterm
130
+ {
131
+ keep.proteins.extend(remove.proteins.iter().cloned());
132
+ // if any source was target, the merged stays target
133
+ keep.decoy &= remove.decoy;
134
+ true
135
+ } else {
136
+ false
137
+ }
138
+ });
139
+
140
+ peptides
141
+ .par_iter_mut()
142
+ .for_each(|p| p.proteins.sort_unstable());
143
+ }