Skip to content

Port OspreySharp pass-2 (transfer-compete, protein-compact frozen) + learned pick, off by default - #57

Merged
brendanx67 merged 8 commits into
mainfrom
port-ospreysharp-pick-and-pass2
Jul 25, 2026
Merged

Port OspreySharp pass-2 (transfer-compete, protein-compact frozen) + learned pick, off by default#57
brendanx67 merged 8 commits into
mainfrom
port-ospreysharp-pick-and-pass2

Conversation

@maccoss

@maccoss maccoss commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • Ports the OspreySharp pass-2 FDR modes and the learned per-platform peak-pick model, all off by default (a plain run matches the legacy product pick + percolator-retrain golden). OSPREY_PASS2_QVALUE=transfer-compete applies the frozen 1st-pass model over the full pre-compaction population; protein-compact constrains that competition to the >=2-peptide protein stratum. Both stream one file at a time via compute_full_population_fdr_streaming (no retrain). The learned pick model is opt-in via OSPREY_PICK_LDA (OSPREY_PICK_LDA_MODEL still overrides).
  • Deterministic shared-boundary charge tie-break (lowest charge) in build_shared_boundaries_from_plan, matching C# BuildSharedBoundaries, so the blib RetentionTimes stay byte-identical cross-impl.

Requested by Mike.

Test plan

  • cargo fmt --check / clippy -D warnings / test
  • Cross-impl byte-parity with C# at 1e-9 on Stellar 3-file (protein-compact frozen: 45,900 precursors; Stage 7 protein FDR + blib content PASS)

Co-Authored-By: Claude noreply@anthropic.com

maccoss and others added 5 commits July 20, 2026 15:33
Port the OspreySharp (C#) learned linear peak-pick model into the CWT
candidate ranking, replacing the product-form rank
(coelution * rt_penalty * ln(1+intensity)) with a standardized linear
combination of four raw terms: coelution, ln_intensity, rt_penalty, and a
per-candidate median-polish cosine. The model is the DEFAULT, keyed by
resolution: unit-resolution data uses the Stellar-trained weights, HRAM the
Astral-trained weights (verbatim from the C# PickLdaModel).

New `pick_lda` module holds the frozen z-score weights and the env
precedence, resolved once per process:
  1. OSPREY_PICK_LDA_MODEL (JSON path)  -> that model (test override)
  2. OSPREY_PICK_LEGACY (set, not "0")  -> the pure product pick
  3. default                            -> the resolution-keyed model

run_search computes the per-candidate median-polish term the same way the C#
CandidateLibCosine does (tukey_median_polish over the peak slice ->
median_polish_libcosine, neutral 1.0 on failure); the argmax + total-order
tie-break are unchanged.

Making the model the default diverges from the historical product pick, so it
stales the regression golden exactly as it does in OspreySharp; OSPREY_PICK_LEGACY=1
restores the product pick for regenerating the golden and for legacy parity.

Verified byte-identical to OspreySharp end-to-end at 1e-9 on Stellar
(Compare-EndToEnd-Crossimpl): 47,639 precursors both impls with the model on
(vs 47,726 legacy), Stage 7 + blib PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the OspreySharp OSPREY_PASS2_QVALUE=transfer-compete pass-2 FDR into the
merge-node second pass. Instead of retraining the 2nd-pass SVM on the
decoy-depleted reconciled+compacted pool, apply the FROZEN 1st-pass model to
the reconciled survivors and recompute run/experiment q + PEP by a fresh
target-decoy competition over the FULL pre-compaction population (the per-file
.1st-pass.fdr_scores.bin scalar sidecars), with the reconciled survivors'
frozen-model scores swapped in. This restores the FDR control the retrain's
decoy-depleted null loses on entrapment libraries.

The default is unchanged (percolator = retrain), so nothing happens unless
OSPREY_PASS2_QVALUE=transfer-compete is set. A new Pass2QValueMode enum + env
read (OnceLock) selects the mode.

New `pass2_qvalue` module:
  - FrozenLinearModel: the fold-averaged 1st-pass weights + bias + standardizer,
    reproducing byte-for-byte the averaged-model scoring run_percolator_fdr
    already applies, so a survivor re-scored from it matches its 1st-pass
    sidecar score.
  - compute_full_population_fdr_streaming: a faithful port of the C#
    ComputeFullPopulationPrecursorFdrStreaming, reusing the existing
    compete_from_indices + compute_conservative_qvalues + PepEstimator
    primitives. One file's scalars resident at a time -> flat in file count.
    Takes an optional stratum_base_ids hook for the upcoming protein-compact
    mode (a constrained competition); None here is the full-population path.

pipeline.rs: run_percolator_fdr gains a frozen_out capture param (the 1st-pass
call captures the averaged model into frozen_first_pass_model); a new
read_fdr_scores_scalars reads the raw entry_id+svm_score from the v3 sidecar;
compute_pass2_transfer_compete frozen-scores the survivors, streams the
full-population competition, and maps q/PEP back onto the survivor stubs
(skipping off-stratum entries when a stratum is supplied). Falls back to the
retrain if the frozen model or 1st-pass sidecars are absent.

Verified byte-identical to OspreySharp end-to-end at 1e-9 on Stellar with
OSPREY_PASS2_QVALUE=transfer-compete (Compare-EndToEnd-Crossimpl): single-file
37,131 precursors (94,044 survivors) and 3-file 43,805 (398,314 survivors),
both impls equal, transfer-compete engaged (no retrain), Stage 7 + blib PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port the OspreySharp OSPREY_PASS2_QVALUE=protein-compact pass-2 FDR. A
protein-constrained second pass recovers the peptide DEPTH of proteins already
detected in the first pass: the compaction gate is expanded to admit every
peptide of a protein with >= 2 first-pass-detected peptides, even when that
peptide failed first-pass FDR. Admitted peptides survive compaction, so they
flow into RT consensus + reconciliation + the second-pass retrain and get
reported at honest FDR (reduced multiple testing / Bourgon 2010 independent
filtering — the candidate pool is shrunk to present-protein peptides).

The stratum's ONLY effect is the compaction-gate expansion. The projection
second pass then RETRAINS over the stratum-expanded compacted pool (it does not
use the frozen model — see FirstJoinTask.RunPercolatorFdr's projection overload,
which passes no frozen model to PercolatorEngine.RunPercolatorFdr). So
protein-compact is the normal percolator retrain plus the expanded gate; it does
not take transfer-compete's frozen-model / full-population path.

New Pass2QValueMode::ProteinCompact + build_protein_compact_stratum (a literal
port of C# FirstJoinTask.BuildProteinCompactStratum: from the raw library
protein assignments and the first-pass detected-peptide set, count distinct
detected peptides per protein, keep >= 2, and collect the base_ids of all their
library precursors). The stratum is built in the first-pass protein-FDR block
(where the detected-peptide set and library are in scope) and unioned into
first_pass_base_ids at the compaction gate. Default stays percolator, so nothing
changes unless OSPREY_PASS2_QVALUE=protein-compact is set.

Verified byte-identical to OspreySharp end-to-end at 1e-9 on Stellar 3-file
(Compare-EndToEnd-Crossimpl): Rust = C# = 49,059 precursors (+5,254 over the
transfer-compete baseline of 43,805 — the recovered present-protein depth),
identical stratum (5,677 proteins >= 2 peptides -> 207,964 base_ids), Stage 7 +
blib PASS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…th C#)

protein-compact previously fell through to the second-pass retrain (the stratum's
only effect was the compaction-gate expansion). It now recomputes q/PEP with the
FROZEN first-pass model over the protein stratum, via
compute_full_population_fdr_streaming's stratum_base_ids argument (streamed one file
at a time), mirroring the C# streaming frozen second pass
(Pass2FdrSidecar.ComputePass2TransferCompeteFull). uses_frozen_model() now includes
ProteinCompact; the dispatch passes the stratum and requires it (falls back to the
retrain if the stratum or frozen model is absent).

Also break shared-boundary run_qvalue ties between gap-filled charge states by lowest
charge in build_shared_boundaries_from_plan, matching C# BuildSharedBoundaries, so the
blib RetentionTimes stay byte-identical cross-impl (fixes a 20-row Astral
transfer-compete divergence). Adds test_shared_boundaries_tie_broken_by_lowest_charge.

Verified Rust == C# end-to-end bit-parity at 1e-9 on Stellar 3-file with
OSPREY_PASS2_QVALUE=protein-compact (45,900 precursors; Stage 7 + blib PASS).
fmt / clippy -D warnings / tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
…LDA)

The learned resolution-keyed pick model is now off by default; OSPREY_PICK_LDA (set and
not "0") opts into it, matching the C# default flip. The pure product-form pick is the
default again -- Rust cross-impl parity + the committed regression golden; turning the
model on by default is a coordinated golden re-baseline. Renames PickMode::Default ->
ResolutionModel now that it is the opt-in rather than the default. OSPREY_PICK_LDA_MODEL
still overrides.

Co-Authored-By: Claude <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 23, 2026 03:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Ports OspreySharp’s optional “pass-2” FDR strategies (frozen-model transfer-compete and protein-compact) plus an opt-in learned peak-picking model, aiming for cross-implementation byte parity while keeping legacy behavior unchanged unless env-vars are set.

Changes:

  • Adds OSPREY_PASS2_QVALUE-controlled second-pass behavior, including a frozen 1st-pass model capture and a streaming full-population target/decoy recompute path.
  • Introduces OSPREY_PICK_LDA / OSPREY_PICK_LDA_MODEL learned peak-pick scoring (resolution-keyed defaults), integrated into run_search when enabled.
  • Makes shared-boundary selection deterministic on run-q ties by breaking ties on lowest charge to avoid cross-impl ordering drift.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
crates/osprey/src/pipeline.rs Implements frozen-model capture + pass-2 recompute wiring, protein-compact stratum construction, deterministic shared-boundary tie-break, and learned pick integration in run_search.
crates/osprey/src/pick_lda.rs Adds the learned peak-pick model implementation and env-var based selection.
crates/osprey/src/pass2_qvalue.rs Adds pass-2 mode selection and the streaming full-population FDR recompute helper used by transfer-compete/protein-compact.
crates/osprey/src/lib.rs Exposes the new pick_lda and pass2_qvalue modules.
crates/osprey/Cargo.toml Adds osprey-ml dependency for PEP estimation/standardizer support in pass-2 recompute.
Cargo.lock Locks the new dependency edge for osprey-ml.
Comments suppressed due to low confidence (1)

crates/osprey/src/pipeline.rs:6276

  • compute_pass2_transfer_compete uses read_fdr_scores_scalars(...).unwrap_or_default(), which silently turns an unreadable/invalid sidecar into an empty population and can yield bogus q-values/PEPs while still returning true. This should trigger a fallback to the 2nd-pass retrain instead.
    // Stream the full-population competition; only one file's scalars resident at a time.
    let read_file = |file_idx: usize| -> (Vec<u32>, Vec<f64>) {
        read_fdr_scores_scalars(&sidecar_paths[file_idx]).unwrap_or_default()
    };

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +1881 to +1890
if data.len() < FDR_SIDECAR_HEADER_LEN
|| &data[0..8] != FDR_SIDECAR_MAGIC
|| data[8] != FDR_SIDECAR_VERSION
{
return None;
}
let count = u64::from_le_bytes(data[16..24].try_into().ok()?) as usize;
if data.len() != FDR_SIDECAR_HEADER_LEN + count * FDR_SIDECAR_RECORD_LEN {
return None;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a checked_mul/checked_add guard in 85ebc47, so an oversized/corrupt header count now returns None (caller falls back to the retrain) instead of wrapping in release.

On the pass byte: read_fdr_scores_scalars is only ever called with the explicitly-constructed {file}.1st-pass.fdr_scores.bin path (compute_pass2_transfer_compete builds it by name), so a 2nd-pass file cannot be selected here, and the magic + version + exact-size checks reject anything else. The pass-byte validation lives in load_fdr_scores_sidecar, which is the reader that takes an expected_pass.

Comment thread crates/osprey/src/pipeline.rs Outdated
Comment on lines +6251 to +6254
let file_overlay = overlay.get(file_name.as_str());
let file_features = per_file_cache_paths
.get(file_name.as_str())
.and_then(|p| load_pin_features_from_parquet(p).ok());

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two different reads here; splitting them:

6254 (PIN feature load) — not a correctness risk. frozen is the 1st-pass model that wrote the sidecar scalars, so for a non-rescored survivor frozen.score(features) reproduces its sidecar score exactly — the override is a no-op. The survivors that genuinely need re-scoring are the reconciliation-rescored ones, which take the overlay path (independent of this parquet load). So a failed load cannot corrupt q-values. I added a warn! for observability in 85ebc47 rather than falling back to the retrain.

6276 (full-population scalar sidecar, the suppressed comment) — this one had FDR consequences and is fixed. unwrap_or_default() silently turned a present-but-corrupt sidecar into an empty population, understating the null and making q-values anti-conservative while still returning true. It now flags the read failure and falls back to the 2nd-pass retrain, matching the "missing sidecar -> retrain" contract in the function docstring.

Comment on lines +9003 to +9009
// Rank score. With a learned pick model active (default),
// replace the product form with a standardized linear
// combination of the same four raw terms — coelution,
// ln_intensity, rt_penalty, and a per-candidate
// median-polish cosine. Without a model (the default; the
// model is opt-in via OSPREY_PICK_LDA) it stays the pure
// product form used for cross-impl parity + the regression golden.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 85ebc47. The "(default)" was stale from when the LDA pick was the default; reworded so the comment states the product form is the default and the learned model is opt-in via OSPREY_PICK_LDA.

maccoss added 3 commits July 22, 2026 21:36
Addresses Copilot review on #57:

- read_fdr_scores_scalars: guard the header-count size arithmetic with
  checked_mul/checked_add so a corrupt/oversized count is rejected
  (-> None) instead of wrapping in release builds.
- compute_pass2_transfer_compete: a present-but-corrupt 1st-pass sidecar
  no longer silently contributes an empty population to the competition
  (which would understate the null and make q-values anti-conservative).
  A read failure now flags and falls back to the 2nd-pass retrain, matching
  the existing "missing sidecar -> retrain" contract in the docstring.
- Surface a warning when a survivor file's PIN features fail to load
  (non-rescored survivors correctly keep their identical 1st-pass sidecar
  score; reconciliation-rescored survivors use the overlay path, so a
  failed load cannot corrupt q-values).
- Fix the self-contradicting "(default)" pick-rank comment: the learned
  pick model is opt-in via OSPREY_PICK_LDA; the product form is the default.
Self-review follow-up on #57.

- An explicitly requested frozen pass-2 mode (OSPREY_PASS2_QVALUE=
  transfer-compete / protein-compact) no longer silently degrades to the
  anti-conservative retrain when its prerequisites are absent. A warm rerun
  that loads cached SVM scores skips 1st-pass training, leaving the frozen
  model (and, for protein-compact, the stratum) unbuilt; a present-but-corrupt
  1st-pass sidecar likewise can't be honored. All three now abort with a
  ConfigError and actionable guidance instead of reporting looser FDR than a
  cold run under the same flag. The default (percolator) path is untouched.
- Port OSPREY_PROTEIN_COMPACT_RETRAIN (default off) to match C#
  OspreyEnvironment.Pass2ProteinCompactRetrain: when set, protein-compact
  retrains the 2nd-pass SVM over the stratum-expanded pool (the frozen-vs-
  retrain A/B lever) instead of taking the frozen path; it is also the one
  frozen prerequisite whose absence is intended, so it is exempt from fail-fast.

Happy path unchanged (same frozen recompute call); only the can't-honor
branches and the retrain opt-in are new. fmt + clippy + 183 tests green.
Mirrors the Copilot review fix on ProteoWizard/pwiz #4446 so the two
pick-model loaders stay identical:

- load_from_json now requires the JSON `features` array and checks it lists
  [coelution, ln_intensity, rt_penalty, median_polish] in that exact order,
  matching the C# PickLdaModel.ExpectedFeatures check. The weights are
  positional, so a re-ordered or older-schema model would otherwise silently
  score the wrong term. Only the OSPREY_PICK_LDA_MODEL override path is
  affected; the hardcoded resolution models and the default product pick are
  untouched.

fmt + clippy + tests green.
@brendanx67
brendanx67 merged commit a3d6c3a into main Jul 25, 2026
3 checks passed
brendanx67 added a commit to ProteoWizard/pwiz-ai that referenced this pull request Jul 25, 2026
* maccoss/osprey#57 landed as a3d6c3a, so C# and Rust are back in step

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants