From 4bd7652b89336d6f5177e41022a4d48b35736c3d Mon Sep 17 00:00:00 2001 From: trishorts Date: Fri, 19 Jun 2026 11:24:22 -0500 Subject: [PATCH] reject ontology accessions written under a short source prefix A short source prefix (U:, M:, R:) denotes a modification name. An accession written under it -- "U:35", "M:00719", "R:AA0581" -- is the full-prefix accession (UNIMOD:/MOD:/RESID:) used with the wrong prefix, which ProForma 2.0 lists as invalid; the parser previously accepted it as a (non-existent) name. Reject a name value that is in the accession format of its ontology (Unimod and PSI-MOD: all digits; RESID: AA followed by digits). Real names and source-tagged masses under the short prefix are unaffected. Adds parser tests for the three spec counter-examples and for the valid name/mass forms. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JNnpzBNwfnTbZxtZdtAzLG --- .../ProForma/ProFormaParser.cs | 75 +++++++++++++++++-- .../ProForma/ProFormaParserTests.cs | 20 +++++ 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/TopDownProteomics/ProForma/ProFormaParser.cs b/src/TopDownProteomics/ProForma/ProFormaParser.cs index 7943c50..c380a97 100644 --- a/src/TopDownProteomics/ProForma/ProFormaParser.cs +++ b/src/TopDownProteomics/ProForma/ProFormaParser.cs @@ -440,16 +440,77 @@ private void ProcessTag(string tag, int? startIndex, int index, bool inSequenceA "gno" => Tuple.Create(ProFormaKey.Identifier, ProFormaEvidenceType.Gno, text, groupName, weight), // Handle names and masses - "u" => Tuple.Create(getKey(isMass), ProFormaEvidenceType.Unimod, text.Substring(colon + 1), groupName, weight), - "m" => Tuple.Create(getKey(isMass), ProFormaEvidenceType.PsiMod, text.Substring(colon + 1), groupName, weight), - "r" => Tuple.Create(getKey(isMass), ProFormaEvidenceType.Resid, text.Substring(colon + 1), groupName, weight), - "x" => Tuple.Create(getKey(isMass), ProFormaEvidenceType.XlMod, text.Substring(colon + 1), groupName, weight), - "g" => Tuple.Create(getKey(isMass), ProFormaEvidenceType.Gno, text.Substring(colon + 1), groupName, weight), - "b" => Tuple.Create(getKey(isMass), ProFormaEvidenceType.Brno, text.Substring(colon + 1), groupName, weight), - "obs" => Tuple.Create(getKey(isMass), ProFormaEvidenceType.Observed, text.Substring(colon + 1), groupName, weight), + "u" => this.CreateNameOrMass(isMass, ProFormaEvidenceType.Unimod, text.Substring(colon + 1), groupName, weight), + "m" => this.CreateNameOrMass(isMass, ProFormaEvidenceType.PsiMod, text.Substring(colon + 1), groupName, weight), + "r" => this.CreateNameOrMass(isMass, ProFormaEvidenceType.Resid, text.Substring(colon + 1), groupName, weight), + "x" => this.CreateNameOrMass(isMass, ProFormaEvidenceType.XlMod, text.Substring(colon + 1), groupName, weight), + "g" => this.CreateNameOrMass(isMass, ProFormaEvidenceType.Gno, text.Substring(colon + 1), groupName, weight), + "b" => this.CreateNameOrMass(isMass, ProFormaEvidenceType.Brno, text.Substring(colon + 1), groupName, weight), + "obs" => this.CreateNameOrMass(isMass, ProFormaEvidenceType.Observed, text.Substring(colon + 1), groupName, weight), _ => Tuple.Create(ProFormaKey.Name, ProFormaEvidenceType.None, text, groupName, weight) }; } + + /// + /// Creates a name or mass descriptor for a short ontology source prefix (U:, M:, R:, ...). + /// A short prefix denotes a modification NAME; when the value is written in the accession + /// format of its ontology (e.g. "U:35" or "R:AA0581") the full accession prefix + /// (UNIMOD:, MOD:, RESID:) was intended. ProForma 2.0 lists such forms as invalid, so they + /// are rejected here rather than silently treated as a (non-existent) name. + /// + private Tuple CreateNameOrMass( + bool isMass, ProFormaEvidenceType evidenceType, string value, string? groupName, double weight) + { + ProFormaKey key = isMass ? ProFormaKey.Mass : ProFormaKey.Name; + + if (key == ProFormaKey.Name) + { + string? accessionPrefix = GetMisusedAccessionPrefix(evidenceType, value); + + if (accessionPrefix != null) + throw new ProFormaParseException( + $"'{value}' is in {accessionPrefix} accession format; use the full accession " + + $"prefix (e.g. [{accessionPrefix}:{value}]) instead of the short source prefix."); + } + + return Tuple.Create(key, evidenceType, value, groupName, weight); + } + + /// + /// Returns the full accession prefix a value should have used when it is written in the + /// accession format of its ontology under a short source prefix, or null otherwise. + /// + private static string? GetMisusedAccessionPrefix(ProFormaEvidenceType evidenceType, string value) + { + static bool IsAllDigits(string s) + { + if (s.Length == 0) + return false; + foreach (char c in s) + if (!char.IsDigit(c)) + return false; + return true; + } + + static bool IsResidAccession(string s) + { + // RESID accessions are "AA" followed by digits, e.g. AA0581. + if (s.Length < 3 || s[0] != 'A' || s[1] != 'A') + return false; + for (int i = 2; i < s.Length; i++) + if (!char.IsDigit(s[i])) + return false; + return true; + } + + return evidenceType switch + { + ProFormaEvidenceType.Unimod when IsAllDigits(value) => "UNIMOD", + ProFormaEvidenceType.PsiMod when IsAllDigits(value) => "MOD", + ProFormaEvidenceType.Resid when IsResidAccession(value) => "RESID", + _ => null + }; + } } } \ No newline at end of file diff --git a/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs b/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs index ffddd5c..5e3094e 100644 --- a/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs +++ b/tests/TopDownProteomics.Tests/ProForma/ProFormaParserTests.cs @@ -1144,5 +1144,25 @@ public void UnimodCappedTest(string proFormaString, ProFormaEvidenceType modType Assert.AreEqual("UNIMOD:15", desc1.Value); } #endregion + + [Test] + public void RejectAccessionUnderShortSourcePrefix() + { + // ProForma 2.0: an ontology accession must use the full prefix (UNIMOD:/MOD:/RESID:). + // Writing the accession under the short name prefix (U:/M:/R:) is invalid. + Assert.Throws(() => _parser.ParseString("EM[U:35]EVEES[U:56]PEK")); + Assert.Throws(() => _parser.ParseString("EM[M:00719]EVEES[M:00046]PEK")); + Assert.Throws(() => _parser.ParseString("EM[R:AA0581]EVEES[R:AA0037]PEK")); + } + + [Test] + public void AllowNamesAndMassesUnderShortSourcePrefix() + { + // Real names and source-tagged masses under the short prefix remain valid. + Assert.DoesNotThrow(() => _parser.ParseString("EM[U:Oxidation]EVEES[U:Phospho]PEK")); + Assert.DoesNotThrow(() => _parser.ParseString("EM[M:L-methionine sulfoxide]EVEES[M:O-phospho-L-serine]PEK")); + Assert.DoesNotThrow(() => _parser.ParseString("EM[U:+15.9949]EVEES[U:+79.9663]PEK")); + Assert.DoesNotThrow(() => _parser.ParseString("EM[R: L-methionine sulfone]EVEESPEK")); + } } } \ No newline at end of file