Skip to content

Record what each annotation rests on, and make every output say the same thing - #800

Open
htsugawa wants to merge 26 commits into
masterfrom
feature/annotation-evidence-record
Open

htsugawa wants to merge 26 commits into
masterfrom
feature/annotation-evidence-record

Conversation

@htsugawa

Copy link
Copy Markdown
Contributor

An annotation evidence record for MS-DIAL 5: what was compared, what the
comparison was worth, which library it was against, and which build produced it.
26 commits, each with its reasoning in the message and a mutation check.

1,805 tests pass, 0 fail, 17 skip.

The record

Two members on MsScanMatchResult, written where the facts are known:

  • MeasuredTerms — which similarity terms were actually computed, recorded at
    the measurement site rather than inferred later from a value. IsSpectrumMatch
    and its siblings say a term passed a threshold; a term that was never computed
    also reports false there, and conflating the two is what this ends.
  • AnnotationEvidenceSource — nine members covering what the evidence was, from
    ReferenceSpectrum down to UnmatchedSpectrum.

Plus the candidate population: how many were found, passed threshold, and were
reference-matched, so a reader can tell a discarded fourth candidate from there
having been only three references in the library.

Defects this found and fixed

  • Retention time was scored against references that have none. The 3-argument
    GetGaussianSimilarity does not guard, so a library record with no RT scored
    against −1. Fixed, with the match caps the author set (2 min; 150 Kovats /
    50,000 Fiehn).
  • A direct-infusion score was divided by terms nobody measured. DimsMspAnnotator
    averaged a fixed three terms, and a feature with no product-ion spectrum got two
    fabricated zeros from Ms2MatchResult.Empty — so a precursor mass worth 1.0 was
    published as 0.33, below any cutoff above a third.
  • Candidate priority was quantitative all the way down, so a name suggested by
    precursor mass alone could beat a name a spectrum decided, on the strength of a
    larger number. An evidence rank now sits above the score in all three ordering
    keys — an order, not a weight, so it cannot be bought at any score.
  • The Console could export a run that processed nothing. NumThreads / 2 with
    no floor gave zero workers at number of threads: 1, and the export stage then
    either crashed or — when the raw data already carried .pai files from an
    earlier run — silently exported the previous run's peaks and Peak IDs under
    this run's parameters, library list and version string.
  • Twenty-seven method-file parameters had no effect, silently, including
    Only report top hit for LBM-based annotation, Sigma window value and
    Replace true zero values with 1/2 of minimum peak height. Reported now, not
    fatal — failing would reject every method file in existence, MS-DIAL's own
    templates first.

One name in all four files

The peak table, the alignment table, mzTab-M and the exported spectra are joined
by peak ID, and that held. What did not is the field a person reads first: mzTab-M
alone stripped the low score: / no MS2: prefixes. AnnotationName.Canonical
is now the single rule at every exit, and what the prefix used to say travels as
its own field — where it says more, since the prefix collapsed "compared and fell
short" with "compared and explained nothing".

Exported spectra now carry the evidence beside PEAKID in COMMENT, so a
spectrum handed to MS-FINDER, ICEBERG or SIRIUS no longer leaves behind what
MS-DIAL had already established about it.

Which build, and which library

MsdialBuildIdentity replaces three hand-maintained .resx literals that had
drifted apart and gone stale — the GUI 5.5.250403-beta, the Console 5.5.241113
frozen since November 2024 while two hundred commits landed, and MsdialCore still
4.24, which was ParameterBase's default. Main version plus build date for
display, commit for provenance; the commit id was already compiled in by
SourceLink and nobody had read it. The update dialog no longer tells a developer
their newer build is out of date.

A library that no DOI names can now say what it is without saying where it lives:
record count, compound count and a digest of the records the run actually
searched, published in mzTab-M custom[n].

DESKTOP-382ETUR\Hiroshi Tsugawa and others added 26 commits September 9, 2026 14:35
MsScanMatchResultHelper.AreEqual is the only field-by-field round-trip
assertion for MsScanMatchResult, and it was three serialized members behind:
CollisionEnergy, EnhancedDotProduct and SpectralEntropy carry [Key(36..38)] and
were compared by nothing.

Extending it was not enough. SaveAndLoadTest, the only test that exercises it,
builds its fixture from `new MsScanMatchResult()` and sets Source and TotalScore
alone, so the other assertions compared a default against a default and could
not fail. The witness existed and watched nothing -- a member added with a
duplicate or omitted [Key] would have round-tripped untested and the suite would
have stayed green.

So the fixture now carries a distinct non-default value for all thirty-eight
serialized members, and a reflection check asserts that it does. That check is
the part worth keeping: it fails when a new [Key] member is added above without
being given a value, which is the moment the omission is cheap to fix rather
than the moment it silently costs a round trip.

This lands before the members it exists to protect, so the helper's own edit is
proven separately from what comes next.

1466 tests pass across the ten suites, against 1465 before. No expected value
was edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four MessagePack keys on MsScanMatchResult, written by nothing yet and read by
nothing at all: MeasuredTerms says which similarity terms were computed,
EvidenceSource says what kind of evidence the annotation rests on, and the two
counts say how many candidates this annotator scored for the peak and how many
passed its thresholds. Keys 39-42; key 25 is a hole that predates this
repository and stays a hole.

The evidence source is a new enum rather than a SourceType bit. SourceType is a
[Flags] byte with two free bits for five evidence kinds, and it already answers
several unrelated questions -- which database, whether a person intervened,
whether anything matched -- for the ranking key, the exporters and the
deserialization strip. It is also not IsReferenceMatched, which reads as "a
spectrum matched" and is not: the text-database annotators set it on precursor
m/z alone and a manual acceptance sets it on anything a person accepted. That
boolean keeps its meaning and its value; this records the evidence beside it.

The candidate count is the member that justifies the commit. The annotation
processes keep only the best few candidates, so the size of the population is
destroyed before any file exists, and without it "the run could not discriminate
between candidates" cannot be told from "the run never reported more than a
handful". Whether the list was truncated is left to the reader to derive,
because the cap differs between the annotation processes and one flag would be
wrong for most of them.

The two counts are int? rather than int = -1, and finding out why was the
useful part. MessagePack's generated deserializer assigns default(T) to every
key absent from a short array -- it does not fall back to the C# property
initializer, contrary to what one might reasonably assume from reading the type,
where LibraryID = -1 and its siblings suggest otherwise. So -1 does not survive
a project written before key 41 and 0 does, which would have every such project
reporting that zero candidates were scored. A new backward-compatibility test
caught it by re-encoding a genuine payload at the old length, and now pins both
directions: an older project loads and asserts no evidence, and a payload from a
future build is skipped past rather than throwing.

The .dcl path is untouched and cannot be reached by this change: its record size
comes from an explicit list of members, not from reflection over the type.

1469 tests pass across the ten suites, against 1466 before. No expected value
was edited. The WPF GUI and the net48 Console both build with no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every place that computes a similarity term now says which terms it
computed, at the point it computes them. No value changes and no branch
is taken differently; nothing reads the record yet.

The reason to record it at the measurement site rather than infer it
afterwards is that the evidence is destroyed on the way out. The
dot-product getters clamp the not-computed -1 to 0, so a spectrum that
was never opened is indistinguishable from one that scored 0.
Ms2MatchResult.Empty is worse: the calculator returns it precisely when
the scoring functions gave -1, and it holds 0 in every spectral field,
so Assign writes a plain 0 with nothing to mark it. Both now carry an
explicit flag instead.

Recorded in nine places -- MsReferenceScorer, MassAnnotator, the
Imms/Lcimms/Lcms initializers, the four IMatchResult.Assign paths, the
shared MsScanMatching leaf that every basic and EI comparison funnels
through, and the target-formula path in IsotopeTracking. The DIMS
annotators need no change of their own: they compose their results from
the Assign paths. The composition rule lives once, in
MeasuredTermsExtension, because these nine sites differ only in which
terms their acquisition mode has and an omission in one leaves that
whole mode's evidence blank.

Two rules, not one, because MsScanMatching has two GetGaussianSimilarity
overloads. The four-argument one returns the sentinel when either side
is missing. The three-argument one does not: it evaluates the
exponential against whatever it is given, so a reference with no
retention time gets a similarity computed against 0 and the value keeps
no trace of the absence. Most annotators call that one, so for them the
flag is set from the inputs. That means the flag deliberately says "not
measured" where the stored similarity is non-zero. The flag is the true
statement; making the value agree moves scores, so it is a separate
change.

41 witnesses across six test assemblies, one per measurement site,
asserting facts and not values. Checked against three mutations of the
recording rule -- never record, record unconditionally, and Assign
records nothing -- each of which turns the relevant assemblies red. The
first pass of that check found the mode-specific tests were exercising
only the enabled/disabled guard, so each mode also got a case where the
term is enabled and there is nothing on the reference side to compare.

All ten suites green: 1510 passed, 2 skipped, up from 1469. WPF GUI and
net48 Console build with no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EvidenceSource is now written at every site that produces an annotation.
No value changes and no branch is taken differently; nothing reads it yet.

Sixteen sites, found by sweeping the repository from five independent
angles rather than by grepping for the constructor. Two of them a
constructor grep cannot find: CalculateMatchScore.CalculateMatches gets
its result back from MsScanMatching and overwrites two members, and it
is the single funnel for the whole GC-MS mode; DimsMspAnnotator needs
its own assignment even though the previous commit's term recording
reached it for free, because a calculator sees a query and a reference
and cannot know what kind of database the reference came from.

The lipid path records RuleBased, not ReferenceSpectrum. Its .lbm2
spectra are generated in silico, and the spectral comparison is a
permissive pre-filter rather than the evidence -- ValidateBase combines
the three dot products with OR for TargetOmics.Lipidomics where
metabolomics uses AND, the default cut-offs are low, and
ValidateOnLipidomics then requires a characteristic-ion rule to have
fired. The rules in MsmsCharacterization establish both whether the
match stands and at what structural level. Keyed on TargetOmics, which
is already a field at every MSP site, so no DataBaseSource plumbing is
needed -- which is as well, because MassAnnotationSettingModel hard-codes
DataBaseSource.Lbm on a branch shared with plain MSP.

The values describe what was compared, not what the database holds. An
MSP candidate for a feature with no product-ion spectrum is
PrecursorOnly. The alternative reading would let a row be labelled "a
reference spectrum was compared" while its own name carried the "no MS2:
" prefix that AnnotationName already applies to exactly that case, and
it would contradict the programme's own policy wording, in which a
reference match outranks a precursor-only suggestion. Which database a
name came from is a separate question that Source already answers.

Manual gets exactly one site: marking a peak unknown. A person accepting
a candidate does NOT relabel it. The object they accept is the
annotator's own instance, stored by reference with no clone anywhere in
the GUI write path, so stamping Manual there would overwrite the only
record of what was compared in exchange for a fact SourceType.Manual and
IsManuallyModified already carry. Producer owns, acceptance does not.

MS-FINDER's reflect-to-MSDIAL records PredictedSpectrum. Today an
in-silico fragmenter result enters the molecule database and reaches the
container with IsReferenceMatched true, indistinguishable from an
experimental match; Source there is bare Manual, so the human step was
recorded twice and the in-silico step nowhere. That site also filled
RtSimilarity and RiSimilarity without recording the terms, which the
previous commit missed; recorded here.

Two blanks are deliberate and reasoned in the code: MsScanMatching
records nothing, because its leaf serves metabolomics, lipidomics, EI
and proteomics alike and CompareEIMSScanProperties is also how
GcmsPeakJoiner compares two sample spectra during alignment, where there
is no reference at all; and the .dcl reader and writer record nothing,
because that block has a fixed byte layout.

Known limitation, pinned rather than hidden: the .dcl annotation block
carries only numeric scores, IDs and booleans -- no strings, not even
Name -- so the GC-MS file process, which saves and reloads through it,
discards the evidence record. ADclRoundTripDiscardsTheEvidenceSource
asserts that loss so it is a named fact.

30 further witnesses, one per site per mode, including the first test
anywhere to reach a GeneratedLipid scorer. Checked against two mutations
of the recording rule -- ignore the omics, and drop the spectrum guard --
each of which turns all five mode projects red.

All ten suites green: 1615 passed, 2 skipped, up from 1510. WPF GUI and
net48 Console build with no errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The candidate counts are now written at every site that stores an
annotation from a searched population. No value changes and no branch is
taken differently; nothing reads them yet.

This is the one fact in the evidence record that cannot be recovered
from anything else. NUMBER_OF_ANNOTATION_RESULTS is 3, the IMMS and
proteo-metabolomics processes keep one, GC-MS keeps five -- so the size
of the candidate population is destroyed before any file exists, and
"the run could not discriminate between forty isomers" has been
indistinguishable from "the run reports at most three".

Three tiers, not two, on the developer's own suggestion: filtering the
threshold-passing list by IsReferenceMatched is exactly what
SelectReferenceMatchResults computes, it is free, and neither figure
survives truncation. So key 43 joins 41 and 42, and the differences give
what the annotation policy distinguishes -- rejected outright, precursor
-only suggestions, MS/MS reference matches. A related finding is written
into key 42's remarks: FilterByThreshold applies no threshold of its own.
It is IsAnnotationSuggested || IsReferenceMatched, and
MsScanMatchResultEvaluator's constructor ignores its search parameter
entirely, so the cut-offs were applied inside the annotator.

Nine sites across six processes. CandidatePopulation carries the rule
because more than one population is live at once in three of them: the
EAD lipid process runs a molecular-species query and then a
generated-lipid query per factory, and giving one population's numbers to
the other's results would be a false record nothing downstream could
detect. Constructing it as a value forces the author to name which one
they mean, and it takes the run's own IsReferenceMatched predicate rather
than reading the property, so the count cannot disagree with the
selection the caller goes on to make.

No .ToList() was needed after all: IMatchResultFinder.FindCandidates is
declared to return List<TResult>, so Count() is O(1) everywhere. That
matters more than economy at one site -- EadLipidAnnotator.FindCandidates
reaches EadLipidDatabase.Generates, which assigns ScanIDs and registers
references, so a second call to count would change existing values, not
merely double the work. The comment there says so. GC-MS is the one
exception and does materialise, because CalculateMatches yields.

Two contract corrections after review, both to statements that would
have misled the first consumer:

- The counts are per (peak, annotator, product-ion spectrum), NOT per
  (peak, annotator) as key 41 previously claimed. AIF acquisition runs
  the same annotator once per collision energy, and LC-IM-MS once per
  drift peak, so one container legitimately holds several populations
  from one annotator. Consumers must group by (AnnotatorID, SpectrumID)
  and take a distinct value, never a sum.
- An annotator that scored candidates and named none of them stores no
  result, so its population has no carrier and is unrecordable here, not
  merely unrecorded. Any total over visible rows is a lower bound of
  unknown looseness. Stated on key 41 and on the test that pins the
  arithmetic for that case.

Also corrects this branch's own previous commit, which overstated the
.dcl limitation: GC-MS does keep the evidence record. The primary store
is MessagePack through AnnotatedMSDecResult's formatter, and that is the
copy GcmsAnalysisMetadataAccessor exports and DataObjConverter merges
into alignment. The .dcl block is a second, lossy copy -- it carries no
strings at all, not even Name -- reached only through the legacy
MSRawID2MspBasedMatchResult dictionary.

Deliberately blank, each with the reason in the code: the GUI
compound-search dialogs (no threshold runs, and the MS-scan dialog scores
the whole library rather than a window, so a count there would be a third
meaning for one field); the target-formula matcher in IsotopeTracking
(the search runs the other way round, windowing spots around a query);
the peptide path, on the developer's instruction that proteomics
annotation criteria differ categorically; and the dead LC-IM-MS process,
which is also the only place candidates.Count would be a post-filter
number.

9 witnesses, checked against three mutations -- record nothing, read the
property instead of the predicate, and confuse the population with the
named subset -- each of which turns both levels red. The first pass of
that check found the process-level test could not catch the third,
because the mock's FilterByThreshold passed everything through and made
the two numbers equal; it now filters as the real evaluator does.

All ten suites green: 1547 passed, 2 skipped. WPF GUI and net48 Console
build with no errors.

Correcting the record: the previous commit's message says 1615 passed and
30 witnesses. Both were arithmetic errors of mine. That run was 1538
passed with 28 new witnesses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
No production code in this commit. It records what MS-DIAL does today at
the four places phase 1b will change, so that each change is a visible,
deliberate edit to a named assertion rather than a silent movement in
output. Every test passes now and is expected to fail when its fix
lands; each names the correct value on the assertion, and the set is
findable with --filter TestCategory=PinnedDefect.

The retention-time term, which the author has confirmed was never
intended. A reference with no retention time carries
ChromX.RetentionTime.Default, whose value is -1, and the unguarded
three-argument GetGaussianSimilarity evaluates the exponential against
it as though it were a measurement. Two findings beyond what was
expected. The fix has TWO sites, not one: IsRtMatch is computed
separately by a Math.Abs comparison in ValidateBase, so guarding the
Gaussian alone would leave the verdict wrongly true -- and it is true
here. And the damage runs the opposite way from the obvious guess: the
score is an average over the included terms with the MS/MS term weighted
3, so a fabricated retention-time term DILUTES it. 1.939 becomes 1.613.
A reference with no retention time is penalised, by an amount that
depends on how early the peak eluted. With a five-minute tolerance the
fabricated similarity is 0.96.

The text-database verdict. A text-database hit is marked
IsReferenceMatched on precursor-mass agreement alone, and the evidence
record added in the preceding commits sits on the same object saying
EvidenceSource is PrecursorOnly and no spectrum was compared. The
contradiction is pinned as a single assertion, so what fails when the
flag is fixed is the contradiction itself. LC-MS is asserted because it
is the campaign's mode; the other three text-database annotators behave
the same way and each already has an evidence witness in its own
project, so a fix must visit all four.

The export divergence, which is live and was not what the plan
described. A genuine total score of exactly zero is written as "0.000"
by the analysis file and as "null" by the alignment file. The cause is
overload resolution, which is why centralising the spectral columns in
AnnotationScoreFormat did not catch it: IMetadataAccessor's
ValueOrNull(float) and ValueOrNull(double) both return "null" below
1e-10, while ValueOrNull(double?) tests only for null -- and the two
accessors differ by one question mark, matchResult?.TotalScore against
matchResult.TotalScore. The analysis file is right by this codebase's own
rule, since TotalScore is always computed. Reachable in production:
scores.DefaultIfEmpty().Average() is exactly 0 when no term was
included. SharedScoreColumns in that test class already omits "Total
score", so the existing agreement test does not cover it.

The truncation cap, which is characterization rather than a defect. Four
threshold-passing candidates, a cap of three, and the lowest-scoring
reference match is discarded leaving no other trace -- so this also
demonstrates what the candidate counts recover: each stored row reports
four above threshold while three rows exist. It will fail if the cap or
the ordering changes, which is the point.

One correction to my own work here: the text-database contradiction test
was first written to fail today, which would have left the suite red and
hidden real regressions. Inverted, with the post-fix form in the comment.

All ten suites green: 1560 passed, 2 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A MoleculeMsReference with no retention time holds ChromX.RetentionTime.Default,
whose value is -1. Two places treated that -1 as a measurement: the unguarded
three-argument GetGaussianSimilarity, which evaluated exp(-0.5*((rt - -1)/tol)^2)
on it, and a separate Math.Abs comparison in each annotator's ValidateBase, which
set IsRtMatch on the same grounds. An early-eluting peak therefore agreed, on
retention time, with a reference that had none -- and agreed more strongly the
earlier it eluted. Confirmed as unintended by the author, 2026-09-10.

The damage was not mainly the total score. DataAccess.GetAnnotationCode reads
IsRtMatch and nothing else to promote 430 (m/z + MS/MS matched) to 330 (RT +
MS/MS matched), and that code is written to the "Annotation tag (VS1.0)" column
of every analysis and alignment export. The fabricated agreement published a
claim of retention-time confirmation for references that had no retention time
to confirm. The score movement was a side effect by comparison, and it went the
unintuitive way: MsReferenceScorer averages the included terms and the MS/MS term
carries a factor of 3, so the extra term diluted the average and LOWERED the
score, penalising such references by an amount that depended on how early the
peak eluted.

The rule is an exemption, not a rejection, and RetentionMatchPolicy states it
once. Simply forcing IsRtMatch false would have deleted these candidates rather
than demoting them: IsReferenceMatched and IsAnnotationSuggested share the
retention clause, FilterByThreshold is their disjunction, and
StandardAnnotationProcess stores only what that returns. In a library where only
some entries carry retention times -- the case this was reported for -- every
entry that does not would have vanished from the output, taking its m/z and MS/MS
evidence with it. MeasuredTerms.RetentionTime, added in the preceding commits,
is what supplies the third state a boolean cannot hold: set with IsRtMatch true
means agreed, set with false means disagreed, clear means no comparison was
possible. Only the middle case may withhold a verdict.

MassRtReferenceSearcher gets the same exemption on the filtering side, where the
behaviour used to flip on the tolerance: a narrow window silently dropped every
reference without a retention time, while the 100-minute default admitted them
all and handed them to the unguarded Gaussian. The mass range has already bounded
that set, so exempting them costs nothing.

Sites: MsReferenceScorer (LC-MS MSP, lipidomics, EAD), LcmsTextDBAnnotator,
LcimmsMspAnnotator, LcimmsTextDBAnnotator, and the two verdicts re-derived after
scoring in LcmsMspAnnotator and EadLipidAnnotator.

Not touched. GC-MS needs no change: CompareBasicMSScanProperties, the single
funnel for every EI comparison, already calls the guarded four-argument overload
for both retention time and retention index. Collision cross section has the same
shape and is deliberately left alone. LcmsFastaAnnotator is left alone because
proteomics annotation criteria are separate by decision.

FabricatedRetentionTimeTermTests pinned the old behaviour and named the correct
value on every assertion; all four of its defect assertions failed on this change,
with the predicted values (RtSimilarity -1, IsRtMatch false, and the two totals
converging on 1.93876). It is replaced by MissingReferenceRetentionTimeTests,
which asserts those values plus the confidence code staying at 430, the candidate
surviving FilterByThreshold, and -- as the guard against an over-broad exemption
-- a reference whose retention time genuinely disagrees still being rejected.

12 suites: 1606 passed, 0 failed, 5 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A search tolerance is not a match criterion. RtTolerance says which references
are worth scoring and its default is 100 minutes; the verdict says whether the
retention times actually agree. Ticking "use retention time for scoring" while
leaving that default meant every reference in the library agreed on retention
time, which promoted the exported confidence code from 430 to 330 (and 440 to
340 on the GC-MS branch, where DatasetParameterSettingModel turns retention
scoring on by default). Searching a predicted-retention-time library loosely is
reasonable; publishing "retention time matched" on the strength of that search is
not.

So the verdict is capped and the score is not. The Gaussian keeps the user's
tolerance as its width, which is what stops this from silently re-ranking
existing projects: TheGaussianStillUsesTheUsersTolerance pins RtSimilarity at
0.99501 for a 10-minute difference under a 100-minute tolerance, the value it had
before this change and 5 orders of magnitude away from what a capped width would
give. Total scores do not move; verdicts and confidence codes do, which is the
intent.

Caps, chosen by the author, 2026-09-10:
  retention time    2 minutes
  Kovats  (alkane)  150 index units -- one carbon is 100 units and about 1.1-1.3
                    minutes, so this is the same 2 minutes
  Fiehn   (FAME)    50,000 index units -- that scale is FAME retention in
                    milliseconds, near 39,350 units per carbon, so it is the same
                    order as 150 Kovats units

The two index scales differ by a factor of about 390, so RiCompoundType is a
required constructor argument on CalculateMatchScore rather than a defaulted one.
That choice immediately earned itself: the compiler found a fifth construction
site in the Console app that a default would have left silently on the wrong
scale.

Sites: the four LC verdicts now call RetentionMatchPolicy.IsRetentionTimeMatch,
which also folds in the preceding commit's guard. GC-MS takes the cap through a
new optional argument on CompareEIMSScanProperties, applied before that function
derives IsReferenceMatched, so nothing has to be recomputed afterwards; the
default of MaxValue leaves every other caller alone, notably the peak joiners,
which compare two acquired spectra rather than annotating.

A consequence worth stating. Unlike a reference with no retention time, which is
exempt, a reference that has one and disagrees is rejected -- and since
IsReferenceMatched and IsAnnotationSuggested share that clause, rejected outright
rather than demoted to a suggestion. That is not new behaviour; it is what
already happened to anyone who set a realistic tolerance. What is new is that the
default no longer exempts everyone from it.

Not addressed: RiTolerance defaults to 20, which is a sensible Kovats window and
20 milliseconds on the Fiehn scale. Same unit ambiguity, separate defect, noted
in the test remarks.

Mutation-checked. Widening the retention-time cap to 1000 fails 3 tests; making
the Fiehn cap equal the Kovats one fails 2, including the behavioural one; and
dropping the cap argument at the CalculateMatchScore call site fails 2, so the
wiring is held and not only the constants.

12 suites: 1618 passed, 0 failed, 5 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Use retention time for scoring" and "use retention time for filtering" mean
different things: scoring ranks candidates, filtering rejects them. The code had
the scoring setting rejecting. IsReferenceMatched and IsAnnotationSuggested
shared the retention clause, MsScanMatchResultEvaluator.FilterByThreshold is
their disjunction, and StandardAnnotationProcess stores only what that returns --
so a candidate whose retention time disagreed was not demoted, it was deleted,
with nothing left in the file for a reader to judge and no trace that a reference
of that mass had been considered.

IsAnnotationSuggested no longer carries the retention clause. That is the shape
MassAnnotator and DimsMspAnnotator always had, so this makes the retention-using
annotators consistent with the ones that do not use it. A retention-time
disagreement now costs the reference match and leaves the precursor-only
suggestion standing, which is what the evidence supports.

This matters most for the preceding commit's cap. Capping the 100-minute default
to 2 minutes, on top of the old shared clause, would have emptied the annotation
output of every project that had ticked the box. It now lowers those rows instead.

LcmsTextDBAnnotator and LcimmsTextDBAnnotator had no suggestion path at all --
ValidateCore set IsReferenceMatched and left IsAnnotationSuggested at its default
-- so for them a disagreement was always fatal. They get one. That is the path a
retention-time-anchored text database is used for in the public-repository
campaign, and a text-database row is precursor-only evidence by construction, so
a suggestion is exactly what it is. This is unrelated to the separately deferred
question of whether such a row should be called a reference match at all
(TextDbVerdictWithoutASpectrumTests), and does not touch it: with retention
scoring off, every mass match is still a reference match and no new suggestion
appears.

Collision cross section is left alone deliberately, keeping its clause on both
verdicts. ImmsMspAnnotator is the precedent -- the mobility axis gates
suggestions there too -- and only the retention-time behaviour was asked to
change. LcmsFastaAnnotator is untouched: proteomics criteria are separate by
decision.

Also corrects three remarks blocks that described the old shared-clause
behaviour, including a <see cref> left dangling by a test rename in this series.

Mutation-checked. Putting the clause back on MsReferenceScorer's suggestion fails
3 tests; removing the text-database suggestion path fails 2, including the one
that asserts FilterByThreshold keeps the row.

12 suites: 1624 passed, 0 failed, 5 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keys 39-43 have been recorded and serialized since the preceding commits but no
export column existed for any of them, so the evidence record reached no file.
This adds five columns -- Measured terms, Evidence source, Candidates found,
Candidates above threshold, Candidates reference matched -- to both text formats
in every mode except proteomics.

Headers and content keys go in together and cannot be split. A header with no
dictionary key throws KeyNotFoundException the moment an exporter indexes the
content by header name; a key with no header is silently dropped. The two live in
different methods, and in this tree they inherit differently: `base.GetContentCore`
has ten callers, while `base.GetHeadersCore` has none at all -- every concrete
accessor replaces the header array. So the content is added once in each base and
the header list is edited in all thirteen places.

Every column is supplied by an accessor. No exporter and no decorator learns the
new names, and that is what keeps proteomics out of it: ProteomicsBaseAccessor
implements IMetadataAccessor directly rather than deriving from the base, and it
is handed to the same AlignmentCSVExporter as everyone else, so a column appended
inside an exporter would demand a key it cannot supply and throw on every
proteomics alignment export. Appending in the accessors avoids that entirely.

Position is append-at-the-end, which is forced rather than chosen: LcmsMethodModel
inserts "Ion mode" at a hard-coded index 34 with no name check, and appending is
the only placement that leaves 34 meaning "MS/MS spectrum".

AnnotationEvidenceFormat is the one place the rendering is decided, beside
AnnotationScoreFormat and using the same "null" token. Three traps it exists to
avoid, each with a test: MeasuredTerms.None and AnnotationEvidenceSource.Unspecified
stringify to confident-looking words and must not be published as facts about runs
that merely predate key 39; a count of 0 is a measurement and only null is
not-recorded, which an accidental trip through ValueOrNull would destroy because
two of its four overloads return "null" for anything within 1e-10 of zero; and
Enum.ToString() on a multi-bit [Flags] value yields a comma, which
AlignmentLongCSVExporter writes unquoted, so a CSV long export would gain phantom
columns. Flags are joined with '|'.

THE PARITY TEST IS THE POINT OF THIS COMMIT AS MUCH AS THE COLUMNS. Updating ten
pinned expected-header arrays turns the suite green whether or not a value ever
reaches a file, so each accessor test now also asserts that every header has a key
and every key has a header, with an explicit declaredDrops list that must carry a
reason. It does not forbid dropping a key; it forbids dropping one by accident.

It found a real defect on its first run. "Enhanced dot product" and "Spectrum
entropy" are computed for every analysis row by BaseAnalysisMetadataAccessor and
listed in its own header array, but all four mode accessors replace that array and
omit them -- so they have been computed and thrown away on every .mdpeak export in
LC-MS, LC-IM-MS, DIMS and IMMS for as long as they have existed. Declared, not
fixed: adding a column changes four modes' published column sets, which is a
compatibility decision that does not belong here. The GC-MS alignment accessor
drops ten more, all with reasons; those are deliberate.

The two GC-MS accessors had no test at all and now do. GcmsAnalysisMetadataAccessor
is the only accessor whose header array and content dictionary are both hand-written
and local, so it is where a mismatch was most likely to reach a user.

Not in this commit: mzTab-M, which reads six fixed keys and declares its own column
lists, so opt_ columns there are a separate interchange decision; the per-candidate
.mdcandidate.tsv sidecar, which uses the opposite absent-value convention -- the
formatter is split into decision and rendering so it can reuse the decision;
ResultExport, whose header and value lists are coupled positionally with no guard
and which is dead code; and any reconciliation between MeasuredTerms and the
existing sentinel-inference predicates. On that last point, worth knowing: the
record is reported ALONGSIDE the inference, not made its source of truth, because
switching the score columns to read the flag would move already-published cells and
would be wrong for every project saved before key 39. So a row can show a blank
rt_similarity, because the Gaussian underflowed, while Measured terms lists
RetentionTime. That is one-directional and it is the flag being right.

Mutation-checked: removing one content key from the base fails 4 tests naming the
column, removing one header from one mode accessor fails 2, and making a zero count
render as not-recorded fails 4 across the formatter, both bases and the
cross-format comparison.

12 suites green: 1648 passed, 0 failed, 5 skipped. That run was made in a working
tree that also carried an unrelated concurrent change, worth 7 of those tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Below threshold" was one bucket. At a 70% cut-off a candidate scoring 69% and a
candidate that matched two fragments out of forty were recorded identically, and
that is what makes the cut-off feel arbitrary: the cliff sat between "named" and
"nothing" rather than between two grades of evidence. AnnotationEvidenceSource
gains WeakSpectrumMatch and UnmatchedSpectrum, appended so stored values do not
move, and a compared reference spectrum now lands in one of three tiers.

NO NEW THRESHOLD IS INTRODUCED, deliberately -- the retention-time work in this
series was caused by exactly one unexamined constant. For metabolomics the line
between "fell short" and "explained nothing" is the analyst's own
MinimumSpectrumMatch, the number their acceptance criteria already use, with a
floor of one fragment for the case where they have set it to zero: a comparison
that matched no fragment at all explained nothing by any reading.

FOR LIPIDOMICS THE PEAK COUNT IS NOT CONSULTED AT ALL. A single diagnostic
fragment can settle a lipid class outright -- cholesteryl ester is the example
the author gave -- so a count near zero is entirely compatible with a correct
class assignment, and asking the count there would demote a correct annotation to
"explained nothing". The characteristic-ion rules are asked instead, which is
consistent with those rules being the evidence on that path rather than the
permissive dot-product pre-filter in front of them.

WHY THIS IS A SECOND, LATER CALL rather than a change to the existing assignment.
The verdict it reads -- IsSpectrumMatch and the lipid rule flags -- is not set
until validation, and in MassAnnotator, ImmsMspAnnotator, LcimmsMspAnnotator and
MsReferenceScorer validation runs in the CALLER of the method that produces the
result, so grading at the production site would read flags that are not there
yet. Moving six assignments and hoping none was missed fails unsafely: a missed
site would leave the record blank. RecordSpectrumVerdict only ever downgrades an
already-recorded value, so a site that never calls it keeps exactly the value it
had before this commit -- wrong in the old way, never blank. Missing a site costs
precision, not the record, and SpectrumVerdictTierTests holds the wiring for the
path that matters most.

The contract of key 40 widens from "what was compared" to "what was compared and
what it supports", and nothing is lost by it: the narrower question is answered
exactly by MeasuredTerms.Spectrum on the same record. Read as a pair the two keys
say what was measured and what the measurement was worth.

WHAT THIS IS FOR, in the author's words, 2026-09-10. A precursor-mass match with
NO product-ion spectrum acquired can legitimately be reported -- at class level
for a lipid, since chains cannot be resolved without MS2. A precursor-mass match
WITH a spectrum that was acquired and failed is, by his criteria, unknown unless
retention time supports it. Those are opposite verdicts and the suggestion
machinery had been treating them as one bucket, both wearing a "no MS2:" or
"low score:" prefix and both landing in IsAnnotationSuggested. The evidence record
now separates them, so a consumer can apply either criterion; the displayed name
is left alone, which was the author's choice, so no existing project loses a name.

The Evidence source column added in 9a8a45d carries the new tiers to file with
no further change.

Not in this commit: the GC-MS EI path, which reaches evidence through
WhenSpectrumCompared and has its own acceptance criteria; the proteomics path,
separate by decision; and the mzTab-M writer, which is a published-format change
and is being put to the author separately.

Mutation-checked: dropping the wiring call in MsReferenceScorer fails 3 of the 5
integration tests, and letting lipidomics fall through to the peak count fails 3
unit tests including the cholesteryl-ester case by name.

12 suites green: 1664 passed, 0 failed, 5 skipped, in a tree that also carries an
unrelated concurrent change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mzTab-M's chemical_name held whatever MS-DIAL had put in the feature name,
including the suggestion prefixes "no MS2: " and "low score: ". It now holds a
compound name, and the status goes to opt_global_evidence_source and
opt_global_measured_terms, where a reader can act on it instead of parsing it out
of a name -- and where it says more than the prefix could, since the prefix
collapsed "compared and fell short" and "compared and explained nothing" into one
word.

A CORRECTION TO WHAT I REPORTED EARLIER, and the reason this is not the change I
described. I said the pipe in a dual lipid name is manufactured in the
not-matched branch, so taking the last segment silently drops the "no MS2"
warning for lipids. That is true of ONE of the five sites that write a pipe.
MsScanMatching.GetRefinedLipidAnnotationLevel writes "class|chains" only at
annotation level 2 or above -- only when the characteristic-ion rules RESOLVED
the chains -- and returns the bare class name at level 1. MsReferenceScorer
writes the identical punctuation inside its if (!result.IsSpectrumMatch) branch,
where the chain half is copied off the reference and nothing measured it. Same
string, opposite evidence, so no rule phrased on the text can be right for both,
and the blanket "take the first segment" I was heading for would have demoted
every correctly chain-resolved lipid.

IsLipidChainsMatch can tell them apart, and it is set from the same call that
produces the name (MsScanMatching.cs:463-471), so the two cannot disagree.
AnnotationName.AtSupportedLevel takes the chains when the chains were resolved
and the class level when they were not. That is the author's rule of 2026-09-10 --
a lipid identified on precursor mass alone is "PC 34:1", not "PC 16:0_18:1" --
implemented from the record rather than from punctuation, and it leaves matched
lipids untouched.

The evidence gate on the SME section stops re-implementing the suggestion
vocabulary as a substring. It tested !Name.Contains("no MS2"), which caught one of
the three spellings: a "low score" spot -- spectrum acquired, compared, below the
criteria -- passed every gate and received a full rank-1 evidence row with
ms_level 2 and a complete score set, with nothing in the row saying it had failed,
and the peptide path's "w/o MS2" passed too. The gate now asks the evidence
record: a weak match keeps its row, because a comparison that partly agreed is
what the evidence section is for; a comparison that explained nothing, and one
that never happened, have nothing to cite. Projects written before the record
existed keep their old answer through the Unspecified arm, with the one
information-free correction that the test is a prefix rather than a substring and
covers "w/o MS2".

The normalisation and the vocabulary live in AnnotationName, which exists for
exactly this reason -- its own remarks say the writers and the reader are kept
together so a renamed prefix cannot leave a reader stale, and the pipe is part of
the same vocabulary.

Two opt columns, not five. The candidate counts describe how many alternatives
there were, and mzTab-M already says that in the ranked SME rows.

THE GOLDEN FIXTURE WAS NOT ENOUGH, and the mutation testing is what showed it.
Reverting the name normalisation left the byte-compared golden entirely green,
because the checked-in project contains no prefixed name, no dual lipid name and
no evidence record. The golden is regenerated here -- the diff is exactly the two
new columns and their values, all "null", which is itself the backward-compatible
arm working on a real pre-record project -- and five behavioural tests now cover
what it cannot. With those, reverting the normalisation fails 2 and restoring the
substring gate fails 1.

12 suites green: 1676 passed, 0 failed, 5 skipped, in a tree that also carries an
unrelated concurrent change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PredictedSpectrum described structure-to-spectrum prediction and was set by
neither producer that used it. MS-FINDER, its only producer of consequence, runs
the other way: measured peaks go in and a candidate structure comes out. CFM-ID
and FIORA are the tools the old name actually described, and MS-DIAL has no
annotator for them. The name was backwards.

It becomes two members. ByStructurePredictionTool keeps the serialized value 3 --
renamed, not renumbered -- and is what MS-FINDER sets. BySpectrumPredictionTool
is new at 8 and is what the peptide path sets, because PeptideMsReference.Spectrum
is a b/y ladder computed from a sequence by SequenceToSpec: a structure went in
and a spectrum came out, which is the other direction.

They are kept apart because they fail differently. A spectrum predictor can be
wrong about how a compound that is genuinely present fragments; a structure
predictor can propose a compound that was never there. Those are not the same
risk and an annotation record that merges them cannot express either.

OUTSIDE MS-DIAL THEY ARE ONE TAG. The evidence inventory this programme publishes
against records in-silico assignment as a single category, so the exported
Evidence source column says "InSilico" for both, and which tool it was is already
on the record beside it in AnnotatorID. Two members inside, one tag outside,
decided by the author of MS-DIAL on 2026-09-14.

No stored value moves: 3 keeps its meaning for the producer that set it, and 8 is
appended. The only path whose stored value changes is the proteomics one, which
moves from 3 to 8 -- correcting a misclassification rather than migrating data,
and no project on this branch exists outside this work.

Not addressed: where the two sit in candidate ordering. That is the next commit,
and the author has settled it -- below WeakSpectrumMatch, since a comparison
against real measured peaks outranks one against a generated spectrum.

12 suites green: 1679 passed, 0 failed, 5 skipped, in a tree that also carries an
unrelated concurrent change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Candidate priority was quantitative all the way down: three orderings, and
below the verdict flags each of them fell through to TotalScore. So a name
suggested by precursor mass alone could beat a name a spectrum decided, on
the strength of a larger number.

The numbers make that likely rather than rare. A precursor-only result is
scored on fewer terms, and the terms it keeps -- accurate mass, sometimes
retention time -- are the ones that agree most easily, so their average
comes out above a genuine spectral comparison more often than not.

AnnotationEvidence.Rank puts the qualitative judgement in the ordering
instead, as the author asked on 2026-09-15: an order, not a weight. A
weight would make it tradeable, so a large enough similarity could buy a
precursor-only candidate past a spectral match; a separate key cannot be
bought at any score. The programme's annotation policy already says this
in words -- "a lower-priority MS/MS reference match outranks a
higher-priority precursor-only suggestion" -- and this is that sentence
compiled.

    compared, and it carried the match      6   ReferenceSpectrum, RuleBased
    compared, and it fell short             4   WeakSpectrumMatch
    not recorded / asserted by a person     3   Unspecified, Manual
    computed, or mass alone                 2   the two in-silico members,
                                                PrecursorOnly
    compared, and it explained nothing      1   UnmatchedSpectrum

Three placements are the whole design.

"Not recorded" is mid-rank, not last. Every candidate deserialized from a
project written before the evidence record reads Unspecified -- absent
MessagePack keys give default(T) -- so in such a project every candidate
ties and the order is exactly today's, Argmax keeping the first of a tie.
Ranked last instead, reopening one and annotating into it would promote a
spectrum that explained nothing over a perfectly good older candidate,
purely because the older one predates the bookkeeping.

In-silico sits below WeakSpectrumMatch, confirmed by the author on
2026-09-14: a computed spectrum or a computed structure is a hypothesis
about a compound, a real spectrum that partly agreed is an observation of
one. It ties with PrecursorOnly rather than being placed above or below,
because whether a calculation outranks a bare mass has not been decided
and an ordering should not invent an answer.

UnmatchedSpectrum is below PrecursorOnly, which is the author's criterion
of 2026-09-10. No spectrum acquired is an absence of evidence; a spectrum
acquired, compared, and disagreeing is a finding against the candidate.

Rank is a switch and never a cast. The serialized values are a
compatibility record -- 3 is where PredictedSpectrum sat, 8 was appended
after it -- and run in the order the members happened to be written. Two
pairs are inverted between the two orders, so the test that a cast has to
fail is not hypothetical.

Where the rank goes, in each of the three keys:

  MsScanMatchResultEvaluator   below the verdicts, above the score. Below
  them because they are the stronger statement, and because moving it
  above would change what a text-database precursor-only match outranks --
  a separate decision, deliberately untouched.

  FacadeMatchResultEvaluator   above Priority. Priority is the analyst's
  ordering of their databases; IdentifySettingModel hands out
  AnnotatorModels.Count - index, so the first library in the list won every
  tie beneath Source, even against a later library that had matched a
  spectrum where it had not. Source stays first, so a candidate a person
  accepted still dominates: Manual is the top bit of the flags byte.

  MsScanMatchResultContainer.ResultOrder   same position. Without it the
  change would be half-applied in the worst way -- SelectTopN would keep
  the better evidence and the exported name would still come from the
  higher-scoring candidate.

SelectTopN is not touched, as agreed. It holds no ordering expression and
defers entirely to SelectTopHit, which is why the cap now discards the
weakest evidence rather than the lowest score with no line changing there.

ResultOrder is also recomputed on load while the name it produced was
stored -- Representative is [IgnoreMember], AlignmentSpotProperty.Name is
Key(12). Reopening a project saved from this branch can therefore pair a
stored name with a different candidate's numbers. The author accepted that
on 2026-09-14 on the grounds that no such project exists outside this
development; reanalysis, not migration, is the remedy if one turns up.

Six mutations checked, each killed by tests that name the behaviour:
dropping the rank from any one of the three keys, ranking Priority above
it again, casting the enum, giving the DefaultIfEmpty placeholder the
neutral rank, and ranking "not recorded" last.

1,750 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DimsMspAnnotator averaged the mass term together with two spectral terms,
always three of them. A feature with no product-ion spectrum -- the
ordinary case in direct infusion, where an MS1 survey may be all there is
-- got its two spectral terms from Ms2MatchResult.Empty, which holds 0 in
every field rather than the -1 the scoring functions returned.

So a precursor mass agreeing to within tolerance, worth 1.0 on its own,
was published as 0.33. Below any TotalScoreCutoff above a third, which is
most of them, so the candidate was not merely ranked low: it was discarded
before anything could rank it.

This is the only annotator that ever counted those zeros. MassAnnotator
and MsReferenceScorer each assemble the terms they actually computed and
average those, and so does CalculateAnnotatedScoreCore twenty lines below
in this same file -- the same candidate, scored two ways, by two methods
that disagreed. The fix is not a new convention; it is this site joining
the existing one.

The guard is Ms2MatchResult.SpectrumCompared, not
IsSpectrumComparisonPerformed. That predicate infers "a comparison
happened" from the spectral fields being non-negative, and Empty's zeros
are non-negative, so for an MspDB result it answers TRUE for exactly the
candidates that have to be excluded -- inert as a guard here, and pinned
as inert by a test, so that simplifying to it later says what breaks.

The two branches held separate ms2Result locals, so one is hoisted out.
LipidMs2MatchResult derives from Ms2MatchResult and carries the same flag,
and its Empty still has an empty Name and IsOtherLipidMatch false, so the
lipid branch keeps falling back to the reference's own name.

Paired with the evidence ordering in the previous commit. Both halves are
needed for the annotation to come out right: the rank says a compared
spectrum outranks a bare mass, and this says the bare mass is worth what
it measured rather than a third of it.

The compared path is unchanged and tested as unchanged -- when all three
terms were measured the divisor is still three and the value is identical.
Only candidates that had terms fabricated for them move. Reverting the
change fails three of the six new tests.

DI-MS is the mode the author has said has few users and least development;
this is a score change and it is deliberate.

1,756 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two in-silico members tied with PrecursorOnly because the question had
not been put. The author answered it on 2026-09-15, and the reasoning is
worth more than the placement.

An in-silico tool takes the product-ion spectrum into account. MS-FINDER
and SIRIUS read the measured peaks to get where they get; a spectrum
predictor is scored against them. So the precursor mass is the one term an
in-silico candidate and a precursor-only candidate have in common, and
everything else the calculation used is extra.

And the precursor-only candidate is making the larger claim. It arrives as
a STRUCTURE, a named compound, on evidence that justifies at most a
formula -- "それは基本行き過ぎ". Retention time plus m/z would be another
matter; m/z alone is not. What ranks below here is therefore not an
absence of evidence but a claim that overreaches its evidence, which is a
different and worse thing.

    compared, carried the match             6   ReferenceSpectrum, RuleBased
    compared, fell short                    5   WeakSpectrumMatch
    not recorded / asserted by a person     4   Unspecified, Manual
    calculated                              3   the two in-silico members
    mass alone                              2   PrecursorOnly
    compared, explained nothing             1   UnmatchedSpectrum

Unspecified stays between the compared tiers and the calculated one, which
is what "neither credited nor penalised" now means, and a project in which
nothing is recorded still has every candidate tied.

In-silico remains below WeakSpectrumMatch, and that is not in tension with
the above: a real spectrum that partly agreed is an observation of the
compound, a calculation is a hypothesis about it. Both bounds are tested.

No interaction with MS-FINDER acceptance in the GUI, which sets
SourceType.Manual and therefore wins at the first key of both orderings
before this one is read.

The numbers are ordinals and nothing serializes them, so the renumbering
costs nothing. Tying the two back together fails three tests.

1,758 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"An experimentally acquired reference spectrum was compared" was asserted
unconditionally, in two places, about every spectral library MS-DIAL has
ever searched. Nothing checked it, and for a growing share of libraries it
is false: NEIMS generates EI spectra for GC-MS, CFM-ID and its kin generate
MS/MS, and MS-DIAL parses, searches and scores those exactly as it does
Wiley, NIST or MassBank. The author asked on 2026-09-15 for the two to be
tagged apart.

MS-DIAL cannot tell by looking -- an MSP holds no field that says so -- so
the analyst says which, by choosing DataBaseSource.PredictedMsp instead of
Msp when adding the library, and every annotator reads it off the database
it is already holding. The two evidence members this needs have existed
since the in-silico vocabulary commit; what was missing was anything able
to choose between them.

ForDatabaseMatch now takes the database, and requires it. That is what
found the sites: the compiler listed all six, including the GC-MS funnel,
which had been asserting ReferenceSpectrum in its own separate line of
code. It now goes through the same function, so the claim is made in one
place for every mode. A future annotator has to answer the question rather
than inherit "acquired" by saying nothing.

The order of the three questions is deliberate. What was compared comes
first, because a predicted library says nothing about a feature that had no
spectrum. The lipid rule set comes second, because there the
diagnostic-fragment rules are the evidence whatever the spectra were. The
library kind is last.

DataBaseSource.Lbm is deliberately NOT counted as predicted, even though
.lbm2 spectra are in silico, and the reason is a defect rather than a
principle: MassAnnotationSettingModel.LoadDataBase switches on DBSource and
then passes the Lbm constant regardless, so a plain MSP library can arrive
wearing that label. Understating is better than mislabelling those. The EAD
lipid databases are counted, because MS-DIAL builds them itself.

DataBaseSettingModel.LoadMspDataBase had the same shape of bug in miniature
-- it passed the DataBaseSource.Msp constant rather than the DBSource it
had just switched on, discarding the analyst's answer at the one point it
was known. Fixed here because the feature does not work otherwise.

PredictedMsp is appended, not inserted: the value is serialized in
MoleculeDataBase key 2 and in the restoration keys, so the existing members
keep the numbers projects on disk already hold. It is read exactly like Msp
everywhere -- same parser, same annotator, same default cut-offs -- and the
GC-MS test asserts the score and the verdicts are identical between the two.
The only difference is the record, and through the evidence rank, that an
acquired match wins a tie.

Not done here, and not a side effect worth taking on quietly:
MethodSettingModelFactory's four "the analyst already added an MSP" guards
still test Msp alone. They govern whether a lipidomics run auto-attaches a
bundled LBM, which is a different question from this one.

Reverting the tag fails five tests across the two suites.

1,771 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The library-kind tag shipped reachable only from the GUI dropdown. Every
run the reanalysis pipeline makes is a Console run, and the Console built
DataBaseSource.Msp unconditionally -- so an in-silico library given to the
pipeline would have been published as a reference-spectrum match, which is
the assertion this whole line of work exists to stop making.

The MSP annotator settings TSV already carries per-annotator columns for
priority, target omics and the search tolerances, so this is one more:
library_kind (or library_type, spectra_source, msp_kind), reading
acquired / experimental / measured / msp against predicted / in silico /
computed / generated. Silence means acquired, so an existing settings file
runs exactly as it did.

An unrecognised value is reported and treated as silence. Failing the run
would cost a whole reanalysis for a misspelt word; guessing "predicted"
would publish an in-silico claim nobody made. The run log states the kind
each annotator actually used, next to the tolerances it already states.

A file is one library, so the kind belongs to the file group rather than to
each annotator over it. Two annotators on the same file that disagree is a
mistake in the settings file, and it is named in the log rather than
silently resolved.

Scope, stated rather than left to be discovered: only LcmsProcess takes the
multi-annotator overload. DIMS, GC-MS, IMMS and LC-IM-MS still go through
the single-library ParseLibraries, which has no settings table to read and
so still builds DataBaseSource.Msp. LC-MS is the whole of the public
repository campaign, so the pipeline is covered; GC-MS -- where NEIMS
raised the question -- is not, and giving it one needs either a method-file
key backed by a new ParameterBase field or the same settings table, which
is a decision rather than a detail.

Always returning Msp fails two of the three new tests.

1,774 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the spectrum

Peak table, alignment table, mzTab-M and exported spectra are joined by peak
ID, and that contract already held. What did not is the field a person looks
at first. mzTab-M alone canonicalised the name; the two tables and every
spectral export published spot.Name raw, so the same peak read "low score:
Quercetin" in the CSV and "Quercetin" in the mzTab -- and a reader has no
reason to suspect that one exporter strips a prefix and another does not.

AnnotationName.Canonical is now the single rule, applied at every exit:
strip the processing prefix, reduce a dual lipid name to the level the
evidence supports. What the prefix used to say travels as its own field,
where it says more than the prefix could -- "Evidence source" and "Measured
terms" in the tables, opt_global_evidence_source in mzTab-M, EVIDENCE= in a
spectrum's COMMENT. The prefix collapsed "compared and fell short" and
"compared and explained nothing" into one word and could not name the
library at all.

The structural level still comes from the match record, never from the
text. "PC 34:1|PC 16:0_18:1" means chains resolved when MsScanMatching wrote
it and chains unsupported when MsReferenceScorer did; reading the string
cannot tell them apart. So the feature is passed rather than the flag --
AnnotatedObjectExtensions.CanonicalName -- and a caller cannot pair one
feature's name with another's verdict, or forget the verdict and publish an
sn-chain composition that nothing measured.

The exported spectra now carry the evidence record in COMMENT, beside
PEAKID: EVIDENCE= and TERMS=. That is what the join was for. A spectrum
leaves MS-DIAL for MS-FINDER, ICEBERG or SIRIUS and comes back as a new
annotation, and until now what MS-DIAL had already established about it
stayed behind in a table -- the downstream tool could not see whether the
spectrum arrived already identified by a reference match or entirely
unexplained. A peak with nothing recorded says "null" rather than dropping
the fields, so the field set is stable enough to parse.

THE TERM LIST USES A COMMA THERE, NOT A PIPE. COMMENT is a '|'-delimited
list of key=value fields, and the tables and mzTab-M join measured terms
with '|'. Reusing it here would turn one value into several fields and a
reader splitting on '|' would lose every term after the first, with no
error -- the exact silent loss this record exists to end. The tab-delimited
formats have no collision and keep the '|' form.

The change is visible in the CSV: a suggestion that used to read "w/o MS2:
PC 34:1" now reads "PC 34:1", with "Evidence source" two columns along.
That is the same decision already taken for mzTab-M, extended to the other
three because the author asked for the four to agree.

Publishing the raw name again fails two of the five new tests.

1,779 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y it used

Two defects an audit of Peak ID traceability turned up, both of which end
with artifacts describing a run that did not happen.

THE WORKER POOL. The Console sized it as NumThreads / 2, integer division,
no floor, in all five modes -- while every GUI path uses
Math.Max(1, UsableNumThreads / 2). So "number of threads: 1", a value the
method-file parser accepts, gave zero workers. ProcessRunner then built an
empty Task array and RunAllAsync returned an already-completed task having
peak-picked, deconvoluted and annotated nothing.

The empty run was not the damage. MS-DIAL writes its per-file intermediates
beside the raw data it reads, so a re-analysis of data processed before
found the earlier run's .pai files, loaded them, and exported THE PREVIOUS
RUN'S PEAKS AND PEAK IDS -- under this run's parameter file, library list
and version string. Peak ID is the join key across .mdpeak, .mdmsp,
.mdalign, .mzTab and .mdpeakid.tsv, so the whole artifact set would agree
with itself about a run nobody performed. With no pre-existing .pai it
crashes instead, which is the good case.

Fixed at the five call sites to match the GUI, and ProcessRunner now
REFUSES a non-positive worker count rather than clamping it. Clamping would
hide the caller's arithmetic error; every caller already floors its own
value, so this is the backstop that speaks up when one stops. A silent
no-op that reports success is the failure mode worth making impossible.

The hazard was newly introduced: the commit that made "number of threads"
readable from a method file is the commit that created the zero case.

THE UNDECLARED LIBRARY. DataBaseSource.PredictedMsp, added two commits ago,
has no case in MztabFormatExport.SetDatabaseList -- so an in-silico library
vanished from the mzTab-M metadata section while its annotation rows kept
citing its database[n] prefix. The file referred to a database it never
defined. My omission, and the shape of it is the one this branch keeps
finding: a switch that was complete when it was written and silently is not
after a member is added. It is declared separately from Msp rather than
folded in, because telling a generated library from an acquired one is the
entire purpose of the kind.

The test enumerates the library kinds the Console can build and fails by
name when one produces no database block, so the next addition is caught at
the point it is made.

Both mutations checked: removing the case fails two tests, accepting zero
workers fails one.

1,784 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A public library is citable: the Zenodo DOI beside it in a run's provenance
identifies it exactly and anyone can fetch it. A laboratory's own MSP has no
such handle, and what a run records about one is its absolute path -- exact
for the person holding that disk, useless to every reader.

The author's framing on 2026-09-15: what one would want is for the record
count, the compound count and similar metadata to be publishable on their
own, so a private library could be registered by its statistics rather than
by its contents. No registry exists for that. MS-DIAL can at least emit the
statistics, which is the precondition for anyone ever registering them, and
which is already enough for a reader to check that two runs searched the
same library and for a laboratory to recognise its own.

    MTD  custom[1]  [,, MS-DIAL library statistics database[1],
                     182345 records; 31204 compounds; sha256:91144a524bc07672]

COMPUTED FROM THE STORED RECORDS, NOT FROM THE FILE. The project keeps its
own copy of every reference it loaded, so the digest describes the library
the run ACTUALLY SEARCHED and stays answerable when the original file has
moved, been renamed, or been edited since. A checksum of whatever is at that
path today answers a weaker and different question.

Derived rather than stored, for the same reason the evidence rank is a
switch and not a cast: MoleculeDataBase has some 220 construction sites, so
a stored field is one any of them could forget, get wrong, or let fall out
of step with the records beside it. It cannot drift if it is computed from
them. This also means no new MessagePack key and no project-format change.

Record count and compound count answer different questions and the gap
between them is the informative part: many adducts and collision energies
per structure is a different kind of library from a flat list, at equal
record count. Compounds collapse on the InChIKey skeleton block, which is
what that block is for; records with no key count one each, because a
library of ten thousand unidentified spectra does not hold one compound.

Intensities are excluded from the digest on purpose -- several load paths
normalise them, so including them would make the identity depend on how the
library was read rather than on what it holds, and two projects that loaded
the same file by different routes would disagree about which library they
used. m/z is rounded to five decimals for the same reason.

database[n]-uri is untouched: the author has said the absolute path there is
not a problem for him.

Every identity-bearing field is tested as changing the digest, and rescaling
every intensity is tested as not.

1,791 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MS-DIAL 5 stated its version from three hand-maintained .resx literals that
had drifted apart and gone stale: the GUI said 5.5.250403-beta, the Console
said 5.5.241113 -- frozen since 2024-11-19 while two hundred commits landed
-- and MsdialCore, from which ParameterBase takes its default, still said
4.24. So every build since November 2024 reported the same version, and
someone who ran a locally built MS-DIAL for a paper could not afterwards say
which one. That is the author's complaint of 2026-09-15.

    MainVersion     5.5              one line in Directory.Build.props
    BuildDate       2026-09-15       stamped at build time
    CommitId        d1ba6b5         already compiled in; see below
    DisplayVersion  5.5.260915       what a user sees
    FullIdentity    5.5.260915+d1ba6b59   what provenance carries

The split is what he asked for: main version plus date for display, commit
kept internal so the user-facing string stays short. A window title is not
where a hex string earns its space; an exported file is exactly where it
does, because two builds made on one day share a display version and nothing
about it identifies the source.

THE COMMIT ID WAS ALREADY THERE. SourceLink is enabled by the .NET SDK for
every SDK-style project in this tree with no configuration at all, and the
generated AssemblyInfo.cs has carried
AssemblyInformationalVersion = "1.0.0+<40 hex>" all along. Nobody read it.
So this needs no git at build time, no network at run time, and no new
build dependency -- only the date, which is two AssemblyMetadata items.

Read from CommonStandard's own assembly, deliberately. Everything references
it and they are built from one working tree, so its commit is theirs. It
also sidesteps MsdialGuiApp setting GenerateAssemblyInfo=false, which leaves
the GUI with no informational version of its own: asking the entry assembly
would return nothing exactly where the answer is most wanted.

IsNewerThan is the question the update notification needs and never asked.
It compares version strings for INEQUALITY, so a locally built MS-DIAL is
told on every start that a new version is available and the newer thing is
the one already running. The release feed already carries published_at and
MS-DIAL already parses it into VersionDescriptionDocument.DatePublished,
where it has never been read once. Comparison is by day, because the
resolution of a build stamp is a day; same-day is not newer, so a release
published the day a build was made still notifies. An unparseable or absent
date returns false, leaving the existing behaviour alone -- returning true
there would silently suppress every notification the moment GitHub changed
its date format, and nobody would notice for a year.

Every value degrades to "unknown" independently rather than throwing. A
version string is not worth failing a run over, and a partial identity is
worth more than none.

This commit adds the identity and proves the stamping works in this build.
Wiring it into the GUI, the Console and the exported provenance -- and
retiring the three literals -- is the next one, because it changes what
users see and what files say.

1,797 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The update check compared version STRINGS for inequality. A locally built
MS-DIAL never equals the published tag, so everyone developing MS-DIAL --
and anyone running the build they made for a paper -- was told on every
start that a new version was available, and the newer thing was the one
already running. The author raised it on 2026-09-15 as a daily annoyance.

The release feed has carried published_at since this class was written, and
MS-DIAL has parsed it into VersionDescriptionDocument.DatePublished and
never read it once. So the fix is a guard, not a feature: if this build is
newer than the release, say nothing.

Same-day still notifies and an unparseable date still notifies. The cost of
a redundant dialog is one click; the cost of the opposite error is a user
sitting on an old version for a year without knowing, which is the failure
nobody would ever report.

ALSO A TRAP DEFUSED. LatestVersion came from
TagName.TrimStart("MSDIAL-v".ToCharArray()), which trims ANY of
{M,S,D,I,A,L,-,v} repeatedly rather than the prefix as a unit. It gives the
right answer today only because the release selection twenty lines above
requires the tag to start "MSDIAL-v5", so the trim stops on a '5' that is
not in the set -- accidental safety resting on a guard in another method.
A tag whose version part began with any of those characters would have had
them eaten off the front, and since a mangled version never equals the local
one, the dialog would then have appeared on every start forever. Now a
prefix strip, and tested with the versions that would have been eaten.

MSDIAL4 has the same TrimStart idiom with the same accidental guard. Left
alone: MS-DIAL 4 is maintenance-only and its tag scheme is not changing.

Not in this commit, because it changes what users see and needs the author's
call: retiring the three stale .resx literals in favour of
MsdialBuildIdentity.DisplayVersion. GlobalResources.IsLabPrivate derives a
BEHAVIOURAL flag from the version string's suffix -- it tests for "-dev" and
"-tada", and it gates both this update check and several GUI features -- so
changing the version's shape silently changes which features a build has.
How a laboratory build should be marked once the version is generated is a
decision, not a detail.

1,801 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    before   MTD software[1]  [MS, MS:1003082, MS-DIAL, Msdial console 5.5.241113]
    after    MTD software[1]  [MS, MS:1003082, MS-DIAL, 5.5.260915+cd5ff71c]

Verified on the fast-LC demo data, not just in tests: the Console banner now
reads "MSDIAL Console Application 5.5.260915" and the exported mzTab names
the commit that produced it.

The three literals are gone, resource entry and generated accessor both, so
nothing can reach them again:

    MsdialCore     4.24              ParameterBase's default -- see below
    MsdialGuiApp   5.5.250403-beta   the GUI's display and update comparison
    Console        5.5.241113        the Console banner and its provenance

MsdialCore's "4.24" was the one that mattered. It was
ParameterBase.MsdialVersionNumber's default, so any path that created a
parameter and never assigned it published "4.24" as the MS-DIAL version --
into the project file and into mzTab-M's software entry. The GUI and the
Console both assign it, so it was not reaching a normal run, but a default
that is quietly wrong is a trap waiting for the next caller. It is now the
running build, which is the true answer for a parameter this build just
made. A parameter DESERIALIZED from a project still keeps its stored value,
as it must: that says which build made the project, not which is reading it.

The Console no longer prefixes "Msdial console". The field is the VERSION,
and mzTab-M already wraps it as "MS-DIAL, <this>" -- the old line said the
application twice and the version once, staleness included.

Display gets DisplayVersion and provenance gets FullIdentity, which is the
split the author asked for. --version prints the full identity deliberately:
it is what someone runs to record which build they used, and the commit is
the half that answers that exactly.

THE LABORATORY SUFFIX IS KEPT, not removed. GlobalResources.IsLabPrivate
tests the displayed version for "-dev" and "-tada" and gates several GUI
behaviours on it, including whether the update check runs. The author said
not to worry about it and that IsLabPrivate=false is right for a
repository-reanalysis run -- which it now is, by default. But deleting the
literal would have deleted the mechanism, not just its current value, and
taken away the main developer's way of marking a build. So the suffix moved
to MsdialVersionSuffix in Directory.Build.props: empty by default, and a
laboratory build sets it there instead of editing a checked-in resource and
remembering to change it back.

MSDIAL4 and MSFINDER keep their own literals. MS-DIAL 4 is maintenance-only.

1,801 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every dispatcher read a line, handed it to the parameter readers, and threw
away the boolean saying whether anything had claimed it -- under a comment
reading "// write something if needed". GC-MS, IMMS and LC-IM-MS threw it
away twice, once for the common reader and once for their own.

So a misspelt key, a key from a newer MS-DIAL, or a key copied from another
mode's template was read, matched nothing, and vanished. The run used the
built-in default and said nothing, and the analyst had every reason to
believe their value had been applied -- with a method file that appears to
document the run correctly. The author called this critical on 2026-09-16.

MEASURED BEFORE DECIDING WHAT TO DO ABOUT IT. Run against MS-DIAL's own
shipped lipidomics template, this reports TWENTY-SEVEN keys with no effect:

    Only report top hit for LBM-based annotation
    Sigma window value
    Process option
    Replace true zero values with 1/2 of minimum peak height over all samples
    Considering Br and Cl for isotopes
    Max isotopes detected in ms1 spectrum
    Ionization,  adduct list
    ... and the eighteen export switches

Several of those change results, not just output shape. So reporting is what
this commit does and failing is what it must not do: rejecting a method file
with an unread key would reject every method file in existence, MS-DIAL's own
templates first. Making those keys work is a separate decision, because
settings ignored for years would start taking effect and change results.

A BLANK VALUE IS NOT AN ERROR and is reported apart from the rest.
"Msp file path:" with nothing after it is how a method file says there is no
MSP library, and how the templates are written. It is still named, because
from the analyst's side it looks identical to a value that failed to apply.

The molecular-networking reader is deliberately exempt: it is handed the same
method file as the mode reader and claims only its own subset, so reporting
there would list every other key in the file. The mode reader is where a key
gets its verdict.

A file whose keys are all understood prints nothing, and that is tested too:
a report that fires on every run is one nobody reads, which is how
twenty-seven of them stayed invisible.

Discarding the verdict again fails two of the three new tests.

1,804 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    $ MSDIALCUI.exe --version
    5.5.260916
    commit 4fb297b

MS-DIAL Interactive learns which build it ran by executing this and matching
/(?:^|\s)(\d+\.\d+(?:\.\d+)+)(?:\s|$)/ against the output. That is how
"5.5.241113" reached its publication report -- and why that report said
5.5.241113 for two years. It has no knowledge of its own; it prints back what
MS-DIAL tells it.

So making --version print "5.5.260916+4fb297ba" would have broken its first
read: the '+' is neither whitespace nor end of input, so the pattern misses
and the code falls through to launching the executable a second time and
matching the help banner. Still correct, but at the cost of a process and of
the commit, which would never reach the report at all.

Version on line one, commit on line two. The newline is the whitespace the
existing pattern wants, so nothing downstream changes and what it captures
becomes true instead of stale. Verified by running the built Console and
applying that regex to its real output.

The shape is now pinned by a test, because the constraint is invisible from
inside this repository: the thing that would break lives in another one.

1,805 passed, 0 failed, 17 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant