Skip to content

Let the protease say where a glycan must be, and make the localizer honour it - #23

Draft
trishorts wants to merge 7 commits into
masterfrom
feat/glyco-localization-obligation
Draft

trishorts wants to merge 7 commits into
masterfrom
feat/glyco-localization-obligation

Conversation

@trishorts

@trishorts trishorts commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

Draft, parked on the fork. This was smith-chem-wisc#2834, moved here while it is a draft to keep the upstream PR list uncluttered. Its earlier review threads and their answers stay on the upstream PR: smith-chem-wisc#2834. It goes back upstream as a live PR when it is ready.

Base: master -- needs an mzLib release carrying smith-chem-wisc/mzLib#1317's CleavageRequirement before it can build.

Project of origin: cleavageRules

DRAFT, and blocked on a release only. This needs DigestionProduct.GetCleavageObligatedSites, which is in mzLib smith-chem-wisc#1317 (commit cbb749c0). MetaMorpheus consumes mzLib as a package (currently 1.0.591), so CI will be red until smith-chem-wisc#1317 and its parents merge and mzLib ships a release. The code itself is verified: built and fully tested locally against mzLib packed from smith-chem-wisc#1317 (see Verification below).

The gap

A glyco search identifies the peptide backbone naked, then makes up the precursor-mass difference with combinations of glycans and tries arrangements, scoring each by matched fragment ions.

Amino-acid motif is the only thing that says where a glycan may go. I checked every constraint in the glyco tree: motif (ModificationLocalization.ModFits), a skip for residues that already carry a modification, a per-box motif count, and the spectral score — which ranks arrangements but never forbids one. There is no occupancy prior, no known-glycosite handling, and nothing from the enzyme. KnownSite, Obligated, SiteConstraint match nothing in the repo, and no open PR or issue attempts it.

But when the enzyme is a glycoprotease, it has already proved part of what the spectrum is being asked to guess. If OpeRATOR cut this peptide out, its first residue carried a glycan. That is not a hypothesis to be scored — it is a consequence of the peptide existing.

What lands

LocalizationGraph gains obligated sites and enforces them inside the dynamic program, not afterwards.

The DAG's child boxes hold the modifications placed at or before each site, so "this site is occupied" is exactly "the incoming edge increases the modification count" — an edge predicate, needing no new data structure. It is applied on the cached and uncached paths, and at the first column, which has no incoming edge.

GraphCheck learns it too: a box with fewer glycans than the peptide has obligated sites cannot fill them, and without screening it the terminal node becomes unreachable and FinishGraph throws — precisely the case its own comment says never happens in a search.

Two things deliberately not done

Not by pruning the candidate-site list. That expresses the wrong thing — deleting a site says "j is not a candidate", not "k is occupied" — and it breaks GraphCheck's modPos.Count >= NumberOfMods feasibility test, wrongly rejecting multi-glycan boxes.

Not by pre-placing a modification. This is the trap. GetPossibleModSites skips any residue that already carries a modification, so pre-placing one makes the obligated site vanish from the graph — the exact inverse of the intent.

Relatedly, an obligation that is not among the candidate sites is ignored, not treated as unsatisfiable: a site can be missing for reasons unrelated to the protease, and refusing the peptide there would delete a correct answer. Under-constraining forgoes a refinement; over-constraining forbids the truth.

Cost, and what it buys

HasObligatedSites short-circuits both loops, and it is false for every ordinary protease, so a non-glyco or non-glycoprotease run pays nothing.

When it does apply the constraint strictly shrinks the route set — including in GetAllPaths_CalP, the exponential step. And because the localization level (1 / 1b / 2 / 3) is a function of how many routes survive, and the probabilities are re-summed over the same reduced set, constraining the routes is precisely what turns a Level 3 into a Level 2 or 1, with the probabilities staying correct.

Blast radius

One production construction site (GlycoSearchEngine.cs:735). The four-argument LocalizationGraph constructor is kept, so the five existing test call sites are untouched.

Verification

Run against mzLib packed locally from smith-chem-wisc#1317 (9.9.1191) and restored into this branch, which is what CI will do once smith-chem-wisc#1317 ships.

MetaMorpheus solution compiles clean against the new mzLib
OGlycoTest_ObligatedSiteConstrainsTheRoutes passes
OGlycoTest_AnObligationNotAmongTheCandidateSitesIsIgnored passes
Full glyco suite 133 passed, 0 failed
Localization / Search / Index / ModSearch 485 passed, 0 failed

The first test carries a vacuity guard — it asserts that the unconstrained graph admits a route leaving the obligated site empty — so its pass proves the constraint actually eliminated routes rather than finding nothing to do. Assertions are relational (fewer routes; every survivor occupies the obligated site) rather than exact route counts, so they do not re-encode the glycan database.

All five pre-existing LocalizationGraph call sites are in that 133 and unchanged.

Counterpart

mzLib smith-chem-wisc#1317 adds DigestionProduct.GetCleavageObligatedSites, the derivation this consumes. It is derived rather than stored on purpose: the peptide index is cached with a field-based, schema-rigid serializer whose reuse check carries no assembly version, so a new serialized field would silently corrupt indices users already have.

🤖 Generated with Claude Code

trishorts and others added 7 commits September 17, 2026 22:26
…onour it

A glyco search identifies the backbone naked and only then makes up the precursor-mass
difference with glycans, arranging them to maximise matched fragment ions. Until now
AMINO-ACID MOTIF WAS THE ONLY THING SAYING WHERE A GLYCAN MAY GO -- no occupancy prior,
no known-glycosite handling, nothing from the enzyme. Confirmed by reading every
constraint in the tree; there is no prior art for this anywhere in the repo.

But a glycoprotease already proved something the spectrum is being asked to guess. If
OpeRATOR cut this peptide out, its first residue carried a glycan. That is not a
hypothesis to score, it is a consequence of the peptide existing at all.

LocalizationGraph gains obligated sites and enforces them inside the dynamic program,
not afterwards. The DAG's child boxes hold the modifications placed at or before each
site, so "this site is occupied" is exactly "the incoming edge increases the count" --
an edge predicate, needing no new data structure. Applied on both the cached and
uncached paths, plus the first column, which has no incoming edge.

Deliberately NOT done by pruning the candidate-site list, which would say the wrong
thing ("j is not a site" rather than "k is occupied") and would break GraphCheck's
count-feasibility test. And deliberately NOT by pre-placing a modification, which is
the trap here: GetPossibleModSites skips any residue that already carries one, so the
obligated site would VANISH from the graph -- the exact inverse of the intent.

GraphCheck learns the obligation too. A box with fewer glycans than the peptide has
obligated sites cannot fill them, and without screening it the terminal node becomes
unreachable and FinishGraph throws -- the case its own comment says never happens in a
search.

An obligation that is not among the candidate sites is IGNORED, not treated as
unsatisfiable. GetPossibleModSites drops already-modified residues, so a site can be
missing for reasons unrelated to the protease, and refusing the peptide there would
delete a correct answer. Under-constraining forgoes a refinement; over-constraining
forbids the truth.

Cost is nothing when it does not apply: HasObligatedSites short-circuits both loops,
and every ordinary protease leaves it false. When it does apply it strictly shrinks the
route set, and since the localization level (1/1b/2/3) is a function of how many routes
survive and the probabilities are re-summed over the same reduced set, it converts
Level 3 assignments into Level 2 or 1 with the probabilities staying correct.

The four-argument constructor is kept, so the five existing test call sites are
untouched. One production construction site changes.

REQUIRES AN UNRELEASED mzLib: DigestionProduct.GetCleavageObligatedSites, added in
mzLib smith-chem-wisc#1317 (commit cbb749c0). MetaMorpheus consumes mzLib as a package, currently
1.0.591, so this cannot build in CI until smith-chem-wisc#1317 and its parents merge and mzLib ships.
EngineLayer was compiled clean locally against that mzLib via temporary project
references; the MetaMorpheus TEST suite could not be run, because the glyco tests need
the glycan databases and spectra the mzLib package ships. The two new tests are
therefore unexecuted, and are written with relational assertions rather than exact
route counts for that reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…al outcome

Both surfaced on the first end-to-end run against PXD035775 (OpeRATOR on PSGL1,
Orbitrap Eclipse, HCD-pd-EThcD). Neither was caught by the unit tests, because both
need a box the obligation cannot satisfy and the test fixtures only used boxes it
could.

1. The obligation filter could strip a node of EVERY predecessor and the node was
   stored anyway. NodeCheck and the valid chart used to guarantee at least one source
   for any stored node; the obligation is the first filter that can remove the last
   one. GetFirstPath then stepped back into it and called First() on an empty source
   list -- "Sequence contains no elements", 12 scans in.

2. FinishGraph threw when the terminal node was unreachable, on the premise that it
   could only mean the caller skipped GraphCheck. An obligation makes "no arrangement
   puts a glycan where the enzyme proves one must be" an ORDINARY answer about a real
   spectrum. GraphCheck cannot screen it out in advance either: comparing counts is not
   enough, because an obligated site whose motif does not match the box's glycans is
   unsatisfiable for a reason no count reveals. The graph now reports HasRoute and the
   engine skips the box.

   Skipping rather than scoring zero matters: a zero-scoring graph can still be the
   best one when every box fails, and GetFirstPath would then walk a graph with no path.

Result on real data, same file, obligation off vs on:

    target PSMs                177  ->  177
    O-glyco PSMs               169  ->  169
    Level 1 O-glyco PSMs         5  ->   29
    localizations at residue 1 5/5  ->  33/33

Identical identifications, 5.8x more confident localizations, every one at residue 1 --
which is OpeRATOR's published rule. The constraint does not move a glycan, it makes the
assignment certain, which is the mechanism this was built on.

133 glyco tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…at digestion

Found by running a real OpeRATOR file: with RespectCleavagePromotingModifications ON,
the peptide index went from 2,864 entries to 0, and the task reported SUCCESS with zero
PSMs. Nothing distinguishes that from a bad database or a bad spectra file.

The cause is structural rather than a bug. A glyco search identifies the backbone naked
and only afterwards makes up the precursor-mass difference with glycans, so there is no
glycan at the moment digestion decides where to cut. Every site a glycoprotease has then
looks infeasible, the site list empties, and nothing survives to be searched.

The setting is not overridden, because silently ignoring what a user asked for is its
own trap; it is explained instead, naming the protease and saying what to do. And what
the requirement can do for this search it already does without the flag -- the localizer
reads the peptide's protease provenance directly, so glycans are still pinned to the
sites the enzyme proves they occupy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…poken for

The obligation said WHERE a glycan must be. It can now say WHICH glycans belong there,
which is the only place a composition-level rule can act in a glyco search: no glycan
exists at digestion, so the composition has nothing to constrain until localization,
where the real glycans are.

OpeRATOR does not merely require a glycan at P1'. It requires at least core 1 and is
blocked again by core 2, so a Tn antigen on that residue is not a worse arrangement --
it is an impossible one. A glycan IS a Modification, so each candidate goes straight
through CleavageRequirement.IsSatisfiedBy.

Glycan now carries its own MonosaccharideComposition, parsed from the same
GetKindString shorthand it already produces, so the two vocabularies cannot drift.

Deliberately permissive wherever the glycan cannot be identified -- an unknown id, an
unloaded glycan list, a composition the database never recorded. An unidentifiable
glycan is not evidence that a rule is broken, and refusing it would delete arrangements
over missing metadata rather than over chemistry. A rule naming no composition behaves
exactly as before.

133 glyco tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clean merge; no conflicts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GetCleavageObligations summary was inserted between GraphCheck's doc
comment and GraphCheck, so GraphCheck's docs attached to the wrong member
and GetCleavageObligations carried two summaries. Move them back and
document the obligatedSiteCount parameter.

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