Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7ed29c9
Closed C# Stage 7 algorithmic gap + ported Rust v2 reconciliation env…
brendanx67 May 19, 2026
d2800d7
Fixed three C# in-memory vs HPC chain divergences in Stages 5/6/7
brendanx67 May 20, 2026
9982593
Bumped OspreySharp tracked Rust version to 26.6.1
brendanx67 May 20, 2026
43100b1
Bumped cal_match dump precision F10 -> F17 to match Rust f64 round-trip
brendanx67 May 20, 2026
af7088d
Bumped LDA scores dump precision F10 -> F17 to match Rust f64 round-trip
brendanx67 May 20, 2026
6c17c20
Switched HRAM XCorr preprocess to f64-internal / f32-storage cache
brendanx67 May 20, 2026
e64bb5a
Populated CalibrationMetadata NumConfidentPeptides + NumSampledPrecur…
brendanx67 May 20, 2026
edb159a
LOESS: ThenBy on y for deterministic sort at duplicate library RT
brendanx67 May 20, 2026
3ec9236
Fixed outer/inner sort mismatch in RTCalibration at duplicate library RT
brendanx67 May 21, 2026
11943b1
Cross-impl-safe f64 parsing + cal_scalars F17 -> G17 dump format
brendanx67 May 21, 2026
1b60a78
cal_match + lda_scores dumps: F17 -> G17 for round-trip-safe doubles
brendanx67 May 21, 2026
7b057b1
MzmlReader: use XmlConvert.ToDouble for cvParam values
brendanx67 May 21, 2026
9a72565
Cross-impl bit-equality: align Pearson + sorted parquet write
brendanx67 May 21, 2026
41c1df5
Stage 7 cross-impl parity: port missing first-pass protein FDR + 2nd-…
brendanx67 May 21, 2026
d85a3cb
Sort DeduplicatePairs by EntryId for cross-impl Percolator parity
brendanx67 May 21, 2026
c250ae3
Stage 6/7 cross-impl: pair decoys by base_id + sort Percolator input
brendanx67 May 21, 2026
fa9b2cb
Updated comment on direct-path Percolator best-per-precursor dedup
brendanx67 May 21, 2026
bfd52e0
Stage 6/7 cross-impl: 4-component psm_id + ParquetIndex sort tie-break
brendanx67 May 21, 2026
3b04664
Refreshed Osprey-workflow.html with 2026-05-21 Stellar 3-file perf
brendanx67 May 21, 2026
cb04e27
Added Welford running mean + MS2 cal errors dump to OspreySharp
brendanx67 May 22, 2026
7ed9cf7
Deterministic protein-FDR sort tiebreak (sorted-accessions string)
brendanx67 May 22, 2026
d99e20b
Diagnostics: dump cumulative-FDR winners + per-peptide best scores
brendanx67 May 23, 2026
45c98ea
Excluded decoys from reconciliation gap-fill
brendanx67 May 23, 2026
4576a46
Merge branch 'master' into Skyline/work/20260516_ospreysharp_wsl_parity
brendanx67 May 23, 2026
8da07fb
Refactored cross-impl diagnostic dumps into isolated module
brendanx67 May 23, 2026
89995c9
Fail fast on missing mzML isolation window cvParams
brendanx67 May 23, 2026
9dda719
Refreshed 8-cell perf table + WSL caveat in Osprey-workflow.html
brendanx67 May 24, 2026
f9d873e
Replaced Welford running mean with sum/n in MzCalibration
brendanx67 May 24, 2026
307fb61
Refreshed perf table with median-of-3 on C: SSD + WSL /home + /mnt/c
brendanx67 May 25, 2026
db5a54f
Corrected perf table medians (Get-Median bug + missing C# 2nd-pass-fdr)
brendanx67 May 25, 2026
953b27d
PerFileScoringTask: LOESS dump fires only when pass 2 is accepted
brendanx67 May 25, 2026
fa25950
Address Copilot: strip UTF-8 BOM + replace mojibake em-dashes with ASCII
brendanx67 May 26, 2026
bbb5b19
Addressed Copilot review: warn on 2nd-pass feature reload skips + dro…
brendanx67 May 26, 2026
7d6f871
Addressed self-review: validate v2 file_stems + warn on unmatched Mer…
brendanx67 May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 231 additions & 34 deletions pwiz_tools/OspreySharp/Osprey-workflow.html

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,19 @@ public static LoessModel Fit(double[] x, double[] y, double bandwidth = 0.3,
if (x.Length < 2)
throw new ArgumentException("Need at least 2 data points");

// Sort by x (stable, matching Rust's slice::sort_by). Array.Sort
// with Comparison<T> is unstable (introsort) and reorders ties
// differently than Rust for duplicate x values, causing LOESS
// divergence on data with repeated x (e.g. multi-charge peptides
// sharing a library RT).
// Sort by x with a secondary key on y, so the order is
// deterministic for duplicate x values (e.g. multi-charge
// peptides sharing a library RT). LINQ OrderBy / ThenBy are
// stable, but the upstream input order can vary across impls
// when discriminant-score-sorted ties get filtered at 1 ULP,
// so we cannot rely on input-order tiebreaking for cross-impl
// bit-equality. Matches Rust osprey-chromatography
// calibration/rt.rs which sorts by (x, y) tuple.
int n = x.Length;
int[] order = Enumerable.Range(0, n).OrderBy(i => x[i]).ToArray();
int[] order = Enumerable.Range(0, n)
.OrderBy(i => x[i])
.ThenBy(i => y[i])
.ToArray();

double[] sortedX = new double[n];
double[] sortedY = new double[n];
Expand Down
50 changes: 37 additions & 13 deletions pwiz_tools/OspreySharp/OspreySharp.Chromatography/MzCalibration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -257,27 +257,51 @@ private static MzCalibrationResult CalculateSingleCalibration(double[] errors,

int n = errors.Length;

// Calculate mean
double sum = 0;
// Naive sum/n with consistent left-to-right accumulation.
// Mirror of osprey-chromatography/src/calibration/mass.rs.
//
// For ppm mass-error inputs the running sum stays well below
// the f64 precision wall (worst case ~18 k errors × ~0.22 ppm
// ≈ 4 000, f64 ULP ~5e-13, ~9 digits headroom for the mean —
// well clear of the 1e-9 cross-impl gate).
//
// The caller sorts the error list by (base_id, entry_id)
// before this method runs, matching the Rust caller's
// deterministic ordering. With deterministic order and .NET's
// default IEEE 754 strict mode (no `-ffast-math`, no
// associative re-ordering), the JIT cannot vectorise the
// dependent reduction `sum += errors[i]`, so the loop stays
// serial-scalar and bit-equal to the Rust mirror which is
// similarly constrained by Rust's default IEEE 754 mode.
//
// The earlier Welford-Knuth recurrence was bit-equal to Rust
// but cost ~21 s extra on Stellar Rust stage1to4 (4 ops per
// element vs 1) for stability we did not need at ppm scale.
double sum = 0.0;
for (int i = 0; i < n; i++)
{
sum += errors[i];
}
double mean = sum / n;

// Calculate median
// Sample variance via a second left-to-right walk over
// (x - mean)^2 contributions. Two passes total still beat
// Welford's single-pass 4-op recurrence on per-element cost.
double sumSqDev = 0.0;
for (int i = 0; i < n; i++)
{
double d = errors[i] - mean;
sumSqDev += d * d;
}
double m2 = sumSqDev;

// Calculate median (sort-based, unaffected by the Welford change)
double median = LoessRegression.Median(errors);

// Calculate standard deviation (sample SD)
// Sample standard deviation: sqrt(M2 / (n-1)).
double variance = 0;
if (n > 1)
{
double sumSq = 0;
for (int i = 0; i < n; i++)
{
double d = errors[i] - mean;
sumSq += d * d;
}
variance = sumSq / (n - 1);
}
variance = m2 / (n - 1);
double sd = Math.Sqrt(variance);

return new MzCalibrationResult
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,16 @@ public RTCalibration Fit(double[] libraryRts, double[] measuredRts)
// for each duplicate and the subsequent LOESS fit diverges from
// Rust. LINQ OrderBy is stable and matches Rust.
int n = libraryRts.Length;
int[] order = Enumerable.Range(0, n).OrderBy(i => libraryRts[i]).ToArray();
// Sort by (libraryRt, measuredRt) so the outer x/y arrays match
// the inner sort inside LoessRegression.Fit (which also uses
// (x, y) for duplicate-x determinism). Without the secondary
// key, outer y[i] and inner fitted[i] can end up corresponding
// to different data points at duplicate-x positions, producing
// swapped abs_residuals[i] in the calibration model.
int[] order = Enumerable.Range(0, n)
.OrderBy(i => libraryRts[i])
.ThenBy(i => measuredRts[i])
.ToArray();

double[] x = new double[n];
double[] y = new double[n];
Expand Down
49 changes: 45 additions & 4 deletions pwiz_tools/OspreySharp/OspreySharp.Core/OspreyConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,33 @@ internal static string EscapeForRustDebug(string s)
/// hash invariant to invocation order).
/// </summary>
public string ReconciliationParameterHash()
{
var stems = new List<string>(InputFiles?.Count ?? 0);
if (InputFiles != null)
{
foreach (var path in InputFiles)
{
string stem = Path.GetFileNameWithoutExtension(path);
if (!string.IsNullOrEmpty(stem))
stems.Add(stem);
}
}
return ReconciliationParameterHashForStems(stems);
}

/// <summary>
/// Compute the reconciliation parameter hash for an explicit set of
/// file stems. Used by per-file Stage 6 rescore workers, whose
/// <see cref="InputFiles"/> only carries this worker's single
/// parquet — the hash that the downstream <c>--join-at-pass=2</c>
/// merge node expects is computed over ALL files in the join, so
/// the worker must read the full set from the planner's
/// <c>reconciliation.json</c> envelope and pass it in here. The
/// stems are sorted + deduped internally so the hash is invariant
/// to caller ordering. Mirrors Rust
/// <c>OspreyConfig::reconciliation_parameter_hash_for_stems</c>.
/// </summary>
public string ReconciliationParameterHashForStems(IReadOnlyList<string> fileStems)
{
using (var sha256 = SHA256.Create())
{
Expand All @@ -401,17 +428,31 @@ public string ReconciliationParameterHash()
// Mirror Rust's `format!("file_stems:{:?}\n", stems)` output
// exactly. {:?} on Vec<String> yields ["a", "b"] with the
// brackets and double-quoted, comma-space-separated values.
var stems = new List<string>(InputFiles?.Count ?? 0);
if (InputFiles != null)
// Stems are sorted + deduped here so the hash matches the
// Rust side, which also sorts + dedups before hashing.
var stems = new List<string>(fileStems?.Count ?? 0);
if (fileStems != null)
{
foreach (var path in InputFiles)
foreach (var stem in fileStems)
{
string stem = Path.GetFileNameWithoutExtension(path);
if (!string.IsNullOrEmpty(stem))
stems.Add(stem);
}
}
stems.Sort(StringComparer.Ordinal);
// Dedup in place (stems is sorted, so duplicates are
// adjacent). Rust does `dedup()` on a sorted Vec; same here.
int write = 0;
for (int read = 0; read < stems.Count; read++)
{
if (read == 0 || !string.Equals(stems[read], stems[read - 1], StringComparison.Ordinal))
{
stems[write++] = stems[read];
}
}
if (write < stems.Count)
stems.RemoveRange(write, stems.Count - write);

var stemsList = new StringBuilder("[");
for (int i = 0; i < stems.Count; i++)
{
Expand Down
125 changes: 125 additions & 0 deletions pwiz_tools/OspreySharp/OspreySharp.FDR/FdrDiagnostics.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/*
* Original author: Brendan MacLean <brendanx .at. uw.edu>,
* MacCoss Lab, Department of Genome Sciences, UW
* AI assistance: Claude Code (Claude Opus 4.7) <noreply .at. anthropic.com>
*
* Based on osprey (https://github.com/MacCossLab/osprey)
* by Michael J. MacCoss, MacCoss Lab, Department of Genome Sciences, UW
*
* Copyright 2026 University of Washington - Seattle, WA
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

// Cross-impl bisection dumps for FDR-level diagnostics. Functions in this
// class are env-var-gated by static bools (evaluated once at class load)
// and no-op in production runs. They write small TSVs to the current
// working directory so they can be diffed against the Rust osprey-fdr
// crate's matching dumps in osprey-fdr/src/diagnostics.rs.
//
// This is a per-project diagnostics class for OspreySharp.FDR; the
// top-level project has its own OspreyDiagnostics (which cannot be
// referenced from here due to layering). Naming kept distinct
// (FdrDiagnostics vs Diagnostics in OspreySharp.Core) to avoid
// collision when both namespaces are imported.

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using pwiz.OspreySharp.Core;

namespace pwiz.OspreySharp.FDR
{
public static class FdrDiagnostics
{
private static bool IsOne(string name)
{
return Environment.GetEnvironmentVariable(name) == @"1";
}

/// <summary>
/// OSPREY_DUMP_STAGE7_WINNERS: dump the full cumulative-FDR winners
/// list (target + decoy together) to cs_stage7_winners.tsv after the
/// sort in ComputeProteinFdr. Columns: rank, score, is_decoy,
/// raw_qvalue, monotonic_qvalue. The existing
/// WriteStage7ProteinFdrDump (in OspreyDiagnostics) emits only target
/// winners' scores; decoy-winner scores are not exposed there,
/// hiding cross-impl divergences driven by decoy-winner scores or
/// sort-position interleaving in the cumulative sweep.
/// </summary>
public static readonly bool DumpStage7Winners = IsOne(@"OSPREY_DUMP_STAGE7_WINNERS");

/// <summary>
/// OSPREY_DUMP_BEST_PEPTIDE_SCORES: dump the per-modseq aggregated
/// best-score map from CollectBestPeptideScores to
/// cs_best_peptide_scores.tsv. Surfaces the protein-FDR input set so
/// upstream aggregation divergences (e.g. different per-peptide max
/// scores from compaction asymmetry) can be diffed directly.
/// </summary>
public static readonly bool DumpBestPeptideScores = IsOne(@"OSPREY_DUMP_BEST_PEPTIDE_SCORES");

/// <summary>
/// Write cs_stage7_winners.tsv. Caller passes a (score, is_decoy)
/// tuple list in sort order plus the parallel q-value arrays. Check
/// <see cref="DumpStage7Winners"/> first to skip the LINQ projection
/// on the disabled-dump path.
/// </summary>
public static void WriteStage7WinnersDump(
IList<(double Score, bool IsDecoy)> winners,
double[] rawQvalues,
double[] monotonicQvalues)
{
const string path = @"cs_stage7_winners.tsv";
var inv = CultureInfo.InvariantCulture;
using (var sw = new StreamWriter(path))
{
sw.WriteLine("rank\tscore\tis_decoy\traw_qvalue\tmonotonic_qvalue");
for (int i = 0; i < winners.Count; i++)
{
sw.WriteLine(string.Format(inv, "{0}\t{1}\t{2}\t{3}\t{4}",
i,
Diagnostics.FormatF64Roundtrip(winners[i].Score),
winners[i].IsDecoy ? "true" : "false",
Diagnostics.FormatF64Roundtrip(rawQvalues[i]),
Diagnostics.FormatF64Roundtrip(monotonicQvalues[i])));
}
}
}

/// <summary>
/// Write cs_best_peptide_scores.tsv. Rows sorted by
/// modified_sequence for stable cross-impl diff.
/// </summary>
public static void WriteBestPeptideScoresDump(Dictionary<string, PeptideScore> best)
{
const string path = @"cs_best_peptide_scores.tsv";
var inv = CultureInfo.InvariantCulture;
var keys = new List<string>(best.Keys);
keys.Sort(StringComparer.Ordinal);
using (var sw = new StreamWriter(path))
{
sw.WriteLine("modified_sequence\tscore\tis_decoy\tbest_qvalue");
foreach (var seq in keys)
{
var ps = best[seq];
sw.WriteLine(string.Format(inv, "{0}\t{1}\t{2}\t{3}",
seq,
Diagnostics.FormatF64Roundtrip(ps.Score),
ps.IsDecoy ? "true" : "false",
Diagnostics.FormatF64Roundtrip(ps.BestQvalue)));
}
}
}
}
}
Loading
Loading