diff --git a/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs b/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs
index 3f9788bb8..7c07dc323 100644
--- a/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs
+++ b/src/Common/CommonStandard/DataObj/Result/MsScanMatchResult.cs
@@ -75,6 +75,48 @@ public float ReverseDotProduct {
[Key(13)]
public float AcurateMassSimilarity { get; set; }
+ /// The value the scoring functions return when there was nothing to compare.
+ private const float NotCompared = -1f;
+
+ ///
+ /// True when the isotope-ratio comparison was attempted, so holds
+ /// a measurement.
+ ///
+ ///
+ /// MsScanMatching.GetIsotopeRatioSimilarity returns -1 when there is nothing to compare: either
+ /// side carries no isotopic peaks, or a monoisotopic abundance is not positive. Unlike the dot
+ /// products there is no clamping getter, so that -1 reaches every consumer unchanged.
+ ///
+ /// The test is equality with the sentinel rather than a sign test, because this score is 1 minus
+ /// an accumulated ratio difference and is genuinely signed: a negative value ordinarily means the
+ /// isotope patterns disagree, which is a measurement and a strong one. Only exactly -1 is the
+ /// sentinel, and a computed value of exactly -1 is indistinguishable from an unattempted
+ /// comparison. That ambiguity is resolved in favour of "not computed", because publishing a
+ /// sentinel as a measurement is the worse of the two errors, and it is narrow: reaching it needs
+ /// the accumulated difference to land on exactly 2.
+ ///
+ [IgnoreMember]
+ public bool IsIsotopeComparisonPerformed => IsotopeSimilarity != NotCompared;
+
+ ///
+ /// True when holds a measurement from one of the Gaussian
+ /// similarity terms: retention time, retention index, collision cross-section or accurate mass.
+ ///
+ ///
+ /// A different rule applies to these than to , because a
+ /// different function produces them. MsScanMatching.GetGaussianSimilarity returns
+ /// exp(-0.5 * ((actual - reference) / tolerance)^2), which is strictly positive for any finite
+ /// argument until it underflows past roughly 38 tolerance widths, and returns -1 when either
+ /// value is missing or not positive.
+ ///
+ /// So a measured term is positive, a term with nothing to compare is -1, and a term the run never
+ /// enabled is left at the field's default 0. Exactly 0 is therefore either that unset default or a
+ /// difference so large the score underflowed, and neither is a measurement worth publishing. This
+ /// is the same test GetTotalScore already applies before adding a term to the total, so the export
+ /// convention and the scoring convention agree.
+ ///
+ public static bool IsGaussianTermMeasured(float similarity) => similarity > 0f;
+
// Link to database
[Key(14)]
public int LibraryID { get; set; } = -1;
diff --git a/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs b/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs
index 61831ce41..a023c3435 100644
--- a/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs
+++ b/src/MSDIAL5/MsdialCore/Export/AlignmentCandidateExporter.cs
@@ -155,15 +155,18 @@ private void WriteCandidate(
BooleanText(candidate.IsSpectrumMatch),
BooleanText(spectrumScored),
Format(candidate.TotalScore),
- // The five similarity terms below have no equivalent of the -1 sentinel that marks an
- // unattempted spectral comparison, so an unused term is stored as 0 and cannot be told apart
- // from a term that was evaluated and scored 0. They are written as stored; a consumer that
- // needs the distinction has to read which terms the run's parameter file enabled.
- Format(candidate.AcurateMassSimilarity),
- Format(candidate.RtSimilarity),
- Format(candidate.RiSimilarity),
- Format(candidate.CcsSimilarity),
- Format(candidate.IsotopeSimilarity),
+ // These five do carry sentinels, and two different ones, because two different functions
+ // produce them. The four Gaussian terms are positive when measured, -1 when there was nothing
+ // to compare, and left at the field's default 0 when the run never enabled the term, so only
+ // a positive value is a measurement. The isotope term is 1 minus an accumulated ratio
+ // difference and is genuinely signed, so a negative value there IS a measurement -- it says
+ // the patterns disagree -- and only exactly -1 is the sentinel. Both rules live on
+ // MsScanMatchResult so the scoring side and the export side cannot drift apart.
+ GaussianTerm(candidate.AcurateMassSimilarity),
+ GaussianTerm(candidate.RtSimilarity),
+ GaussianTerm(candidate.RiSimilarity),
+ GaussianTerm(candidate.CcsSimilarity),
+ candidate.IsIsotopeComparisonPerformed ? Format(candidate.IsotopeSimilarity) : NotApplicable,
Score(spectrumScored, candidate.SimpleDotProduct),
Score(spectrumScored, candidate.WeightedDotProduct),
Score(spectrumScored, candidate.ReverseDotProduct),
@@ -190,6 +193,9 @@ private static void WriteRow(StreamWriter writer, string[] values)
private static string Score(bool computed, float value)
=> computed ? Format(value) : NotApplicable;
+ private static string GaussianTerm(float similarity)
+ => MsScanMatchResult.IsGaussianTermMeasured(similarity) ? Format(similarity) : NotApplicable;
+
///
/// The stored score fields are Single, and G9 round-trips a Single exactly. The widening to double is
/// deliberate and matches : .NET Framework formats a Single through
diff --git a/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs b/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs
index 53c0ed035..373ae06d5 100644
--- a/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs
+++ b/tests/MSDIAL5/MsdialCoreTests/Export/AlignmentCandidateExporterTests.cs
@@ -40,11 +40,14 @@ public void EveryCandidateIsExportedOnceInRankOrder()
Assert.AreEqual(3, lines.Length);
Assert.AreEqual(
"12\t10\t-1\t1\t2\ttrue\tmsp\t\tMspDB\t1\t7\tQuercetin\tC15H10O7\tflavonoid\tQUERCETIN-KEY\tc1cc(O)ccc1\t" +
- "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.75\t0.5\t0\t0\t0\t0\t0.5\t0.25\t0.75\t12\t0.5",
+ // rt, ri and ccs are empty: this run never enabled those terms, so the 0 sitting in the field
+ // is the unset default rather than a measurement. The isotope 0 is written, because only
+ // exactly -1 is that term's sentinel and 0 there is a real ratio agreement.
+ "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.75\t0.5\t\t\t\t0\t0.5\t0.25\t0.75\t12\t0.5",
lines[1]);
Assert.AreEqual(
"12\t10\t-1\t2\t2\tfalse\tmsp\t\tMspDB\t1\t8\tMorin\tC15H10O7\tflavonoid\tMORIN-KEY\tc1cc(O)ccc1\t" +
- "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.5\t0.5\t0\t0\t0\t0\t0.5\t0.25\t0.75\t12\t0.5",
+ "300.125\t2.5\t[M+H]+\t430\ttrue\tfalse\ttrue\ttrue\ttrue\t0.5\t0.5\t\t\t\t0\t0.5\t0.25\t0.75\t12\t0.5",
lines[2]);
}
@@ -98,6 +101,57 @@ public void AnUncomparedSpectrumPublishesNoSpectralScore()
Assert.AreEqual("0.5", fields[25], "the aggregate total score is still a measurement");
}
+ ///
+ /// The isotope term has its own sentinel and its own rule. -1 means the comparison was never
+ /// attempted; every other negative value is a measurement saying the patterns disagree.
+ ///
+ [TestMethod]
+ public void AnUnattemptedIsotopeComparisonPublishesNoIsotopeSimilarity()
+ {
+ // GetIsotopeRatioSimilarity returns -1 when either side carries no isotopic peaks. Unlike the
+ // dot products there is no clamping getter, so the -1 reaches the export unchanged. On one real
+ // reference library 495 of 4972 candidate rows carried it, all of them in-house records with no
+ // isotopic pattern deposited.
+ var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f);
+ candidate.IsotopeSimilarity = -1f;
+
+ var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t');
+
+ Assert.AreEqual("", fields[30], "isotope_similarity");
+ }
+
+ [TestMethod]
+ public void AnIsotopePatternThatDisagreesIsAMeasurement()
+ {
+ // 1 minus an accumulated ratio difference, so this is genuinely signed. Discarding it would
+ // throw away the strongest negative evidence the term produces.
+ var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f);
+ candidate.IsotopeSimilarity = -0.75f;
+
+ var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t');
+
+ Assert.AreEqual("-0.75", fields[30], "isotope_similarity");
+ }
+
+ ///
+ /// The four Gaussian terms follow the opposite rule, because a different function produces them.
+ ///
+ [TestMethod]
+ public void AGaussianTermPublishesNothingUnlessItWasMeasured()
+ {
+ var candidate = ReferenceMatch("Quercetin", libraryId: 7, totalScore: 0.5f);
+ candidate.AcurateMassSimilarity = 0.5f; // measured
+ candidate.RtSimilarity = 0f; // the run never enabled retention-time scoring
+ candidate.RiSimilarity = -1f; // attempted, nothing to compare
+ candidate.CcsSimilarity = 0.25f; // measured
+
+ var fields = ExportLines(SpotWith(candidate), Refer())[1].Split('\t');
+
+ CollectionAssert.AreEqual(
+ new[] { "0.5", "", "", "0.25" },
+ new[] { fields[26], fields[27], fields[28], fields[29] });
+ }
+
[TestMethod]
public void AnUnannotatedSpotContributesNoRow()
{