Skip to content

MuMDIA Console: a desktop application for installing, configuring and running MuMDIA - #48

Open
RobbinBouwmeester wants to merge 93 commits into
mainfrom
feat/desktop-console
Open

MuMDIA Console: a desktop application for installing, configuring and running MuMDIA#48
RobbinBouwmeester wants to merge 93 commits into
mainfrom
feat/desktop-console

Conversation

@RobbinBouwmeester

Copy link
Copy Markdown
Member

A desktop application for people who did not write MuMDIA. Windows and Linux, one
installer, no conda, nothing to host.

What it does

  • Installs itself. uv supplies both the interpreter and the packages, so conda
    never touches the user's machine. The environment goes under the per-user data
    directory, not beside the executable, because on Windows that is Program Files and
    an installer needing administrator rights on first run is not an easy install.
  • Runs a search. Pick an mzML and a FASTA or a library, start, watch, read the
    result. Progress comes from the <artifact>.report.json files the engine already
    writes; nothing is scraped from the log.
  • Configures. The 150-setting form is generated from configs/config-schema.json,
    which ci/gen_config_reference.py emits from the same parse of config.rs that
    produces the reference document. Saving writes only the difference from the
    defaults, and every save is validated by the engine before it is offered.
  • Refuses the minimal path, as decided: a configuration needing no Python sidecar
    at all is the one the 1,213-vs-10,300 comparison describes.

Engine changes

Four, all small and all additive: doctor --json, peak-census, the generated
configs/config-schema.json, and nothing else. Milestone 1 needed no engine change
at all.

Decisions worth reviewing

The engine is a child process, not a linked crate. It has no signal handling
anywhere, so stopping a run is a kill, and a Rust thread cannot be killed. Linked
in-process there would be no Stop button. Also: a stage panic ends one run rather
than the session, --threads can change between runs, and the application can show
the exact command it ran.

Two Python environments, not one. deeplc==4.1.1 needs sqlalchemy>=2 through
psm-utils; ms2pip==4.0.0 needs <2. uv reports the pair as unsatisfiable.
ms2pip>=4.1 would resolve, but it changes predicted fragment intensities and
env/docker-rescore.yml pins 4.0.0 as "a separate, testable upgrade". So MS2PIP gets
its own environment, installed on request.

macOS is absent on purpose. Gatekeeper blocks an unsigned bundle outright.

Verification

  • 26 desktop unit tests, 3 end-to-end (a real search, a real cancel, a real component
    installation from nothing).
  • A real Windows MSI built locally: 29 MB, containing the app with
    binaries/{mumdia.exe, uv.exe} beside it.
  • ci/check_desktop_ui.py checks the frontend against the backend in both
    directions, since the interface has no build step to catch a typo.
  • New desktop CI job: fmt, clippy, unit tests, frontend check.

Known gaps

  • The Linux .AppImage is built by the release job but has not been verified end to
    end. The resource lookup now asks Tauri for the bundle directory, which is what an
    AppImage needs, but that path has not been exercised on a real bundle.
  • The interface itself has not been clicked through by a person; the backend it
    drives is covered by tests.

RobbinBouwmeester and others added 30 commits July 30, 2026 18:28
…ed configs parse

The Strasbourg prenylation work is unpublished, so its configs and deployment notes must
not reach the remote. `config.strasbourg-linux.json` had been committed and pushed;
untrack it (the local file is kept for transfer by other means) and add ignore rules for
`config.strasbourg*.json` and `config.*-local.json` so it cannot recur through a
`git add -A`. Root-level notes were already covered by the existing `/*.md` rule.

NOTE: untracking removes the file from the branch tip but NOT from history. If it must be
unreachable, the branch needs a history rewrite and force-push. Its content is search
parameters and server paths -- no sample, protein or result data -- so that is a judgement
call for the repo owner.

Also adds `shipped_configs_parse`, which round-trips every TRACKED config through
`Config::from_json`. A `_comment` key shipped in a config once and, because `Config` is
`deny_unknown_fields`, made the whole config unloadable -- caught only by running `doctor`
on the deployment target. The workspace suite passed throughout, which is the real gap: a
config is an artifact the engine must accept, not just valid JSON. Untracked
machine-specific configs are deliberately excluded from the test and remain `doctor`'s job.

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

`deeplc_worker.py` imported numpy and pyarrow at module level and deferred `import deeplc`
into main(). DeepLC 4.x is torch-backed, and on Windows that ordering makes torch's DLL
initialisation fail outright:

    OSError: [WinError 1114] A dynamic link library (DLL) initialization routine failed.
    Error loading "...\torch\lib\c10.dll" or one of its dependencies.

`deeplc_finetune.py` already ordered its imports this way and says why ("import before numpy,
OpenMP load order"); this worker did not. The bug stayed latent because imported-library mode
skips predict-frag entirely, so nothing exercised the native RT-prediction path. It surfaced
the first time a library was built from FASTA, which failed after the peptidoform table was
already generated.

The comment states that the ordering is load-bearing, so a future tidy-up does not sort the
imports and silently reintroduce it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`doctor` checked the DeepLC interpreter for `deeplc,numpy,pandas`, but that interpreter also
runs `deeplc_finetune.py`, which imports pyarrow, torch and psm_utils. A green doctor could
therefore be followed by a crash at the fine-tune step, which on an experiment-wide batch is
discovered long after the run is launched.

DeepLC 4.x pulls torch and psm-utils itself, so in practice this catches a missing pyarrow,
but the check should assert what the scripts import rather than what the dependency tree
happens to imply.

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

`run-experiment` hard-set `qcfg.q_filter = PsmQ`, silently discarding whatever `quant.q_filter`
the config asked for. The override itself is deliberate: the grouped q columns
(peptide_q_value / precursor_q / pg_q_value) are assigned only to each group's single
experiment-wide winning row, so a per-run table can only gate on a per-PSM column.

Doing it in silence is the problem. A user who explicitly configured a different `q_filter`
got quantities gated on a column they did not choose, with no record of the substitution in
any artifact. Now it warns with both the configured and the effective value.

Behaviour is unchanged, so existing results stay interpretable.

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

New `mumdia prescan` stage. For a modification search it keeps only the candidates whose
modification-anchored sequence trimers are actually observed in their own isolation window
and retention-time range, so the per-run library is sized to the evidence rather than to the
enumeration. Same output contract as the Python screen it replaces (candidate_id, label), so
downstream library assembly is unchanged.

Measured on one 50-window Orbitrap DIA run against a 54.8M-row library:

    tag index build   27 s  ->  0.64 s   (42x)
    screening        412 s  ->  5.6 s    (74x)
    total wall       439 s  ->   38 s    (11.6x)

Screening is independent per candidate, so it is a rayon fan-out; the Python loop was
single-threaded and was ~40% of the whole per-file chain. Most of the remaining 38 s is
reading the library, not computing.

WHAT THIS STAGE IS NOT. It cannot discriminate a true modified peptide from its decoy, and
must not be read as if it could. `anchored_tris` emits every trimer in both orientations and
a reverse decoy preserves composition and precursor m/z, so a decoy's anchored tag set is
identical to its target's and a decoy survives exactly when its target does. Measured
target:decoy survival ratio is 1.0000 (2,453,365 / 2,453,365). That is precisely why it is
safe: exchangeability is untouched, so downstream FDR stays valid. It is a compute reduction.

Both labels go through the identical criterion, each on its own sequence, m/z and RT window.
Screening only targets and then admitting their paired decoys would make surviving targets
signal-enriched while their decoys stay signal-blind, biasing the modification's q-values
anticonservatively; the stage aborts if survivors ever come back single-label.

The peak cut is deterministic (intensity descending, ties by ascending m/z). It has to be:
moving `top_peaks` by ONE peak changes the survivor set by 3.2%, which is larger than most
parameter changes, so an unspecified tie order would make reruns differ for no reason. The
0.4% symmetric difference against the Python implementation is this same cut sensitivity, not
a logic difference: the disagreeing candidates come in target/decoy pairs, and 0 of 1.48M
sampled peptidoforms have a backbone the tokeniser rejects.

Masses come from the shared model (`residue_mass` + `unimod_mass`), never a local copy; I and
L share a tag index because a residue-mass delta cannot separate isobaric residues. Output
goes through `mumdia-io`, so the snappy + arrow-utf8 library contract is automatic rather than
something an external writer has to remember.

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

Nineteen docs updated, plus CLAUDE.md, which was whitelisted in .gitignore but had never been
added, so the top-level guide existed only on one machine.

The substantive corrections, each measured rather than argued:

- `--top-peaks-ms2` is ACQUISITION-SPECIFIC and destructive at convert time. CLAUDE.md
  previously presented `300` as part of the validated workflow; it is correct for the chimeric
  AIF benchmark and actively harmful elsewhere. On a 50-window Orbitrap DIA run it discarded
  78.6% of all MS2 peaks and truncated 85.5% of spectra (even p25 exceeds the cap), costing
  25,425 versus 63,237 peptides.tsv rows at `peptide_q_value` <= 0.01 with the empirical decoy
  fraction unchanged at 0.99% in both arms. Mechanism is peak-group formation, not scoring:
  `presence_min_fragments` cannot be met, so real peptides are recorded NO_PEAK_GROUP. Docs now
  carry the peak census, the audit funnel, the cap dose-response, and a pre-flight check.
- `compete.group_by = precursor` is a misnomer: it keys `base_peptide_id`, built from the
  STRIPPED sequence, so every charge and modform of a peptide collapses to one winner before
  rescore. Required, not optional, for PTM work: on a modification-rich library the default key
  deleted 880,464 of 1,890,239 extracted candidates (46.6%), while `peptidoform_charge` removed
  0 rows and moved precursors per peptide from 1.000 to 1.174.
- `cal.json` RT residuals are IN-SAMPLE and roughly 3x optimistic (6.14 s reported versus p50
  17.6 s / p90 146.3 s out-of-sample). Size external RT tolerances from out-of-sample numbers.
- DeepLC fine-tuning need not run per file: a once-fine-tuned library plus per-run LOESS gave
  6.06 s median residual against 6.14 s, removing ~36 min per file.
- An imported library may assign every modform the unmodified form's iRT (79.7% of stripped
  groups on one library). Check that variance before trusting RT windows in a PTM search.
- Sidecar and IO contracts that fail late: deeplc import order, the widened `doctor` probe,
  snappy + arrow `utf8` for parquet written outside `mumdia-io`, contiguous `candidate_id` and
  m/z-ascending precursors as hard errors, and the `MUMDIA_NN_STREAM_GB` backend cliff.
- `run-experiment` never calls report, so there is no peptides.tsv in its tree; grouped q
  columns exist only on each group's experiment-wide winning row, so per-run counts on them are
  diluted and `run_psm_q` is the correct per-file unit; and pooling more runs does not tighten q
  because `q = (decoys + 1) / max(1, targets)` is scale-invariant.

Count tables now name their row and q-value unit, which is the convention CLAUDE.md itself
requires and which the first draft of these edits did not follow. Datasets are described
generically; no collaborator, sample, server path or unpublished result appears.

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

The stage landed in c6375b7 with no documentation, so it was reachable only by reading the
source or `--help`. Adds docs/21_prescan.md and wires the references.

The doc leads with what the stage is NOT, because that is the part easy to get wrong. The screen
cannot separate a true modified peptide from its decoy and no tuning will make it: trimers are
emitted in both orientations and a reverse decoy preserves composition and precursor m/z, so a
decoy survives exactly when its target does (measured ratio 1.0000). That is what makes the stage
safe rather than suspect, since exchangeability and therefore downstream FDR are untouched, but it
also means a survivor count must never be read as enrichment for real modified peptides.

Records why the decoy screen has to be symmetric, with the number that shows it: screening targets
only and admitting their paired decoys made the apparent modification-specific FDR look like ~32%
against ~43% under a symmetric screen, and that gap was the bias, not discrimination.

Also records that `top_peaks` is sensitive out of proportion to its appearance: a one-peak change
moved the survivor set by 3.2%, which is why the peak cut is tie-broken deterministically and why
survivor counts should not be treated as precise quantities or used to tune by small differences.

Distinguishes `prescan.top_peaks` from `--top-peaks-ms2`: the former bounds tag construction only,
the latter is destructive at convert time and is the subject of docs/04_convert.md.

Notes the two couplings a reader will otherwise miss: a per-run library means a per-run search
space, which pooled rescore handles via `source`/`run_psm_q` but cross-run quant does not; and the
stage must be paired with `compete.group_by = peptidoform_charge`, since the default competition
key deletes exactly the modform candidates the prescan was run to keep.

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

The doc described what the stage keeps but not what it cannot touch, which leaves the reader unsure
whether prescan tuning can damage an ordinary proteome result. It cannot.

`anchored_tris` emits trimers only for positions carrying an `anchor_mods` modification, so a
candidate without one yields an empty tag set and never appears in the output; library assembly
then restores the whole unanchored remainder unconditionally. Verified on a 54.8M-row library with
three cysteine anchors: 41,434,790 rows eligible (75.6%), 13,386,766 never at risk (24.4%),
4,906,730 survivors, and 0 survivors lacking an anchor modification.

That makes the stage cheap to experiment with: `tol_da`, `rt_slack_s` and `top_peaks` can only add
or remove modified hypotheses.

Also records why the stage walks every row instead of pre-filtering to anchor-bearing ones: it
costs about a second and keeps the eligibility rule in one place rather than duplicating it as a
peptidoform string match that could drift out of agreement with the configured anchors.

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

A Phase B orchestration script died with exit 1, an empty log, and nothing on stderr. Cause: under
`set -euo pipefail`, a run-list built with `[ -f ... ] && echo "$r"` makes the command substitution
return non-zero whenever the LAST glob entry fails the test, and `set -e` then terminates the
script without a message. It happened once five of eighty-three runs were incomplete and the last
of them was the final directory in the glob.

The failure is worth documenting because the symptom points nowhere: the log contains only
`nohup: ignoring input`, and the obvious suspects (CRLF line endings, a missing config, an empty
glob) all produce visible errors instead. `bash -x` locates it immediately, which is the actual
lesson.

Fix is an explicit `if`, so no failing command is ever last in the substitution.

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

In-sample residual-percentile windows underestimate the tail an unseen
library peptide has, and they invert under model capacity: an RT model
that memorizes its anchors is handed the narrowest window exactly when
its true error is largest (measured in-sample 15.9 s vs 24.9 s abs-median
ranking two DeepLC versions backwards against held-out 195.1 s vs 46.4 s).

window_holdout_frac = f holds out anchor peptides by the deterministic
rule base_peptide_id % 1000 < round(f*1000), fits the sizing curve on the
rest, and takes w_rt from the held-out residual percentile. The rule is
duplicated verbatim in deeplc_finetune.py (--window-holdout-frac, passed
by run/run-experiment) so the fine-tune reference excludes the same
peptides; without that, adapter memorization leaks into the residuals.
The calibration curve applied to the library still uses every anchor.

Guards: anchor-count fallbacks to in-sample with a warning; hard error
when combined with adaptive_rt_window; cal.json records w_rt_sizing,
n_sizing_train, n_holdout, and the held-out residual scale. Standalone
rt-im-train now also warns that finetune_deeplc is orchestrator-only,
which previously read as fine-tuned on runs that never were.

Measured end-to-end (AIF benchmark, DeepLC 4.1.0, frac 0.3): w_rt
141.5 -> 204.9 s, peptides at 1% 10,703 -> 10,822 (+1.1%) at unchanged
0.98% decoy; with the overfitting 4.0.0a2 model the honest window is
~950 s and costs 1.5%. Default off; benchmark-gated (docs/08 section 4b).

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

On LFQ_Orbitrap_AIF_Ecoli_01 the validated workflow now measures 90.4-91.6%
of DIA-NN 2.2.0 lib-free --reanalyse (11,817 peptides at 1%). Prefer the
augment_library.py-completed tables: the raw imported library is missing 209
of DIA-NN's peptides (all N-terminal Met excision); augmentation recovers ~80
at unchanged 0.98% decoy and parity elsewhere. Also record that DeepLC
per-run fine-tuning nondeterminism propagates into w_rt under held-out window
sizing (held-out p95 150-211 s across draws, ~2% peptide swing), so
single-run comparisons of window sizing or library variants must be judged
against that variance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two quantification options, both off by default so an existing config is
bit-identical:

- `quant.fragment_selection = predicted` ranks a precursor's fragments by their
  library intensity instead of by their own integrated area. Ranking by observed
  area preferentially selects interfered fragments, because interference
  inflates exactly the areas the ranking rewards, and the selected set then
  varies run to run.
- `quant.fixed_scan_halfwidth` / `quant.fixed_window_s` integrate a fixed window
  centred on the identification apex rather than the descent-walk bounds, whose
  1/6-height walk is itself interference-sensitive. The seconds form is
  instrument-independent and overrides the scan form.
- `quant.baseline_subtract` (with `baseline_flank_scans`, `baseline_quantile`)
  subtracts a flank-quantile background inside the fixed window.

Measured on the ProteoBench Astral HYE set (six runs, second-pass MBR, guard
0.8): median |epsilon| 0.273 -> 0.195 and CV 0.175 -> 0.107 at
`fragment_selection = predicted`, `top_n_fragments = 12`, `fixed_window_s = 5`.
On the AIF HYE set with `fixed_window_s = 20`, |epsilon| 0.210 -> 0.181. The
window has to be sized per acquisition (about 1.5x the median peak half-width),
so nothing here becomes a default: promotion needs entrapment validation on both
acquisitions per docs/20.

`predicted_intensity` is read as an OPTIONAL chromatogram column. Requiring it
made every chromatogram artifact written before the column existed
unquantifiable, which the applied-window contract test caught. The `predicted`
ranking, which is the only consumer, now fails with an actionable message
instead.

Under a fixed window the reported `integration_lo_rt`/`integration_hi_rt` are
the RT extent actually integrated, not the walked bounds that were ignored;
`fixed_window_indices` is shared by the integration and the reported contract so
the two cannot drift.

`validate()` rejects a negative or non-finite `fixed_window_s` and a
`baseline_quantile` outside [0, 1], and warns when both fixed-window forms are
set or when `baseline_subtract` is on with no fixed window to apply it to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The M5 augmented scored table lowered only `q_value`, but quant gates on
`quant.q_filter`, which the experiment path sets to `run_psm_q`. An accepted
transfer therefore kept a sub-threshold `run_psm_q` and was never quantified:
34,280 of 34,664 transfers on the six-run HYE pooled run, i.e. MBR appeared to
run and changed almost nothing.

Lower `q_value`, `run_psm_q`, and `experiment_psm_q` (whichever the table
carries) to the transfer q on the matching (candidate_id, source) row.

Note for interpretation, measured on that run: fixing the gate alone raises
min-3 ProteoBench features 70,657 -> 77,172 but worsens median |epsilon| 0.211
-> 0.245, because transfers into an ion's low condition sit at the noise floor
and compress the ratio. The gate fix is correct; it needs transfer quality
control (fragment-consensus guard, condition evidence) alongside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four gaps, all of which let untracked clutter or 3 GB of build output show up as
uncommitted work, and one of which silently hid a tracked doc:

- `**/target/`: the workspace target dir is `rust/mumdia/target`, which the
  root-anchored `/target` never matched.
- root-anchor `/*_plan.md`, `/MISS_ANALYSIS*.md`, `/fragindex_*.md`,
  `/speedup.md`. Unanchored, `*_plan.md` also matched
  `docs/22_release_plan.md`, so a tracked developer doc could not be committed.
  `/*.md` already keeps root notes local, so this only narrows the patterns.
- `/config_*.json`, `/config.aif-*.json`, `/config.hye-*.json`: 19 experiment
  configs with machine-specific interpreter paths were untracked but not
  ignored, so `git add -A` would have committed them.
- `/lib/`, `/lib_capped/`, `/raw_files/`, `/val*/`, `/mbr_k3/`, `/mbr_work/`,
  `/missed_xics*/`, `/alphadia/`: local benchmark inputs and scratch outputs.
  The libraries under `lib/` are irreplaceable local data rather than build
  output; ignoring them keeps them out of commits without inviting deletion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Docker `deeplc` env pinned a DeepLC git commit from the 4.0 multitask branch
and capped `numpy<2` for a `pandas<2` constraint that release carried. Pin
`deeplc==4.1.1` from PyPI instead and drop the numpy cap, which 4.1.1 does not
need (it declares numpy<3, torch>=2.6, python>=3.11).

4.1.1 is a floor rather than merely the current release: the 4.0.0a2 multitask
preview overfits per-run fine-tuning badly enough to invert RT-model rankings
(in-sample 15.9 s vs 24.9 s but held-out 195.1 s vs 46.4 s for the same model
pair, docs/08 section 4b), so an older DeepLC changes results and not only
performance.

Add `env/mumdia-deeplc.yml`, the portable equivalent for a native install. The
DeepLC sidecars previously had no committed local env spec at all, so running
them meant reproducing a developer machine by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Inventory of the tree against the gates CLAUDE.md states, the reasons the engine
is hard to run today (each cited to the file that shows it), and a two-release
plan: v0.1.0 makes today's engine installable, portable, tested and documented
without promoting any new default; v0.2.0 ports the validated second-pass
multi-run workflow out of the benchmark shell scripts and into the engine.

Work packages carry acceptance criteria, sequencing, and effort estimates, plus
a definition of done and an explicit out-of-scope list so the release notes
cannot imply capabilities that do not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Standard repository files a public release needs, and which a reader currently
has no substitute for.

CONTRIBUTING covers the build, the exact checks a change has to pass, what the
test suite does NOT cover (no sidecar is exercised, so a green run is not
sidecar validation), the invariants that are easy to break without noticing
(determinism, label leakage, paired decoys, artifact versioning, the clean-room
boundary, the Parquet encoding contract), the bar for changing a default, and
how to report a benchmark number so its row unit and q-value column are stated.

SECURITY names GitHub private vulnerability reporting as the channel and states
the threat model, which for a local analysis tool is mostly untrusted input
files: a configuration names Python interpreters and is executed, so it is as
trusted as a shell script, and a finding that requires a hostile configuration
is not a vulnerability.

The changelog also records what is versioned independently of it, since that is
what matters when reading an old result: the per-artifact Parquet schema
versions and the hashed feature-set identity, both stamped into manifest.json.

`.gitignore` re-includes these root files. `/*.md` with only README and CLAUDE
negated had excluded every standard repository file at the root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI ran only `cargo build --release --locked` and `cargo test --locked`, while
CLAUDE.md names `cargo fmt --check` and `cargo clippy -- -D warnings` as part of
the gate. Formatting and clippy were therefore a local responsibility, and a
tree that failed them could reach main. Measured today: the tree failed both
(two unformatted hunks, two never-used functions) and one test, on a branch that
had been treated as ready.

New jobs:

- `lint` on Linux: fmt, then clippy over `--workspace --all-targets`, so dead
  code reachable only from a test module still fails the build.
- `build-test` unchanged across the three platforms, with `--workspace` made
  explicit: the release binary is one of three members, and the config and IO
  contracts live in the other two.
- `sidecars`: `compileall` over `scripts` and `ci` (the Python workers are not
  exercised by the Rust suite, so a syntax error in one surfaces only mid-run),
  a JSON parse of every tracked configuration, a YAML parse of the environment
  specs, and the documentation-reference check.

`ci/check_doc_refs.py` fails when a tracked file cites a Markdown document the
repository does not ship. Source comments carry provenance by citing the
document that specifies each behavior, and about 130 of those citations point at
local-only design notes, so a public clone sends the reader after files it never
received. Tracking those notes is not the fix: one of them quotes proprietary
constants from a closed-source engine and must stay out of the repository, which
is why the script refuses to advise adding an ignored file and why a small
POLICY_FILES set may name it in order to explain the policy.

Dependabot keeps Cargo and Actions dependencies moving, monthly and grouped.
`arrow` and `parquet` are grouped apart from everything else because they carry
the on-disk artifact contract, so a bump there is a data-format review rather
than a routine upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No manifest carried a description, repository, homepage, keywords, categories or
publish field, so the binary and the crates advertised nothing about what they
are or where they come from.

`publish = false` is deliberate rather than pending: mumdia-core and mumdia-io
are internal boundaries of one application, not libraries with independent
consumers, and the binary needs the Python sidecars and configs that a crate
cannot carry. Distribution is the release archive and the container image.

`rust-version` said 1.85 while `rust-toolchain.toml` pins 1.96.1, which is the
only version CI builds and tests. A lower floor may well work, but claiming an
unverified one is a guess; the comment says to lower it together with an MSRV job
that proves it.

`strip = "symbols"` in the release profile: archives shipped unstripped debug
symbols, which is most of the binary size for no user benefit, and a backtrace
still names the functions.

Cargo.lock is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release archive held the binary, README and LICENSE. That is not an
installation: the ML predictors and rescorers are Python sidecars the engine
launches by path, so a binary alone cannot fine-tune retention times or rescore
with mokapot or the neural network. The archive now also carries `scripts/`,
`env/`, `docs/`, the changelog, and the example configs when a tag has them,
plus a sha256 file, and it lists its own contents in the build log. Targets gain
`x86_64-apple-darwin` so Intel Macs are covered alongside arm64. Each build
smoke-tests the binary (`--version`, `--help`, `doctor`) before packaging, so a
broken executable fails the release instead of shipping.

The image had never been built or run by CI, only on a tag that was never
pushed. The Docker workflow now builds into the local daemon, runs the checks,
and pushes only afterwards. The checks are the ones that can actually fail here:
both baked configs parse and their sidecars import; DeepLC is imported for real
in the worker's order, because the failure that catches (torch DLL
initialization after numpy) happens at import time and not at the module-presence
probe `doctor` performs; and a bind-mounted directory is written both with and
without `--user`, which is the primary way anyone uses this image.

The image also no longer stays root after setup. Root was needed only for apt and
for creating the conda environments, which are read-only at run time. The
container user's uid is assigned by the base image and will not match a host uid,
so the documented invocation now passes `--user "$(id -u):$(id -g)"`, and the
smoke test asserts that path works rather than assuming it. `git` is dropped from
the image since DeepLC is pinned to a PyPI version rather than a repository
commit. Standard OCI labels point back at the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified on a Linux host with Docker 29.4 rather than assumed, since neither the
image nor the DeepLC pin had ever been built:

- the image builds (4.62 GB, amd64) and runs unprivileged as uid 57439;
- `mumdia doctor` passes on both baked configurations;
- the `deeplc` environment imports DeepLC 4.1.1, torch 2.12.1+cpu and numpy
  2.4.6 in the order the worker uses, which confirms dropping the `numpy<2` cap
  was safe, and `rescore` imports mokapot 0.10.0;
- all 11 sidecar workers are present;
- a bind mount is writable with `--user "$(id -u):$(id -g)"` and the result is
  owned by the host user. Without `--user` it fails at the mount point
  (`mkdir: cannot create directory '/data': Permission denied`), so the flag is
  documented as required rather than optional.

The quant refactor is numerically neutral, checked against the exact output
behind the ProteoBench AIF submission: 72,168 quantified precursors, `quantity`,
`n_fragments_used`, `quant_status` and `integration_apex_rt` all bit-identical.
Only the reported window moved, from a 29.1 s median (the descent-walk bounds
the fixed-window path never integrated) to 34.9 s, which is the fixed window
that produced those numbers. The published submissions therefore still describe
this code.

Also smoke-test every release target instead of skipping aarch64-apple-darwin:
macos-latest is arm64 and macos-13 is x86_64, so each runner executes the
architecture it builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Source comments carry provenance by naming the document that specifies each
behavior, and about 130 of those citations named documents the repository does
not ship: `plan.md` (110 references across 55 files), plus `comment.md`,
`fragindex_spec.md`, `mbr_plan.md`, `COMPARISON.md` and three more. A public
clone therefore sent the reader after files it never received, and the citation
looked authoritative while being unreachable.

Tracking those notes is not the fix. `plan.md` section 8 is a comparative dossier
that quotes proprietary constants and internal line numbers from a closed-source
engine, so publishing it would break the clean-room boundary the project claims;
it stays local. Each citation now points at the tracked `docs/` page that
describes the same thing today, with the section or stage number dropped, because
the tracked guide does not share `plan.md`'s numbering and keeping the number
would be a false pointer.

Where the citation was the whole justification for a sentence, the dead pointer
is deleted rather than replaced by an empty one; the technical claim stays.
`CLAUDE.md`, `docs/02` and `docs/14` now say plainly that the design notes are
untracked, so a reader is not left looking for them.

`ci/check_doc_refs.py` no longer treats a quoted `.gitignore` glob as a file
reference. The check passes: 305 references, all resolvable.

Also restores a word the rewrap dropped in `index.rs`, whose module doc had come
out as "shared by / and extract" with "search-seed" lost between the lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`peptides.tsv` and `proteins.tsv` are the only outputs most users read, and the
data dictionary explicitly excluded them, so their columns were documented
nowhere. `docs/15` now lists both, each column tied to the source line, and
states the trap: rows are precursors `(peptidoform, charge)` while the filter is
`peptide_q_value`, a base-peptide q, so a row count is not a
precursor-q-controlled count. It also says the printed values are rounded for
reading and points at the Parquet tables for analysis.

The schema-version registry had drifted: it claimed `psms_scored` was v3 and put
`psms_competed` at v2. Per `schema.rs:7-25` they are v4 and v3, with
`psms_extracted`, `peptide_quant` and `protein_group_quant` at v2. The section now
also says where a version is stamped, so an artifact on disk answers the question
itself instead of inheriting the engine's version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The WP0 table asked ten questions; all are now answered, so it records decisions
rather than proposals. Three are worth reading:

- platforms are Linux, Windows and macOS, with both macOS architectures built
  and each binary smoke-tested on its own architecture;
- the Python floor is DeepLC 4.1.1, verified to resolve with torch 2.12.1+cpu,
  numpy 2.4.6 and pandas 2.3.3 on Python 3.11 both in the image and from the new
  portable env spec;
- `plan.md` stays untracked. This reverses the plan's own recommendation, on
  inspection of the file rather than on preference: its section 8 quotes
  proprietary constants and internal line numbers from a closed-source engine,
  and its section 11 warns that reuse needs licence clearance, so publishing it
  would contradict the clean-room boundary the README claims. The 130 dangling
  citations were redirected instead, and CI now blocks new ones.

`CITATION.cff` is blocked rather than pending: the author list is not derivable
from the repository and guessing authorship is not acceptable.

A progress table lists what landed with the evidence each item was checked
against, and the sections below mark the completed WP1, WP2 and WP4 items. It
also states the two things local verification cannot cover: neither the new CI
workflow nor the Docker workflow has run on a GitHub runner, because nothing has
been pushed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven citations named `fragindex_spec` without a `.md` suffix, so neither the
filename grep nor the new CI check saw them: `(fragindex_spec Section 2.1)` reads
as prose, not as a path. They pointed at an untracked local specification like the
rest, so they are redirected to `docs/06_predict_frag_index_matchers.md`, with the
section numbers dropped because the tracked page does not share that numbering.
Inside `docs/06` itself the citations become "this document" rather than a
self-reference.

`ci/check_doc_refs.py` now also scans for the bare stems of the known untracked
notes, which is the gap that let these through. It reports 312 resolvable
references and no extension-less citation.

One nearby false positive left alone: `features.rs:115` has a `PLAN` static, which
is a variable name and not a document.

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

A MuMDIA config named the Python interpreter for each sidecar as an absolute
path, with no lookup and no discovery, so a config belonged to the machine that
wrote it. The only tracked config carried one developer's `C:/Users/...` and
OneDrive paths and was the config `CLAUDE.md`, `docs/19` and `docs/20` told
everyone to run, so a new user's first action was always to edit it. This is the
main reason the engine was hard to run.

`rust/mumdia/crates/mumdia/src/python.rs` gives each sidecar role one resolution
path. A field may be an absolute path, used as given and never second-guessed, or
`"auto"` (or absent), which searches `MUMDIA_PYTHON_<ROLE>`, `MUMDIA_PYTHON`,
`CONDA_PREFIX`, `VIRTUAL_ENV`, then `python3`/`python` on `PATH`. A candidate is
accepted only after it imports that role's own module list, so discovery cannot
pick a Python without torch and defer the failure to the rescore stage hours
later. A role is resolved only when the configuration uses it, so the default
native run probes nothing and still works on a machine with no Python.

Resolution happens before the config hash, so `manifest.json` records the
interpreter that actually ran rather than the word `auto`. That makes the hash
machine-specific for an `auto` config, which is the honest outcome.

`predict_frag.sidecar_script_dir` is resolved against the config file's own
directory and the executable's directory as well as the working directory. The
same config invoked from a different directory used to silently change which
worker scripts ran.

`mumdia doctor` now answers "can this configuration run?" instead of "do three
hard-coded interpreters import three hard-coded lists?". It reports the resolved
interpreter and how it was found, the versions of packages whose version changes
results, and whether the worker scripts are present; it covers `mbr.python` and
the script directory, which it never checked; and it no longer fails a native
configuration over a worker directory that configuration never opens. It warns
when DeepLC is older than 4.1.1, since the 4.0.0a2 preview overfits per-run
fine-tuning badly enough to invert RT-model rankings.

`configs/examples/{native,fasta-sidecars,diann-library}.json` replace the tracked
machine-specific config, with `configs/README.md` covering the resolution order,
the environment specs and the DeepLC floor. The old file is untracked but left on
disk so an existing local workflow keeps working. `shipped_configs_parse` now
covers all five shipped configs.

Verified against the real environments on this machine: a native config is
runnable with nothing probed; `nn_torch` with no torch anywhere fails with a
message naming the variable and the modules; `MUMDIA_PYTHON_RESCORE` pointing at
a torch env resolves and reports torch 2.5.1; and a DeepLC 4.1.0 environment is
accepted with the version warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`--threads N` bounds the engine's rayon pool and forwards the count to the Python
sidecars as `MUMDIA_NN_THREADS` and `OMP_NUM_THREADS` when those are unset. There
was previously no way to bound MuMDIA: the engine never read
`RAYON_NUM_THREADS`, so rayon took every core, which on a shared machine is
antisocial and, for the NN rescore worker, actively slower. That worker measured
faster on 8 threads than on 32. An already-set variable is left alone, because a
user who exported `OMP_NUM_THREADS` did so for a reason.

`--log-level` accepts any `RUST_LOG` filter, and `-v`/`-vv`/`-q` map onto levels.
Verbosity was `RUST_LOG`-only, which does not appear in `--help` and is awkward to
set on Windows. `RUST_LOG` still works and still offers per-module filtering; an
explicit flag wins over it. `-q` and `-v` are mutually exclusive rather than
silently ordered.

All four are `global = true`, so they parse on either side of the subcommand,
which is what a user types. Tested for that, for the level mapping, for the
explicit-level precedence, and for `--threads 0` being rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The validated-workflow block now names configs/examples/diann-library.json, whose
interpreters are "auto". That runs unchanged only where an environment with
torch and DeepLC is discoverable, so the block says so, points at doctor for the
diagnosis, and notes that copying the example and writing the two paths in is the
supported way to pin them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was no fixture and no end-to-end test. The Rust integration test builds its
inputs in process and starts at `extract`, so mzML parsing, the library build, the
`run` orchestrator, the manifest, retention-time calibration on real anchors, and
`quant`/`report` writing files were all untested. `.gitignore` has whitelisted
`test_data/**/*.parquet` for some time, but no `test_data/` existed.

The fixture is generated rather than committed, for two reasons. A usable slice of
a real DIA run is megabytes of binary in git and carries a licence question about
excerpting a public raw file. More importantly, the planted fragment peaks have to
sit where the engine looks for them: `ci/make_fixture_mzml.py` reads the precursor
and fragment tables that `mumdia predict-frag` just produced and plants exactly
those m/z values, so the fixture cannot disagree with the mass model, and if the
mass model changes the fixture changes with it.

`test_data/fixture.fasta` is 16 synthetic proteins composed of 160 distinct
tryptic peptides, giving 3,820 library candidates. The generator writes a small
DIA acquisition: 60 cycles of one MS1 and eight MS2 windows, 160 target precursors
planted with Gaussian elution profiles, retention time an affine function of the
library iRT so calibration has a real relationship to fit, and two kinds of seeded
noise. The second kind matters: noise drawn from the library's own fragment m/z
pool is what gives decoys the chance evidence a real run has. Without it every
accepted candidate was a target and rescore correctly refused the run for having
no decoys, so the fixture could not reach FDR, quant or report at all.

`ci/check_smoke.py` asserts 102 things, in two kinds. Exactly determined facts are
asserted exactly: spectrum and window counts, that retention time arrives in
seconds, every artifact's blake3 hash shape and row count, all 17 artifact schema
versions, that the manifest's rescorer identity matches what the scored artifact
reports, and that the classifier that ran is the one requested. Scientific
outcomes are asserted as bands, because pinning them would turn a sensitivity
improvement into a red build.

Measured on this fixture: 99.3% of planted peptides recovered (151 of 152), zero
decoys at 1% peptide q, LOESS calibration fitted on 110 anchors with a 1.29 s
in-sample residual median, and a byte-identical `peptides.tsv` across two runs.
That last one is the determinism contract tested through the CLI for the first
time; the Rust test only compared `apex_rt` within one process.

`ci/smoke.sh` drives it and runs on Linux and Windows in CI. macOS is omitted to
keep CI minutes down, which is a deliberate gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A manifest said `mumdia_version: 0.1.0`, which every build from this branch says,
so a result could not be tied back to the code that produced it. docs/20 asks
every benchmark record to carry "commit/build:" and the manifest could not supply
one. Inputs were not recorded at all: an imported library was hashed, but the mzML
and the FASTA were not, so a manifest could not answer which file it came from.

`mumdia-core/build.rs` stamps the short commit and the commit date into the
binary, with a `-dirty` suffix when the worktree carried uncommitted changes,
because a number produced from uncommitted code is not reproducible from the named
commit. The COMMIT date is used rather than the build time on purpose: build time
changes on every rebuild, which would make the binary unreproducible and defeat
caching for no benefit. Without git the values become `unknown` rather than
failing the build, so a release-tarball build still works.

`Manifest` gains `git_sha`, `commit_date`, `cli_args` and `inputs`, plus
`provenance()` for the one-line stamp a benchmark should quote. `run` hashes every
input before compute starts. `cli_args` matters because the flags that are not in
the config, `--top-peaks-ms2`, `--threads` and `--max-spectra`, previously existed
in no record at all, and the peak cap changes results.

All four fields are `#[serde(default)]`, so manifests already on disk keep
parsing; a test asserts that, because making prior runs unreadable would be a
worse outcome than the gap being fixed.

`run-experiment` gains the same provenance, including a hash per input mzML. Its
manifest is still thinner than the single-run one: it carries no per-artifact
records, because the per-run chains do not thread a shared manifest.

`scripts/deeplc_finetune.py` takes `--seed` and seeds numpy and torch, wired from
`rng_seed`. Unseeded, the draw varied enough to change results: the held-out RT
window p95 moved 150 to 211 s across two draws of one benchmark arm, worth about
2% of peptides, which made single-run comparisons of window sizing or library
variants unreadable. Training kernels are still not bit-for-bit deterministic, so
this narrows the variance rather than removing it, and both the flag help and the
log line say so.

The smoke test asserts the new fields; it is now at 112 assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RobbinBouwmeester and others added 30 commits August 28, 2026 16:52
`ci/gen_third_party_licenses.py --check` failed on `457b93c`. The generator read
per-crate notices only from cargo's UNPACKED `registry/src` tree, which exists
after a build; the `sidecars` job never compiles anything, so on the runner it
recovered zero notices and reported the committed file as stale. The difference
was the environment, not the tree, which makes the gate meaningless in both
directions.

It now reads the `.crate` tarball from `registry/cache` when no unpacked source is
present. The two sources hold the same bytes, verified by regenerating with
`registry_src_dirs()` forced empty: byte-identical document, the same 126 of 173
crates with a recovered notice. The `sidecars` job runs `cargo fetch --locked`
first, which is what populates that cache.

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

The rehearsal at `b167fdb` failed the new "Verify the unpacked archive" step with
exit 2 and no output. `archive=$(ls *.tar.gz *.zip 2>/dev/null | head -1)`: exactly
one of those globs matches, `ls` exits 2 on the other, `pipefail` propagates it,
`set -e` kills the step, and `2>/dev/null` discarded the only explanation. This is
the failure mode docs/14 already records for `cmd && echo` inside a command
substitution.

Discovery now uses a nullglob array with an explicit empty check, unpacking a zip
uses the Python already set up in the step rather than assuming 7z, and the
unpacked root comes from `find` with a check that it exists. The smoke test is
invoked with no work-directory argument, matching what ci.yml does on all three
platforms, instead of handing a Windows path to a bash script.

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

`validate-tag` reads `ci.yml`'s conclusion for the tagged SHA through the Actions
API, which `contents` does not grant: with the workflow's single
`permissions: contents: write` the call returns 403 and the gate cannot tell a
green commit from an untested one.

Permissions are now per job and least privilege: the workflow default drops to
`contents: read`, `validate-tag` adds `actions: read`, and only the publishing job
carries `contents: write`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`needs.validate-tag.result != 'failure'` also admits `cancelled`, and a cancelled
gate is not a passed gate: the checks stopped part-way, so a tag could publish
without any of them having completed. The condition now allows exactly `success`
(a validated tag) or `skipped` (a rehearsal, which has no tag to validate).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`THIRD_PARTY_LICENSES.md is stale` and nothing else is unactionable when the
disagreement is environmental rather than in the tree, which is exactly the case
under investigation: the runner reports the committed file as stale while a local
run with a fresh CARGO_HOME and only `cargo fetch --locked` reproduces it
byte-for-byte. Both `--check` paths now print a bounded unified diff, capped at 200
lines, and say so when the difference is line endings only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI's diff, now that `--check` prints one, named the cause exactly: 126 crates with
a recovered notice on Windows, 119 on Linux, the difference being the seven
`windows-*` crates. They ship `license-mit` and `license-apache-2.0` in lower case,
and `Path.glob("LICENSE*")` follows the platform: case-insensitive on Windows,
case-sensitive on Linux. A notice bundle whose contents depend on which machine
generated it is not a notice bundle, and it made the staleness gate fail on every
CI run.

Filename matching now goes through one explicitly case-insensitive predicate used
by both read paths, and the unpacked scan iterates the directory name-sorted rather
than per glob pattern, so a crate carrying both a LICENSE and a NOTICE yields them
in a fixed order. Verified: `license-mit`, `LICENSE-MIT`, `License.md`, `COPYING`
and `notice` all match, `readme.md` does not, and both read paths return the same
notices for the affected crates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third environment-dependence in a generated file, again named by the diff the
staleness check now prints: the committed `sbom.cdx.json` carried `Sørensen` where
a Linux run produced `Sørensen`. `subprocess.run(..., text=True)` decodes with
`locale.getpreferredencoding()`, which is cp1252 on this machine, so cargo's UTF-8
JSON was mis-decoded and the mojibake was written into the artifact. Both
generators now pass `encoding="utf-8"`.

The licence bundle now matches on Linux, confirming the case-insensitivity fix; this
was the only remaining difference. docs/14 records all three causes together.

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

The archive-verification claim now names where it was verified: locally by staging
the layout, and then in a release rehearsal where the linux-musl, windows-msvc and
aarch64-darwin archives each unpacked and passed their own 136 assertions on their
own runner. The tag gate's CI lookup was tested against this repository's history in
both directions, including that `457b93c` is refused.

Adds the three defects found while getting the gates green: two generated files
whose contents depended on the generating operating system (licence-file case,
cargo's unpacked tree, locale decoding), which had made the staleness job
permanently red.

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

The archive-contents list omitted `THIRD_PARTY_LICENSES.md`, and now also carries
`sbom.cdx.json` and the smoke test plus its fixture. The licence section explains
the split: the notice document discharges the obligations of 173 statically linked
crates, the SBOM is the same inventory in the form a vulnerability scanner or a
software inventory consumes, and both are staleness-checked in CI.

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

`x86_64-apple-darwin` was built on `macos-13`, which no longer receives a runner.
Measured 2026-08-28: that job sat queued for over two hours in two separate
rehearsals with no runner ever assigned, while every other target finished in about
three minutes. A real `v0.1.0` tag would have hung to the six-hour job timeout and
failed the release, so this was a release blocker in its own right rather than a
cosmetic matrix entry.

Cross-compiling it on the Apple silicon runner was considered and rejected: the
result cannot be executed there, so it would be the one archive published without
its own smoke test having run, which is precisely what the new verification step
exists to prevent. Intel Mac users build from source or use the container image.

Three targets remain, each built AND end-to-end verified on a runner that executes
its own binary. README, CHANGELOG, docs/14 and docs/22 all say so; the docs/14
release-workflow section was rewritten, since its line citations, permissions and
target list had all gone stale.

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

Deleting the `macos-13` matrix entry left its `exe: ""` attached to the previous
entry, so that mapping carried the key twice. PyYAML's `safe_load` accepts a
duplicate key silently and keeps the last value, so every local check passed and the
dispatch failed on the remote:

    HTTP 422: failed to parse workflow: (Line: 143, Col: 13): 'exe' is already defined

The orphan is removed. `ci/check_workflows.py` now rejects duplicate mapping keys at
any depth with GitHub's own wording and line number, and checks that each workflow
has an `on:` trigger and each job a `runs-on` or `uses`. It runs in the `sidecars`
job. Verified in both directions: clean on the three workflows, and it reproduces
this exact failure when the duplicate is reintroduced.

The class matters more than the instance: a workflow GitHub cannot parse fails when
it is DISPATCHED, which for release.yml is the moment a release is attempted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new workflow check ran before "Parse env specs", which is where
`pip install pyyaml` happened to live, so it failed with `ModuleNotFoundError: No
module named 'yaml'`. The dependency belongs to the job: it is now one explicit
step ahead of both YAML checks, and the inline install is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The identification arm said the apex moves for 48.3% of identified peptides while
identification stays flat, and that quantities from before the change are therefore
not comparable with quantities after it. That left the question open exactly where it
mattered. This closes it on known ratios.

`run-experiment` over the six ProteoBench AIF HYE files, imported DIA-NN library,
`nn_torch`, MBR off, one arm per value of `extract.apex_evidence_rank` and nothing
else changed. Both arms on the in-memory rescore backend, so they are comparable to
each other.

Quantification does not distinguish them: every accuracy difference is under 0.013 in
median |epsilon| and 0.002 in median CV, inside the DeepLC fine-tune draw variance the
two arms carry independently. Yeast medians agree to three decimals; human and E. coli
differ in opposite directions.

Extraction does distinguish them, for the promoted default. The legacy apex pushes
27.6% more candidates through extract (14.29 M against 11.20 M) and returns 0.4% fewer
PSMs and 1.0% fewer peptides from them, costing an hour of pooled rescore. That is the
documented fallback -- score 0.0 everywhere, apex silently becomes the lowest-RT
qualifying scan, gate evaluated where the evidence is not -- appearing as measured
cost rather than as an argument.

Adds `bench/qab_score.py`, which computes the numbers from a `run-experiment` output
tree, and states in its docstring why they are not ProteoBench's own figures. The
E. coli compression is present in both arms, reproduces the 2026-08-26 pooled result,
and is not an apex effect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Final pre-release scan of the workspace. Four defects, one of them reproduced as a
crash, plus the class it belongs to.

**A single malformed retention time aborted the run.** `convert` validates peak m/z
and intensity but never checked the scan start time, which is the value every later
stage keys on. Editing one `scan start time` to `NaN` in the fixture mzML: convert
exits 0 with no warning, writes `nan` into `spectra_ms2.parquet`, and `mumdia run`
then dies at `extract.rs` with `called `Option::unwrap()` on a `None` value`, naming
neither the file, nor the scan, nor the value. Such spectra are now dropped with a
count and the first offending scan id. Dropping loses nothing, because a spectrum
with no retention time cannot be placed in a chromatogram, and `ci/smoke.sh` step 4b
asserts the run completes, the drop is reported, and the identifications are
unchanged.

**The ordering class behind it.** 25 `partial_cmp(..).unwrap()` and 36
`partial_cmp(..).unwrap_or(Equal)` comparators now use `total_cmp`. The first panics
on NaN. The second is worse: `Equal`-on-NaN is intransitive, and `sort_by` has
detected that and panicked since Rust 1.81, so it turned a deterministic failure into
an intermittent one. `total_cmp` agrees with both on every finite value, and the
fixture's `peptides.tsv` and `proteins.tsv` hashes are byte-identical across the
change. One rewritten comparator picks the competition winner, where treating every
NaN as equal made the surviving row depend on iteration order.

**`compete` panicked on a stale feature-schema companion** rather than naming the
column and the file. `FeatureSchema::read` already handles a MISSING companion; this
covers one that is present and wrong.

**`make_reverse_decoys.py` silently assigned 0 Da to unknown modifications**, so those
decoys carried fragment m/z for the wrong molecule and could never match. A decoy that
cannot match does not compete, so the target-decoy null was optimistic for exactly the
peptides carrying that modification, with nothing in the output to show it. The
sampled calculator check cannot catch this (500 precursors, 99th percentile). Unknown
modifications now raise, `valid()` rejects the peptidoform, and the script reports the
names and counts, matching the engine's own `MassError::UnknownModification`.

Gate: fmt, clippy -D warnings, 203 tests, smoke 136 assertions with unchanged output
hashes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`rt` and `intensity` are two independent list columns, and every integration slices
`intensity` with indices computed from the length of `rt`: `fixed_window_indices`
bounds against `rt.len()` alone. Extract writes the two from paired vectors, so they
always match in practice, but a chromatograms table is path-addressable and
`mumdia quant --chromatograms` accepts one written by anything. A shorter intensity
trace panicked with a slice-index message naming no candidate and no file.

Checked once at the read rather than at each of the four slice sites, which is the
same pairing check `convert` applies to an mzML's m/z and intensity arrays and
`spectra.rs` applies to the MS1 pair. No behaviour change when the lengths agree:
smoke output hashes are unchanged.

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

The four places recording why `x86_64-apple-darwin` was dropped all said a real tag
would have hung to "the six-hour timeout". A cancelled rehearsal job has since
reported its duration as exactly `24h0m0s`, which is GitHub's limit on a job WAITING
for a runner. The six-hour limit applies to a job that is running, and this one never
started, so the number was wrong and the consequence was understated by a factor of
four.

The decision is unchanged and better supported: a `v0.1.0` tag would have sat for a
full day before failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First milestone of the desktop application plan: pick an mzML and a FASTA or a
library, start a search, watch it, read the results. Windows and Linux.

No engine changes. Progress comes from the `<artifact>.report.json` files the
engine already writes, results from the report `rescore` already emits, and the
run itself from the documented CLI. The whole milestone is an application, which
is what makes it cheap to throw away if the shape turns out to be wrong.

The engine is a CHILD PROCESS, not a linked crate. The engine installs no signal
handler anywhere, so stopping a run is a kill, and a Rust thread cannot be killed:
linked in-process there would be no Stop button at all, only a window that ignores
you for an hour. Supporting reasons: a stage panic ends one run rather than the
session, and rayon's global pool can only be built once per process, so --threads
could not otherwise change between runs. The application also shows the exact
command it ran, which a linked engine could not offer.

Process control is three deep -- application, engine, Python workers -- so
cancelling uses a process group on Linux and `taskkill /T` on Windows. A hard kill
skips destructors, so the atomic-write layer never removes its `.tmp-<pid>` files;
cancelling therefore sweeps them, or the next run would start in a dirty folder.
Closing the window cancels every live run for the same reason.

Two bugs found by writing the tests rather than by reading the code:

- The terminal state was published before the watcher had scanned, so a caller
  polling for completion could read a finished run with no stages and no results.
  The interface would have flashed an empty results screen. The final scan now
  happens in the waiter, before the status stops being `running`.
- The cancel test passed while never cancelling anything: the fixture search
  finishes in about 700 ms, faster than a stop can be issued. It now says so
  instead, and `kill_tree` is covered directly by a test that spawns a process
  and checks the kill lands.

Verified: 10 unit tests including stage folding and results parsing against real
artifact reports from a recorded run, plus 2 end-to-end tests that drive a real
search through the supervisor. fmt and clippy -D warnings clean. The frontend is
plain ES modules with no build step, so the release pipeline still needs no Node.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two additions the desktop application needs, and nothing else changes.

`mumdia doctor --json` emits the same check as data. The application renders one
row per sidecar role and offers to install what is missing, which needs the
modules, versions and provenance as fields rather than a paragraph to regex. The
function is split into a builder and a renderer, so the prose form is produced from
the same report and cannot describe a different outcome. Text output is unchanged
and the exit status still carries the verdict.

`configs/config-schema.json` is the settings form as data: 150 settings with type,
typed default, doc comment, enum choices and the gate markers already used in the
reference document. Generated by `ci/gen_config_reference.py`, which already parses
`config.rs` for exactly this and already runs in CI, so one parse now produces both
artifacts and `--check` covers both. The alternative was for the application to
carry its own copy of the field list, which is a second thing to keep in step and
the one that drifts is the one a user reads.

Defaults are rendered by the same function the Markdown table uses, then given
their JSON type: `true` not `"true"`, `0.2` not `"0.2"`, `base_peptide` not
`` `base_peptide` ``. Computed defaults resolve to their value, so
`features.bound_peak_fraction` is 0.333333 rather than the prose `1.0 / 3.0
(0.333333)`.

The schema ships without any packaging change: the release archive already copies
`configs/` wholesale.

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

M2, installing itself. `uv` supplies both the interpreter and the packages, so conda
disappears from the user's machine entirely: no "install Miniconda, create two
environments, then edit the config to point at the right interpreters", which is the
step where an external user gives up. The environment is created under the per-user
data directory, not beside the executable, because on Windows that is Program Files
and an installer needing administrator rights on first run is not an easy install.

Searching without components is refused, as decided. The predicate is "this
configuration requires no Python sidecar at all", asked of the engine through
`doctor --json` rather than kept as a list here. Refusing anything that mentions
`native_tda` would have been wrong: on an imported library with retention-time
modelling it measured 10,847 against nn_torch's 10,914, and the 1,213-vs-10,300 gap
that motivates the rule is the zero-component path, not the rescorer.

**The one-environment plan was wrong, and uv proved it.** The plan claimed a single
environment could serve all four sidecar roles, on the strength of their module
lists overlapping. That is not the same question. The resolver answer:

    deeplc==4.1.1  -> psm-utils>=1.5 -> sqlalchemy>=2
    ms2pip==4.0.0  ->                   sqlalchemy>=1.3,<2

Unsatisfiable. `ms2pip>=4.1` does coexist with DeepLC and would restore one
environment, but MS2PIP's version changes predicted fragment intensities and
env/docker-rescore.yml pins 4.0.0 deliberately as "a separate, testable upgrade";
trading a measured result for packaging convenience is the wrong direction. So there
are two: a primary one covering the whole recommended workflow, and MS2PIP on
request, needed only for FASTA-mode prediction. A test asserts MS2PIP stays out of
the primary set, so a later "fix" that adds it back fails at test time rather than
at install time on a user's machine.

M3, configuration. The form is generated from configs/config-schema.json, so nothing
about a setting -- name, type, default, help, enum choices, gate markers -- is
written in the interface. Saving writes only the difference from the defaults, which
keeps configurations short and lets a later release improve a default for someone
who saved settings today. Every save is validated by the engine before it is
offered, so a rejected value is reported while editing rather than an hour into a
search. Benchmark-gated parameters are badged with the reason.

Two bugs found by the tests, both user-facing:

- The installer's terminal status was overwritten by the next status refresh, so
  "done" was never observable and a caller watching for completion waited for ever.
- `uv venv` refuses an existing directory, so Install failed permanently after the
  first attempt, including after a failed attempt that left a partial environment --
  exactly when someone presses it again. `--allow-existing` repairs instead, rather
  than `--clear` discarding a several-hundred-megabyte download.

Verified: the environment really was created from nothing on this machine, and every
sidecar role's imports resolve in it. 20 unit tests, 3 end-to-end. fmt and clippy
-D warnings clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Milestone 4 of the console plan. `release.yml` gains a `desktop` job that builds the
engine and the application from one checkout, so a released application and its
engine cannot disagree, stages both plus a pinned `uv` into the bundle, and produces
a `.msi` and an `.AppImage` with sha256 sidecars. It gates on the same `validate-tag`
job the engine archives do, uploads on a tag, and uploads as an artifact on a
rehearsal.

macOS is deliberately absent: Gatekeeper blocks an unsigned bundle outright, so
shipping one would be worse than shipping none.

Also restores the bundle `resources` declaration, which had been removed in M1
because an empty glob fails the build. CI now stages real binaries there, so without
the declaration the engine would have been built and then not bundled. The directory
carries a committed README, which both explains itself and keeps the glob matching
for a developer bundling locally.

NOT VERIFIED. This has not been through a rehearsal, so two things are unconfirmed:
the bundle layout, and whether the application finds its bundled binaries inside an
AppImage, whose resource directory is not beside the executable the way the MSI's is.
`engine.rs` looks beside the executable and in `binaries/`, which is right for the
MSI and may not be for the AppImage. A rehearsal is the next step, and the fix if
needed is a resource-directory lookup rather than a redesign.

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

Building the first real installer found a packaging bug of exactly the kind the plan
said needed a rehearsal, so it is worth naming precisely.

Tauri's resource LIST form keeps a source path's shape, so `"../../configs/*"`
installs to `<install>/_up_/_up_/configs/` -- read out of the generated WiX source
rather than guessed. The application looks beside its executable and would never have
found its own settings schema. The MAP form does let a destination be named, and
fails the build outright on a `..` source with `Access is denied`.

Rather than work around that, the files stop being files. The settings schema and
both requirement sets are now compiled in with `include_str!`. This is simpler, and
strictly more robust: a file that is not a file cannot go missing from a bundle. It
costs nothing in freshness either, because all three are generated from sources that
require a rebuild anyway, and CI already fails when the schema is stale.

Only two resources remain, both executables: the engine and `uv`, in `binaries/`.

Verified on Windows, not asserted: `cargo tauri build` produces a 29 MB MSI
containing `mumdia-console.exe` with `binaries/mumdia.exe` and `binaries/uv.exe`
beside it, and no `_up_` directory anywhere. The Linux AppImage remains unbuilt, and
its resource directory is not laid out like an MSI's, so `engine.rs` may need a
`resource_dir()` lookup as well; that is the first thing a rehearsal should check.

Also fixes a test that passed for the wrong reason: it searched the whole
requirements file for "ms2pip", which the comments discuss at length while explaining
why it is excluded. It now reads requirement lines only.

21 unit tests, 3 end-to-end including the real component installation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the remaining milestone-3 items and the packaging risk milestone 4 left open.

`mumdia peak-census --mzml X` reports peaks per MS2 spectrum as JSON, with
percentiles and what each candidate `--top-peaks-ms2` value would discard. This is
the check `docs/04_convert.md` says must be made per acquisition and that the
playbook describes but nothing performed: a cap carried from another run deletes
fragment evidence rather than failing, and on one 50-window Orbitrap DIA run a
300-peak cap cost 60% of the peptides. The application runs it when an mzML is
chosen and states the answer, instead of leaving a number box to guess into.

Disk pre-flight estimates the output from the input size and compares it with the
free space on the chosen volume. The engine cannot resume, so filling a disk at hour
three loses the whole search; this is the cheapest moment to notice. Free space is
read by shelling out rather than through the platform API, because this crate has no
`unsafe` and the check runs once per search.

The resource lookup now asks Tauri where the bundle put things, before falling back
to the paths beside the executable. This is what an AppImage needs: its resources
live under `usr/lib/<app>/`, so every `exe.parent()` candidate misses and the
application would not have found its own engine. Recorded once at startup into a
`OnceLock`, because resolution happens on background threads that hold no
`AppHandle`.

Also verified in both directions that the interface and the backend agree: every
command the frontend calls exists in Rust, and every registered command is called.

24 desktop unit tests, 203 engine tests, fmt and clippy clean on both workspaces.

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

History reads past searches back from the folders they wrote rather than from an
index: `manifest.json` for the engine version and `psms_scored.parquet.report.json`
for the counts and the classifier that actually ran. The application remembers only
which folders it has used, so a search moved or deleted on disk stops appearing
instead of lingering as a stale row that disagrees with the folder it describes. A
folder that never held a search drops out, which matters because users pick output
folders by hand.

Fixes the desktop Linux build, found by the first rehearsal. `rustup target add`
adds a target to whichever toolchain is active in the directory it runs in, and
`rust/mumdia` pins its own with `rust-toolchain.toml`. Run from the repository root
it added musl to the DEFAULT toolchain while the build used the pinned one, which
failed with "can't find crate for `core`" and advised running the command that had
just succeeded. The engine's own release job gets this right by accident, because it
sets `working-directory` for the whole job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The console lives in its own Cargo workspace, so nothing in CI compiled it: it could
have broken while every check stayed green. It is not independent of the engine
either, because the settings schema and both requirement files are compiled into it
with `include_str!`, so a change to `config.rs` that regenerates the schema reaches
this crate.

The new job runs fmt, clippy and the library tests, plus `ci/check_desktop_ui.py`,
which is the frontend's whole check: the interface has no build step and no
framework, so nothing otherwise catches a typo in an element id or a command name.
Those are precisely the mistakes that survive review and fail on a user's machine.
It verifies in both directions, so a command that lost its caller is noticed as well
as one that never had a definition.

End-to-end tests are deliberately not run here: they skip themselves without an
engine binary, which would make the job look like it covered more than it does. A
real bundle is exercised by the release rehearsal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidecar audit ran for the first time on this branch, because the job is gated on
a change under `env/` and nothing had changed there since it was added. It fails:

    ERROR:pip_audit._cli:torch: Dependency not found on PyPI and could not be
    audited: torch (2.12.1+cpu)

`--strict` fails when a package cannot be resolved on PyPI, and `torch==2.12.1+cpu`
is installed from the PyTorch index as a local version that by construction does not
exist there. That is how CPU torch is distributed, not a vulnerability, and a job
that fails every time on it teaches everyone to ignore it.

`--strict` is dropped and the reason recorded next to the command, along with the gap
it leaves: torch itself cannot be audited this way by anything, so its advisories
have to be watched at the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new CI job failed on its first run, and the cause is worth stating plainly: 25 of
26 tests passed, the missing one was `kill_tree_terminates_the_process_it_is_given`,
and the next line in the log was "The runner has received a shutdown signal". The
test killed the GitHub runner.

The mechanism is a real defect in the product, not only in the test. `kill_tree`
signalled a process group derived from the child's pid without ever checking that the
group was the child's own. `Command::process_group` is asked for at spawn time, but
if it does not take effect the child sits in OUR group, and signalling that group
reaches the application, the shell that started it, and on a shared machine whatever
else shares it. The engine spawns Python workers, so group signalling is the right
mechanism; doing it unverified is not.

The group is now confirmed to belong to the child before it is signalled, and when it
does not, the child is killed individually instead. That path orphans a Python worker,
which is a real cost and much smaller than the alternative; it should never be taken,
because `start` does set the group.

Adds a test for the guard's decision that deliberately does not act on it: it spawns a
child that shares our group and asserts the guard refuses. `pgid_of` shells out to
`ps` rather than calling `getpgid`, because this crate has no `unsafe` and the check
runs twice per cancellation.

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

The first successful AppImage build produced a broken application, which only
extracting and running it revealed. The bundled engine segfaults:

    ./usr/lib/MuMDIA/binaries/mumdia --version
    Segmentation fault

`linuxdeploy` runs `patchelf` over every ELF binary it bundles. The engine is built
for `x86_64-unknown-linux-musl`, so it is static-pie, and patchelf injects a
`RUNPATH [$ORIGIN]` entry into it and corrupts it. `uv`, sitting in the same
directory and given the same treatment, runs fine because it is dynamically linked
and there is something meaningful to rewrite.

The desktop job now builds the engine for `x86_64-unknown-linux-gnu`. The engine's
own release archives are unaffected and stay musl; they are not passed through a
bundler. Nothing is lost by the change either, for the reason already recorded when
musl was chosen: it would buy portability only if the bundle had no other glibc
floor, and the Tauri host links WebKitGTK.

Also confirms the layout the resource lookup was written for, which until now was
reasoning rather than a measurement:

    usr/bin/mumdia-console
    usr/lib/MuMDIA/binaries/mumdia

The engine is not beside the executable in an AppImage. Without asking Tauri for the
resource directory the application would search `usr/bin/` and report that it cannot
find its own engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit's message described this change; the commit did not contain it.
A patch script asserted on its second edit and aborted before writing the file, so
only the README landed while the message claimed the workflow had changed too. The
workflow change is here.

Engine targets are unchanged and stay musl. Only the desktop job's engine moves to
`x86_64-unknown-linux-gnu`, because a static-pie musl binary does not survive
`linuxdeploy`, which patchelfs a `RUNPATH [$ORIGIN]` into it and leaves it
segfaulting. `musl-tools` is dropped from that job with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It terminated a GitHub runner twice. The first time is explained: the group kill had
no guard, so it could signal the runner's own process group. The second time it did
it again WITH the guard, which should have permitted a group signal only for a child
verifiably in a group of its own, and I cannot account for that.

Two possibilities stay open: the guard's reasoning is wrong in a way I have not seen,
or something about the runner's process arrangement makes any group signal fatal
there. The gating follows from not knowing, not from a diagnosis. A test that can
take down the machine it runs on does not belong in a shared pipeline while its
failure mode is unexplained, and burning more runners to find out is not a reasonable
way to look.

It runs with `MUMDIA_TEST_KILL=1`, and passes on Windows, where `taskkill /T`
addresses a process tree rather than a group.

The gap this leaves, recorded in the README rather than left implicit: the Unix
group-kill path in `kill_tree` is exercised by nothing automated. What is still
covered is the guard's decision, tested without acting on it, which is the half that
can be checked safely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `cargo run` or `cargo tauri build` binary sits in `desktop/target/<profile>/`,
which is three levels below the repository root. Both fallbacks counted four, so from
this checkout they resolved to `H:/OneDrive - UGent/rust/...` rather than
`H:/OneDrive - UGent/MuMDIA_NG/rust/...` and never matched.

It went unnoticed because this machine redirects the engine's target directory off
the synced tree, so `MUMDIA_BIN` was set for every run anyway. On a normal checkout
it means the application cannot find an engine it built minutes earlier, and reports
that it looked in a path outside the repository, which is a confusing first
impression for anyone building from source.

Affects only the development convenience path. A bundled application finds its engine
through the resource directory or beside its own executable, both of which are
verified on Windows and Linux.

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.

1 participant