Skip to content

Making the MetaMorpheus GUI usable on macOS and Linux - #2709

Open
acesnik wants to merge 12 commits into
smith-chem-wisc:masterfrom
acesnik:feat/avalonia-gui-crossplatform
Open

Making the MetaMorpheus GUI usable on macOS and Linux#2709
acesnik wants to merge 12 commits into
smith-chem-wisc:masterfrom
acesnik:feat/avalonia-gui-crossplatform

Conversation

@acesnik

@acesnik acesnik commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🤖

Making the MetaMorpheus GUI usable on macOS and Linux

MetaMorpheus's engine is cross-platform and the CLI runs anywhere, but the GUI is WPF and
therefore Windows-only. Users on macOS and Linux can run searches, but only by hand-writing
TOML — there is no way to build a task list, adjust settings, or see progress.

This adds an Avalonia front end that runs on all three platforms. It is deliberately additive:
GUI and GuiFunctions are untouched, and the WPF GUI remains the Windows experience. If this
direction is not wanted, deleting the two new directories reverts it.

Opened as a draft for a direction decision rather than for merge. That decision has now been
given (@trishorts, converge rather than coexist), and the review's line-level findings are addressed —
see the update below.

What works

  • Building a task list, adding spectra and databases, running EverythingRunnerEngine,
    live progress and log output
  • Settings dialogs for all six task types (Search, Calibrate, GPTMD, XLSearch, GlycoSearch, Average),
    each reachable from its own button
  • Fixed and variable modification selection over the whole modification set, grouped and filterable,
    plus GPTMD's ListOfModsGptmd on the same widget
  • Contaminant databases: the filename heuristic, an editable override, and "Add default contaminants"

87 tests, headless via Avalonia.Headless.NUnit, running on Linux, macOS and Windows. Some run real
searches and assert the defaults still yield 22 PSMs, so a settings regression that quietly changes
results fails the build instead of being discovered later.

Two implementation notes for review:

  • The view model subscribes to MetaMorpheusEngine's static events, so it is IDisposable and
    unsubscribes. Without that, tests passed individually and failed in a suite, because each
    surviving view model kept handling the next one's events.
  • Engine events arrive off the UI thread and are marshalled through Dispatcher.UIThread.

Why Avalonia

Avalonia is the closest thing to WPF that also runs on Linux and macOS: same XAML dialect, same
MVVM and data-binding model, same attached-property pattern. That matters concretely for the port
ahead — every WPF type MetaDraw uses (Canvas, TextBlock, Ellipse, Rectangle, Panel,
Point, Color, Colors, SolidColorBrush, Key, Thickness, FontFamily, and
Canvas.SetLeft/SetTop) exists in Avalonia under the same name with the same shape. I verified
this by compiling all of them against Avalonia 12.1.1 rather than assuming it.

The practical consequence is that most of the remaining work is namespace translation rather than
rewriting, and that WPF experience transfers directly. The alternatives are worse fits: MAUI has no
real Linux story, and Uno or a web front end would mean learning a genuinely different model.

arm64

Native Apple Silicon works as of master: #2701 made CMD, EngineLayer and TaskLayer
architecture-neutral and #2704 brought in mzLib 1.0.584. I had these as a commit here and have
dropped it as redundant. Verified on this branch: all 87 tests pass on native arm64.

(#2503 and #2504 both still appear open, though the fixes for them have landed.)

Not included

MetaDraw, file-specific parameters, and roughly 25 auxiliary dialogs.

Two deliberate differences from the WPF GUI

Both were raised in review and are stated here rather than left to be inferred:

  1. Fixed and variable modifications are mutually exclusive here. WPF harvests the two trees
    independently with no cross-check (SearchTaskWindow.xaml.cs:567-577); the only follow-up,
    TaskValidator.VariableModCheck, merely warns when more than one variable mod is selected. A
    modification submitted as both makes the search treat that residue inconsistently.
  2. ValidModification filtering applies to every task type, where WPF applies it only in Search.

I think both are improvements, but they mean the two GUIs build different tasks from identical user
actions. I will open an issue against the WPF side so they converge rather than drift.

CI

Both projects are added to the solution, so the existing build job compiles them on
ubuntu/macos/windows, and a new job runs the 87 tests on all three, with coverage collected under its own flag.

Note for whoever reviews the solution diff: dotnet sln add maps UbuntuMac to Debug, and for
GUI.Avalonia it omitted Build.0 entirely, which makes the solution build "successfully" while
silently skipping the project. The mappings here are set by hand to match GuiFunctions.

Finishing the job, including MetaDraw

MetaDraw is the biggest remaining piece and it should be part of any port — a cross-platform GUI
without it would not be a real replacement. Having measured it, I think it is very doable.

It is ~36 files in GuiFunctions with about 600 references to WPF types:

WPF type Uses Avalonia equivalent
SolidColorBrush / Color / Colors / Brush 263 same names, Avalonia.Media
Point 129 Avalonia.Point
Key 95 Avalonia.Input.Key
Canvas / TextBlock / Ellipse / Rectangle / Panel 81 same names, same attached properties
ICommand 16 already portable, no work

Only one of those files is genuinely a WPF control (ChimeraLegendCanvas); the other 35 are logic
and view models that happen to reach for WPF types.

How hard this is depends entirely on the "replace or coexist" question below:

  • If Avalonia eventually replaces WPF, it is largely mechanical — swap namespaces, keep the
    code. The 600 references map essentially 1:1.
  • If both must coexist, the shared code has to become UI-neutral first, which means replacing
    the colour and geometry types with something both toolkits can consume and adding converters on
    the WPF side. That is a public API change and real work.

Either way, one preliminary is worth doing on its own merits: extracting the UI-free part of
GuiFunctions into a net8.0 project. I have this working locally — 25 files move with no API
change, and it makes 177 of the existing tests run on macOS, which today are Windows-only purely
because GuiFunctions targets net8.0-windows. I would offer that as a separate PR whichever way
this one goes.

The remaining known dependency is OxyPlot: MetaDraw is on 2.0.0, and the live Avalonia path needs
2.2.0 (OxyPlot.Avalonia 2.1.0 pins Avalonia 0.10.11 and is effectively abandoned; the maintained
option is OxyPlot.SkiaSharp). I would do that upgrade in the WPF GUI first, so any plotting
regression is attributable to the upgrade rather than to the port.

Releasing it

The current release job is Windows-only: it patches the version from the tag, builds the WiX MSI,
zips the CLI, and uploads with softprops/action-gh-release@v2. The GUI would slot in as an
additional matrix job on the same tag trigger:

  • dotnet publish -c Release --self-contained for linux-x64, osx-arm64, osx-x64 and
    win-x64, so users need no .NET install
  • macOS wrapped as a .app bundle (Info.plist, then ditto -c -k --keepParent to preserve the
    bundle structure) — a bare binary will not launch from Finder
  • uploaded to the same GitHub release as the MSI

Trade-off worth deciding explicitly: self-contained builds are large (roughly 80–150 MB each), so
this materially increases release size. Framework-dependent builds are ~10× smaller but require the
user to install .NET first, which is most of the friction we are trying to remove. Unsigned macOS
builds are also Gatekeeper-quarantined and need either a documented right-click-open or
notarisation, which requires an Apple Developer account.

Questions

  1. Avalonia as the toolkit — any objection to the reasoning above?
  2. Should this eventually replace the WPF GUI, or run alongside it indefinitely? This is the one
    that changes the most downstream: replacing makes the remaining port mostly mechanical, while
    coexisting requires making the shared code UI-neutral.
  3. Self-contained releases and the size increase: acceptable?

Update since review (six commits)

🤖 29 of the 33 inline findings are fixed, plus the build.yml item that had no line to attach to.
Tests: 35 → 87, all passing on macOS arm64. Detail is in the review replies; the parts that changed
an answer rather than just closing a finding:

  • Apply() fidelity (Gate A). CommonParameters was losing ~30 of its 42 settings per Save and
    DigestionParams 6 of its 12. Both now clone-and-override. Gate A is a reflective load/Save/compare
    over every CommonParameters property, so a setting added later is covered without the test knowing
    about it. Verified failing against the unfixed code.
  • Event marshalling, then the events. OnUiThread tested Dispatcher.UIThread for null, which is
    never null in Avalonia 12, and then Posted to a dispatcher nobody pumped. Fixed first, then
    MetaMorpheusEngine's three events, MyFileManager.WarnHandler and the task-chaining events were
    subscribed. Gate C is a reflection test requiring every static engine event to be handled or listed
    as a deliberate exclusion with a reason; it found two I had missed.
  • Locale. The project had no CultureInfo at all. Reproduced against the unfixed parse: on de-DE
    "0.05" reads as 5.0, and on fr-FR the parse fails and silently substitutes the fallback.
  • Namespace. Renamed to MetaMorpheusAvalonia. Confirmed the hazard first by reproducing
    CS0234: 'Point' does not exist in the namespace 'MetaMorpheus.Avalonia'; there is now a
    compile-time guard so it cannot return. Cheaper now than after MetaDraw's ~600 references.
  • One finding does not reproduce. The modification dedup was said to collapse distinct terminal
    variants. Of 84 colliding (ModificationType, IdWithMotif) identities in the shipped databases,
    zero differ in location, mass or target — all are duplicate glycan entries. The collapse is also
    forced, since that pair is the identity TOML stores. Kept, with a test that fails if that stops
    being true.
  • One gap nobody had spotted. The picker only read GlobalVariables.AllModsKnown, but RNA
    modifications live in AllRnaModsKnown — so an RNA task could not select any of its own
    modifications.

Still open

  • Needs a maintainer: the three Avalonia GUI tests (...) contexts are not required checks
    (branch protection lists only Test on Windows), so a red Avalonia leg merges silently. A fork PR
    cannot change this.
  • Gate B (result equivalence against CMD on the same TOML) is not written yet.
  • IsThermoRawUnsupportedHere still hard-codes the restriction, pending Docs: Thermo .raw reading works on macOS, not just Windows and Linux #2723.
  • MaxThreadsPerFile losing its -1 sentinel is not a GUI defect: CommonParameters' constructor
    resolves it at construction, WPF displays the resolved number too, and the checked-in vignette TOMLs
    say MaxThreadsToUsePerFile = 3. Preserving it would change every TOML MetaMorpheus writes, so it
    wants its own issue.
  • The GuiFunctions extraction is still recommended as a separate PR ahead of further GUI work.

@acesnik

acesnik commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author
Screenshot 2026-08-11 at 20 24 44

@acesnik
acesnik requested a review from trishorts August 12, 2026 01:27
acesnik and others added 2 commits August 11, 2026 20:38
The WPF GUI is the only way to drive MetaMorpheus interactively and it does not
run outside Windows. This adds an Avalonia shell that builds the task list, adds
spectra and databases, and runs EverythingRunnerEngine, plus settings dialogs for
all six task types and fixed/variable modification selection.

Two things worth knowing for review:

- The view model subscribes to MetaMorpheusEngine's static events, so it is
  IDisposable and unsubscribes. Without that, tests passed alone and failed in a
  suite, because each surviving view model kept handling another's events.
- Engine events arrive off the UI thread, so they are marshalled through
  Dispatcher.UIThread.

35 tests, headless via Avalonia.Headless.NUnit. One of them runs a real search end
to end and asserts the defaults still yield 22 PSMs, so a settings regression that
silently changes results fails the build rather than being noticed later.

Not yet ported: file-specific parameters and the smaller auxiliary dialogs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds both projects to the solution, so the existing build job compiles them on all
three platforms, and adds a test job that runs the 35 GUI tests on all three.

The UbuntuMac mappings are set by hand because `dotnet sln add` points that
configuration at Debug, and for GUI.Avalonia it omitted Build.0 entirely - the
solution then builds "successfully" while silently skipping the project.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@acesnik
acesnik force-pushed the feat/avalonia-gui-crossplatform branch from 16f6c37 to e1452cf Compare August 12, 2026 01:39
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.09121% with 161 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.88%. Comparing base (258d748) to head (a1f7f2b).

Files with missing lines Patch % Lines
...etaMorpheus/GUI.Avalonia/Views/MainWindow.axaml.cs 1.85% 53 Missing ⚠️
...eus/GUI.Avalonia/ViewModels/MainWindowViewModel.cs 85.06% 24 Missing and 9 partials ⚠️
...s/GUI.Avalonia/ViewModels/TaskSettingsViewModel.cs 88.55% 13 Missing and 14 partials ⚠️
...lonia/ViewModels/ModificationSelectionViewModel.cs 88.46% 3 Missing and 9 partials ⚠️
...rpheus/GUI.Avalonia/Views/TaskSettingsWindow.axaml 92.68% 12 Missing ⚠️
...eus/GUI.Avalonia/Views/TaskSettingsWindow.axaml.cs 15.38% 11 Missing ⚠️
MetaMorpheus/GUI.Avalonia/App.axaml.cs 37.50% 4 Missing and 1 partial ⚠️
MetaMorpheus/GUI.Avalonia/Program.cs 0.00% 5 Missing ⚠️
...heus/GUI.Avalonia/ToolkitNamespaceIsNotShadowed.cs 0.00% 2 Missing ⚠️
MetaMorpheus/GUI.Avalonia/App.axaml 75.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #2709      +/-   ##
==========================================
- Coverage   93.32%   92.88%   -0.45%     
==========================================
  Files         214      225      +11     
  Lines       21804    22753     +949     
  Branches     4083     4170      +87     
==========================================
+ Hits        20349    21134     +785     
- Misses        901     1040     +139     
- Partials      554      579      +25     
Flag Coverage Δ
avaloniagui 31.60% <82.09%> (?)
unittests 93.25% <0.00%> (-0.08%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
MetaMorpheus/EngineLayer/CommonParameters.cs 99.55% <100.00%> (+0.03%) ⬆️
...aMorpheus/EngineLayer/DatabaseLoading/DbForTask.cs 100.00% <100.00%> (ø)
MetaMorpheus/GUI.Avalonia/Views/MainWindow.axaml 100.00% <100.00%> (ø)
MetaMorpheus/GUI.Avalonia/App.axaml 75.00% <75.00%> (ø)
...heus/GUI.Avalonia/ToolkitNamespaceIsNotShadowed.cs 0.00% <0.00%> (ø)
MetaMorpheus/GUI.Avalonia/App.axaml.cs 37.50% <37.50%> (ø)
MetaMorpheus/GUI.Avalonia/Program.cs 0.00% <0.00%> (ø)
...eus/GUI.Avalonia/Views/TaskSettingsWindow.axaml.cs 15.38% <15.38%> (ø)
...lonia/ViewModels/ModificationSelectionViewModel.cs 88.46% <88.46%> (ø)
...rpheus/GUI.Avalonia/Views/TaskSettingsWindow.axaml 92.68% <92.68%> (ø)
... and 3 more

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@trishorts

Copy link
Copy Markdown
Contributor

Thanks for opening this as a draft with the questions stated up front — that framing made it much easier to review as a direction proposal rather than as a merge candidate. Answering Q1–Q3 below. A separate review with line-level findings follows; none of it is a merge objection, since this isn't offered for merge.

Short version: the toolkit choice is sound, converge rather than coexist, and there is one piece of sequencing that has become urgent since you opened this.


0. The thing that changed this week: rebase onto .NET 10 before Milestone 1

#2710 and smith-chem-wisc/mzLib#1141 (both "Move from .NET 8 to .NET 10", both non-draft and mergeable) are expected to land shortly. That reorders the work here.

As it stands, this PR will not restore once #2710 merges. GUI.Avalonia.csproj has a ProjectReference to TaskLayer; when TaskLayer becomes net10.0, a net8.0 GUI.Avalonia cannot reference it — NU1201, a hard error at restore. And because this PR adds both projects to MetaMorpheus.sln, it breaks the solution-wide restore: build.yml, both Test.yml jobs, InstallRunAndArtifact.yml and DockerUpload.yml, on every platform leg.

There is no textual merge conflict, which is the trap. I checked with a real three-way merge against the shared base: #2710's Test.yml hunks and yours are disjoint, and #2710 doesn't touch MetaMorpheus.sln at all. So the merge succeeds silently and leaves your avalonia-gui-tests job pinned to dotnet-version: 8.0.204 while every sibling job moves to ${{ env.DOTNET_VERSION }} and global.json demands ≥ 10.0.400 with rollForward: latestFeature, which never rolls back.

The fix is four lines, and it is much cheaper now than after Milestone 1:

  1. GUI.Avalonia.csprojnet10.0
  2. GUI.Avalonia.Tests.csprojnet10.0
  3. the avalonia-gui-tests job: 8.0.204${{ env.DOTNET_VERSION }}
  4. the net8.0/headless comment above the job

Nothing in the dependency set objects: Avalonia 12.1.1 and Avalonia.Controls.DataGrid 12.1.2 both ship lib/net10.0 with native net10.0 dependency groups, and Avalonia.Headless.NUnit, NUnit 4.5.1 and the test SDK all restore and build clean on net10.0. CommunityToolkit.Mvvm 8.4.2 has no net10.0 group, which is a non-issue — the net8.0 asset applies under normal backwards compatibility and compiles cleanly. Flagging that explicitly so it doesn't send anyone hunting for a replacement.

The stronger reason to do it first, though, isn't the build break — it's that your compiled bindings aren't being checked right now. CI shows 8 × CS9057 per leg because Avalonia's analyzers target Roslyn 4.14 and the pinned SDK ships 4.11. One of those skipped assemblies is Avalonia.Generators.dll, a source generator, and GUI.Avalonia.csproj sets AvaloniaUseCompiledBindingsByDefault=true. So the compiled-bindings path is currently unvalidated. I reproduced this with your exact package set: SDK 8.0.418 (Roslyn 4.11.0) → the identical CS9057 pair; SDK 10.0.300 (Roslyn 5.6.0) → 0 warnings, 0 errors. Note the trigger is the SDK pin, not the TFM — a net8.0 build on the .NET 10 SDK is also clean.

Milestone 1 is the milestone that adds most of the XAML and bindings. Written today, all of it compiles without the generator ever running, and the errors surface later across a much larger diff with no clean bisect point.

So: stop any CS9057 suppression or NoWarn, don't downgrade Avalonia to match Roslyn 4.11, and don't harden anything against net8.0. When you rebase, re-measure the warning baseline rather than subtracting 8 — .NET 10 turns on transitive package auditing and package pruning, which may add warnings of their own. Order is forced: #2710 → mzLib#1141 → this, rebased. One upside worth stating: .NET 10 is LTS through 2028-11-14, so this work gets a two-year runway instead of three months.


Q1 — Avalonia as the toolkit

No objection, but two caveats the reasoning doesn't cover, and one argument in your favour you didn't make.

The WPF-similarity argument holds, and there's a strong precedent you don't cite: JetBrains ported dotMemory and dotTrace from WPF to Avalonia — data-dense, chart-and-grid-heavy engineering tools, exactly this shape — and reached all three OSes. (Rider is not Avalonia; best not to cite it.) Unity collapsed WinForms + GTK# + Xamarin.Mac into one Avalonia codebase in ~2 months.

Caveat 1: there is zero Avalonia prior art in mass spectrometry. I searched hard for it — web, gh search repos across ten term variants, and code search inside LCMS-Spectator, UIMF-Viewer, LIQUID, stitch, topdownproteomics/sdk, ECLViewer2 and ProteoWizard — and found nothing. MetaMorpheus would be first. No community to copy from under plot-heavy MS workloads; conversely, a real novelty claim if this ever becomes a technical note.

Caveat 2: Wayland behaviour under Avalonia 12 is unconfirmed, and is the likeliest unpleasant Linux surprise.

Two claims worth tightening before a maintainer pushes back:

  • MAUI — the defensible framing is maintenance mode, mobile-first roadmap, no Linux, not "no real Linux story implies dead". Microsoft reaffirmed commitment in May 2025, and its own guidance for an existing WPF app is "keep WPF, modernise with Windows App SDK".
  • Uno — there's a harder blocker than "a genuinely different model": Uno.WinUI 6.6.184 declares dependency groups for net9.0/net10.0 only, no net8.0. Note this particular objection dissolves once Move from .NET 8 to .NET 10 #2710 lands, so it's worth re-checking rather than repeating. Uno also offers free Pro licences to students, teachers and OSS maintainers — a UW–Madison lab may qualify twice.
  • Please drop "Microsoft uses Avalonia" unless it can be sourced.
  • Avalonia XPF, the binary-compatible WPF fork that would make this nearly free, is €9,500 / €29,500 / €124,500+ per app with no academic tier. The cheap path exists and is priced out of reach.

The strongest argument for this PR is the peer landscape, and it isn't in the description. The dominant pattern in the field is cross-platform engine, Windows-only GUI: Skyline (WinForms; the official answer is Parallels), MaxQuant, DIA-NN and Spectronaut are all Windows-GUI/Linux-CLI; FragPipe ships no macOS asset at all. Only MZmine (JavaFX) and OpenMS/TOPPView (Qt) have real cross-platform GUIs — and both were born cross-platform; neither ported a Windows GUI. MetaMorpheus already has the cross-platform engine the others lack, so this would put it ahead of Skyline, MaxQuant, DIA-NN, Spectronaut and FragPipe on OS reach, not at parity.

The counterweight is where the recurring cost actually lands: MZmine maintains an institutional Apple Developer ID with a documented sign/notarise/staple procedure and ships separate Intel and Silicon artefacts, and OpenMS explicitly does not conda-package its GUI tools. The cost is in shipping, which MM's CI has none of today.


Q2 — Replace or coexist

Converge, don't coexist — but the convergence is the extraction, not a rewrite, and it should be sequenced behind cheaper work.

Coexistence is the expensive option, and divergence has already started. This PR makes fixed/variable modifications mutually exclusive because "the WPF window allows it" — and you're right: WPF harvests two independent trees with no cross-check (SearchTaskWindow.xaml.cs:567-577), and the only follow-up is TaskValidator.VariableModCheck, which merely warns when more than one variable mod is selected. You also normalize ValidModification filtering to all task types, which WPF applies only in Search. Both are genuine improvements — and both are behavioural forks, where two GUIs now build different tasks from identical user actions. Please state them in the PR description and open an issue against the WPF side, so they read as decisions rather than drift.

For calibration: GUI/ is ~9,667 XAML + ~13,182 C#, GuiFunctions/ ~14,484 C# — ~37k LOC, against ~1,900 here. That makes this roughly 3–5% of a replacement, and the remaining 95% is where the divergence risk lives.

After the .NET 10 rebase, three things should still come before more GUI work.

1. Test whether Thermo .raw actually reads on an arm64 Mac. This is the highest-value experiment in the whole decision, because it either removes or confirms the largest objection to a macOS GUI — and MainWindowViewModel.IsThermoRawUnsupportedHere currently hard-codes the assumption.

I need to correct something I nearly told you: there is no x64 "pin" in mzLib to lift. All 19 mzLib projects declare <Platforms>x64</Platforms>, but that element only populates the IDE platform list — MSBuild never validates $(Platform) against it. Built with /p:Platform="Any CPU", Readers.csproj compiles clean and emits an architecture-neutral I386/PE32 assembly, and mzLib#1127 achieved arm64 without touching a single .csproj.

The real suspect is much more specific. mzLib vendors Thermo CommonCore 8.0.37 as four checked-in DLLs (bare <Reference> + HintPath, not a NuGet package), all managed IL with no native payload, and there is no platform guard anywhere in mzLib — zero hits for PlatformNotSupported, IsWindows or #if WINDOWS. Three of the four are architecture-neutral I386/PE32. But ThermoFisher.CommonCore.MassPrecisionEstimator.dll is AMD64/PE32+ — precisely the case #1127's own release.yml comment describes: "An x64 PlatformTarget stamps AMD64 into the PE header, and an arm64 .NET runtime then refuses the assembly." #1127 fixed that for mzLib's own assemblies and left this vendored third-party one untouched.

Load that one assembly on an arm64 Mac first. It's minutes of work and it's the most likely single point of failure. If it loads, README.md:43's "Thermo .raw — Windows and Linux only" may simply be stale, and the macOS story gets substantially better. (Known genuine limitation off-Windows: instrument-method metadata needs -LoadMethod:false; scan data is fine.)

2. Let #2710 and mzLib#1141 land, and rebase onto them — as above.

3. Ship a bioconda recipe + BioContainer + an arm64 Docker manifest. MetaMorpheus is absent from bioconda entirely, while comet-ms has 336k downloads, openms 117k and sage-proteomics 44k. There are zero MetaMorpheus images on quay.io/BioContainers and no Galaxy wrapper, and the Docker Hub image (~34.7k pulls) is linux/amd64 with no arm64 manifest. That's roughly a weekend and it delivers more cross-platform reach this year than a GUI will.

Then: the cross-platform GuiFunctions extraction → de-WPF the shared layer → the OxyPlot decision → the Avalonia GUI rebased on it.

One thing Bruker settles permanently. mzLib ships timsdata.dll/baf2sql_c.dll as Windows-x64 natives — verified native, AMD64, PE32+ — and the nuspec ships them into both lib\net8.0 and lib\net8.0-windows7.0, so even the architecture-neutral package carries Windows-only binaries. There are 26 DllImports across BrukerFileReader.cs and the timsTOF readers with no platform guard of any kind; the first calls to fail on macOS would be BrukerFileReader.cs:631 for .baf and TimsTofFileReader.cs:150 for timsTOF, with a native-library load failure rather than a clean PlatformNotSupportedException. (I haven't run this on a Mac — the missing guards and the call sites are verified; the exact exception type is inferred.) Bruker has never shipped a macOS library; AlphaTims estimates m/z from metadata on macOS with "errors up to 6 Th"; timsrust supports Linux and Windows only. The msconvert escape hatch is closed too — the ProteoWizard Wine image is linux/amd64-only and the Apple Silicon wine64 bug has been open since 2022, atop a Rosetta 2 phase-out around 2027.

A Mac user with a timsTOF cannot use MetaMorpheus regardless of GUI toolkit. That's not an argument against this work; it's a scope boundary the PR should state up front.

Two work items missing from the plan:

  • Thermo licence obligations follow the GUI. ThermoRawFileReaderLicence.cs §3.2 requires the About box to display "RawFileReader reading tool. Copyright © 2016 by Thermo Fisher Scientific, Inc.", and §3.3 requires end-user acceptance. MM implements both (GUI/Views/ThermoLicenceAgreementWindow.xaml plus a CMD prompt). A second GUI must re-implement both. Small, non-optional, currently absent.
  • Consider the MultiQC/AlphaPept pattern for MetaDraw specifically — a self-contained interactive HTML report, no server and no toolkit commitment. That decouples the hardest ~36 files from the GUI decision entirely and is the cheapest possible "MetaDraw on a Mac". AlphaPept (Mann lab, Nat. Commun. 15, 2168) took the local-server-plus-browser route for exactly this reason.

Milestone 1 — Calibrate → GPTMD → Search, cross-platform

Deliverable: a macOS/Linux user builds and runs MetaMorpheus's standard three-task pipeline without hand-writing TOML. XLSearch, GlycoSearch and Average are explicitly out of scope, and their currently-unreachable code comes out.

Why this scope rather than Search-only: CalibrationParameters is seven defaulted, rarely-touched properties, so Calibrate needs no task-specific UI. GptmdParameters' one setting users change is ListOfModsGptmd — the same widget as the fixed/variable picker. So one mod-selection control unlocks both Search's mods and GPTMD's. Search alone can't run the recommended workflow, so it wouldn't retire the hand-written-TOML problem.

Gates — binary, independently checkable, agreed before the work

A — Parameter fidelity. Load a task from TOML with non-default values across the parameter space, open settings, click Save without editing, write TOML back. The two TOMLs must be equivalent. One test subsuming dozens. It fails today — see the inline findings on Apply().

The repo already contains both halves of the mechanism, so this is small: Test/TestToml.cs:61 asserts results.SequenceEqual(resultsToml), running both the original and the round-tripped task through EverythingRunnerEngine and comparing output byte-for-byte — the strongest fidelity assertion in the codebase, and one that needs no property list. Pair it with the reflective GetProperties() loop CommonParameters.Clone() already uses and Gate A is roughly 15 lines.

No-go: if this can't pass without restructuring the engine's parameter objects, stop — every later task inherits the defect. On current evidence it can, easily: CommonParameters.Clone() and DigestionParams.Clone() already exist and already do exactly this.

B — Result equivalence with the CLI. Build the three-task pipeline through the GUI, run it, run the same TOML through CMD on the same input, compare. This replaces the 22-PSM assertion with something that cannot pass by coincidence.

C — Event-surface completeness. A reflection test asserting that every event the WPF GUI subscribes to is either subscribed here or in an explicit, commented exclusion set. One ordering constraint: the marshalling has to handle events raised from arbitrary pool threads first, or newly-subscribed engine events get posted to an unpumped dispatcher and silently dropped, and the gate passes while delivering nothing. Details inline.

D — CI genuinely exercises three platforms. Build.0 under UbuntuMac (done, and correct across all three configurations); new assemblies covered by the arm64 PE-header assertion; coverage actually collected from the new job; long-running tests filtered consistently; and the three Avalonia contexts made required checks by a maintainer — a fork PR can't do that itself.

E — No newly invented conventions. CultureInfo.InvariantCulture on every double round-trip, matching the 157 existing uses with zero counter-examples; GlobalVariables.AcceptedSpectraFormats rather than a copied literal; Avalonia kept at 12.1.1 with the SDK moved forward, not pinned backwards; namespace renamed off MetaMorpheus.Avalonia; TaskValidator's thresholds matched rather than re-derived. And where duplication is genuinely forced by the TFM split, a comment saying so plus a tracking issue.

F — Claims match the UI. Every capability in the PR description reachable by clicking.


Q3 — Distribution, merge and release

Merged ≠ released, and that's the safety net. MetaMorpheusSetup.wixproj harvests only CMD/EngineLayer/GUI/TaskLayer with DoNotHarvest=True, and InstallRunAndArtifact.yml's validate_installer_contents list omits GUI.Avalonia/bin — so a merged Avalonia GUI ships to nobody and has zero runtime effect on existing users. Adding it to the harvest list is the deliberate act that turns merged code into shipped software. That's the real release gate, and it means merging early is low-risk.

There is a live cost to the asymmetry today, though: adding GUI.Avalonia to the sln means BuildReleaseArtifact.yml and InstallRunAndArtifact.yml now compile it on every release build, while packaging still only zips CMD\bin\Release\net8.0\* and the MSI. The release pipeline pays for the GUI and emits nothing anyone can install. Is a follow-up artifact step planned?

On size — that's the wrong axis. GitHub allows 2 GiB per file, no total release limit, and unmetered bandwidth. The real constraints are:

  • BuildReleaseArtifact.yml patches <Version> in EngineLayer.csproj only; GUI.Avalonia.csproj has no <Version>, so a shipped GUI would self-report 1.0.0. A Directory.Build.props with one <Version> would be better than XML-poking csprojs — the repo has none today, which is also why the SDK version is hard-coded in several places.
  • macOS must publish on a macOS runner. The SDK ad-hoc-signs the osx-arm64 apphost only when the host is macOS; cross-publishing from Linux/Windows yields a binary that dies with killed: 9 before Gatekeeper is even reached. So a 3-OS matrix, not one job.
  • ditto -c -k --keepParent is right and important (plain zip destroys the symlinks inside a .app). But "documented right-click-open" is out of date — macOS 15 removed that shortcut; users now go to System Settings → Privacy & Security → Open Anyway. An unsigned quarantined bundle also often reports "is damaged and can't be opened", which reads as corruption rather than security. Worth checking Apple's current wording before documenting it.
  • Notarisation, if you ever go there: $99/yr Developer Program, a Developer ID Application certificate (not available to a free personal team), hardened runtime, notarytool submit --wait + stapler staple, .p12 in a CI secret. On a self-contained .NET bundle every embedded .dylib must be signed inside-out first — dozens here (Microsoft.ML, SQLite, mzLib, Skia).
  • Say no trimming out loud in the workflow — Nett, NetSerializer and Microsoft.ML are all reflection-heavy.

Recommendation: framework-dependent for linux-x64 (~15 MB; that user can install the .NET runtime), self-contained for macOS (where "install .NET first" is the friction being removed, and the .app bundle is cleaner self-contained), MSI unchanged for Windows. Halves size and CI cost while removing friction where it actually exists. And worth noting the Windows MSI is already unsigned and users already click through SmartScreen — so unsigned macOS builds with a good README are consistent with the project's existing posture, not a new compromise.


What abandonment would actually cost

The code being "deletable in two directories" is true, and misses four things:

  1. A permanent CI tax on every future PR by every contributor — though in fairness the measured cost today is small: ~16 s per leg, 1–3 minute jobs against 37 minutes for the required Windows job.
  2. Dead code that can redden the required check: both projects have Release|Any CPU.Build.0, so they compile inside the required Windows test job. The WPF GUI project shows the lever — ActiveCfg with no Build.0 under UbuntuMac is exactly how it isolates itself.
  3. 117 warnings instead of 95, until the .NET 10 rebase.
  4. A second MVVM stack, a 13th copy of the mod-tree build, a second settings-mapping layer, a second validation layer and a third copy of the contaminant rule — with the fixed/variable rule already a deliberate behavioural fork.

Suggested PR sequence. (0) Rebase onto .NET 10 — now the first move, not a later cleanup. (1) The cross-platform GuiFunctions extraction, alone: it retains full value even if the GUI is abandoned, and doing it before Milestone 1 stops the duplicate being hardened. Two caveats the description omits — moving source frees no test on its own, because Test.csproj is itself net*-windows/x64 and must also be split or multi-targeted; and any new extraction project must copy GUI.Avalonia.csproj's deliberate omission of PlatformTarget (your #2503/#2504 comment) rather than inheriting GuiFunctions.csproj's PlatformTarget x64. (2) Milestone 1 in small pieces, each behind its gate. (3) No release artifact until A–F pass.

On the extraction's real size, correcting my own first estimate: BaseViewModel and RelayCommand are already portable as-is — zero WPF usings, and ICommand lives in System.ObjectModel.dll, which Avalonia binds identically — so those move for free. The mod-tree types do not: ModForTreeViewModel is WPF-bound at five separate points via DrawnSequence, carries OxyPlot OxyColor and MetaDrawSettings statics, and its SelectedColor/ColorBrush are load-bearing for MetaDraw. A UI-neutral tree model plus WPF-side subclasses, then rewiring ~13 build sites and re-verifying MetaDraw colour round-tripping, is a multi-hundred-line refactor of the shipping GUI. Its own PR, not folded into this one.

Stop-without-waste points, so this never becomes sunk cost: after the .NET 10 rebase (pure gain regardless); after the GuiFunctions extraction (full value retained — existing tests running on macOS, currently untestable code becoming testable); after Gate A fails, if it does (you'd have learned the parameter contract can't be made faithful, for one PR's cost); and after Milestone 1 ships (a coherent product even if nothing follows). The genuinely irreversible commitment is MetaDraw, where "make GuiFunctions UI-neutral" stops being additive and becomes a public API change to code the WPF GUI depends on.

A long-lived branch is the failure mode this PR already brushed against — seven commits behind, re-doing #2701. You caught and fixed that; the .NET 10 migration is the next instance and it's days away.


Nice work on this. Several things here are done more carefully than the surrounding codebase: dropping the redundant #2701 commit in the rebase rather than carrying it, the deliberate PlatformTarget omission with its explanatory comment, avoiding a ProjectReference on the Windows-only Test project, verifying the transitive content-folder copying rather than asserting it, complete and correct sln rows across all three configurations, routing errors to the log instead of modal boxes, and the OnUiThread inline fallback — which the WPF side has no equivalent of. The premise of TighteningThePrecursorToleranceChangesTheResult — proving the dialog reaches the engine by observing a result change rather than reading a property back — is the best idea in the test suite and worth applying more widely.

@trishorts trishorts left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Line-level findings from a full pass over the diff, with the surrounding MetaMorpheus and mzLib code checked for each one. Commented rather than "request changes", because this is a draft opened for a direction decision — none of this is a merge objection. My answers to Q1–Q3 are in the conversation comment above; this review is just the detail.

Two framing notes before the findings.

Most of the duplication here was forced, not careless. GuiFunctions is net8.0-windows with UseWPF and 60+ WPF/System.Drawing dependencies, and GUI.Avalonia is plain net8.0 — so it cannot reference it at any price. For the MVVM base, the mod tree and the contaminant rule, writing new code was the only option available to you. Where I raise those, the ask is that they be recorded as deliberate, tracked duplication, not that they be undone here.

One theme ties the three most serious findings together. TaskSettingsViewModel.Apply() drops 28 of CommonParameters'' 42 constructor arguments and 6 of DigestionParams'' 12, and LoadFrom reads the wrong protease property. All three are fixed by the same move: CommonParameters.Clone() (EngineLayer/CommonParameters.cs:222) and DigestionParams.Clone() (mzLib Proteomics/ProteolyticDigestion/DigestionParams.cs:128) already exist, already copy every field forward, and the GUI has never used either. Clone, then overwrite only what the dialog edits. That single change takes out the highest-severity items in this review.

One item with no diff line to attach to, so it goes here: build.yml:56 asserts architecture-neutral PE headers by looping for project in CMD EngineLayer TaskLayer and deriving the DLL name from the project name (:57). GUI.Avalonia.csproj sets <AssemblyName>MetaMorpheus.Avalonia</AssemblyName>, so adding GUI.Avalonia naively would look for a file that doesn''t exist and hit the hard-failure branch at :60 — it needs project:assembly pairs. Worth doing: this PR''s .sln change means GUI.Avalonia now compiles on the Unix legs, so the desktop app users will actually launch on Apple Silicon is the one new assembly that assertion exists to protect, and the one left unchecked. It would pass — the csproj deliberately omits PlatformTarget. Also, build.yml:77 still carries TODO(#2503): make this step required after the mzLib bump lands, and 1.0.584 has landed, so that TODO is now actionable.

A few smaller things I didn''t comment inline, to keep the noise down: CanRun (MainWindowViewModel.cs:195) raises no notification and isn''t bound — the Run button uses IsEnabled="{Binding !IsRunning}" — so it''s test-only; same for the unused Version property. Log += line (:246) is O(n²) over a multi-MB string bound to a TextBox that re-measures the whole document per line, and per the dispatcher note it now executes on engine worker threads, making it a racy read-modify-write. DoQuantification (:85, assigned :145) is never read by Apply() and unbound. MaxThreadsPerFile loses the -1 sentinel, so a load/apply round trip bakes a machine-specific integer into the TOML. CreateSettingsFor (TaskSettingsViewModel.cs:182-185) can return null and MainWindow.axaml.cs:67-72 dereferences it unguarded. MassDiffAcceptorType.Custom is offered but Apply() never sets SearchParameters.CustomMdac, which WPF does at SearchTaskWindow.xaml.cs:748. Rebuild() re-materialises ~4,000 mods and raises Reset on every keystroke, discarding scroll offset and Expander state — though selection state is correctly preserved by reusing ModificationChoice instances, which is good design. GlobalVariables.SetUpGlobalVariables() runs per-test in four fixtures; [OneTimeSetUp] would save re-parsing the mod databases ~20 times per leg. And the namespace MetaMorpheus.Avalonia shadows the toolkit root — it compiles today only because every using Avalonia.* sits above the file-scoped namespace declaration, but with ~600 WPF type references in the stated MetaDraw roadmap, fully-qualified Avalonia.Point/Avalonia.Media references will fail with errors pointing at the wrong thing, fixable only with global::. Repo convention is a single token (MetaMorpheusGUI), so MetaMorpheusAvalonia both matches and dodges it. That one is rename-now-or-never.

Things I checked and specifically am not raising, so nobody re-litigates them: the .sln configuration rows are correct and complete across all three configurations; the deliberate PlatformTarget omission with its #2503/#2504 comment is right and any future extraction project should copy it; [assembly: AvaloniaTestApplication] is wired correctly; static-event unsubscription in Dispose is correct and every test view model uses using var; RunAsync re-entrancy can''t happen because [RelayCommand] disallows concurrent execution; Avalonia 12.1.1 against DataGrid 12.1.2 is fine (the DataGrid nuspec declares Avalonia >= 12.1.0 and ships out of band); the App.axaml DataGrid theme include is present; compiled bindings against internal view models are fine; and GitHub release size limits aren''t a real constraint. I also initially thought the end-to-end test never observed engine events — that was wrong, and I verified it by instrumenting the real view model: the events land, Progress reaches 100, and the tests genuinely pass 9/9 with the search writing 2 .psmtsv files in about 2 seconds.

Nice work on this — the test suite in particular is better than the WPF side it''s modelled on, and I''ve said so in the conversation comment.

private void Cancel()
{
// The engines poll this cooperatively; there is no forceful abort.
GlobalVariables.StopLoops = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result-changing: Cancel() permanently poisons the process.

This sets GlobalVariables.StopLoops = true and nothing ever resets it. The WPF GUI does both halves: GUI/MainWindow.xaml.cs:843 sets it true on cancel, and :896 sets it false as the very first statement of RunAllTasks_Click. EverythingRunnerEngine.Run() never touches it, and the ~19 TaskLayer/EngineLayer sites only read it.

So: click Cancel once, then Run again — every later run in that process returns in under a second, writes no results, and reports "Ready." The only recovery is restarting the app.

One-line fix at the top of RunAsync (around line 214), mirroring the WPF ordering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in b34c6d12, mirroring WPF's ordering (reset before validation). Worth noting the symptom is worse than described: the next run starts, then dies with ArgumentNullException (Parameter 'source') when an engine bails out early — a loud failure pointing nowhere near the cancel. The regression test cancels and then runs a real search requiring results, rather than reading the flag back.


CommonParameters existing = _task.CommonParameters ?? new CommonParameters();

_task.CommonParameters = new CommonParameters(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result-changing: this silently resets 28 of CommonParameters'' 42 constructor arguments.

14 named arguments are passed here; EngineLayer/CommonParameters.cs:25-67 takes 42. The rest revert to constructor defaults rather than the task''s own values, on every Apply — including addCompIons, totalPartitions, separationType, pepQValueThreshold, trimMs1Peaks, addTruncations, assumeOrphanPeaksAreZ1Fragments, numberOfPeaksToKeepPerWindow, precursorDeconParams, productDeconParams, precursorMassMatchMode and rtPredictorName.

This is the exact failure mode the codebase warns about in capitals, in two places:

  • EngineLayer/CommonParameters.cs:150-151 — "If you add a new property here, you must add it to MetaMorpheusTask.cs/SetAllFileSpecificCommonParams !! If you forget this, ... your settings will be overwritten by default values!"
  • TaskLayer/MetaMorpheusTask.cs:584 — "//NEED THESE OR THEY''LL BE OVERWRITTEN", above the 33 lines that pass every remaining argument forward.

MetaMorpheusTask.SetAllFileSpecificCommonParams (MetaMorpheusTask.cs:519) is the canonical "override a subset, lose nothing" implementation and is structurally identical to a settings dialog. For comparison, SearchTaskWindow.xaml.cs:622-654 passes 32 of 42, with most of the remainder carried through precursorDeconParams/productDeconParams.

The fix is one call, not 28 arguments. CommonParameters.Clone() already exists at CommonParameters.cs:222 (reflection over every property), with CloneWithNewDissociationType (:232) and CloneWithNewTerminus (:243) beside it. Four engine/task call sites use them; the GUI uses none. Clone existing, then overwrite only what the dialog actually edits.

Checkable today: GlycoSearchTask.cs:21-41 sets ms2childScanDissociationType: EThcD and numberOfPeaksToKeepPerWindow: 1000. Open its settings, click Save without editing anything, and they become Unknown and 200. SpectralAveragingTaskWindow.xaml.cs:60-67 deliberately sets dissociationType: LowCID ("ensures no filters occur when loading files in MM Task"); this dialog writes HCD.

Minor: the XML comment above this method, and the one on SettingsNotShownInTheDialogAreNotLost, both say "41-argument constructor". It''s 42.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 4ab29d9c via a new CommonParameters.CloneWithNewValues, a sibling of the existing CloneWithNewDissociationType. Also corrected the "41-argument" comments — it is 42.

/// </summary>
public void Apply()
{
IDigestionParams digestion = new DigestionParams(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result-changing: 6 of DigestionParams'' 12 constructor parameters are dropped, and RNA mode throws.

mzLib''s Proteomics/ProteolyticDigestion/DigestionParams.cs:15-18 takes 12 parameters. Six are passed here. The consequences differ in severity:

  • searchModeType is dropped, so a NonSpecific or Semi search silently becomes CleavageSpecificity.Full on Save. SearchTaskWindow.xaml.cs:540 preserves it precisely because only Search offers semi/non-specific — a shared VM covering SearchTask inherits that obligation.
  • fragmentationTerminus is dropped (same reason, SearchTaskWindow.xaml.cs:541).
  • initiatorMethionineBehavior is dropped. This one is preserved by 4 of the 5 WPF windows, plus the file-specific path (MetaMorpheusTask.cs:542) and mzLib''s own Clone. Only CalibrateTaskWindow omits it, and that''s the one window with no such combo box. Dropping it here regresses Search, GPTMD, XL and Glyco to Calibrate''s level.
  • keepNGlycopeptide / keepOGlycopeptide are also dropped.

Separately, this line throws in RNA mode. All five WPF windows branch on GuiGlobalParamsViewModel.Instance.IsRnaMode and build RnaDigestionParams instead (SearchTaskWindow.xaml.cs:520-528, CalibrateTaskWindow.xaml.cs:280-288, GPTMDTaskWindow.xaml.cs:539-547). There''s no RNA branch here, and new DigestionParams(protease: ...) performs an unguarded ProteaseDictionary.Dictionary[protease] (mzLib DigestionParams.cs:20) — so any RNase name gives a KeyNotFoundException.

Same fix as the CommonParameters comment: DigestionParams.Clone(FragmentationTerminus? = null) (mzLib DigestionParams.cs:128) copies all 12 forward and already branches on SearchModeType == CleavageSpecificity.None.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in beef683a, using the same clone-and-override shape. One correction: the RNA case never threw. LoadFrom's type test was is DigestionParams, which RnaDigestionParams fails, so Protease kept its "trypsin" default and Apply() replaced the RnaDigestionParams with a proteolytic one — no exception, an RNA task quietly became a protein search.


if (common.DigestionParams is DigestionParams digestion)
{
Protease = digestion.Protease?.Name ?? Protease;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result-changing: this displays an enzyme the user never chose.

The WPF convention is SearchTaskWindow.xaml.cs:200:

ProteaseComboBox.SelectedItem = digestionParams.SpecificProtease; //needs to be first, so nonspecific can override if necessary

For a non-specific task, Protease is singleN/singleC while SpecificProtease holds the user''s actual choice (mzLib DigestionParams.cs:45 and RecordSpecificProtease()). Reading Protease?.Name means loading a semi- or non-specific task shows singleN in the enzyme box.

Combined with searchModeType being dropped in Apply(), the round trip both loses the mode and misreports the enzyme. DigestionParams.Clone() handles this case explicitly and would fix both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in beef683a — reads SpecificProtease, with a test that a non-specific task shows the user's enzyme in the box and keeps CleavageSpecificity.None across Save.


private static Tolerance BuildTolerance(string value, bool isPpm, double fallback)
{
if (!double.TryParse(value, out double parsed) || parsed <= 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-platform: culture-sensitive parsing, in the PR whose entire purpose is other platforms and locales.

double.TryParse(value, out double parsed) uses the current culture. The five WPF task windows contain 157 uses of CultureInfo.InvariantCulture with zero counter-examples — every double round-trip, without exception (SearchTaskWindow.xaml.cs:299,317,326,511,549; TaskValidator.cs:125-356). GUI.Avalonia contains no CultureInfo at all.

Two distinct failure modes on a de-DE machine:

  1. double.TryParse("0.05") returns true, with the value 5.0 — . is the group separator there and .NET Core parses it leniently. A user typing 0.4 into Product tolerance silently gets a 10× looser tolerance, with no error and no visual cue.
  2. When a parse genuinely fails, this method doesn''t report it — it silently substitutes fallback (5 ppm / 20 ppm). Validate() uses the same current-culture parse, so it reports nothing either.

The formatting side has the same problem (Describe at :196-202 calls .ToString() with no culture), so a value written on one locale can be misread on another.

This also breaks SettingsAffectResultsTests.cs:80 on such a machine, with a failure message that falsely accuses correct wiring.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 8d35b030. Both failure modes reproduced against the unfixed parse: on de-DE "0.05" reads as 5.0, and on fr-FR the parse fails and BuildTolerance silently substitutes its 20 ppm fallback. The test covers de-DE, fr-FR and en-US, and the formatting side goes through InvariantCulture too.

Comment thread MetaMorpheus/MetaMorpheus.sln Outdated
EndProject
Project("{B7DD6F7E-DEF8-4E67-B5B7-07EF123DB6F0}") = "Bootstrapper", "Bootstrapper\Bootstrapper.wixproj", "{E0EA5AC4-24A9-43DC-8FBC-CCEB3B9935B6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GUI.Avalonia", "GUI.Avalonia\GUI.Avalonia.csproj", "{BD94D208-D962-42AC-84A7-E66A39FBACA8}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both new projects use the legacy project-type GUID {FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}, while all seven existing C# projects in this solution use the SDK-style {9A19103F-16F7-4668-BE54-9A1E7A4F7556}.

MSBuild ignores it, but Visual Studio uses it to choose a project system. Cosmetic, one-character-class fix.

(The configuration rows below are correct and complete, incidentally — Debug|Any CPU, Release|Any CPU and UbuntuMac|Any CPU, each with both ActiveCfg and Build.0. That''s the thing most easily got wrong after #1127, and it''s right here.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 9d2d3f6d — both entries now use the SDK-style GUID like the other seven. (And noted on the configuration rows; those were the part I was most worried about getting wrong.)

/// Apply() rebuilds CommonParameters from a subset of its 41 constructor arguments, so anything
/// not represented in the dialog must survive rather than reverting to a constructor default.
/// </summary>
[Test]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test certifies the opposite of its name.

It asserts only TaskDescriptor (:121) and ListOfModsFixed (:123) — and those are two of the 14 arguments Apply() does forward. None of the 28 arguments it drops are checked, so the test passes precisely because it examines the safe subset.

RemainingTaskTypeTests.GlycoSearchOptionsRoundTrip (ModificationAndTaskTypeTests.cs:143-160) has the same shape: it runs Apply() on a GlycoSearchTask and passes while silently degrading ms2childScanDissociationType from EThcD to Unknown.

The repo already has the mechanism to make this real. Test/TestToml.cs:61 asserts results.SequenceEqual(resultsToml) — running the original and the round-tripped task through EverythingRunnerEngine and comparing output byte-for-byte. That''s the strongest fidelity assertion in the codebase and it needs no property list. Combined with the reflective GetProperties() loop CommonParameters.Clone() already uses (CommonParameters.cs:225), an exhaustive version of this test is about 15 lines and would fail today on all 28.

That test is Gate A in my conversation comment — the one gate I''d want passing before any further task types are added, since every later task inherits the defect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in beef683a — this is Gate A. ApplyWithoutEditingChangesNothing loads a task holding non-default values, Saves without editing, and requires every CommonParameters property to compare equal by reflection, so a property added later is covered without the test knowing about it. Three explicit tests name the digestion settings, the non-specific round trip and the RNA case so a failure says which one broke. Verified all four fail against the unfixed view model.

/// task is not the one that runs. These drive real searches and compare the results, which is the only
/// way to show the dialog is wired all the way through to the engines.
/// </summary>
[Category("LongRunning")]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Category("LongRunning")] is filtered by nothing.

This category appears exactly twice in the repository — here and MainWindowTests.cs:78 — and the string LongRunning appears nowhere else: no workflow, no .runsettings, no csproj. The new job applies no --filter at all; the required job filters Category!=ExternalService.

So the attribute is currently decorative. Either wire it into a filter or drop it, so it doesn''t read as protection that isn''t there.

In fairness this costs almost nothing today — the legs run 35/35 in ~16 s, whole jobs 1m17s–3m18s against 37 minutes for the required Windows job. I''m flagging the misleading name, not a CI cost.

(For contrast, the repo''s existing categories are used: PlotModelStat 23 times, ExternalService 3, UniProt 1 — and ExternalService is genuinely filtered in three places.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 9d2d3f6d — dropped the attribute rather than inventing a filter for it. Nothing filtered it, the suite runs in seconds, and a category that reads as protection while providing none is worse than no category.

[Test]
public void DefaultSettingsReproduceTheKnownPsmCount()
{
Assert.That(RunSearch(_ => { }), Is.EqualTo(22),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default baseline is recomputed from scratch in each of the three tests here (:70, :77, :93), so this fixture runs 6 full EverythingRunnerEngine searches per platform leg — 18 per push across the matrix — where 4 would do.

Hoisting the baseline to a [OneTimeSetUp] removes two searches immediately.

Two related nits in the same file: RunSearch deletes the output folder in its finally (:54-60), so a search that fails in CI leaves nothing to diagnose; and the -1 sentinel at :52 means a total search failure still satisfies Is.LessThan(baseline) at :96, so a broken run reads as a passing test.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 9d2d3f6d, all three points. The baseline is hoisted to [OneTimeSetUp] (six searches per leg down to four); RunSearch now fails instead of returning -1, since as you spotted -1 satisfied every Is.LessThan below it so a totally broken run read as a pass; and the output folder is kept when the run fails, which is the only time anyone wants it.

viewModel.RunCommand.Execute(null);

// RunCommand is async; wait for it rather than sleeping a fixed amount.
DateTime deadline = DateTime.UtcNow.AddMinutes(10);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A hard 10-minute wall-clock deadline is likely to flake on a loaded macOS runner.

In practice the search completes in about 2 seconds, so the margin is enormous today — but hosted macOS runners are the slowest and most contended of the three, and this is the kind of assertion that fails once a quarter for reasons unrelated to the code.

If the intent is "don''t hang forever", timeout-minutes on the job expresses that without coupling a test assertion to runner load.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Fixed in 9d2d3f6dtimeout-minutes: 20 on the job expresses "don't hang", as you suggested, and the in-test bound is now a 30-minute backstop reporting Assert.Inconclusive rather than a failure, so runner load cannot read as a correctness problem.

trishorts and others added 9 commits August 13, 2026 13:09
… it does not show

The Avalonia projects were written when master targeted net8.0 and still
said so, as did the SDK pin in their CI job. Master is on net10.0, so both
projects and the workflow now match.

The more serious half: TaskSettingsViewModel.Apply() rebuilt
CommonParameters through its constructor, passing the 13 settings this
dialog shows. CommonParameters takes 44 arguments, so the other ~30 were
silently restored to their defaults every time a user pressed OK -
deconvolution parameters, windowing, separation type, partition count.
Several of those change search results, so the loss was invisible in the
GUI and visible only in the output.

Apply() now goes through a new CommonParameters.CloneWithNewValues, a
sibling of the existing CloneWithNewDissociationType and
CloneWithNewTerminus. It copies every setting via Clone() and overrides
only what was passed, so a property added to CommonParameters later is
carried through without this having to know about it.

The existing tests asserted that edited values arrive; none asserted that
unedited ones survive, which is where the defect lived.
ApplyPreservesSettingsTheDialogDoesNotShow closes that: it sets five
non-default values the dialog never shows, edits a tolerance, and requires
all five to survive. Verified it fails against the constructor version -
1 failed of 36 - and passes with the clone. 36 pass on macOS arm64.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sibling of the CommonParameters fix in 4ab29d9, and the same defect:
Apply() rebuilt DigestionParams through its constructor, passing the 6
settings this dialog shows. DigestionParams takes 12, so the other 6 went
back to their defaults on every Save.

Three consequences, in descending severity:

  - searchModeType was dropped, so a non-specific or semi-specific search
    silently became CleavageSpecificity.Full. fragmentationTerminus went
    with it. Both change results and neither is visible in the dialog.
  - initiatorMethionineBehavior was dropped, which 4 of the 5 WPF windows
    preserve, along with the SILAC and glycopeptide flags.
  - LoadFrom read Protease rather than SpecificProtease. For a non-specific
    task Protease holds singleN/singleC and SpecificProtease holds the
    user's actual choice, so the enzyme box showed singleN. Combined with
    the above, a round trip both lost the mode and misreported the enzyme.

An RNA task fared worse than the review predicted. LoadFrom's type test
was `is DigestionParams`, which RnaDigestionParams fails, so Protease kept
its "trypsin" default and Apply replaced the RnaDigestionParams with a
proteolytic one - no exception, just an RNA task quietly turned into a
protein search. BuildDigestionParams now branches on the interface and
carries the rnase through; the enzyme box is filled from
ProteaseDictionary and has nothing to offer an RNA task.

Tests: ApplyWithoutEditingChangesNothing is the general form - load a task
holding non-default values, Save without editing, and require every
CommonParameters property to compare equal by reflection, so a setting
added later is covered without the test knowing about it. Three explicit
tests name the digestion settings, the non-specific round trip and the RNA
case so a failure says which one broke. Verified all four fail against the
unfixed view model and pass with it. 40 pass on macOS arm64, up from 36.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cancel() set GlobalVariables.StopLoops and nothing lowered it again.
EverythingRunnerEngine.Run() never touches it and the TaskLayer and
EngineLayer sites only read it, so the flag stayed raised for the life of
the process: one Cancel, and every later run was broken until the app was
restarted. The WPF GUI does both halves - MainWindow.xaml.cs:843 raises it
on cancel and :896 lowers it as the first statement of RunAllTasks_Click,
before its own validation. RunAsync now matches that ordering.

The symptom is worse than "no results". With the flag still raised the
search starts, an engine bails out early leaving a collection unset, and
the task dies with ArgumentNullException (Parameter 'source') - so the
second run fails loudly but for a reason that points nowhere near the
cancel that caused it.

ASearchAfterACancellationStillProducesResults cancels, then runs a real
search and requires results, rather than reading the flag back - the flag
being false is not the property worth protecting. Verified it fails against
the unfixed view model with exactly the ArgumentNullException above, and
passes with the fix. TearDown lowers the flag too, since it is static and
would otherwise leak into whichever test ran next.

Also hoisted the run-completion wait the two end-to-end tests now share.
41 pass on macOS arm64, up from 40.

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

Nine review findings with a common shape: the Avalonia GUI re-derived
things the engine already owns, and got them slightly wrong.

Culture. The project had no CultureInfo at all, against 157 InvariantCulture
uses in the WPF task windows with no counter-examples. Both failure modes
are real and now covered by a test: on de-DE, "." is the group separator, so
double.TryParse("0.05") returns true with the value 5.0 - a product
tolerance a hundred times looser than the one typed, silently. On fr-FR the
parse fails and BuildTolerance substitutes its 20 ppm fallback without
telling anyone. Verified both against the unfixed parse; the test covers
de-DE, fr-FR and en-US.

Accepted formats. IsSupportedSpectraFile hard-coded four extensions,
dropping .msalign, .tdf and .tdf_bin, and AddDatabases filtered nothing at
all. Both now go through GlobalVariables.AcceptedSpectraFormats /
AcceptedDatabaseFormats with GetFileExtension, so .msp and .msl spectral
libraries load and a format added to the engine is picked up here for free.

Bruker. A .d is a folder, and a user who opens one picks a file inside it.
CMD and the WPF windows both map that back to the parent folder; this did
not, so the selection was dropped. ToBrukerFolderIfInside does it now.

File pickers. Patterns are projected from the same canonical lists rather
than written out. That fixes a Linux-only bug that could not reproduce on
the developer's machine: Avalonia's FreeDesktop backend passes patterns to
the XDG portal as GlobStyle globs and portal matching is case-sensitive, so
"*.mzML" hid sample.mzml on the one platform this GUI exists for. .d is
excluded from the file picker, since OpenFilePickerAsync cannot select a
directory - the .tdf remap above is how those get added.

Start-up. Constructing the view model reads the modification, protease and
glycan databases off disk. A missing or malformed data directory threw
straight out of OnFrameworkInitializationCompleted and killed the process
before any window existed - no message, on exactly the platforms whose
directory layout is most likely to differ. App now catches it and shows the
window with the reason in the log, with running disabled.

Also: .WithInterFont(), so a minimal Linux image with no fontconfig renders
text rather than blank boxes; the score cutoff threshold matched to
TaskValidator.CheckMinScoreAllowed (>= 1, not > 0) so the two GUIs agree on
what is valid; and the contaminant filename heuristic moved to
DbForTask.LooksLikeContaminant. That last one is additive - CMD and
ProteinDbForDataGrid still carry their own copies, including CMD's
culture-sensitive ToUpper(), and collapsing those touches shipping code that
does not belong in this PR.

Tests: 59 pass on macOS arm64, up from 41, and the suite went from 11s to
4s - a [SetUpFixture] now loads GlobalVariables once for the assembly
instead of four fixtures reloading ~4,300 modifications per test.

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

The root namespace was MetaMorpheus.Avalonia, which shadows the toolkit's
own Avalonia root. Inside it, a fully-qualified Avalonia.Point binds to
MetaMorpheus.Avalonia.Point and fails with

  CS0234: The type or namespace name 'Point' does not exist in the
          namespace 'MetaMorpheus.Avalonia'

an error naming the wrong namespace, fixable only with global::. Verified by
reproducing it: a throwaway file in the old namespace referencing
Avalonia.Point and Avalonia.Media.Colors produced exactly that pair of
errors, and the same file compiles clean under the new name.

Nothing hit this because every file happens to carry a using directive above
its namespace declaration. That is luck, and it runs out at the MetaDraw
port, which the PR description measures at ~600 references to Avalonia types
across 36 files. Renaming afterwards would mean touching all of them again,
so this is cheaper now than at any later point.

MetaMorpheusAvalonia, matching the repo's single-token convention -
GUI.csproj uses MetaMorpheusGUI for both RootNamespace and AssemblyName, so
this follows for both. Renaming the assembly is free today because nothing
packages it: MetaMorpheusSetup.wixproj harvests only CMD, EngineLayer, GUI
and TaskLayer, and the release job zips CMD alone. It would not be free
after a release artifact exists.

The test namespace had the identical defect and is renamed too, Test.Avalonia
to Test.AvaloniaGui.

ToolkitNamespaceIsNotShadowed keeps this from coming back: two
fully-qualified toolkit references with no using directive, which stop
compiling if the root namespace ever contains a member called Avalonia
again. A compile-time guard rather than a test, since that is what the
failure is.

Note build.yml's arm64 PE-header assertion still derives the DLL name from
the project name, so it needs project:assembly pairs to cover this project -
the assembly is MetaMorpheusAvalonia, the project GUI.Avalonia. Unchanged
here; that finding is still open.

59 tests pass on macOS arm64.

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

Closes the UI-surface, threading, modification, test and CI clusters from the
review. Marshalling comes first in the diff because the engine events could
not be subscribed until it was right.

MARSHALLING. OnUiThread tested Dispatcher.UIThread for null, which is never
null in Avalonia 12, then Post()ed. With no Avalonia platform the dispatcher
is built over ManagedDispatcherImpl and binds to whichever thread first
touches it, so anything raised from another thread queued onto a dispatcher
nobody pumps and vanished. Engines raise their events from inside
Parallel.For, so that is most of them. Capturing the SynchronizationContext
at construction settles it: Avalonia installs one on the UI thread, and its
absence is exactly the case where inline is correct.

ENGINE EVENTS. MetaMorpheusEngine's three events were never subscribed, so
inner-engine progress and engine warnings never appeared - the progress bar
sat still for the whole of a search. Added those, MyFileManager.WarnHandler
(warnings about the spectra files themselves, the first thing a new platform
trips over), and the EverythingRunnerEngine events that report task chaining
swapping the database and spectra lists mid-run.

Gate C is a reflection test over every public static event on the four engine
types, each of which must be subscribed or listed as a deliberate exclusion
with a reason. It immediately found two I had missed -
MetaMorpheusEngine.StartingSingleEngineHander and FinishedSingleEngineHandler
- which is the point of writing it that way.

UI. The description claimed six task types and modification selection; the
window offered three task types and no modification UI at all, so
ModificationSelectionViewModel was reachable only from tests and three
Apply() arms were dead. There are now six buttons, a settings section per
task type, and a grouped filterable modification picker. GPTMD's
ListOfModsGptmd gets the same widget on a third, non-exclusive axis, since
that is the one setting a GPTMD task really has. The contaminant column is
editable and there is an "Add default contaminants" button, so the filename
heuristic is a guess the user can overrule rather than a verdict.

MODIFICATIONS. Exclusivity moved into ModificationChoice's setters, so
binding a CheckBox straight to IsFixed can no longer produce the
both-fixed-and-variable state the rule exists to prevent. ResetToDefaults
reads CommonParameters instead of repeating its literals, which also makes
it correct for RNA.

Two corrections to the review here, both settled by measurement rather than
argument. First, the dedup does not collapse distinct terminal variants: of
84 colliding (ModificationType, IdWithMotif) identities in the shipped
databases, zero differ in location restriction, mass or target - all 84 are
duplicate glycan entries, 64 O-linked and 20 N-linked. Collapsing is also
forced, because that pair is the identity TOML stores, so two modifications
sharing it cannot be selected apart at any price. Kept, with a test that
fails if a database change ever makes a collapsed pair genuinely distinct.

Second, and not in the review: the picker only ever offered
GlobalVariables.AllModsKnown, but RNA modifications live in the separate
AllRnaModsKnown, so an RNA task could not select any of its modifications.
Found while writing the RNA defaults test, which failed for that reason.

TESTS. Dropped [Category("LongRunning")], which nothing filtered and which
read as protection that was not there. The wall-clock deadline is no longer
an assertion about runner speed - timeout-minutes on the job says "do not
hang", and an over-run is Inconclusive rather than a failure. The baseline
search is hoisted to [OneTimeSetUp], taking the fixture from six searches per
leg to four. RunSearch now fails instead of returning -1 when no results are
written, because -1 satisfied every Is.LessThan below it, so a totally broken
run read as a pass; and it keeps the output folder when the run failed, which
is the only time anyone wants it.

CI. Coverage is collected from the Avalonia job under its own flag - without
it the new files sat outside the denominator, which is why Codecov reported a
zero-line delta on a four-thousand-line PR. build.yml's arm64 PE-header
assertion takes project:assembly pairs, so it now covers the desktop app
Apple Silicon users actually launch; deriving the file name from the project
name looked for GUI.Avalonia.dll, which does not exist. The Avalonia job uses
env.DOTNET_VERSION rather than a fourth hard-coded SDK version, and the two
new solution entries use the SDK-style project GUID like the other seven.

Still outstanding and not fixable from a fork: the three Avalonia contexts
are not required checks. Branch protection lists only "Test on Windows", so a
red Avalonia leg still merges silently. That needs a maintainer.

83 tests pass on macOS arm64, up from 59. Solution restore verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Checked each of the review's remaining minor items against the WPF GUI before
touching anything, because fixing a shared defect in one front end only makes
the two diverge further. Three of the four are this project's alone; the
fourth is not a GUI defect at all and is left alone deliberately.

CustomMdac. The combo box offered MassDiffAcceptorType.Custom and Apply()
never wrote SearchParameters.CustomMdac, so the choice was made and silently
ignored. SearchTaskWindow.xaml.cs has always handled it - loads at :439,
validates at :732, writes at :748 - so this was ours. Now carried, shown in
the dialog, and validated through SearchTask.GetMassDiffAcceptor so an
unreadable expression is refused here instead of failing part-way into a run.

The log. `Log += line` reallocated the whole document per line, quadratic over
the thousands a search emits, and after the marshalling change it also ran on
engine worker threads, making it a racy read-modify-write. Now a StringBuilder
behind a lock. The WPF window does not have this: notificationsTextBox
.AppendText() appends incrementally and it marshals through
Dispatcher.CheckAccess first.

The null dereference. CreateSettingsFor can return null and the caller used it
unguarded. WPF has the same shape - its switch on taskType leaves dialog null
and MainWindow.xaml.cs:2058 assigns dialog.Owner regardless - but MyTask is an
exhaustive enum there, so it is unreachable. Here the value comes from a
ListBox item, so it is reachable, and it is now guarded. Not touching the WPF
twin: it is dead code in shipping software, and this PR is not the place.

NOT FIXED, on purpose: MaxThreadsPerFile losing its -1 sentinel. This is not
a GUI defect in either front end. CommonParameters' constructor resolves -1 to
ProcessorCount - 1 at construction, so the sentinel is gone before any GUI
sees it; SearchTaskWindow.xaml.cs:343 displays the resolved number too, and
CMD is no different. The checked-in vignette TOMLs say
"MaxThreadsToUsePerFile = 3", which is the machine that generated them.
Preserving the sentinel means changing CommonParameters to resolve at use
rather than at construction, which changes every TOML MetaMorpheus writes and
wants its own issue and its own decision - not a quiet edit inside a GUI PR.

87 tests pass on macOS arm64, up from 83.

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

acesnik commented Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

🤖

Thanks — this was an unusually useful review, and most of it is now addressed. 29 of the 33 inline
findings are fixed across six commits, along with the build.yml item that had no line to attach to.
Tests went from 35 to 87, all passing on macOS arm64.

Four things are worth calling out, because in each case doing the work changed the answer.

The modification dedup finding does not reproduce, and the measurement is the argument. You
predicted that grouping by (ModificationType, IdWithMotif) and taking First() collapses distinct
N- and C-terminal variants. On the shipped databases there are 84 colliding identities, and zero
of them differ in LocationRestriction, MonoisotopicMass or Target — all 84 are duplicate glycan
entries, 64 O-linked and 20 N-linked. The collapse is also forced rather than chosen: that pair is the
identity TOML stores, so two modifications sharing it cannot be selected apart at any price. I have
kept the behaviour and added a test that fails if a database change ever makes a collapsed pair
genuinely distinct, so the assumption is checked rather than assumed.

A gap neither of us spotted, found while fixing ResetToDefaults. The picker only ever read
GlobalVariables.AllModsKnown, but RNA modifications live in the separate AllRnaModsKnown — so an
RNA task could not select any of its own modifications. The RNA-defaults test failed for exactly that
reason, which is how it surfaced.

The RNA digestion case failed more quietly than predicted. You expected KeyNotFoundException
from ProteaseDictionary.Dictionary[protease]. It never got that far: LoadFrom's type test was
is DigestionParams, which RnaDigestionParams fails, so Protease kept its "trypsin" default and
Apply() replaced the RnaDigestionParams with a proteolytic one. No exception — an RNA task quietly
became a protein search.

Cancel() was also worse than predicted. Rather than returning quickly with no results, the
second run starts, an engine bails out early leaving a collection unset, and the task dies with
ArgumentNullException (Parameter 'source') — a loud failure pointing nowhere near the cancel that
caused it.

On sequencing: the .NET 10 rebase landed first, as you argued. Gate A now has a real test — a
reflective load/Save/compare over every CommonParameters property, so a setting added later is
covered without the test knowing about it. Gate C is a reflection test over every public static event
on the four engine types; it immediately found two I had missed
(MetaMorpheusEngine.StartingSingleEngineHander and FinishedSingleEngineHandler), which is the best
argument for writing it that way. Gate B — result equivalence against CMD on the same TOML — is not
written yet.

Two behavioural forks from the WPF GUI, now stated in the description as you asked: fixed and
variable modifications are mutually exclusive here, and ValidModification filtering applies to all
task types rather than only Search. Both deliberate. I will open an issue against the WPF side so the
two converge rather than drift.

What I need from a maintainer: the three Avalonia GUI tests (...) contexts are not required
checks — branch protection lists only Test on Windows — so a red Avalonia leg still merges silently.
A fork PR cannot change that.

Two things left open deliberately. IsThermoRawUnsupportedHere still hard-codes the restriction,
pending #2723. And MaxThreadsPerFile losing its -1 sentinel is not a GUI defect in either front
end — CommonParameters' constructor resolves -1 to ProcessorCount - 1, so the sentinel is gone
before any GUI sees it, SearchTaskWindow.xaml.cs:343 displays the resolved number too, and the
checked-in vignette TOMLs say MaxThreadsToUsePerFile = 3. Preserving it means changing
CommonParameters to resolve at use rather than at construction, which changes every TOML
MetaMorpheus writes. That wants its own issue.

@acesnik
acesnik marked this pull request as ready for review August 15, 2026 20:20
…2 warning

Coverage. Adding --collect:"XPlat Code Coverage" to the Avalonia job was not
enough: the collector ships in coverlet.collector, which GUI.Avalonia.Tests did
not reference. Without it the run prints "Unable to find a datacollector with
friendly name 'XPlat Code Coverage'" on a line nobody reads, exits 0, and
uploads nothing - so codecov/patch reported no coverage for a PR whose tests
cover it well. Referenced in the csproj rather than added by the workflow, as
the Windows job does, so a local run collects coverage too.

The real figure, now that it is measured: 85.6% line coverage on
MetaMorpheusAvalonia across the 87 tests. The code was covered; the reporting
was broken, which is worse than a low number because it looks like a low number.

CS8632. CommonParameters.DIAparameters carries a reference-type ? annotation
with no annotations context, so it only produced a warning. This is pre-existing
on master, not introduced here - it surfaces on this PR because the file is
touched. Scoped the annotations context to that one member rather than the whole
file, so the nullability metadata of every other member is unchanged for
consumers that do enable nullable. The int?/double? properties above it are
Nullable<T> and were never affected.

Co-Authored-By: Claude Opus 5 (1M context) <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.

2 participants