diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a93434b..41b6bcd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -298,9 +298,17 @@ jobs: run: | set -euo pipefail python -m pip install --quiet pip-audit - # --strict fails on a package pip-audit cannot resolve, rather than passing - # over it in silence, which would make a clean report meaningless. - pip-audit --strict --progress-spinner off + # NOT --strict, and the reason is specific rather than a shrug. --strict + # fails when a package cannot be resolved on PyPI at all, and `torch` is + # installed from the PyTorch index as `2.12.1+cpu`, a local version that by + # construction does not exist on PyPI. That is not a finding, it is how CPU + # torch is distributed, and failing on it would train everyone to ignore + # this job. Real advisories still fail the run. + # + # The gap this leaves is torch itself. Watch + # https://github.com/pytorch/pytorch/security/advisories directly; nothing + # here can audit a wheel PyPI has never seen. + pip-audit --progress-spinner off # The exact package set the audit above ran against. The specifications pin the # tools that matter (mokapot, ms2pip, deeplc, torch) and leave the scientific @@ -338,6 +346,44 @@ jobs: python -m pip install --quiet pytest python -m pytest tests/python -q -rs + # The desktop application. A separate Cargo workspace, so nothing above builds it, + # and without this job it could break while every other check stayed green. + # + # It is not independent of the engine either: the settings schema and both + # requirement files are compiled into it with `include_str!`, so a change to + # `config.rs` that regenerates `configs/config-schema.json` reaches this crate. + # That coupling is what makes the job worth its minutes. + desktop: + name: desktop app + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + # Tauri links WebKitGTK, whose development headers the runner does not carry. + - name: Install the build dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + + - name: Format + working-directory: desktop/src-tauri + run: cargo fmt --check + + - name: Clippy + working-directory: desktop/src-tauri + run: cargo clippy --all-targets -- -D warnings + + # Library tests only. The end-to-end tests skip themselves without an engine + # binary, which would make this job look like it covered more than it does; a + # real bundle is exercised by the release rehearsal instead. + - name: Unit tests + working-directory: desktop/src-tauri + run: cargo test --lib + + # The frontend has no build step, so this is its whole check. + - name: Frontend agrees with the backend + run: python ci/check_desktop_ui.py + smoke-cross-platform: name: cross-platform byte equality runs-on: ubuntu-latest @@ -434,7 +480,10 @@ jobs: # docs/24 is generated from config.rs, so a new field or a changed default # must land with its documentation or CI fails. A reference nobody # regenerates is worse than none, because it reads as current. - - name: Config reference is current + # One parse of config.rs produces two artifacts: the reference document a + # person reads, and configs/config-schema.json, which the desktop settings + # editor renders its form from. They go stale together, so --check covers both. + - name: Config reference and schema are current run: python ci/gen_config_reference.py --check # The release binary is statically linked, so it carries 173 third-party crates diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d05cd954..c687d7b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -342,3 +342,146 @@ jobs: rust/mumdia/dist/*.sha256 if-no-files-found: error retention-days: 7 + + # The desktop application, built for the two platforms it targets. + # + # A separate job rather than steps inside `release`: it needs Node-free but + # webview-ful build dependencies on Linux, its own cargo workspace, and it must not + # slow down or fail the engine archives, which are the primary artifact. macOS is + # deliberately absent -- an unsigned bundle is blocked outright by Gatekeeper, so + # shipping one would be worse than shipping none. + desktop: + name: desktop ${{ matrix.target }} + needs: [validate-tag] + if: always() && contains(fromJSON('["success", "skipped"]'), needs.validate-tag.result) + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + # GNU, not musl, and measured rather than assumed. The engine's own + # release archives are musl and stay musl; inside an AppImage they do not + # survive. `linuxdeploy` runs `patchelf` over every ELF binary it bundles, + # and a static-pie musl binary comes out with a `RUNPATH [$ORIGIN]` entry + # injected into it and segfaults immediately. Verified by extracting a + # built AppImage and running the engine inside it; `uv`, dynamically + # linked, survived the same treatment untouched. + # + # Nothing is lost. musl would buy portability only if the bundle had no + # other glibc floor, and the Tauri host links WebKitGTK, so the + # application sets that floor whatever the engine is built against. + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + engine_exe: "" + bundle_glob: "desktop/target/release/bundle/appimage/*.AppImage" + - os: windows-latest + target: x86_64-pc-windows-msvc + engine_exe: ".exe" + bundle_glob: "desktop/target/release/bundle/msi/*.msi" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + + # Tauri needs the platform webview headers at build time on Linux. The + # produced AppImage carries what it needs at runtime. + - name: Install the Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \ + libssl-dev + + # Inside `rust/mumdia`, not at the repository root. That directory pins its + # toolchain with `rust-toolchain.toml`, and `rustup target add` adds the target + # to whichever toolchain is active where it runs. From the root it added musl + # to the DEFAULT toolchain while the build used the pinned one, which then + # failed with "can't find crate for `core`" and advice to run the command that + # had just succeeded. + - name: Add the engine target + working-directory: rust/mumdia + run: rustup target add ${{ matrix.target }} + + # The application ships the engine it was built alongside. Building both from + # one checkout is what makes a version mismatch impossible. + - name: Build the engine + working-directory: rust/mumdia + run: cargo build --release --locked --target ${{ matrix.target }} --bin mumdia + + # Everything the application expects to find beside itself. `binaries/` is + # where `engine.rs` and `components.rs` look, and `uv` is what installs the + # analysis components without conda. + - name: Stage the bundled binaries + shell: bash + run: | + set -euo pipefail + mkdir -p desktop/src-tauri/binaries + cp "rust/mumdia/target/${{ matrix.target }}/release/mumdia${{ matrix.engine_exe }}" \ + desktop/src-tauri/binaries/ + # uv publishes static binaries per platform; take the pinned release rather + # than whatever `latest` happens to be on the day of a build. + UV_VERSION=0.10.6 + case "${{ runner.os }}" in + Linux) UV_ASSET="uv-x86_64-unknown-linux-gnu.tar.gz" ;; + Windows) UV_ASSET="uv-x86_64-pc-windows-msvc.zip" ;; + esac + curl -fsSL -o uv-asset \ + "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/${UV_ASSET}" + case "$UV_ASSET" in + *.tar.gz) tar xzf uv-asset --strip-components=1 -C desktop/src-tauri/binaries ;; + *.zip) python -c "import sys,zipfile;zipfile.ZipFile('uv-asset').extractall('desktop/src-tauri/binaries')" ;; + esac + ls -la desktop/src-tauri/binaries + + # The bundler that produces the .msi and .AppImage. Installed rather than + # vendored, and pinned, so a bundler change arrives as a reviewed version bump. + - name: Install the Tauri bundler + run: cargo install tauri-cli --version "^2" --locked + + - name: Build the installer + working-directory: desktop + run: cargo tauri build + + - name: Collect the installers + shell: bash + run: | + set -euo pipefail + mkdir -p desktop/dist + shopt -s nullglob + found=(${{ matrix.bundle_glob }}) + shopt -u nullglob + if [ ${#found[@]} -eq 0 ]; then + echo "::error::the bundler produced no installer" + find desktop/target/release/bundle -maxdepth 2 -type f || true + exit 1 + fi + for f in "${found[@]}"; do + cp "$f" desktop/dist/ + done + cd desktop/dist + for f in *; do + if command -v sha256sum > /dev/null 2>&1; then + sha256sum "$f" > "$f.sha256" + else + shasum -a 256 "$f" > "$f.sha256" + fi + done + ls -la + + - name: Upload to release + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631 # v2.2.2 + with: + files: | + desktop/dist/* + generate_release_notes: true + + - name: Upload installers as a workflow artifact (rehearsal only) + if: github.event_name == 'workflow_dispatch' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: desktop-dryrun-${{ matrix.target }} + path: desktop/dist/* + if-no-files-found: error + retention-days: 7 diff --git a/ci/check_desktop_ui.py b/ci/check_desktop_ui.py new file mode 100644 index 00000000..9f6acd36 --- /dev/null +++ b/ci/check_desktop_ui.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Check that the desktop frontend and its Rust backend agree. + +The interface has no build step and no framework, which keeps Node out of the +release pipeline but also means nothing catches a typo in an element id or a command +name. Those are exactly the mistakes that survive review and fail on a user's +machine, so they are checked here instead: + +- every `$("id")` the frontend looks up exists in `index.html`; +- every `invoke("name")` it calls is registered in `generate_handler!`; +- every registered command is called by something, so a command that lost its caller + is noticed rather than left as dead weight. + +Usage: + python ci/check_desktop_ui.py +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +HTML = ROOT / "desktop" / "ui" / "index.html" +JS = ROOT / "desktop" / "ui" / "app.js" +MAIN = ROOT / "desktop" / "src-tauri" / "src" / "main.rs" + + +def main() -> int: + for f in (HTML, JS, MAIN): + if not f.is_file(): + print(f"missing {f.relative_to(ROOT).as_posix()}", file=sys.stderr) + return 1 + + html = HTML.read_text(encoding="utf-8") + js = JS.read_text(encoding="utf-8") + rust = MAIN.read_text(encoding="utf-8") + + problems: list[str] = [] + + ids = set(re.findall(r'id="([^"]+)"', html)) + used = set(re.findall(r'\$\("([^"]+)"\)', js)) + if missing := sorted(used - ids): + problems.append(f"element ids used by app.js but absent from index.html: {missing}") + + called = set(re.findall(r'invoke\("([^"]+)"', js)) + handler = re.search(r"generate_handler!\[(.*?)\]", rust, re.S) + if handler is None: + problems.append("no generate_handler! block found in main.rs") + registered: set[str] = set() + else: + registered = {x.strip() for x in handler.group(1).split(",") if x.strip()} + + if unknown := sorted(called - registered): + problems.append(f"commands called by app.js but not registered in Rust: {unknown}") + if unused := sorted(registered - called): + problems.append(f"commands registered in Rust but never called: {unused}") + + if problems: + for p in problems: + print(p, file=sys.stderr) + return 1 + + print( + f"desktop frontend ok: {len(ids)} element ids, " + f"{len(called)} commands, both directions agree." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/gen_config_reference.py b/ci/gen_config_reference.py index cda053eb..9d002acd 100644 --- a/ci/gen_config_reference.py +++ b/ci/gen_config_reference.py @@ -38,6 +38,7 @@ import argparse import ast import difflib +import json import re import sys from pathlib import Path @@ -47,6 +48,7 @@ CRATES_DIR = REPO_ROOT / "rust" / "mumdia" / "crates" SCRIPTS_DIR = REPO_ROOT / "scripts" DEFAULT_OUT = REPO_ROOT / "docs" / "24_config_reference.md" +DEFAULT_SCHEMA_OUT = REPO_ROOT / "configs" / "config-schema.json" GENERATOR = "ci/gen_config_reference.py" CONFIG_RS_REL = "rust/mumdia/crates/mumdia-core/src/config.rs" @@ -1533,9 +1535,137 @@ def check(out_path: Path, generated: str) -> int: return 1 + +# --------------------------------------------------------------------------- +# Machine-readable schema, for the desktop application's settings editor +# --------------------------------------------------------------------------- + + +def build_schema() -> dict: + """The same parse the reference document uses, as data instead of prose. + + The desktop application renders one form control per field, and it must not + carry its own copy of the field list, the types, the defaults or the help text: + a second copy is a second thing to keep in step, and the one that drifts is the + one a user reads. So the form is generated from this, and this is generated from + `config.rs`, checked for staleness in CI exactly as the Markdown is. + + Every field carries the doc comment verbatim as `help`, and `gates` carries the + markers already used in the reference ("benchmark-gated", "experimental", ...), + so the interface can mark a parameter that must not be changed casually. + """ + text = CONFIG_RS.read_text(encoding="utf-8") + structs, enums, _warnings = parse_config_rs(text) + sections, items = walk_sections(structs) + + def field_entry(path: str, field: Field) -> dict: + base = base_type(field.rtype) + # `default_rendered` is populated by the Markdown row builder, which the + # schema does not run. Render it here with the same function, so the value + # the form shows and the value the table shows are produced by one code + # path and cannot disagree. + rendered = ( + render_default(field.default_expr, field.rtype, enums, structs) + if field.default_expr is not None + else None + ) + # `render_default` produces a Markdown cell, so an enum arrives as + # `` `base_peptide` `` and a number as text. The schema is data: strip the + # formatting and give the value its JSON type, so a form control can be + # populated without the interface having to unpick Markdown. + def typed_default(text_value: str | None, kind: str): + if text_value is None: + return None + v = text_value.strip().strip("`").strip() + # A computed default is rendered for prose as `1.0 / 3.0 (0.333333)`. + # The parenthesised value is the number a form needs. + m = re.fullmatch(r".*\(([-+0-9.eE]+)\)", v) + if m: + v = m.group(1) + if kind == "bool": + return {"true": True, "false": False}.get(v, v) + if kind in ("integer", "float"): + try: + return int(v) if kind == "integer" else float(v) + except ValueError: + return v + return v + + kind = ( + "enum" + if base in enums + else "bool" + if base == "bool" + else "float" + if base in ("f32", "f64") + else "integer" + if base in ("u8", "u16", "u32", "u64", "usize", "i8", "i16", "i32", "i64") + else "string" + if base in ("String", "str") + else "other" + ) + entry: dict = { + "path": f"{path}.{field.name}" if path else field.name, + "name": field.name, + "section": path, + "rust_type": field.rtype, + "kind": kind, + "optional": field.rtype.startswith("Option<"), + "default": typed_default(rendered, kind), + "default_text": rendered, + "help": field.doc.strip(), + "gates": gate_markers(field.doc), + "source_line": field.line, + } + if base in enums: + entry["choices"] = [snake_case(v.name) for v in enums[base].variants] + return entry + + fields: list[dict] = [] + for path, _kind, struct in sections: + for field in struct.fields: + if base_type(field.rtype) in structs: + continue # a nested section, not a leaf setting + fields.append(field_entry(path, field)) + + # Vec item structs are reachable settings too, but they are edited as lists + # rather than as single controls; name them so the interface can say so instead + # of silently omitting them. + list_sections = [] + for paths, struct in items: + list_sections.append( + { + "paths": paths, + "item": struct.name, + "fields": [field_entry("", f) for f in struct.fields], + } + ) + + return { + "generated_by": GENERATOR, + "source": CONFIG_RS_REL, + "sections": [p for p, _k, _s in sections], + "fields": fields, + "list_sections": list_sections, + "profiles": { + name: [{"path": p, "value": v} for p, v in changes] + for name, changes in parse_profiles(text).items() + }, + } + + +def schema_text() -> str: + return json.dumps(build_schema(), indent=2, ensure_ascii=False) + "\n" + + def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--out", default=str(DEFAULT_OUT), help="output Markdown path") + ap.add_argument( + "--schema-out", + default=str(DEFAULT_SCHEMA_OUT), + help="output JSON schema path, read by the desktop settings editor", + ) ap.add_argument( "--check", action="store_true", @@ -1548,13 +1678,38 @@ def main(argv: list[str] | None = None) -> int: generated, stats = build_document() out_path = Path(args.out) + schema = schema_text() + schema_path = Path(args.schema_out) if args.check: - return check(out_path, generated) + rc = check(out_path, generated) + # Both artifacts come from one parse of one file, so they go stale together + # and must be checked together. + current = ( + schema_path.read_text(encoding="utf-8") if schema_path.is_file() else "" + ) + if current.replace("\r\n", "\n") != schema: + print( + f"{schema_path.as_posix()} is stale. Regenerate with:\n" + f" python {GENERATOR}", + file=sys.stderr, + ) + rc = 1 + else: + n = len(json.loads(schema)["fields"]) + print(f"{schema_path.as_posix()} is up to date ({n} settings).") + return rc out_path.parent.mkdir(parents=True, exist_ok=True) with open(out_path, "w", encoding="utf-8", newline="\n") as handle: handle.write(generated) + schema_path.parent.mkdir(parents=True, exist_ok=True) + with open(schema_path, "w", encoding="utf-8", newline="\n") as handle: + handle.write(schema) + print( + f"wrote {schema_path.as_posix()}: " + f"{len(json.loads(schema)['fields'])} settings." + ) print( f"wrote {out_path.as_posix()}: {len(generated.splitlines())} lines, " f"{generated.count(chr(10) + '## ')} sections, " diff --git a/configs/config-schema.json b/configs/config-schema.json new file mode 100644 index 00000000..23f5d60b --- /dev/null +++ b/configs/config-schema.json @@ -0,0 +1,2184 @@ +{ + "generated_by": "ci/gen_config_reference.py", + "source": "rust/mumdia/crates/mumdia-core/src/config.rs", + "sections": [ + "", + "prescan", + "digest", + "digest.decoy", + "peptidoforms", + "predict_frag", + "search_seed", + "rt_im_train", + "extract", + "extract.claim_cues", + "features", + "compete", + "rescore", + "quant", + "mbr", + "experiment" + ], + "fields": [ + { + "path": "rng_seed", + "name": "rng_seed", + "section": "", + "rust_type": "u64", + "kind": "integer", + "optional": false, + "default": 0, + "default_text": "0", + "help": "", + "gates": [], + "source_line": 1459 + }, + { + "path": "prescan.tol_da", + "name": "tol_da", + "section": "prescan", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.005, + "default_text": "0.005", + "help": "Peak-delta match tolerance in Da. Permissive on purpose: a false tag only fails to prune, while a missed tag discards a real candidate with no way to recover it downstream.", + "gates": [], + "source_line": 284 + }, + { + "path": "prescan.rt_slack_s", + "name": "rt_slack_s", + "section": "prescan", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 150.0, + "default_text": "150.0", + "help": "Widen each candidate's RT window by this many seconds before binning. The window comes from a calibration fitted on a different run, and `cal.json` residuals are in-sample and roughly 3x optimistic, so size this from out-of-sample RT error, not from the reported fit.", + "gates": [], + "source_line": 288 + }, + { + "path": "prescan.rt_bin_s", + "name": "rt_bin_s", + "section": "prescan", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 25.0, + "default_text": "25.0", + "help": "RT bin width for the observed-tag index.", + "gates": [], + "source_line": 290 + }, + { + "path": "prescan.top_peaks", + "name": "top_peaks", + "section": "prescan", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 150, + "default_text": "150", + "help": "Most intense peaks per MS2 used to build tags (0 = all). This bounds the O(peaks^2) delta search and is NOT destructive: it only affects tag construction, never the spectra artifact that extraction later reads.", + "gates": [], + "source_line": 294 + }, + { + "path": "prescan.mods", + "name": "mods", + "section": "prescan", + "rust_type": "Vec", + "kind": "other", + "optional": false, + "default": "[\"C:Carbamidomethyl\", \"M:Oxidation\"]", + "default_text": "[\"C:Carbamidomethyl\", \"M:Oxidation\"]", + "help": "Residue:UniModName entries that may appear in a screened peptidoform, e.g. `C:Carbamidomethyl`. A peptidoform carrying anything outside this set plus `anchor_mods` is dropped rather than screened on a partially understood sequence.", + "gates": [], + "source_line": 298 + }, + { + "path": "prescan.anchor_mods", + "name": "anchor_mods", + "section": "prescan", + "rust_type": "Vec", + "kind": "other", + "optional": false, + "default": "[]", + "default_text": "[]", + "help": "Residue:UniModName entries the screen anchors ON. Only trimers covering one of these positions count as evidence, so backbone signal cannot keep a modified hypothesis alive.", + "gates": [], + "source_line": 301 + }, + { + "path": "digest.enzyme", + "name": "enzyme", + "section": "digest", + "rust_type": "Enzyme", + "kind": "enum", + "optional": false, + "default": "trypsin_p", + "default_text": "`trypsin_p`", + "help": "", + "gates": [], + "source_line": 319, + "choices": [ + "trypsin_p", + "trypsin" + ] + }, + { + "path": "digest.missed_cleavages", + "name": "missed_cleavages", + "section": "digest", + "rust_type": "u32", + "kind": "integer", + "optional": false, + "default": 2, + "default_text": "2", + "help": "", + "gates": [], + "source_line": 320 + }, + { + "path": "digest.min_len", + "name": "min_len", + "section": "digest", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 5, + "default_text": "5", + "help": "", + "gates": [], + "source_line": 321 + }, + { + "path": "digest.max_len", + "name": "max_len", + "section": "digest", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 50, + "default_text": "50", + "help": "", + "gates": [], + "source_line": 322 + }, + { + "path": "digest.n_term_met_excision", + "name": "n_term_met_excision", + "section": "digest", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": true, + "default_text": "true", + "help": "N-terminal methionine excision: when a protein begins with `M`, also emit the initiator-Met-removed form of its N-terminal peptides. The initiator methionine is cleaved in vivo for most proteins, so search engines (including DIA-NN via `--met-excision`) enumerate both forms. Omitting it makes the search database structurally miss those excised peptides.", + "gates": [], + "source_line": 329 + }, + { + "path": "digest.decoy.strategy", + "name": "strategy", + "section": "digest.decoy", + "rust_type": "DecoyStrategy", + "kind": "enum", + "optional": false, + "default": "reverse", + "default_text": "`reverse`", + "help": "", + "gates": [], + "source_line": 265, + "choices": [ + "reverse", + "scramble", + "diann_shift", + "none" + ] + }, + { + "path": "peptidoforms.fixed_mods", + "name": "fixed_mods", + "section": "peptidoforms", + "rust_type": "Vec", + "kind": "other", + "optional": false, + "default": "[{\"residue\": \"C\", \"name\": \"Carbamidomethyl\"}]", + "default_text": "[{\"residue\": \"C\", \"name\": \"Carbamidomethyl\"}]", + "help": "UniMod names applied to every matching residue (residue -> mod name).", + "gates": [], + "source_line": 348 + }, + { + "path": "peptidoforms.variable_mods", + "name": "variable_mods", + "section": "peptidoforms", + "rust_type": "Vec", + "kind": "other", + "optional": false, + "default": "[{\"residue\": \"M\", \"name\": \"Oxidation\"}]", + "default_text": "[{\"residue\": \"M\", \"name\": \"Oxidation\"}]", + "help": "", + "gates": [], + "source_line": 349 + }, + { + "path": "peptidoforms.max_variable_mods", + "name": "max_variable_mods", + "section": "peptidoforms", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "", + "gates": [], + "source_line": 350 + }, + { + "path": "peptidoforms.charge_min", + "name": "charge_min", + "section": "peptidoforms", + "rust_type": "i32", + "kind": "integer", + "optional": false, + "default": 2, + "default_text": "2", + "help": "", + "gates": [], + "source_line": 351 + }, + { + "path": "peptidoforms.charge_max", + "name": "charge_max", + "section": "peptidoforms", + "rust_type": "i32", + "kind": "integer", + "optional": false, + "default": 3, + "default_text": "3", + "help": "", + "gates": [], + "source_line": 352 + }, + { + "path": "peptidoforms.charge_by_basic_residues", + "name": "charge_by_basic_residues", + "section": "peptidoforms", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Composition-based precursor charge range. When true, ignore `charge_min`/`charge_max` and emit every charge from 1 up to `1 (N-terminus) + (#R + #H + #K)`, the proton-carrying capacity of the peptide. Peptides therefore never receive a charge state they cannot physically hold, and each peptide's range depends on its own basic-residue count. Default false (fixed `charge_min..=charge_max` for every peptide). Pairs with `predict_frag.charge_by_basic_residues` for fragments. Changing the enumerated charge states changes the search/training/FDR population, so this remains benchmark-gated.", + "gates": [ + "benchmark-gated" + ], + "source_line": 362 + }, + { + "path": "peptidoforms.unknown_modification", + "name": "unknown_modification", + "section": "peptidoforms", + "rust_type": "UnknownModPolicy", + "kind": "enum", + "optional": false, + "default": "error", + "default_text": "`error`", + "help": "`error` (default) or `skip` for unknown modifications.", + "gates": [], + "source_line": 364, + "choices": [ + "error", + "skip" + ] + }, + { + "path": "predict_frag.predictor", + "name": "predictor", + "section": "predict_frag", + "rust_type": "FragPredictorKind", + "kind": "enum", + "optional": false, + "default": "native", + "default_text": "`native`", + "help": "", + "gates": [], + "source_line": 406, + "choices": [ + "native", + "ms2pip" + ] + }, + { + "path": "predict_frag.rt_predictor", + "name": "rt_predictor", + "section": "predict_frag", + "rust_type": "RtPredictorKind", + "kind": "enum", + "optional": false, + "default": "native", + "default_text": "`native`", + "help": "", + "gates": [], + "source_line": 407, + "choices": [ + "native", + "deeplc" + ] + }, + { + "path": "predict_frag.charge2_from_precursor_charge", + "name": "charge2_from_precursor_charge", + "section": "predict_frag", + "rust_type": "i32", + "kind": "integer", + "optional": false, + "default": 2, + "default_text": "2", + "help": "Fragment charges rule: charge 1 always; charge 2 added for precursor charge >= this threshold (docs/18_findings_and_decisions.md). Default 2: DIA-NN uses doubly-charged fragments for ~16% of charge-2 precursors' transitions, so blocking them (the old default of 3) discarded real signal.", + "gates": [], + "source_line": 413 + }, + { + "path": "predict_frag.charge_by_basic_residues", + "name": "charge_by_basic_residues", + "section": "predict_frag", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Composition-based fragment charge cap. When true, a b/y fragment is kept at charge z only if `z <= 1 (its N-terminal amine) + (#R + #H + #K within that fragment)`, and never above the precursor charge. This supersedes the `charge2_from_precursor_charge` rule when set. Default false. Pairs with `peptidoforms.charge_by_basic_residues` for precursors; benchmark-gated because it changes the scored transition set.", + "gates": [ + "benchmark-gated" + ], + "source_line": 420 + }, + { + "path": "predict_frag.top_n_fragments", + "name": "top_n_fragments", + "section": "predict_frag", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 6, + "default_text": "6", + "help": "", + "gates": [], + "source_line": 421 + }, + { + "path": "predict_frag.ms2pip_model", + "name": "ms2pip_model", + "section": "predict_frag", + "rust_type": "String", + "kind": "string", + "optional": false, + "default": "\"HCD\"", + "default_text": "\"HCD\"", + "help": "", + "gates": [], + "source_line": 422 + }, + { + "path": "predict_frag.ms2pip_python", + "name": "ms2pip_python", + "section": "predict_frag", + "rust_type": "Option", + "kind": "other", + "optional": true, + "default": "null", + "default_text": "null", + "help": "Python executable for the MS2PIP sidecar (env with ms2pip + pyarrow).", + "gates": [], + "source_line": 424 + }, + { + "path": "predict_frag.deeplc_python", + "name": "deeplc_python", + "section": "predict_frag", + "rust_type": "Option", + "kind": "other", + "optional": true, + "default": "null", + "default_text": "null", + "help": "Python executable for the DeepLC sidecar (env with deeplc + pyarrow).", + "gates": [], + "source_line": 426 + }, + { + "path": "predict_frag.sidecar_script_dir", + "name": "sidecar_script_dir", + "section": "predict_frag", + "rust_type": "String", + "kind": "string", + "optional": false, + "default": "\"scripts\"", + "default_text": "\"scripts\"", + "help": "Directory holding the sidecar worker scripts.", + "gates": [], + "source_line": 428 + }, + { + "path": "search_seed.fdr_seed", + "name": "fdr_seed", + "section": "search_seed", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.01, + "default_text": "0.01", + "help": "", + "gates": [], + "source_line": 449 + }, + { + "path": "search_seed.fragment_tol_ppm", + "name": "fragment_tol_ppm", + "section": "search_seed", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 20.0, + "default_text": "20.0", + "help": "", + "gates": [], + "source_line": 450 + }, + { + "path": "search_seed.report_psms", + "name": "report_psms", + "section": "search_seed", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 5, + "default_text": "5", + "help": "Max reported PSMs per spectrum (wide-window DIA, docs/07_search_seed.md).", + "gates": [], + "source_line": 452 + }, + { + "path": "search_seed.min_matched_peaks", + "name": "min_matched_peaks", + "section": "search_seed", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 4, + "default_text": "4", + "help": "Minimum matched fragments for a seed PSM.", + "gates": [], + "source_line": 454 + }, + { + "path": "search_seed.top_n_peaks", + "name": "top_n_peaks", + "section": "search_seed", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 300, + "default_text": "300", + "help": "If > 0, probe only the `top_n_peaks` most intense peaks per MS2 scan (0 = all peaks). The seed only produces calibration anchors (RT/mass/IM), which come from abundant peptides, so this cuts the dominant per-peak index probing cost without discarding peaks from the downstream extraction artifact. Default 300; set to 0 to probe every converted peak.", + "gates": [], + "source_line": 460 + }, + { + "path": "search_seed.matcher", + "name": "matcher", + "section": "search_seed", + "rust_type": "MatcherKind", + "kind": "enum", + "optional": false, + "default": "fragindex", + "default_text": "`fragindex`", + "help": "Fragment-matcher backend (docs/06_predict_frag_index_matchers.md). Default `Fragindex`.", + "gates": [], + "source_line": 463, + "choices": [ + "bucketed", + "fragindex" + ] + }, + { + "path": "search_seed.two_pass_mass_cal", + "name": "two_pass_mass_cal", + "section": "search_seed", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Robust two-pass fragment mass calibration (sensitivity_plan P3.1). After the first median-offset + tolerance fit, re-fit on only the deviations inside the first-pass tolerance window (rejecting outliers), giving a tighter, more robust offset + local uncertainty. Falls back to the single-pass result when too few in-window calibrants remain. Default false (single pass unchanged).", + "gates": [], + "source_line": 469 + }, + { + "path": "search_seed.mass_cal_loess", + "name": "mass_cal_loess", + "section": "search_seed", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "m/z-dependent fragment mass calibration. When true, fit a LOESS of the calibrant ppm deviation versus fragment m/z and emit a sampled correction grid to `.masscal.json`; extract then applies an m/z-interpolated offset per peak instead of the single scalar `frag_ppm_offset`. This removes any m/z-correlated curvature the flat offset leaves. Default false (scalar offset unchanged), opt-in and benchmark-gated.", + "gates": [ + "benchmark-gated" + ], + "source_line": 476 + }, + { + "path": "rt_im_train.calibration_method", + "name": "calibration_method", + "section": "rt_im_train", + "rust_type": "CalibrationMethod", + "kind": "enum", + "optional": false, + "default": "loess", + "default_text": "`loess`", + "help": "", + "gates": [], + "source_line": 496, + "choices": [ + "loess", + "linear", + "none" + ] + }, + { + "path": "rt_im_train.q_train", + "name": "q_train", + "section": "rt_im_train", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.01, + "default_text": "0.01", + "help": "", + "gates": [], + "source_line": 497 + }, + { + "path": "rt_im_train.p_rt", + "name": "p_rt", + "section": "rt_im_train", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.95, + "default_text": "0.95", + "help": "Percentile of |obs - calibrated_pred| residuals for the RT window.", + "gates": [], + "source_line": 499 + }, + { + "path": "rt_im_train.rt_window_multiplier", + "name": "rt_window_multiplier", + "section": "rt_im_train", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 1.0, + "default_text": "1.0", + "help": "", + "gates": [], + "source_line": 500 + }, + { + "path": "rt_im_train.min_seed_for_calibration", + "name": "min_seed_for_calibration", + "section": "rt_im_train", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 50, + "default_text": "50", + "help": "", + "gates": [], + "source_line": 501 + }, + { + "path": "rt_im_train.loess_span", + "name": "loess_span", + "section": "rt_im_train", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.3, + "default_text": "0.3", + "help": "LOESS span (fraction of points in each local fit).", + "gates": [], + "source_line": 503 + }, + { + "path": "rt_im_train.fallback_rt_window_s", + "name": "fallback_rt_window_s", + "section": "rt_im_train", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 120.0, + "default_text": "120.0", + "help": "Fallback fixed RT window in seconds when calibration cannot be fit.", + "gates": [], + "source_line": 505 + }, + { + "path": "rt_im_train.finetune_deeplc", + "name": "finetune_deeplc", + "section": "rt_im_train", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Fine-tune the DeepLC multitask model on this run's confident seed PSMs and rewrite the library's `predicted_irt` before RT calibration. Requires `predict_frag.deeplc_python` (the DeepLC interpreter). Off by default; the main use is library-input mode, where the base iRT comes from the imported library rather than a DeepLC prediction.", + "gates": [], + "source_line": 511 + }, + { + "path": "rt_im_train.finetune_epochs", + "name": "finetune_epochs", + "section": "rt_im_train", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 25, + "default_text": "25", + "help": "DeepLC fine-tune training epochs (passed to `deeplc_finetune.py --epochs`). Early stopping with `finetune_patience` usually halts before this cap, so it is an upper bound rather than a fixed count. Only used when `finetune_deeplc`.", + "gates": [], + "source_line": 515 + }, + { + "path": "rt_im_train.finetune_patience", + "name": "finetune_patience", + "section": "rt_im_train", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 10, + "default_text": "10", + "help": "DeepLC fine-tune early-stopping patience (`--patience`): epochs without validation-loss improvement before stopping. Only used when `finetune_deeplc`.", + "gates": [], + "source_line": 518 + }, + { + "path": "rt_im_train.finetune_batch", + "name": "finetune_batch", + "section": "rt_im_train", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 0, + "default_text": "0", + "help": "DeepLC fine-tune batch size (`--batch`). 0 (default) auto-scales to the confident seed size so each epoch has >= ~30 gradient steps; a fixed large batch underfits small seeds (a ~4k-peptide reference at batch 512 is ~8 steps/epoch and never converges). Only used when `finetune_deeplc`.", + "gates": [], + "source_line": 523 + }, + { + "path": "rt_im_train.adaptive_rt_window", + "name": "adaptive_rt_window", + "section": "rt_im_train", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Adaptive RT window (sensitivity_plan spec 03 §3.5, backlog P3.2/P3.3): instead of one global residual-percentile half-width for every candidate, bin the calibration anchors by calibrated RT and give each candidate the LOCAL residual percentile of its RT region, clamped to `[rt_window_min_s, fallback_rt_window_s]` and scaled by `rt_window_multiplier`. A fixed window is simultaneously too wide for well-calibrated regions and too narrow for poorly-calibrated ones; this tightens clean regions (less interference) and widens noisy ones (more recall). Empty/sparse bins fall back to the global width. Default false.", + "gates": [], + "source_line": 533 + }, + { + "path": "rt_im_train.adaptive_rt_bins", + "name": "adaptive_rt_bins", + "section": "rt_im_train", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 12, + "default_text": "12", + "help": "Number of equal-width calibrated-RT bins for the adaptive window.", + "gates": [], + "source_line": 535 + }, + { + "path": "rt_im_train.rt_window_min_s", + "name": "rt_window_min_s", + "section": "rt_im_train", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 1.0, + "default_text": "1.0", + "help": "Lower clamp (seconds) for any RT half-window (the existing 1 s floor).", + "gates": [], + "source_line": 537 + }, + { + "path": "rt_im_train.window_holdout_frac", + "name": "window_holdout_frac", + "section": "rt_im_train", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "Size `w_rt` from HELD-OUT residuals instead of in-sample ones. A fraction of anchor peptides (`base_peptide_id % 1000 < round(frac*1000)`, so the split is deterministic and shared with `deeplc_finetune.py`) is excluded from the sizing fit and, when `finetune_deeplc` runs, from the fine-tune reference; `w_rt` is then the residual percentile of those held-out anchors against the fit they never entered. The final calibration curve still uses every anchor. In-sample sizing underestimates the tail and rewards a memorizing RT model with a window it does not deserve (measured: it inverted the 4.0.0a2/4.1.0 ranking); held-out sizing measured +0.9% peptides with DeepLC 4.1.0 and -1.5% with 4.0.0a2 on the AIF benchmark, both at 0.98% decoy, so enable it only with a generalizing RT model. 0.0 (default) keeps in-sample sizing. Mutually exclusive with `adaptive_rt_window`. Benchmark-gated; do not default on.", + "gates": [ + "benchmark-gated", + "do not default" + ], + "source_line": 550 + }, + { + "path": "extract.fixed_scan_window", + "name": "fixed_scan_window", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 3, + "default_text": "3", + "help": "", + "gates": [], + "source_line": 577 + }, + { + "path": "extract.frag_tol_ppm", + "name": "frag_tol_ppm", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 20.0, + "default_text": "20.0", + "help": "", + "gates": [], + "source_line": 578 + }, + { + "path": "extract.prec_tol_ppm", + "name": "prec_tol_ppm", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 20.0, + "default_text": "20.0", + "help": "", + "gates": [], + "source_line": 579 + }, + { + "path": "extract.presence_min_matched", + "name": "presence_min_matched", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 3, + "default_text": "3", + "help": "tier-(b) minimum matched fragment count.", + "gates": [], + "source_line": 581 + }, + { + "path": "extract.presence_min_fragments", + "name": "presence_min_fragments", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 3, + "default_text": "3", + "help": "minimum distinct fragments for acceptance.", + "gates": [], + "source_line": 583 + }, + { + "path": "extract.presence_min_coelution", + "name": "presence_min_coelution", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 2, + "default_text": "2", + "help": "minimum simultaneously-present fragments over the consecutive-scan run.", + "gates": [], + "source_line": 585 + }, + { + "path": "extract.gate_min_score", + "name": "gate_min_score", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.2, + "default_text": "0.2", + "help": "tier-(d) spectral-agreement gate: reject a candidate whose observed fragment intensities agree with the predicted pattern below this score. Renamed from `min_frag_corr`, which was accurate for none of the four `gate_mode` values: under the default `apex_pearson` it is an intensity correlation at ONE apex scan rather than a chromatographic co-elution correlation, and under `spectral_entropy` it is not a correlation at all. The old name is not accepted (`deny_unknown_fields`), so an old config fails loudly with the offending key named rather than silently reverting to a default. Applied symmetrically to targets and decoys, but that alone does not prove null exchangeability in chimeric DIA; validate every threshold with an independent entrapment. 0 disables.", + "gates": [], + "source_line": 598 + }, + { + "path": "extract.min_matched_fraction", + "name": "min_matched_fraction", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "tier-(c) minimum fraction of the candidate's predicted fragments that must be observed. With enough predicted fragments (top_n>=~10) this is a strong, symmetric discriminator: real peptides match a large fraction, chimeric false matches and decoys match a small fraction alike, so the target-decoy null stays valid.", + "gates": [], + "source_line": 604 + }, + { + "path": "extract.apex_top_fragments", + "name": "apex_top_fragments", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 0, + "default_text": "0", + "help": "Shape-aware apex selection: choose the apex scan group by the summed observed intensity of only the top-K predicted (signature) fragments, rather than all matched fragments. In chimeric DIA a bright co-eluting interferent contributing to arbitrary channels wins a max-over-all-fragments apex; restricting to the peptide's strongest predicted ions locks onto its true elution instead. 0 selects the implementation default of the top 3 predicted fragments.", + "gates": [], + "source_line": 612 + }, + { + "path": "extract.apex_rt_prior_s", + "name": "apex_rt_prior_s", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "Optional Gaussian RT prior on apex selection: weight each scan group by exp(-0.5*((rt - rt_cal)/sigma)^2) with sigma = this value in seconds, so a distant interferent inside a wide RT window cannot define the apex. 0 = off.", + "gates": [], + "source_line": 616 + }, + { + "path": "extract.apex_count_tol", + "name": "apex_count_tol", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "Fragment-count apex: pick the scan with the most distinct matched fragments, allowing scans within `apex_count_tol` of that maximum (so a slightly-lower- count but much more intense scan can still win), then the max summed-top-3 intensity among them. Supersedes the summed-intensity apex when set.", + "gates": [], + "source_line": 621 + }, + { + "path": "extract.apex_count_window", + "name": "apex_count_window", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "Rolling-window width (in scan groups, centered, odd) for the distinct- fragment count that drives apex selection. Low-intensity fragments flicker in and out scan-to-scan; a single-scan count then spikes at noise scans and misplaces the apex. This sums the per-scan distinct-fragment count over a centered window so the apex lands in the region of *sustained* fragment presence, not an isolated flicker. A sum (not a mean) is used deliberately: edge truncation makes interior positions accumulate more, center-weighting the apex toward the RT-window centre (~= predicted RT) as a mild RT-prior; measured to beat a mean by ~+300 IDs on AIF. 1 = no smoothing (per-scan).", + "gates": [], + "source_line": 631 + }, + { + "path": "extract.apex_gaussian_sigma_scans", + "name": "apex_gaussian_sigma_scans", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "Gaussian matched-filter smoothing of the per-scan fragment-count series before apex selection, as a sigma in scan units. 0.0 (default) keeps the `apex_count_window` rolling-sum smoother unchanged. When > 0, the count series is convolved with a Gaussian kernel (radius = 3*sigma) instead, which localizes the apex more robustly than a uniform window against scan-to-scan flicker. Opt-in and benchmark-gated: it changes apex selection and therefore identifications.", + "gates": [ + "benchmark-gated" + ], + "source_line": 639 + }, + { + "path": "extract.emit_window_grid", + "name": "emit_window_grid", + "section": "extract", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": true, + "default_text": "true", + "help": "Emit per-fragment chromatograms on the FULL isolation-window scan grid with 0.0 where a fragment is absent (aggregating scans of the same isolation window), so the elution profile drops to zero between peaks and the features-stage boundary calling is not misled by interpolated gaps.", + "gates": [], + "source_line": 644 + }, + { + "path": "extract.bucket_size", + "name": "bucket_size", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 8192, + "default_text": "8192", + "help": "m/z bucket size (power of two).", + "gates": [], + "source_line": 646 + }, + { + "path": "extract.peak_claim", + "name": "peak_claim", + "section": "extract", + "rust_type": "PeakClaim", + "kind": "enum", + "optional": false, + "default": "none", + "default_text": "`none`", + "help": "How a shared observed peak's intensity is apportioned among co-isolated, co-eluting candidates that all match it (see `PeakClaim`).", + "gates": [], + "source_line": 649, + "choices": [ + "none", + "winner_predicted_intensity", + "proportional", + "coelution_winner", + "coelution_proportional", + "coelution_winner_margin", + "coelution_multi_cue", + "coelution_demix", + "coelution_shadow" + ] + }, + { + "path": "extract.emit_demix_features", + "name": "emit_demix_features", + "section": "extract", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Spectrum-centric NNLS demixing (D2, fragment-competition report). When true, at each accepted candidate's apex scan, assemble the co-isolated candidate x fragment design matrix, solve non-negative least squares (deterministic ridge-regularized), and emit non-destructive demix features (deconv_explained_frac, deconv_active, deconv_share) so the rescorer sees each candidate's interference-corrected abundance. Default false; changes no extracted intensity.", + "gates": [], + "source_line": 659 + }, + { + "path": "extract.demix_lambda", + "name": "demix_lambda", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 1.0, + "default_text": "1.0", + "help": "Ridge for the demix NNLS passive solve (keeps it PD/deterministic under the ~98% wide-window column collinearity). Default 1.0.", + "gates": [], + "source_line": 662 + }, + { + "path": "extract.demix_max_candidates", + "name": "demix_max_candidates", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 64, + "default_text": "64", + "help": "Cap on the number of co-isolated candidates (design-matrix columns) in a single demix solve, to bound compute on crowded windows. Default 64.", + "gates": [], + "source_line": 665 + }, + { + "path": "extract.demix_scan_stride", + "name": "demix_scan_stride", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "Scan stride for the DESTRUCTIVE `CoelutionDemix` redistribution: solve the per-scan NNLS every Nth scan and reuse the resulting candidate abundances to apportion the intervening scans (a re-solve is forced whenever a new candidate enters the co-isolated set, so accuracy is preserved where the population changes). This is the practicality lever - a full per-scan solve over the ~465k scans of a wide-window run is impractical. 1 (default) solves at every scan. Only affects `CoelutionDemix`; the non-destructive demix FEATURES are unaffected.", + "gates": [], + "source_line": 673 + }, + { + "path": "extract.emit_contested_features", + "name": "emit_contested_features", + "section": "extract", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Emit a non-destructive `contested_frac` per PSM: the fraction of a candidate's matched intensity that a co-eluting competitor claims more strongly (by the two-pass elution-profile arbitration). Does not alter the extracted intensities; feeds a rescorer feature. Forces the two-pass path.", + "gates": [], + "source_line": 678 + }, + { + "path": "extract.peak_claim_margin", + "name": "peak_claim_margin", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 2.0, + "default_text": "2.0", + "help": "Dominance factor for `CoelutionWinnerMargin`: a shared peak is claimed winner-take-all only if the top eluter's profile height is at least this multiple of the runner-up's; otherwise the peak stays shared.", + "gates": [], + "source_line": 682 + }, + { + "path": "extract.matcher", + "name": "matcher", + "section": "extract", + "rust_type": "MatcherKind", + "kind": "enum", + "optional": false, + "default": "fragindex", + "default_text": "`fragindex`", + "help": "Fragment-matcher backend (docs/06_predict_frag_index_matchers.md). Default `Fragindex`.", + "gates": [], + "source_line": 685, + "choices": [ + "bucketed", + "fragindex" + ] + }, + { + "path": "extract.min_coelution_run", + "name": "min_coelution_run", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 0, + "default_text": "0", + "help": "Minimum-PSMs-per-peptide evidence filter: reject a candidate whose fragments co-elute over fewer than this many consecutive scan groups (`coelution_run`). A single/double-scan spike is a transient (likely-interferent) match; a real peptide persists across its elution. 0 disables (the `scan_window` floor still applies). This is the DIA analog of a \"seen in >= N PSMs\" requirement.", + "gates": [], + "source_line": 691 + }, + { + "path": "extract.ms1_rescue", + "name": "ms1_rescue", + "section": "extract", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Rescue a candidate that fails the single-scan fragment-Pearson gate when it has adequate matched fragments AND MS1 isotope-pattern support (mono + a plausible +1/mono ratio). Off by default: it relaxes acceptance, so enable it only with target-decoy/entrapment FDR validation. MS1 evidence is now computed before the gate so this can take effect.", + "gates": [], + "source_line": 697 + }, + { + "path": "extract.retain_top_peaks", + "name": "retain_top_peaks", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "Number of chromatographic peak hypotheses to enumerate per candidate. `K>1` writes up to K local maxima to the diagnostic `.peaks.parquet` sidecar. The primary PSM still contains only the selected apex, so these extra hypotheses are not currently rescored or used to improve identifications. K=1 preserves the single-apex behaviour.", + "gates": [ + "diagnostic", + "not currently" + ], + "source_line": 703 + }, + { + "path": "extract.promote_top_peaks", + "name": "promote_top_peaks", + "section": "extract", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "Number of chromatographic peaks PROMOTED to real feature/rescore rows per candidate (AlphaDIA plan #7, top-K). `1` (default) emits only the selected apex, so the pipeline is byte-identical. `>1` additionally emits the next strongest non-overlapping `enumerate_peaks` groups (each a full re-sliced PSM record carrying `peak_rank`), so the rescorer can pick the correct-but-not-apex peak; the selected apex stays `peak_rank = 0`. Must be `<= retain_top_peaks`. Behaviour-changing and benchmark/entrapment-gated: it changes the extracted row population, and compete/rescore must collapse per candidate so the decoy null is not K-inflated.", + "gates": [ + "gated" + ], + "source_line": 713 + }, + { + "path": "extract.alt_peak_min_area_frac", + "name": "alt_peak_min_area_frac", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.1, + "default_text": "0.10", + "help": "Minimum integrated area of a promoted alternate peak (rank >= 1) as a fraction of the rank-0 peak's area. Suppresses noise-level alternates. Only used when `promote_top_peaks > 1`.", + "gates": [], + "source_line": 717 + }, + { + "path": "extract.alt_peak_min_separation_s", + "name": "alt_peak_min_separation_s", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 5.0, + "default_text": "5.0", + "help": "Minimum apex-RT separation (seconds) between a promoted alternate peak and the rank-0 apex, so a near-duplicate of the selected peak is not re-emitted. Only used when `promote_top_peaks > 1`.", + "gates": [], + "source_line": 721 + }, + { + "path": "extract.emit_candidate_audit", + "name": "emit_candidate_audit", + "section": "extract", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Diagnostic candidate-audit: when true, extraction records, for every probed candidate, either the survivor stage-flags or the earliest `RejectionReason`, and writes `.audit.parquet` (spec 01 §4 / P0.3). Near-zero cost when false (no per-candidate audit allocation). Default false (production).", + "gates": [ + "diagnostic" + ], + "source_line": 726 + }, + { + "path": "extract.apex_evidence_rank", + "name": "apex_evidence_rank", + "section": "extract", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": true, + "default_text": "true", + "help": "Evidence-count apex selection: choose the apex scan by the NUMBER of distinct co-eluting predicted fragments present (breadth of evidence), using observed signature-ion intensity only as a sub-integer tiebreak. In wide-window DIA a single fragment m/z channel is chimeric, so the tallest scan is often a co-isolated interferent; the scan where the most of the peptide's own predicted transitions co-elute is a more reliable apex. Default `true`, on correctness grounds rather than a count: `false` keeps the legacy signature-intensity apex, whose score is 0.0 at every qualifying scan when none of the top-K predicted fragments is observed, so the strict `>` never replaces the first candidate and the apex silently becomes the LOWEST-RT qualifying scan. The rolling distinct-fragment count (`apex_count_window`) still gates which scans qualify in both modes.", + "gates": [], + "source_line": 739 + }, + { + "path": "extract.emit_gate_diagnostics", + "name": "emit_gate_diagnostics", + "section": "extract", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Emit the four gate-diagnostic scores (`gate_apex`, `gate_peak_spectral`, `gate_coelution`, `gate_spectral_entropy`) as extra `psms.parquet` columns, for the offline gate-metric comparison. Default `false` (diagnostic sidecar, like `emit_candidate_audit`): when off, neither the columns nor the extra per-candidate score computation happen, so the default chain is byte-identical.", + "gates": [ + "diagnostic" + ], + "source_line": 745 + }, + { + "path": "extract.gate_mode", + "name": "gate_mode", + "section": "extract", + "rust_type": "GateMode", + "kind": "enum", + "optional": false, + "default": "apex_pearson", + "default_text": "`apex_pearson`", + "help": "Which spectral-agreement score the `gate_min_score` gate thresholds (sensitivity program). The legacy gate uses a single apex-scan intensity Pearson, which one chimeric scan can dominate. See `GateMode`.", + "gates": [], + "source_line": 749, + "choices": [ + "apex_pearson", + "peak_spectral", + "spectral_entropy", + "coelution", + "combined" + ] + }, + { + "path": "extract.gate_coelution_min", + "name": "gate_coelution_min", + "section": "extract", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.5, + "default_text": "0.5", + "help": "Second threshold for `GateMode::Combined`: the co-elution score must exceed this while the peak-integrated spectral score exceeds `gate_min_score`. Requiring BOTH is more specific (rejects interferents that pass one axis).", + "gates": [], + "source_line": 753 + }, + { + "path": "extract.claim_cues.mz_close", + "name": "mz_close", + "section": "extract.claim_cues", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Sub-tolerance m/z proximity (S3): weight a claimant by `exp(-(ppm_err/sigma)^2)`, where `ppm_err` is the signed ppm offset of the observed peak from this claimant's predicted fragment m/z. Two collided fragments share a peak only because both fall within `frag_tol`, but the observed peak sits at the true owner's m/z; the sub-tolerance offset is a novel apportionment weight (engines use ppm only as a binary gate).", + "gates": [], + "source_line": 200 + }, + { + "path": "extract.claim_cues.mz_close_sigma_ppm", + "name": "mz_close_sigma_ppm", + "section": "extract.claim_cues", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 5.0, + "default_text": "5.0", + "help": "Gaussian sigma (ppm) for the `mz_close` cue. Default 5 ppm.", + "gates": [], + "source_line": 202 + }, + { + "path": "extract.claim_cues.rt_prior", + "name": "rt_prior", + "section": "extract.claim_cues", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "DeepLC retention-time prior (S3): weight a claimant by `exp(-(rt - rt_pred)^2 / 2 tau^2)`, where `rt_pred` is the candidate's calibrated predicted RT. A co-isolated interferent whose predicted RT is far from the current scan gets a low weight even if it briefly co-elutes, so a shared peak is apportioned toward the candidate the RT model actually places there. No-op where the predicted RT is unset (0).", + "gates": [], + "source_line": 209 + }, + { + "path": "extract.claim_cues.rt_prior_tau_s", + "name": "rt_prior_tau_s", + "section": "extract.claim_cues", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 30.0, + "default_text": "30.0", + "help": "Gaussian sigma (seconds) for the `rt_prior` cue. Default 30 s.", + "gates": [], + "source_line": 211 + }, + { + "path": "extract.claim_cues.ms1_support", + "name": "ms1_support", + "section": "extract.claim_cues", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "MS1 precursor-envelope support (S4, cross-dimension): weight a claimant by whether its own precursor isotope envelope (mono + a plausible +1/mono ratio) is actually present in the nearest MS1 scan. A shift/reverse decoy has a well-defined precursor m/z but no real co-eluting MS1 precursor, so its support is noise, starving its MS2 claim via an orthogonal dimension that is nearly impossible to fake. No-op when no MS1 is provided. Down-weights (never zeroes) so a genuinely MS1-poor real peptide is not eliminated.", + "gates": [], + "source_line": 219 + }, + { + "path": "extract.claim_cues.reassign", + "name": "reassign", + "section": "extract.claim_cues", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "DESTRUCTIVE redistribution for `CoelutionMultiCue`. When true, the cue-weighted arbitration rewrites the extracted peak intensities (winner-take-all on the composite weight), instead of only emitting the apportioned/contested features. The competed evidence then feeds EVERY downstream feature (co-elution, spectral, mass-accuracy, ...), so this is the impactful form. Off by default; changes the search/FDR evidence, so it is entrapment-gated per CLAUDE.md.", + "gates": [ + "gated" + ], + "source_line": 226 + }, + { + "path": "extract.claim_cues.apportion_em_iters", + "name": "apportion_em_iters", + "section": "extract.claim_cues", + "rust_type": "u32", + "kind": "integer", + "optional": false, + "default": 0, + "default_text": "0", + "help": "Uniqueness-seeded EM apportionment (S2): number of fixed-point iterations that re-seed each candidate's per-scan elution profile from its APPORTIONED (not full) intensity before the final arbitration. The plain profile is built from full intensities, so a borrowing candidate's profile is inflated by the very peaks it borrows; re-seeding from the cue-weighted share removes that feedback, while uncontested (single-claimant) peaks contribute full intensity every iteration as an immovable anchor. 0 (default) disables EM (single-pass profile). Deterministic (fixed N); applies under `CoelutionMultiCue`.", + "gates": [], + "source_line": 235 + }, + { + "path": "features.set", + "name": "set", + "section": "features", + "rust_type": "FeatureSet", + "kind": "enum", + "optional": false, + "default": "minimal", + "default_text": "`minimal`", + "help": "", + "gates": [], + "source_line": 865, + "choices": [ + "minimal", + "rich", + "extended" + ] + }, + { + "path": "features.emit_pin", + "name": "emit_pin", + "section": "features", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Write the Percolator-style `.pin` text file requested by `--out-pin`. No MuMDIA stage consumes it (`rescore` builds its own PIN for the sidecars); it exists for external tooling. At 1.5M rows x 387 features it is a ~5.4 GB text write. Default false: nothing in MuMDIA reads it, which makes the write pure cost unless an external tool wants the file. Set true to get the artifact back.", + "gates": [], + "source_line": 871 + }, + { + "path": "features.coelution_corr_threshold", + "name": "coelution_corr_threshold", + "section": "features", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.9, + "default_text": "0.9", + "help": "", + "gates": [], + "source_line": 872 + }, + { + "path": "features.prec_tol_ppm", + "name": "prec_tol_ppm", + "section": "features", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 20.0, + "default_text": "20.0", + "help": "", + "gates": [], + "source_line": 873 + }, + { + "path": "features.bound_features", + "name": "bound_features", + "section": "features", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": true, + "default_text": "true", + "help": "Restrict trace-based features (co-elution, profile, xcorr, interference, base width) to the elution peak around the apex rather than the whole extracted RT window, so they are not diluted over large RT stretches.", + "gates": [], + "source_line": 877 + }, + { + "path": "features.bound_peak_fraction", + "name": "bound_peak_fraction", + "section": "features", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.333333, + "default_text": "1.0 / 3.0 (0.333333)", + "help": "Peak-boundary threshold as a fraction of apex height (DIA-NN-style: descend to peak*fraction, or stop earlier at a valley below it). 1/3 matched DIA-NN's RT bounds best in the diagnostic-plot benchmark.", + "gates": [ + "diagnostic" + ], + "source_line": 881 + }, + { + "path": "features.bound_peak_grace", + "name": "bound_peak_grace", + "section": "features", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 0, + "default_text": "0", + "help": "Grace when walking the elution-peak boundary: number of consecutive sub-threshold scans to BRIDGE before stopping. 0 (default) stops at the first scan below `bound_peak_fraction` (brittle on jagged/gappy profiles); 1 bridges a single-scan dip (DIA sampling gap / noise), giving steadier boundaries.", + "gates": [], + "source_line": 886 + }, + { + "path": "features.bound_from_confident", + "name": "bound_from_confident", + "section": "features", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": true, + "default_text": "true", + "help": "Elution-peak boundary source. When true (default) a single set of left/right half-widths (seconds) is learned once from the confident seed PSMs (`spectrum_q <= 0.01`, target-only, the same set that anchors RT calibration / DeepLC fine-tune) and applied to EVERY candidate around its own apex. This removes per-candidate boundary manipulation so a decoy is scored over a real- peptide-width window centred on its apex. When false, each candidate detects its own peak boundary from its top-3-predicted-fragment profile (per-candidate, but noisy/manipulable for chimeric decoys; the legacy behaviour). If the seed yields < 20 confident anchors the stage logs a warning and falls back to per-candidate detection for that run.", + "gates": [], + "source_line": 897 + }, + { + "path": "features.bound_confident_pct", + "name": "bound_confident_pct", + "section": "features", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 50.0, + "default_text": "50.0", + "help": "Percentile (0-100) of the confident-set half-widths taken as the global left/ right elution half-width when `bound_from_confident` is true. 50 = median (typical real peak width); higher percentiles widen the shared window.", + "gates": [], + "source_line": 901 + }, + { + "path": "features.ms1_precursor_features", + "name": "ms1_precursor_features", + "section": "features", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Emit the MS1 apex-isotope precursor feature `ms1_isotope_height_corr` (Pearson of the observed apex isotope heights `[i0,i1,i2]` against the Poisson-averagine model). Default false (the feature is present in the battery but returns 0.0, so the vector length is unchanged in effect). It overlaps the existing `ms1_isotope_cosine_apex`, so it is opt-in and benchmark-gated rather than default-on (AlphaDIA-plan item 12).", + "gates": [ + "benchmark-gated" + ], + "source_line": 908 + }, + { + "path": "compete.group_by", + "name": "group_by", + "section": "compete", + "rust_type": "CompeteGroupBy", + "kind": "enum", + "optional": false, + "default": "base_peptide", + "default_text": "`base_peptide`", + "help": "Competition grouping: `precursor` collapses charge/modification siblings separately within each target/decoy label; targets and decoys therefore do not compete directly. `apex` also groups by rounded apex RT; `peptidoform_charge` keeps each peptidoform+charge as its own group (precursor-level, as DIA-NN/Spectronaut report), so sibling charges of one peptide are not collapsed.", + "gates": [], + "source_line": 941, + "choices": [ + "base_peptide", + "apex", + "peptidoform_charge" + ] + }, + { + "path": "compete.apex_rt_tolerance_s", + "name": "apex_rt_tolerance_s", + "section": "compete", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 5.0, + "default_text": "5.0", + "help": "", + "gates": [], + "source_line": 942 + }, + { + "path": "compete.mode", + "name": "mode", + "section": "compete", + "rust_type": "CompetitionMode", + "kind": "enum", + "optional": false, + "default": "winner_take_all", + "default_text": "`winner_take_all`", + "help": "How within-group competition resolves (sensitivity program, spec 04 §6 / P2.4). `winner_take_all` = legacy (keep only the top `prelim_score` per group). The other modes preserve more candidate evidence for the rescorer/ FDR to arbitrate. Default `winner_take_all` (unchanged behaviour).", + "gates": [], + "source_line": 947, + "choices": [ + "winner_take_all", + "none", + "features_only", + "unique_evidence", + "margin_gated" + ] + }, + { + "path": "compete.margin", + "name": "margin", + "section": "compete", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "Score margin (in `prelim_score` units) required to remove a loser under `margin_gated`. A loser closer than this to the winner is kept.", + "gates": [ + "gated" + ], + "source_line": 950 + }, + { + "path": "compete.unique_evidence_min_fragments", + "name": "unique_evidence_min_fragments", + "section": "compete", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 2, + "default_text": "2", + "help": "Minimum distinct unique-fragment count a loser must have to survive under `unique_evidence` (needs the `unique_fragment_count` feature; falls back to winner-take-all when the column is absent).", + "gates": [], + "source_line": 954 + }, + { + "path": "compete.emit_competition_audit", + "name": "emit_competition_audit", + "section": "compete", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Diagnostic: when true, write `.compete_audit.parquet` recording every removed candidate with its group, winner, scores, and removal reason.", + "gates": [ + "diagnostic" + ], + "source_line": 957 + }, + { + "path": "rescore.classifier", + "name": "classifier", + "section": "rescore", + "rust_type": "RescorerKind", + "kind": "enum", + "optional": false, + "default": "native_tda", + "default_text": "`native_tda`", + "help": "", + "gates": [], + "source_line": 1293, + "choices": [ + "native_tda", + "mokapot", + "nn_torch", + "percolator", + "entrapment" + ] + }, + { + "path": "rescore.folds", + "name": "folds", + "section": "rescore", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 3, + "default_text": "3", + "help": "", + "gates": [], + "source_line": 1294 + }, + { + "path": "rescore.train_fdr", + "name": "train_fdr", + "section": "rescore", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.01, + "default_text": "0.01", + "help": "", + "gates": [], + "source_line": 1295 + }, + { + "path": "rescore.num_iter", + "name": "num_iter", + "section": "rescore", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 10, + "default_text": "10", + "help": "number of semi-supervised iterations for the native rescorer.", + "gates": [], + "source_line": 1297 + }, + { + "path": "rescore.max_feature_matrix_gib", + "name": "max_feature_matrix_gib", + "section": "rescore", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "Refuse a rescore whose in-memory feature matrix would exceed this many GiB. 0 (default) means no ceiling, which is the previous behaviour. The matrix is `Vec>`: eight bytes per value, plus a heap allocation and a 24-byte spine entry per PSM. Nothing in the workspace estimates or checks available memory (there is deliberately no `sysinfo` dependency), so an experiment-wide rescore over enough runs was simply killed by the OS after however long it took to get there. `native_tda` additionally runs all folds in parallel, each holding an owned standardised copy of its training slice, so the true peak is roughly `(1 + folds) x` this figure. Setting a ceiling converts that into an error at startup, naming the estimate and the two ways out. It is not a batching implementation: sub-batching changes which PSMs share a pooled `q_value`, so it is the operator's decision, not a silent one.", + "gates": [], + "source_line": 1312 + }, + { + "path": "rescore.python", + "name": "python", + "section": "rescore", + "rust_type": "Option", + "kind": "other", + "optional": true, + "default": "null", + "default_text": "null", + "help": "", + "gates": [], + "source_line": 1313 + }, + { + "path": "rescore.percolator_bin", + "name": "percolator_bin", + "section": "rescore", + "rust_type": "Option", + "kind": "other", + "optional": true, + "default": "null", + "default_text": "null", + "help": "Path to an external `percolator` executable. Parsed and never read: no stage launches percolator, and `RescorerKind` has no variant that would. It is the only silently inert config field in the tree, since the three MBR ones warn (see `validate`). Kept rather than deleted because the external-percolator path is still intended; `validate` now warns when it is set.", + "gates": [], + "source_line": 1320 + }, + { + "path": "rescore.entrapment_marker", + "name": "entrapment_marker", + "section": "rescore", + "rust_type": "Option", + "kind": "other", + "optional": true, + "default": "null", + "default_text": "null", + "help": "Protein-accession substring marking spike-in (entrapment) negatives, e.g. \"_HUMAN\". Required when `classifier = entrapment`; PSMs whose protein contains it are the empirical false population.", + "gates": [], + "source_line": 1324 + }, + { + "path": "rescore.entrapment_exclude", + "name": "entrapment_exclude", + "section": "rescore", + "rust_type": "Option", + "kind": "other", + "optional": true, + "default": "null", + "default_text": "null", + "help": "If a protein also contains this substring it is NOT counted as entrapment (the sample's own species, e.g. \"_ECOLI\"): shared peptides then count as real targets. `None` = the marker alone decides.", + "gates": [], + "source_line": 1328 + }, + { + "path": "rescore.entrapment_contaminant_markers", + "name": "entrapment_contaminant_markers", + "section": "rescore", + "rust_type": "Vec", + "kind": "other", + "optional": false, + "default": "[]", + "default_text": "[]", + "help": "Protein substrings marking genuine contaminants inside the spike-in proteome (e.g. \"KRT\", \"ALBU\", keratin/albumin entry-name tokens). A PSM matching `entrapment_marker` but also one of these is treated as a REAL target, not an entrapment negative: such peptides are truly present (handling contaminants) so using them as negatives mislabels real signal and inflates the estimated FDR. Empty = every spike-in hit is a negative.", + "gates": [], + "source_line": 1335 + }, + { + "path": "rescore.entrapment_ratio", + "name": "entrapment_ratio", + "section": "rescore", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 1.0, + "default_text": "1.0", + "help": "N_real_lib / N_entrap_lib. Scales the entrapment FDR estimate so it is unbiased when the spike-in library differs in size from the real one.", + "gates": [], + "source_line": 1338 + }, + { + "path": "rescore.strict", + "name": "strict", + "section": "rescore", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": true, + "default_text": "true", + "help": "When true, any sidecar/classifier failure or misconfiguration (Mokapot or entrapment sidecar error, unwired percolator, entrapment mode with no entrapment PSMs) is a hard error instead of a silent fall back to the native rescorer. Default true so a named scientific workflow cannot silently execute a different model; set false only for explicit legacy compatibility.", + "gates": [], + "source_line": 1345 + }, + { + "path": "rescore.handoff", + "name": "handoff", + "section": "rescore", + "rust_type": "Handoff", + "kind": "enum", + "optional": false, + "default": "tsv", + "default_text": "`tsv`", + "help": "How the feature matrix reaches a sidecar rescorer. See `Handoff`. `parquet` is dramatically faster on large pools but applies to nn_torch only.", + "gates": [], + "source_line": 1349, + "choices": [ + "tsv", + "parquet" + ] + }, + { + "path": "quant.q_threshold", + "name": "q_threshold", + "section": "quant", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.01, + "default_text": "0.01", + "help": "Peptide-level q-value cutoff for inclusion.", + "gates": [], + "source_line": 1108 + }, + { + "path": "quant.top_n_fragments", + "name": "top_n_fragments", + "section": "quant", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 3, + "default_text": "3", + "help": "Number of top fragments summed per peptidoform.", + "gates": [], + "source_line": 1110 + }, + { + "path": "quant.top_n_peptides", + "name": "top_n_peptides", + "section": "quant", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 3, + "default_text": "3", + "help": "Number of top peptides summed per protein group (TopNSum).", + "gates": [], + "source_line": 1112 + }, + { + "path": "quant.rollup", + "name": "rollup", + "section": "quant", + "rust_type": "RollupMethod", + "kind": "enum", + "optional": false, + "default": "top_n_sum", + "default_text": "`top_n_sum`", + "help": "", + "gates": [], + "source_line": 1113, + "choices": [ + "top_n_sum", + "sum" + ] + }, + { + "path": "quant.bound_peak", + "name": "bound_peak", + "section": "quant", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": true, + "default_text": "true", + "help": "Integrate each fragment only over the detected elution-peak window rather than the whole chromatogram. The window is found from the summed XIC apex.", + "gates": [], + "source_line": 1116 + }, + { + "path": "quant.peak_fraction", + "name": "peak_fraction", + "section": "quant", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.166667, + "default_text": "1.0 / 6.0 (0.166667)", + "help": "Descent threshold for the peak-window walk: stop where the summed XIC drops below `peak_fraction` * apex height (1/6 expanded from the 1/3 feature bound).", + "gates": [], + "source_line": 1119 + }, + { + "path": "quant.peak_grace", + "name": "peak_grace", + "section": "quant", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "Zig-zag grace: bridge up to this many consecutive sub-threshold scans during the peak-window walk; the boundary triggers on `peak_grace + 1` consecutive sub-threshold scans (1 = stop on 2 consecutive misses).", + "gates": [], + "source_line": 1123 + }, + { + "path": "quant.peak_window_mode", + "name": "peak_window_mode", + "section": "quant", + "rust_type": "PeakWindowMode", + "kind": "enum", + "optional": false, + "default": "per_candidate", + "default_text": "`per_candidate`", + "help": "Per-candidate window vs a consensus width derived from confident peptides.", + "gates": [], + "source_line": 1125, + "choices": [ + "per_candidate", + "consensus" + ] + }, + { + "path": "quant.reliable_q", + "name": "reliable_q", + "section": "quant", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.001, + "default_text": "0.001", + "help": "Peptide q-value cutoff defining the \"confident\" set that calibrates the consensus half-widths (Consensus mode only). Tighter than `q_threshold`.", + "gates": [], + "source_line": 1128 + }, + { + "path": "quant.q_filter", + "name": "q_filter", + "section": "quant", + "rust_type": "QuantQColumn", + "kind": "enum", + "optional": false, + "default": "peptide_q", + "default_text": "`peptide_q`", + "help": "Which q-value column to filter candidates on (`peptide_q` default; `precursor_q` is single-run only; use `run_psm_q` for per-run slices of an experiment-wide rescore). See `QuantQColumn`.", + "gates": [], + "source_line": 1132, + "choices": [ + "peptide_q", + "precursor_q", + "psm_q", + "run_psm_q" + ] + }, + { + "path": "quant.interference_envelope", + "name": "interference_envelope", + "section": "quant", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Apply an apex-outward interference-correction envelope to each fragment trace before integrating its area, stripping co-eluting interference in the peak wings. Off by default (identity on a clean peak). Opt-in and benchmark-gated: it changes reported quantities.", + "gates": [ + "benchmark-gated" + ], + "source_line": 1137 + }, + { + "path": "quant.fragment_selection", + "name": "fragment_selection", + "section": "quant", + "rust_type": "FragmentSelection", + "kind": "enum", + "optional": false, + "default": "observed_area", + "default_text": "`observed_area`", + "help": "Which fragments enter the top-N sum. `observed_area` (default, legacy) ranks by the integrated area itself, which preferentially selects interfered fragments (their areas are inflated) and so varies run to run. `predicted` ranks by the library (predicted or empirical) fragment intensity, a per-precursor constant, so every run sums the same fragments. Astral HYE 2026-08-26: CV 0.163 -> 0.112 on 6/6 ions at top-3. Benchmark-gated.", + "gates": [ + "benchmark-gated" + ], + "source_line": 1144, + "choices": [ + "observed_area", + "predicted" + ] + }, + { + "path": "quant.fixed_scan_halfwidth", + "name": "fixed_scan_halfwidth", + "section": "quant", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 0, + "default_text": "0", + "help": "When > 0, integrate each fragment over the `2k+1` scans centred on the identification apex instead of the descent-walk window (`bound_peak` window ignored; falls back to it when the apex is unknown). A fixed narrow window is far less sensitive to interference in the peak wings than the walked bounds. 0 (default) = off.", + "gates": [], + "source_line": 1150 + }, + { + "path": "quant.baseline_subtract", + "name": "baseline_subtract", + "section": "quant", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "Subtract a per-fragment local background before integrating (fixed-scan window only). The background is the `baseline_quantile` quantile of the intensities in the two flanks (`baseline_flank_scans` samples on each side of the integration window); window intensities are clipped at zero after subtraction. Targets the additive floor that compresses ratios in the low-abundance condition. Off by default; benchmark-gated.", + "gates": [ + "benchmark-gated" + ], + "source_line": 1157 + }, + { + "path": "quant.baseline_flank_scans", + "name": "baseline_flank_scans", + "section": "quant", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 12, + "default_text": "12", + "help": "Flank length (samples per side) used to estimate the background.", + "gates": [], + "source_line": 1159 + }, + { + "path": "quant.baseline_quantile", + "name": "baseline_quantile", + "section": "quant", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.25, + "default_text": "0.25", + "help": "Quantile of the flank intensities taken as the background level.", + "gates": [], + "source_line": 1161 + }, + { + "path": "quant.fixed_window_s", + "name": "fixed_window_s", + "section": "quant", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "When > 0, integrate each fragment over the samples within `fixed_window_s` seconds of the identification apex (instrument-independent alternative to `fixed_scan_halfwidth`, which it overrides). 0 (default) = off.", + "gates": [], + "source_line": 1165 + }, + { + "path": "mbr.strategy", + "name": "strategy", + "section": "mbr", + "rust_type": "MbrStrategy", + "kind": "enum", + "optional": false, + "default": "none", + "default_text": "`none`", + "help": "", + "gates": [], + "source_line": 1246, + "choices": [ + "none", + "empirical_library", + "rt_transfer", + "full" + ] + }, + { + "path": "mbr.q_anchor", + "name": "q_anchor", + "section": "mbr", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.01, + "default_text": "0.01", + "help": "q-value for a precursor to become a cross-run anchor (validated at 0.01).", + "gates": [], + "source_line": 1248 + }, + { + "path": "mbr.min_anchor_runs", + "name": "min_anchor_runs", + "section": "mbr", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 2, + "default_text": "2", + "help": "Minimum number of OTHER runs a precursor must be confident in to transfer.", + "gates": [], + "source_line": 1250 + }, + { + "path": "mbr.q_transfer", + "name": "q_transfer", + "section": "mbr", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.01, + "default_text": "0.01", + "help": "Accept threshold for a transferred identification's transfer q-value.", + "gates": [], + "source_line": 1252 + }, + { + "path": "mbr.rt_window_s", + "name": "rt_window_s", + "section": "mbr", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 20.0, + "default_text": "20.0", + "help": "NOT YET WIRED. Transfer RT half-window (seconds) around the cross-run-predicted RT. The M2 leave-target-out residual was ~17 s at p95, ~15x tighter than the search window, which is where this default comes from -- but no code reads this field yet, so setting it has no effect. Kept as the recorded design value for the MBR transfer tier; `validate()` warns if it is changed from the default. See CLAUDE.md, \"MBR transfer/re-extraction remains benchmark-gated\".", + "gates": [ + "benchmark-gated", + "not yet wired" + ], + "source_line": 1259 + }, + { + "path": "mbr.decoy_transfer", + "name": "decoy_transfer", + "section": "mbr", + "rust_type": "DecoyTransfer", + "kind": "enum", + "optional": false, + "default": "permuted_rt", + "default_text": "`permuted_rt`", + "help": "NOT YET WIRED. Which decoy-transfer null would estimate the false-transfer rate (M4). No code reads this field yet; `validate()` warns if it is changed.", + "gates": [ + "not yet wired" + ], + "source_line": 1262, + "choices": [ + "permuted_rt", + "reverse_sequence", + "both" + ] + }, + { + "path": "mbr.consensus_corr_min", + "name": "consensus_corr_min", + "section": "mbr", + "rust_type": "f64", + "kind": "float", + "optional": false, + "default": 0.0, + "default_text": "0.0", + "help": "Minimum correlation of the observed fragment pattern to the empirical consensus for a transfer to be accepted (interference guard; 0 disables).", + "gates": [], + "source_line": 1265 + }, + { + "path": "mbr.requant_all", + "name": "requant_all", + "section": "mbr", + "rust_type": "bool", + "kind": "bool", + "optional": false, + "default": false, + "default_text": "false", + "help": "NOT YET WIRED. Would requantify already-identified precursors too (fill the matrix), not only transferred ones, under `strategy = Full`. No code reads this field yet; `validate()` warns if it is changed.", + "gates": [ + "not yet wired" + ], + "source_line": 1269 + }, + { + "path": "mbr.python", + "name": "python", + "section": "mbr", + "rust_type": "Option", + "kind": "other", + "optional": true, + "default": "null", + "default_text": "null", + "help": "Python interpreter for the `mbr_worker.py` sidecar (pandas/pyarrow/numpy; e.g. the `py312_mumdia` env). Required when `strategy != None`.", + "gates": [], + "source_line": 1272 + }, + { + "path": "experiment.parallel_runs", + "name": "parallel_runs", + "section": "experiment", + "rust_type": "usize", + "kind": "integer", + "optional": false, + "default": 1, + "default_text": "1", + "help": "How many per-run search chains to execute concurrently. 1 (default) is strictly sequential, i.e. the historical behaviour. Runs are independent, so raising this scales nearly linearly in wall time, but EACH concurrent run holds its own extraction working set (tens of GB on a large library), so the practical ceiling is memory, not cores. Raise it deliberately after checking peak RSS for a single run; 2-4 is a reasonable start on a large-memory machine. Results are unaffected: chunks are processed in index order and completion order never reaches the output.", + "gates": [], + "source_line": 1440 + }, + { + "path": "experiment.finetune_scope", + "name": "finetune_scope", + "section": "experiment", + "rust_type": "FinetuneScope", + "kind": "enum", + "optional": false, + "default": "first_run_only", + "default_text": "`first_run_only`", + "help": "Whether the DeepLC fine-tune runs once for the experiment or once per run. Only consulted when `rt_im_train.finetune_deeplc` is set.", + "gates": [], + "source_line": 1443, + "choices": [ + "first_run_only", + "per_run" + ] + } + ], + "list_sections": [ + { + "paths": [ + "peptidoforms.fixed_mods", + "peptidoforms.variable_mods" + ], + "item": "ResidueMod", + "fields": [ + { + "path": "residue", + "name": "residue", + "section": "", + "rust_type": "char", + "kind": "other", + "optional": false, + "default": null, + "default_text": null, + "help": "Target residue; `*` for any / terminal handled separately in MVP.", + "gates": [], + "source_line": 390 + }, + { + "path": "name", + "name": "name", + "section": "", + "rust_type": "String", + "kind": "string", + "optional": false, + "default": null, + "default_text": null, + "help": "UniMod name.", + "gates": [], + "source_line": 392 + } + ] + } + ], + "profiles": { + "dia": [ + { + "path": "features.set", + "value": "FeatureSet::Extended" + }, + { + "path": "extract.apex_count_window", + "value": "5" + }, + { + "path": "extract.apex_rt_prior_s", + "value": "120.0" + } + ] + } +} diff --git a/desktop/.gitignore b/desktop/.gitignore new file mode 100644 index 00000000..76ba0d16 --- /dev/null +++ b/desktop/.gitignore @@ -0,0 +1,11 @@ +# Build output for the desktop workspace. +target/ + +# Engine and installer binaries staged into the bundle by CI, never committed: +# they are build artifacts of another workspace and are megabytes each. +src-tauri/binaries/* +!src-tauri/binaries/README.md + +# Tauri writes generated permission schemas here. +src-tauri/gen/ +src-tauri/resources/ diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock new file mode 100644 index 00000000..79b334b1 --- /dev/null +++ b/desktop/Cargo.lock @@ -0,0 +1,4670 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "mumdia-console" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-dialog", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.1", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.1", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "walkdir", +] + +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.20", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.1", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.1", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.1", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml new file mode 100644 index 00000000..c60e299d --- /dev/null +++ b/desktop/Cargo.toml @@ -0,0 +1,21 @@ +# Separate workspace, deliberately. +# +# The desktop application is NOT a member of `rust/mumdia`'s workspace and does not +# depend on the `mumdia` crate. Two reasons: +# +# 1. It spawns the engine as a child process rather than linking it, so there is +# nothing to depend on. See src-tauri/src/run.rs for why (the engine has no +# signal handling, and a linked engine could not be stopped). +# 2. Keeping Tauri out of the engine workspace means `cargo test --workspace` and +# the CI lint job never compile a webview. +[workspace] +members = ["src-tauri"] +resolver = "2" + +# Optimise the shipped application for size rather than speed: it is a window and a +# process supervisor, and every millisecond of real work happens in the engine. +[profile.release] +strip = true +opt-level = "s" +lto = true +codegen-units = 1 diff --git a/desktop/README.md b/desktop/README.md new file mode 100644 index 00000000..839296b7 --- /dev/null +++ b/desktop/README.md @@ -0,0 +1,202 @@ +# MuMDIA Console + +Desktop interface for the MuMDIA search engine, for people who should not have to use +a terminal. Windows and Linux. + +This directory is a **separate Cargo workspace**. It is not a member of +`rust/mumdia`'s workspace and does not depend on the `mumdia` crate, so +`cargo test --workspace` in the engine never compiles a webview. + +## Running it during development + +```bash +cd desktop/src-tauri +cargo run +``` + +The application needs an engine binary. It looks in this order: + +1. `$MUMDIA_BIN`, if set; +2. beside its own executable, and in a `binaries/` subdirectory beside it (this is + where a release bundle puts it); +3. `rust/mumdia/target/release/mumdia` relative to a `cargo run` build, which is the + convenient case while developing; +4. anything named `mumdia` on `PATH`. + +It runs `--version` on whatever it finds at startup, so a binary that exists but +cannot execute fails immediately rather than an hour into a search. + +To point it at a specific build: + +```bash +MUMDIA_BIN=/path/to/mumdia cargo run # Linux +$env:MUMDIA_BIN = "C:\path\to\mumdia.exe"; cargo run # Windows PowerShell +``` + +## Why the engine is a subprocess and not a linked library + +The engine is a library crate, so linking it looks attractive. It is the wrong +choice, for one decisive reason: **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. + +Two supporting reasons. A stage panic would take the whole application down rather +than ending one run — and the engine does still panic on some malformed input. And +rayon's global pool can only be built once per process, so `--threads` could not +change between runs. + +The cost is that the application must resolve a path and manage a process tree. That +is `src/engine.rs` and `src/run.rs`. + +## Process control + +The tree is three deep: application, engine, and the Python workers the engine +spawns. Killing only the engine orphans a worker that may hold tens of gigabytes. + +- **Linux**: the engine is spawned into a new process group, and cancelling signals + the group (`TERM`, then `KILL`). +- **Windows**: `taskkill /T /F` walks the tree at kill time. + +A hard kill skips destructors, so the engine's atomic-write layer never removes its +`.tmp-` files. Cancelling therefore sweeps them from the output directory, or the +next run would start in a dirty folder. + +Closing the window cancels every running search, for the same reason. + +## How progress works + +No log parsing. Every engine stage writes `.report.json` beside its output, +carrying the producing stage, row count, elapsed time and per-stage statistics. The +application polls the output directory and folds those into one row per stage. + +The results panel is read from `psms_scored.parquet.report.json`, which records the +classifier that **actually** ran alongside the one requested. Those differ when a +sidecar fails and `rescore.strict` is false, and the interface says so rather than +echoing the request. + +## Frontend + +Plain ES modules, no framework and no build step, so the release pipeline needs no +Node. `ui/` is served as static files. If this grows to include the generated +settings editor, revisit that decision then: it is much easier to add a bundler +later than to remove one. + +## Analysis components + +The application installs its own Python environment with `uv`, so conda is never +needed. It goes under the per-user data directory (`%LOCALAPPDATA%\MuMDIA` or +`~/.local/share/MuMDIA`), not beside the executable, because on Windows that is +Program Files and an installer that needs administrator rights on first run is not +an easy install. + +**Searching without the components is refused.** MuMDIA does run with no Python at +all, but the recorded numbers make that a bad default to offer: on the same file the +fully native FASTA path returns about 1,213 report rows against about 10,300 for the +imported-library workflow with DeepLC and neural rescoring. The refusal predicate is +narrow on purpose -- "this configuration requires no sidecar at all", asked of +`mumdia doctor --json` rather than kept as a list here. Refusing anything mentioning +`native_tda` would be wrong: on an imported library it measured 10,847 against +`nn_torch`'s 10,914. + +### Two environments, not one + +MS2PIP cannot share an environment with DeepLC at the versions this project tests: + + deeplc==4.1.1 -> psm-utils>=1.5 -> sqlalchemy>=2 + ms2pip==4.0.0 -> sqlalchemy>=1.3,<2 + +`uv` reports the pair as unsatisfiable. `ms2pip>=4.1` does resolve alongside DeepLC, +but MS2PIP's version changes predicted fragment intensities, and +`env/docker-rescore.yml` pins 4.0.0 deliberately as "a separate, testable upgrade". +So the primary environment covers rescoring, DeepLC and match-between-runs, which is +the whole recommended workflow, and MS2PIP gets its own, installed on request and +needed only for FASTA-mode library building with predicted intensities. + +## Settings + +The editor 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, staleness-checked in CI beside it. Nothing about a setting +is written in the interface, so it cannot describe a parameter the engine does not +have. + +Saving writes only the difference from the defaults. `Config` is +`deny_unknown_fields` with serde defaults, so that is a valid configuration, and it +means a later release that improves a default still reaches someone who saved +settings today. Every save is validated by the engine before it is offered for use. + +## Testing + + cargo test --lib # unit tests, no engine needed + + # end to end, against a real engine and the fixture ci/smoke.sh generates + MUMDIA_BIN=... MUMDIA_TEST_MZML=... MUMDIA_TEST_FASTA=... cargo test + + # the real component installation; downloads several hundred megabytes + MUMDIA_TEST_INSTALL=1 cargo test the_primary_environment + + # the process-tree kill. NOT run in CI, and not on a machine you share + MUMDIA_TEST_KILL=1 cargo test kill_tree + +`MUMDIA_TEST_KILL` is opt-in because that test terminated a GitHub runner twice. The +first time is explained: the group kill had no guard and could signal the runner's +own process group. The second time it did it again with the guard in place, which +should have permitted a group signal only for a child verifiably in its own group, +and that is not accounted for. The gating follows from not knowing rather than from +a diagnosis. + +The consequence, stated plainly: the Unix group-kill path in `kill_tree` is covered +by nothing automated. The guard's decision is tested without acting on it, and the +kill itself is verified on Windows, where `taskkill /T` addresses a process tree +rather than a group. + +## Packaging + +`cargo tauri build` produces an `.msi` on Windows and an `.AppImage` on Linux. Only +two files are shipped as Tauri resources, both executables: the engine and `uv`, in +`binaries/` beside the application. + +Everything else the application needs from the repository is compiled in with +`include_str!`: the settings schema, and the two requirement sets. That is both +simpler and more robust than shipping them as files, and it costs nothing in +freshness, because all three are generated from sources that require a rebuild +anyway. It also avoids two Tauri packaging traps found while building the first +installer: + +- in the resource LIST form, a `..` source keeps its shape, so `"../../configs/*"` + installs to `/_up_/_up_/configs/`, where nothing looks for it. This was + read out of the generated WiX source, not guessed; +- in the resource MAP form, which does let a destination be named, a `..` source + fails the build outright with `Access is denied`. + +Verified on Windows: a 29 MB installer containing `mumdia-console.exe` with +`binaries/mumdia.exe` and `binaries/uv.exe` beside it, and no `_up_` directory. + +### The Linux engine is a GNU build, not musl + +The engine's own release archives are musl and stay musl. Inside an AppImage they do +not survive: `linuxdeploy` runs `patchelf` over every ELF binary it bundles, and a +static-pie musl binary comes out with `RUNPATH [$ORIGIN]` injected and segfaults +immediately. Verified by extracting a built AppImage and running the engine inside +it; `uv`, dynamically linked, survived the same treatment untouched. + +Nothing is lost. musl would buy portability if the bundle had no other glibc floor, +but the Tauri host links WebKitGTK and sets that floor regardless. + +### Where the engine actually sits in each bundle + + MSI /mumdia-console.exe + /binaries/mumdia.exe + + AppImage usr/bin/mumdia-console + usr/lib/MuMDIA/binaries/mumdia + +Note that the AppImage does NOT put the engine beside the executable. That is why +the lookup asks Tauri for the resource directory first; without it the application +would search `usr/bin/` and report that it cannot find its own engine. + +## What is not here yet + +Nobody has clicked through the interface. The backend it drives is covered by tests, +and both bundles have been built and inspected, but the buttons themselves rest on +inspection. diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml new file mode 100644 index 00000000..a559b74e --- /dev/null +++ b/desktop/src-tauri/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "mumdia-console" +version = "0.1.0" +edition = "2021" +description = "Desktop interface for the MuMDIA DIA search engine" +license = "Apache-2.0" +publish = false + +[lib] +name = "mumdia_console" +path = "src/lib.rs" + +# The Tauri shell. Named with a hyphen because that is the executable users see; +# the library keeps the underscore Rust requires. +[[bin]] +name = "mumdia-console" +path = "src/main.rs" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = [] } +tauri-plugin-dialog = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/desktop/src-tauri/binaries/README.md b/desktop/src-tauri/binaries/README.md new file mode 100644 index 00000000..a245e510 --- /dev/null +++ b/desktop/src-tauri/binaries/README.md @@ -0,0 +1,19 @@ +# Bundled binaries + +Filled in by `release.yml` at build time, and deliberately empty in the repository: +these are build artifacts of another workspace and are megabytes each. + +At bundle time this directory holds + +- `mumdia` (`mumdia.exe` on Windows), the search engine the application spawns, built + from the same checkout so a released application and its engine cannot disagree; +- `uv` (`uv.exe`), the installer used to create the managed Python environment + without conda. + +The directory is declared as a Tauri `resource`, and a resource glob that matches +nothing fails the build. This file is what keeps it matching while the directory is +otherwise empty, so a developer running `cargo tauri build` locally gets a working +bundle rather than a packaging error. + +`src/engine.rs` and `src/components.rs` look here, and beside the executable, when +resolving those two programs. diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/desktop/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json new file mode 100644 index 00000000..f8bfb6be --- /dev/null +++ b/desktop/src-tauri/capabilities/default.json @@ -0,0 +1,6 @@ +{ + "identifier": "default", + "description": "Native file and folder pickers for the main window.", + "windows": ["main"], + "permissions": ["core:default", "dialog:default"] +} diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png new file mode 100644 index 00000000..cf6c3703 Binary files /dev/null and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..cb5002d4 Binary files /dev/null and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/256x256.png b/desktop/src-tauri/icons/256x256.png new file mode 100644 index 00000000..cb5002d4 Binary files /dev/null and b/desktop/src-tauri/icons/256x256.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png new file mode 100644 index 00000000..693d922a Binary files /dev/null and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico new file mode 100644 index 00000000..8eea273f Binary files /dev/null and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png new file mode 100644 index 00000000..cb5002d4 Binary files /dev/null and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/src/components.rs b/desktop/src-tauri/src/components.rs new file mode 100644 index 00000000..3681407a --- /dev/null +++ b/desktop/src-tauri/src/components.rs @@ -0,0 +1,617 @@ +//! Installing and detecting the Python analysis components. +//! +//! # Why this exists at all +//! +//! MuMDIA runs with no Python: the native predictors and the `native_tda` rescorer +//! are the shipped defaults. It is tempting to build a zero-dependency application +//! on that basis. The recorded numbers say otherwise -- on the same file, the fully +//! native FASTA path returns about 1,213 report rows against about 10,300 for the +//! imported-library workflow with DeepLC and neural rescoring. Two things differ +//! between those, the library source as well as the predictors, so the gap is not +//! attributable to Python alone; it is large enough to settle the design. An +//! application that avoids installing Python ships the weak path, so this one +//! installs it, and `preflight` refuses to search without it. +//! +//! # Why `uv` and not conda +//! +//! Reading `env/mumdia-deeplc.yml`, conda contributes `python=3.11` and `pip`, and +//! every dependency that matters is already pip. `uv` supplies the interpreter as +//! well, as one self-contained binary, so "install Miniconda first, create two +//! environments, then edit the config to point at the right interpreters" -- the +//! step where an external user gives up -- disappears entirely. +//! +//! # One environment, and the role it cannot cover +//! +//! Rescoring, DeepLC and match-between-runs share one interpreter happily, and that +//! is the whole recommended workflow. MS2PIP cannot join them: at the versions this +//! project tests, `deeplc==4.1.1` needs `sqlalchemy>=2` through `psm-utils` and +//! `ms2pip==4.0.0` needs `sqlalchemy<2`, which `uv` reports as unsatisfiable. +//! +//! This was checked rather than assumed, and the assumption was wrong. `ms2pip>=4.1` +//! does resolve alongside DeepLC, but MS2PIP's version changes predicted fragment +//! intensities, so taking that upgrade to simplify packaging would trade a +//! convenience for a results change. MS2PIP therefore gets its own environment, +//! installed only on request, and it is needed only for FASTA-mode library building +//! with predicted intensities. + +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::{Arc, Mutex}; + +use serde::Serialize; + +/// Where the managed environment lives, and the state of the last install. +#[derive(Serialize, Clone, Debug, Default)] +pub struct Status { + /// Absolute path to the managed interpreter, if it exists. + pub python: Option, + /// True when that interpreter can import everything every role needs. + pub complete: bool, + /// Modules the managed interpreter cannot import. + pub missing: Vec, + /// Versions of the packages whose version changes results. + pub versions: std::collections::BTreeMap, + /// `idle` | `installing` | `done` | `failed` + pub install_status: String, + pub install_log: Vec, + pub error: Option, + /// Whether a bundled `uv` was found, without which nothing can be installed. + pub uv: Option, +} + +/// Every module the primary environment must provide. +/// +/// This is the union of what the rescore, DeepLC and match-between-runs roles +/// import: the whole recommended workflow. MS2PIP is deliberately absent -- it +/// cannot share an environment with DeepLC (see the module documentation) and lives +/// in its own, which `MS2PIP_MODULES` describes. +/// +/// Kept here rather than asked of the engine because it is needed BEFORE any +/// configuration exists: the setup screen runs on first launch, when there is +/// nothing to point `doctor` at. `doctor --json` remains the authority once a +/// configuration is in play, and the application shows that too. +const REQUIRED_MODULES: &[&str] = &[ + "deeplc", + "torch", + "psm_utils", + "mokapot", + "sklearn", + "numpy", + "pandas", + "pyarrow", +]; + +/// The optional MS2PIP environment's modules. +const MS2PIP_MODULES: &[&str] = &["ms2pip", "numpy", "pandas"]; + +/// Which managed environment a call is about. +/// +/// Two exist because they must: MS2PIP and DeepLC cannot share one. Everything else +/// about them is identical, so the create-and-install path is written once. +#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Env { + /// Rescoring, DeepLC and match-between-runs: the recommended workflow. + Primary, + /// MS2PIP, for FASTA-mode library building with predicted intensities. + Ms2pip, +} + +impl Env { + fn dir_name(self) -> &'static str { + match self { + Env::Primary => "python", + Env::Ms2pip => "python-ms2pip", + } + } + + fn requirements_name(self) -> &'static str { + match self { + Env::Primary => "console-requirements.txt", + Env::Ms2pip => "console-ms2pip-requirements.txt", + } + } + + pub fn modules(self) -> &'static [&'static str] { + match self { + Env::Primary => REQUIRED_MODULES, + Env::Ms2pip => MS2PIP_MODULES, + } + } + + /// Python version for this environment. MS2PIP pulls `pandas<2`, which has no + /// cp312 wheel, which is why both stay on 3.11. + fn python_version(self) -> &'static str { + "3.11" + } +} + +/// Packages whose version is worth reporting, because it changes results. +const REPORT_VERSIONS: &[&str] = &["deeplc", "torch", "mokapot", "ms2pip", "numpy"]; + +/// Per-user application data, where the managed environment is created. +/// +/// Not beside the executable: on Windows that is under Program Files, which a +/// normal user cannot write to, and an installer that needs administrator rights to +/// finish its first run is not the easy installation this exists to provide. +pub fn data_dir() -> PathBuf { + let base = if cfg!(windows) { + std::env::var_os("LOCALAPPDATA").map(PathBuf::from) + } else { + std::env::var_os("XDG_DATA_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share"))) + }; + base.unwrap_or_else(std::env::temp_dir).join("MuMDIA") +} + +pub fn venv_dir(env: Env) -> PathBuf { + data_dir().join(env.dir_name()) +} + +/// The interpreter inside a managed environment, whether or not it exists yet. +pub fn managed_python(env: Env) -> PathBuf { + let v = venv_dir(env); + if cfg!(windows) { + v.join("Scripts").join("python.exe") + } else { + v.join("bin").join("python") + } +} + +/// Locate `uv`: bundled beside the application first, then whatever is on PATH. +/// +/// PATH is accepted because a developer very likely has it already, and refusing to +/// use it would mean nobody could test this without building a bundle. +pub fn find_uv() -> Option { + let exe_name = if cfg!(windows) { "uv.exe" } else { "uv" }; + // Same reasoning as the engine lookup: an AppImage's resources are not beside + // the executable, and only the shell knows where they are. + if let Some(res) = crate::engine::resource_dir() { + for cand in [res.join("binaries").join(exe_name), res.join(exe_name)] { + if cand.is_file() { + return Some(cand); + } + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + for cand in [dir.join(exe_name), dir.join("binaries").join(exe_name)] { + if cand.is_file() { + return Some(cand); + } + } + } + } + let path = std::env::var_os("PATH")?; + std::env::split_paths(&path) + .map(|d| d.join(exe_name)) + .find(|p| p.is_file()) +} + +/// The two requirement sets, compiled in for the same reason as the settings +/// schema: a file that is not a file cannot go missing from a bundle, and these +/// change only when the application itself is rebuilt. +const REQUIREMENTS_PRIMARY: &str = include_str!("../../../env/console-requirements.txt"); +const REQUIREMENTS_MS2PIP: &str = include_str!("../../../env/console-ms2pip-requirements.txt"); + +/// Write the requirements for `env` where `uv` can read them, and return the path. +/// +/// Into the managed data directory rather than a temporary file, so that when an +/// installation fails the exact input is still on disk to look at. +pub fn requirements(env: Env) -> Result { + let text = match env { + Env::Primary => REQUIREMENTS_PRIMARY, + Env::Ms2pip => REQUIREMENTS_MS2PIP, + }; + let dir = data_dir(); + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + let path = dir.join(env.requirements_name()); + std::fs::write(&path, text).map_err(|e| format!("could not write {}: {e}", path.display()))?; + Ok(path) +} + +/// Ask an interpreter which of `modules` it cannot import. +/// +/// One process for the whole list: nine separate probes cost about a second each on +/// Windows, which is long enough to be visible on a screen that exists to feel +/// responsive. +fn missing_modules(python: &Path, modules: &[&str]) -> Result, String> { + let script = format!( + "import importlib.util as u\n\ + print('\\n'.join(m for m in {:?} if u.find_spec(m) is None))", + modules + ); + let out = crate::engine::command(python) + .args(["-c", &script]) + .output() + .map_err(|e| format!("could not run {}: {e}", python.display()))?; + if !out.status.success() { + return Err(String::from_utf8_lossy(&out.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) +} + +fn versions(python: &Path, modules: &[&str]) -> std::collections::BTreeMap { + let script = format!( + "import importlib.metadata as m\n\ + for name in {:?}:\n\ + \x20 try: print(name, m.version(name))\n\ + \x20 except Exception: pass", + modules + ); + let mut out = std::collections::BTreeMap::new(); + if let Ok(o) = crate::engine::command(python) + .args(["-c", &script]) + .output() + { + for line in String::from_utf8_lossy(&o.stdout).lines() { + if let Some((k, v)) = line.trim().split_once(' ') { + out.insert(k.to_string(), v.to_string()); + } + } + } + out +} + +/// Inspect a managed environment as it stands. +pub fn status_of(env: Env) -> Status { + let mut s = Status { + install_status: "idle".into(), + uv: find_uv().map(|p| p.display().to_string()), + ..Default::default() + }; + let py = managed_python(env); + if !py.is_file() { + s.missing = env.modules().iter().map(|m| m.to_string()).collect(); + return s; + } + s.python = Some(py.display().to_string()); + match missing_modules(&py, env.modules()) { + Ok(missing) => { + s.complete = missing.is_empty(); + s.missing = missing; + if s.complete { + // `sklearn` is imported as `sklearn` but distributed as + // `scikit-learn`, so ask metadata for the name pip knows. + s.versions = versions(&py, REPORT_VERSIONS); + } + } + Err(e) => { + s.error = Some(e); + s.missing = env.modules().iter().map(|m| m.to_string()).collect(); + } + } + s +} + +/// The primary environment, which is what "are the components installed" means. +pub fn status() -> Status { + status_of(Env::Primary) +} + +/// Shared, mutable install state so the interface can poll while it runs. +#[derive(Default)] +pub struct Installer { + primary: Mutex, + ms2pip: Mutex, +} + +impl Installer { + fn slot(&self, env: Env) -> &Mutex { + match env { + Env::Primary => &self.primary, + Env::Ms2pip => &self.ms2pip, + } + } + + /// Refresh one environment from disk. Cheap enough to call whenever the screen + /// is shown. + pub fn refresh(&self, env: Env) -> Status { + let fresh = status_of(env); + if let Ok(mut s) = self.slot(env).lock() { + // Anything an installation put there outlives a refresh. Keeping only + // "installing" lost the outcome: the install thread would set "done", + // the next refresh would overwrite it with the "idle" a fresh probe + // carries, and a caller watching for the transition would wait for ever. + // Found by the test that does exactly that. + let keep = s.install_status.clone(); + let log = s.install_log.clone(); + let err = s.error.clone(); + *s = fresh; + if keep != "idle" { + s.install_status = keep; + s.install_log = log; + // A failure message is part of the outcome and must survive too. + if s.error.is_none() { + s.error = err; + } + } + return s.clone(); + } + fresh + } +} + +/// Create the managed environment and install everything into it. +/// +/// Runs on its own thread and streams both streams into the shared log, because a +/// several-hundred-megabyte download with no visible progress is indistinguishable +/// from a hang. +pub fn install(installer: Arc, env: Env) -> Result<(), String> { + let uv = find_uv().ok_or_else(|| { + "the installer component `uv` was not found beside the application or on PATH".to_string() + })?; + let reqs = requirements(env)?; + let venv = venv_dir(env); + std::fs::create_dir_all(data_dir()) + .map_err(|e| format!("cannot create {}: {e}", data_dir().display()))?; + + { + let mut s = installer + .slot(env) + .lock() + .map_err(|_| "internal state is poisoned".to_string())?; + if s.install_status == "installing" { + return Err("an installation is already running".into()); + } + s.install_status = "installing".into(); + s.install_log.clear(); + s.error = None; + } + + std::thread::spawn(move || { + let log = |installer: &Installer, line: String| { + if let Ok(mut s) = installer.slot(env).lock() { + s.install_log.push(line); + if s.install_log.len() > 2000 { + let drop = s.install_log.len() - 2000; + s.install_log.drain(0..drop); + } + } + }; + + // Two steps: an interpreter, then the packages. `uv venv` downloads a + // standalone CPython if the machine has none, which is the whole point. + let steps: Vec<(&str, Vec)> = vec![ + ( + "creating the Python environment", + vec![ + "venv".into(), + // Without this, `uv venv` refuses a directory that already + // exists, so Install would fail for ever after the first + // attempt -- including after a FAILED attempt that left a + // partial environment behind, which is exactly when someone + // presses it again. `--clear` would also work but throws away + // a several-hundred-megabyte download to repair one package. + "--allow-existing".into(), + "--python".into(), + env.python_version().into(), + venv.display().to_string(), + ], + ), + ( + "installing the analysis packages", + vec![ + "pip".into(), + "install".into(), + "--python".into(), + venv.display().to_string(), + "-r".into(), + reqs.display().to_string(), + ], + ), + ]; + + for (title, args) in steps { + log(&installer, format!("== {title}")); + let mut cmd = crate::engine::command(&uv); + cmd.args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + if let Ok(mut s) = installer.slot(env).lock() { + s.install_status = "failed".into(); + s.error = Some(format!("could not run uv: {e}")); + } + return; + } + }; + for stream in [ + child + .stdout + .take() + .map(|s| Box::new(s) as Box), + child + .stderr + .take() + .map(|s| Box::new(s) as Box), + ] + .into_iter() + .flatten() + { + let installer = Arc::clone(&installer); + std::thread::spawn(move || { + for line in BufReader::new(stream).lines().map_while(Result::ok) { + if let Ok(mut s) = installer.slot(env).lock() { + s.install_log.push(line); + } + } + }); + } + match child.wait() { + Ok(st) if st.success() => {} + Ok(st) => { + if let Ok(mut s) = installer.slot(env).lock() { + s.install_status = "failed".into(); + s.error = Some(format!( + "{title} failed ({st}). The log above says why; a failed download \ + can simply be retried." + )); + } + return; + } + Err(e) => { + if let Ok(mut s) = installer.slot(env).lock() { + s.install_status = "failed".into(); + s.error = Some(format!("{title} could not be waited for: {e}")); + } + return; + } + } + } + + // Verify rather than assume: uv exiting zero says the packages resolved, not + // that the interpreter can import them. A torch wheel that does not match the + // machine installs perfectly and fails on import. + let fresh = status_of(env); + if let Ok(mut s) = installer.slot(env).lock() { + let log_so_far = s.install_log.clone(); + *s = fresh; + s.install_log = log_so_far; + if s.complete { + s.install_status = "done".into(); + } else { + s.install_status = "failed".into(); + s.error = Some(format!( + "the packages installed but the interpreter still cannot import: {}", + s.missing.join(", ") + )); + } + } + }); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn both_requirement_sets_are_compiled_in_and_look_right() { + // Requirement lines only. The comments legitimately discuss MS2PIP at + // length, explaining why it is absent, and a substring search over the whole + // file reads that prose as a dependency. + fn requirement_lines(text: &str) -> Vec<&str> { + text.lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#') && !l.starts_with("--")) + .collect() + } + let primary = requirement_lines(REQUIREMENTS_PRIMARY); + assert!( + primary.iter().any(|l| l.starts_with("deeplc==")), + "the primary set pins DeepLC: {primary:?}" + ); + assert!( + primary.iter().any(|l| l.starts_with("torch==")), + "the primary set pins torch: {primary:?}" + ); + // The whole reason there are two environments: they cannot be one. + assert!( + !primary.iter().any(|l| l.starts_with("ms2pip")), + "MS2PIP must not be in the primary set; it conflicts with DeepLC: {primary:?}" + ); + assert!( + requirement_lines(REQUIREMENTS_MS2PIP) + .iter() + .any(|l| l.starts_with("ms2pip==")), + "the optional set pins MS2PIP" + ); + } + + #[test] + fn the_two_environments_do_not_share_a_directory() { + // They must not: installing one would then half-overwrite the other. + assert_ne!(venv_dir(Env::Primary), venv_dir(Env::Ms2pip)); + assert_ne!( + requirements_name_of(Env::Primary), + requirements_name_of(Env::Ms2pip) + ); + } + + fn requirements_name_of(e: Env) -> &'static str { + e.requirements_name() + } + + #[test] + fn the_managed_paths_are_per_user_and_platform_shaped() { + let py = managed_python(Env::Primary); + let s = py.display().to_string(); + assert!(s.contains("MuMDIA"), "{s}"); + if cfg!(windows) { + assert!(s.ends_with("python.exe"), "{s}"); + assert!(s.contains("Scripts"), "{s}"); + } else { + assert!(s.ends_with("bin/python"), "{s}"); + } + // Never inside the installation directory, which is not user-writable on + // Windows. + let exe_dir = std::env::current_exe() + .ok() + .and_then(|e| e.parent().map(|p| p.to_path_buf())); + if let Some(d) = exe_dir { + assert!( + !py.starts_with(d), + "the environment must not live beside the exe" + ); + } + } + + #[test] + fn status_of_an_absent_environment_lists_everything_as_missing() { + // Nothing is installed under a temp HOME, so this exercises the cold path + // without touching a real installation. + let s = if managed_python(Env::Primary).is_file() { + eprintln!("a managed environment exists on this machine; checking the warm path"); + let s = status(); + assert!(s.python.is_some()); + return; + } else { + status() + }; + assert!(s.python.is_none()); + assert!(!s.complete); + assert_eq!(s.missing.len(), REQUIRED_MODULES.len()); + } + + #[test] + fn every_role_the_engine_defines_is_covered_by_the_required_modules() { + // The engine's own lists, transcribed from python.rs. If a role gains a + // dependency there and not here, the setup screen would report a complete + // installation that a run then fails on. + for role_modules in [ + vec!["torch", "numpy", "pandas", "pyarrow"], + vec!["mokapot", "sklearn", "numpy", "pandas", "pyarrow"], + vec!["deeplc", "numpy", "pandas", "pyarrow", "torch", "psm_utils"], + vec!["numpy", "pyarrow"], + ] { + for m in role_modules { + assert!( + REQUIRED_MODULES.contains(&m), + "{m} is imported by a sidecar role but is not installed" + ); + } + } + } + + /// MS2PIP is excluded on purpose, not by oversight. If someone "fixes" the list + /// by adding it, the environment stops resolving; this says so at test time + /// rather than at install time on a user's machine. + #[test] + fn ms2pip_is_deliberately_not_in_the_primary_environment() { + assert!( + !REQUIRED_MODULES.contains(&"ms2pip"), + "ms2pip==4.0.0 needs sqlalchemy<2 and deeplc==4.1.1 needs sqlalchemy>=2; they cannot share an environment" + ); + assert!(MS2PIP_MODULES.contains(&"ms2pip")); + } +} diff --git a/desktop/src-tauri/src/engine.rs b/desktop/src-tauri/src/engine.rs new file mode 100644 index 00000000..70ef699f --- /dev/null +++ b/desktop/src-tauri/src/engine.rs @@ -0,0 +1,175 @@ +//! Locating the engine binary and asking it what it is. +//! +//! The application ships the engine beside itself rather than linking it, so the +//! first question at startup is "where is it, and does it run". Resolution order +//! mirrors `ci/smoke.sh`'s `find_bin` in spirit: an explicit override wins, then the +//! bundled copy, then whatever is on PATH. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +use serde::Serialize; + +/// Where the bundler put this application's resources, once the shell knows. +/// +/// Needed because the answer is not "beside the executable" on every platform. An +/// MSI installs resources next to the exe; an AppImage mounts them under +/// `usr/lib//`, which no amount of `exe.parent()` will find. Tauri knows the +/// right directory, so `main` records it here at startup and the lookups below +/// consult it first. +/// +/// A `OnceLock` rather than threading the handle through every call: resolution +/// happens on background threads that have no `AppHandle`, and the value is set once +/// and never changes. +static RESOURCE_DIR: OnceLock = OnceLock::new(); + +/// Record the bundle's resource directory. Called once, from `main`. +pub fn set_resource_dir(dir: PathBuf) { + let _ = RESOURCE_DIR.set(dir); +} + +/// The bundle's resource directory, if the shell has told us. +pub fn resource_dir() -> Option<&'static PathBuf> { + RESOURCE_DIR.get() +} + +/// Name of the engine executable on this platform. +pub const EXE: &str = if cfg!(windows) { + "mumdia.exe" +} else { + "mumdia" +}; + +/// What the application knows about the engine it will run. +#[derive(Serialize, Clone, Debug)] +pub struct Info { + /// Absolute path to the binary that will be spawned. + pub path: String, + /// The `--version` line, verbatim. + pub version: String, + /// How the path was found, for the "why is it using that one" question. + pub source: &'static str, +} + +/// Every place the engine may live, in the order they are tried. +/// +/// `MUMDIA_BIN` first so a developer can point the application at a freshly built +/// engine without reinstalling it. Then the two bundled locations: Tauri puts +/// declared resources next to the executable, and the `binaries/` subdirectory is +/// where the release workflow stages the engine. PATH last, because a copy the user +/// installed separately is the least likely to be the one we were tested against. +fn candidates() -> Vec<(PathBuf, &'static str)> { + let mut out = Vec::new(); + + if let Some(p) = std::env::var_os("MUMDIA_BIN") { + out.push((PathBuf::from(p), "MUMDIA_BIN")); + } + // The bundler's own answer first, because on Linux it is the only correct one: + // an AppImage keeps resources under `usr/lib//`, not beside the executable. + if let Some(res) = resource_dir() { + out.push((res.join("binaries").join(EXE), "bundled")); + out.push((res.join(EXE), "bundled")); + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + out.push((dir.join(EXE), "bundled")); + out.push((dir.join("binaries").join(EXE), "bundled")); + // A `cargo run`/`cargo tauri build` binary sits in + // `desktop/target//`, which is three levels below the + // repository root, not four. Convenient during development and + // harmless in a release, where the path simply does not exist. + out.push(( + dir.join("../../../rust/mumdia/target/release").join(EXE), + "repository build", + )); + } + } + out +} + +/// Resolve the engine, or explain every place that was tried. +pub fn resolve() -> Result<(PathBuf, &'static str), String> { + let cands = candidates(); + for (path, source) in &cands { + if path.is_file() { + let abs = path.canonicalize().unwrap_or_else(|_| path.clone()); + return Ok((strip_unc(abs), source)); + } + } + if let Ok(path) = which_on_path() { + return Ok((path, "PATH")); + } + let tried: Vec = cands + .iter() + .map(|(p, s)| format!(" {} ({s})", p.display())) + .collect(); + Err(format!( + "could not find the {EXE} engine. Tried:\n{}\n and every directory on PATH.\n\ + Set MUMDIA_BIN to the engine binary, or reinstall the application.", + tried.join("\n") + )) +} + +/// `Path::canonicalize` returns a `\\?\C:\...` extended-length path on Windows, which +/// many programs render but few accept back. Strip the prefix so the path we display +/// is the path a user could paste into a terminal. +fn strip_unc(p: PathBuf) -> PathBuf { + let s = p.to_string_lossy().to_string(); + match s.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => p, + } +} + +fn which_on_path() -> Result { + let path = std::env::var_os("PATH").ok_or(())?; + for dir in std::env::split_paths(&path) { + let cand = dir.join(EXE); + if cand.is_file() { + return Ok(cand); + } + } + Err(()) +} + +/// Resolve the engine and ask it for its version. +/// +/// Running it is the point: a binary that exists but cannot execute (wrong +/// architecture, missing library, quarantined by antivirus) fails here, at startup, +/// rather than at the moment someone starts an hour-long search. +pub fn info() -> Result { + let (path, source) = resolve()?; + let out = command(&path) + .arg("--version") + .output() + .map_err(|e| format!("found {} but could not run it: {e}", path.display()))?; + if !out.status.success() { + return Err(format!( + "{} --version exited with {}", + path.display(), + out.status + )); + } + Ok(Info { + path: path.display().to_string(), + version: String::from_utf8_lossy(&out.stdout).trim().to_string(), + source, + }) +} + +/// A `Command` for the engine with the console window suppressed on Windows. +/// +/// Without this every engine invocation flashes a console window, including the +/// version probe at startup. +pub fn command(path: &Path) -> Command { + #[allow(unused_mut)] + let mut cmd = Command::new(path); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs new file mode 100644 index 00000000..4f990833 --- /dev/null +++ b/desktop/src-tauri/src/lib.rs @@ -0,0 +1,11 @@ +//! The console's logic, as a library so integration tests can drive a real search +//! without a window. +//! +//! `main.rs` is the Tauri shell over this: it owns the window, the command surface +//! and the run registry, and nothing else. + +pub mod components; +pub mod engine; +pub mod preflight; +pub mod run; +pub mod settings; diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs new file mode 100644 index 00000000..ab1990d2 --- /dev/null +++ b/desktop/src-tauri/src/main.rs @@ -0,0 +1,314 @@ +// A release build must not open a console window behind the application. Debug +// builds keep it, because that is where the developer's own `println!` goes. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +use mumdia_console::{components, engine, preflight as pf, run, settings}; + +use std::collections::HashMap; +use std::path::Path; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use tauri::Manager; + +#[derive(Default)] +struct AppState { + runs: Mutex>>, + next_id: AtomicU64, + installer: Arc, +} + +/// Which engine will be used, and does it execute. +#[tauri::command] +fn engine_info() -> Result { + engine::info() +} + +/// The state of the managed Python environment. +#[tauri::command] +fn components_status(state: tauri::State<'_, AppState>) -> serde_json::Value { + serde_json::json!({ + "primary": state.installer.refresh(components::Env::Primary), + // Reported separately because it is optional and cannot share the primary + // environment: MS2PIP and DeepLC pin incompatible sqlalchemy majors. + "ms2pip": state.installer.refresh(components::Env::Ms2pip), + }) +} + +/// Create the managed environment and install the analysis packages into it. +/// +/// Returns as soon as the work is under way; the interface polls +/// `components_status` for the log and the outcome. +#[tauri::command] +fn components_install( + state: tauri::State<'_, AppState>, + env: Option, +) -> Result<(), String> { + components::install( + Arc::clone(&state.installer), + env.unwrap_or(components::Env::Primary), + ) +} + +/// Everything that must be true before a search can start. +/// +/// Checked here rather than at the moment of starting, so the interface can explain +/// and offer a fix instead of reporting a failure. +#[tauri::command] +fn preflight( + state: tauri::State<'_, AppState>, + req: run::Request, +) -> Result { + let (exe, _) = engine::resolve()?; + let mut blockers: Vec = Vec::new(); + + // The minimal path is not offered. See `run::needs_no_sidecar` for why the + // predicate is "needs no sidecar at all" rather than anything about the + // rescorer. + match run::needs_no_sidecar(&exe, req.config.as_deref()) { + Ok(true) => blockers.push( + "This configuration would run without any of the analysis components, which \ + identifies far fewer peptides. Choose a preset that uses retention-time \ + modelling, or install the components." + .into(), + ), + Ok(false) => {} + // A configuration the engine cannot even read is a real problem, but it is + // the engine's message that says what is wrong with it. + Err(e) => blockers.push(e), + } + + let comp = state.installer.refresh(components::Env::Primary); + if !comp.complete { + blockers.push(format!( + "The analysis components are not installed{}.", + if comp.missing.is_empty() { + String::new() + } else { + format!(" (missing {})", comp.missing.join(", ")) + } + )); + } + + // Room on disk. The engine cannot resume, so filling the volume at hour three + // loses the whole search; this is the cheapest possible moment to notice. + let disk = pf::disk(&req.mzml, &req.out_dir); + let mut warnings: Vec = Vec::new(); + if !disk.unknown && !disk.enough { + let gb = |b: u64| format!("{:.1} GB", b as f64 / 1e9); + warnings.push(format!( + "This search may need about {} and the drive has {} free. A search cannot \ + resume, so running out part-way loses all of it.", + gb(disk.estimated_output_bytes), + gb(disk.free_bytes) + )); + } + + Ok(serde_json::json!({ + "ok": blockers.is_empty(), + "blockers": blockers, + "warnings": warnings, + "disk": disk, + "components_complete": comp.complete, + })) +} + +/// Past runs, read back from the folders they wrote. +/// +/// The interface remembers which folders it has used; the content of each entry +/// comes from the folder itself, so a run deleted or moved on disk simply stops +/// appearing rather than lingering as a stale row. +#[tauri::command] +fn history(dirs: Vec) -> Vec { + let mut out: Vec = dirs + .iter() + .filter_map(|d| run::history_entry(Path::new(d))) + .collect(); + // Newest first. + out.sort_by_key(|e| std::cmp::Reverse(e.finished_unix_ms)); + out +} + +/// Peaks per MS2 spectrum for a chosen file, so the peak cap can be set from the +/// file rather than from another acquisition. +#[tauri::command] +fn peak_census(mzml: String) -> Result { + pf::peak_census(&mzml) +} + +/// The settings schema the editor renders its form from. +#[tauri::command] +fn config_schema() -> Result { + settings::load_schema() +} + +/// Write an override set, then ask the engine whether it accepts it. +/// +/// Validating here rather than at run time is the point: a value the engine would +/// reject is reported next to the field while it is being edited. +#[tauri::command] +fn save_settings( + name: String, + overrides: std::collections::BTreeMap, +) -> Result { + let path = settings::save(&name, overrides)?; + settings::validate(&path)?; + Ok(path) +} + +/// Start a search. Returns the run id used by every subsequent call. +#[tauri::command] +fn start_run(state: tauri::State<'_, AppState>, req: run::Request) -> Result { + let id = format!("run-{}", state.next_id.fetch_add(1, Ordering::SeqCst) + 1); + let handle = run::start(id.clone(), req)?; + state + .runs + .lock() + .map_err(|_| "internal state is poisoned".to_string())? + .insert(id.clone(), handle); + Ok(id) +} + +/// Poll one run. The interface calls this on a timer; everything it displays is here. +#[tauri::command] +fn run_state(state: tauri::State<'_, AppState>, id: String) -> Option { + let runs = state.runs.lock().ok()?; + runs.get(&id).map(|r| r.snapshot()) +} + +/// Stop a run: kill the process tree, then sweep the temporary files it left. +#[tauri::command] +fn cancel_run(state: tauri::State<'_, AppState>, id: String) -> Result<(), String> { + let handle = { + let runs = state + .runs + .lock() + .map_err(|_| "internal state is poisoned".to_string())?; + runs.get(&id).cloned() + }; + match handle { + Some(r) => { + // Killing waits for SIGTERM to be given a chance on Unix, so do it off + // the command thread and let the interface keep polling meanwhile. + std::thread::spawn(move || r.cancel()); + Ok(()) + } + None => Err(format!("no such run: {id}")), + } +} + +/// Open a folder in the platform file manager. +#[tauri::command] +fn reveal(path: String) -> Result<(), String> { + let p = Path::new(&path); + if !p.exists() { + return Err(format!("{path} does not exist")); + } + #[cfg(windows)] + let r = std::process::Command::new("explorer").arg(p).spawn(); + #[cfg(target_os = "macos")] + let r = std::process::Command::new("open").arg(p).spawn(); + #[cfg(all(unix, not(target_os = "macos")))] + let r = std::process::Command::new("xdg-open").arg(p).spawn(); + // `explorer` returns a non-zero exit code even on success, so only the spawn + // itself is checked. + r.map(|_| ()) + .map_err(|e| format!("could not open {path}: {e}")) +} + +/// Configuration presets shipped beside the engine, for the input screen. +/// +/// Found rather than hard-coded: the release archive carries `configs/examples/`, and +/// listing what is actually there means a preset cannot be offered that does not +/// exist. An empty list is a valid answer and the interface says so. +#[tauri::command] +fn presets() -> Vec { + let mut out = Vec::new(); + let mut dirs = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + if let Some(d) = exe.parent() { + dirs.push(d.join("configs").join("examples")); + // Three levels from `desktop/target//` to the repository + // root, not four. + dirs.push(d.join("../../../configs/examples")); + } + } + for dir in dirs { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + let mut found: Vec<_> = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .collect(); + found.sort(); + for p in found { + let name = p + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("config") + .to_string(); + out.push(serde_json::json!({ + "name": name, + "path": p.display().to_string(), + })); + } + if !out.is_empty() { + break; + } + } + out +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_dialog::init()) + .manage(AppState::default()) + .setup(|app| { + // Tell the engine and component lookups where the bundler put things. + // Without this an AppImage cannot find its own engine: its resources live + // under `usr/lib//`, not beside the executable, so every + // `exe.parent()` candidate misses. + match app.path().resource_dir() { + Ok(dir) => engine::set_resource_dir(dir), + Err(e) => eprintln!("could not resolve the resource directory: {e}"), + } + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + engine_info, + components_status, + components_install, + config_schema, + save_settings, + peak_census, + history, + preflight, + start_run, + run_state, + cancel_run, + reveal, + presets + ]) + .on_window_event(|window, event| { + // Closing the window must not leave an engine, or a Python worker, running + // with no way to reach it. Every live run is stopped first. + if let tauri::WindowEvent::Destroyed = event { + if let Some(state) = window.app_handle().try_state::() { + let handles: Vec<_> = state + .runs + .lock() + .map(|r| r.values().cloned().collect()) + .unwrap_or_default(); + for h in handles { + if h.snapshot().status == "running" { + h.cancel(); + } + } + } + } + }) + .run(tauri::generate_context!()) + .expect("failed to start the MuMDIA console"); +} diff --git a/desktop/src-tauri/src/preflight.rs b/desktop/src-tauri/src/preflight.rs new file mode 100644 index 00000000..990a458e --- /dev/null +++ b/desktop/src-tauri/src/preflight.rs @@ -0,0 +1,170 @@ +//! Checks run before a search starts, so a problem is explained rather than hit. +//! +//! Everything here answers a question that is cheap now and expensive later. The +//! engine cannot resume: a run that fills the disk at hour three has lost the whole +//! search, and a peak cap carried from another acquisition silently deletes fragment +//! evidence rather than failing. + +use std::path::Path; + +use serde::Serialize; + +/// Rough output size for a single-run search, as a multiple of the mzML. +/// +/// From the fixture and the recorded benchmark runs, chromatogram extraction +/// dominates and the whole output lands within a small multiple of the input. This +/// is a guard rail, not a prediction: it exists to catch "you have 4 GB free and a +/// 12 GB input", which is the case that loses a day. +const OUTPUT_SIZE_MULTIPLE: u64 = 5; + +#[derive(Serialize, Default, Clone, Debug)] +pub struct Disk { + pub input_bytes: u64, + pub estimated_output_bytes: u64, + pub free_bytes: u64, + /// False when the estimate does not fit in the free space. + pub enough: bool, + /// True when free space could not be determined, in which case `enough` is not + /// a judgement and the interface should say nothing rather than guess. + pub unknown: bool, +} + +/// Free bytes on the volume holding `path`. +/// +/// Shelling out rather than calling the platform API, which would need `unsafe` in a +/// crate that has none. Both commands are present on a stock system and this runs +/// once per search, not in a loop. +fn free_bytes(path: &Path) -> Option { + #[cfg(windows)] + { + // `wmic` is gone from recent Windows, so use PowerShell's provider, which is + // present on every supported version. + let drive = path.components().next().map(|c| { + c.as_os_str() + .to_string_lossy() + .trim_end_matches('\\') + .to_string() + })?; + let drive = drive.trim_end_matches(':').to_string(); + let out = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + &format!("(Get-PSDrive -Name '{drive}').Free"), + ]) + .output() + .ok()?; + String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .ok() + } + #[cfg(unix)] + { + let out = std::process::Command::new("df") + .args(["-kP", &path.display().to_string()]) + .output() + .ok()?; + let text = String::from_utf8_lossy(&out.stdout); + // "Filesystem 1024-blocks Used Available Capacity Mounted" + let line = text.lines().nth(1)?; + let available_kb: u64 = line.split_whitespace().nth(3)?.parse().ok()?; + Some(available_kb * 1024) + } +} + +/// Is there room for this search's output? +pub fn disk(mzml: &str, out_dir: &str) -> Disk { + let input = std::fs::metadata(mzml).map(|m| m.len()).unwrap_or(0); + let estimate = input.saturating_mul(OUTPUT_SIZE_MULTIPLE); + + // The output directory may not exist yet, so ask about the nearest ancestor that + // does; free space is a property of the volume either way. + let mut probe = Path::new(out_dir).to_path_buf(); + while !probe.exists() { + match probe.parent() { + Some(p) if p != probe => probe = p.to_path_buf(), + _ => break, + } + } + + match free_bytes(&probe) { + Some(free) => Disk { + input_bytes: input, + estimated_output_bytes: estimate, + free_bytes: free, + enough: free >= estimate, + unknown: false, + }, + None => Disk { + input_bytes: input, + estimated_output_bytes: estimate, + free_bytes: 0, + enough: true, + unknown: true, + }, + } +} + +/// Peaks per MS2 spectrum for the chosen file, from the engine. +/// +/// The interface shows this next to the peak cap. `docs/04_convert.md` is emphatic +/// that a cap is acquisition-specific: on one 50-window Orbitrap DIA run a 300-peak +/// cap discarded 78.6% of MS2 peaks and cost 60% of the peptides. The application +/// has the file, so it can answer the question instead of asking a user to guess. +pub fn peak_census(mzml: &str) -> Result { + let (exe, _) = crate::engine::resolve()?; + let out = crate::engine::command(&exe) + .args(["peak-census", "--mzml", mzml, "--max-spectra", "2000"]) + .output() + .map_err(|e| format!("could not run the engine: {e}"))?; + serde_json::from_slice(&out.stdout).map_err(|_| { + String::from_utf8_lossy(&out.stderr) + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("the engine could not read this mzML") + .trim() + .to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn free_space_is_readable_for_a_directory_that_exists() { + let d = disk( + "does-not-exist.mzML", + &std::env::temp_dir().display().to_string(), + ); + // The input is missing, so the estimate is zero and there is trivially room; + // what this asserts is that the free-space probe itself works on this + // platform, because a silent `unknown` would disable the check for everyone. + assert!(!d.unknown, "free space could not be read on this platform"); + assert!(d.free_bytes > 0, "a real volume reports some free space"); + } + + #[test] + fn a_missing_output_directory_still_reports_its_volume() { + // The directory a user picks usually does not exist yet. + let missing = std::env::temp_dir().join("mumdia_no_such_dir_12345/deeper"); + let d = disk("does-not-exist.mzML", &missing.display().to_string()); + assert!(!d.unknown, "the nearest existing ancestor should answer"); + } + + #[test] + fn the_estimate_scales_with_the_input() { + let f = std::env::temp_dir().join("mumdia_preflight_probe.bin"); + std::fs::write(&f, vec![0u8; 1024]).unwrap(); + let d = disk( + &f.display().to_string(), + &std::env::temp_dir().display().to_string(), + ); + assert_eq!(d.input_bytes, 1024); + assert_eq!(d.estimated_output_bytes, 1024 * OUTPUT_SIZE_MULTIPLE); + let _ = std::fs::remove_file(&f); + } +} diff --git a/desktop/src-tauri/src/run.rs b/desktop/src-tauri/src/run.rs new file mode 100644 index 00000000..4d2c484d --- /dev/null +++ b/desktop/src-tauri/src/run.rs @@ -0,0 +1,932 @@ +//! Starting, watching and stopping one search. +//! +//! # Why the engine is a child process and not a linked library +//! +//! The engine is a library crate, so linking it and calling stages in-process looks +//! attractive: one binary, no path resolution, no version skew. It is the wrong +//! choice, for one decisive reason and two supporting ones. +//! +//! 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 would take the whole application down with it rather than ending one run, +//! and rayon's global pool can only be built once per process, so `--threads` could +//! not change between runs. +//! +//! # How progress is observed +//! +//! Not by parsing the log. Every stage writes `.report.json` beside its +//! output, carrying the producing stage, row count, elapsed time and per-stage +//! statistics. Polling the output directory for those files is a structured progress +//! feed that costs the engine nothing and works identically for a run this +//! application started and one it is merely looking at. + +use std::collections::BTreeMap; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::engine; + +/// How often the output directory is rescanned for stage reports. +const POLL: Duration = Duration::from_millis(700); +/// Log lines kept in memory. The pane shows the tail; the full log is on disk. +const LOG_TAIL: usize = 4000; + +/// What the interface asks for when it starts a search. +#[derive(Deserialize, Debug, Clone)] +pub struct Request { + pub mzml: String, + pub out_dir: String, + /// FASTA mode. Mutually exclusive with the library pair. + pub fasta: Option, + pub lib_precursors: Option, + pub lib_fragments: Option, + pub config: Option, + pub threads: Option, +} + +/// One stage, as observed from the artifact reports it produced. +/// +/// A stage can write several artifacts (`convert` writes four), so rows and elapsed +/// time are summed and the artifact count is kept, which is more honest than +/// reporting whichever file happened to be read last. +#[derive(Serialize, Clone, Debug, Default)] +pub struct Stage { + pub name: String, + pub rows: u64, + pub elapsed_ms: u64, + pub artifacts: usize, +} + +/// Everything the results panel shows, taken from the scored table's own report. +/// +/// Read from disk rather than recomputed: `psms_scored.parquet.report.json` records +/// the classifier that ACTUALLY ran alongside the one that was requested, and those +/// differ when a sidecar fails and `rescore.strict` is false. +#[derive(Serialize, Clone, Debug, Default)] +pub struct Results { + pub classifier: String, + pub classifier_requested: String, + pub config_hash: String, + pub peptides_1pct: u64, + pub precursors_1pct: u64, + pub protein_groups_1pct: u64, + pub psms: u64, + pub has_peptides_tsv: bool, + pub has_proteins_tsv: bool, +} + +/// The whole observable state of a run. Serialised to the interface on every poll. +#[derive(Serialize, Clone, Debug)] +pub struct Snapshot { + pub id: String, + /// `starting` | `running` | `done` | `failed` | `cancelled` + pub status: String, + pub exit_code: Option, + pub error: Option, + pub stages: Vec, + pub log: Vec, + pub out_dir: String, + /// The exact command line, so it can be shown, copied and reproduced. + pub command: String, + pub started_unix_ms: u64, + pub elapsed_ms: u64, + pub results: Option, + /// True in library-input mode, which skips digest, peptidoforms and predict-frag. + pub library_mode: bool, +} + +pub struct Run { + pub snapshot: Mutex, + /// Process id of the engine. On Unix this is also its process-group id, because + /// it is spawned into a new group. + pid: Mutex>, + cancelled: AtomicBool, +} + +impl Run { + fn set(&self, f: F) { + if let Ok(mut s) = self.snapshot.lock() { + f(&mut s); + } + } + + pub fn snapshot(&self) -> Snapshot { + self.snapshot + .lock() + .map(|s| s.clone()) + .unwrap_or_else(|e| e.into_inner().clone()) + } + + /// Stop the run: kill the process tree, then remove the rubble. + /// + /// Both halves matter. The engine spawns Python workers, so killing only the + /// engine would orphan a process that may hold tens of gigabytes. And a hard + /// kill skips destructors, so the atomic-write layer never removes its + /// `.tmp-` files; without a sweep the next run starts in a dirty directory. + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::SeqCst); + let pid = self.pid.lock().ok().and_then(|p| *p); + if let Some(pid) = pid { + kill_tree(pid); + } + let out_dir = self.snapshot().out_dir; + sweep_temp_files(Path::new(&out_dir)); + self.set(|s| { + if s.status == "running" || s.status == "starting" { + s.status = "cancelled".into(); + } + }); + } +} + +/// Kill a process and everything it spawned. +/// +/// Deliberately shelling out rather than calling the platform APIs directly: both +/// would need `unsafe`, and this is not on any hot path. `taskkill /T` walks the +/// tree at kill time and `kill` on a negative pid signals the whole group, so a +/// Python worker the application never knew about is included either way. +fn kill_tree(pid: u32) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/T", "/F"]) + .creation_flags(CREATE_NO_WINDOW) + .status(); + } + #[cfg(unix)] + { + // Signalling a process group is how the engine's Python workers get + // included, and it is also how you kill everything you are running inside + // if the group turns out to be your own. That is not hypothetical: an + // earlier version of this function, exercised by its own test, terminated a + // CI runner. + // + // So the group is verified before it is signalled. `Command::process_group` + // is asked for at spawn time, but if it did not take effect the child sits + // in OUR group, and a group kill would take down the application, the shell + // that started it, and on a shared machine whatever else shares that group. + // When the guard trips the child is still killed, just individually. + let target_pgid = pgid_of(pid); + let own_pgid = pgid_of(std::process::id()); + let group_is_safe = match (target_pgid, own_pgid) { + // A group of its own: signal the group, which is the whole point. + (Some(t), Some(o)) => t != o && t == pid, + // Unknown either way: do not guess with SIGKILL. + _ => false, + }; + let target = if group_is_safe { + format!("-{pid}") + } else { + pid.to_string() + }; + // TERM first so the engine can unwind and remove its own temp files, KILL + // shortly after for anything that ignored it. + let _ = std::process::Command::new("kill") + .args(["-TERM", &target]) + .status(); + std::thread::sleep(Duration::from_millis(1500)); + let _ = std::process::Command::new("kill") + .args(["-KILL", &target]) + .status(); + } +} + +/// The process-group id of `pid`, via `ps`, or `None` if it cannot be determined. +/// +/// Shelling out rather than calling `getpgid`, which would need `unsafe` in a crate +/// that has none. This runs twice per cancellation, not in a loop. +#[cfg(unix)] +fn pgid_of(pid: u32) -> Option { + let out = std::process::Command::new("ps") + .args(["-o", "pgid=", "-p", &pid.to_string()]) + .output() + .ok()?; + String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .ok() +} + +/// Remove `*.tmp-` files left by a killed run, recursively. +fn sweep_temp_files(dir: &Path) { + for f in walk(dir) { + if f.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.contains(".tmp-")) + { + let _ = std::fs::remove_file(&f); + } + } +} + +/// Every file under `dir`, recursively. Small hand-rolled walk to avoid a dependency +/// for one function; output directories are shallow. +fn walk(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { + continue; + }; + for e in entries.flatten() { + let p = e.path(); + match e.file_type() { + Ok(t) if t.is_dir() => stack.push(p), + Ok(t) if t.is_file() => out.push(p), + _ => {} + } + } + } + out +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// Build the argument list, and reject the input combinations the engine would +/// reject anyway — here, where the message can point at a field. +fn argv(req: &Request) -> Result, String> { + let lib = req.lib_precursors.is_some() || req.lib_fragments.is_some(); + if lib && req.fasta.is_some() { + return Err("choose either a FASTA or a spectral library, not both".into()); + } + if !lib && req.fasta.is_none() { + return Err("select a FASTA file or a spectral library".into()); + } + if lib && (req.lib_precursors.is_none() || req.lib_fragments.is_none()) { + return Err("a spectral library needs both the precursor and the fragment table".into()); + } + if req.mzml.trim().is_empty() { + return Err("select an mzML file".into()); + } + if req.out_dir.trim().is_empty() { + return Err("choose a folder for the results".into()); + } + + let mut a: Vec = vec!["run".into()]; + a.push("--mzml".into()); + a.push(req.mzml.clone()); + a.push("--out-dir".into()); + a.push(req.out_dir.clone()); + if let Some(f) = &req.fasta { + a.push("--fasta".into()); + a.push(f.clone()); + } + if let (Some(p), Some(g)) = (&req.lib_precursors, &req.lib_fragments) { + a.push("--lib-precursors".into()); + a.push(p.clone()); + a.push("--lib-fragments".into()); + a.push(g.clone()); + } + if let Some(c) = &req.config { + if !c.trim().is_empty() { + a.push("--config".into()); + a.push(c.clone()); + } + } + if let Some(t) = req.threads { + if t > 0 { + a.push("--threads".into()); + a.push(t.to_string()); + } + } + Ok(a) +} + +/// Quote an argument for display only. This string is shown and copied, never +/// executed, so it just has to be pasteable. +fn quote(s: &str) -> String { + if s.contains(' ') { + format!("\"{s}\"") + } else { + s.to_string() + } +} + +/// Does this request describe a search that needs no Python at all? +/// +/// That is the configuration the application refuses to run. The predicate is +/// deliberately narrow. The measured gap that motivates the refusal is about 1,213 +/// report rows against about 10,300 on the same file, and that is the fully native +/// FASTA path against the imported-library workflow -- but the rescorer is not what +/// separates them. On an imported library with retention-time modelling in place, +/// `native_tda` measured 10,847 against `nn_torch`'s 10,914, a difference of 0.6%. +/// Refusing every configuration that mentions `native_tda` would block one that is +/// within noise of the best. +/// +/// So the rule is "needs no sidecar at all", which is exactly the zero-component +/// path the 1,213 figure describes. +/// +/// The authority for this is the engine, not a list kept here: `mumdia doctor +/// --json` reports `required` per role from the configuration it is given, and if +/// every role is unrequired then the run is the minimal path. +pub fn needs_no_sidecar(engine: &Path, config: Option<&str>) -> Result { + let mut cmd = engine::command(engine); + cmd.arg("doctor").arg("--json"); + if let Some(c) = config { + if !c.trim().is_empty() { + cmd.arg("--config").arg(c); + } + } + let out = cmd + .output() + .map_err(|e| format!("could not ask the engine what this configuration needs: {e}"))?; + // `doctor` exits non-zero when the configuration cannot run, which is exactly + // the case where an interpreter is required and missing. The report on stdout is + // still valid and still says which roles are required, so the exit status is + // deliberately not checked here. + let text = String::from_utf8_lossy(&out.stdout); + let v: serde_json::Value = serde_json::from_str(&text) + .map_err(|e| format!("could not read the engine's configuration report: {e}"))?; + let roles = v + .get("roles") + .and_then(|r| r.as_array()) + .ok_or_else(|| "the engine's configuration report has no roles section".to_string())?; + Ok(!roles + .iter() + .any(|r| r.get("required").and_then(|b| b.as_bool()).unwrap_or(false))) +} + +/// Start a search. Returns immediately with a handle; progress arrives by polling. +pub fn start(id: String, req: Request) -> Result, String> { + let args = argv(&req)?; + let (exe, _source) = engine::resolve()?; + + std::fs::create_dir_all(&req.out_dir) + .map_err(|e| format!("cannot create the results folder {}: {e}", req.out_dir))?; + + let display = format!( + "{} {}", + quote(&exe.display().to_string()), + args.iter().map(|a| quote(a)).collect::>().join(" ") + ); + + let library_mode = req.lib_precursors.is_some(); + let run = Arc::new(Run { + snapshot: Mutex::new(Snapshot { + id: id.clone(), + status: "starting".into(), + exit_code: None, + error: None, + stages: Vec::new(), + log: Vec::new(), + out_dir: req.out_dir.clone(), + command: display, + started_unix_ms: now_ms(), + elapsed_ms: 0, + results: None, + library_mode, + }), + pid: Mutex::new(None), + cancelled: AtomicBool::new(false), + }); + + let mut cmd = engine::command(&exe); + cmd.args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .stdin(Stdio::null()); + + // A new process group, so cancelling can signal the engine AND the Python + // workers it spawns. Windows gets the same effect from `taskkill /T`. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + + let mut child = cmd + .spawn() + .map_err(|e| format!("could not start {}: {e}", exe.display()))?; + + let pid = child.id(); + if let Ok(mut p) = run.pid.lock() { + *p = Some(pid); + } + run.set(|s| s.status = "running".into()); + + // stderr carries the log; stdout carries result summaries. Both are shown. + for (stream, tag) in [ + ( + child + .stderr + .take() + .map(|s| Box::new(s) as Box), + "", + ), + ( + child + .stdout + .take() + .map(|s| Box::new(s) as Box), + "", + ), + ] { + let Some(stream) = stream else { continue }; + let run = Arc::clone(&run); + let _ = tag; + std::thread::spawn(move || { + let reader = BufReader::new(stream); + for line in reader.lines().map_while(Result::ok) { + run.set(|s| { + s.log.push(line); + if s.log.len() > LOG_TAIL { + let drop = s.log.len() - LOG_TAIL; + s.log.drain(0..drop); + } + }); + } + }); + } + + // Progress: rescan the output directory until the run stops. + { + let run = Arc::clone(&run); + let out_dir = PathBuf::from(&req.out_dir); + std::thread::spawn(move || { + let started = Instant::now(); + loop { + let stages = scan_stages(&out_dir); + let running = { + let s = run.snapshot(); + s.status == "running" || s.status == "starting" + }; + run.set(|s| { + s.stages = stages; + s.elapsed_ms = started.elapsed().as_millis() as u64; + }); + if !running { + // The final scan belongs to the waiter, not here: it has to happen + // BEFORE the status becomes terminal, or a caller that polls until + // the run is finished can read a snapshot whose stages and results + // have not been filled in yet. + break; + } + std::thread::sleep(POLL); + } + }); + } + + // Reap the child, then publish the terminal state in one step. + // + // The order matters. Everything a finished run displays -- its stages and its + // results -- is read from disk here, BEFORE the status stops being `running`. + // Doing it the other way round leaves a window in which the run says it is + // finished but has no stages, which an interface polling for completion will + // reliably catch: the results screen renders empty and then fills in. + { + let run = Arc::clone(&run); + let out_dir = PathBuf::from(&req.out_dir); + std::thread::spawn(move || { + let outcome = child.wait(); + let stages = scan_stages(&out_dir); + let results = read_results(&out_dir); + match outcome { + Ok(status) => run.set(|s| { + s.stages = stages; + s.results = results; + s.exit_code = status.code(); + if s.status == "cancelled" { + return; + } + if status.success() { + s.status = "done".into(); + } else { + s.status = "failed".into(); + // The last stderr line is almost always the anyhow error + // chain, which is the sentence worth showing. + s.error = s.log.iter().rev().find(|l| !l.trim().is_empty()).cloned(); + } + }), + Err(e) => run.set(|s| { + s.stages = stages; + s.status = "failed".into(); + s.error = Some(format!("could not wait for the engine: {e}")); + }), + } + }); + } + + Ok(run) +} + +/// One past run, reconstructed from what it left on disk. +/// +/// There is no history database. A finished run already carries a complete record +/// in its own output folder: `manifest.json` for the engine version, the commit and +/// the hashed inputs, and `psms_scored.parquet.report.json` for the counts and the +/// classifier that actually ran. Reading those back is both less code and more +/// honest than a separate index, which could disagree with the folder it describes. +#[derive(Serialize, Clone, Debug)] +pub struct HistoryEntry { + pub out_dir: String, + pub name: String, + pub finished_unix_ms: u64, + pub results: Option, + /// Present when the run wrote a manifest, which is every completed run. + pub engine_version: Option, +} + +/// Read one output directory as a history entry, or `None` if it is not one. +pub fn history_entry(dir: &Path) -> Option { + let scored = dir.join("psms_scored.parquet.report.json"); + let manifest = dir.join("manifest.json"); + if !scored.is_file() && !manifest.is_file() { + return None; + } + let finished = scored + .metadata() + .or_else(|_| manifest.metadata()) + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let engine_version = std::fs::read_to_string(&manifest) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .and_then(|v| { + v.get("mumdia_version") + .and_then(|s| s.as_str()) + .map(|s| s.to_string()) + }); + Some(HistoryEntry { + name: dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("run") + .to_string(), + out_dir: dir.display().to_string(), + finished_unix_ms: finished, + results: read_results(dir), + engine_version, + }) +} + +/// Fold every `*.report.json` under `dir` into one row per producing stage. +fn scan_stages(dir: &Path) -> Vec { + let mut by_stage: BTreeMap = BTreeMap::new(); + for f in walk(dir) { + if !f + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.ends_with(".report.json")) + { + continue; + } + let Ok(text) = std::fs::read_to_string(&f) else { + continue; + }; + let Ok(v) = serde_json::from_str::(&text) else { + continue; + }; + let Some(stage) = v.get("stage").and_then(|s| s.as_str()) else { + continue; + }; + let e = by_stage.entry(stage.to_string()).or_insert_with(|| Stage { + name: stage.to_string(), + ..Default::default() + }); + e.rows += v.get("rows").and_then(|r| r.as_u64()).unwrap_or(0); + e.elapsed_ms += v.get("elapsed_ms").and_then(|r| r.as_u64()).unwrap_or(0); + e.artifacts += 1; + } + by_stage.into_values().collect() +} + +/// Read the results panel out of the scored table's report. +fn read_results(dir: &Path) -> Option { + let path = dir.join("psms_scored.parquet.report.json"); + let text = std::fs::read_to_string(path).ok()?; + let v: serde_json::Value = serde_json::from_str(&text).ok()?; + let params = v.get("params"); + let stats = v.get("stats"); + let s = |o: Option<&serde_json::Value>, k: &str| -> String { + o.and_then(|p| p.get(k)) + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string() + }; + let n = |o: Option<&serde_json::Value>, k: &str| -> u64 { + o.and_then(|p| p.get(k)) + .and_then(|x| x.as_u64()) + .unwrap_or(0) + }; + Some(Results { + classifier: s(stats, "classifier"), + classifier_requested: s(params, "classifier_requested"), + config_hash: s(params, "config_hash"), + peptides_1pct: n(stats, "target_peptides_at_1pct"), + precursors_1pct: n(stats, "target_precursors_at_1pct"), + protein_groups_1pct: n(stats, "target_protein_groups_at_1pct"), + psms: n(stats, "psms"), + has_peptides_tsv: dir.join("peptides.tsv").is_file(), + has_proteins_tsv: dir.join("proteins.tsv").is_file(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn req() -> Request { + Request { + mzml: "a.mzML".into(), + out_dir: "out".into(), + fasta: None, + lib_precursors: None, + lib_fragments: None, + config: None, + threads: None, + } + } + + #[test] + fn fasta_and_library_together_is_rejected() { + let mut r = req(); + r.fasta = Some("p.fasta".into()); + r.lib_precursors = Some("p.parquet".into()); + r.lib_fragments = Some("f.parquet".into()); + let e = argv(&r).unwrap_err(); + assert!(e.contains("not both"), "{e}"); + } + + #[test] + fn a_search_space_is_required() { + let e = argv(&req()).unwrap_err(); + assert!(e.contains("FASTA") && e.contains("library"), "{e}"); + } + + #[test] + fn half_a_library_is_rejected() { + // The engine would reject this too, but only after starting up. Catching it + // here lets the message name the missing field. + let mut r = req(); + r.lib_precursors = Some("p.parquet".into()); + let e = argv(&r).unwrap_err(); + assert!(e.contains("both"), "{e}"); + } + + #[test] + fn fasta_mode_builds_the_documented_invocation() { + let mut r = req(); + r.fasta = Some("p.fasta".into()); + r.threads = Some(8); + r.config = Some("c.json".into()); + assert_eq!( + argv(&r).unwrap(), + vec![ + "run", + "--mzml", + "a.mzML", + "--out-dir", + "out", + "--fasta", + "p.fasta", + "--config", + "c.json", + "--threads", + "8", + ] + ); + } + + #[test] + fn library_mode_passes_both_tables_and_no_fasta() { + let mut r = req(); + r.lib_precursors = Some("p.parquet".into()); + r.lib_fragments = Some("f.parquet".into()); + let a = argv(&r).unwrap(); + assert!(a.contains(&"--lib-precursors".to_string())); + assert!(a.contains(&"--lib-fragments".to_string())); + assert!(!a.contains(&"--fasta".to_string())); + } + + #[test] + fn an_empty_config_is_omitted_rather_than_passed_as_an_empty_path() { + let mut r = req(); + r.fasta = Some("p.fasta".into()); + r.config = Some(" ".into()); + assert!(!argv(&r).unwrap().contains(&"--config".to_string())); + } + + /// Fold real artifact reports, written by a real run, into stage rows. + #[test] + fn stages_are_folded_from_real_artifact_reports() { + let dir = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/run_out"); + if !dir.is_dir() { + eprintln!("fixture missing, skipping"); + return; + } + let stages = scan_stages(&dir); + let names: Vec<&str> = stages.iter().map(|s| s.name.as_str()).collect(); + for expected in [ + "convert", + "digest", + "peptidoforms", + "predict-frag", + "search-seed", + "rt-im-train", + "extract", + "features", + "compete", + "rescore", + "quant", + ] { + assert!(names.contains(&expected), "missing {expected} in {names:?}"); + } + // `convert` writes four artifacts under spectra/; the walk must recurse and + // the four must fold into one row. + let convert = stages.iter().find(|s| s.name == "convert").unwrap(); + assert_eq!(convert.artifacts, 4); + assert_eq!(convert.rows, 8 + 480 + 60 + 480); + } + + #[test] + fn results_come_from_the_scored_report_not_from_the_request() { + let dir = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/run_out"); + if !dir.is_dir() { + eprintln!("fixture missing, skipping"); + return; + } + let r = read_results(&dir).expect("scored report should parse"); + assert_eq!(r.classifier, "native_tda"); + assert_eq!(r.peptides_1pct, 151); + assert_eq!(r.psms, 152); + assert!(r.has_peptides_tsv && r.has_proteins_tsv); + } + + /// The engine spawns Python workers, so a kill that reaches only the process we + /// launched would leave one behind holding tens of gigabytes. This spawns a + /// parent that spawns its own child and checks the kill lands. + /// + /// The end-to-end cancel test cannot cover this: the fixture search finishes in + /// under a second, faster than a stop can be issued. + /// The guard that stops a cancellation killing the application itself. + /// + /// This is the check whose absence terminated a CI runner: without it, + /// `kill_tree` would signal whatever group the child happened to be in, and if + /// that is our own group the kill reaches the process doing the killing. + #[cfg(unix)] + #[test] + fn a_process_in_our_own_group_is_never_group_killed() { + // A child spawned WITHOUT `process_group` inherits ours, which is exactly + // the situation the guard exists for. + let mut cmd = std::process::Command::new("sh"); + cmd.args(["-c", "sleep 30"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = cmd.spawn().expect("could not spawn the test process"); + let own = pgid_of(std::process::id()); + let theirs = pgid_of(child.id()); + assert_eq!( + own, theirs, + "a child spawned without process_group should share our group" + ); + // The decision the guard makes, without acting on it: this must NOT be a + // group kill. + let group_is_safe = match (theirs, own) { + (Some(t), Some(o)) => t != o && t == child.id(), + _ => false, + }; + assert!( + !group_is_safe, + "killing this group would kill the test process itself" + ); + let _ = child.kill(); + let _ = child.wait(); + } + + /// Opt-in with `MUMDIA_TEST_KILL=1`, and never in shared CI. + /// + /// This test 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 made a + /// group kill possible only when the child is verifiably in a group of its own, + /// and I cannot account for that. Two possibilities remain 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. + /// + /// What follows from not knowing is the gating, not a guess. 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 the thing it checks is verified on + /// Windows, where `taskkill /T` addresses a process tree rather than a group. + /// + /// The consequence to be honest about: the Unix group-kill path in `kill_tree` + /// is exercised by nothing automated. `a_process_in_our_own_group_is_never_group_killed` + /// covers the guard's decision without acting on it, which is the part that can + /// be tested safely. + #[test] + fn kill_tree_terminates_the_process_it_is_given() { + if std::env::var("MUMDIA_TEST_KILL").ok().as_deref() != Some("1") { + eprintln!("MUMDIA_TEST_KILL=1 not set; skipping (see the comment above)"); + return; + } + let mut cmd = if cfg!(windows) { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "ping -n 30 127.0.0.1"]); + c + } else { + let mut c = std::process::Command::new("sh"); + c.args(["-c", "sleep 30 & wait"]); + c + }; + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.process_group(0); + } + let mut child = cmd.spawn().expect("could not spawn the test process"); + std::thread::sleep(Duration::from_millis(400)); + assert!( + matches!(child.try_wait(), Ok(None)), + "the test process exited on its own; the test proves nothing" + ); + + kill_tree(child.id()); + + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + _ if start.elapsed() > Duration::from_secs(15) => { + let _ = child.kill(); + panic!("the process survived kill_tree"); + } + _ => std::thread::sleep(Duration::from_millis(100)), + } + } + } + + #[test] + fn the_temp_sweep_removes_only_temp_files() { + let dir = std::env::temp_dir().join(format!("mumdia_sweep_{}", std::process::id())); + let nested = dir.join("spectra"); + std::fs::create_dir_all(&nested).unwrap(); + let keep = dir.join("peptides.tsv"); + let kill = dir.join("psms_scored.parquet.tmp-12345"); + let kill_nested = nested.join("spectra_ms2.parquet.tmp-9"); + for f in [&keep, &kill, &kill_nested] { + std::fs::write(f, b"x").unwrap(); + } + sweep_temp_files(&dir); + assert!(keep.is_file(), "a real output must survive the sweep"); + assert!(!kill.is_file(), "the temp file must go"); + assert!(!kill_nested.is_file(), "the sweep must recurse"); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod history_tests { + use super::*; + + #[test] + fn a_finished_run_folder_reads_back_as_history() { + // The same real artifact reports the stage test uses: a history entry must + // come from the folder, not from anything the application remembered. + let dir = + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/run_out"); + if !dir.is_dir() { + eprintln!("fixture missing, skipping"); + return; + } + let e = history_entry(&dir).expect("a completed run folder is a history entry"); + assert_eq!(e.name, "run_out"); + let r = e.results.expect("results come from the scored report"); + assert_eq!(r.classifier, "native_tda"); + assert_eq!(r.peptides_1pct, 151); + assert!( + e.engine_version.is_some(), + "the manifest names the engine version" + ); + assert!(e.finished_unix_ms > 0); + } + + #[test] + fn a_folder_that_is_not_a_run_is_not_history() { + // A user picks output folders by hand, so the list will contain directories + // that never held a search. They must drop out rather than appear empty. + let dir = std::env::temp_dir().join(format!("mumdia_not_a_run_{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("notes.txt"), b"hello").unwrap(); + assert!(history_entry(&dir).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/desktop/src-tauri/src/settings.rs b/desktop/src-tauri/src/settings.rs new file mode 100644 index 00000000..73408632 --- /dev/null +++ b/desktop/src-tauri/src/settings.rs @@ -0,0 +1,254 @@ +//! The generated settings editor, and writing a configuration the engine accepts. +//! +//! # Why the form is generated +//! +//! There are 150 settings. An interface that restated their names, types, defaults +//! and help text would be a second copy of `config.rs`, and the copy that drifts is +//! the one a user reads. So the form is rendered from `configs/config-schema.json`, +//! which `ci/gen_config_reference.py` emits from the same parse that produces the +//! reference document, checked for staleness in CI beside it. +//! +//! # Why only overrides are written +//! +//! `Config` is `deny_unknown_fields` with serde defaults, so a valid configuration +//! contains only what differs from the default. Writing the full 150 keeps nothing +//! useful and freezes every default at the moment the file was saved: a later +//! release that improves a default would not reach anyone who had ever opened this +//! screen. Writing the difference keeps saved configurations short, reviewable, and +//! forward-compatible. + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// One setting, as the schema describes it. +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct Field { + pub path: String, + pub name: String, + pub section: String, + pub kind: String, + pub optional: bool, + #[serde(default)] + pub default: serde_json::Value, + #[serde(default)] + pub help: String, + #[serde(default)] + pub gates: Vec, + #[serde(default)] + pub choices: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct Schema { + pub sections: Vec, + pub fields: Vec, +} + +/// The settings schema, compiled in. +/// +/// Embedded rather than shipped beside the application, for two reasons. It cannot +/// then go missing from a bundle, which is a real failure mode: the first Windows +/// installer built here put `..`-rooted resources in a literal `_up_` directory +/// where nothing would have found them. And it costs nothing in freshness, because +/// the schema is generated from `config.rs` and any change to it requires a rebuild +/// anyway. +/// +/// `ci/gen_config_reference.py` writes this file and CI fails when it is stale, so +/// the compiled-in copy is the same one the reference document describes. +const SCHEMA_JSON: &str = include_str!("../../../configs/config-schema.json"); + +pub fn load_schema() -> Result { + serde_json::from_str(SCHEMA_JSON) + .map_err(|e| format!("the compiled-in settings schema could not be parsed: {e}")) +} + +/// Turn `{"extract.gate_min_score": 0.3}` into the nested JSON the engine reads. +/// +/// Only the paths present are written, so the result is the override set and +/// nothing else. +pub fn nest(flat: &BTreeMap) -> serde_json::Value { + let mut root = serde_json::Map::new(); + for (path, value) in flat { + let mut cursor = &mut root; + let parts: Vec<&str> = path.split('.').collect(); + for part in &parts[..parts.len().saturating_sub(1)] { + cursor = cursor + .entry((*part).to_string()) + .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())) + .as_object_mut() + .expect("intermediate config nodes are always objects"); + } + if let Some(last) = parts.last() { + cursor.insert((*last).to_string(), value.clone()); + } + } + serde_json::Value::Object(root) +} + +/// Where a configuration built in the interface is written. +/// +/// Under the per-user data directory rather than beside the results, so the same +/// settings can be reused across searches, and so a results folder stays a results +/// folder. +pub fn config_dir() -> PathBuf { + crate::components::data_dir().join("configs") +} + +/// Write an override set and hand back the path. +pub fn save(name: &str, flat: BTreeMap) -> Result { + let dir = config_dir(); + std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?; + // A name typed by a person becomes a filename, so keep it to something that is + // one on every platform. + let safe: String = name + .chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect(); + let safe = if safe.trim_matches('_').is_empty() { + "settings".to_string() + } else { + safe + }; + let path = dir.join(format!("{safe}.json")); + let text = serde_json::to_string_pretty(&nest(&flat)) + .map_err(|e| format!("could not serialise the settings: {e}"))?; + std::fs::write(&path, text + "\n") + .map_err(|e| format!("could not write {}: {e}", path.display()))?; + Ok(path.display().to_string()) +} + +/// Ask the engine whether a configuration file is acceptable. +/// +/// `doctor` loads the configuration through the same path a run does, so a value the +/// engine would reject is rejected here, while editing, rather than an hour into a +/// search. A missing interpreter is NOT a validation failure: that is what the setup +/// screen and the preflight component check are for, and conflating the two would +/// make every configuration look invalid until the components are installed. +pub fn validate(config_path: &str) -> Result<(), String> { + let (exe, _) = crate::engine::resolve()?; + let out = crate::engine::command(&exe) + .args(["doctor", "--config", config_path, "--json"]) + .output() + .map_err(|e| format!("could not run the engine: {e}"))?; + // A configuration the engine cannot even parse produces no JSON at all; that is + // the case worth reporting, and stderr carries the reason. + if serde_json::from_slice::(&out.stdout).is_err() { + let err = String::from_utf8_lossy(&out.stderr); + let line = err + .lines() + .rev() + .find(|l| !l.trim().is_empty() && !l.contains("INFO") && !l.contains("WARN")) + .unwrap_or("the engine rejected this configuration"); + return Err(line.trim().to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn flat(pairs: &[(&str, serde_json::Value)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.clone())) + .collect() + } + + #[test] + fn nesting_builds_the_shape_the_engine_reads() { + let v = nest(&flat(&[ + ("extract.gate_min_score", json!(0.3)), + ("extract.frag_tol_ppm", json!(20.0)), + ("rescore.classifier", json!("nn_torch")), + ("threads", json!(8)), + ])); + assert_eq!(v["extract"]["gate_min_score"], json!(0.3)); + assert_eq!(v["extract"]["frag_tol_ppm"], json!(20.0)); + assert_eq!(v["rescore"]["classifier"], json!("nn_torch")); + assert_eq!(v["threads"], json!(8)); + } + + #[test] + fn only_the_given_paths_appear() { + // The whole point: a saved configuration is the difference from the + // defaults, so a later release that improves a default still reaches a user + // who saved settings today. + let v = nest(&flat(&[("extract.gate_min_score", json!(0.3))])); + let obj = v.as_object().unwrap(); + assert_eq!(obj.len(), 1); + assert_eq!(obj["extract"].as_object().unwrap().len(), 1); + } + + #[test] + fn an_empty_override_set_is_an_empty_object() { + // Which is a valid configuration meaning "every default", not an error. + assert_eq!(nest(&BTreeMap::new()), json!({})); + } + + #[test] + fn a_hostile_name_cannot_escape_the_configuration_directory() { + let dir = config_dir(); + for name in ["../../evil", "a/b", "c:\\d", "..", ""] { + let p = save(name, BTreeMap::new()).expect("save should succeed"); + let p = PathBuf::from(p); + assert_eq!( + p.parent().map(|x| x.to_path_buf()), + Some(dir.clone()), + "{name:?} escaped to {}", + p.display() + ); + let _ = std::fs::remove_file(&p); + } + } + + /// The schema ships with the repository, so this runs everywhere the tests do. + #[test] + fn the_shipped_schema_parses_and_describes_real_settings() { + let Ok(s) = load_schema() else { + eprintln!("config-schema.json not found from this build; skipping"); + return; + }; + assert!( + s.fields.len() > 100, + "expected the full settings set, got {}", + s.fields.len() + ); + let gate = s + .fields + .iter() + .find(|f| f.path == "extract.gate_min_score") + .expect("a known setting should be present"); + assert_eq!(gate.kind, "float"); + assert_eq!(gate.default, json!(0.2)); + assert!( + !gate.help.is_empty(), + "help text should come from the doc comment" + ); + + let group_by = s + .fields + .iter() + .find(|f| f.path == "compete.group_by") + .expect("an enum setting should be present"); + assert_eq!(group_by.kind, "enum"); + let choices = group_by.choices.as_ref().expect("an enum has choices"); + assert!(choices.contains(&"base_peptide".to_string()), "{choices:?}"); + + // Gate markers are what stop a benchmark-gated parameter being changed as if + // it were ordinary. + assert!( + s.fields.iter().any(|f| !f.gates.is_empty()), + "some settings are documented as gated and should be marked" + ); + } +} diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json new file mode 100644 index 00000000..f3f49e2b --- /dev/null +++ b/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "MuMDIA", + "version": "0.1.0", + "identifier": "be.ugent.compomics.mumdia.console", + "build": { + "frontendDist": "../ui" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "MuMDIA", + "width": 1120, + "height": 800, + "minWidth": 900, + "minHeight": 620 + } + ], + "security": { + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:" + }, + "withGlobalTauri": true + }, + "bundle": { + "active": true, + "targets": [ + "msi", + "appimage" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/icon.ico" + ], + "shortDescription": "MuMDIA DIA search engine", + "longDescription": "Desktop interface for the MuMDIA data-independent acquisition proteomics search engine.", + "resources": { + "binaries/*": "binaries/" + } + } +} diff --git a/desktop/src-tauri/tests/end_to_end.rs b/desktop/src-tauri/tests/end_to_end.rs new file mode 100644 index 00000000..1eb62361 --- /dev/null +++ b/desktop/src-tauri/tests/end_to_end.rs @@ -0,0 +1,235 @@ +//! Drive a real search through the supervisor, with no window involved. +//! +//! This is the milestone-1 acceptance criterion expressed as a test: spawn the +//! engine, watch the artifact reports appear, and read the results back. It covers +//! the parts a person clicking buttons would exercise, minus the buttons. +//! +//! Skipped unless both are set, so it never fails a machine that has no engine: +//! +//! MUMDIA_BIN the engine binary +//! MUMDIA_TEST_MZML an mzML to search +//! MUMDIA_TEST_FASTA a FASTA to digest +//! +//! `ci/smoke.sh` generates a suitable fixture pair in its work directory. + +use std::time::{Duration, Instant}; + +/// Poll a run until it leaves the running state, or give up. +fn wait_for_finish( + run: &mumdia_console::run::Run, + limit: Duration, +) -> mumdia_console::run::Snapshot { + let start = Instant::now(); + loop { + let s = run.snapshot(); + if s.status != "running" && s.status != "starting" { + return s; + } + if start.elapsed() > limit { + panic!( + "run did not finish within {limit:?}; last status {}", + s.status + ); + } + std::thread::sleep(Duration::from_millis(200)); + } +} + +fn env(name: &str) -> Option { + std::env::var(name).ok().filter(|v| !v.trim().is_empty()) +} + +#[test] +fn a_fasta_search_runs_to_completion_and_reports_itself() { + let (Some(mzml), Some(fasta)) = (env("MUMDIA_TEST_MZML"), env("MUMDIA_TEST_FASTA")) else { + eprintln!("MUMDIA_TEST_MZML / MUMDIA_TEST_FASTA not set; skipping"); + return; + }; + if env("MUMDIA_BIN").is_none() { + eprintln!("MUMDIA_BIN not set; skipping"); + return; + } + + let out = std::env::temp_dir().join(format!("mumdia_console_e2e_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + + let req = mumdia_console::run::Request { + mzml, + out_dir: out.display().to_string(), + fasta: Some(fasta), + lib_precursors: None, + lib_fragments: None, + config: None, + threads: Some(2), + }; + + let run = mumdia_console::run::start("e2e".into(), req).expect("the engine should start"); + + // The displayed command must be the real invocation, because a user is invited + // to copy it into a terminal. + let cmd = run.snapshot().command; + assert!(cmd.contains(" run "), "{cmd}"); + assert!(cmd.contains("--fasta"), "{cmd}"); + assert!(cmd.contains("--threads 2"), "{cmd}"); + + let s = wait_for_finish(&run, Duration::from_secs(600)); + assert_eq!( + s.status, + "done", + "run failed: {:?}\n{}", + s.error, + s.log.join("\n") + ); + assert_eq!(s.exit_code, Some(0)); + + // Progress was actually observed while it ran, not reconstructed at the end. + let names: Vec<&str> = s.stages.iter().map(|x| x.name.as_str()).collect(); + for expected in ["convert", "search-seed", "extract", "rescore", "quant"] { + assert!( + names.contains(&expected), + "missing stage {expected}: {names:?}" + ); + } + + // The log is captured from the engine's stderr. + assert!(!s.log.is_empty(), "no log lines were captured"); + + // Results come from the scored table's own report. + let r = s + .results + .expect("results should be present after a successful run"); + assert!(!r.classifier.is_empty(), "the classifier should be named"); + assert!(r.psms > 0, "a successful fixture run scores some PSMs"); + assert!( + r.has_peptides_tsv, + "the report stage should have written peptides.tsv" + ); + + let _ = std::fs::remove_dir_all(&out); +} + +#[test] +fn stopping_a_run_kills_it_and_leaves_no_temp_files() { + let (Some(mzml), Some(fasta)) = (env("MUMDIA_TEST_MZML"), env("MUMDIA_TEST_FASTA")) else { + eprintln!("MUMDIA_TEST_MZML / MUMDIA_TEST_FASTA not set; skipping"); + return; + }; + if env("MUMDIA_BIN").is_none() { + eprintln!("MUMDIA_BIN not set; skipping"); + return; + } + + let out = std::env::temp_dir().join(format!("mumdia_console_cancel_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&out); + + let req = mumdia_console::run::Request { + mzml, + out_dir: out.display().to_string(), + fasta: Some(fasta), + lib_precursors: None, + lib_fragments: None, + config: None, + threads: Some(1), + }; + let run = mumdia_console::run::start("cancel".into(), req).expect("the engine should start"); + + // Long enough to be doing real work and holding files open, short enough that a + // small fixture has not finished. + std::thread::sleep(Duration::from_millis(400)); + run.cancel(); + + let s = wait_for_finish(&run, Duration::from_secs(60)); + + // The fixture search takes about two seconds, so on a fast machine it can finish + // before the cancel lands. Say so rather than asserting something that did not + // happen: a test that quietly passes without exercising the thing it names is + // worse than no test. Point it at real data and the cancellation path runs. + if s.status == "done" { + eprintln!( + "the run completed in {} ms, before the stop could land; cancellation was NOT exercised (use a larger input to cover it)", + s.elapsed_ms + ); + } else { + assert_eq!( + s.status, "cancelled", + "a stopped run reports itself as cancelled" + ); + assert_ne!(s.exit_code, Some(0), "a killed engine did not exit cleanly"); + } + + // A hard kill skips destructors, so the sweep is what keeps the folder clean. + let mut leftovers = Vec::new(); + let mut stack = vec![out.clone()]; + while let Some(d) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { + continue; + }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + stack.push(p); + } else if p.to_string_lossy().contains(".tmp-") { + leftovers.push(p); + } + } + } + assert!( + leftovers.is_empty(), + "temp files survived the sweep: {leftovers:?}" + ); + + let _ = std::fs::remove_dir_all(&out); +} + +/// Create the primary environment for real and check every role can import. +/// +/// This is the milestone-2 acceptance criterion: no conda, no pre-existing Python, +/// just the bundled installer and the pinned requirements. Opt in with +/// MUMDIA_TEST_INSTALL=1, because it downloads several hundred megabytes. +#[test] +fn the_primary_environment_installs_and_imports() { + if std::env::var("MUMDIA_TEST_INSTALL").ok().as_deref() != Some("1") { + eprintln!("MUMDIA_TEST_INSTALL=1 not set; skipping the real installation"); + return; + } + use mumdia_console::components::{self, Env}; + + assert!( + components::find_uv().is_some(), + "uv must be bundled or on PATH for the installer to work" + ); + assert!( + components::requirements(Env::Primary).is_ok(), + "the compiled-in requirements must be writable to the data directory" + ); + + let installer = std::sync::Arc::new(components::Installer::default()); + components::install(std::sync::Arc::clone(&installer), Env::Primary) + .expect("the installation should start"); + + let start = Instant::now(); + loop { + let s = installer.refresh(Env::Primary); + if s.install_status == "done" { + assert!(s.complete, "installed but not importable: {:?}", s.missing); + assert!( + s.versions.contains_key("torch") && s.versions.contains_key("deeplc"), + "the versions that change results should be reported: {:?}", + s.versions + ); + break; + } + if s.install_status == "failed" { + panic!( + "installation failed: {}\n{}", + s.error.unwrap_or_default(), + s.install_log.join("\n") + ); + } + assert!( + start.elapsed() < Duration::from_secs(1800), + "installation did not finish within 30 minutes" + ); + std::thread::sleep(Duration::from_secs(2)); + } +} diff --git a/desktop/src-tauri/tests/fixtures/run_out/cal.json b/desktop/src-tauri/tests/fixtures/run_out/cal.json new file mode 100644 index 00000000..a9411bc9 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/cal.json @@ -0,0 +1,19 @@ +{ + "calibration_status": "loess", + "holdout_resid_abs_median_s": null, + "holdout_resid_p_rt_s": null, + "intercept": 21.540443852217876, + "method": "loess", + "multiplier": 1.0, + "n_holdout": null, + "n_sizing_train": null, + "n_train": 110, + "p_rt": 0.95, + "rt_residual_abs_median_s": 1.29002288639483, + "rt_residual_mad_s": 1.2889020987967257, + "rt_residual_median_s": 0.011864247283511986, + "slope": 0.9453850141345133, + "w_rt": 11.826482451982308, + "w_rt_sizing": "in_sample", + "window_holdout_frac": 0.0 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/chromatograms.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/chromatograms.parquet.report.json new file mode 100644 index 00000000..961bbf4c --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/chromatograms.parquet.report.json @@ -0,0 +1,25 @@ +{ + "logical_name": "chromatograms", + "schema_name": "chromatograms", + "schema_version": 1, + "stage": "extract", + "rows": 2556, + "content_hash": "884d4dba966c1d4168180150ec9c09579d2cd883fac7b854a266a8c76dd63346", + "params": { + "effective_frag_tol_ppm": 5.0, + "frag_ppm_offset": -0.00026103398249893126, + "frag_tol_ppm": 20.0, + "gate_coelution_min": 0.5, + "gate_min_score": 0.2, + "gate_mode": "apex_pearson", + "presence_min_coelution": 2, + "presence_min_fragments": 3, + "scan_window": 3 + }, + "stats": { + "accepted": 284, + "scan_window": 3 + }, + "model_identity": null, + "elapsed_ms": 11 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/features.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/features.parquet.report.json new file mode 100644 index 00000000..f494283e --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/features.parquet.report.json @@ -0,0 +1,19 @@ +{ + "logical_name": "features", + "schema_name": "features", + "schema_version": 1, + "stage": "features", + "rows": 284, + "content_hash": "686820b41516510286c9c94f753940f26fb9a748435e7c9864f281a231267bf1", + "params": { + "coelution_corr_threshold": 0.9, + "set": "Extended" + }, + "stats": { + "feature_schema_id": "e5b4eff0133e411dbf406947c89466619ec557ab37874bc3948792d9890afe8e", + "n_features": 387, + "set": "Extended" + }, + "model_identity": null, + "elapsed_ms": 18 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/features.parquet.schema.json b/desktop/src-tauri/tests/fixtures/run_out/features.parquet.schema.json new file mode 100644 index 00000000..6b9230bc --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/features.parquet.schema.json @@ -0,0 +1,392 @@ +{ + "feature_columns": [ + "rt_error_abs", + "rt_error_rel", + "n_matched_fragments", + "coelution_run", + "log_apex_intensity", + "frag_corr", + "frag_cosine", + "spectral_angle", + "coelution_mean", + "coelution_best", + "n_coelution_above", + "charge", + "peptide_length", + "n_proteins", + "library_norm_manhattan", + "library_rmsd", + "xcorr_coelution", + "xcorr_shape", + "sum_b_intensity", + "sum_y_intensity", + "diff_by_intensity", + "n_b_ions", + "n_y_ions", + "weighted_mass_error", + "mean_mass_error", + "isotope_corr", + "ms1_isom1_ratio", + "log_mono_ms1", + "has_ms1", + "log_sn", + "n_observations", + "base_width_rt", + "seed_score", + "seed_identified", + "matched_fraction", + "profile_cos", + "ref_corr", + "best_ref_corr", + "low_frag_coel", + "evidence", + "contrast_min", + "resid_corr", + "coel_clean", + "shadow_frac", + "spectrum_cosine_matched", + "spectrum_cosine_sqrt", + "spectrum_cosine_log", + "spectral_angle_sqrt", + "spectral_angle_matched", + "pearson_intensity_matched", + "pearson_intensity_log", + "spearman_intensity", + "spearman_intensity_matched", + "kendall_tau_intensity", + "dot_product_raw", + "dot_product_norm", + "library_recall_intensity", + "manhattan_sim", + "manhattan_sqrt", + "rmsd_norm", + "mae_norm", + "mse_log", + "mae_weighted_pred", + "abs_diff_q3", + "max_positive_residual", + "chebyshev_dist", + "minkowski_p3", + "bray_curtis", + "bray_curtis_sqrt", + "canberra", + "canberra_matched", + "wave_hedges", + "chi_square_pearson", + "chi_square_symmetric", + "divergence_distance", + "bhattacharyya_coef", + "hellinger", + "squared_chord", + "harmonic_mean_sim", + "jaccard_presence", + "dice_presence", + "intensity_weighted_pearson", + "regression_slope", + "gini_diff", + "wasserstein_mz", + "footrule_norm", + "rank_overlap_top3", + "top1_frag_match", + "top1_predicted_observed", + "frac_top3_predicted_observed", + "count_strong_predicted_absent", + "frac_predicted_absent", + "cosine_area", + "pearson_area", + "spectral_angle_area", + "cosine_fullwindow", + "stein_scott_weighted_dot", + "log_dot_product", + "spectral_log_evidence", + "scribe_score", + "log_dot_product_area", + "spectral_log_evidence_area", + "scribe_score_area", + "cosine_high_ordinal", + "cosine_robust_trim1", + "cosine_robust_trim2", + "cosine_robust_trim3", + "spectral_entropy_similarity", + "weighted_spectral_entropy_similarity", + "spectral_entropy_similarity_sqrt", + "spectral_entropy_similarity_topk", + "spectral_entropy_similarity_area", + "jensen_shannon_divergence", + "jeffreys_divergence", + "kl_obs_pred", + "kl_pred_obs", + "cross_entropy_obs_pred", + "obs_spectrum_entropy", + "pred_spectrum_entropy", + "entropy_diff", + "entropy_ratio", + "obs_normalized_entropy", + "normalized_entropy_diff", + "residual_spectrum_entropy", + "entropy_weight_obs", + "frag_ref_corr_mean", + "frag_ref_corr_obsweighted", + "frag_ref_corr_min", + "frag_ref_corr_std", + "frag_ref_corr_sq_mean", + "frag_ref_corr_topk_weighted", + "n_frag_ref_corr_above_0_9", + "frac_frag_ref_corr_above_0_8", + "frag_ref_corr_mean_full", + "full_vs_peak_corr_gain", + "pairwise_coelution_weighted", + "pairwise_coelution_min", + "pairwise_coelution_median", + "pairwise_coelution_std", + "pairwise_coelution_frac_negative", + "pairwise_coelution_hi", + "pairwise_coelution_lo", + "coelution_hi_lo_contrast", + "coelution_corr_entropy", + "xcorr_shape_mean", + "xcorr_shape_min", + "xcorr_shape_std", + "xcorr_lag_mean_abs", + "xcorr_lag_std", + "xcorr_lag_iqr", + "xcorr_lag_frac_zero", + "xcorr_lag_max_abs", + "xcorr_lag_entropy", + "ref_xcorr_lag_mean", + "ref_xcorr_shape_mean", + "observed_sum_vs_template_corr", + "frag_loo_ref_corr_mean", + "frag_loo_ref_corr_min", + "frac_frags_apex_aligned", + "top3_frag_ref_corr", + "by_cross_coelution", + "by_cross_lag_mean", + "charge_cross_coelution", + "explained_variance_ref", + "profile_residual_fraction", + "n_interfered_fragments", + "corrected_vs_raw_cos", + "corrected_vs_raw_ratio", + "ifs_removed_count", + "ifs_removed_intensity_frac", + "ifs_corr_gain", + "ifs_retained_frac", + "matched_frac_after_ifs", + "peak_to_full_area_ratio_profile", + "peak_to_full_area_ratio_frag_mean", + "peak_to_full_area_ratio_weighted", + "out_of_peak_intensity_frac", + "profile_corr_full_vs_peak_delta", + "frac_frag_ref_corr_below_0_5", + "explained_apex_intensity_frac", + "apex_purity", + "interference_apex_residual_fraction", + "dominant_frag_ref_corr", + "explained_variance_ratio", + "second_component_fraction", + "profile_second_peak_ratio", + "n_competing_peaks_in_window", + "matched_pred_intensity_fraction", + "top_pred_frag_matched", + "gaussian_fit_r2", + "gaussian_cosine", + "emg_fit_improvement", + "apex_prominence", + "profile_peak_snr", + "fwhm_seconds", + "fwhm_to_window_ratio", + "width_at_10pct", + "width_ratio_10_50", + "hwhm_asymmetry", + "tailing_factor_usp", + "asymmetry_factor_10pct", + "apex_sharpness", + "apex_curvature", + "apex_to_boundary_ratio", + "apex_dominance", + "zigzag_index", + "jaggedness", + "roughness_2nd_deriv", + "n_local_maxima", + "modality", + "rt_skewness", + "rt_excess_kurtosis", + "rt_std_seconds", + "mean_mode_offset", + "fraction_area_within_fwhm", + "triangle_area_similarity", + "baseline_fraction", + "peak_completeness", + "apex_centering_offset", + "intensity_score", + "total_xic_log", + "frag_fwhm_cv", + "frag_fwhm_mean", + "frag_apex_rt_dispersion", + "frag_apex_rt_dispersion_weighted", + "frag_apex_offset_from_profile_mean", + "frag_gaussianity_mean", + "frag_gaussianity_weighted", + "frag_zigzag_mean", + "sumtrace_unweighted_gaussian_r2", + "reference_profile_rt_entropy_peak", + "reference_profile_rt_entropy_ratio", + "median_abs_frag_ppm", + "signed_mean_frag_ppm", + "ppm_std", + "ppm_iqr", + "ppm_range", + "max_abs_frag_ppm", + "intensity_weighted_abs_ppm", + "intensity_weighted_signed_ppm", + "intensity_weighted_ppm_std", + "lib_weighted_abs_ppm", + "frac_frag_within_half_tol", + "high_ppm_intensity_frac", + "ppm_intensity_anticorr", + "mass_error_mz_trend", + "mean_abs_mz_error_da", + "mass_evidence_gauss", + "mass_log_evidence", + "n_matched_b", + "n_matched_y", + "frac_matched_b", + "frac_matched_y", + "by_count_balance", + "by_intensity_ratio", + "by_ratio_agreement", + "by_ratio_consistency", + "longest_b_run", + "longest_y_run", + "longest_run_max", + "longest_run_frac_length", + "series_coverage_b", + "series_coverage_y", + "sequence_coverage", + "series_gap_fraction", + "by_complement_count", + "by_complement_mz_consistency", + "by_complement_coelution", + "ordinal_intensity_concordance_y", + "ordinal_intensity_concordance_b", + "series_coelution_y", + "series_coelution_b", + "spectral_angle_b", + "spectral_angle_y", + "pearson_b", + "pearson_y", + "cosine_charge1", + "cosine_charge2", + "charge_corr_balance", + "mean_matched_ordinal_norm", + "by_ion_contiguous_intensity", + "by_ion_contiguous_lib_frac", + "both_series_present", + "ms1_isotope_cosine_apex", + "ms1_isotope_spectral_angle_apex", + "ms1_isotope_chi2_apex", + "ms1_isotope_manhattan_apex", + "iso_ratio_1_0", + "iso_ratio_2_0", + "iso_plus1_ratio_dev", + "iso_plus2_ratio_dev", + "iso_minus_one_fraction", + "iso_overlap_flag", + "log_ms1_mono", + "ms1_total_isotope_log", + "has_ms1_signal", + "ms1_isotope_apex_entropy_3", + "ms1_m1_entropy_contribution", + "ms1_ms2_time_corr", + "ms1_ms2_envelope_time_corr", + "ms1_iso_coelution", + "ms1_ms2_apex_rt_delta", + "ms1_iso_ratio_stability", + "ms1_mono_gaussianity", + "ms1_ms2_fwhm_ratio", + "ms1_isotope_corr_xic", + "ms1_envelope_over_time_corr", + "ms1_isotope_xic_shape_consistency", + "ms1_isotope_height_corr", + "rt_error_signed", + "rt_error_squared", + "rt_error_signed_norm_gradient", + "rt_error_abs_norm_gradient", + "observed_rt_raw", + "predicted_rt_raw", + "observed_rt_fraction", + "predicted_rt_fraction", + "rt_error_over_peak_width", + "rt_error_over_fwhm", + "rt_diff_profile_apex", + "predicted_rt_in_gradient", + "log_seed_hyperscore", + "seed_hyperscore_per_matched", + "precursor_charge", + "charge_is_2", + "charge_is_3", + "charge_is_4plus", + "precursor_mass", + "log_total_matched_intensity", + "n_matched_frags", + "n_predicted_frags", + "frag_corr_peakmax", + "frag_cosine_peakmax", + "spectral_angle_peakmax", + "frag_corr_matched_nz", + "frag_cosine_matched_nz", + "peakmax_apex_gain", + "n_frag_present_inpeak", + "frac_frag_present_inpeak", + "coelution_mean_bothpos", + "coelution_mean_summpos", + "ref_corr_nz", + "profile_cos_nz", + "rank_corr_vs_apex_mean", + "rank_corr_vs_apex_std", + "rank_corr_adjacent_mean", + "kendall_vs_apex_mean", + "top1_frag_persistence", + "top2_order_persistence", + "argmax_frag_entropy", + "self_cosine_vs_apex_mean", + "n_peak_scans", + "peak_window_degenerate", + "frag_apex_rt_std", + "frag_apex_rt_mad", + "frag_apex_max_dev", + "frag_apex_mean_dev", + "frag_apex_agree_frac", + "precursor_frag_apex_delta", + "peak_symmetry", + "peak_tailing", + "peak_n_local_maxima", + "peak_shoulder_score", + "peak_fwhm_scans", + "peak_truncation", + "apex_frac_of_window", + "frag_mass_err_median", + "frag_mass_err_abs_median", + "frag_mass_err_std", + "frag_mass_err_iqr", + "frag_mass_err_max_abs", + "frag_mass_err_range", + "effective_frag_count", + "evidence_concentration", + "frac_top3_pred_observed", + "frac_top5_pred_observed", + "deconv_explained_frac", + "deconv_active", + "deconv_share", + "deconv_max_collinearity", + "shadow_kept_frac", + "peak_contested_frac", + "peak_contested_count_frac", + "peak_apportioned_frac", + "n_charge_states", + "charge_multi_flag", + "cross_charge_intensity_log" + ], + "schema_id": "e5b4eff0133e411dbf406947c89466619ec557ab37874bc3948792d9890afe8e" +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/fragment_library_fragments.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/fragment_library_fragments.parquet.report.json new file mode 100644 index 00000000..4fbeecc6 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/fragment_library_fragments.parquet.report.json @@ -0,0 +1,21 @@ +{ + "logical_name": "fragment_library_fragments", + "schema_name": "fragment_library_fragments", + "schema_version": 1, + "stage": "predict-frag", + "rows": 22920, + "content_hash": "b1e6fd23482df40ddd81f13c133eab8d44c28016004a56eb8f0b6efd1c03291b", + "params": { + "fragment_predictor": "Native", + "ms2pip_model": "HCD", + "rt_predictor": "Native", + "top_n": 6 + }, + "stats": { + "candidates": 3820, + "fragments": 22920, + "parse_errors": 0 + }, + "model_identity": "native-rt-v1; native-frag-v1", + "elapsed_ms": 15 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/fragment_library_precursors.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/fragment_library_precursors.parquet.report.json new file mode 100644 index 00000000..b6ce34f3 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/fragment_library_precursors.parquet.report.json @@ -0,0 +1,21 @@ +{ + "logical_name": "fragment_library_precursors", + "schema_name": "fragment_library_precursors", + "schema_version": 1, + "stage": "predict-frag", + "rows": 3820, + "content_hash": "9516998ec891009f8131080baac32c108d58dbff11f382556601d54f084d9394", + "params": { + "fragment_predictor": "Native", + "ms2pip_model": "HCD", + "rt_predictor": "Native", + "top_n": 6 + }, + "stats": { + "candidates": 3820, + "fragments": 22920, + "parse_errors": 0 + }, + "model_identity": "native-rt-v1; native-frag-v1", + "elapsed_ms": 15 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/fragment_quant.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/fragment_quant.parquet.report.json new file mode 100644 index 00000000..1eb5cc38 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/fragment_quant.parquet.report.json @@ -0,0 +1,40 @@ +{ + "logical_name": "fragment_quant", + "schema_name": "fragment_quant", + "schema_version": 1, + "stage": "quant", + "rows": 906, + "content_hash": "0f5ef625af43bf8a88cf09fb24c185951674666d42a427eed2ab2aea39b5c5e7", + "params": { + "apex_rt_column_present": true, + "baseline_flank_scans": 12, + "baseline_quantile": 0.25, + "baseline_subtract": false, + "bound_peak": true, + "candidates_with_scored_apex": 152, + "chromatograms": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/chromatograms.parquet", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2", + "fixed_scan_halfwidth": 0, + "fixed_window_s": 0.0, + "fragment_selection": "ObservedArea", + "peak_fraction": 0.16666666666666666, + "peak_grace": 1, + "peak_window_mode": "PerCandidate", + "psms_scored": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/psms_scored.parquet", + "q_filter": "PeptideQ", + "q_threshold": 0.01, + "reliable_q": 0.001, + "rollup": "TopNSum", + "top_n_fragments": 3, + "top_n_peptides": 3 + }, + "stats": { + "nonquantifiable_peptides": 0, + "peptide_rows": 151, + "protein_group_rows": 16, + "quantified_peptides": 151, + "quantified_protein_groups": 16 + }, + "model_identity": null, + "elapsed_ms": 4 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/manifest.json b/desktop/src-tauri/tests/fixtures/run_out/manifest.json new file mode 100644 index 00000000..dc9ba80d --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/manifest.json @@ -0,0 +1,239 @@ +{ + "mumdia_version": "0.1.0", + "git_sha": "8ea79be03399-dirty", + "commit_date": "2026-08-29T07:25:51+02:00", + "cli_args": [ + "C:\\Users\\robbi\\mumdia_build\\release\\mumdia.exe", + "run", + "--fasta", + "test_data/fixture.fasta", + "--mzml", + "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/fixture.mzML", + "--out-dir", + "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out", + "--config", + "configs/examples/native.json", + "--threads", + "2" + ], + "config_json": "{\"prescan\":{\"tol_da\":0.005,\"rt_slack_s\":150.0,\"rt_bin_s\":25.0,\"top_peaks\":150,\"mods\":[\"C:Carbamidomethyl\",\"M:Oxidation\"],\"anchor_mods\":[]},\"rng_seed\":0,\"digest\":{\"enzyme\":\"trypsin_p\",\"missed_cleavages\":2,\"min_len\":5,\"max_len\":50,\"decoy\":{\"strategy\":\"reverse\"},\"n_term_met_excision\":true},\"peptidoforms\":{\"fixed_mods\":[{\"residue\":\"C\",\"name\":\"Carbamidomethyl\"}],\"variable_mods\":[{\"residue\":\"M\",\"name\":\"Oxidation\"}],\"max_variable_mods\":1,\"charge_min\":2,\"charge_max\":3,\"charge_by_basic_residues\":false,\"unknown_modification\":\"error\"},\"predict_frag\":{\"predictor\":\"native\",\"rt_predictor\":\"native\",\"charge2_from_precursor_charge\":2,\"charge_by_basic_residues\":false,\"top_n_fragments\":6,\"ms2pip_model\":\"HCD\",\"ms2pip_python\":null,\"deeplc_python\":null,\"sidecar_script_dir\":\"scripts\"},\"search_seed\":{\"fdr_seed\":0.01,\"fragment_tol_ppm\":20.0,\"report_psms\":5,\"min_matched_peaks\":4,\"top_n_peaks\":300,\"matcher\":\"fragindex\",\"two_pass_mass_cal\":false,\"mass_cal_loess\":false},\"rt_im_train\":{\"calibration_method\":\"loess\",\"q_train\":0.01,\"p_rt\":0.95,\"rt_window_multiplier\":1.0,\"min_seed_for_calibration\":50,\"loess_span\":0.3,\"fallback_rt_window_s\":120.0,\"finetune_deeplc\":false,\"finetune_epochs\":25,\"finetune_patience\":10,\"finetune_batch\":0,\"adaptive_rt_window\":false,\"adaptive_rt_bins\":12,\"rt_window_min_s\":1.0,\"window_holdout_frac\":0.0},\"extract\":{\"fixed_scan_window\":3,\"frag_tol_ppm\":20.0,\"prec_tol_ppm\":20.0,\"presence_min_matched\":3,\"presence_min_fragments\":3,\"presence_min_coelution\":2,\"gate_min_score\":0.2,\"min_matched_fraction\":0.0,\"apex_top_fragments\":0,\"apex_rt_prior_s\":120.0,\"apex_count_tol\":1,\"apex_count_window\":5,\"apex_gaussian_sigma_scans\":0.0,\"emit_window_grid\":true,\"bucket_size\":8192,\"peak_claim\":\"none\",\"claim_cues\":{\"mz_close\":false,\"mz_close_sigma_ppm\":5.0,\"rt_prior\":false,\"rt_prior_tau_s\":30.0,\"ms1_support\":false,\"reassign\":false,\"apportion_em_iters\":0},\"emit_demix_features\":false,\"demix_lambda\":1.0,\"demix_max_candidates\":64,\"demix_scan_stride\":1,\"emit_contested_features\":false,\"peak_claim_margin\":2.0,\"matcher\":\"fragindex\",\"min_coelution_run\":0,\"ms1_rescue\":false,\"retain_top_peaks\":1,\"promote_top_peaks\":1,\"alt_peak_min_area_frac\":0.1,\"alt_peak_min_separation_s\":5.0,\"emit_candidate_audit\":false,\"apex_evidence_rank\":true,\"emit_gate_diagnostics\":false,\"gate_mode\":\"apex_pearson\",\"gate_coelution_min\":0.5},\"features\":{\"set\":\"extended\",\"emit_pin\":false,\"coelution_corr_threshold\":0.9,\"prec_tol_ppm\":20.0,\"bound_features\":true,\"bound_peak_fraction\":0.3333333333333333,\"bound_peak_grace\":0,\"bound_from_confident\":true,\"bound_confident_pct\":50.0,\"ms1_precursor_features\":false},\"compete\":{\"group_by\":\"base_peptide\",\"apex_rt_tolerance_s\":5.0,\"mode\":\"winner_take_all\",\"margin\":0.0,\"unique_evidence_min_fragments\":2,\"emit_competition_audit\":false},\"rescore\":{\"classifier\":\"native_tda\",\"folds\":3,\"train_fdr\":0.01,\"num_iter\":10,\"max_feature_matrix_gib\":0.0,\"python\":null,\"percolator_bin\":null,\"entrapment_marker\":null,\"entrapment_exclude\":null,\"entrapment_contaminant_markers\":[],\"entrapment_ratio\":1.0,\"strict\":true,\"handoff\":\"tsv\"},\"quant\":{\"q_threshold\":0.01,\"top_n_fragments\":3,\"top_n_peptides\":3,\"rollup\":\"top_n_sum\",\"bound_peak\":true,\"peak_fraction\":0.16666666666666666,\"peak_grace\":1,\"peak_window_mode\":\"per_candidate\",\"reliable_q\":0.001,\"q_filter\":\"peptide_q\",\"interference_envelope\":false,\"fragment_selection\":\"observed_area\",\"fixed_scan_halfwidth\":0,\"baseline_subtract\":false,\"baseline_flank_scans\":12,\"baseline_quantile\":0.25,\"fixed_window_s\":0.0},\"mbr\":{\"strategy\":\"none\",\"q_anchor\":0.01,\"min_anchor_runs\":2,\"q_transfer\":0.01,\"rt_window_s\":20.0,\"decoy_transfer\":\"permuted_rt\",\"consensus_corr_min\":0.0,\"requant_all\":false,\"python\":null},\"experiment\":{\"parallel_runs\":1,\"finetune_scope\":\"first_run_only\"}}", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2", + "model_identities": { + "feature_schema_id": "e5b4eff0133e411dbf406947c89466619ec557ab37874bc3948792d9890afe8e", + "fragment_predictor": "Native", + "rescorer": "native-percolator-lite-v1", + "rt_predictor": "Native" + }, + "inputs": { + "fasta": { + "path": "test_data/fixture.fasta", + "bytes": 2670, + "content_hash": "ff9ba5b1e9367f8641bec0a9e7eb438c62991ef935acf44c2c48618c73d06f2a" + }, + "mzml": { + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/fixture.mzML", + "bytes": 1761106, + "content_hash": "f5187d0c1d35be705377d9db90ce0fae5d22ae062c02d42cd1a63dd291907cf9" + } + }, + "artifacts": { + "chromatograms": { + "logical_name": "chromatograms", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/chromatograms.parquet", + "format": "parquet", + "schema_name": "chromatograms", + "schema_version": 1, + "rows": 2556, + "content_hash": "884d4dba966c1d4168180150ec9c09579d2cd883fac7b854a266a8c76dd63346", + "producing_stage": "extract", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "features": { + "logical_name": "features", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/features.parquet", + "format": "parquet", + "schema_name": "features", + "schema_version": 1, + "rows": 284, + "content_hash": "686820b41516510286c9c94f753940f26fb9a748435e7c9864f281a231267bf1", + "producing_stage": "features", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "fragment_library_fragments": { + "logical_name": "fragment_library_fragments", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/fragment_library_fragments.parquet", + "format": "parquet", + "schema_name": "fragment_library_fragments", + "schema_version": 1, + "rows": 22920, + "content_hash": "b1e6fd23482df40ddd81f13c133eab8d44c28016004a56eb8f0b6efd1c03291b", + "producing_stage": "predict-frag", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "fragment_library_precursors": { + "logical_name": "fragment_library_precursors", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/fragment_library_precursors.parquet", + "format": "parquet", + "schema_name": "fragment_library_precursors", + "schema_version": 1, + "rows": 3820, + "content_hash": "9516998ec891009f8131080baac32c108d58dbff11f382556601d54f084d9394", + "producing_stage": "predict-frag", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "fragment_quant": { + "logical_name": "fragment_quant", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/fragment_quant.parquet", + "format": "parquet", + "schema_name": "fragment_quant", + "schema_version": 1, + "rows": 906, + "content_hash": "0f5ef625af43bf8a88cf09fb24c185951674666d42a427eed2ab2aea39b5c5e7", + "producing_stage": "quant", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "isolation_windows": { + "logical_name": "isolation_windows", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/spectra/isolation_windows.parquet", + "format": "parquet", + "schema_name": "isolation_windows", + "schema_version": 1, + "rows": 8, + "content_hash": "3ceab06d7b4d1ed3d814722235920d2677bb6877535fa02767a67519de0ea32f", + "producing_stage": "convert", + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b" + }, + "ms2_to_ms1": { + "logical_name": "ms2_to_ms1", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/spectra/ms2_to_ms1.parquet", + "format": "parquet", + "schema_name": "ms2_to_ms1", + "schema_version": 1, + "rows": 480, + "content_hash": "c60988ab4d075d9655fb7406169ee92e804c7dff05b42124e818ccc9f249346f", + "producing_stage": "convert", + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b" + }, + "peptide_quant": { + "logical_name": "peptide_quant", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/peptide_quant.parquet", + "format": "parquet", + "schema_name": "peptide_quant", + "schema_version": 2, + "rows": 151, + "content_hash": "db6dee23c4e5f1b40fad156b10a16f3a3748b3c83016770370933282ee36af88", + "producing_stage": "quant", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "peptides": { + "logical_name": "peptides", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/peptides.parquet", + "format": "parquet", + "schema_name": "peptides", + "schema_version": 1, + "rows": 876, + "content_hash": "b88380d3934a4f87e4c65b900f1a8e2edfc156eb599f967859a4f70d91ef7106", + "producing_stage": "digest", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "peptidoforms": { + "logical_name": "peptidoforms", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/peptidoforms.parquet", + "format": "parquet", + "schema_name": "peptidoforms", + "schema_version": 1, + "rows": 3820, + "content_hash": "b8c0df35a3ba1faa0ac44736aa95e31563735a93b6b5f99c9a0749b2445b04f3", + "producing_stage": "peptidoforms", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "protein_group_quant": { + "logical_name": "protein_group_quant", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/protein_group_quant.parquet", + "format": "parquet", + "schema_name": "protein_group_quant", + "schema_version": 2, + "rows": 16, + "content_hash": "1eb8ddb19566390bc59e17ede88b0d7264d48b3831079346b74c73aeaa8b5920", + "producing_stage": "quant", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "psms_competed": { + "logical_name": "psms_competed", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/psms_competed.parquet", + "format": "parquet", + "schema_name": "psms_competed", + "schema_version": 3, + "rows": 152, + "content_hash": "f5d11d59f94a46211bcddcf5b2ce49de95ed4192d5ee361aa61e7b1d5ef9d518", + "producing_stage": "compete", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "psms_extracted": { + "logical_name": "psms_extracted", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/psms_extracted.parquet", + "format": "parquet", + "schema_name": "psms_extracted", + "schema_version": 2, + "rows": 284, + "content_hash": "7f2eae351f0d936467458d3ef2942b020f93fc9b72d7484e2c3a08942f44d033", + "producing_stage": "extract", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "psms_scored": { + "logical_name": "psms_scored", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/psms_scored.parquet", + "format": "parquet", + "schema_name": "psms_scored", + "schema_version": 4, + "rows": 152, + "content_hash": "9c92be4ad83b794db312831fb8797877e0230156cd297624f9120401b5fb82a7", + "producing_stage": "rescore", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "run_windows": { + "logical_name": "run_windows", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/run_windows.parquet", + "format": "parquet", + "schema_name": "run_windows", + "schema_version": 1, + "rows": 3820, + "content_hash": "cec9473ba70a0955e310cdc9ceb1a1962bbf3fcce67d354f6dfe09ca00885c73", + "producing_stage": "rt-im-train", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "seed_psms": { + "logical_name": "seed_psms", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/seed_psms.parquet", + "format": "parquet", + "schema_name": "seed_psms", + "schema_version": 1, + "rows": 179, + "content_hash": "a12ee0d4627587e8c2dfb7fda55a0258ec4563c3e0067a3c93effa7edbf64686", + "producing_stage": "search-seed", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2" + }, + "spectra_ms1": { + "logical_name": "spectra_ms1", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/spectra/spectra_ms1.parquet", + "format": "parquet", + "schema_name": "spectra_ms1", + "schema_version": 1, + "rows": 60, + "content_hash": "c70bba2b31203c20d8108d47c3f802c0ac5b79bb797701cf1b29041d5cd0fa6d", + "producing_stage": "convert", + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b" + }, + "spectra_ms2": { + "logical_name": "spectra_ms2", + "path": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/spectra/spectra_ms2.parquet", + "format": "parquet", + "schema_name": "spectra_ms2", + "schema_version": 1, + "rows": 480, + "content_hash": "6228d8b36e3931ed6271122e735494957c8217dbd33d8a81d1fef568b775cf26", + "producing_stage": "convert", + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b" + } + } +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/peptide_quant.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/peptide_quant.parquet.report.json new file mode 100644 index 00000000..f23eaad8 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/peptide_quant.parquet.report.json @@ -0,0 +1,40 @@ +{ + "logical_name": "peptide_quant", + "schema_name": "peptide_quant", + "schema_version": 2, + "stage": "quant", + "rows": 151, + "content_hash": "db6dee23c4e5f1b40fad156b10a16f3a3748b3c83016770370933282ee36af88", + "params": { + "apex_rt_column_present": true, + "baseline_flank_scans": 12, + "baseline_quantile": 0.25, + "baseline_subtract": false, + "bound_peak": true, + "candidates_with_scored_apex": 152, + "chromatograms": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/chromatograms.parquet", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2", + "fixed_scan_halfwidth": 0, + "fixed_window_s": 0.0, + "fragment_selection": "ObservedArea", + "peak_fraction": 0.16666666666666666, + "peak_grace": 1, + "peak_window_mode": "PerCandidate", + "psms_scored": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/psms_scored.parquet", + "q_filter": "PeptideQ", + "q_threshold": 0.01, + "reliable_q": 0.001, + "rollup": "TopNSum", + "top_n_fragments": 3, + "top_n_peptides": 3 + }, + "stats": { + "nonquantifiable_peptides": 0, + "peptide_rows": 151, + "protein_group_rows": 16, + "quantified_peptides": 151, + "quantified_protein_groups": 16 + }, + "model_identity": null, + "elapsed_ms": 4 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/peptides.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/peptides.parquet.report.json new file mode 100644 index 00000000..0a474311 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/peptides.parquet.report.json @@ -0,0 +1,25 @@ +{ + "logical_name": "peptides", + "schema_name": "peptides", + "schema_version": 1, + "stage": "digest", + "rows": 876, + "content_hash": "b88380d3934a4f87e4c65b900f1a8e2edfc156eb599f967859a4f70d91ef7106", + "params": { + "decoy_strategy": "Reverse", + "enzyme": "TrypsinP", + "max_decoy_attempts": 64, + "max_len": 50, + "min_len": 5, + "missed_cleavages": 2, + "rng_seed": 0 + }, + "stats": { + "decoy_collision_retries": 0, + "dropped_target_decoy_pairs": 0, + "n_decoys": 438, + "n_targets": 438 + }, + "model_identity": null, + "elapsed_ms": 2 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/peptides.tsv b/desktop/src-tauri/tests/fixtures/run_out/peptides.tsv new file mode 100644 index 00000000..7004413b --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/peptides.tsv @@ -0,0 +1,152 @@ +precursor stripped_sequence charge protein q_value score quantity +HHALPAR HHALPAR 3 sp|FIXT05|FIX05_TEST 0.006623 5.1179 48580307.1 +VM[Oxidation]LLYDK VMLLYDK 3 sp|FIXT08|FIX08_TEST 0.006623 5.0160 25206727.8 +DQDFMMK DQDFMMK 3 sp|FIXT01|FIX01_TEST 0.006623 4.8114 19589306.7 +HGGELTVDK HGGELTVDK 3 sp|FIXT11|FIX11_TEST 0.006623 5.0495 23309212.7 +FNQEFM[Oxidation]K FNQEFMK 3 sp|FIXT15|FIX15_TEST 0.006623 5.0803 39505347.8 +FNSIALAFK FNSIALAFK 3 sp|FIXT13|FIX13_TEST 0.006623 5.0196 17612255.0 +DPSQDWFK DPSQDWFK 3 sp|FIXT02|FIX02_TEST 0.006623 5.2486 49942119.3 +FGMASGK FGMASGK 2 sp|FIXT15|FIX15_TEST 0.006623 1.9968 32594288.5 +IVFFGGDYR IVFFGGDYR 3 sp|FIXT03|FIX03_TEST 0.006623 5.1964 29162611.5 +EEMAFPGVSK EEMAFPGVSK 3 sp|FIXT12|FIX12_TEST 0.006623 5.2598 20842822.0 +FTAISSIPWK FTAISSIPWK 3 sp|FIXT13|FIX13_TEST 0.006623 5.0984 13268190.8 +LVELLPTFDR LVELLPTFDR 3 sp|FIXT14|FIX14_TEST 0.006623 5.0073 27343912.4 +STHIHTDWPR STHIHTDWPR 3 sp|FIXT02|FIX02_TEST 0.006623 5.1712 12560409.1 +TTSESHHESHK TTSESHHESHK 3 sp|FIXT08|FIX08_TEST 0.006623 5.2781 23140475.0 +VASQMSLQSSDGR VASQMSLQSSDGR 3 sp|FIXT15|FIX15_TEST 0.006623 3.2939 19193106.2 +NLPNGAWPPYIR NLPNGAWPPYIR 3 sp|FIXT16|FIX16_TEST 0.006623 1.9634 26899500.0 +LHNPAFVIQYDK LHNPAFVIQYDK 3 sp|FIXT08|FIX08_TEST 0.006623 5.1281 37712583.5 +SSFLMTGIPWQAR SSFLMTGIPWQAR 3 sp|FIXT04|FIX04_TEST 0.006623 5.1989 11816956.6 +APM[Oxidation]VVGFGWVINFK APMVVGFGWVINFK 3 sp|FIXT06|FIX06_TEST 0.006623 5.2251 22501906.5 +THFEQVPM[Oxidation]FWPR THFEQVPMFWPR 3 sp|FIXT14|FIX14_TEST 0.006623 6.3208 47349097.0 +LGMQIGPISFSAYPR LGMQIGPISFSAYPR 3 sp|FIXT15|FIX15_TEST 0.006623 5.2012 14346644.8 +SWVTGDLQWHMYK SWVTGDLQWHMYK 3 sp|FIXT07|FIX07_TEST 0.006623 5.2450 44406585.4 +WNYSM[Oxidation]QER WNYSMQER 2 sp|FIXT02|FIX02_TEST 0.006623 5.3086 35624475.1 +TM[Oxidation]IQHVMNEYTDLK TMIQHVMNEYTDLK 3 sp|FIXT07|FIX07_TEST 0.006623 5.1907 16724874.9 +TFYPATEMNNSWMR TFYPATEMNNSWMR 3 sp|FIXT08|FIX08_TEST 0.006623 5.1925 43552720.9 +QITDNM[Oxidation]WFK QITDNMWFK 2 sp|FIXT06|FIX06_TEST 0.006623 4.9791 28344402.7 +DYWGFPMEHLVDYK DYWGFPMEHLVDYK 3 sp|FIXT14|FIX14_TEST 0.006623 5.1523 12691033.0 +IPFNDAELM[Oxidation]IIYMTK IPFNDAELMIIYMTK 3 sp|FIXT05|FIX05_TEST 0.006623 5.2300 34610339.6 +DMWWYQTTNPQGFR DMWWYQTTNPQGFR 3 sp|FIXT02|FIX02_TEST 0.006623 5.5184 47496065.9 +IM[Oxidation]MYGWDWK IMMYGWDWK 2 sp|FIXT14|FIX14_TEST 0.006623 6.8611 47911820.3 +M[Oxidation]HDIGDGEDLR MHDIGDGEDLR 2 sp|FIXT12|FIX12_TEST 0.006623 5.3150 29344114.7 +MHLVQWFWQLQNHR MHLVQWFWQLQNHR 3 sp|FIXT16|FIX16_TEST 0.006623 5.1609 17428896.2 +IYSPYTVKEPEQIEM[Oxidation]K IYSPYTVKEPEQIEMK 3 sp|FIXT01|FIX01_TEST 0.006623 7.3188 31298494.7 +DTWVQTHDVTR DTWVQTHDVTR 2 sp|FIXT08|FIX08_TEST 0.006623 5.3049 28003241.3 +AFPEHAFTSPYK AFPEHAFTSPYK 2 sp|FIXT03|FIX03_TEST 0.006623 4.7113 5619232.3 +YAIPTVQRHPVNHTDSFK YAIPTVQRHPVNHTDSFK 3 sp|FIXT04|FIX04_TEST 0.006623 5.8861 36928863.7 +LWGANWEWMLK LWGANWEWMLK 2 sp|FIXT10|FIX10_TEST 0.006623 5.1563 10142656.5 +AWNLVWKDEHMVYM[Oxidation]SSR AWNLVWKDEHMVYMSSR 3 sp|FIXT14|FIX14_TEST 0.006623 -0.0794 22719712.5 +EDM[Oxidation]TQTVFGQAGAK EDMTQTVFGQAGAK 2 sp|FIXT16|FIX16_TEST 0.006623 5.1634 29547387.4 +DQDFMMKHPIIIYLYEVK DQDFMMKHPIIIYLYEVK 3 sp|FIXT01|FIX01_TEST 0.006623 -1.0713 31904186.7 +M[Oxidation]HDIGDGEDLRDNDPVMWR MHDIGDGEDLRDNDPVMWR 3 sp|FIXT12|FIX12_TEST 0.006623 2.1324 22243397.8 +LQMVSGELLLDHKNLINQLR LQMVSGELLLDHKNLINQLR 3 sp|FIXT05|FIX05_TEST 0.006623 -3.0267 12388366.2 +VFIFLYTGEPVSGK VFIFLYTGEPVSGK 2 sp|FIXT13|FIX13_TEST 0.006623 5.1966 37150167.0 +LDPTSGPMDVFPPGR LDPTSGPMDVFPPGR 2 sp|FIXT16|FIX16_TEST 0.006623 5.2685 27792345.4 +LVELLPTFDRIMMYGWDWK LVELLPTFDRIMMYGWDWK 3 sp|FIXT14|FIX14_TEST 0.006623 3.4382 46066713.5 +FNQEFMKFGMASGK FNQEFMKFGMASGK 2 sp|FIXT15|FIX15_TEST 0.006623 -4.2878 13016898.1 +EQNDEHVNSVLPLR EQNDEHVNSVLPLR 2 sp|FIXT11|FIX11_TEST 0.006623 6.8677 30590418.4 +SVMLNIWHATPNLR SVMLNIWHATPNLR 2 sp|FIXT10|FIX10_TEST 0.006623 5.2424 28783564.7 +QITDNMWFKWINIDWASISR QITDNMWFKWINIDWASISR 3 sp|FIXT06|FIX06_TEST 0.006623 3.8383 23377945.5 +SLAQGWRTFYPATEM[Oxidation]NNSWMR SLAQGWRTFYPATEMNNSWMR 3 sp|FIXT08|FIX08_TEST 0.006623 2.1700 27386507.8 +LWYSWGVM[Oxidation]WEVNK LWYSWGVMWEVNK 2 sp|FIXT09|FIX09_TEST 0.006623 5.3120 37640941.9 +FPQADM[Oxidation]EIEPFFEK FPQADMEIEPFFEK 2 sp|FIXT01|FIX01_TEST 0.006623 5.1701 13370764.7 +HEPHVTKALLMQWYQATPNDTR HEPHVTKALLMQWYQATPNDTR 3 sp|FIXT11|FIX11_TEST 0.006623 7.8683 37794364.6 +WIYALDHAQLYDGYKHTFPHAK WIYALDHAQLYDGYKHTFPHAK 3 sp|FIXT11|FIX11_TEST 0.006623 2.2265 31879304.6 +DEMNVWSNQQFSWK DEMNVWSNQQFSWK 2 sp|FIXT13|FIX13_TEST 0.006623 5.1838 25322221.7 +LWYSWGVM[Oxidation]WEVNKTTDYHEFR LWYSWGVMWEVNKTTDYHEFR 3 sp|FIXT09|FIX09_TEST 0.006623 7.5927 49465021.8 +AVEGAAALRETSGIWWKYAIPTVQR AVEGAAALRETSGIWWKYAIPTVQR 3 sp|FIXT04|FIX04_TEST 0.006623 -1.8925 24421953.7 +M[Oxidation]QDNPNDIPYDILHR MQDNPNDIPYDILHR 2 sp|FIXT03|FIX03_TEST 0.006623 5.2762 38600157.9 +IM[Oxidation]MYGWDWKTHFEQVPMFWPR IMMYGWDWKTHFEQVPMFWPR 3 sp|FIXT14|FIX14_TEST 0.006623 6.3988 37652902.6 +EIINSTYHHPYSPYR EIINSTYHHPYSPYR 2 sp|FIXT08|FIX08_TEST 0.006623 5.1824 17421658.2 +DNDPVMWRHNYYENM[Oxidation]QSSGTFLR DNDPVMWRHNYYENMQSSGTFLR 3 sp|FIXT12|FIX12_TEST 0.006623 -2.5247 15442846.8 +IDYIVYHPTIYQKWYMVWVELR IDYIVYHPTIYQKWYMVWVELR 3 sp|FIXT02|FIX02_TEST 0.006623 0.0969 17404898.5 +FQFMLLAPDWDYLYK FQFMLLAPDWDYLYK 2 sp|FIXT06|FIX06_TEST 0.006623 5.2171 27852727.8 +LDPTSGPMDVFPPGRDLANPVQEESFK LDPTSGPMDVFPPGRDLANPVQEESFK 3 sp|FIXT16|FIX16_TEST 0.006623 5.8220 49092154.1 +MFHEEWYKAVEGAAALRETSGIWWK MFHEEWYKAVEGAAALRETSGIWWK 3 sp|FIXT04|FIX04_TEST 0.006623 6.0846 41924498.7 +M[Oxidation]FHEEWYKAVEGAAALR MFHEEWYKAVEGAAALR 2 sp|FIXT04|FIX04_TEST 0.006623 -5.1824 6333597.2 +WMWSENNNHWNIPKVVEGDQELGTR WMWSENNNHWNIPKVVEGDQELGTR 3 sp|FIXT15|FIX15_TEST 0.006623 2.8305 39674702.4 +YWFWQPM[Oxidation]DPWSPPSKFTAISSIPWK YWFWQPMDPWSPPSKFTAISSIPWK 3 sp|FIXT13|FIX13_TEST 0.006623 7.9455 37358434.9 +HPIIIYLYEVKFPQADM[Oxidation]EIEPFFEK HPIIIYLYEVKFPQADMEIEPFFEK 3 sp|FIXT01|FIX01_TEST 0.006623 0.5280 16865554.7 +FYPGMQRMHDIGDGEDLRDNDPVMWR FYPGMQRMHDIGDGEDLRDNDPVMWR 3 sp|FIXT12|FIX12_TEST 0.006623 -6.2701 10573083.7 +WYGVSMWWKIMSWVWLYMYIAMEK WYGVSMWWKIMSWVWLYMYIAMEK 3 sp|FIXT09|FIX09_TEST 0.006623 -6.3099 5781413.2 +IVFFGGDYREYFQNADGR IVFFGGDYREYFQNADGR 2 sp|FIXT03|FIX03_TEST 0.006623 -1.4635 16896069.7 +HNYYENMQSSGTFLRMQPHIIAFAANSR HNYYENMQSSGTFLRMQPHIIAFAANSR 3 sp|FIXT12|FIX12_TEST 0.006623 3.8245 33574482.7 +WSWVYMNEVTIRHHALPARSFVQSPHR WSWVYMNEVTIRHHALPARSFVQSPHR 3 sp|FIXT05|FIX05_TEST 0.006623 6.1679 30425685.9 +DELLGIYMDEFLKMQQYYSSWIDFQGR DELLGIYMDEFLKMQQYYSSWIDFQGR 3 sp|FIXT16|FIX16_TEST 0.006623 6.6290 38977889.3 +EDMTQTVFGQAGAKMHLVQWFWQLQNHR EDMTQTVFGQAGAKMHLVQWFWQLQNHR 3 sp|FIXT16|FIX16_TEST 0.006623 3.9737 27464994.0 +LWGANWEWM[Oxidation]LKDVTTTTAK LWGANWEWMLKDVTTTTAK 2 sp|FIXT10|FIX10_TEST 0.006623 -10.4039 5865711.9 +YMSEGWTVMGHPIERSVM[Oxidation]LNIWHATPNLR YMSEGWTVMGHPIERSVMLNIWHATPNLR 3 sp|FIXT10|FIX10_TEST 0.006623 8.5180 41926718.7 +DLTLAIGISQVDRAWNLVWK DLTLAIGISQVDRAWNLVWK 2 sp|FIXT14|FIX14_TEST 0.006623 -0.5090 19627528.3 +QFVTHIM[Oxidation]PDRLWGANWEWMLKDVTTTTAK QFVTHIMPDRLWGANWEWMLKDVTTTTAK 3 sp|FIXT10|FIX10_TEST 0.006623 7.8488 45178131.6 +TMIQHVMNEYTDLKIFIYDYNFLHNWR TMIQHVMNEYTDLKIFIYDYNFLHNWR 3 sp|FIXT07|FIX07_TEST 0.006623 7.9974 41045807.7 +TQWAFAERVQEHELWWAR TQWAFAERVQEHELWWAR 2 sp|FIXT10|FIX10_TEST 0.006623 -1.9068 14719401.3 +DLTLAIGISQVDRAWNLVWKDEHM[Oxidation]VYMSSR DLTLAIGISQVDRAWNLVWKDEHMVYMSSR 3 sp|FIXT14|FIX14_TEST 0.006623 9.2179 42179845.4 +WINIDWASISRMYIAIINPK WINIDWASISRMYIAIINPK 2 sp|FIXT06|FIX06_TEST 0.006623 6.4217 39142628.9 +VGNNYM[Oxidation]QGFNHDVGRYWFWQPMDPWSPPSK VGNNYMQGFNHDVGRYWFWQPMDPWSPPSK 3 sp|FIXT13|FIX13_TEST 0.006623 9.9152 47960620.9 +YSQIGIIHSWDRMVLQNGAQNKQIYLGTADLK YSQIGIIHSWDRMVLQNGAQNKQIYLGTADLK 3 sp|FIXT11|FIX11_TEST 0.006623 -4.5352 7800532.2 +TYGQQATTIKMNLDGWAFFM[Oxidation]R TYGQQATTIKMNLDGWAFFMR 2 sp|FIXT09|FIX09_TEST 0.006623 0.2023 24448191.9 +YSYDM[Oxidation]MGTASNRLWYQWHSRSTHIHTDWPR YSYDMMGTASNRLWYQWHSRSTHIHTDWPR 3 sp|FIXT02|FIX02_TEST 0.006623 2.4320 20194841.6 +VPEGAIHWVGKHGDLWWEDISK VPEGAIHWVGKHGDLWWEDISK 2 sp|FIXT09|FIX09_TEST 0.006623 7.5256 48991215.0 +TESFWQGDYHVGRDQDFM[Oxidation]MKHPIIIYLYEVK TESFWQGDYHVGRDQDFMMKHPIIIYLYEVK 3 sp|FIXT01|FIX01_TEST 0.006623 3.1509 44919593.4 +SSFLM[Oxidation]TGIPWQARVVYYGSYFK SSFLMTGIPWQARVVYYGSYFK 2 sp|FIXT04|FIX04_TEST 0.006623 -1.4234 13851431.5 +MNLDGWAFFMRVPEGAIHWVGKHGDLWWEDISK MNLDGWAFFMRVPEGAIHWVGKHGDLWWEDISK 3 sp|FIXT09|FIX09_TEST 0.006623 -4.8434 6631299.0 +LVELLPTFDRIMMYGWDWKTHFEQVPMFWPR LVELLPTFDRIMMYGWDWKTHFEQVPMFWPR 3 sp|FIXT14|FIX14_TEST 0.006623 -2.6037 15703723.1 +DQDFM[Oxidation]MKHPIIIYLYEVKFPQADMEIEPFFEK DQDFMMKHPIIIYLYEVKFPQADMEIEPFFEK 3 sp|FIXT01|FIX01_TEST 0.006623 -348.3624 15256653.8 +QFVTHIMPDRLWGANWEWM[Oxidation]LK QFVTHIMPDRLWGANWEWMLK 2 sp|FIXT10|FIX10_TEST 0.006623 7.9608 36285056.6 +DPSQDWFKNPHTHIFGFQLTIKYSYDM[Oxidation]MGTASNR DPSQDWFKNPHTHIFGFQLTIKYSYDMMGTASNR 3 sp|FIXT02|FIX02_TEST 0.006623 1.0531 17408992.1 +APM[Oxidation]VVGFGWVINFKQITDNMWFK APMVVGFGWVINFKQITDNMWFK 2 sp|FIXT06|FIX06_TEST 0.006623 11.9058 50444533.9 +MQPHIIAFAANSRLSMNWFQHNR MQPHIIAFAANSRLSMNWFQHNR 2 sp|FIXT12|FIX12_TEST 0.006623 7.2723 48499826.8 +EIINSTYHHPYSPYRVM[Oxidation]LLYDKLHNPAFVIQYDK EIINSTYHHPYSPYRVMLLYDKLHNPAFVIQYDK 3 sp|FIXT08|FIX08_TEST 0.006623 7.0999 47531581.5 +DSDTAPWRWM[Oxidation]WSENNNHWNIPK DSDTAPWRWMWSENNNHWNIPK 2 sp|FIXT15|FIX15_TEST 0.006623 11.2223 52795786.8 +WINIDWASISRM[Oxidation]YIAIINPKNYVAVAYNNEYIDMK WINIDWASISRMYIAIINPKNYVAVAYNNEYIDMK 3 sp|FIXT06|FIX06_TEST 0.006623 2.5190 30203337.9 +HEPHVTKALLMQWYQATPNDTREQNDEHVNSVLPLR HEPHVTKALLMQWYQATPNDTREQNDEHVNSVLPLR 3 sp|FIXT11|FIX11_TEST 0.006623 -1.6157 24068721.0 +DNDPVMWRHNYYENMQSSGTFLRMQPHIIAFAANSR DNDPVMWRHNYYENMQSSGTFLRMQPHIIAFAANSR 3 sp|FIXT12|FIX12_TEST 0.006623 -5.8746 8423723.9 +M[Oxidation]YIAIINPKNYVAVAYNNEYIDMK MYIAIINPKNYVAVAYNNEYIDMK 2 sp|FIXT06|FIX06_TEST 0.006623 10.7453 53136438.3 +STHIHTDWPRDMWWYQTTNPQGFREQM[Oxidation]VTSLYYR STHIHTDWPRDMWWYQTTNPQGFREQMVTSLYYR 3 sp|FIXT02|FIX02_TEST 0.006623 5.4764 36513928.1 +TFGSTHHREEMAFPGVSKFYPGM[Oxidation]QR TFGSTHHREEMAFPGVSKFYPGMQR 2 sp|FIXT12|FIX12_TEST 0.006623 8.4409 42183337.5 +YHHVDFSQFQM[Oxidation]TKEIINSTYHHPYSPYRVMLLYDK YHHVDFSQFQMTKEIINSTYHHPYSPYRVMLLYDK 3 sp|FIXT08|FIX08_TEST 0.006623 -7.0396 9468244.0 +DLANPVQEESFKLDDSAALSWMWMPK DLANPVQEESFKLDDSAALSWMWMPK 2 sp|FIXT16|FIX16_TEST 0.006623 1.3770 25812463.5 +VSLHVIWQM[Oxidation]AGHRFFMYLIQISEVK VSLHVIWQMAGHRFFMYLIQISEVK 2 sp|FIXT06|FIX06_TEST 0.006623 3.0918 23566640.6 +HIMQTHQTLALFAKWTEQIMGVPEQK HIMQTHQTLALFAKWTEQIMGVPEQK 2 sp|FIXT15|FIX15_TEST 0.006623 -5.3525 18175491.1 +IM[Oxidation]SWVWLYMYIAMEKTYGQQATTIK IMSWVWLYMYIAMEKTYGQQATTIK 2 sp|FIXT09|FIX09_TEST 0.006623 10.8936 44761620.5 +FYNPGVNDTISRTMIQHVM[Oxidation]NEYTDLK FYNPGVNDTISRTMIQHVMNEYTDLK 2 sp|FIXT07|FIX07_TEST 0.006623 2.8819 29195194.3 +FPQADM[Oxidation]EIEPFFEKNNDGAAMGNNEALR FPQADMEIEPFFEKNNDGAAMGNNEALR 2 sp|FIXT01|FIX01_TEST 0.006623 9.3125 47990901.8 +FYHGPQSHLAPRIPFNDAELMIIYMTK FYHGPQSHLAPRIPFNDAELMIIYMTK 2 sp|FIXT05|FIX05_TEST 0.006623 0.8099 21303169.3 +EEM[Oxidation]AFPGVSKFYPGMQRMHDIGDGEDLR EEMAFPGVSKFYPGMQRMHDIGDGEDLR 2 sp|FIXT12|FIX12_TEST 0.006623 11.0251 48809250.2 +LDDSAALSWMWMPKDELLGIYM[Oxidation]DEFLK LDDSAALSWMWMPKDELLGIYMDEFLK 2 sp|FIXT16|FIX16_TEST 0.006623 11.1121 48234125.1 +LGM[Oxidation]QIGPISFSAYPRHIMQTHQTLALFAK LGMQIGPISFSAYPRHIMQTHQTLALFAK 2 sp|FIXT15|FIX15_TEST 0.006623 1.1847 18457790.9 +WWIPTSYEENM[Oxidation]KYSHAPMLAYHIYHK WWIPTSYEENMKYSHAPMLAYHIYHK 2 sp|FIXT10|FIX10_TEST 0.006623 6.8843 35346508.0 +THFEQVPMFWPRDYWGFPMEHLVDYK THFEQVPMFWPRDYWGFPMEHLVDYK 2 sp|FIXT14|FIX14_TEST 0.006623 -1.3766 10949982.7 +NNDGAAMGNNEALRIYSPYTVKEPEQIEM[Oxidation]K NNDGAAMGNNEALRIYSPYTVKEPEQIEMK 2 sp|FIXT01|FIX01_TEST 0.006623 2.1208 30862976.4 +IFIYDYNFLHNWRSWVTGDLQWHMYK IFIYDYNFLHNWRSWVTGDLQWHMYK 2 sp|FIXT07|FIX07_TEST 0.006623 6.3690 35478127.9 +EHFFLDVRLVELLPTFDRIM[Oxidation]MYGWDWK EHFFLDVRLVELLPTFDRIMMYGWDWK 2 sp|FIXT14|FIX14_TEST 0.006623 5.1690 37769896.3 +MMDDAWSFWDEHGKYMSEGWTVM[Oxidation]GHPIER MMDDAWSFWDEHGKYMSEGWTVMGHPIER 2 sp|FIXT10|FIX10_TEST 0.006623 4.8275 22654250.5 +WGGDMDEVVRTESFWQGDYHVGRDQDFM[Oxidation]MK WGGDMDEVVRTESFWQGDYHVGRDQDFMMK 2 sp|FIXT01|FIX01_TEST 0.006623 2.6433 18969720.8 +TYGQQATTIKMNLDGWAFFM[Oxidation]RVPEGAIHWVGK TYGQQATTIKMNLDGWAFFMRVPEGAIHWVGK 2 sp|FIXT09|FIX09_TEST 0.006623 3.6672 28435758.7 +SSFLMTGIPWQARVVYYGSYFKM[Oxidation]FHEEWYK SSFLMTGIPWQARVVYYGSYFKMFHEEWYK 2 sp|FIXT04|FIX04_TEST 0.006623 -3.7186 43349765.8 +HPVNHTDSFKALHVMGIFRTASEEDIHAVEIVTK HPVNHTDSFKALHVMGIFRTASEEDIHAVEIVTK 2 sp|FIXT04|FIX04_TEST 0.006623 1.1944 18096630.4 +WYM[Oxidation]VWVELRDPSQDWFKNPHTHIFGFQLTIK WYMVWVELRDPSQDWFKNPHTHIFGFQLTIK 2 sp|FIXT02|FIX02_TEST 0.006623 5.5601 48689602.3 +M[Oxidation]QDNPNDIPYDILHRIVFFGGDYREYFQNADGR MQDNPNDIPYDILHRIVFFGGDYREYFQNADGR 2 sp|FIXT03|FIX03_TEST 0.006623 -4.5743 7474406.4 +HIMQTHQTLALFAKWTEQIM[Oxidation]GVPEQKFNQEFMK HIMQTHQTLALFAKWTEQIMGVPEQKFNQEFMK 2 sp|FIXT15|FIX15_TEST 0.006623 4.8413 24950940.3 +LQM[Oxidation]VSGELLLDHKNLINQLRDDGDYVHQHETFIK LQMVSGELLLDHKNLINQLRDDGDYVHQHETFIK 2 sp|FIXT05|FIX05_TEST 0.006623 3.5352 20199602.9 +MHDIGDGEDLRDNDPVMWRHNYYENM[Oxidation]QSSGTFLR MHDIGDGEDLRDNDPVMWRHNYYENMQSSGTFLR 2 sp|FIXT12|FIX12_TEST 0.006623 10.0501 51118722.7 +DYWGFPMEHLVDYKNDWELTATEHSKADLM[Oxidation]DQDR DYWGFPMEHLVDYKNDWELTATEHSKADLMDQDR 2 sp|FIXT14|FIX14_TEST 0.006623 -4.0430 18137698.2 +VPEGAIHWVGKHGDLWWEDISKFYDLASGFHYWMK VPEGAIHWVGKHGDLWWEDISKFYDLASGFHYWMK 2 sp|FIXT09|FIX09_TEST 0.006623 12.0050 52662259.9 +SFVQSPHRLFWGWHQVQDHFEKLQMVSGELLLDHK SFVQSPHRLFWGWHQVQDHFEKLQMVSGELLLDHK 2 sp|FIXT05|FIX05_TEST 0.006623 8.2503 40658837.3 +WYGVSMWWKIMSWVWLYM[Oxidation]YIAMEKTYGQQATTIK WYGVSMWWKIMSWVWLYMYIAMEKTYGQQATTIK 2 sp|FIXT09|FIX09_TEST 0.006623 -1082.1209 26904853.1 +TFYPATEMNNSWMRTTSESHHESHKDTWVQTHDVTR TFYPATEMNNSWMRTTSESHHESHKDTWVQTHDVTR 2 sp|FIXT08|FIX08_TEST 0.006623 -2.8155 11319221.0 +WM[Oxidation]WSENNNHWNIPKVVEGDQELGTRVASQMSLQSSDGR WMWSENNNHWNIPKVVEGDQELGTRVASQMSLQSSDGR 2 sp|FIXT15|FIX15_TEST 0.006623 -6.1943 6186360.9 +IMSWVWLYMYIAMEKTYGQQATTIKMNLDGWAFFMR IMSWVWLYMYIAMEKTYGQQATTIKMNLDGWAFFMR 2 sp|FIXT09|FIX09_TEST 0.006623 9.6590 43928065.8 +QDAPQISATSFTKAFPEHAFTSPYKWMNLHDVYDMVAFR QDAPQISATSFTKAFPEHAFTSPYKWMNLHDVYDMVAFR 2 sp|FIXT03|FIX03_TEST 0.006623 9.5181 52830984.9 +IM[Oxidation]MYGWDWKTHFEQVPMFWPRDYWGFPMEHLVDYK IMMYGWDWKTHFEQVPMFWPRDYWGFPMEHLVDYK 2 sp|FIXT14|FIX14_TEST 0.006623 4.5983 28299806.0 +VASQMSLQSSDGRLGMQIGPISFSAYPRHIMQTHQTLALFAK VASQMSLQSSDGRLGMQIGPISFSAYPRHIMQTHQTLALFAK 2 sp|FIXT15|FIX15_TEST 0.006623 -6.1551 15343926.6 +VQEHELWWARWWIPTSYEENM[Oxidation]KYSHAPMLAYHIYHK VQEHELWWARWWIPTSYEENMKYSHAPMLAYHIYHK 2 sp|FIXT10|FIX10_TEST 0.006623 5.8658 34793069.8 +LGMQIGPISFSAYPRHIMQTHQTLALFAKWTEQIMGVPEQK LGMQIGPISFSAYPRHIMQTHQTLALFAKWTEQIMGVPEQK 2 sp|FIXT15|FIX15_TEST 0.006623 11.4455 52265711.4 +SIPIAIDPHEPMHMKVSLHVIWQM[Oxidation]AGHRFFMYLIQISEVK SIPIAIDPHEPMHMKVSLHVIWQMAGHRFFMYLIQISEVK 2 sp|FIXT06|FIX06_TEST 0.006623 6.5839 35567088.6 +THFEQVPMFWPRDYWGFPM[Oxidation]EHLVDYKNDWELTATEHSK THFEQVPMFWPRDYWGFPMEHLVDYKNDWELTATEHSK 2 sp|FIXT14|FIX14_TEST 0.006623 5.6838 34802511.1 +AFPEHAFTSPYKWMNLHDVYDMVAFRNDNWDQVNFAHIGR AFPEHAFTSPYKWMNLHDVYDMVAFRNDNWDQVNFAHIGR 2 sp|FIXT03|FIX03_TEST 0.006623 -4.7874 14225761.8 +DDGDYVHQHETFIKFYHGPQSHLAPRIPFNDAELMIIYMTK DDGDYVHQHETFIKFYHGPQSHLAPRIPFNDAELMIIYMTK 2 sp|FIXT05|FIX05_TEST 0.006623 7.6208 47117389.7 +MDDAWSFWDEHGKYM[Oxidation]SEGWTVMGHPIERSVMLNIWHATPNLR MDDAWSFWDEHGKYMSEGWTVMGHPIERSVMLNIWHATPNLR 2 sp|FIXT10|FIX10_TEST 0.006623 -39.4688 43729525.5 +M[Oxidation]MDDAWSFWDEHGKYMSEGWTVMGHPIERSVMLNIWHATPNLR MMDDAWSFWDEHGKYMSEGWTVMGHPIERSVMLNIWHATPNLR 2 sp|FIXT10|FIX10_TEST 0.006623 -27.7492 46468485.6 +FQFMLLAPDWDYLYKSIPIAIDPHEPM[Oxidation]HMKVSLHVIWQMAGHR FQFMLLAPDWDYLYKSIPIAIDPHEPMHMKVSLHVIWQMAGHR 2 sp|FIXT06|FIX06_TEST 0.006623 -5.5552 15158008.8 diff --git a/desktop/src-tauri/tests/fixtures/run_out/peptidoforms.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/peptidoforms.parquet.report.json new file mode 100644 index 00000000..4c3c4b4c --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/peptidoforms.parquet.report.json @@ -0,0 +1,21 @@ +{ + "logical_name": "peptidoforms", + "schema_name": "peptidoforms", + "schema_version": 1, + "stage": "peptidoforms", + "rows": 3820, + "content_hash": "b8c0df35a3ba1faa0ac44736aa95e31563735a93b6b5f99c9a0749b2445b04f3", + "params": { + "charges": "2..3", + "fixed_mods": [ + "C:Carbamidomethyl" + ], + "max_variable_mods": 1, + "variable_mods": [ + "M:Oxidation" + ] + }, + "stats": {}, + "model_identity": null, + "elapsed_ms": 3 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/protein_group_quant.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/protein_group_quant.parquet.report.json new file mode 100644 index 00000000..18d9fba4 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/protein_group_quant.parquet.report.json @@ -0,0 +1,40 @@ +{ + "logical_name": "protein_group_quant", + "schema_name": "protein_group_quant", + "schema_version": 2, + "stage": "quant", + "rows": 16, + "content_hash": "1eb8ddb19566390bc59e17ede88b0d7264d48b3831079346b74c73aeaa8b5920", + "params": { + "apex_rt_column_present": true, + "baseline_flank_scans": 12, + "baseline_quantile": 0.25, + "baseline_subtract": false, + "bound_peak": true, + "candidates_with_scored_apex": 152, + "chromatograms": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/chromatograms.parquet", + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2", + "fixed_scan_halfwidth": 0, + "fixed_window_s": 0.0, + "fragment_selection": "ObservedArea", + "peak_fraction": 0.16666666666666666, + "peak_grace": 1, + "peak_window_mode": "PerCandidate", + "psms_scored": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/psms_scored.parquet", + "q_filter": "PeptideQ", + "q_threshold": 0.01, + "reliable_q": 0.001, + "rollup": "TopNSum", + "top_n_fragments": 3, + "top_n_peptides": 3 + }, + "stats": { + "nonquantifiable_peptides": 0, + "peptide_rows": 151, + "protein_group_rows": 16, + "quantified_peptides": 151, + "quantified_protein_groups": 16 + }, + "model_identity": null, + "elapsed_ms": 4 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/proteins.tsv b/desktop/src-tauri/tests/fixtures/run_out/proteins.tsv new file mode 100644 index 00000000..fcb8a3b5 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/proteins.tsv @@ -0,0 +1 @@ +protein_group q_value quantity diff --git a/desktop/src-tauri/tests/fixtures/run_out/psms_competed.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/psms_competed.parquet.report.json new file mode 100644 index 00000000..b8f70ad8 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/psms_competed.parquet.report.json @@ -0,0 +1,19 @@ +{ + "logical_name": "psms_competed", + "schema_name": "psms_competed", + "schema_version": 3, + "stage": "compete", + "rows": 152, + "content_hash": "f5d11d59f94a46211bcddcf5b2ce49de95ed4192d5ee361aa61e7b1d5ef9d518", + "params": { + "group_by": "BasePeptide", + "mode": "WinnerTakeAll" + }, + "stats": { + "input_rows": 284, + "kept": 152, + "removed": 132 + }, + "model_identity": null, + "elapsed_ms": 10 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/psms_competed.parquet.schema.json b/desktop/src-tauri/tests/fixtures/run_out/psms_competed.parquet.schema.json new file mode 100644 index 00000000..6b9230bc --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/psms_competed.parquet.schema.json @@ -0,0 +1,392 @@ +{ + "feature_columns": [ + "rt_error_abs", + "rt_error_rel", + "n_matched_fragments", + "coelution_run", + "log_apex_intensity", + "frag_corr", + "frag_cosine", + "spectral_angle", + "coelution_mean", + "coelution_best", + "n_coelution_above", + "charge", + "peptide_length", + "n_proteins", + "library_norm_manhattan", + "library_rmsd", + "xcorr_coelution", + "xcorr_shape", + "sum_b_intensity", + "sum_y_intensity", + "diff_by_intensity", + "n_b_ions", + "n_y_ions", + "weighted_mass_error", + "mean_mass_error", + "isotope_corr", + "ms1_isom1_ratio", + "log_mono_ms1", + "has_ms1", + "log_sn", + "n_observations", + "base_width_rt", + "seed_score", + "seed_identified", + "matched_fraction", + "profile_cos", + "ref_corr", + "best_ref_corr", + "low_frag_coel", + "evidence", + "contrast_min", + "resid_corr", + "coel_clean", + "shadow_frac", + "spectrum_cosine_matched", + "spectrum_cosine_sqrt", + "spectrum_cosine_log", + "spectral_angle_sqrt", + "spectral_angle_matched", + "pearson_intensity_matched", + "pearson_intensity_log", + "spearman_intensity", + "spearman_intensity_matched", + "kendall_tau_intensity", + "dot_product_raw", + "dot_product_norm", + "library_recall_intensity", + "manhattan_sim", + "manhattan_sqrt", + "rmsd_norm", + "mae_norm", + "mse_log", + "mae_weighted_pred", + "abs_diff_q3", + "max_positive_residual", + "chebyshev_dist", + "minkowski_p3", + "bray_curtis", + "bray_curtis_sqrt", + "canberra", + "canberra_matched", + "wave_hedges", + "chi_square_pearson", + "chi_square_symmetric", + "divergence_distance", + "bhattacharyya_coef", + "hellinger", + "squared_chord", + "harmonic_mean_sim", + "jaccard_presence", + "dice_presence", + "intensity_weighted_pearson", + "regression_slope", + "gini_diff", + "wasserstein_mz", + "footrule_norm", + "rank_overlap_top3", + "top1_frag_match", + "top1_predicted_observed", + "frac_top3_predicted_observed", + "count_strong_predicted_absent", + "frac_predicted_absent", + "cosine_area", + "pearson_area", + "spectral_angle_area", + "cosine_fullwindow", + "stein_scott_weighted_dot", + "log_dot_product", + "spectral_log_evidence", + "scribe_score", + "log_dot_product_area", + "spectral_log_evidence_area", + "scribe_score_area", + "cosine_high_ordinal", + "cosine_robust_trim1", + "cosine_robust_trim2", + "cosine_robust_trim3", + "spectral_entropy_similarity", + "weighted_spectral_entropy_similarity", + "spectral_entropy_similarity_sqrt", + "spectral_entropy_similarity_topk", + "spectral_entropy_similarity_area", + "jensen_shannon_divergence", + "jeffreys_divergence", + "kl_obs_pred", + "kl_pred_obs", + "cross_entropy_obs_pred", + "obs_spectrum_entropy", + "pred_spectrum_entropy", + "entropy_diff", + "entropy_ratio", + "obs_normalized_entropy", + "normalized_entropy_diff", + "residual_spectrum_entropy", + "entropy_weight_obs", + "frag_ref_corr_mean", + "frag_ref_corr_obsweighted", + "frag_ref_corr_min", + "frag_ref_corr_std", + "frag_ref_corr_sq_mean", + "frag_ref_corr_topk_weighted", + "n_frag_ref_corr_above_0_9", + "frac_frag_ref_corr_above_0_8", + "frag_ref_corr_mean_full", + "full_vs_peak_corr_gain", + "pairwise_coelution_weighted", + "pairwise_coelution_min", + "pairwise_coelution_median", + "pairwise_coelution_std", + "pairwise_coelution_frac_negative", + "pairwise_coelution_hi", + "pairwise_coelution_lo", + "coelution_hi_lo_contrast", + "coelution_corr_entropy", + "xcorr_shape_mean", + "xcorr_shape_min", + "xcorr_shape_std", + "xcorr_lag_mean_abs", + "xcorr_lag_std", + "xcorr_lag_iqr", + "xcorr_lag_frac_zero", + "xcorr_lag_max_abs", + "xcorr_lag_entropy", + "ref_xcorr_lag_mean", + "ref_xcorr_shape_mean", + "observed_sum_vs_template_corr", + "frag_loo_ref_corr_mean", + "frag_loo_ref_corr_min", + "frac_frags_apex_aligned", + "top3_frag_ref_corr", + "by_cross_coelution", + "by_cross_lag_mean", + "charge_cross_coelution", + "explained_variance_ref", + "profile_residual_fraction", + "n_interfered_fragments", + "corrected_vs_raw_cos", + "corrected_vs_raw_ratio", + "ifs_removed_count", + "ifs_removed_intensity_frac", + "ifs_corr_gain", + "ifs_retained_frac", + "matched_frac_after_ifs", + "peak_to_full_area_ratio_profile", + "peak_to_full_area_ratio_frag_mean", + "peak_to_full_area_ratio_weighted", + "out_of_peak_intensity_frac", + "profile_corr_full_vs_peak_delta", + "frac_frag_ref_corr_below_0_5", + "explained_apex_intensity_frac", + "apex_purity", + "interference_apex_residual_fraction", + "dominant_frag_ref_corr", + "explained_variance_ratio", + "second_component_fraction", + "profile_second_peak_ratio", + "n_competing_peaks_in_window", + "matched_pred_intensity_fraction", + "top_pred_frag_matched", + "gaussian_fit_r2", + "gaussian_cosine", + "emg_fit_improvement", + "apex_prominence", + "profile_peak_snr", + "fwhm_seconds", + "fwhm_to_window_ratio", + "width_at_10pct", + "width_ratio_10_50", + "hwhm_asymmetry", + "tailing_factor_usp", + "asymmetry_factor_10pct", + "apex_sharpness", + "apex_curvature", + "apex_to_boundary_ratio", + "apex_dominance", + "zigzag_index", + "jaggedness", + "roughness_2nd_deriv", + "n_local_maxima", + "modality", + "rt_skewness", + "rt_excess_kurtosis", + "rt_std_seconds", + "mean_mode_offset", + "fraction_area_within_fwhm", + "triangle_area_similarity", + "baseline_fraction", + "peak_completeness", + "apex_centering_offset", + "intensity_score", + "total_xic_log", + "frag_fwhm_cv", + "frag_fwhm_mean", + "frag_apex_rt_dispersion", + "frag_apex_rt_dispersion_weighted", + "frag_apex_offset_from_profile_mean", + "frag_gaussianity_mean", + "frag_gaussianity_weighted", + "frag_zigzag_mean", + "sumtrace_unweighted_gaussian_r2", + "reference_profile_rt_entropy_peak", + "reference_profile_rt_entropy_ratio", + "median_abs_frag_ppm", + "signed_mean_frag_ppm", + "ppm_std", + "ppm_iqr", + "ppm_range", + "max_abs_frag_ppm", + "intensity_weighted_abs_ppm", + "intensity_weighted_signed_ppm", + "intensity_weighted_ppm_std", + "lib_weighted_abs_ppm", + "frac_frag_within_half_tol", + "high_ppm_intensity_frac", + "ppm_intensity_anticorr", + "mass_error_mz_trend", + "mean_abs_mz_error_da", + "mass_evidence_gauss", + "mass_log_evidence", + "n_matched_b", + "n_matched_y", + "frac_matched_b", + "frac_matched_y", + "by_count_balance", + "by_intensity_ratio", + "by_ratio_agreement", + "by_ratio_consistency", + "longest_b_run", + "longest_y_run", + "longest_run_max", + "longest_run_frac_length", + "series_coverage_b", + "series_coverage_y", + "sequence_coverage", + "series_gap_fraction", + "by_complement_count", + "by_complement_mz_consistency", + "by_complement_coelution", + "ordinal_intensity_concordance_y", + "ordinal_intensity_concordance_b", + "series_coelution_y", + "series_coelution_b", + "spectral_angle_b", + "spectral_angle_y", + "pearson_b", + "pearson_y", + "cosine_charge1", + "cosine_charge2", + "charge_corr_balance", + "mean_matched_ordinal_norm", + "by_ion_contiguous_intensity", + "by_ion_contiguous_lib_frac", + "both_series_present", + "ms1_isotope_cosine_apex", + "ms1_isotope_spectral_angle_apex", + "ms1_isotope_chi2_apex", + "ms1_isotope_manhattan_apex", + "iso_ratio_1_0", + "iso_ratio_2_0", + "iso_plus1_ratio_dev", + "iso_plus2_ratio_dev", + "iso_minus_one_fraction", + "iso_overlap_flag", + "log_ms1_mono", + "ms1_total_isotope_log", + "has_ms1_signal", + "ms1_isotope_apex_entropy_3", + "ms1_m1_entropy_contribution", + "ms1_ms2_time_corr", + "ms1_ms2_envelope_time_corr", + "ms1_iso_coelution", + "ms1_ms2_apex_rt_delta", + "ms1_iso_ratio_stability", + "ms1_mono_gaussianity", + "ms1_ms2_fwhm_ratio", + "ms1_isotope_corr_xic", + "ms1_envelope_over_time_corr", + "ms1_isotope_xic_shape_consistency", + "ms1_isotope_height_corr", + "rt_error_signed", + "rt_error_squared", + "rt_error_signed_norm_gradient", + "rt_error_abs_norm_gradient", + "observed_rt_raw", + "predicted_rt_raw", + "observed_rt_fraction", + "predicted_rt_fraction", + "rt_error_over_peak_width", + "rt_error_over_fwhm", + "rt_diff_profile_apex", + "predicted_rt_in_gradient", + "log_seed_hyperscore", + "seed_hyperscore_per_matched", + "precursor_charge", + "charge_is_2", + "charge_is_3", + "charge_is_4plus", + "precursor_mass", + "log_total_matched_intensity", + "n_matched_frags", + "n_predicted_frags", + "frag_corr_peakmax", + "frag_cosine_peakmax", + "spectral_angle_peakmax", + "frag_corr_matched_nz", + "frag_cosine_matched_nz", + "peakmax_apex_gain", + "n_frag_present_inpeak", + "frac_frag_present_inpeak", + "coelution_mean_bothpos", + "coelution_mean_summpos", + "ref_corr_nz", + "profile_cos_nz", + "rank_corr_vs_apex_mean", + "rank_corr_vs_apex_std", + "rank_corr_adjacent_mean", + "kendall_vs_apex_mean", + "top1_frag_persistence", + "top2_order_persistence", + "argmax_frag_entropy", + "self_cosine_vs_apex_mean", + "n_peak_scans", + "peak_window_degenerate", + "frag_apex_rt_std", + "frag_apex_rt_mad", + "frag_apex_max_dev", + "frag_apex_mean_dev", + "frag_apex_agree_frac", + "precursor_frag_apex_delta", + "peak_symmetry", + "peak_tailing", + "peak_n_local_maxima", + "peak_shoulder_score", + "peak_fwhm_scans", + "peak_truncation", + "apex_frac_of_window", + "frag_mass_err_median", + "frag_mass_err_abs_median", + "frag_mass_err_std", + "frag_mass_err_iqr", + "frag_mass_err_max_abs", + "frag_mass_err_range", + "effective_frag_count", + "evidence_concentration", + "frac_top3_pred_observed", + "frac_top5_pred_observed", + "deconv_explained_frac", + "deconv_active", + "deconv_share", + "deconv_max_collinearity", + "shadow_kept_frac", + "peak_contested_frac", + "peak_contested_count_frac", + "peak_apportioned_frac", + "n_charge_states", + "charge_multi_flag", + "cross_charge_intensity_log" + ], + "schema_id": "e5b4eff0133e411dbf406947c89466619ec557ab37874bc3948792d9890afe8e" +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/psms_extracted.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/psms_extracted.parquet.report.json new file mode 100644 index 00000000..1fc03f57 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/psms_extracted.parquet.report.json @@ -0,0 +1,25 @@ +{ + "logical_name": "psms_extracted", + "schema_name": "psms_extracted", + "schema_version": 2, + "stage": "extract", + "rows": 284, + "content_hash": "7f2eae351f0d936467458d3ef2942b020f93fc9b72d7484e2c3a08942f44d033", + "params": { + "effective_frag_tol_ppm": 5.0, + "frag_ppm_offset": -0.00026103398249893126, + "frag_tol_ppm": 20.0, + "gate_coelution_min": 0.5, + "gate_min_score": 0.2, + "gate_mode": "apex_pearson", + "presence_min_coelution": 2, + "presence_min_fragments": 3, + "scan_window": 3 + }, + "stats": { + "accepted": 284, + "scan_window": 3 + }, + "model_identity": null, + "elapsed_ms": 11 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/psms_scored.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/psms_scored.parquet.report.json new file mode 100644 index 00000000..d1723c20 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/psms_scored.parquet.report.json @@ -0,0 +1,31 @@ +{ + "logical_name": "psms_scored", + "schema_name": "psms_scored", + "schema_version": 4, + "stage": "rescore", + "rows": 152, + "content_hash": "9c92be4ad83b794db312831fb8797877e0230156cd297624f9120401b5fb82a7", + "params": { + "classifier": "native_tda", + "classifier_requested": "NativeTda", + "competed_inputs": [ + "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/out/psms_competed.parquet" + ], + "config_hash": "7dfde09c5047373b95038420ca4248fd4aadf46942bdd8630c30ce1b13403ef2", + "feature_schema_id": "e5b4eff0133e411dbf406947c89466619ec557ab37874bc3948792d9890afe8e", + "folds": 3, + "num_iter": 10, + "strict": true, + "train_fdr": 0.01 + }, + "stats": { + "classifier": "native_tda", + "psms": 152, + "target_peptides_at_1pct": 151, + "target_precursors_at_1pct": 151, + "target_protein_groups_at_1pct": 0, + "target_psms_at_1pct": 151 + }, + "model_identity": "native-percolator-lite-v1", + "elapsed_ms": 66 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/run_windows.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/run_windows.parquet.report.json new file mode 100644 index 00000000..01542294 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/run_windows.parquet.report.json @@ -0,0 +1,20 @@ +{ + "logical_name": "run_windows", + "schema_name": "run_windows", + "schema_version": 1, + "stage": "rt-im-train", + "rows": 3820, + "content_hash": "cec9473ba70a0955e310cdc9ceb1a1962bbf3fcce67d354f6dfe09ca00885c73", + "params": { + "method": "Loess", + "p_rt": 0.95, + "q_train": 0.01 + }, + "stats": { + "calibration_status": "loess", + "n_train": 110, + "w_rt": 11.826482451982308 + }, + "model_identity": null, + "elapsed_ms": 2 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/seed_psms.parquet.masscal.json b/desktop/src-tauri/tests/fixtures/run_out/seed_psms.parquet.masscal.json new file mode 100644 index 00000000..76391422 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/seed_psms.parquet.masscal.json @@ -0,0 +1,11 @@ +{ + "cal_passes": 1, + "frag_ppm_offset": -0.00026103398249893126, + "frag_ppm_sigma": 5.0, + "frag_tol_ppm": 5.0, + "mz_cal_grid_mz": [], + "mz_cal_grid_ppm": [], + "n_dev": 1068, + "ppm_residual_mad": 0.019232855968052062, + "ppm_residual_median": 0.0 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/seed_psms.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/seed_psms.parquet.report.json new file mode 100644 index 00000000..ec474ae6 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/seed_psms.parquet.report.json @@ -0,0 +1,21 @@ +{ + "logical_name": "seed_psms", + "schema_name": "seed_psms", + "schema_version": 1, + "stage": "search-seed", + "rows": 179, + "content_hash": "a12ee0d4627587e8c2dfb7fda55a0258ec4563c3e0067a3c93effa7edbf64686", + "params": { + "fdr_seed": 0.01, + "fragment_tol_ppm": 20.0, + "min_matched_peaks": 4, + "report_psms": 5, + "top_n_peaks": 300 + }, + "stats": { + "psms": 179, + "targets_at_q0.01": 179 + }, + "model_identity": "native-seed-hyperscore-v1", + "elapsed_ms": 6 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/spectra/isolation_windows.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/spectra/isolation_windows.parquet.report.json new file mode 100644 index 00000000..a15de6c8 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/spectra/isolation_windows.parquet.report.json @@ -0,0 +1,18 @@ +{ + "logical_name": "isolation_windows", + "schema_name": "isolation_windows", + "schema_version": 1, + "stage": "convert", + "rows": 8, + "content_hash": "3ceab06d7b4d1ed3d814722235920d2677bb6877535fa02767a67519de0ea32f", + "params": { + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b", + "max_spectra": 0, + "mzml": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/fixture.mzML", + "top_peaks_ms1": 0, + "top_peaks_ms2": 0 + }, + "stats": {}, + "model_identity": null, + "elapsed_ms": 12 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/spectra/ms2_to_ms1.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/spectra/ms2_to_ms1.parquet.report.json new file mode 100644 index 00000000..e20fa2c4 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/spectra/ms2_to_ms1.parquet.report.json @@ -0,0 +1,18 @@ +{ + "logical_name": "ms2_to_ms1", + "schema_name": "ms2_to_ms1", + "schema_version": 1, + "stage": "convert", + "rows": 480, + "content_hash": "c60988ab4d075d9655fb7406169ee92e804c7dff05b42124e818ccc9f249346f", + "params": { + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b", + "max_spectra": 0, + "mzml": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/fixture.mzML", + "top_peaks_ms1": 0, + "top_peaks_ms2": 0 + }, + "stats": {}, + "model_identity": null, + "elapsed_ms": 12 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/spectra/spectra_ms1.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/spectra/spectra_ms1.parquet.report.json new file mode 100644 index 00000000..2f07841c --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/spectra/spectra_ms1.parquet.report.json @@ -0,0 +1,18 @@ +{ + "logical_name": "spectra_ms1", + "schema_name": "spectra_ms1", + "schema_version": 1, + "stage": "convert", + "rows": 60, + "content_hash": "c70bba2b31203c20d8108d47c3f802c0ac5b79bb797701cf1b29041d5cd0fa6d", + "params": { + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b", + "max_spectra": 0, + "mzml": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/fixture.mzML", + "top_peaks_ms1": 0, + "top_peaks_ms2": 0 + }, + "stats": {}, + "model_identity": null, + "elapsed_ms": 12 +} \ No newline at end of file diff --git a/desktop/src-tauri/tests/fixtures/run_out/spectra/spectra_ms2.parquet.report.json b/desktop/src-tauri/tests/fixtures/run_out/spectra/spectra_ms2.parquet.report.json new file mode 100644 index 00000000..48f97a05 --- /dev/null +++ b/desktop/src-tauri/tests/fixtures/run_out/spectra/spectra_ms2.parquet.report.json @@ -0,0 +1,18 @@ +{ + "logical_name": "spectra_ms2", + "schema_name": "spectra_ms2", + "schema_version": 1, + "stage": "convert", + "rows": 480, + "content_hash": "6228d8b36e3931ed6271122e735494957c8217dbd33d8a81d1fef568b775cf26", + "params": { + "config_hash": "e11217cb603b8854a93c176b421cd926fb371ab35ee4647c4301b3f1c7631c3b", + "max_spectra": 0, + "mzml": "C:/Users/robbi/AppData/Local/Temp/claude/H--OneDrive---UGent-MuMDIA-NG/ed948dad-9d5a-423e-9f52-cf197cbee207/scratchpad/smoke_final/fixture.mzML", + "top_peaks_ms1": 0, + "top_peaks_ms2": 0 + }, + "stats": {}, + "model_identity": null, + "elapsed_ms": 12 +} \ No newline at end of file diff --git a/desktop/ui/app.css b/desktop/ui/app.css new file mode 100644 index 00000000..08261b41 --- /dev/null +++ b/desktop/ui/app.css @@ -0,0 +1,222 @@ +/* MuMDIA console. + One palette, defined light-first and redefined for dark, so the application + follows the operating system without a second stylesheet. */ + +:root { + --ground:#F6F7F8; --panel:#FFFFFF; --sunk:#EDEFF2; + --ink:#14181D; --ink-soft:#4A535E; --ink-faint:#77818D; + --rule:#DDE1E7; --rule-strong:#C3C9D2; + --accent:#1E4D8C; --accent-soft:#E8EEF7; --on-accent:#FFFFFF; + --ok:#2F7D4F; --ok-soft:#E4F1E9; + --warn:#9A6510; --warn-soft:#FBF0DA; + --danger:#A93826; --danger-soft:#FAE7E3; + --radius:7px; +} +@media (prefers-color-scheme: dark) { + :root { + --ground:#0E1216; --panel:#171D24; --sunk:#1C222A; + --ink:#E4E8ED; --ink-soft:#A6AFBA; --ink-faint:#79838F; + --rule:#262E37; --rule-strong:#38424E; + --accent:#7FA9E0; --accent-soft:#17263A; --on-accent:#0E1216; + --ok:#6FBF8F; --ok-soft:#14251B; + --warn:#D9A544; --warn-soft:#2A2113; + --danger:#E08472; --danger-soft:#2C1814; + } +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + display: grid; + grid-template-columns: 190px 1fr; + background: var(--ground); + color: var(--ink); + font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + overflow: hidden; +} + +/* ── rail ─────────────────────────────────────────────────────────────── */ +.rail { + background: var(--panel); + border-right: 1px solid var(--rule); + display: flex; flex-direction: column; + padding: 18px 0 12px; + min-width: 0; +} +.brand { + font-size: 15px; font-weight: 700; letter-spacing: .02em; + padding: 0 18px 16px; +} +.rail nav { display: flex; flex-direction: column; gap: 2px; } +.nav { + appearance: none; border: 0; background: none; text-align: left; + font: inherit; color: var(--ink); padding: 8px 18px; cursor: pointer; + border-left: 2px solid transparent; +} +.nav:hover:not(:disabled) { background: var(--sunk); } +.nav.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; border-left-color: var(--accent); } +.nav:disabled { color: var(--ink-faint); cursor: default; opacity: .55; } +.nav:focus-visible, .btn:focus-visible, .tab:focus-visible, +select:focus-visible, input:focus-visible, summary:focus-visible { + outline: 2px solid var(--accent); outline-offset: 2px; +} +.rail-foot { margin-top: auto; padding: 12px 18px 0; border-top: 1px solid var(--rule); } +.engine-line { font-size: 11px; color: var(--ink-faint); word-break: break-word; line-height: 1.4; } + +/* ── screens ──────────────────────────────────────────────────────────── */ +main { overflow-y: auto; padding: 28px 30px 40px; min-width: 0; } +.screen { display: none; max-width: 780px; } +.screen.on { display: block; } +h1 { font-size: 21px; font-weight: 650; margin: 0 0 3px; letter-spacing: -.01em; } +.sub { color: var(--ink-soft); font-size: 13px; margin: 0 0 20px; } +.head-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; } +.head-row .sub { margin-bottom: 20px; } + +.card { + background: var(--panel); border: 1px solid var(--rule); + border-radius: var(--radius); padding: 14px 16px; margin-bottom: 12px; +} +.card-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; } +.hint { font-size: 12px; color: var(--ink-faint); margin: 8px 0 0; line-height: 1.5; } +.hint.inline { margin: 0; } + +.picker { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.picker:last-of-type { margin-bottom: 0; } +.picked { + flex: 1; min-width: 0; font-size: 12.5px; color: var(--ink-soft); + background: var(--sunk); border: 1px solid var(--rule); border-radius: 5px; + padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + direction: rtl; text-align: left; /* keep the filename visible when the path is long */ +} +.picked.set { color: var(--ink); } + +.tabs { display: flex; gap: 6px; margin-bottom: 12px; } +.tab { + appearance: none; font: inherit; font-size: 12.5px; cursor: pointer; + border: 1px solid var(--rule); background: var(--panel); color: var(--ink-soft); + padding: 5px 12px; border-radius: 20px; +} +.tab.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; } + +.row { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; } +.row:last-child { margin-bottom: 0; } +.row label { font-size: 12.5px; color: var(--ink-soft); width: 62px; flex: none; } +select, input[type="number"] { + font: inherit; font-size: 12.5px; padding: 5px 9px; + border: 1px solid var(--rule); border-radius: 5px; + background: var(--panel); color: var(--ink); +} +input[type="number"] { width: 110px; } +select { min-width: 220px; } + +.btn { + appearance: none; font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; + padding: 7px 14px; border-radius: 5px; + border: 1px solid var(--accent); background: var(--accent); color: var(--on-accent); +} +.btn:hover { filter: brightness(1.06); } +.btn:disabled { opacity: .5; cursor: default; filter: none; } +.btn.quiet { background: transparent; color: var(--ink); border-color: var(--rule-strong); } +.btn.big { font-size: 14px; padding: 9px 20px; } +.btn.small { font-size: 12px; padding: 4px 10px; margin-top: 8px; } + +.actions { display: flex; align-items: center; gap: 14px; margin-top: 18px; } + +.banner { + border-radius: 6px; padding: 11px 14px; margin-bottom: 14px; font-size: 13px; + border: 1px solid transparent; white-space: pre-wrap; line-height: 1.5; +} +.banner.bad { background: var(--danger-soft); border-color: var(--danger); color: var(--ink); } +.banner.warn { background: var(--warn-soft); border-color: var(--warn); color: var(--ink); } + +/* ── stages ───────────────────────────────────────────────────────────── */ +.stages { display: flex; flex-direction: column; } +.stage { + display: grid; grid-template-columns: 20px 1fr auto auto; gap: 10px; + align-items: center; padding: 7px 0; border-bottom: 1px solid var(--rule); + font-size: 13px; +} +.stage:last-child { border-bottom: none; } +.stage .mark { text-align: center; font-size: 12px; color: var(--ink-faint); } +.stage.done .mark { color: var(--ok); } +.stage.now { background: var(--accent-soft); border-radius: 5px; padding-left: 8px; padding-right: 8px; margin: 0 -8px; } +.stage.now .mark { color: var(--accent); } +.stage.todo .name, .stage.todo .mark { color: var(--ink-faint); } +.stage .name { min-width: 0; } +.stage .stat, .stage .time { + font-size: 11.5px; color: var(--ink-faint); font-variant-numeric: tabular-nums; text-align: right; +} +.stage .time { min-width: 58px; } + +/* ── log and command ──────────────────────────────────────────────────── */ +.details summary { font-size: 13px; font-weight: 600; cursor: pointer; } +.cmd, .log { + font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; + font-size: 11.5px; line-height: 1.6; background: var(--sunk); + border: 1px solid var(--rule); border-radius: 6px; padding: 10px 12px; + margin: 10px 0 0; white-space: pre-wrap; word-break: break-word; +} +.logcard { display: flex; flex-direction: column; } +.log { max-height: 300px; overflow-y: auto; white-space: pre; word-break: normal; margin: 0; } + +/* ── results ──────────────────────────────────────────────────────────── */ +.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 12px; } +.kpi { background: var(--panel); border: 1px solid var(--rule); border-radius: var(--radius); padding: 12px 14px; } +.kpi .v { font-size: 23px; font-weight: 650; font-variant-numeric: tabular-nums; letter-spacing: -.02em; } +.kpi .k { font-size: 11.5px; color: var(--ink-soft); margin-top: 1px; } +.kpi .u { + font-family: ui-monospace, "Cascadia Mono", monospace; + font-size: 10.5px; color: var(--ink-faint); margin-top: 5px; +} +.card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; flex-wrap: wrap; } +.card-sub { font-size: 12px; color: var(--ink-faint); margin-top: 4px; line-height: 1.5; max-width: 62ch; } +.pill { display: inline-flex; align-items: center; font-size: 11px; font-weight: 600; + padding: 1px 9px; border-radius: 20px; vertical-align: 1px; } +.pill.ok { background: var(--ok-soft); color: var(--ok); } +.pill.warn { background: var(--warn-soft); color: var(--warn); } +.pill.bad { background: var(--danger-soft); color: var(--danger); } +.pill.mute { background: var(--sunk); color: var(--ink-faint); } +.bar { height: 4px; border-radius: 3px; background: var(--sunk); overflow: hidden; margin-top: 12px; } +.bar > i { display: block; height: 100%; width: 40%; background: var(--accent); + animation: slide 1.4s ease-in-out infinite; } +@keyframes slide { 0% { margin-left: -40%; } 100% { margin-left: 100%; } } +@media (prefers-reduced-motion: reduce) { .bar > i { animation: none; width: 100%; } } + +/* ── settings editor ──────────────────────────────────────────────────── */ +input[type="search"] { + font: inherit; font-size: 12.5px; padding: 6px 10px; + border: 1px solid var(--rule); border-radius: 5px; + background: var(--panel); color: var(--ink); +} +.toggle-label { display: flex; align-items: center; gap: 6px; font-size: 12.5px; + color: var(--ink-soft); white-space: nowrap; } +.sec-title { font-size: 12px; font-weight: 600; letter-spacing: .06em; + text-transform: uppercase; color: var(--ink-faint); + margin: 18px 0 6px; font-family: ui-monospace, monospace; } +.setting { display: grid; grid-template-columns: 1fr auto; gap: 3px 14px; + padding: 10px 0; border-bottom: 1px solid var(--rule); align-items: start; } +.setting:last-child { border-bottom: none; } +.setting .sname { font-family: ui-monospace, "Cascadia Mono", monospace; + font-size: 12.5px; font-weight: 500; } +.setting .shelp { grid-column: 1; font-size: 11.5px; color: var(--ink-faint); + line-height: 1.45; max-width: 62ch; } +.setting .sctl { grid-column: 2; grid-row: 1 / span 2; display: flex; + align-items: center; gap: 8px; } +.setting .sctl input[type="text"], .setting .sctl select { + font-family: ui-monospace, monospace; font-size: 12.5px; + border: 1px solid var(--rule); border-radius: 5px; padding: 4px 8px; + background: var(--panel); color: var(--ink); min-width: 130px; +} +.setting.changed .sname { color: var(--accent); } +.banner.ok { background: var(--ok-soft); border-color: var(--ok); color: var(--ink); } + +.files { display: flex; flex-direction: column; gap: 5px; font-size: 12.5px; } +.files code { + font-family: ui-monospace, "Cascadia Mono", monospace; font-size: 11.5px; + background: var(--sunk); border: 1px solid var(--rule); + border-radius: 4px; padding: 1px 6px; +} + +@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } diff --git a/desktop/ui/app.js b/desktop/ui/app.js new file mode 100644 index 00000000..069eff9c --- /dev/null +++ b/desktop/ui/app.js @@ -0,0 +1,764 @@ +// MuMDIA console frontend. +// +// No framework and no build step, deliberately: the heaviest screen here is a list +// of stages and a handful of fields, and adding Node to the release pipeline for +// that would be a poor trade. If this grows into the generated 86-field settings +// editor, revisit it then. +// +// State lives in the backend. This polls `run_state` and renders what it returns, +// so a reload or a reopened window shows the truth rather than a stale copy. + +const { invoke } = window.__TAURI__.core; +const dialog = window.__TAURI__.dialog; + +const $ = (id) => document.getElementById(id); + +// ── pipeline order ────────────────────────────────────────────────────────── +// The engine reports the stage that produced each artifact, but not what is still +// to come, so the expected sequence lives here. Library-input mode skips the three +// library-building stages, exactly as the engine does. +const STAGES_FASTA = [ + ["convert", "Reading spectra"], + ["digest", "Digesting the FASTA"], + ["peptidoforms", "Expanding peptidoforms"], + ["predict-frag", "Predicting the library"], + ["search-seed", "First-pass search"], + ["rt-im-train", "Retention-time model"], + ["extract", "Extracting chromatograms"], + ["features", "Computing features"], + ["compete", "Competition"], + ["rescore", "Rescoring"], + ["quant", "Quantification"], + ["report", "Writing the report"], +]; +const SKIP_IN_LIBRARY_MODE = new Set(["digest", "peptidoforms", "predict-frag"]); + +const state = { + mode: "fasta", + picks: { mzml: "", fasta: "", lib_precursors: "", lib_fragments: "", out_dir: "" }, + runId: null, + timer: null, + lastStatus: null, + outDir: "", + componentsReady: false, + setupTimer: null, + schema: null, + overrides: {}, + savedConfig: null, + // Output folders this application has used. The only thing it remembers; what + // each one contains is read back from the folder. + known: JSON.parse(localStorage.getItem("mumdia.folders") || "[]"), +}; + +function rememberFolder(dir) { + if (!dir || state.known.includes(dir)) return; + state.known.unshift(dir); + state.known = state.known.slice(0, 50); + try { + localStorage.setItem("mumdia.folders", JSON.stringify(state.known)); + } catch { + // A browser storage that refuses to write is not worth failing a search over. + } +} + +// ── small helpers ─────────────────────────────────────────────────────────── +const fmtInt = (n) => (n ?? 0).toLocaleString("en-GB"); + +function fmtDuration(ms) { + if (!ms || ms < 0) return ""; + const s = Math.round(ms / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; + return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; +} + +function baseName(p) { + if (!p) return ""; + const parts = p.split(/[\\/]/); + return parts[parts.length - 1] || p; +} + +function show(el, on) { + if (on) el.removeAttribute("hidden"); + else el.setAttribute("hidden", ""); +} + +function banner(el, message) { + if (!message) { + show(el, false); + return; + } + el.textContent = message; + show(el, true); +} + +function screen(name) { + for (const s of document.querySelectorAll(".screen")) { + s.classList.toggle("on", s.id === `screen-${name}`); + } + for (const b of document.querySelectorAll(".nav")) { + b.classList.toggle("on", b.dataset.screen === name); + } +} + +// ── startup ───────────────────────────────────────────────────────────────── +async function init() { + const cores = navigator.hardwareConcurrency; + if (cores) $("cores").textContent = `${cores} cores available`; + + try { + const info = await invoke("engine_info"); + $("engine-line").textContent = `${info.version}\n${info.path}`; + $("engine-line").title = `${info.version} — found via ${info.source}\n${info.path}`; + } catch (e) { + $("engine-line").textContent = "engine not found"; + banner($("engine-error"), String(e)); + $("start").disabled = true; + } + + try { + const list = await invoke("presets"); + const sel = $("preset"); + for (const p of list) { + const o = document.createElement("option"); + o.value = p.path; + o.textContent = p.name; + sel.appendChild(o); + } + } catch { + /* Presets are a convenience; engine defaults remain available without them. */ + } + + for (const b of document.querySelectorAll(".nav")) { + b.addEventListener("click", () => { + if (b.disabled) return; + screen(b.dataset.screen); + if (b.dataset.screen === "setup") refreshComponents(); + if (b.dataset.screen === "settings") loadSettings(); + if (b.dataset.screen === "history") loadHistory(); + }); + } + for (const t of document.querySelectorAll(".tab")) { + t.addEventListener("click", () => setMode(t.dataset.mode)); + } + for (const b of document.querySelectorAll("[data-pick]")) { + b.addEventListener("click", () => pick(b.dataset.pick)); + } + $("start").addEventListener("click", start); + $("install-primary").addEventListener("click", () => installComponents("primary")); + $("install-ms2pip").addEventListener("click", () => installComponents("ms2pip")); + refreshComponents(); + $("stop").addEventListener("click", stop); + $("another").addEventListener("click", () => screen("input")); + $("open-folder").addEventListener("click", () => invoke("reveal", { path: state.outDir })); + $("copy-cmd").addEventListener("click", async () => { + await navigator.clipboard.writeText($("cmd").textContent); + $("copy-cmd").textContent = "Copied"; + setTimeout(() => ($("copy-cmd").textContent = "Copy"), 1200); + }); +} + + +// ── settings editor ───────────────────────────────────────────────────────── +// The form is generated from configs/config-schema.json, which the engine's own +// documentation generator emits from config.rs. Nothing about a setting -- its +// name, type, default or help text -- is written here, so this cannot describe a +// parameter the engine does not have. + +async function loadSettings() { + if (state.schema) return; + try { + state.schema = await invoke("config_schema"); + } catch (e) { + banner($("settings-error"), String(e)); + return; + } + $("settings-search").addEventListener("input", renderSettings); + $("only-changed").addEventListener("change", renderSettings); + $("save-settings").addEventListener("click", saveSettings); + renderSettings(); +} + +/// The value currently shown for a setting: an override if one was typed, else the +/// engine's default. +function currentValue(f) { + return f.path in state.overrides ? state.overrides[f.path] : f.default; +} + +function renderSettings() { + const q = $("settings-search").value.trim().toLowerCase(); + const onlyChanged = $("only-changed").checked; + const bySection = new Map(); + + for (const f of state.schema.fields) { + const changed = f.path in state.overrides; + if (onlyChanged && !changed) continue; + if (q && !(f.path.toLowerCase().includes(q) || f.help.toLowerCase().includes(q))) continue; + const sec = f.section || "(top level)"; + if (!bySection.has(sec)) bySection.set(sec, []); + bySection.get(sec).push(f); + } + + const esc = (t) => + String(t).replace(/&/g, "&").replace(//g, ">") + .replace(/"/g, """); + + const parts = []; + for (const [sec, fields] of bySection) { + parts.push(`
${esc(sec)}
`); + for (const f of fields) { + const changed = f.path in state.overrides; + const v = currentValue(f); + let control; + if (f.kind === "bool") { + control = + ``; + } else if (f.kind === "enum" && f.choices) { + control = + ``; + } else { + control = ``; + } + // A gated parameter is one the project documents as not to be changed from a + // single benchmark count. Saying so where the decision is made is the whole + // reason the schema carries the marker. + const gate = f.gates.length + ? `gated` + : ""; + const badge = changed ? `changed` : ""; + parts.push( + `
` + + `
${esc(f.path)} ${gate} ${badge}
` + + `
${esc(f.help || "No description in the engine source.")}` + + (f.default !== null && f.default !== undefined + ? ` Default: ${esc(f.default)}` + : "") + + `
${control}
` + ); + } + parts.push(`
`); + } + + const list = $("settings-list"); + list.innerHTML = + parts.join("") || `

Nothing matches that search.

`; + + for (const el of list.querySelectorAll("[data-path]")) { + el.addEventListener("change", () => onSettingChanged(el)); + } + + const n = Object.keys(state.overrides).length; + $("settings-sub").textContent = + `${state.schema.fields.length} settings. ` + + (n ? `${n} changed from the defaults; only those are saved.` : "None changed."); +} + +/// Record a change, or drop it when the value returns to the default. +/// +/// Dropping matters: a saved configuration is meant to be the difference from the +/// defaults, and a value typed and then typed back should leave nothing behind. +function onSettingChanged(el) { + const path = el.dataset.path; + const f = state.schema.fields.find((x) => x.path === path); + if (!f) return; + let raw = el.value; + let value = raw; + if (f.kind === "bool") value = raw === "true"; + else if (f.kind === "integer" || f.kind === "float") { + const n = Number(raw); + if (raw.trim() === "" || Number.isNaN(n)) { + banner($("settings-error"), `${path} must be a number.`); + return; + } + value = n; + } + banner($("settings-error"), ""); + if (JSON.stringify(value) === JSON.stringify(f.default)) delete state.overrides[path]; + else state.overrides[path] = value; + renderSettings(); +} + +async function saveSettings() { + banner($("settings-error"), ""); + banner($("settings-saved"), ""); + try { + const path = await invoke("save_settings", { + name: "console", + overrides: state.overrides, + }); + state.savedConfig = path; + // The saved file becomes the preset the next search uses, so the settings on + // this screen and the settings a run uses cannot diverge. + const sel = $("preset"); + let opt = [...sel.options].find((o) => o.value === path); + if (!opt) { + opt = document.createElement("option"); + opt.value = path; + opt.textContent = "My settings"; + sel.appendChild(opt); + } + sel.value = path; + banner( + $("settings-saved"), + `Saved and accepted by the engine. The next search will use these settings.` + ); + } catch (e) { + banner($("settings-error"), String(e)); + } +} + +// ── components ────────────────────────────────────────────────────────────── +// Polled while an installation runs, then left alone. The backend owns the state, +// so closing and reopening this screen shows the truth rather than a stale copy. +async function refreshComponents() { + let c; + try { + c = await invoke("components_status"); + } catch (e) { + banner($("setup-error"), String(e)); + return; + } + const p = c.primary; + state.componentsReady = !!p.complete; + + const pill = $("primary-pill"); + const btn = $("install-primary"); + const installing = p.install_status === "installing"; + show($("primary-bar"), installing); + show($("install-log-card"), installing || p.install_status === "failed"); + + if (installing) { + pill.textContent = "installing…"; + pill.className = "pill warn"; + btn.disabled = true; + } else if (p.complete) { + pill.textContent = "installed"; + pill.className = "pill ok"; + btn.disabled = true; + btn.textContent = "Installed"; + } else { + pill.textContent = "not installed"; + pill.className = "pill bad"; + btn.disabled = !c.primary.uv; + btn.textContent = "Install"; + } + + if (!c.primary.uv && !p.complete) { + banner( + $("setup-error"), + "The installer component `uv` was not found beside the application or on PATH, " + + "so components cannot be installed automatically." + ); + } else if (p.error) { + banner($("setup-error"), p.error); + } else { + banner($("setup-error"), ""); + } + + const m = c.ms2pip; + const mpill = $("ms2pip-pill"); + const mbtn = $("install-ms2pip"); + if (m.install_status === "installing") { + mpill.textContent = "installing…"; + mpill.className = "pill warn"; + mbtn.disabled = true; + } else if (m.complete) { + mpill.textContent = "installed"; + mpill.className = "pill ok"; + mbtn.disabled = true; + mbtn.textContent = "Installed"; + } else { + mpill.textContent = "optional"; + mpill.className = "pill mute"; + mbtn.disabled = false; + } + + // One log pane, showing whichever installation is talking. + const active = m.install_status === "installing" ? m : p; + const logEl = $("install-log"); + const text = (active.install_log || []).join("\n"); + if (logEl.textContent !== text) { + logEl.textContent = text; + logEl.scrollTop = logEl.scrollHeight; + } + + const versions = Object.entries(p.versions || {}); + $("setup-versions").textContent = versions.length + ? versions.map(([k, v]) => `${k} ${v}`).join(" · ") + : ""; + + const busy = installing || m.install_status === "installing"; + if (busy && !state.setupTimer) { + state.setupTimer = setInterval(refreshComponents, 900); + } else if (!busy && state.setupTimer) { + clearInterval(state.setupTimer); + state.setupTimer = null; + } +} + +async function installComponents(env) { + banner($("setup-error"), ""); + try { + await invoke("components_install", { env }); + } catch (e) { + banner($("setup-error"), String(e)); + return; + } + refreshComponents(); +} + +function setMode(mode) { + state.mode = mode; + for (const t of document.querySelectorAll(".tab")) t.classList.toggle("on", t.dataset.mode === mode); + show($("mode-fasta"), mode === "fasta"); + show($("mode-library"), mode === "library"); +} + +// ── file pickers ──────────────────────────────────────────────────────────── +const FILTERS = { + mzml: [{ name: "mzML", extensions: ["mzML", "mzml"] }], + fasta: [{ name: "FASTA", extensions: ["fasta", "fa", "fas"] }], + lib_precursors: [{ name: "Parquet", extensions: ["parquet"] }], + lib_fragments: [{ name: "Parquet", extensions: ["parquet"] }], +}; +const LABEL = { + mzml: "p-mzml", + fasta: "p-fasta", + lib_precursors: "p-libp", + lib_fragments: "p-libf", + out_dir: "p-out", +}; + +async function pick(what) { + const chosen = + what === "out_dir" + ? await dialog.open({ directory: true, multiple: false }) + : await dialog.open({ multiple: false, filters: FILTERS[what] }); + if (!chosen) return; + const path = Array.isArray(chosen) ? chosen[0] : chosen; + state.picks[what] = path; + if (what === "mzml") showPeakCensus(path); + const el = $(LABEL[what]); + // Shown right-to-left so the filename stays visible on a long path; the full + // path is the tooltip. + el.textContent = path; + el.title = path; + el.classList.add("set"); + banner($("start-error"), ""); +} + +// ── the peak cap, answered from the file ──────────────────────────────────── +// The documentation is emphatic that a peak cap is acquisition-specific, and that +// one carried from another run deletes fragment evidence rather than failing: on a +// 50-window Orbitrap DIA run a 300-peak cap discarded 78.6% of MS2 peaks and cost +// 60% of the peptides. The application has the file, so it answers the question +// rather than leaving a number box for someone to guess into. +async function showPeakCensus(mzml) { + const panel = $("peak-note"); + const body = $("peak-body"); + body.textContent = "Reading the file…"; + show(panel, true); + let c; + try { + c = await invoke("peak_census", { mzml }); + } catch { + // Not being able to read it here is not a reason to say anything alarming; the + // engine will report the real problem if the file is unusable. + show(panel, false); + return; + } + const p = c.peaks_per_ms2; + const at300 = (c.caps || []).find((x) => x.cap === 300); + const lines = [ + `${fmtInt(c.ms2_spectra)} MS2 spectra sampled. Peaks per spectrum: ` + + `p25 ${fmtInt(p.p25)}, median ${fmtInt(p.p50)}, p95 ${fmtInt(p.p95)}, ` + + `max ${fmtInt(p.max)}.`, + ]; + if (at300 && at300.fraction_of_peaks_discarded > 0.02) { + lines.push( + `A 300-peak cap would truncate ${(at300.fraction_of_spectra_truncated * 100).toFixed(0)}% ` + + `of spectra and discard ${(at300.fraction_of_peaks_discarded * 100).toFixed(0)}% of all ` + + `peaks. Uncapped is the default, and is right for this file.` + ); + } else { + lines.push("No cap is applied by default, which is right for this file."); + } + if (c.profile_ms2_spectra > 0) { + lines.push( + `${fmtInt(c.profile_ms2_spectra)} spectra are profile mode, so these are raw ` + + `sample counts rather than centroided peaks.` + ); + } + body.textContent = lines.join(" "); +} + +// ── starting and polling ──────────────────────────────────────────────────── +async function start() { + banner($("start-error"), ""); + const p = state.picks; + const threads = parseInt($("threads").value, 10); + const req = { + mzml: p.mzml, + out_dir: p.out_dir, + fasta: state.mode === "fasta" ? p.fasta || null : null, + lib_precursors: state.mode === "library" ? p.lib_precursors || null : null, + lib_fragments: state.mode === "library" ? p.lib_fragments || null : null, + config: $("preset").value || null, + threads: Number.isFinite(threads) && threads > 0 ? threads : null, + }; + + // Ask the backend whether this is runnable before starting it, so a missing + // component or a configuration that needs no components at all is explained here + // rather than failing once the engine is under way. + banner($("preflight-block"), ""); + try { + const pf = await invoke("preflight", { req }); + if (!pf.ok) { + banner( + $("preflight-block"), + pf.blockers.join("\n\n") + + (pf.components_complete ? "" : "\n\nOpen Setup to install the components.") + ); + return; + } + // Not blocking, but worth saying before an hour is spent on it. + banner($("preflight-note"), (pf.warnings || []).join("\n\n")); + } catch (e) { + // A preflight that cannot run is not a reason to refuse: say so and let the + // engine be the judge, since it reports its own errors perfectly well. + banner($("preflight-block"), `Could not check before starting: ${e}`); + } + + try { + state.runId = await invoke("start_run", { req }); + } catch (e) { + banner($("start-error"), String(e)); + return; + } + + state.outDir = p.out_dir; + rememberFolder(p.out_dir); + state.lastStatus = null; + $("nav-progress").disabled = false; + $("nav-results").disabled = true; + $("stop").disabled = false; + $("log").textContent = ""; + banner($("run-error"), ""); + screen("progress"); + + clearInterval(state.timer); + state.timer = setInterval(poll, 700); + poll(); +} + +async function stop() { + if (!state.runId) return; + $("stop").disabled = true; + $("stop").textContent = "Stopping…"; + try { + await invoke("cancel_run", { id: state.runId }); + } catch (e) { + banner($("run-error"), String(e)); + } +} + +async function poll() { + if (!state.runId) return; + let s; + try { + s = await invoke("run_state", { id: state.runId }); + } catch { + return; + } + if (!s) return; + render(s); + + if (s.status !== "running" && s.status !== "starting") { + clearInterval(state.timer); + state.timer = null; + $("stop").disabled = true; + $("stop").textContent = "Stop"; + if (s.status === "done") { + $("nav-results").disabled = false; + renderResults(s); + screen("results"); + } + } +} + +// ── history ───────────────────────────────────────────────────────────────── +async function loadHistory() { + const list = $("history-list"); + let entries = []; + try { + entries = await invoke("history", { dirs: state.known }); + } catch (e) { + list.innerHTML = `

Could not read past searches: ${e}

`; + return; + } + if (!entries.length) { + list.innerHTML = `

No past searches yet.

`; + return; + } + const esc = (t) => + String(t).replace(/&/g, "&").replace(//g, ">"); + list.innerHTML = entries + .map((e) => { + const r = e.results; + const when = e.finished_unix_ms + ? new Date(e.finished_unix_ms).toLocaleString() + : ""; + const counts = r + ? `${fmtInt(r.peptides_1pct)} peptides at peptide_q_value 0.01 · ` + + `${fmtInt(r.protein_groups_1pct)} protein groups · ${esc(r.classifier)}` + : "no scored table in this folder"; + return ( + `
` + + `
${esc(e.name)}
` + + `
${counts}
${esc(when)} · ${esc(e.out_dir)}` + + (e.engine_version ? ` · ${esc(e.engine_version)}` : "") + + `
` + + `` + + `
` + ); + }) + .join(""); + for (const b of list.querySelectorAll("[data-open]")) { + b.addEventListener("click", () => invoke("reveal", { path: b.dataset.open })); + } +} + +// ── rendering ─────────────────────────────────────────────────────────────── +function render(s) { + const seen = new Map(s.stages.map((x) => [x.name, x])); + const expected = STAGES_FASTA.filter( + ([key]) => !(s.library_mode && SKIP_IN_LIBRARY_MODE.has(key)) + ); + + // The furthest stage with a report is the one in progress; everything before it + // is done. `report` writes TSVs rather than an artifact report, so it is only + // ever complete once the process itself has finished successfully. + let lastSeen = -1; + expected.forEach(([key], i) => { + if (seen.has(key)) lastSeen = i; + }); + const finished = s.status === "done"; + + const rows = expected.map(([key, label], i) => { + const st = seen.get(key); + let cls, mark; + if (finished) { + cls = "done"; + mark = "✓"; + } else if (st && i < lastSeen) { + cls = "done"; + mark = "✓"; + } else if (st && i === lastSeen) { + cls = s.status === "running" ? "now" : "done"; + mark = s.status === "running" ? "●" : "✓"; + } else if (i === lastSeen + 1 && s.status === "running") { + cls = "now"; + mark = "●"; + } else { + cls = "todo"; + mark = "·"; + } + const stat = st && st.rows ? `${fmtInt(st.rows)} rows` : ""; + const time = st && st.elapsed_ms ? fmtDuration(st.elapsed_ms) : ""; + return `
+ ${mark} + ${label} + ${stat} + ${time} +
`; + }); + $("stages").innerHTML = rows.join(""); + + const titles = { + starting: "Starting", + running: "Searching", + done: "Finished", + failed: "Search failed", + cancelled: "Search stopped", + }; + $("prog-title").textContent = titles[s.status] || s.status; + + const done = expected.filter(([k]) => seen.has(k)).length; + const parts = []; + if (s.status === "running") parts.push(`stage ${Math.min(done + 1, expected.length)} of ${expected.length}`); + if (s.elapsed_ms) parts.push(fmtDuration(s.elapsed_ms)); + if (s.status === "cancelled") parts.push("partial results were discarded"); + $("prog-sub").textContent = parts.join(" · "); + + $("cmd").textContent = s.command; + + if (s.error && (s.status === "failed" || s.status === "cancelled")) { + banner($("run-error"), s.error); + } + + // Only touch the log when it changed, so a user scrolled up to read something + // is not yanked back to the bottom on every poll. + const log = $("log"); + const text = s.log.join("\n"); + if (log.textContent !== text) { + const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 40; + log.textContent = text; + if (atBottom) log.scrollTop = log.scrollHeight; + } +} + +function renderResults(s) { + const r = s.results; + $("res-title").textContent = "Finished"; + const bits = [fmtDuration(s.elapsed_ms)]; + if (r && r.classifier) bits.push(`rescoring: ${r.classifier}`); + if (r && r.config_hash) bits.push(`settings ${r.config_hash.slice(0, 8)}`); + $("res-sub").textContent = bits.filter(Boolean).join(" · "); + + // The requested classifier and the one that ran can differ, when a sidecar fails + // and strict mode is off. Saying so is the whole point of reading the artifact + // report rather than echoing the request. + const requested = (r?.classifier_requested || "").toLowerCase().replace(/_/g, ""); + const actual = (r?.classifier || "").toLowerCase().replace(/_/g, ""); + if (r && requested && actual && requested !== actual) { + banner( + $("res-warn"), + `Rescoring fell back to ${r.classifier}; ${r.classifier_requested} was requested. ` + + `The counts below come from ${r.classifier}.` + ); + } else { + banner($("res-warn"), ""); + } + + // Every count names its row unit and its q-value column. A number without both + // is not interpretable, and this is where a screenshot gets taken. + const kpis = r + ? [ + [fmtInt(r.peptides_1pct), "peptides", "peptide_q_value ≤ 0.01"], + [fmtInt(r.precursors_1pct), "precursors", "precursor_q ≤ 0.01"], + [fmtInt(r.protein_groups_1pct), "protein groups", "pg_q_value ≤ 0.01"], + [fmtInt(r.psms), "PSMs scored", "all, before thresholding"], + ] + : []; + $("kpis").innerHTML = kpis + .map(([v, k, u]) => `
${v}
${k}
${u}
`) + .join(""); + + const files = []; + if (r?.has_peptides_tsv) files.push(["peptides.tsv", "one row per (peptidoform, charge), selected by peptide q"]); + if (r?.has_proteins_tsv) files.push(["proteins.tsv", "protein groups"]); + files.push(["psms_scored.parquet", "every scored match, with all features"]); + files.push(["manifest.json", "engine version, commit, and a hash of every input"]); + $("files").innerHTML = files + .map(([f, d]) => `
${f}  ${d}
`) + .join(""); +} + +init(); diff --git a/desktop/ui/index.html b/desktop/ui/index.html new file mode 100644 index 00000000..a82fc3b3 --- /dev/null +++ b/desktop/ui/index.html @@ -0,0 +1,260 @@ + + + + + +MuMDIA + + + + + + +
+ + +
+

Analysis components

+

+ MuMDIA needs a small Python environment for retention-time modelling and + rescoring. It is installed once, into your user folder, and needs no + administrator rights. +

+ + + +
+
+
+
+ Required components checking… +
+
+ Retention-time modelling (DeepLC) and rescoring. Downloads a few hundred + megabytes the first time. +
+
+ +
+ +
+ +
+
+
+
+ MS2PIP optional +
+
+ Only for building a library from a FASTA with predicted fragment + intensities. Installed separately because MS2PIP and DeepLC require + incompatible versions of a shared dependency and cannot share an + environment. +
+
+ +
+
+ + + +

+
+ + +
+

New search

+

Select the data, choose where results go, and start.

+ + + +
+
Spectra
+
+
No file selected
+ +
+
+ +
+
Search space
+
+ + +
+ +
+
+
No file selected
+ +
+

+ The engine digests the FASTA and predicts the library itself. Simplest to + start from, and considerably less sensitive than an imported library. +

+
+ + +
+ +
+
Results folder
+
+
No folder selected
+ +
+

+ Use an empty folder. A search overwrites its own outputs and cannot resume, so + starting a second search here replaces the first. +

+
+ +
+
Settings
+
+ + +
+
+ + + +
+
+ + + + + + +
+ + Runs take from minutes to hours. +
+
+ + +
+
+
+

All settings

+

+ Every engine parameter, with its default and the explanation from the source. +

+
+ +
+ + + + +
+ + +
+ +
+
+ + +
+
+
+

Searching

+

Starting…

+
+ +
+ + + +
+
+
+ +
+ Command being run +

+      
+    
+ +
+
Log
+

+    
+
+ + +
+
+
+

Results

+

+
+ +
+ + + +
+ +
+
Files
+
+
+ +
+ +
+
+ + +
+

Past searches

+

+ Read back from the folders themselves, so a search moved or deleted on disk + simply stops appearing. +

+
+
+ +
+ + + + diff --git a/docs/14_build_test_deploy_gotchas.md b/docs/14_build_test_deploy_gotchas.md index 0ef30fb6..f09def32 100644 --- a/docs/14_build_test_deploy_gotchas.md +++ b/docs/14_build_test_deploy_gotchas.md @@ -31,12 +31,13 @@ configurations in `configs/`, the container definition in `Dockerfile` + | `rust/mumdia/crates/mumdia-io/Cargo.toml` | I/O crate; adds `arrow`/`parquet`/`blake3` over `mumdia-core` | | `rust/mumdia/crates/mumdia/tests/pipeline.rs` | The only integration test file: extract -> features -> compete -> rescore on crafted Parquet | | `rust/mumdia/crates/mumdia-core/build.rs` | Stamps the git commit and build date into the crate so `manifest.json` can record them | -| `.github/workflows/ci.yml` | Seven jobs: `lint` (fmt + clippy `-D warnings` + rustdoc), `audit` (`cargo audit`/`cargo deny`), `build-test` matrix on ubuntu/macos/windows, `smoke` (end-to-end `run` and `run-experiment` on a generated fixture, ubuntu + windows), `sidecar-imports` (a real conda env per sidecar, matrixed, plus `pip-audit`), `smoke-cross-platform` (asserts the two platforms produced byte-identical `peptides.tsv` and `proteins.tsv`), `sidecars` (compileall + JSON/YAML parse + doc-reference check + generated-document freshness); on push-to-`main`, every PR, weekly, and on demand | +| `.github/workflows/ci.yml` | Eight jobs: `lint` (fmt + clippy `-D warnings` + rustdoc), `audit` (`cargo audit`/`cargo deny`), `build-test` matrix on ubuntu/macos/windows, `smoke` (end-to-end `run` and `run-experiment` on a generated fixture, ubuntu + windows), `sidecar-imports` (a real conda env per sidecar, matrixed, plus `pip-audit`), `smoke-cross-platform` (asserts the two platforms produced byte-identical `peptides.tsv` and `proteins.tsv`), `desktop` (the console's own workspace: fmt, clippy, unit tests, and a frontend/backend consistency check), `sidecars` (compileall + JSON/YAML parse + doc-reference check + generated-document freshness); on push-to-`main`, every PR, weekly, and on demand | | `.github/workflows/release.yml` | Dormant until a `v*` tag; `validate-tag` gates on tag-equals-workspace-version, ancestry from `main` and a green `ci.yml` for that exact SHA, then builds three target binaries, smoke-tests each, unpacks each archive into a clean directory and runs that archive's own `ci/smoke.sh`, and attaches archives + `.sha256` to the Release. `workflow_dispatch` rehearses everything except the upload | | `.github/workflows/docker.yml` | Builds the image into the local daemon, smoke-tests it, then pushes to GHCR only on a `v*` tag; build-and-smoke-only on `workflow_dispatch` | | `.github/dependabot.yml` | Monthly grouped Cargo + GitHub Actions + Docker base-image updates; `arrow*`/`parquet*` grouped apart because they carry the on-disk contract. No `pip` entry: the Python pins live in the pip sections of the `env/` conda specifications, which Dependabot cannot parse | | `ci/check_doc_refs.py` | Fails when a tracked file cites a Markdown document the repository does not ship | | `ci/check_workflows.py` | Rejects workflow YAML GitHub would refuse, notably duplicate mapping keys, which PyYAML accepts silently and which only surface when a workflow is dispatched | +| `ci/check_desktop_ui.py` | Checks the desktop frontend against its backend: every element id it looks up exists, and the `invoke` names and `generate_handler!` list agree in both directions | | `ci/smoke.sh` | End-to-end smoke test: builds the fixture, runs `convert` and `run`, then `ci/check_smoke.py`; runnable locally as well as in CI | | `ci/make_fixture_mzml.py` | Generates the fixture mzML from `test_data/fixture.fasta` and the library the engine builds from it, so the planted peaks cannot disagree with the mass model | | `ci/check_smoke.py` | Asserts the smoke run's artifacts, manifest completeness, and schema versions | diff --git a/docs/23_cli_reference.md b/docs/23_cli_reference.md index 5706750d..74cbcb51 100644 --- a/docs/23_cli_reference.md +++ b/docs/23_cli_reference.md @@ -54,6 +54,7 @@ Commands: align Cross-run RT alignment (experiment-level) -> alignment.parquet mbr Match-between-runs identification transfer (Stage D3) -> transferred.parquet inspect Print schema, head sample, and row count for any artifact + peak-census Peaks per MS2 spectrum for an mzML, as JSON: percentiles plus what each candidate `--top-peaks-ms2` cap would discard audit Candidate audit: reconstruct per-candidate stage flags + earliest rejection reason across the artifact chain and write candidate_audit.parquet (sensitivity program, P0.3/P0.4). Non-destructive; reruns no compute report Write peptides.tsv + proteins.tsv from a scored PSM table doctor Check that the configured Python sidecar environments are usable @@ -145,16 +146,17 @@ first sentence of the description, with the full text in the section below. | [`align`](#align) | yes | Cross-run RT alignment (experiment-level) -> alignment.parquet | | [`mbr`](#mbr) | yes | Match-between-runs identification transfer (Stage D3) -> transferred.parquet | | [`inspect`](#inspect) | no | Print schema, head sample, and row count for any artifact | +| [`peak-census`](#peak-census) | no | Peaks per MS2 spectrum for an mzML, as JSON: percentiles plus what each candidate `--top-peaks-ms2` cap would discard | | [`audit`](#audit) | no | Candidate audit: reconstruct per-candidate stage flags + earliest rejection reason across the artifact chain and write candidate_audit.parquet (sensitivity program, P0.3/P0.4). | | [`report`](#report) | yes | Write peptides.tsv + proteins.tsv from a scored PSM table | | [`doctor`](#doctor) | yes | Check that the configured Python sidecar environments are usable | | `help` | n/a | Print this message or the help of the given subcommand(s) | -17 of the 21 documented subcommands accept `--config`: +17 of the 22 documented subcommands accept `--config`: `align`, `compete`, `digest`, `doctor`, `extract`, `features`, `mbr`, `peptidoforms`, `predict-frag`, `prescan`, `quant`, `report`, `rescore`, `rt-im-train`, `run`, `run-experiment`, `search-seed`. -4 do not, so every setting they use comes from their own flags: - `audit`, `convert`, `inspect`, `quant-lfq`. +5 do not, so every setting they use comes from their own flags: + `audit`, `convert`, `inspect`, `peak-census`, `quant-lfq`. ## convert @@ -588,6 +590,26 @@ Arguments: Plus the 5 repeated flags removed above: see "Global flags". +## peak-census + +```text +Peaks per MS2 spectrum for an mzML, as JSON: percentiles plus what each candidate `--top-peaks-ms2` cap would discard. + +The pre-flight for a decision the documentation says must be made per acquisition. Reading it before setting a cap is the difference between bounding peak volume and deleting fragment evidence from most spectra. + +Usage: mumdia peak-census [OPTIONS] --mzml + +Options: + --mzml + + --max-spectra + Stop after this many spectra from the head of the file (0 = all) + + [default: 0] +``` + +Plus the 5 repeated flags removed above: see "Global flags". + ## audit ```text @@ -665,6 +687,11 @@ Usage: mumdia doctor [OPTIONS] Options: --config + + --json + Emit the report as JSON on stdout instead of prose on stdout. + + For a caller that has to act on the result rather than read it: the desktop application renders one row per role and offers to install what is missing, which means it needs the modules and versions as data, not a paragraph to regex. The exit status is unchanged. ``` Plus the 5 repeated flags removed above: see "Global flags". diff --git a/env/console-ms2pip-requirements.txt b/env/console-ms2pip-requirements.txt new file mode 100644 index 00000000..5c88d287 --- /dev/null +++ b/env/console-ms2pip-requirements.txt @@ -0,0 +1,17 @@ +# Optional MS2PIP environment for the desktop application. +# +# Separate from env/console-requirements.txt because it has to be: at the versions +# this project tests, MS2PIP and DeepLC cannot share an environment. +# +# deeplc==4.1.1 -> psm-utils>=1.5 -> sqlalchemy>=2 +# ms2pip==4.0.0 -> sqlalchemy>=1.3,<2 +# +# Needed only for FASTA-mode library building with predicted fragment intensities. +# The recommended workflow imports a library instead and never invokes MS2PIP. +# +# Pinned to the version the project tests. `ms2pip>=4.1` resolves alongside DeepLC +# and would collapse this back into one environment, but it changes predicted +# intensities, so it is an upgrade to measure rather than to assume. +ms2pip==4.0.0 +numpy +pandas diff --git a/env/console-requirements.txt b/env/console-requirements.txt new file mode 100644 index 00000000..ffa27ee8 --- /dev/null +++ b/env/console-requirements.txt @@ -0,0 +1,35 @@ +# The single environment the desktop application installs, covering every sidecar +# role at once. +# +# Why this instead of the two conda specifications beside this file: rescoring, +# DeepLC and match-between-runs share one dependency set happily, and that is the +# whole recommended workflow, so one interpreter serves all three. MS2PIP is the +# exception, and is dealt with below. +# +# Why pip and not conda: reading env/mumdia-deeplc.yml, conda contributes +# `python=3.11` and `pip`, and everything that matters is already pip. `uv` supplies +# the interpreter too, as one self-contained binary, so the application can build +# this with no conda anywhere on the user's machine. +# +# Python 3.11 rather than 3.12: mokapot and ms2pip pull `pandas<2`, which has no +# cp312 wheel, which is exactly why env/docker-rescore.yml pins 3.11 as well. +# +# Versions are the ones the project already tests. Keep them in step with +# env/mumdia-deeplc.yml and env/mumdia-rescore.yml; they are the same pins, merged. +--extra-index-url https://download.pytorch.org/whl/cpu + +# Retention-time prediction and per-run fine-tuning. 4.1.1 is a floor, not a +# preference: the 4.0.0a2 preview overfits fine-tuning badly enough to invert +# retention-time model rankings (docs/08_rt_im_train.md). +deeplc==4.1.1 +torch==2.12.1+cpu +psm-utils + +# Rescoring. +mokapot==0.10.0 +scikit-learn + +# Shared by every worker. +numpy +pandas +pyarrow diff --git a/rust/mumdia/crates/mumdia/src/main.rs b/rust/mumdia/crates/mumdia/src/main.rs index 67efdfb6..06cf51e7 100644 --- a/rust/mumdia/crates/mumdia/src/main.rs +++ b/rust/mumdia/crates/mumdia/src/main.rs @@ -377,6 +377,19 @@ enum Cmd { }, /// Print schema, head sample, and row count for any artifact. Inspect { artifact: String }, + /// Peaks per MS2 spectrum for an mzML, as JSON: percentiles plus what each + /// candidate `--top-peaks-ms2` cap would discard. + /// + /// The pre-flight for a decision the documentation says must be made per + /// acquisition. Reading it before setting a cap is the difference between + /// bounding peak volume and deleting fragment evidence from most spectra. + PeakCensus { + #[arg(long)] + mzml: String, + /// Stop after this many spectra from the head of the file (0 = all). + #[arg(long, default_value_t = 0)] + max_spectra: usize, + }, /// Candidate audit: reconstruct per-candidate stage flags + earliest rejection /// reason across the artifact chain and write candidate_audit.parquet /// (sensitivity program, P0.3/P0.4). Non-destructive; reruns no compute. @@ -432,9 +445,150 @@ enum Cmd { Doctor { #[arg(long)] config: Option, + /// Emit the report as JSON on stdout instead of prose on stdout. + /// + /// For a caller that has to act on the result rather than read it: the + /// desktop application renders one row per role and offers to install what + /// is missing, which means it needs the modules and versions as data, not a + /// paragraph to regex. The exit status is unchanged. + #[arg(long)] + json: bool, }, } +/// One sidecar role, as `doctor` found it. +#[derive(serde::Serialize)] +struct RoleReport { + /// `rescore` | `deeplc` | `ms2pip` | `mbr` + role: String, + /// The configuration field that names this interpreter. + field: String, + /// Does THIS configuration need the role at all. + required: bool, + /// `ok` | `fail` | `skip` | `warn` + status: String, + python: Option, + /// `configured`, an environment variable name, `CONDA_PREFIX`, `PATH`, ... + provenance: String, + /// What the workers for this role import. + modules: Vec, + /// Of those, the ones this interpreter cannot import. + missing: Vec, + /// Versions of the packages whose version changes results. + versions: std::collections::BTreeMap, + /// The environment variable that overrides this role. + env_var: String, + /// Anything worth saying that is not a failure, such as a DeepLC below the floor. + warnings: Vec, +} + +/// The worker-script directory check, which runs before any interpreter. +#[derive(serde::Serialize)] +struct ScriptsReport { + /// `ok` | `fail` | `skip` + status: String, + dir: String, + /// Worker files the directory should contain but does not. + missing: Vec, + /// False when the configuration needs no sidecar, in which case the directory + /// is never opened and its absence is not a problem. + needed: bool, +} + +/// The whole report. `ok` is the same verdict the exit status carries. +#[derive(serde::Serialize)] +struct DoctorReport { + ok: bool, + scripts: ScriptsReport, + roles: Vec, +} + +/// Peaks-per-MS2-spectrum percentiles for one mzML. +/// +/// The pre-flight the peak-cap decision needs. `docs/04_convert.md` is emphatic that +/// `--top-peaks-ms2` is acquisition-specific and that a value carried from another +/// run deletes fragment evidence: on one 50-window Orbitrap DIA run a 300-peak cap +/// discarded 78.6% of all MS2 peaks and cost 60% of the peptides. The playbook's +/// advice is to compute the percentiles before setting a cap, and this is that +/// computation, callable rather than described. +/// +/// Reads peaks and counts them; it does not centroid, so a profile-mode file reports +/// raw sample counts and says so. +fn peak_census(mzml: &str, max_spectra: usize) -> Result { + use mzdata::prelude::*; + + // The same reader uses, so this sees exactly the spectra a run would. + let reader = mzdata::MZReader::open_path(mzml).with_context(|| format!("opening {mzml}"))?; + + let mut counts: Vec = Vec::new(); + let mut profile = 0usize; + let mut ms1 = 0usize; + for (i, spec) in reader.enumerate() { + if max_spectra > 0 && i >= max_spectra { + break; + } + if spec.ms_level() != 2 { + if spec.ms_level() == 1 { + ms1 += 1; + } + continue; + } + if spec.signal_continuity() == mzdata::spectrum::SignalContinuity::Profile { + profile += 1; + } + let n = spec + .raw_arrays() + .and_then(|a| a.mzs().ok().map(|m| m.len())) + .unwrap_or(0); + counts.push(n); + } + + if counts.is_empty() { + anyhow::bail!("{mzml} contains no MS2 spectra, so there is nothing to cap"); + } + counts.sort_unstable(); + let pct = |p: f64| -> usize { + let idx = ((counts.len() - 1) as f64 * p).round() as usize; + counts[idx.min(counts.len() - 1)] + }; + let total: u64 = counts.iter().map(|&c| c as u64).sum(); + + // Computed before the macro: `json!` parses its values as literals and cannot + // take an iterator chain in value position. + let caps: Vec = [50usize, 100, 200, 300, 500, 1000] + .iter() + .map(|&cap| { + let kept: u64 = counts.iter().map(|&c| c.min(cap) as u64).sum(); + let truncated = counts.iter().filter(|&&c| c > cap).count(); + serde_json::json!({ + "cap": cap, + "spectra_truncated": truncated, + "fraction_of_spectra_truncated": truncated as f64 / counts.len() as f64, + "fraction_of_peaks_discarded": + if total == 0 { 0.0 } else { 1.0 - kept as f64 / total as f64 }, + }) + }) + .collect(); + + Ok(serde_json::json!({ + "mzml": mzml, + "ms1_spectra": ms1, + "ms2_spectra": counts.len(), + "profile_ms2_spectra": profile, + "total_ms2_peaks": total, + "peaks_per_ms2": { + "min": counts[0], + "p25": pct(0.25), + "p50": pct(0.50), + "p75": pct(0.75), + "p95": pct(0.95), + "max": counts[counts.len() - 1], + }, + // What a cap would actually cost, which is the question being asked. + "caps": caps, + })) +} + /// Report whether this configuration can actually run: which interpreter each /// sidecar role resolves to, whether it can import what its workers import, which /// versions it has, and whether the worker scripts are where the engine will look @@ -445,7 +599,7 @@ enum Cmd { /// checked that `sidecar_script_dir` existed (the most common misconfiguration, /// and the one baked into the tracked example config), and reported no versions, /// so a DeepLC old enough to change results looked identical to a current one. -fn doctor(cfg: &Config, config_path: Option<&str>) -> Result<()> { +fn doctor_report(cfg: &Config, config_path: Option<&str>) -> DoctorReport { use mumdia::python::{self, Role, ALL_ROLES}; let mut cfg = cfg.clone(); @@ -453,7 +607,7 @@ fn doctor(cfg: &Config, config_path: Option<&str>) -> Result<()> { let dir_moved = script_dir != cfg.predict_frag.sidecar_script_dir; cfg.predict_frag.sidecar_script_dir = script_dir.clone(); - let mut bad = false; + let mut ok = true; let any_sidecar = ALL_ROLES.iter().any(|r| r.required_by(&cfg)); // 1. Worker scripts, checked before the interpreters because a missing script @@ -461,43 +615,50 @@ fn doctor(cfg: &Config, config_path: Option<&str>) -> Result<()> { // configuration needs no sidecar: the native predictors and `native_tda` // rescorer are the default, and that run must not be failed for a directory // it never opens. - println!("worker scripts"); let dir = std::path::Path::new(&script_dir); - if !any_sidecar { - println!(" [skip] no Python sidecar is needed by this configuration"); + let scripts = if !any_sidecar { + ScriptsReport { + status: "skip".into(), + dir: script_dir.clone(), + missing: Vec::new(), + needed: false, + } } else if !dir.is_dir() { - bad = true; - println!( - " [FAIL] predict_frag.sidecar_script_dir: {script_dir} is not a directory.\n\ - \x20 Point it at the `scripts/` directory that ships beside the binary." - ); - } else { - if dir_moved { - println!(" [note] resolved sidecar_script_dir to {script_dir}"); + ok = false; + ScriptsReport { + status: "fail".into(), + dir: script_dir.clone(), + missing: Vec::new(), + needed: true, } - let mut missing: Vec<&str> = Vec::new(); + } else { + let mut missing: Vec = Vec::new(); for role in ALL_ROLES { if !role.required_by(&cfg) { continue; } for worker in role.workers() { if !dir.join(worker).exists() { - missing.push(worker); + missing.push(worker.to_string()); } } } - if missing.is_empty() { - println!(" [ ok ] {script_dir}"); - } else { - bad = true; - missing.sort_unstable(); - missing.dedup(); - println!(" [FAIL] {script_dir}: missing {}", missing.join(", ")); + missing.sort_unstable(); + missing.dedup(); + if !missing.is_empty() { + ok = false; } - } + ScriptsReport { + status: if missing.is_empty() { "ok" } else { "fail" }.into(), + dir: script_dir.clone(), + missing, + needed: true, + } + }; + let _ = dir_moved; - // 2. Interpreters, one line per role, resolving `auto` exactly as a run would. - println!("sidecar interpreters"); + // 2. Interpreters, one entry per role, resolving `auto` exactly as a run would. + let mut roles = Vec::new(); for role in ALL_ROLES { let configured = match role { Role::Rescore => cfg.rescore.python.clone(), @@ -506,19 +667,32 @@ fn doctor(cfg: &Config, config_path: Option<&str>) -> Result<()> { Role::Mbr => cfg.mbr.python.clone(), }; let required = role.required_by(&cfg); - let modules = role.modules(&cfg); + let modules: Vec = role.modules(&cfg).iter().map(|m| m.to_string()).collect(); let explicit = configured .as_deref() .map(|v| !v.eq_ignore_ascii_case(python::AUTO)) .unwrap_or(false); - let label = role.field(); - // Neither needed nor named: say so and probe nothing. Discovery here used - // to run for every role and then report the interpreter it happened to - // find as "configured but not needed", which described neither the config - // nor the outcome. + let mut r = RoleReport { + role: format!("{role:?}").to_lowercase(), + field: role.field().to_string(), + required, + status: "skip".into(), + python: None, + provenance: "not required".into(), + modules, + missing: Vec::new(), + versions: std::collections::BTreeMap::new(), + env_var: role.env_var().to_string(), + warnings: Vec::new(), + }; + + // Neither needed nor named: say so and probe nothing. Discovery here used to + // run for every role and then report the interpreter it happened to find as + // "configured but not needed", which described neither the config nor the + // outcome. if !required && !explicit { - println!(" [skip] {label}: not needed by this config"); + roles.push(r); continue; } @@ -530,89 +704,138 @@ fn doctor(cfg: &Config, config_path: Option<&str>) -> Result<()> { None => (None, "not found"), } }; + r.provenance = provenance.to_string(); + r.python = path.clone(); + match (&path, required) { (None, true) => { - bad = true; - println!( - " [FAIL] {label}: required by this config, and no usable interpreter was \ - found.\n\x20 Set it, set {}, or activate an environment that can \ - import {}.", - role.env_var(), - modules.join(", ") - ); + ok = false; + r.status = "fail".into(); } - (None, false) => println!(" [skip] {label}: not needed by this config"), + (None, false) => r.status = "skip".into(), (Some(p), _) => { let interp = std::path::Path::new(p); - match python::missing_modules(interp, modules) { + let module_refs: Vec<&str> = r.modules.iter().map(|s| s.as_str()).collect(); + match python::missing_modules(interp, &module_refs) { Ok(missing) if missing.is_empty() => { - // Versions of the packages whose version changes results. - let mut notes: Vec = Vec::new(); for m in ["deeplc", "torch", "mokapot", "ms2pip", "numpy"] { - if modules.contains(&m) { + if module_refs.contains(&m) { if let Some(v) = python::module_version(interp, m) { - notes.push(format!("{m} {v}")); + r.versions.insert(m.to_string(), v); } } } - let tag = if required { " ok " } else { "note" }; - println!( - " [{tag}] {label}: {p} ({provenance}){}", - if notes.is_empty() { - String::new() - } else { - format!("\n\x20 {}", notes.join(", ")) - } - ); - if !required { - println!("\x20 (configured but not needed by this config)"); - } + r.status = if required { "ok" } else { "note" }.into(); // DeepLC below 4.1.1 changes results rather than only - // performance: the 4.0.0a2 multitask preview overfits - // per-run fine-tuning badly enough to invert RT-model - // rankings (docs/08_rt_im_train.md). + // performance: the 4.0.0a2 multitask preview overfits per-run + // fine-tuning badly enough to invert RT-model rankings + // (docs/08_rt_im_train.md). if role == Role::DeepLc && required { - if let Some(v) = python::module_version(interp, "deeplc") { - if version_below(&v, &[4, 1, 1]) { - println!( - "\x20 [warn] DeepLC {v} is older than the \ - supported floor 4.1.1; results, not just speed, differ" - ); + if let Some(v) = r.versions.get("deeplc") { + if version_below(v, &[4, 1, 1]) { + r.warnings.push(format!( + "DeepLC {v} is older than the supported floor 4.1.1; \ + results, not just speed, differ" + )); } } } } Ok(missing) => { if required { - bad = true; + ok = false; } - println!( - " [{}] {label}: {p} ({provenance}) cannot import {}", - if required { "FAIL" } else { "warn" }, - missing.join(", ") - ); + r.status = if required { "fail" } else { "warn" }.into(); + r.missing = missing; } Err(e) => { if required { - bad = true; + ok = false; } - println!( - " [{}] {label}: {e}", - if required { "FAIL" } else { "warn" } - ); + r.status = if required { "fail" } else { "warn" }.into(); + r.warnings.push(e.to_string()); } } } } + roles.push(r); } - if bad { - anyhow::bail!( - "mumdia doctor: this configuration cannot run as it stands (see the FAIL lines above)" - ); + DoctorReport { ok, scripts, roles } +} + +/// Render the report as the prose a person reads in a terminal. +fn print_doctor(rep: &DoctorReport) { + println!("worker scripts"); + match rep.scripts.status.as_str() { + "skip" => println!(" [skip] no Python sidecar is needed by this configuration"), + "ok" => println!(" [ ok ] {}", rep.scripts.dir), + _ if !rep.scripts.missing.is_empty() => println!( + " [FAIL] {}: missing {}", + rep.scripts.dir, + rep.scripts.missing.join(", ") + ), + _ => println!( + " [FAIL] predict_frag.sidecar_script_dir: {} is not a directory.\n\ + \x20 Point it at the `scripts/` directory that ships beside the binary.", + rep.scripts.dir + ), + } + + println!("sidecar interpreters"); + for r in &rep.roles { + let label = &r.field; + match (r.status.as_str(), &r.python) { + ("skip", _) => println!(" [skip] {label}: not needed by this config"), + ("fail", None) => println!( + " [FAIL] {label}: required by this config, and no usable interpreter was \ + found.\n\x20 Set it, set {}, or activate an environment that can \ + import {}.", + r.env_var, + r.modules.join(", ") + ), + (status, Some(p)) + if r.missing.is_empty() && r.warnings.iter().all(|w| w.contains("DeepLC")) => + { + let tag = if status == "ok" { " ok " } else { "note" }; + let notes: Vec = + r.versions.iter().map(|(k, v)| format!("{k} {v}")).collect(); + println!( + " [{tag}] {label}: {p} ({}){}", + r.provenance, + if notes.is_empty() { + String::new() + } else { + format!("\n\x20 {}", notes.join(", ")) + } + ); + if !r.required { + println!("\x20 (configured but not needed by this config)"); + } + for w in &r.warnings { + println!("\x20 [warn] {w}"); + } + } + (status, Some(p)) if !r.missing.is_empty() => println!( + " [{}] {label}: {p} ({}) cannot import {}", + if status == "fail" { "FAIL" } else { "warn" }, + r.provenance, + r.missing.join(", ") + ), + (status, _) => { + for w in &r.warnings { + println!( + " [{}] {label}: {w}", + if status == "fail" { "FAIL" } else { "warn" } + ); + } + } + } + } + + if rep.ok { + println!("mumdia doctor: configuration is runnable"); } - println!("mumdia doctor: configuration is runnable"); - Ok(()) } /// True when the dotted version `v` is below `floor`. Unparseable components @@ -1094,6 +1317,12 @@ fn main() -> Result<()> { Cmd::Inspect { artifact } => { print!("{}", mumdia_io::inspect(&artifact)?); } + Cmd::PeakCensus { mzml, max_spectra } => { + println!( + "{}", + serde_json::to_string_pretty(&peak_census(&mzml, max_spectra)?)? + ); + } Cmd::Report { psms_scored, out_dir, @@ -1127,12 +1356,20 @@ fn main() -> Result<()> { "MuMDIA: {n_pep} peptides, {n_prot} protein groups at q <= {q}\n {pep}\n {prot}" ); } - Cmd::Doctor { config } => { + Cmd::Doctor { config, json } => { // Deliberately the raw loader: doctor must be able to diagnose a // configuration whose interpreters do not resolve, so it cannot go // through the resolving loader that would bail first. let cfg = load_config_raw(&config)?; - doctor(&cfg, config.as_deref())?; + let report = doctor_report(&cfg, config.as_deref()); + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print_doctor(&report); + } + if !report.ok { + anyhow::bail!("this configuration cannot run as it stands; see the report above"); + } } } Ok(())