Skill Tracer
Skills › Scientific Agent Skills › matchms › filtering.md

filtering.md

312 lines · skills/matchms/references/filtering.md at 5f2bc03

Open on GitHub
1# Filtering and Spectrum Processing (matchms 0.33.1)
2
3Use this reference when building or debugging metadata-cleaning, peak-processing,
4or quality-control pipelines. The authoritative API is the matchms
5[`filtering` package](https://matchms.readthedocs.io/en/latest/api/matchms.filtering.html).
6

Filtering and Spectrum Processing (matchms 0.33.1)

Use this reference when building or debugging metadata-cleaning, peak-processing, or quality-control pipelines. The authoritative API is the matchms [filtering package](https://matchms.readthedocs.io/en/latest/api/matchms.filtering.html).

Raw lines 1 to 6
7## Filter Contract
8
9Most matchms filters:
11- accept one `Spectrum` as the first argument;
12- return a `Spectrum` or `None`;
13- default to `clone=True`, so direct calls usually return a modified copy;
14- use `None` to indicate that a `require_*` condition failed; and
15- should be assigned back when called directly.
17```python
18spectrum = normalize_intensities(spectrum)
19spectrum = require_minimum_number_of_peaks(spectrum, n_required=5)
20if spectrum is None:
21 # The spectrum failed a quality requirement.
22 ...
23```
25Do not continue passing `None` through ordinary filters. `SpectrumProcessor`
26stops the chain and discards rejected spectra for you.

Filter Contract

Most matchms filters:

  • accept one Spectrum as the first argument;
  • return a Spectrum or None;
  • default to clone=True, so direct calls usually return a modified copy;
  • use None to indicate that a require_* condition failed; and
  • should be assigned back when called directly.
spectrum = normalize_intensities(spectrum)
spectrum = require_minimum_number_of_peaks(spectrum, n_required=5)
if spectrum is None:
    # The spectrum failed a quality requirement.
    ...

Do not continue passing None through ordinary filters. SpectrumProcessor stops the chain and discards rejected spectra for you.

Raw lines 7 to 27
28## Prefer `SpectrumProcessor`
30`SpectrumProcessor` accepts:
32- a built-in filter name such as `"normalize_intensities"`;
33- a callable such as `normalize_intensities`; or
34- `(filter, parameter_dict)`, where `filter` can be a name or callable.
36Built-in filters are automatically sorted into matchms's required filter order.
37Custom filters are appended unless an explicit position is supplied with
38`parse_and_add_filter()`.
40```python
41from matchms import SpectrumProcessor
42from matchms.filtering import (
43 default_filters,
44 normalize_intensities,
45 remove_peaks_relative_to_precursor_mz,
46 require_minimum_number_of_peaks,
47 require_precursor_mz,
48 select_by_mz,
49 select_by_relative_intensity,
52# Run the aggregate metadata harmonizer before SpectrumProcessor. If included as
53# one callable inside the processor, it is treated as custom and placed last.
54spectra = [default_filters(spectrum) for spectrum in spectra]
56processor = SpectrumProcessor(
57 [
58 (require_precursor_mz, {"minimum_accepted_mz": 50.0, "maximum_mz": 1500.0}),
59 normalize_intensities,
60 (remove_peaks_relative_to_precursor_mz, {"offset_to_precursor": -1.6}),
61 (select_by_mz, {"mz_from": 20.0, "mz_to": 1500.0}),
62 (select_by_relative_intensity, {"intensity_from": 0.01}),
63 (require_minimum_number_of_peaks, {"n_required": 5}),
64 ]
67cleaned, report = processor.process_spectra(
68 spectra,
69 progress_bar=False,
70 create_report=False,
72print(processor.processing_steps)
73```
75Important current behavior:
77- `SpectrumProcessor` is **not callable**.
78- Use `processor.process_spectrum(spectrum.clone())` to preserve one input.
79 Without a processing report, `process_spectrum` passes `clone=False` to
80 filters and can mutate its argument; it does not clone on entry.
81- Use `processor.process_spectra(spectra)` for a list.
82- `process_spectra()` returns `(processed_spectra, processing_report)`.
83- With `create_report=False`, the processor clones each input once and disables
84 per-filter cloning where supported.
85- With `create_report=True`, filters are cloned step by step to measure changes.
86 The aggregate `default_filters` function is not in the processor's built-in
87 order registry and does not expose a `clone` parameter. If passed directly,
88 it is placed after registered filters and detailed reporting logs a warning.
89 Run it before the processor or expand it into the nine filters below.
90- `process_spectrums()` is a deprecated spelling.

Prefer SpectrumProcessor

SpectrumProcessor accepts:

  • a built-in filter name such as "normalize_intensities";
  • a callable such as normalize_intensities; or
  • (filter, parameter_dict), where filter can be a name or callable.

Built-in filters are automatically sorted into matchms's required filter order. Custom filters are appended unless an explicit position is supplied with parse_and_add_filter().

from matchms import SpectrumProcessor
from matchms.filtering import (
    default_filters,
    normalize_intensities,
    remove_peaks_relative_to_precursor_mz,
    require_minimum_number_of_peaks,
    require_precursor_mz,
    select_by_mz,
    select_by_relative_intensity,
)

# Run the aggregate metadata harmonizer before SpectrumProcessor. If included as
# one callable inside the processor, it is treated as custom and placed last.
spectra = [default_filters(spectrum) for spectrum in spectra]

processor = SpectrumProcessor(
    [
        (require_precursor_mz, {"minimum_accepted_mz": 50.0, "maximum_mz": 1500.0}),
        normalize_intensities,
        (remove_peaks_relative_to_precursor_mz, {"offset_to_precursor": -1.6}),
        (select_by_mz, {"mz_from": 20.0, "mz_to": 1500.0}),
        (select_by_relative_intensity, {"intensity_from": 0.01}),
        (require_minimum_number_of_peaks, {"n_required": 5}),
    ]
)

cleaned, report = processor.process_spectra(
    spectra,
    progress_bar=False,
    create_report=False,
)
print(processor.processing_steps)

Important current behavior:

  • SpectrumProcessor is not callable.
  • Use processor.process_spectrum(spectrum.clone()) to preserve one input. Without a processing report, process_spectrum passes clone=False to filters and can mutate its argument; it does not clone on entry.
  • Use processor.process_spectra(spectra) for a list.
  • process_spectra() returns (processed_spectra, processing_report).
  • With create_report=False, the processor clones each input once and disables per-filter cloning where supported.
  • With create_report=True, filters are cloned step by step to measure changes. The aggregate default_filters function is not in the processor's built-in order registry and does not expose a clone parameter. If passed directly, it is placed after registered filters and detailed reporting logs a warning. Run it before the processor or expand it into the nine filters below.
  • process_spectrums() is a deprecated spelling.
Raw lines 28 to 91
92## What `default_filters()` Does
94In 0.33.1, `default_filters()` applies exactly:
961. `make_charge_int`
972. `add_compound_name`
983. `derive_adduct_from_name`
994. `derive_formula_from_name`
1005. `clean_compound_name`
1016. `interpret_pepmass`
1027. `add_precursor_mz`
1038. `derive_ionmode`
1049. `correct_charge`
106It does **not** normalize peaks, add retention fields, harmonize structural
107identifiers, require precursor metadata, or enforce peak-count quality. Add
108those steps explicitly.

What default_filters() Does

In 0.33.1, default_filters() applies exactly:

  1. make_charge_int
  2. add_compound_name
  3. derive_adduct_from_name
  4. derive_formula_from_name
  5. clean_compound_name
  6. interpret_pepmass
  7. add_precursor_mz
  8. derive_ionmode
  9. correct_charge

It does not normalize peaks, add retention fields, harmonize structural identifiers, require precursor metadata, or enforce peak-count quality. Add those steps explicitly.

Raw lines 92 to 109
110## Peak Processing

Peak Processing

Raw lines 110 to 111
112### Normalize and select peaks
114- `normalize_intensities(spectrum_in, clone=True, scaling=None)` — normalize to
115 maximum intensity 1 by default; pass `scaling=(low, high)` for min-max scaling.
116- `select_by_intensity(spectrum_in, intensity_from=10.0, intensity_to=200.0,
117 clone=True)` — keep an absolute intensity interval.
118- `select_by_relative_intensity(spectrum_in, intensity_from=0.0,
119 intensity_to=1.0, clone=True)` — keep a fraction-of-maximum interval.
120- `select_by_mz(spectrum_in, mz_from=0.0, mz_to=1000.0, clone=True)` — crop the
121 fragment m/z interval.
123`select_by_relative_intensity` divides by the current maximum internally; prior
124normalization is optional, and retained intensities keep their original scale.
125Cropping away a base peak before this filter can change the retained peaks.

Normalize and select peaks

  • normalize_intensities(spectrum_in, clone=True, scaling=None) — normalize to maximum intensity 1 by default; pass scaling=(low, high) for min-max scaling.
  • select_by_intensity(spectrum_in, intensity_from=10.0, intensity_to=200.0, clone=True) — keep an absolute intensity interval.
  • select_by_relative_intensity(spectrum_in, intensity_from=0.0, intensity_to=1.0, clone=True) — keep a fraction-of-maximum interval.
  • select_by_mz(spectrum_in, mz_from=0.0, mz_to=1000.0, clone=True) — crop the fragment m/z interval.

select_by_relative_intensity divides by the current maximum internally; prior normalization is optional, and retained intensities keep their original scale. Cropping away a base peak before this filter can change the retained peaks.

Raw lines 112 to 126
127### Reduce and clean peaks
129- `reduce_to_number_of_peaks(spectrum_in, n_required=0, n_max=inf,
130 ratio_desired=None, clone=True)` — keep the most intense peaks within count
131 constraints.
132- `remove_noise_below_frequent_intensities(spectrum_in,
133 min_count_of_frequent_intensities=5, noise_level_multiplier=2.0, clone=True)`
134 — estimate and remove repeated low-intensity noise.
135- `remove_peaks_around_precursor_mz(spectrum_in, mz_tolerance=17, clone=True)`
136 — remove peaks within a precursor-centered exclusion window.
137- `remove_peaks_relative_to_precursor_mz(spectrum_in,
138 offset_to_precursor=-1.6, clone=True)` — remove peaks above a cutoff relative
139 to precursor m/z.
140- `remove_peaks_outside_top_k(spectrum_in, k=6, mz_window=50, clone=True)` —
141 retain peaks within `mz_window` Da of one of the globally `k` most intense
142 peaks; this is not a per-window top-k filter.
143- `remove_profiled_spectra(spectrum_in, mz_window=0.5, clone=True)` — reject
144 spectra likely to contain profile-mode rather than centroided data.
146These defaults are starting points, not instrument-independent truth. Record
147chosen windows and thresholds.

Reduce and clean peaks

  • reduce_to_number_of_peaks(spectrum_in, n_required=0, n_max=inf, ratio_desired=None, clone=True) — keep the most intense peaks within count constraints.
  • remove_noise_below_frequent_intensities(spectrum_in, min_count_of_frequent_intensities=5, noise_level_multiplier=2.0, clone=True) — estimate and remove repeated low-intensity noise.
  • remove_peaks_around_precursor_mz(spectrum_in, mz_tolerance=17, clone=True) — remove peaks within a precursor-centered exclusion window.
  • remove_peaks_relative_to_precursor_mz(spectrum_in, offset_to_precursor=-1.6, clone=True) — remove peaks above a cutoff relative to precursor m/z.
  • remove_peaks_outside_top_k(spectrum_in, k=6, mz_window=50, clone=True) — retain peaks within mz_window Da of one of the globally k most intense peaks; this is not a per-window top-k filter.
  • remove_profiled_spectra(spectrum_in, mz_window=0.5, clone=True) — reject spectra likely to contain profile-mode rather than centroided data.

These defaults are starting points, not instrument-independent truth. Record chosen windows and thresholds.

Raw lines 127 to 148
149### Peak quality requirements
151- `require_minimum_number_of_peaks(spectrum_in, n_required=10,
152 ratio_required=None, clone=True)`
153- `require_maximum_number_of_peaks(spectrum_in,
154 maximum_number_of_fragments=1000, clone=True)`
155- `require_minimum_number_of_high_peaks(spectrum_in, no_peaks=5,
156 intensity_percent=2.0, clone=True)`
158Older examples commonly use invalid
159`require_minimum_number_of_high_peaks(n_required=..., intensity_threshold=...)`
160arguments. In 0.33.1 the names are `no_peaks` and `intensity_percent`, where the
161latter is a percentage rather than a 0-1 fraction.

Peak quality requirements

  • require_minimum_number_of_peaks(spectrum_in, n_required=10, ratio_required=None, clone=True)
  • require_maximum_number_of_peaks(spectrum_in, maximum_number_of_fragments=1000, clone=True)
  • require_minimum_number_of_high_peaks(spectrum_in, no_peaks=5, intensity_percent=2.0, clone=True)

Older examples commonly use invalid require_minimum_number_of_high_peaks(n_required=..., intensity_threshold=...) arguments. In 0.33.1 the names are no_peaks and intensity_percent, where the latter is a percentage rather than a 0-1 fraction.

Raw lines 149 to 162
163## Precursor, Adduct, Charge, and Ion Mode
165- `interpret_pepmass(spectrum_in, clone=True)`
166- `add_precursor_mz(spectrum_in, clone=True)`
167- `add_parent_mass(spectrum_in, estimate_from_adduct=True,
168 overwrite_existing_entry=False, estimate_from_charge=True, clone=True)`
169- `add_precursor_formula(spectrum_in, clone=True)`
170- `make_charge_int(spectrum_in, clone=True)`
171- `correct_charge(spectrum_in, clone=True)`
172- `clean_adduct(spectrum_in, clone=True)`
173- `derive_adduct_from_name(spectrum_in, remove_adduct_from_name=True,
174 clone=True)`
175- `derive_ionmode(spectrum_in, clone=True)`
176- `require_correct_ionmode(spectrum_in, ion_mode_to_keep)`
177- `require_matching_adduct_and_ionmode(spectrum)`
178- `require_matching_adduct_precursor_mz_parent_mass(spectrum, tolerance=0.1)`
179- `require_precursor_mz(spectrum_in, minimum_accepted_mz=10.0,
180 maximum_mz=None, clone=True)`
182`require_precursor_below_mz()` is deprecated. Use
183`require_precursor_mz(maximum_mz=...)`.
185Modified-cosine and neutral-loss scoring require a valid `precursor_mz`.
186Parent-mass matching additionally requires a valid `parent_mass`. Check that
187masses are finite and positive: `require_precursor_mz` uses inclusive bounds
188and does not itself reject NaN or infinity. The bundled CLI adds that check.

Precursor, Adduct, Charge, and Ion Mode

  • interpret_pepmass(spectrum_in, clone=True)
  • add_precursor_mz(spectrum_in, clone=True)
  • add_parent_mass(spectrum_in, estimate_from_adduct=True, overwrite_existing_entry=False, estimate_from_charge=True, clone=True)
  • add_precursor_formula(spectrum_in, clone=True)
  • make_charge_int(spectrum_in, clone=True)
  • correct_charge(spectrum_in, clone=True)
  • clean_adduct(spectrum_in, clone=True)
  • derive_adduct_from_name(spectrum_in, remove_adduct_from_name=True, clone=True)
  • derive_ionmode(spectrum_in, clone=True)
  • require_correct_ionmode(spectrum_in, ion_mode_to_keep)
  • require_matching_adduct_and_ionmode(spectrum)
  • require_matching_adduct_precursor_mz_parent_mass(spectrum, tolerance=0.1)
  • require_precursor_mz(spectrum_in, minimum_accepted_mz=10.0, maximum_mz=None, clone=True)

require_precursor_below_mz() is deprecated. Use require_precursor_mz(maximum_mz=...).

Modified-cosine and neutral-loss scoring require a valid precursor_mz. Parent-mass matching additionally requires a valid parent_mass. Check that masses are finite and positive: require_precursor_mz uses inclusive bounds and does not itself reject NaN or infinity. The bundled CLI adds that check.

Raw lines 163 to 189
190## Compound Names, Formulae, and Structures

Compound Names, Formulae, and Structures

Raw lines 190 to 191
192### Name and formula processing
194- `add_compound_name(spectrum_in, clone=True)`
195- `clean_compound_name(spectrum_in, clone=True)`
196- `derive_formula_from_name(spectrum_in, remove_formula_from_name=True,
197 clone=True)`
198- `derive_formula_from_smiles(spectrum_in, overwrite=True, clone=True)`
199- `require_compound_name(spectrum)`
200- `require_formula(spectrum)`

Name and formula processing

  • add_compound_name(spectrum_in, clone=True)
  • clean_compound_name(spectrum_in, clone=True)
  • derive_formula_from_name(spectrum_in, remove_formula_from_name=True, clone=True)
  • derive_formula_from_smiles(spectrum_in, overwrite=True, clone=True)
  • require_compound_name(spectrum)
  • require_formula(spectrum)
Raw lines 192 to 201
202### SMILES, InChI, and InChIKey
204- `derive_inchi_from_smiles(spectrum_in, clone=True)`
205- `derive_inchikey_from_inchi(spectrum_in, clone=True)`
206- `derive_smiles_from_inchi(spectrum_in, clone=True)`
207- `harmonize_undefined_inchi(...)`
208- `harmonize_undefined_inchikey(...)`
209- `harmonize_undefined_smiles(...)`
210- `repair_inchi_inchikey_smiles(spectrum_in, clone=True)`
211- `repair_not_matching_annotation(spectrum_in, clone=True)`
212- `require_valid_annotation(spectrum)`
214`derive_annotation_from_compound_name(spectrum_in,
215annotated_compound_names_file=None, mass_tolerance=0.1, clone=True)` uses
216PubChemPy name search when no valid SMILES/InChI exists. It requires a plausible
217compound name and `parent_mass`, selecting an annotation by neutral mass
218agreement. The tolerance is an absolute mass difference, not ppm. It can read
219and append its optional CSV cache and can retry network failures for a long
220time. Run deliberately, retain original annotations, and preserve the cache
221with provenance. Name/mass agreement does not resolve isomers or validate the
222query identity. No credential is required for the public PubChem service.

SMILES, InChI, and InChIKey

  • derive_inchi_from_smiles(spectrum_in, clone=True)
  • derive_inchikey_from_inchi(spectrum_in, clone=True)
  • derive_smiles_from_inchi(spectrum_in, clone=True)
  • harmonize_undefined_inchi(...)
  • harmonize_undefined_inchikey(...)
  • harmonize_undefined_smiles(...)
  • repair_inchi_inchikey_smiles(spectrum_in, clone=True)
  • repair_not_matching_annotation(spectrum_in, clone=True)
  • require_valid_annotation(spectrum)

derive_annotation_from_compound_name(spectrum_in, annotated_compound_names_file=None, mass_tolerance=0.1, clone=True) uses PubChemPy name search when no valid SMILES/InChI exists. It requires a plausible compound name and parent_mass, selecting an annotation by neutral mass agreement. The tolerance is an absolute mass difference, not ppm. It can read and append its optional CSV cache and can retry network failures for a long time. Run deliberately, retain original annotations, and preserve the cache with provenance. Name/mass agreement does not resolve isomers or validate the query identity. No credential is required for the public PubChem service.

Raw lines 202 to 223
224### Structure/mass repair
226Current repair helpers include:
228- `repair_adduct_and_parent_mass_based_on_smiles`
229- `repair_adduct_based_on_parent_mass`
230- `repair_parent_mass_from_smiles`
231- `repair_parent_mass_is_molar_mass`
232- `repair_parent_mass_match_smiles_wrapper`
233- `repair_smiles_of_salts`
234- `require_parent_mass_match_smiles`
236Several require an explicit `mass_tolerance`. Do not silently "repair" library
237annotations without preserving original fields and logging the rule used.

Structure/mass repair

Current repair helpers include:

  • repair_adduct_and_parent_mass_based_on_smiles
  • repair_adduct_based_on_parent_mass
  • repair_parent_mass_from_smiles
  • repair_parent_mass_is_molar_mass
  • repair_parent_mass_match_smiles_wrapper
  • repair_smiles_of_salts
  • require_parent_mass_match_smiles

Several require an explicit mass_tolerance. Do not silently "repair" library annotations without preserving original fields and logging the rule used.

Raw lines 224 to 238
239## Retention and MS-Level Metadata
241- `add_retention_time(spectrum_in, clone=True)`
242- `add_retention_index(spectrum_in, clone=True)`
243- `require_retention_time(spectrum_in, minimum_rt=None, maximum_rt=None,
244 clone=True)`
245- `require_retention_index(spectrum_in, clone=True)`
246- `require_correct_ms_level(spectrum, required_ms_level=2)`
248Retention time and retention index are not interchangeable. Record units and
249the chromatographic method before using either as a matching constraint.

Retention and MS-Level Metadata

  • add_retention_time(spectrum_in, clone=True)
  • add_retention_index(spectrum_in, clone=True)
  • require_retention_time(spectrum_in, minimum_rt=None, maximum_rt=None, clone=True)
  • require_retention_index(spectrum_in, clone=True)
  • require_correct_ms_level(spectrum, required_ms_level=2)

Retention time and retention index are not interchangeable. Record units and the chromatographic method before using either as a matching constraint.

Raw lines 239 to 250
251## Fingerprints
253`add_fingerprint()` still works in 0.33.1, but it is marked for removal in
254matchms 1.0. Prefer the top-level `Fingerprints` class:
256```python
257from matchms import Fingerprints
259fingerprints = Fingerprints(
260 fingerprint_algorithm="morgan2",
261 fingerprint_method="bit",
262 nbits=2048,
264fingerprints.compute_fingerprints(spectra)
265```
267`Fingerprints` maps valid InChIKeys to fingerprints and needs a valid InChIKey
268plus SMILES or InChI. In 0.33.1, `FingerprintSimilarity` still reads a
269`"fingerprint"` field from each spectrum, so bridge deliberately when that
270legacy similarity class is needed:
272```python
273for spectrum in spectra:
274 fingerprint = fingerprints.get_fingerprint_by_spectrum(spectrum)
275 if fingerprint is not None:
276 spectrum.set("fingerprint", fingerprint)
277```
279Do not describe fingerprint similarity as spectral similarity.

Fingerprints

add_fingerprint() still works in 0.33.1, but it is marked for removal in matchms 1.0. Prefer the top-level Fingerprints class:

from matchms import Fingerprints

fingerprints = Fingerprints(
    fingerprint_algorithm="morgan2",
    fingerprint_method="bit",
    nbits=2048,
)
fingerprints.compute_fingerprints(spectra)

Fingerprints maps valid InChIKeys to fingerprints and needs a valid InChIKey plus SMILES or InChI. In 0.33.1, FingerprintSimilarity still reads a "fingerprint" field from each spectrum, so bridge deliberately when that legacy similarity class is needed:

for spectrum in spectra:
    fingerprint = fingerprints.get_fingerprint_by_spectrum(spectrum)
    if fingerprint is not None:
        spectrum.set("fingerprint", fingerprint)

Do not describe fingerprint similarity as spectral similarity.

Raw lines 251 to 280
281## Custom Filters
283A custom filter should accept a spectrum first and return a spectrum or `None`:
285```python
286def require_fragment(spectrum_in, fragment_mz, tolerance=0.02):
287 if spectrum_in is None:
288 return None
289 if any(abs(mz - fragment_mz) <= tolerance for mz in spectrum_in.peaks.mz):
290 return spectrum_in
291 return None
294processor.parse_and_add_filter(
295 (require_fragment, {"fragment_mz": 184.0733, "tolerance": 0.01})
297```
299Avoid mutation unless it is intentional and documented. If the custom filter
300modifies a spectrum, either implement a `clone` parameter consistently or clone
301inside the function.

Custom Filters

A custom filter should accept a spectrum first and return a spectrum or None:

def require_fragment(spectrum_in, fragment_mz, tolerance=0.02):
    if spectrum_in is None:
        return None
    if any(abs(mz - fragment_mz) <= tolerance for mz in spectrum_in.peaks.mz):
        return spectrum_in
    return None


processor.parse_and_add_filter(
    (require_fragment, {"fragment_mz": 184.0733, "tolerance": 0.01})
)

Avoid mutation unless it is intentional and documented. If the custom filter modifies a spectrum, either implement a clone parameter consistently or clone inside the function.

Raw lines 281 to 302
303## Reproducibility Checklist
305- Save `processor.processing_steps`.
306- Record matchms and Python versions.
307- Record whether metadata harmonization was enabled at import.
308- Apply identical peak filters to query and reference collections.
309- Preserve counts before and after every requirement filter.
310- Preserve original metadata before repair/enrichment.
311- Record network calls and external annotation sources.
312- Test a few spectra with expected pass/fail behavior before batch processing.

Reproducibility Checklist

  • Save processor.processing_steps.
  • Record matchms and Python versions.
  • Record whether metadata harmonization was enabled at import.
  • Apply identical peak filters to query and reference collections.
  • Preserve counts before and after every requirement filter.
  • Preserve original metadata before repair/enrichment.
  • Record network calls and external annotation sources.
  • Test a few spectra with expected pass/fail behavior before batch processing.
Raw lines 303 to 312