osprey: Collapsed the protein q-values into one field written per pass - #4569
Conversation
|
TeamCity Perf/Regression is green on this PR: build 4131578, That closes the one caveat in the test plan above. The local It also reproduces the local
One note for anyone re-triggering this config: it only works on |
Validated at 82-file scaleAn 82-file SEA-AD Astral-DIA run finished on this branch tip (
That is ~26× the largest regression dataset (Astral, 3.45M records), so the second-pass protein FDR demonstrably reaches the sidecar on production-scale data and not just on the 3-file sets. It also puts the original issue in proportion. #4559 was filed over a 390-record divergence on Stellar; the same population here is 513,952 records, and before this change every one of those 88.5M records carried a pass-1 value in a pass-2 file. FDR calibration is unmoved, which is what the "sidecar-only" claim predicts. Entrapment FDP at reported q ≤ 0.01 is 1.575% combined / 0.800% lower-bound (r = 0.9695, 749,259 reported precursors) — the already-documented pass-2 recalibration figure of ~1.57% for this configuration, reproduced to three significant figures. Reproducibility looks normal too: peptides identified in ≥41 of 82 runs sit at 0.18% FDP, and the 5,422 seen in all 82 at 0.00%. |
8266662 to
6e726fd
Compare
* RunProteinQvalue was never per-run: the first-pass protein FDR pools the detected peptides over every file and propagates one value per peptide * The pair encoded PASS in the field name, which is why a pass-1 value ends up in the 2nd-pass sidecar (#4559); pass belongs to the sidecar, as it already does for the precursor and peptide q-values * Pure rename plus the two-boolean PropagateProteinQvalues collapse; no value computed here changes See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
* Single-run property, so it runs on the DEFAULT arm every dataset - #4559 was invisible there because both routes copied the same pass-1 value * Covers what a two-route comparison structurally cannot: a column both sides get identically wrong * RED as added: all 994,509 shared records on Stellar 3-file are byte-identical to the 1st-pass column, so the second-pass protein FDR never reached the file See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
* PatchPass2ProteinQvalues runs after the second-pass protein FDR, which is necessarily after the sidecar write - the sidecar feeds that FDR - so it is a patch, like the two-phase first-pass write it reuses * RestorePass1Scalars no longer seeds the protein q; the patch is now its single producer, which is what stops a pass-1 value landing in a pass-2 file * Covered the accepting direction of the pass-byte check, only ever exercised as a rejection before See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
* One experiment-scope field; the 1st-pass sidecar carries pass 1's value and the 2nd-pass sidecar pass 2's, as it already works for precursor and peptide q * Recorded the measurement behind the rename: 483,820 of 483,820 multi-file precursors carried one value, so "run" was a misnomer See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
* The count was computed and never printed, so the doc claim that those records are "reported separately rather than silently skipped" was not true * They have no 1st-pass value to compare against, so they cannot contribute to the liveness check - and they are the population #4559 was filed about (390 on Stellar 3-file) See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
* Moved the 2nd-pass sidecar patch ahead of the Stage-7 dump, which ends the process under OSPREY_STAGE7_PROTEIN_FDR_ONLY and so left the protein column at its ResetScores default in a sidecar kept for downstream rehydration * Corrected the patch-failure warning, which still claimed an unpatched file keeps a first-pass value; that stopped being true when the seed was removed * Renamed the last runProtein identifiers and reported the records actually patched rather than the size of the map See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
* The patch loop gated on File.Exists alone, which cannot see a version, so a sidecar from an older record layout reached PatchProteinQvalues and was rejected mid-stream instead of up front. Pre-check with IsCurrentFormat, the gate #4558 added for exactly this ("presence is not readability") * Kept both gates: absent is a silent skip, since a file with no reconciled parquet legitimately has no 2nd-pass sidecar, while present-but-unusable is reported. IsCurrentFormat alone is false for both and cannot tell them apart * This is the semantics the Rust twin already had, so it closes a C#/Rust divergence rather than creating one See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
6e726fd to
fe77ce5
Compare
There was a problem hiding this comment.
Pull request overview
This PR updates Osprey’s protein q-value handling so the per-entry protein q is represented as a single experiment-wide field (ExperimentProteinQvalue) and ensures the 2nd-pass .2nd-pass.fdr_scores.bin sidecar actually contains the pass-2 protein q-value (patched in after second-pass protein FDR). It also adds a new regression leg (“mode1c”) to catch the previously undetectable shared-defect scenario where both routes copied an incorrect pass-1 protein q into the pass-2 sidecar.
Changes:
- Collapses
RunProteinQvalue/ExperimentProteinQvalueinto oneExperimentProteinQvalue, and renames the sidecar column toexperiment_protein_qvalue. - Patches the pass-2 protein q-values into the 2nd-pass sidecar after second-pass protein FDR runs, aligning sidecar semantics with other q-value columns.
- Adds a new regression check (
mode1c) asserting the 2nd-pass sidecar’s protein q column is not a verbatim copy of the 1st-pass column.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pwiz_tools/Osprey/Regression/FdrSidecars.ps1 | Adds a liveness check for pass-2 protein q-values and updates field naming/layout references. |
| pwiz_tools/Osprey/regression.ps1 | Adds mode1c leg to run the pass-2 protein q-value liveness check on straight-through output. |
| pwiz_tools/Osprey/Osprey/OspreyFileDiagnostics.cs | Updates diagnostic dumps/comments to use ExperimentProteinQvalue. |
| pwiz_tools/Osprey/Osprey.Test/ReconciliationTest.cs | Updates tests to set/read ExperimentProteinQvalue. |
| pwiz_tools/Osprey/Osprey.Test/Pass2FdrSidecarTest.cs | Updates pass-2 sidecar tests to reflect renamed protein q field. |
| pwiz_tools/Osprey/Osprey.Test/OspreyConfigTest.cs | Updates comments/assertions referencing protein q field name. |
| pwiz_tools/Osprey/Osprey.Test/IOTest.cs | Updates sidecar roundtrip/patch tests and adds explicit pass-2 protein-q patch test. |
| pwiz_tools/Osprey/Osprey.Test/FdrTest.cs | Updates projection roundtrip to use ExperimentProteinQvalue. |
| pwiz_tools/Osprey/Osprey.Tasks/SecondPassFdrTask.cs | Ensures pass-2 protein q is patched into the pass-2 sidecar after protein FDR runs. |
| pwiz_tools/Osprey/Osprey.Tasks/RescoreHydration.cs | Updates hydration docs/behavior references to ExperimentProteinQvalue. |
| pwiz_tools/Osprey/Osprey.Tasks/PerFileRescoreTask.cs | Updates reconciliation/rehydration commentary and field naming for protein q. |
| pwiz_tools/Osprey/Osprey.Tasks/Pass2FdrSidecar.cs | Stops seeding protein q from pass-1 for pass-2 sidecars; adds pass-2 patch routine and updated messaging. |
| pwiz_tools/Osprey/Osprey.Tasks/ModelDiagnostics/PeakCoAssignmentSource.cs | Updates sidecar column naming in comments. |
| pwiz_tools/Osprey/Osprey.Tasks/FirstPassFdrTask.cs | Updates compaction predicate to use ExperimentProteinQvalue and renames patch method usage. |
| pwiz_tools/Osprey/Osprey.Tasks/FdrProjectionSinks.cs | Updates streaming sinks to write/lookup ExperimentProteinQvalue. |
| pwiz_tools/Osprey/Osprey.IO/FdrScoresSidecar.cs | Renames protein q column, updates read/write, and replaces patch API with PatchProteinQvalues(..., out recordsPatched). |
| pwiz_tools/Osprey/Osprey.IO/FdrScoreRecord.cs | Renames record field to ExperimentProteinQvalue. |
| pwiz_tools/Osprey/Osprey.FDR/Reconciliation/ConsensusRts.cs | Uses ExperimentProteinQvalue in protein rescue eligibility. |
| pwiz_tools/Osprey/Osprey.FDR/ProteinFdrEngine.cs | Updates propagation semantics to a single protein q field and simplifies propagation call. |
| pwiz_tools/Osprey/Osprey.FDR/ProteinFdr.cs | Collapses propagation to always set ExperimentProteinQvalue and updates first/second pass semantics/docs. |
| pwiz_tools/Osprey/Osprey.FDR/FdrProjectionOutput.cs | Updates documentation references for where protein q-values are produced. |
| pwiz_tools/Osprey/Osprey.Core/FdrEntry.cs | Removes RunProteinQvalue and documents single-field, pass-by-sidecar semantics for protein q. |
| pwiz_tools/Osprey/docs/14-intermediate-files.md | Updates intermediate file documentation to experiment_protein_qvalue and clarifies pass-dependent meaning. |
| pwiz_tools/Osprey/docs/08-protein-parsimony.md | Updates protein-parsimony documentation to reflect single protein-q field and pass-by-sidecar semantics. |
Suppressed comments (2)
pwiz_tools/Osprey/Osprey.IO/FdrScoresSidecar.cs:456
- Comment in PatchProteinQvalues still says it streams a "60-byte" record, but the method streams RecordLength bytes per record (68 in v4).
// Stream one 60-byte record at a time: overwrite ONLY the
pwiz_tools/Osprey/Osprey.IO/FdrScoresSidecar.cs:763
- ReadRecords XML doc still says it streams a "60-byte" record. The implementation streams RecordLength bytes (68 in v4), and keeping the doc in sync helps avoid future offset/stride mistakes.
/// discard) on any mismatch or IO failure. Streams one 60-byte record at a time from
/// the source (one record resident, not an O(file-size) whole-file buffer), matching
/// <see cref="PatchProteinQvalues"/>. Records are delivered in stored (file) order.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
* Corrected four XML/inline doc comments still describing 60-byte sidecar records; v4 records are 68 bytes, stale since the v3 to v4 change * Referenced RecordLength rather than restating the number, so the next layout change cannot leave these stale again * Includes the occurrence at line 515 that the review did not flag, since fixing three of four would have been worse than either extreme Co-Authored-By: Claude <noreply@anthropic.com>
* Added a pass2proteinq token to SecondPassFdrTask's ValidityKey. #4559 changed the meaning of the 2nd-pass protein column without moving a byte, which the format version cannot express, so a post-#4559 build resuming into an older output directory skipped the task and kept the stale column * Made mode 1c able to detect its own subject failing. Removing the pass-1 seed meant an unpatched record reads 1.0, which also differs from pass 1, so "differs" alone would have passed a run with the patch reverted. The comparer now counts records sitting at the reset default and the gate refuses a run where every moved record is one * Moved the liveness assertion from per-file to per-run. PropagateProteinQvalues legitimately assigns 1.0 in both passes for sequences absent from the peptide map, so one such file could red an entire dataset * Guarded mode 1c on 2nd-pass sidecars existing, matching mode 1b and mode 3. An arm that does no reconciliation work has nothing to assert * Failed fast when a stale OspreyFdrSidecarComparer is already loaded in the session, instead of throwing MethodNotFound mid-gate for a type that cannot be reloaded * Fixed comments the field collapse left self-contradictory, and a run_protein_q column header in the diagnostics dump See ai/todos/active/TODO-20260811_experiment_protein_qvalue.md Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Collapsed the two per-entry protein q-value fields on
FdrEntryinto one experiment-wideExperimentProteinQvalue, and renamed the sidecar columnrun_protein_qvalue->experiment_protein_qvalue. The field was per-run in name only - measured onstraight-through 1st-pass sidecars, 483,820 of 483,820 multi-file precursors carry ONE
value across every file they appear in, and 0 carry different values.
The 2nd-pass FDR sidecar now carries the pass-2 protein q, patched in after the
second-pass protein FDR. It previously carried a pass-1 value unconditionally - the only
column in that file that did. Protein q now follows the rule precursor and peptide q
already follow: one field, with the pass recorded by which sidecar it lives in rather
than by which field.
Removed a latent C#/Rust divergence in the process. C#'s second-pass
PropagateProteinQvaluespassed(setRun: true, setExperiment: true); Rust's passedset_run: false. After Stage 7 the two implementations therefore held differentvalues in that field - C# the pass-2 value, Rust the pass-1 one - and every parity gate
was green through it, because
effective_run_qvalue(Protein)has no caller on eitherside. The collapse makes the disagreement unrepresentable rather than merely fixed.
Added a
mode1cleg toregression.ps1(Test-Pass2ProteinQvalueinRegression/FdrSidecars.ps1) asserting that the 2nd-pass sidecar's protein column reallyis a pass-2 value. Mode 3 cannot see this class of defect - it compares the two routes
against each other, and both routes copied the same pass-1 value.
The gate found the defect was much larger than Gap-fill entries get a run protein q on the batch-hydrate arm but not straight-through #4559 reported. Gap-fill entries get a run protein q on the batch-hydrate arm but not straight-through #4559 described a
390-record sliver where the two routes disagreed; on the arm CI actually runs, the column
was never a pass-2 value for any record - all 994,509 shared records on Stellar were
identical to the 1st-pass column. That is what
mode1cwas red on before the fix.This dissolves Gap-fill entries get a run protein q on the batch-hydrate arm but not straight-through #4559's original question rather than answering it. "Should a gap-fill entry
carry 0.0 or 1.0?" only arises because a pass-1 quantity was stored in a pass-2 file and
then asked what it says about rows that did not exist in pass 1. A gap-fill row now gets
whatever the second-pass protein FDR computes for it, from a pool that includes it.
Hardened the patch itself after a review pass over the branch. The Stage-7
OSPREY_STAGE7_PROTEIN_FDR_ONLYearly exit callsEnvironment.Exit(0)insideRunProteinFdr, which is before the patch call that sat in the caller - so on thatdiagnostic path the 2nd-pass sidecar was left with its protein column at the
ResetScoresdefault, and the comment above the sidecar write says that exit deliberately leaves the file
on disk for downstream rehydration. Both implementations now order it
propagate -> patch -> dump; the dump reads only the parsimony / FDR result and never the
stubs, so its content is unchanged. This also removes a cross-impl ordering difference (Rust
dumped before propagation, C# after). No production path and no reported output is affected.
Also from that pass: the patch-failure warning claimed unpatched files "keep a FIRST-pass
protein q-value", which stopped being true when the seed was removed; the remaining
runProtein*identifiers are gone, so the "run" framing is now unrepresentable rather thanmerely unused; and
PatchProteinQvaluesreports how many records it actually rewrote(
out int, asserted in three tests) instead of the caller assuming the map size, which iswhat the Rust side already counted.
Sidecar version: decided, NOT bumping (Brendan, 2026-08-13). The question was whether the
2nd-pass protein column changing meaning warrants v4 -> v5. It does not, for now:
experiment_protein_qvaluestays at
[52..60]. A v4 and a v5 reader would parse identical bytes identically. The1st-pass sidecar is unchanged in meaning as well as layout.
ParquetScoreCachehard-fails on any osprey version mismatch ("different daily build" /"incompatible release identity"), and
.scores.parquetis read before any sidecar - so across-build resume never reaches this file. What remains is same-version-stamp-different-code,
i.e. same-day dev builds or a run pinned under
OSPREY_VERSION_OVERRIDE.Rejecting a v4 sidecar discards
Score,Pep, run/experiment precursor and peptide q andExperimentAggregateScore- all still correct - to protect the one column thatPropagateProteinQvaluesoverwrites unconditionally in Stage 7 before anything reads it.(This point was originally stronger and is now partly overtaken: it also said the read path
warns and proceeds rather than recomputing. osprey: Surfaced single-peak multiple-ID co-assignment in --model-diagnostics #4558's
FdrScoresSidecar.IsCurrentFormathassince replaced the bare
File.Existsgates atPass2FdrSidecar.cs:188/:511andPerFileRescoreTask.cs:268, so a stale-version sidecar now fails the "already done" test andis regenerated. A bump would do the right thing rather than the awkward thing. The
decision below is unchanged, but it rests on the first and last bullets, not this one.)
development sessions, which will take fresh runs over
-LinkFromwhile this much is moving.The cost of a stale artifact is a re-run, not a wrong answer shipped to a user.
Revisit when either of these lands: the first public release, or #4561 (
--fdr-level proteinin C#), which is what gives the 2nd-pass protein column a real consumer - at whichpoint a stale pass-1 value becomes a wrong reported q rather than one that gets overwritten.
The bump should then be paired with making the sidecar read hard-fail instead of
warn-and-proceed, since a version guard that warns and continues is not really a guard.
Stacked on #4558 - review that one first. Retargeting to
masterand rebasing once it merges.Fixes #4559
Test plan
regression.ps1 -Dataset All- 52 legs, 52 PASS, 0 FAIL, run against osprey: Surfaced single-peak multiple-ID co-assignment in --model-diagnostics #4558'srebaselined goldens
mode1 (vs golden)PASS on all four datasets - the change is sidecar-only and movesno reported output, which the code reading predicted (Stage 7 reports
ProteinFdrResult.GroupQvalues, per GROUP, not either per-entry field) and thismeasures
mode1c(new) red -> green on Stellar with the same command both ways: FAIL (all994,509 shared records identical to the 1st-pass column) -> PASS. Movement on every
dataset: Stellar 25,006/994,509, StellarLibDecoy 6,486/923,246, StellarGenDecoyEntrap
89,116/259,678, Astral 19,008/3,449,774
mode1bFDR sanity bounds PASS on all three datasets that carry it - those are theentrapment-measured true-FDP ceilings
-CreateGoldendeliberately does notregenerate, so calibration is unmoved on evidence independent of the golden
mode3(sidecars == straight-through) PASS everywhere, up to 9,685,318 records onAstral - both routes still agree after the change
Osprey.Test579 tests pass; zero-warning ReSharper inspectionregression.ps1 -Dataset Stellarre-run to confirm the golden still does not move.It changes ordering only on the
OSPREY_STAGE7_PROTEIN_FDR_ONLYdiagnostic path pluscomments, a log message and identifier names, so it cannot move production output -
but the full
-Dataset Allfigures above were measured on the commit below it, andthe TeamCity Perf/Regression run on this PR covers the final tree.
Co-Authored-By: Claude noreply@anthropic.com