Report an uncomputed annotation score as null in both text exports - #785
Conversation
The analysis (.mdpeak) and alignment (.mdalign) exports disagreed on how to
represent an annotation score that was never computed. For a precursor-only
suggestion, named "no MS2: " because MS2RawSpectrumID is negative, .mdalign
wrote null while .mdpeak wrote 0.000, even though both wrote the explicit
not-applicable marker -1 into Matched peaks count and Matched peaks
percentage of the same row.
Neither value was a decision. Two sibling base classes each declare their own
ValueOrNull helper, and the two call sites differ by one null-conditional
operator. BaseAnalysisMetadataAccessor passes matchResult?.SimpleDotProduct,
so the argument is float? and binds the nullable overload, which returns
"null" only when the whole match result is null. BaseMetadataAccessor passes
matchResult.SimpleDotProduct, so the argument is float and binds an overload
that returns "null" for any value within 1e-10 of zero.
Underneath, MsScanMatching returns -1 from GetSimpleDotProduct and its
siblings when there is nothing to compare, the same condition that returns
{-1, -1} from GetMatchedPeaksScores. Before the squared-metrics rename in
#589 that -1 reached both exports and matched the -1 in the matched-peak
columns. #589 made the dot products derived properties clamped with
Math.Max(squared, 0f), which turned the sentinel into 0. So .mdpeak began
reporting a score for a comparison that never happened, and .mdalign's eps
rule began reporting null for a reverse dot product that a real comparison
had produced as 0.
Both exports now take the five shared score columns from one decision in
AnnotationScoreFormat: null when the score was never computed, and the value
otherwise, including an exact 0. "Compared and scored zero" is a weaker claim
than "no comparison was possible", and only the first is a measurement.
MsScanMatchResult.IsSpectrumComparisonPerformed reads the sentinel from the
raw squared fields, where it survives, and also excludes an Unknown result
and a TextDB result, whose annotator never scores a spectrum because a text
database holds no reference spectrum. DimsMspAnnotator scores through
Ms2MatchCalculator, which collapses the sentinel to Ms2MatchResult.Empty
before it reaches the match result, so the formatter additionally treats an
entirely unset score block as uncomputed when the peak or spot carries no
product-ion spectrum at all. That second test can never discard a computed
score, because a computed score implies a spectrum was present.
The selector reads the score as a double on purpose. .NET Framework formats a
Single through a 7-significant-digit intermediate, so 0.59349996 snaps to
0.5935 and "F3" rounds it up to 0.594 instead of down to 0.593. Widening
matches what .mdpeak already did and is the accurate rounding of the stored
value; a float selector changed two demo cells for no benefit.
This changes .mdpeak content for precursor-only and text-database rows, and
.mdalign content for rows whose comparison genuinely scored zero. ADR 0003
records the decision, the measured demo diff and the migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
CI note: the That is the only hard error in the log; Local evidence for this branch instead, all passing:
The FastLC demo comparison in the description was produced with that Release/net48 Console. |
There was a problem hiding this comment.
🟡 Changes recommended
The new unit tests assume dot-decimal formatting without forcing invariant culture, which can make the suite non-deterministic under non-English locales.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR standardizes how uncomputed annotation score columns are represented across the LC-MS text exports, ensuring .mdpeak and .mdalign both emit null only when a spectral comparison was never performed (and preserve a computed exact 0).
Changes:
- Centralized shared score-column formatting in
AnnotationScoreFormat.Score()and updated both analysis and alignment exporters to use it. - Added
MsScanMatchResult.IsSpectrumComparisonPerformedto distinguish “never computed” from “computed as zero”, including a TextDB exclusion. - Added unit tests and an ADR documenting the decision and historical context.
File summaries
| File | Description |
|---|---|
| tests/MSDIAL5/MsdialCoreTests/Export/AnnotationScoreRepresentationTests.cs | Adds coverage asserting consistent null/0 behavior across .mdpeak and .mdalign. |
| src/MSDIAL5/MsdialCore/Export/IMetadataAccessor.cs | Switches .mdalign shared score columns to the centralized formatter. |
| src/MSDIAL5/MsdialCore/Export/IAnalysisMetadataAccessor.cs | Switches .mdpeak score columns (including enhanced/entropy) to the centralized formatter. |
| src/MSDIAL5/MsdialCore/Export/AnnotationScoreFormat.cs | Introduces the single formatting decision point for “not computed” vs “computed value”. |
| src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs | Adds IsSpectrumComparisonPerformed predicate used by exporters/formatter. |
| docs/adr/0003-uncomputed-annotation-score-representation.md | Documents the rationale, decision, and known divergences left out of scope. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…tation-score-null
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Problem
.mdpeakand.mdaligndisagreed on how to represent an annotation score that was never computed. On the FastLC demo, a precursor-only suggestion (namedno MS2:becauseMS2RawSpectrumID < 0) came out as:Both wrote
Matched peaks count = -1.00andMatched peaks percentage = -1.00on the same row, which is an explicit not-applicable marker. A consumer reading0.000as a value concludes that a spectral comparison was performed and returned zero similarity, which is materially stronger than "no comparison was possible".Why they differed
Neither value was a decision.
BaseAnalysisMetadataAccessorandBaseMetadataAccessoreach declare their ownValueOrNull, and the two call sites differ by one null-conditional operator, so overload resolution picks a different helper:.mdpeak.mdalignValueOrNull(matchResult?.SimpleDotProduct, "F3")ValueOrNull(matchResult.SimpleDotProduct, "F3")float?floatValueOrNull(double?, string)ValueOrNull(float, string)value?.ToString(f) ?? "null"Math.Abs(v) > 1e-10 ? v.ToString(f) : "null"Underneath,
MsScanMatching.GetSimpleDotProductand its siblings return-1when there is nothing to compare — the same condition that makesGetMatchedPeaksScoresreturn{-1, -1}. Before #589 that-1reached both exports and agreed with the matched-peak columns. #589 made the dot products derived properties clamped withMath.Max(squared, 0f), which turned the sentinel into0. From 5.5.250625 on,.mdpeakreported a score for a comparison that never happened..mdalign's eps rule is lossy the other way. Demo alignment ID 264,low score: NAGly 11:0,MS/MS assigned = True: Simple 0.007, Weighted 0.230, Reversenull, Matched peaks countnull. A real comparison produced 0 and.mdaligndiscarded it. So "null means never computed" described neither export.Change
Both exports now take the five shared score columns from one decision in
MsdialCore/Export/AnnotationScoreFormat.cs:nullwhen and only when the score was never computed; the value otherwise, including an exact 0.MsScanMatchResult.IsSpectrumComparisonPerformedholds the result-side test — reads the sentinel from the rawSquared*fields where it survives, and excludes anUnknownresult and aTextDBresult (a text database holds no reference spectrum, so its annotator never scores one).DimsMspAnnotatorscores throughMs2MatchCalculator, which collapses the sentinel toMs2MatchResult.Emptybefore it reaches the match result, so the formatter additionally treats an entirely unset score block as uncomputed when the peak or spot carries no product-ion spectrum. That second test cannot discard a computed score.The selector reads the score as
doubledeliberately: .NET Framework formats aSinglethrough a 7-significant-digit intermediate, so0.59349996snaps to0.5935and"F3"rounds it up to0.594instead of down to0.593. Widening matches what.mdpeakalready did and is the accurate rounding of the stored value.docs/adr/0003-uncomputed-annotation-score-representation.mdrecords the decision, the history, the divergences deliberately left in place, and the measured diff.Release note
.mdpeakand.mdalignchange content. A score that was never computed is nownullin both files..mdpeakdot products.mdpeakmatched peaks.mdaligndot products.mdalignmatched peaks0.0000.00null→0.000null→0.000.000→null-1.00→nullnull-1.00→null0.000→null0.00→nullnullnullnullnullnullnullA parser that read
-1as the not-applicable marker must now also acceptnull. MS-DIAL Interactive is updated in systemsomicslab/msdial-interactive-app#11 to read either notation.Validation
Console built Release/net48 from this branch at
tests/MSDIAL5/MsdialCoreTestApp/bin/Release/net48/MSDIALCUI.exe, the path Interactive uses for a local source build. A baseline Console was built the same way fromorigin/masterin a separate worktree, so every differing cell is attributable to this change.MsdialGuiAppalso builds Release/net48 with-p:SkipLibraryDownload=true; without that switch MSBuild fails onMSB3923because Zenodo answers 403 for the bundled.lbm2, which is not a compilation failure.Determinism control. The baseline Console ran the demo twice and produced byte-identical
.mdpeakand.mdalign; only the timestamp-derivedMTD mzTab-IDline differed. The checked-in demo output used as the reference is itself byte-identical to that baseline, so the comparison has no confounds.Run A — the demo's own
method.txt, LBM annotation, 7 SCIEX WIFF files, 13,193.mdpeakrows and 2,512.mdalignrows:.mdpeak×70.000→null, matched peaks-1.00→null.mdalign-1.00→null; 68 Simple, 74 Weighted, 74 Reverse, 74 Matched peaks count, 71 Matched peaks percentage cells of low-score rows recovered as0.000/0.00; one reference-matched row recovered a Weighted dot product.mdmsp(per-file and alignment),.qa.tsv.mzTabMTD mzTab-IDlineNo cell outside the five score columns changed in any artefact.
The recovered reference-matched row is the clearest case for the decision:
Run B — same method plus
lib\MSMS-Public_all-neg-VS19.mspandlib\20200121_MsdialTxtDB_Neg_EquiSPLASH_rapid.txt, to reach the text-database case with real data. Same shape, plus 2–6 text-database rows per.mdpeak(LPC 18:1(d7),SM 18:1;2O/18:1(d9), annotation tag 530) moving from0.000/0.00tonull. Those rows do carry a product-ion spectrum, so only theTextDBtest catches them.Downstream ingest. Both Run A outputs were ingested into a scratch
msdial_spectrum_catalogdatabase:annotation_kindprecursor_onlylow_scoremsms_matchedThe catalog already normalized
precursor_onlyexact-0.0dot products and negative matched-peak values to NULL, so that normalization becomes a no-op. Thelow_scorefigures are the measurements the eps rule was discarding.Tests. New
tests/MSDIAL5/MsdialCoreTests/Export/AnnotationScoreRepresentationTests.csasserts the representation of all four annotation outcomes plus a text-database annotation in both formats, that the two formats agree cell for cell on every outcome, and that the sentinel is visible only in the squared fields.Suites run, all passing: MsdialCoreTests 302, CommonStandardTests 833, MsdialLcMsApiTests 66, MsdialDimsCoreTests 33, MsdialImmsCoreTests 56, MsdialLcImMsApiTests 52, MsdialGcMsApiTests 6, MsdialCoreTestAppTests 9.
Deliberately out of scope
Recorded in the ADR, not changed here:
.mdscanwrites the string-1through its ownNegativeIfNullhelper — a third convention..mdalignoverridesFragment presence %asMatchedPeaksPercentage * 100, so the sentinel renders as-100.0. Its dot-product and Matched peaks count columns do come fromBaseMetadataAccessorand so do follow this ADR.matchResult.SimpleDotProductwith no not-applicable handling, soid_confidence_measurereads0for a precursor-only row.ResultExport.csformats the same properties as{0:0.00}— a fifth convention.0.000, becauseMs2MatchCalculatordiscarded the sentinel. Fixing that changes DI-MS scoring input and needs its own validation.Separate finding, filed as #786
MsReferenceScorer.CalculateScoreguards the MS/MS term of the total score withresult.WeightedDotProduct >= 0 && result.SimpleDotProduct >= 0 && result.ReverseDotProduct >= 0. Since #589 clamped those getters to 0 the guard can no longer fail, so the MS/MS term is now always added — including for a precursor-only candidate whoseMatchedPeaksPercentageis-1. That is whyTotal scorereads-0.143for ano MS2:row instead of being computed from mass and RT similarity alone. This changes candidate ranking, not just formatting, so it is a scoring question and is not touched here.IsSpectrumComparisonPerformedgives that guard a correct expression when it is addressed.🤖 Generated with Claude Code