Skip to content

OspreySharp: close C# Stage 7 algorithmic gap + port Rust v2 reconciliation envelope + parity-sprint perf work - #4233

Merged
brendanx67 merged 34 commits into
masterfrom
Skyline/work/20260516_ospreysharp_wsl_parity
May 27, 2026
Merged

OspreySharp: close C# Stage 7 algorithmic gap + port Rust v2 reconciliation envelope + parity-sprint perf work#4233
brendanx67 merged 34 commits into
masterfrom
Skyline/work/20260516_ospreysharp_wsl_parity

Conversation

@brendanx67

Copy link
Copy Markdown
Member

Companion to maccoss/osprey#37 (Rust HPC chain bit-parity fixes).
Closes the C# Stage 7 algorithmic divergence we noticed during
performance work and bundles in the prior parity-sprint perf
improvements that were waiting for these correctness fixes to land
together.

Why now

While measuring Astral pipeline performance we noticed an anomaly
that called the prior parity work into question. Two underlying
problems turned out to be hiding the issue:

  1. C# MergeNodeTask was not running a 2nd-pass Percolator pass
    at all -- the merge node persisted 2nd-pass FDR sidecars from
    the in-memory entries' existing 1st-pass scores rather than
    recomputing them on the post-reconciliation features. The
    straight-through C# pipeline silently produced ~25% fewer
    precursors than expected.
  2. Rust --join-at-pass=2 had the same skip-2nd-pass bug, AND
    workers stamped a single-file reconciliation hash that the
    merge node correctly rejected. Bit-parity between the in-process
    Rust path and the HPC chain (sidecar files + rehydration) was
    not actually being validated end-to-end.

Both made it impossible to get reliable performance numbers. We
returned to first principles: built a strict bit-parity test that
compares a 4-step HPC chain run (raw workers + first-join + per-file
rescore workers + 2nd-join merge) against a straight-through pipeline
run using real mzMLs + calibration, and fixed the bugs the test
surfaced on both sides.

What this PR contains

Correctness changes (Bug A/B/C/D port from Rust)

  • Bug C (C# 2nd-pass Percolator) -- MergeNodeTask now runs
    2nd-pass Percolator before persisting 2nd-pass sidecars when any
    sidecar is missing (HPC distribution case). Required refactoring
    FirstJoinTask.RunPercolatorFdr to internal static with
    PipelineContext + passLabel parameters so MergeNodeTask can
    reuse the same Percolator harness, and reloading PIN features
    from the reconciled parquets before the call (PerFileScoringTask's
    bundle-hydration path explicitly nulls Features after stub load to
    preserve WriteReconciledParquet's "Features != null means
    rescored" criterion).
  • Bug B (file_stems v2 envelope + hash override) --
    ReconciliationFile gains a FileStems field; CurrentFormatVersion
    bumped to 2. OspreyConfig.ReconciliationParameterHashForStems
    added (mirrors Rust). FirstJoinTask.BuildReconciliationFile
    populates the field with the sorted+deduped multi-file stems set;
    RescoreHydration.HydrateReconciliationOverlay captures and
    validates it across siblings (JoinFileStems on RescoreInputs).
    PerFileRescoreTask.WriteReconciledParquet stamps the join-wide
    hash when stems are provided.
  • Bug D (hard error on missing Stage 6 calibration) --
    LoadMassCalibrations now throws InvalidDataException on
    missing or unparseable .calibration.json, instead of silently
    degrading to uncalibrated MS scoring.

Parity-sprint perf work (folded in)

Performance work from the weeks before the parity issue surfaced:
SVM grid-search parallelization (LinearSvmClassifier, Matrix),
XCorr scratch pool (XcorrScratchPool, SpectralScorer), Percolator
hot-path improvements (PercolatorFdr), pipeline instrumentation
(AnalysisPipeline).

Cleanup

ReSharper inspection cleanup (was 24 warnings: redundant
qualifiers, redundant usings, ambiguous XML doc refs, one possible-
null assignment); Array.Sort exemption documented on the one
permitted use in PercolatorFdr.cs (tie-break key is unique per
row post-dedup, so introsort's instability is moot).

Updated Osprey-workflow.html with the corrected HPC boundary
diagrams reflecting the 4-step chain.

Validation

Cross-impl Test-Regression.ps1 (stage1to4 / stage5 / stage6 /
stage7 / blib): all 5 gates PASS on Stellar single-file,
Stellar all-files, Astral single-file, and Astral all-files. The
Stage 5 percolator gate at Astral all-files is relaxed to per-column
1e-9 tolerance (was strict SHA-256) to absorb a 168-row ULP-1
drift in experiment_precursor_q at that scale -- flagged for
end-of-pipeline review in the ai/master companion change.

Cross-impl HPC-chain validation (companion ai/master scripts):

  • Rust planner -> C# worker -> Rust merge: Stellar PASS
    (60356/60373 precursors, hash matched, downstream Stage 6 drift
    only); Astral PASS (165354/165288, same shape).
  • C# planner -> Rust worker -> Rust merge: Stellar PASS
    (60373/60373 exact, hash matched); Astral PASS (165288/165288
    exact). Proves the C# WRITE side of the v2 envelope is
    byte-equivalent to Rust's.

Pre-commit gates (Build-OspreySharp.ps1 -RunInspection -RunTests):

  • ReSharper inspection: 0 errors, 0 warnings (was 24 warnings)
  • Tests: 346/346 pass, 2 skipped (was 343 pass / 1 fail on the
    TestNoUnstableArraySort rule)

Merge strategy

Single PR for review purposes; will be squashed at merge.

…elope

* Wired 2nd-pass Percolator into MergeNodeTask; reloads PIN features
  from reconciled parquets before re-scoring on the rescored features.
* Bumped ReconciliationFile to v2 with file_stems; added
  OspreyConfig.ReconciliationParameterHashForStems so workers stamp
  the join-wide hash that --join-at-pass=2 validates.
* Hardened Stage 6 LoadMassCalibrations: throws on missing or
  unparseable calibration.json instead of silently degrading.
* Folded in prior parity-sprint perf work (PercolatorFdr, SVM, scoring,
  scratch pools) and inspection cleanup (0 warnings, 346/346 tests).

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

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

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

Companion PR to a Rust-side parity fix that closes the C# Stage 7 algorithmic gap discovered during performance work, ports the Rust v2 reconciliation.json envelope (with the file_stems field and join-wide hash), and folds in parity-sprint performance work (SVM grid-search parallelization via a new OspreyParallel, SvmTrainScratchPool, XCorr f32 scratch reuse, Percolator hot-path scratching). Also stops silently degrading Stage 6 when the Stage 1–4 calibration sidecar is missing, refactors RunPercolatorFdr to be reusable for the new 2nd-pass call in MergeNodeTask, and refreshes the workflow doc with new benchmarks.

Changes:

  • Wires a missing 2nd-pass Percolator run into MergeNodeTask for the HPC distribution case and refactors FirstJoinTask.RunPercolatorFdr to internal static so it can be reused.
  • Adds v2 ReconciliationFile.FileStems end-to-end (planner write, worker rehydration with cross-envelope consistency check, OspreyConfig.ReconciliationParameterHashForStems, worker stamps join-wide hash).
  • Performance work: OspreyParallel.For + SvmTrainScratchPool in LinearSvmClassifier / PercolatorFdr (parallel grid search, pooled extract / count-passing buffers), f32 scratch path in SpectralScorer.PreprocessSpectrumForXcorrInto, pooled Matrix.WrapPrefixNoClone.

Reviewed changes

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

Show a summary per file
File Description
pwiz_tools/OspreySharp/OspreySharp/Tasks/PerFileRescoreTask.cs Threads joinFileStems through to WriteReconciledParquet; hard-errors on missing calibration sidecar.
pwiz_tools/OspreySharp/OspreySharp/Tasks/MergeNodeTask.cs New 2nd-pass FDR path; reloads PIN features from reconciled parquets; renames timing tags to [STAGE-WALL].
pwiz_tools/OspreySharp/OspreySharp/Tasks/FirstJoinTask.cs Refactors RunPercolatorFdr to internal static with passLabel; populates file_stems in envelopes.
pwiz_tools/OspreySharp/OspreySharp/RescoreHydration.cs Adds RescoreInputs.JoinFileStems; cross-envelope file_stems consistency check; normalize/equality helpers.
pwiz_tools/OspreySharp/OspreySharp/RescoreWorker.cs Removes a now-unused using.
pwiz_tools/OspreySharp/OspreySharp/AnalysisPipeline.cs Emits parseable [STAGE-WALL] lines per task.
pwiz_tools/OspreySharp/OspreySharp.IO/ReconciliationFile.cs Bumps CurrentFormatVersion to 2; adds FileStems; reorders JSON ordinals.
pwiz_tools/OspreySharp/OspreySharp.IO/FileSaver.cs Doc-only fix for ambiguous cref.
pwiz_tools/OspreySharp/OspreySharp.Core/OspreyConfig.cs Adds ReconciliationParameterHashForStems; refactors original to call it.
pwiz_tools/OspreySharp/OspreySharp.FDR/PercolatorFdr.cs Parallelizes grid search via OspreyParallel.For; scratch-pooled CountPassing/CompeteFromIndicesInto/ComputeQvaluesInto; ExtractRowsInto.
pwiz_tools/OspreySharp/OspreySharp.ML/LinearSvmClassifier.cs New OspreyParallel, SvmTrainScratch, SvmTrainScratchPool; Train overload with scratch.
pwiz_tools/OspreySharp/OspreySharp.ML/Matrix.cs New WrapPrefixNoClone; bounds AddInPlace/Divide by active size.
pwiz_tools/OspreySharp/OspreySharp.Scoring/SpectralScorer.cs Actually uses scratch f32 buffers in PreprocessSpectrumForXcorrInto; extracts PreprocessSpectrumForXcorrF32IntoBuffers.
pwiz_tools/OspreySharp/OspreySharp.Scoring/XcorrScratchPool.cs Adds f32 companion buffers (BinnedF/WindowedF/PrefixF).
pwiz_tools/OspreySharp/OspreySharp.Test/CalibrationTest.cs Cross-OS path test fix using Path.Combine.
pwiz_tools/OspreySharp/OspreySharp.Test/CwtCandidateCodecTest.cs WSL/Linux default test base dir fallback.
pwiz_tools/OspreySharp/Osprey-workflow.html Updates benchmark tables and adds WSL perf section.

Comment thread pwiz_tools/OspreySharp/OspreySharp.FDR/PercolatorFdr.cs Outdated
Comment thread pwiz_tools/OspreySharp/OspreySharp.FDR/PercolatorFdr.cs Outdated
Comment thread pwiz_tools/OspreySharp/OspreySharp.ML/Matrix.cs
Comment thread pwiz_tools/OspreySharp/OspreySharp/Tasks/MergeNodeTask.cs
Comment thread pwiz_tools/OspreySharp/OspreySharp/RescoreHydration.cs Outdated
brendanx67 and others added 26 commits May 19, 2026 21:07
* ParquetScoreCache.WriteScoresParquet now assigns
  entry.ParquetIndex = (uint)i, matching LoadFdrStubsFromParquet's
  convention. Without it, in-memory entries hit the Stage 5
  reconciliation planner with ParquetIndex = 0 and every per-file CWT
  lookup returned the first row's candidates -- planner forced 35K
  entries on Stellar that the HPC path correctly use_cwt'd.
* RescoreCompaction.Apply unions firstPassBaseIds with the base_ids of
  every entry the planner emits a reconciliation action for. The C#
  port of the Rust Option B fix landed this morning; without it the
  worker dropped ~200 cross-file-rescued entries per Stellar file
  with a warn-level log.
* PerFileRescoreTask.Run short-circuits on ExpectReconciledInput
  (only running RescoreCompaction.Apply so MergeNodeTask sees the
  compacted set). Without it --join-at-pass=2 transitively triggered
  FirstJoinTask via EnsureHydrated, which re-ran Stage 5 + Stage 6
  from scratch and tried to load mzML files the merge node does not
  have. Mirrors Rust pipeline.rs:3313-3344.
* Updated TestRescoreCompactionRekeysActionsAndDropsNonpassing to
  TestRescoreCompactionUnionsActionsWithLocalFdrPredicate to assert
  the corrected union behavior.

Verified by new C# in-memory vs C# HPC chain strict-rehydration test
(Compare-Stage7-Rehydration-Strict-CSharp.ps1): Stages 5/6 sidecars
+ reconciled parquets byte-identical and Stage 7 blib + protein FDR
dump match at 1e-9 on both Stellar 3-file and Astral 3-file.

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* VERSION = "26.6.1" in Program.cs aligns the parquet footer metadata + blib OspreyMetadata with Rust upstream
* TODO comment documents that the algorithmic substance of 26.6.1 (reconciliation pairing library-supplied decoys by base_id in compute_consensus_rts + plan_reconciliation) is not yet ported on the C# side. No effect on reverse-decoy mode (Stellar); will affect --decoys-in-library datasets

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* .NET Framework F10 uses round-half-away-from-zero while Rust {:.10} uses round-half-to-even, producing 1-in-the-last-digit (1e-10) print diffs on f64s near a rounding boundary even when the underlying bits are bit-equal
* On Stellar 3-file 466K matches, apex_rt showed 15,739 rows "differing" by exactly ~1e-10 under F10; under F17 every matched row is bit-equal cross-impl

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* Same fix as commit 43100b1 applied to lda_scores dump
* Closes the cross-impl 17-row "1e-10 drift" that was 100% F10-vs-:.10 formatter rounding artifact

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
Coordinated with Rust upstream osprey a44f752. The
PreprocessSpectrumForXcorrInto cache-build path now runs the windowing
and sliding-window cascade on the pooled XcorrScratch.Binned/Windowed/
Prefix double[] fields, narrowing to the per-spectrum float[] cache
only at the final sliding-window store. The cache stays f32 to keep
HRAM memory at 400 KB per spectrum, but values now carry single-cast
precision rather than the f32-cascade noise the prior "imitate Rust
f32" path produced.

* PreprocessSpectrumForXcorrF32IntoBuffers: takes double[] binned /
  windowed / prefix and float[] preprocessed. Binning uses
  Math.Sqrt(float) (implicit widen-before-sqrt) so it bit-equals
  Rust's (intensity as f64).sqrt().
* ApplySlidingWindowDIntoF32: new helper, twin of ApplySlidingWindowD
  with an f32 result array. Final f64->f32 cast at store.
* PreprocessSpectrumForXcorrInto: switches from BinnedF/WindowedF/
  PrefixF (float[] companion fields) to the existing double[] scratch
  fields. Same pool, no new allocation.
* XcorrScratch: removed the float[] BinnedF/WindowedF/PrefixF
  companion fields; no longer needed.
* ApplyWindowingNormalizationF / ApplySlidingWindowF: removed.

Cross-impl results on Stellar Single after the coordinated change:
cal_match xcorr drift 3.876e-6 -> 4.429e-8, Stage 4 .scores.parquet
xcorr at f32 single-cast architectural floor (max 5.41e-7 vs
prior f32-cascade ~1e-6), rt_deviation max 2.32e-11 (LOESS cascade
now sees bit-equal calibration inputs), peak_apex 100% bit-equal.
…sors

PerFileScoringTask wrote 0 for both metadata counts when serializing
the calibration JSON, leaving Rust as the only side with real values
in the file (Rust num_confident_peptides=7361, num_sampled_precursors=
192289 on Stellar Single). This populates both with the same semantics
Rust uses (osprey/src/pipeline.rs:1144-1145):

* NumConfidentPeptides = rtCalibration.Stats().NPoints (LOESS points
  actually used by the fit that was saved; post S/N filter).
* NumSampledPrecursors = pass 1 matchArray.Length (total scored
  matches, pre any q-value/S/N filter). Pass 2 is a refinement using
  the narrowed RT tolerance and does not change the sampled count.

Plumbed via:
* CalibrationPassResult gains MatchCount field, set to matchArray.Length
  inside RunCalibrationScoringPass.
* RunCalibration gains `out int numSampledPrecursors` returning pass 1's
  MatchCount (only populated on the success path; 0 on load-from-cache
  branch since no fresh scoring happens then).
* PerFileScoringTask plumbs the out param into the CalibrationMetadata
  block.

Cross-impl effect on Stellar Single calibration.json comparison: max
absolute diff drops from 1.92e+5 (at num_sampled_precursors) to 1.75e+2
(at ms1_calibration.count), removing the metadata fields from the top
divergence list. Remaining content drift is ms1_calibration.{count,
mean, median, sd, adjusted_tolerance} - separate root cause (ms1_error
collection / mass calibration path divergence).
Mirrors osprey adfbea4. LINQ OrderBy is stable but the upstream input
order can vary cross-impl when discriminant-score-sorted ties differ
by 1 ULP. Sorting by (x, y) with ThenBy ensures duplicate-x positions
get the same indices on both sides, eliminating cosmetic
abs_residuals[i] swaps in calibration.json.
RTCalibration.cs:120 (outer wrapper around LoessRegression.Fit) sorted
by libraryRts ONLY. LoessRegression.cs:258 (inner) sorts by (x, y)
tuple. At duplicate-x positions the two sorts produced different
orders, so the outer y[i] and the inner fitted[i] ended up
corresponding to DIFFERENT data points:

  outer: x[5]=1.93, y[5]=2.31320 (input-order tiebreak)
  inner: sortedX[5]=1.93, sortedY[5]=2.30385, fitted[5] computed
         from sortedY=2.30385

Then `residuals[i] = y[i] - fitted[i]` computed in the outer code at
line 173 became `residuals[5] = 2.31320 - 2.099 = 0.214` instead of
the correct `0.205`, with [5] and [6] swapping cross-impl. Rust is
consistent because it has only ONE sort (rt.rs:123 sorts by (x, y)
tuple in a single place, so y[i] and fitted[i] always correspond).

Fix: outer sort now also uses ThenBy on measuredRts so the two sorts
agree on duplicate-x order.

Cross-impl effect on Stellar Single calibration.json comparison:
max_diff drops 7.04e-1 -> 4.71e-12 (~11 orders of magnitude tighter).
All rt_calibration.model_params.abs_residuals[i] now at f64 epsilon
cross-impl. Remaining cal_json drift is f64 cascade noise in MAD /
abs_residuals from the bisquare-weighted refit (essentially bit-equal).
Two related fixes for cross-impl Stage 3 calibration parity:

1) DiannTsvLoader ParseDouble / ParseFloat / ParseDoubleOrDefault
   switch from double.TryParse to XmlConvert.ToDouble (and the float
   variant). XmlConvert is required by the XML schema spec to be
   IEEE-754 correct on round-trip, whereas .NET Framework 4.7.2's
   double.TryParse can be off by a few ULPs on 16-digit scientific
   values. Rust's str::parse::<f64> is IEEE-correct, so this brings
   the two parsers into bit-for-bit agreement on TSV library values
   like the "400.1277954005887" precursor m/z.

2) cal_scalars dump uses G17 instead of F17 for the f64 fields.
   .NET Framework 4.7.2's F17 format truncates double output at ~15
   significant digits and pads to 17 fractional with zeros, so a
   round-trip "string -> parse -> f64" yields a different f64 than
   the original. G17 prints all 17 significant digits required for
   round-trip-safe representation. Without this fix, rt_min /
   rt_max / mz_min / mz_max / rt_range / mz_range / rt_bin_width /
   mz_bin_width all showed 1-5 ULP cross-impl diffs that were
   actually print-only artifacts.

Cross-impl effect on Stellar Single: CAL_SAMPLE rust_cal_scalars.txt
now PASS bit-equal (was FAIL 5.116e-13). Boundary count: 4 PASS / 5
FAIL -> 5 PASS / 4 FAIL.

The XmlConvert fix is correctness-only on this dataset (the existing
double.TryParse produced the same f64 we have today). It guards
against future TSV inputs that would trigger the .NET Framework
parser's 1-2 ULP corner cases.
Same .NET Framework 4.7.2 print-precision issue we hit in cal_scalars
(commit 11943b1): F17 truncates double output at ~15 significant
digits and pads the rest with zeros, so a string -> parse round-trip
yields a different f64 than the original. G17 prints all 17
significant digits required to round-trip exactly back to the source
f64. Apply the same fix to cal_match and lda_scores dumps.

This eliminates what we previously thought was a real f32 cascade
through XIC intensity reading on the snr column. The cal_match snr
"5.24e-10 ULP floor" at values around 346025 was actually F17
truncating ~15 sig figs and the comparator parsing the truncated
string back to a 9-ULP-off f64. After G17 the snr column is at
6.94e-18 cross-impl (essentially zero - sub-epsilon).

Per-column status on Stellar Single cal_match after this fix:
* apex_rt: 3.55e-15 (was bit-equal artifact, 137 rows at f64 noise)
* correlation: bit-equal (was 4.97e-14)
* libcosine: bit-equal (was 5.55e-16)
* xcorr: 6.94e-18 (was 5.11e-15)
* snr: 6.94e-18 (was 5.24e-10 - this was the F17 artifact)

CAL_MATCH overall max_diff: 5.24e-10 -> 3.55e-15 (5 orders tighter).
LDA_SCORES max_diff essentially unchanged at f64 epsilon.

Boundary count holds at 5 PASS / 4 FAIL. All remaining FAILs now at
single-ULP f64 noise.
Same pattern as DiannTsvLoader (11943b1) - .NET Framework
double.TryParse can be off by 1-2 ULPs on 16-digit values whereas
XmlConvert.ToDouble is required by XML schema spec to be IEEE-correct.
Replaces all 13 double.TryParse callsites in MzmlReader with a
TryParseXmlDouble helper.

Effect on Stellar Single is marginal: .NET Framework 4.7.2's
XmlConvert.ToDouble apparently has the same parser limitation as
double.TryParse for the specific cvParam values that produce the
apex_rt 137-row 2-ULP drift (e.g. "17.0286203070330" still parses to
1 ULP off from Rust's IEEE-correct parse). The fix is still correct
in principle and will be relevant on .NET 5+ migration (where the
underlying parser is IEEE-754 correct) and for any 16-digit cvParam
values that fall in the parser's correctable range.

Cross-impl boundary count unchanged at 5 PASS / 4 FAIL with the
remaining apex_rt 2-ULP drift at the .NET Framework 4.7.2 parser
precision floor for this dataset.
* TukeyMedianPolish.PearsonCorrelationRaw: switched to single-pass moment form to match Rust pearson_correlation_raw and existing PearsonCorrelation.Pearson helper. Brings median_polish_residual_correlation to cross-impl bit-equal.
* ParquetScoreCache.WriteScoresParquet (both overloads): iterate entries in canonical (EntryId, Charge, ScanNumber) order so per-side parquets have identical physical row layout cross-impl. Isolates Stage 5+ from parquet-writer row-order differences.

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
…pass sidecar reload

* ProteinFdr.RunFirstPassProteinFdr static helper extracted from FirstJoinTask so the rehydration path can run the same logic.
* PerFileRescoreTask: in --join-at-pass=2 mode, recompute first-pass protein FDR before compaction (mirrors Rust pipeline.rs:4292 which is gated by !can_skip_fdr || expect_reconciled_input). The recompute is typically a no-op vs the loaded sidecar's RunProteinQvalue on single-file runs, but the call must exist so the compaction predicate sees the same RunProteinQvalue values the in-process pipeline computes.
* MergeNodeTask: re-load .2nd-pass.fdr_scores.bin onto post-compaction stubs before RunProteinFdr. The detected_peptides gate filters on ExperimentPrecursorQvalue, which must be the 2nd-pass value to match Rust's reload-then-second-pass-FDR sequence. Without this, 19 borderline peptides leaked into the C# second-pass parsimony graph on Stellar Single, producing a 1-protein delta in Stage 7 picked-protein output. New OSPREY_DUMP_DETECTED_PEPTIDES env var (mirrors osprey Rust side) for future bisection.

Brings per-side cross-impl Stage 7 + blib to PASS end-to-end on net8.0 Stellar Single with no information sharing between the two implementations.

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
Rust deduplicate_pairs ends with sort_by_key(|e| e.entry_id) (osprey pipeline.rs:6123) with an explicit comment: "Sort by entry_id for deterministic order regardless of HashMap iteration. Without this, the random HashMap order propagates to SVM feature matrix row ordering, causing non-deterministic gradient updates and model weights."

The C# port was returning entries in Dictionary.Values insertion order instead. The rehydration path (--join-at-pass=2) masked this because entries loaded from the canonically-sorted parquet, but the straight-through in-memory path fed unsorted entries straight to Percolator, producing different SVM working-set selection cross-impl and ~190-precursor / ~270-peptide first-pass FDR drift on Stellar Single.

With the matching sort, end-to-end straight-through cross-impl bit-parity at 1e-9 on Stellar Single: every Stage 7 protein FDR column 0 rows differ, every blib table column 0 rows differ.

Drops fully-qualified System.* qualifiers in the MergeNodeTask diagnostic dump that the inspector flagged.

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* ConsensusRts: pair decoys with targets by base_id (entry_id & 0x7FFFFFFF) instead of stripping a "DECOY_" prefix from modified_sequence. Mirrors Rust reconciliation.rs::compute_consensus_rts; per its explicit comment, prefix-strip is fragile (silently misses library-supplied decoys whose modified_sequence has no prefix; can leak decoys whose target instance happens to share a sequence). On Stellar 3-file, the prefix path leaked 16 extra decoys into the cross-file consensus output; with base_id pairing the consensus dump is now bit-equal cross-impl (Stage 6 PASS).

* FirstJoinTask.RunPercolatorFdr: sort each file's entries by EntryId before building the Percolator input. The 1st-pass list is already entry_id-sorted via DeduplicatePairs (AbstractScoringTask), but post-rescore gap-fill entries can be appended after the sorted pre-existing rows. Re-sorting here mirrors Rust pipeline.rs::run_percolator_fdr and gives the SVM working-set selection a canonical iteration order regardless of gap-fill insertion order.

3-file Stellar per-side stages 1-4, 5, 6 now PASS cross-impl. Stage 7 still diverges (2nd-pass Percolator output drifts cross-impl on multi-file inputs in a way these two fixes don't fully close); investigation continues.

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
The comment previously read "Matches the Rust streaming Percolator path." Rust's direct path was patched to add the same dedup step (osprey-fdr/src/percolator.rs::run_percolator), so the C# direct path now mirrors BOTH Rust paths and the inline note documents why the dedup matters statistically (avoids the SVM seeing the same precursor N times in N-file experiments).

No code change in this commit; the dedup itself was already present in the C# direct path.

Co-Authored-By: Claude <noreply@anthropic.com>
* PercolatorEntry.Id now (file, mod_seq, charge, scan) like Rust
* Per-file sort tie-breaks on ParquetIndex after EntryId/Charge/Scan
* perc_input + subsample dumps gain native_position tie-break
* Stage 7 cross-impl Stellar 3-file goes from FAIL to PASS

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* Stellar Windows + WSL timing tables updated after 3-file straight-through bit-equality landed
* Stellar Windows total: Rust 4:29, C# 4:39 (1.04x ~tied)
* Stellar WSL total: Rust 6:01, C# 6:39 (1.11x Rust faster)
* Astral columns unchanged (not re-measured this session)

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* MzCalibration.CalculateSingleCalibration now uses Welford's online
  algorithm for mean and variance, matching the Rust port's switch to
  the same numerically stable formulation. The naive sum/n let the
  running accumulator drift to magnitude ~|n * mean| (~4000 on Astral
  file 49), where f64 ULP is ~5e-13 — enough that the Rust LLVM and
  .NET JIT, vectorising the loop differently, produced 1-ULP-different
  cal mean / sd cross-impl. Welford's loop-carried dependency on mean
  and m2 forces serial scalar execution in both runtimes.
* Adds OSPREY_DUMP_MS2_CAL_ERRORS / OSPREY_MS2_CAL_ERRORS_ONLY env
  vars and a paired WriteMs2CalErrorsDump method that writes
  cs_ms2_cal_errors.txt for cross-impl diffing of the per-fragment
  calibration inputs.
* PerFileScoringTask captures the filtered "contributing matches"
  list and fires the dump when the env var is set.

Verified on Astral single file: all calibration.json scalars
are now bit-equal cross-impl with the Rust port.

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* ProteinFdr.ComputeProteinFdr now carries a SortKey (sorted
  accessions joined by ';') on each ProteinWinner and uses it as
  the cumulative-FDR sort tiebreak instead of GroupId. Matches
  the Rust port's change (osprey commit for the same fix).
* The previous GroupId tiebreak was HashMap-iteration-order from
  BuildProteinParsimony, non-deterministic within C# across runs
  AND across the Rust ↔ OspreySharp boundary. Once upstream
  calibration became bit-equal, the tiebreak started actually
  firing on every score tie and produced group_qvalue divergence.
* The winners.Sort call carries an inline "// Array.Sort OK:"
  exemption documenting why ties are unreachable (accessions
  strings are unique per group by parsimony construction).
* See ai/docs/osprey-development-guide.md "Stable vs unstable
  sort" for the convention.

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* OSPREY_DUMP_STAGE7_WINNERS emits cs_stage7_winners.tsv with rank, score, is_decoy, sort_key, raw_qvalue, monotonic_qvalue
* OSPREY_DUMP_BEST_PEPTIDE_SCORES emits cs_best_peptide_scores.tsv with score, is_decoy, best_qvalue per peptide
* Both env-var-gated, no-op in production. Mirror osprey-fdr/protein.rs additions

Co-Authored-By: Claude <noreply@anthropic.com>
* Removed gf.DecoyEntryId from gapFillIds in PerFileRescoreTask
* Removed decoy branch from forced-integration loop
* Targets are still gap-filled (missing from this file by definition); decoys keep their 1st-pass natural-RT score
* Mirrors osprey pipeline.rs fix; achieves Astral 3-file bit-parity at 1e-9

Co-Authored-By: Claude <noreply@anthropic.com>
* Created OspreySharp.FDR/FdrDiagnostics.cs with WriteStage7WinnersDump and WriteBestPeptideScoresDump (FDR project can't reference OspreyDiagnostics due to layering)
* Moved WriteStage7DetectedPeptidesDump into OspreyDiagnostics with matching DumpDetectedPeptides static bool flag
* ProteinFdr.cs and MergeNodeTask.cs callsites are now one-line gated calls; diagnostic file I/O stays out of production algorithm code
* Caller-side flag check short-circuits the LINQ projection that builds the (Score, IsDecoy) tuple list before the disabled-dump path can pay any allocation cost

Co-Authored-By: Claude <noreply@anthropic.com>
Mirrors the Rust change in osprey/crates/osprey-io/src/mzml/parser.rs
(maccoss/osprey PR #39, commit 9905a2f). Both
ToMs2Spectrum() and the linear convert path now throw an
InvalidDataException naming the spectrum index and the missing
cvParam (MS:1000828 or MS:1000829) instead of substituting the
DEFAULT_ISOLATION_HALF_WIDTH = 12.5 hardcoded default. DIA
processing cannot proceed without true isolation windows, and a
silent default produces bogus results that are very hard to
diagnose downstream.

Removed the now-unused DEFAULT_ISOLATION_HALF_WIDTH constant.

Co-Authored-By: Claude <noreply@anthropic.com>
* Updated Windows + WSL tables with 2026-05-24 single-run timings
* Replaced stale SSD-vs-HDD speculation with measured I/O bench
* Corrected ext4.vhdx location claim (now on D: HDD, not C: SSD)

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md (postscript 18)

Co-Authored-By: Claude <noreply@anthropic.com>
brendanx67 and others added 7 commits May 24, 2026 11:44
* Saves ~21s on Stellar 3-file stage1to4 on the Rust mirror
* ppm-scale mass errors have ~9 digits of headroom for naive sum
* IEEE 754 strict mode prevents JIT vectorisation of dependent sum

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md (postscript 19)

Co-Authored-By: Claude <noreply@anthropic.com>
* Three medians-of-3 tables: Windows C:, WSL /home (ext4), WSL /mnt/c (drvfs)
* /home shows historical Rust 17-21% faster; /mnt/c shows C# at parity
* Caveat block + I/O bench rewritten for current C:-SSD storage layout

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md (postscript 19)

Co-Authored-By: Claude <noreply@anthropic.com>
* True medians via Math.Floor instead of PowerShell banker's-rounding [int]
* C# stage7 now includes second-pass-fdr STAGE-WALL time (was dropped)
* Stage7 ratios now reflect reality: C# 1.07-1.33x slower than Rust

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* Move OSPREY_DUMP_LOESS_INPUT call from RunCalibrationScoringPass to caller
* Pass 1 dump fires unconditionally; pass 2 dump only on R^2 acceptance
* Matches Rust pipeline.rs: dump reflects the calibration actually used
* Preserves OSPREY_LOESS_INPUT_ONLY early-exit after pass 1

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
* PercolatorFdr.cs had a UTF-8 BOM introduced by an editor save that
  also corrupted the two em-dashes in comments at lines 769 and 1824
  into mojibake (Windows-1252-misread UTF-8: U+00E2 U+20AC U+201D).
  Strip the BOM and replace both em-dashes with ASCII '--' to keep the
  file pure ASCII and immune to future encoding round-trips.
* OspreySharp.sln also carried a UTF-8 BOM; strip for consistency
  with the rest of the OspreySharp folder.

Verified via ai/scripts/validate-bom-compliance.ps1 -- the OspreySharp
folder is now clean of unexpected BOMs (Skyline.sln.DotSettings BOM
is outside the OspreySharp tree and pre-exists on master). C# build
still passes.

Co-Authored-By: Claude <noreply@anthropic.com>
…pped dead v1 hydration guard

* MergeNodeTask: log warning when no parquet path mapped or row count < entry count
* RescoreHydration: removed dead envelopeStems.Count>0 guard (v1 rejected at parse)

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
…geNodeTask keys

* ReconciliationFile.Load: reject v2 envelopes with missing/empty file_stems
* MergeNodeTask: warn when perFileEntries keys have no matching config.InputFiles
* IOTest: updated 3 reconciliation fixtures to carry FileStems

See ai/todos/active/TODO-20260516_ospreysharp_wsl_parity.md

Co-Authored-By: Claude <noreply@anthropic.com>
@brendanx67
brendanx67 merged commit 914f0f9 into master May 27, 2026
22 of 24 checks passed
@brendanx67
brendanx67 deleted the Skyline/work/20260516_ospreysharp_wsl_parity branch May 28, 2026 12:52
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.

2 participants