diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md
similarity index 98%
rename from CONTRIBUTING.md
rename to .github/CONTRIBUTING.md
index 504ad6b0..930ec561 100644
--- a/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -98,7 +98,7 @@ A pull request does not reopen these. An issue can discuss them.
- `config/defaults/tool_versions.yml` pins Julia, R, bun and the external tools,
and `install.jl` and CI both install from it. The Julia version is repeated in
- the CI matrix. Change a pin by editing that file, and expect the change to be
+ the CI workflow. Change a pin by editing that file, and expect the change to be
discussed.
- R packages are pinned in `renv.lock`.
- CI job names carry no version numbers, so that a pin change leaves required
diff --git a/codecov.yml b/.github/codecov.yml
similarity index 100%
rename from codecov.yml
rename to .github/codecov.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..2f06b99e
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,53 @@
+# Dependabot version updates. Until this file landed, only security updates
+# ran (those need no configuration), and those failed for a separate reason:
+# the "Dependabot Updates" runs of 2026-09-26..27 tried to fix alerts against
+# fixtures under test/doi using the npm_and_yarn updater, which refuses a
+# Bun-managed project ("bun.lock but no package-lock.json"). Those fixtures
+# have since left the repository and both alerts are fixed.
+#
+# What cannot be covered here: Julia (Project.toml / Manifest.toml) and R
+# (renv.lock) are not Dependabot package-ecosystems. Their pins live in
+# config/defaults/tool_versions.yml and are asserted by
+# test/unit/test_install_pins.jl and the CI workflow instead.
+#
+# The workflows pin every action to a full commit SHA (this account's
+# allowlist policy, asserted by test/unit/test_install_pins.jl). Dependabot
+# advances those pins to the current SHA of the tracked version, so the
+# pinning policy itself survives updates.
+#
+# The #124 rule: minor and patch updates are batched, because they are
+# interchangeable in kind; a MAJOR (React 18->19, vite 6->8, TypeScript
+# 5->7 -- which #124 landed as one grouped PR, with the build already red
+# until #140 repaired it) is a migration, not an update. Every group below
+# therefore names its update-types as minor and patch ONLY: an update that
+# matches no group opens its own PR, carrying its own review and its own CI
+# run. test/unit/test_install_pins.jl asserts this shape, so a new group
+# cannot silently go back to batching majors.
+version: 2
+updates:
+ # The frontend is Bun-managed: package.json plus bun.lock, no npm/yarn/pnpm
+ # lockfile, so it must be declared as the "bun" ecosystem.
+ - package-ecosystem: "bun"
+ directory: "/frontend"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "chore(deps)"
+ groups:
+ # Minor and patch in one weekly PR; majors arrive one PR each.
+ frontend-minor-and-patch:
+ patterns: ["*"]
+ update-types: ["minor", "patch"]
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "chore(deps)"
+ groups:
+ # Same rule as the frontend: a new major of an action (checkout
+ # v4.4.0 -> v7.0.1 came through #123) gets a PR of its own.
+ actions-minor-and-patch:
+ patterns: ["*"]
+ update-types: ["minor", "patch"]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 912daaba..4de43ca4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,20 @@
name: CI
+# This fork's account allows only GitHub's own actions (actions/*) and its own,
+# each pinned to a full commit SHA. Upstream's julia-actions, oven-sh/setup-bun
+# and codecov/codecov-action are none of these, and with them this workflow
+# ended in startup_failure on every run. So Julia and Bun are installed here
+# from the archives config/defaults/tool_versions.yml pins, each refused unless
+# it hashes to the pinned SHA256, and the Julia depot is cached with
+# actions/cache. Coverage is kept as a workflow artifact, since the Codecov
+# project is upstream's. test/unit/test_install_pins.jl asserts that every
+# `uses:` in .github/workflows is allowed by that policy.
+
on:
push:
branches: [main]
+ # Any base branch, so a PR stacked on another one is tested too.
pull_request:
- branches: [main]
permissions:
contents: read
@@ -17,8 +27,33 @@ concurrency:
jobs:
test:
- name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }}
- runs-on: ${{ matrix.os }}
+ # The name is a fixed string, it carries no version, and the job has NO matrix.
+ # Both are required, and the first without the second is a trap that has already
+ # been sprung here once.
+ #
+ # A branch ruleset matches a required status check by the DISPLAY NAME GitHub posts
+ # for the job. A renamed check does not fail -- it is simply absent, and an absent
+ # required check can never be satisfied, so every pull request deadlocks until an
+ # admin bypasses the rule, triggered by nothing more alarming than editing a version
+ # number.
+ #
+ # MEASURED on hyperpolymath/MetaManifold-WebUI#39 (2026-09-21): this job, named exactly `Julia tests` but still
+ # carrying a 1x1 `strategy.matrix`, posted `Julia tests (1.12.5, ubuntu-24.04)`.
+ # GitHub appends the matrix combination whenever the name does not already reference
+ # the matrix, so a 1x1 matrix is still a matrix and the pins were still embedded in
+ # the check name. The matrix was therefore removed rather than merely renamed around:
+ # it selected exactly one combination and bought nothing.
+ #
+ # The two values it held are literals below, each keeping its own reasoning.
+ # `test/unit/test_install_pins.jl` asserts, for EVERY job in this file, that the name
+ # interpolates nothing AND that the job has no matrix -- so this cannot regress
+ # silently. If a matrix is ever genuinely wanted here, the required context must move
+ # to a matrix-free aggregator job first.
+ name: Julia tests
+ # Explicit, not ubuntu-latest: the R pin below names an apt package revision built
+ # for 24.04, and a runner that silently rolled to the next LTS would take that pin
+ # with it. `runs-on` cannot read `env`, so this is a literal.
+ runs-on: ubuntu-24.04
# The repository's root .Rprofile sources renv/activate.R, so R started from
# the checkout auto-activates renv and rewrites the library paths. renv is a
# local-development convenience; CI installs into the system library and
@@ -28,30 +63,45 @@ jobs:
# the environment, so those commands pass --no-init-file instead.
env:
RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE"
- strategy:
- fail-fast: false
- matrix:
- # Pinned rather than tracking latest stable, because a pinned Manifest.toml
- # resolved by an unpinned Julia is not a reproducible build. This must equal
- # julia_version in Manifest.toml; it cannot be read from the pin file, since
- # nothing can be read before Julia exists. test/unit/test_install_pins.jl
- # fails if the three ever disagree.
- julia-version: ["1.12.5"]
- # Explicit, not ubuntu-latest: the R pin below names an apt package revision
- # built for 24.04, and a runner that silently rolled to the next LTS would
- # take that pin with it.
- os: [ubuntu-24.04]
+ # Pinned rather than tracking latest stable, because a pinned Manifest.toml
+ # resolved by an unpinned Julia is not a reproducible build. These must equal
+ # runtimes.julia.archives.linux-x86_64 in the pin file, whose version must
+ # equal julia_version in Manifest.toml; they cannot be read from the pin
+ # file, since nothing can be read before Julia exists.
+ # test/unit/test_install_pins.jl fails if any of them disagree.
+ JULIA_URL: https://julialang-s3.julialang.org/bin/linux/x64/1.12/julia-1.12.5-linux-x86_64.tar.gz
+ JULIA_SHA256: 41b84d727e4e96fbf3ed9e92fa195d773d247b9097f73fad688f8b699758bae7
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
-
- - name: Set up Julia
- uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- version: ${{ matrix.julia-version }}
+ persist-credentials: false # nothing here pushes
- - name: Cache Julia packages
- uses: julia-actions/cache@d10a6fd8f31b12404a54613ebad242900567f2b9 # v2.1.0
+ - name: Install Julia
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$JULIA_URL" julia.tar.gz
+ echo "$JULIA_SHA256 julia.tar.gz" | sha256sum -c -
+ sudo mkdir -p /opt/julia
+ sudo tar xzf julia.tar.gz -C /opt/julia --strip-components=1
+ rm julia.tar.gz
+ echo /opt/julia/bin >> "$GITHUB_PATH"
+
+ # The registry, packages, artifacts and precompiled caches, keyed on the
+ # Manifest (which also names the Julia version). Saved once instantiation
+ # has succeeded, so a failed run cannot store a half-built depot.
+ - name: Restore the Julia depot
+ id: julia-depot
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: |
+ ~/.julia/registries
+ ~/.julia/packages
+ ~/.julia/artifacts
+ ~/.julia/compiled
+ key: julia-depot-${{ runner.os }}-${{ hashFiles('Manifest.toml') }}
+ restore-keys: |
+ julia-depot-${{ runner.os }}-
# Every external version CI installs is read from the committed pin file, so CI
# and a developer's machine cannot drift apart. A temporary environment is used
@@ -67,7 +117,10 @@ jobs:
pins = YAML.load_file("config/defaults/tool_versions.yml")
open(ENV["GITHUB_ENV"], "a") do io
println(io, "R_APT_VERSION=", pins["runtimes"]["r"]["apt_version"])
- println(io, "BUN_VERSION=", pins["toolchain"]["bun"]["version"])
+ bun = pins["toolchain"]["bun"]
+ println(io, "BUN_VERSION=", bun["version"])
+ println(io, "BUN_URL=", bun["archives"]["linux-x86_64"]["url"])
+ println(io, "BUN_SHA256=", bun["archives"]["linux-x86_64"]["sha256"])
println(io, "CUTADAPT_SPEC=cutadapt==", pins["tools"]["cutadapt"]["version"])
println(io, "MULTIQC_SPEC=multiqc==", pins["tools"]["multiqc"]["version"])
fastqc = pins["tools"]["fastqc"]["archives"]["any"]
@@ -122,7 +175,7 @@ jobs:
# the restore runs as root. The run id makes each key unique so every attempt
# saves its progress; restore-keys picks the newest match.
- name: Restore the renv cache
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /opt/renv-cache
key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }}
@@ -139,7 +192,7 @@ jobs:
sudo chmod 777 "$RENV_PATHS_CACHE"
sudo env R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)'
sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)'
- Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
+ Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS","zCompositions")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
# Saved on failure too, since the packages built before a failure are the ones
# the next attempt should reuse. Root writes the cache, so it is made readable first.
@@ -149,7 +202,7 @@ jobs:
- name: Save the renv cache
if: always()
- uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /opt/renv-cache
key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }}
@@ -204,10 +257,28 @@ jobs:
- name: Instantiate Julia project
run: julia --project=. -e 'import Pkg; Pkg.instantiate()'
- - name: Set up Bun
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ - name: Save the Julia depot
+ if: steps.julia-depot.outputs.cache-hit != 'true'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
- bun-version: ${{ env.BUN_VERSION }}
+ path: |
+ ~/.julia/registries
+ ~/.julia/packages
+ ~/.julia/artifacts
+ ~/.julia/compiled
+ key: julia-depot-${{ runner.os }}-${{ hashFiles('Manifest.toml') }}
+
+ # Downloaded against the pinned URL and refused unless it hashes to the pinned
+ # SHA256, as install.jl does when no bun of this version is on PATH.
+ - name: Install Bun
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$BUN_URL" bun.zip
+ echo "$BUN_SHA256 bun.zip" | sha256sum -c -
+ unzip -q bun.zip
+ sudo mv bun-linux-x64/bun /usr/local/bin/bun
+ rm -rf bun.zip bun-linux-x64
+ test "$(bun --version)" = "$BUN_VERSION"
- name: Build frontend
working-directory: frontend
@@ -230,7 +301,7 @@ jobs:
julia --project=. -e '
using RCall
R"cat(.libPaths(), sep=\"\n\")"
- for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS"]
+ for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS", "zCompositions"]
R"if (!require($pkg, character.only=TRUE, quietly=TRUE)) stop($pkg, \" not found\")"
end
'
@@ -272,13 +343,67 @@ jobs:
fi
exit "$status"
+ # What julia-processcoverage did: the .cov files under src/ to lcov.info, with
+ # CoverageTools pinned to an exact release.
- name: Process coverage
- uses: julia-actions/julia-processcoverage@03114f09f119417c3242a9fb6e0b722676aedf38 # v1.2.2
+ run: |
+ julia -e '
+ using Pkg
+ Pkg.activate(temp = true)
+ Pkg.add(name = "CoverageTools", version = "1.4.2")
+ using CoverageTools
+ LCOV.writefile("lcov.info", process_folder("src"))'
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2
+ # The Codecov project (codecov.yml, the README badge) is upstream's, so the fork
+ # keeps its coverage as an artifact of the run instead.
+ - name: Keep coverage
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: lcov
+ path: lcov.info
+ retention-days: 14
+
+ # Machine-checked proofs of the Benjamini-Hochberg adjustment, the TSS/RLE
+ # scaling algebra and PERMANOVA's permutation bookkeeping (proofs/agda,
+ # proofs/agda/README.md).
+ # Agda publishes no 2.6.4.3 binary, and agda-stdlib 2.1 requires Agda 2.6.4.x,
+ # so the pair comes from Debian trixie, which packages exactly that pair with
+ # pre-built library interfaces. The image is pinned by digest and the two
+ # packages by revision, so a rebuild in the archive fails loudly here instead
+ # of silently changing the checker. These pins live here rather than in
+ # config/defaults/tool_versions.yml because no other job and no installer
+ # reads them.
+ agda:
+ name: Agda proofs
+ runs-on: ubuntu-24.04
+ container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
+ timeout-minutes: 30
+ env:
+ DEBIAN_FRONTEND: noninteractive
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- files: lcov.info
- token: ${{ secrets.CODECOV_TOKEN }}
- slug: JoshuaJewell/MetaManifold-WebUI
- fail_ci_if_error: false
+ persist-credentials: false # nothing here pushes
+
+ - name: Install Agda and the standard library
+ run: |
+ apt-get update -qq
+ apt-get install -y --no-install-recommends agda-bin=2.6.4.3-1+b2 agda-stdlib=2.1-4
+ agda --version | grep -qx 'Agda version 2.6.4.3'
+ grep -qx 'name: standard-library-2.1' /usr/share/agda-stdlib/standard-library.agda-lib
+
+ # Runs before the type-check: --safe already refuses postulates, but the
+ # audit also refuses holes, check-disabling pragmas and flags, and any
+ # module that MetaManifold/All.agda does not import (an unimported module
+ # is never type-checked at all).
+ - name: Axiom audit
+ run: bash proofs/tests/axiom-audit.sh
+
+ - name: Type-check
+ env:
+ AGDA_DIR: /tmp/agda-dir
+ run: |
+ mkdir -p "$AGDA_DIR"
+ echo /usr/share/agda-stdlib/standard-library.agda-lib > "$AGDA_DIR/libraries"
+ cd proofs/agda
+ agda MetaManifold/All.agda
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
new file mode 100644
index 00000000..0513de5c
--- /dev/null
+++ b/.github/workflows/occupancy.yml
@@ -0,0 +1,238 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# The occupancy reading of the vsearch stage (docs/occupancy-2026-10.adoc):
+# * the two-thread disagreement test (test/occupancy_disagreement_test.jl) on
+# the pinned vsearch, and
+# * Session IR's counted bytes against the typed peak computed in Agda from
+# the pinned proof siblings (scripts/check-occupancy.sh).
+#
+# This account allows only GitHub's own actions and its own, pinned by SHA, so
+# everything here installs from pinned URLs checked against pinned SHA256s, as
+# ci.yml now does too. The disagreement test also runs in ci.yml's full suite;
+# it runs here as well so that a change to the probe gets its own check.
+#
+# #116 merged with this workflow's "Occupancy typed peak" check red (its squash
+# carried a draft `TH.[_]` placeholder; #117 fixed the proof), because nothing
+# required the check to pass first -- and nothing COULD have: a check posts
+# only when its workflow runs, and the pull_request trigger was path-filtered,
+# so requiring these checks would have deadlocked every PR that touches no
+# occupancy file. A required check that never posts can never be satisfied; the
+# trap ci.yml's header records for its own job names.
+#
+# So the pull_request path filter moved into the `Occupancy relevance` gate
+# below. The workflow now runs on EVERY pull request; the gate decides whether
+# the change touches the occupancy reading; the two heavy jobs run only when it
+# does, and post their checks as `skipped` otherwise, which a required check
+# counts as satisfied. The gate itself posts on every PR too, and it is the one
+# check of the three that must be required without exception: if the gate
+# failed, its dependents would be skipped and would satisfy their requirement
+# silently. The push trigger keeps its native paths filter, so a push to main
+# still starts this workflow only when it should.
+name: Occupancy reading
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - src/core/memory_probe.jl
+ - src/core/provenance.jl
+ - src/pipeline/tools.jl
+ - src/server/routes/pipeline.jl
+ - test/occupancy_disagreement_test.jl
+ - test/unit/test_occupancy_grade.jl
+ - scripts/check-occupancy-grades.jl
+ - scripts/lint-occupancy-readings.sh
+ - docs/occupancy/**
+ - docs/occupancy-2026-10.adoc
+ - scripts/check-occupancy.sh
+ - scripts/fetch-siblings.sh
+ - .github/workflows/occupancy.yml
+ # No paths filter, on purpose (see above): the gate must post its check on
+ # every PR, whichever files the PR touches. Any base branch, so a PR stacked
+ # on another one is gated too.
+ pull_request:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name != 'push' }}
+
+env:
+ # Julia 1.12.5, the version Manifest.toml was resolved with. The checksum is
+ # julialang's own (bin/checksums/julia-1.12.5.sha256), as the official Docker
+ # image (docker-library/julia a39df01) records it.
+ JULIA_URL: https://julialang-s3.julialang.org/bin/linux/x64/1.12/julia-1.12.5-linux-x86_64.tar.gz
+ JULIA_SHA256: 41b84d727e4e96fbf3ed9e92fa195d773d247b9097f73fad688f8b699758bae7
+
+jobs:
+ # Decides whether the PR touches the occupancy reading at all. The file list
+ # is the pull_request half of the old paths filter, moved here because a
+ # pinned-actions workflow cannot use a third-party paths-filter action (the
+ # account's allow-list refuses one) -- plain `case` globs are all it takes.
+ # In a case pattern `*` crosses `/`, so `docs/occupancy/*` is the old
+ # `docs/occupancy/**`.
+ gate:
+ name: Occupancy relevance
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ outputs:
+ relevant: ${{ steps.push.outputs.relevant || steps.pr.outputs.relevant }}
+ steps:
+ # A push only starts this workflow when a `paths:` file changed (the
+ # trigger filter above), so the gate has nothing to decide on pushes.
+ - name: A push is already path-filtered
+ id: push
+ if: github.event_name == 'push'
+ run: echo 'relevant=true' >> "$GITHUB_OUTPUT"
+
+ # Full history, because the three-dot diff names the merge base of the
+ # PR's base tip and its merge commit -- a shallow checkout does not have
+ # both, and a stacked PR's merge base sits further back still.
+ - name: Checkout the change
+ if: github.event_name == 'pull_request'
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ persist-credentials: false # nothing here pushes
+ fetch-depth: 0
+
+ - name: Does the change touch the occupancy reading?
+ id: pr
+ if: github.event_name == 'pull_request'
+ env:
+ # zizmor flags template expressions interpolated into run scripts
+ # (template-injection), and the gate script is easier to run locally
+ # with the value in an environment variable in any case. The base SHA
+ # is GitHub-supplied, but the indirection costs nothing.
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ relevant=false
+ patterns=(src/core/memory_probe.jl src/core/provenance.jl src/pipeline/tools.jl
+ src/server/routes/pipeline.jl test/occupancy_disagreement_test.jl
+ test/unit/test_occupancy_grade.jl scripts/check-occupancy-grades.jl
+ scripts/lint-occupancy-readings.sh 'docs/occupancy/*'
+ docs/occupancy-2026-10.adoc scripts/check-occupancy.sh
+ scripts/fetch-siblings.sh .github/workflows/occupancy.yml)
+ # The diff goes to a file first because a process substitution hides
+ # git diff's exit status: an unavailable comparison SHA or a repository
+ # error would read as "no changes", and the gate would post
+ # relevant=false -- skipping the two occupancy jobs, whose `skipped`
+ # conclusion satisfies a required check -- so a relevant PR would
+ # pass silently. A failed diff must fail the gate itself: that is
+ # the one failure the required-check design cannot afford to skip.
+ changed="$RUNNER_TEMP/occupancy-changed-files"
+ if ! git diff --no-renames --name-only -z "$BASE_SHA...HEAD" > "$changed"; then
+ echo "ERROR: git diff $BASE_SHA...HEAD failed" >&2
+ exit 1
+ fi
+ while IFS= read -r -d '' file; do
+ for pattern in "${patterns[@]}"; do
+ case "$file" in
+ $pattern) relevant=true; break 2 ;;
+ esac
+ done
+ done < "$changed"
+ echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
+
+ disagreement:
+ needs: gate
+ if: needs.gate.outputs.relevant == 'true'
+ name: Occupancy disagreement test
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ env:
+ # As in ci.yml: the root .Rprofile would otherwise bootstrap renv.
+ RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE"
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Install Julia
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$JULIA_URL" julia.tar.gz
+ echo "$JULIA_SHA256 julia.tar.gz" | sha256sum -c -
+ sudo mkdir -p /opt/julia
+ sudo tar xzf julia.tar.gz -C /opt/julia --strip-components=1
+ rm julia.tar.gz
+ echo /opt/julia/bin >> "$GITHUB_PATH"
+
+ # RCall builds against R. The occupancy test uses no R package.
+ - name: Install R
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends r-base-core
+
+ - name: Instantiate the project
+ run: julia --project=. -e 'using Pkg; Pkg.instantiate()'
+
+ # The pinned vsearch (config/defaults/tool_versions.yml), refused unless it
+ # hashes to the pinned SHA256, as ci.yml installs it.
+ - name: Install vsearch
+ run: |
+ eval "$(julia --project=. -e '
+ using YAML
+ v = YAML.load_file("config/defaults/tool_versions.yml")["tools"]["vsearch"]
+ a = v["archives"]["linux-x86_64"]
+ println("VSEARCH_VERSION=", v["version"]); println("VSEARCH_URL=", a["url"]); println("VSEARCH_SHA256=", a["sha256"])')"
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$VSEARCH_URL" vsearch.tar.gz
+ echo "$VSEARCH_SHA256 vsearch.tar.gz" | sha256sum -c -
+ tar xzf vsearch.tar.gz --warning=no-unknown-keyword
+ sudo mv "vsearch-$VSEARCH_VERSION-linux-x86_64/bin/vsearch" /usr/local/bin/vsearch
+ rm -rf vsearch.tar.gz "vsearch-$VSEARCH_VERSION-linux-x86_64"
+ vsearch --version
+
+ - name: Three orders of the vsearch stage
+ run: julia --project=. -t 2 test/occupancy_disagreement_test.jl
+
+ # Fork issue #105: the grades the recorded run's windows imply, read back
+ # against its own measurement (docs/occupancy/schedules.yml).
+ - name: Graded reading of the recorded run
+ run: julia --project=. scripts/check-occupancy-grades.jl
+
+ # And no production code reads a peak without those grades.
+ - name: Occupancy readings are graded
+ run: bash scripts/lint-occupancy-readings.sh
+
+ typed-peak:
+ needs: gate
+ if: needs.gate.outputs.relevant == 'true'
+ name: Occupancy typed peak
+ runs-on: ubuntu-24.04
+ # The Agda 2.6.4.3 Debian trixie packages, in a digest-pinned image, as the
+ # upstream proof job uses. The siblings need no standard library.
+ container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
+ timeout-minutes: 20
+ env:
+ DEBIAN_FRONTEND: noninteractive
+ steps:
+ # git before checkout, so the checkout is a repository and the siblings can be fetched.
+ - name: Install git, curl and Agda
+ run: |
+ apt-get update -qq
+ apt-get install -y --no-install-recommends ca-certificates curl git agda-bin=2.6.4.3-1+b2
+ agda --version | grep -qx 'Agda version 2.6.4.3'
+
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false # the siblings are public; nothing here pushes
+
+ # The Session IR stepper is Julia (standard library only).
+ - name: Install Julia
+ shell: bash
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$JULIA_URL" julia.tar.gz
+ echo "$JULIA_SHA256 julia.tar.gz" | sha256sum -c -
+ mkdir -p /opt/julia
+ tar xzf julia.tar.gz -C /opt/julia --strip-components=1
+ rm julia.tar.gz
+ echo /opt/julia/bin >> "$GITHUB_PATH"
+
+ - name: Counted bytes against typed peak
+ shell: bash
+ run: bash scripts/check-occupancy.sh
diff --git a/.gitignore b/.gitignore
index a6ed2b7d..42d6c9c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -250,6 +250,9 @@ vignettes/*.pdf
## Documentation kept local except published release notes
docs/*
!docs/release-notes/
+# and the dated occupancy reading (MMW-01), whose table the gate checks
+!docs/occupancy-2026-10.adoc
+!docs/occupancy/
# translation temp files
@@ -332,3 +335,9 @@ LocalPreferences.toml
# Development server log (dev/dev.sh)
dev/server.log
+
+# Agda interface files (proofs/agda)
+proofs/agda/_build/
+# Proof siblings fetched by scripts/fetch-siblings.sh, and Agda interface files
+/_build/
+*.agdai
diff --git a/README.md b/README.md
index bb9883cc..bdf25c98 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ bash install.sh
```
`install.sh` installs Julia (via juliaup) if missing, checks for R, then hands
-off to `install.jl`, which installs the Julia dependencies, locates or downloads
+off to `scripts/install.jl`, which installs the Julia dependencies, locates or downloads
each external tool (cutadapt, FastQC, MultiQC, vsearch, cd-hit-est, and the
phylogeny tools MAFFT, trimAl, IQ-TREE, RAxML and gappa), and reproduces the R
environment from `renv.lock`. It ends with a summary listing every dependency as
@@ -159,6 +159,7 @@ The web UI lets you create studies, configure pipeline parameters, launch runs,
| `JULIA_METAMANIFOLD_PORT` | `8080` | Server port |
| `JULIA_METAMANIFOLD_ROOT` | working directory | Project root |
| `JULIA_THREADS` | `8` | Julia threads |
+| `JULIA_METAMANIFOLD_ALLOWED_ORIGINS` | none | Extra browser origins allowed to call the API, comma-separated (the server's own origin and the Vite dev server at `localhost:5173` are always allowed) |
Or run the Julia server directly:
@@ -644,6 +645,30 @@ bash start.sh
Open `http://localhost:8080`. The backend serves the frontend.
+### Split deployment (frontend and API on different origins)
+
+To serve the frontend from a different address than the API, set `apiBase` in
+`frontend/public/config.json` to the API's address, and list the frontend's
+address when starting the server:
+
+```bash
+JULIA_METAMANIFOLD_ALLOWED_ORIGINS=https://frontend.example bash start.sh
+```
+
+The server accepts browser requests from its own origin (`http://localhost:`
+and `http://127.0.0.1:`), from the Vite dev server (`localhost:5173`), and
+from the origins listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`, separated by
+commas. An origin has to match exactly, scheme and port included:
+`https://frontend.example` does not cover `http://frontend.example` or
+`https://frontend.example:8443`. A page on any other origin gets
+`403 Forbidden: origin not allowed` on every request that carries an `Origin`
+header, which is every write and every cross-origin read.
+
+This also applies to an SSH tunnel on a different local port (e.g.
+`http://localhost:9000`): the browser's origin is then `http://localhost:9000`,
+not the server's `http://localhost:8080`, so start the server with
+`JULIA_METAMANIFOLD_ALLOWED_ORIGINS=http://localhost:9000`.
+
## Input data
Place paired-end FASTQ files under `data/{project_name}/` following Illumina naming:
diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml
index 11198e7a..8181ae6d 100644
--- a/config/defaults/pipeline.yml
+++ b/config/defaults/pipeline.yml
@@ -347,10 +347,39 @@ analysis:
# comparing two conditions on the raw read counts, with Benjamini-Hochberg
# adjusted p-values.
differential:
+ # The model fitted to each taxon. nb_glm: a negative-binomial GLM on the read
+ # counts with a library-size offset; the effect is a log2 fold change. clr_welch:
+ # Welch's t-test on centred log-ratios over the taxa passing min_prevalence,
+ # after zCompositions::cmultRepl replaces their zeros; the effect is a
+ # difference in CLR units, not a fold change.
+ method: nb_glm
# How library size enters each model, as a log offset: tss (total reads per
# sample) or rle (median-of-ratios over the taxa present in every sample).
# rle is refused when no taxon is present in every sample.
+ # Used by nb_glm only; clr_welch ignores it, since log-ratios carry no library size.
offset: tss
# Fraction of samples, 0 to 1, in which a taxon must have reads to be
- # tested. Taxa below it are listed as filtered, without a p-value.
+ # tested. Taxa below it are listed as filtered, without a p-value. For
+ # clr_welch it is applied before zero replacement, so on a sparse table it also
+ # keeps imputed values for rare taxa out of every sample; clr_welch refuses a
+ # taxon seen in fewer than 2 samples.
min_prevalence: 0.0
+ # clr_welch only: cmultRepl's frac, between 0 and 1. An imputed proportion above
+ # its taxon's smallest observed proportion is lowered to this fraction of it.
+ # A replaced zero is not a measurement.
+ replacement_delta: 0.65
+
+## Occupancy probe (MMW-01)
+# Measures each attested stage's memory: Julia's allocation total, and the
+# sampled resident memory of the tools it runs as child processes (Linux /proc).
+# Recorded under the stage's `occupancy` key in attestation.yml. See the
+# header of src/core/memory_probe.jl.
+#
+# Off unless set, and deliberately left commented out here: the run config is
+# embedded in attestation.yml, so a default key would change every run's record
+# even with the probe off. Set it by editing a pipeline.yml (the config editor
+# only offers keys that have a factory default).
+# occupancy_probe:
+# enabled: true
+# # Sampling interval in milliseconds (at least 1).
+# interval_ms: 10
diff --git a/docs/occupancy-2026-10.adoc b/docs/occupancy-2026-10.adoc
new file mode 100644
index 00000000..6d291b05
--- /dev/null
+++ b/docs/occupancy-2026-10.adoc
@@ -0,0 +1,167 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+= Occupancy of the vsearch stage
+:revdate: 2026-10-14
+
+The vsearch stage runs two independent searches, ASV and OTU. The full
+pipeline spawns them together; a single-stage re-run runs them one after the
+other. The high-water-mark (HWM) algebra of occupancy-types predicts that the
+stage's cost is the same in every order but its memory peak is not: serially
+the peak is the larger search's, concurrently it can be their sum
+(choreographic-types A-2 and A-3). This page records one measurement of that,
+on 2026-10-14, and checks it against the typed reading.
+
+== What was measured
+
+`MemoryProbe` (`src/core/memory_probe.jl`), off unless a pipeline.yml sets
+`occupancy_probe.enabled: true`, records for each attested stage:
+
+* `julia_allocated_bytes`: the `Base.gc_num()` delta over the stage (cost);
+* per task (one spawned search): the largest sampled VmRSS of the child
+ process it started (`peak_rss_kib`), the kernel's own VmHWM for it
+ (`hwm_kib`), and the sample window its children were live in — `first_sample`,
+ the first sample taken after its first child started, and `last_sample`, the
+ last taken before its last was waited for, 0 for never — which its grade is
+ read from;
+* `child_peak_rss_kib`: the largest sum of the stage's children's VmRSS at
+ one sampling instant (the stage's occupancy).
+
+The run: `test/occupancy_disagreement_test.jl`, which runs the same two searches
+in three orders under the probe, on a fixture it generates: 1000 reference
+sequences of 400 bp, 300 ASV and 150 OTU queries. Linux 6.18 with 4 CPUs,
+Julia 1.12.5 with 2 threads, vsearch 2.30.5 with `--threads 1`. Sampling
+interval 10 ms.
+
+== Results
+
+[cols="2,1,1,1,1,1,1",options="header"]
+|===
+| Order | ASV peak (KiB) | OTU peak (KiB) | Stage peak, measured (KiB) | Counted bytes, Session IR | Typed peak, HWM | Julia allocation (bytes)
+| ASV then OTU | 9204 | 9180 | 9204 | 9204 | 9204 | 978432
+| OTU then ASV | 9152 | 9192 | 9192 | 9192 | 9192 | 978720
+| Concurrent | 9176 | 9204 | 18380 | 18380 | 18380 | 978816
+|===
+
+* *Cost commutes.* The allocation totals agree to 0.04%. The test asserts
+ agreement within 1%; a planted extra search in one order breaks it (spread
+ 25%).
+* *Each search's peak is the same in every order*, to within 0.6%
+ (9152-9204 KiB). Each search's VmHWM equals its sampled peak, so the
+ sampling caught the peaks.
+* *The stage peak is not the same.* Serially it is the larger search's
+ (9192-9204 KiB); concurrently it is 18380 KiB, twice that. This is the A-3
+ order dependence, and nothing in the stage's outputs shows it: the hits are
+ byte-identical in all three orders. The prediction would have been falsified
+ had the concurrent peak matched the serial one; it did not.
+* *Counted bytes equal the typed peak*, in all three orders. The counted bytes
+ come from occupancy-types' Session IR stepper running
+ `docs/occupancy/vsearch-*.sir`, one buffer per search. The typed peak is
+ computed, by `refl`, from occupancy-types' `Timeline` over resource-algebra's
+ HWM grade (`docs/occupancy/TypedPeak.agda`). A search alone exits holding
+ nothing (drift 0); in the concurrent order the first is still resident
+ when the second starts (drift = peak). The sequence law
+ `max (peak a) (drift a + peak b)` then gives the larger peak serially and the
+ sum concurrently.
+* *The measured stage peak also equals the typed peak* here. For the concurrent
+ order the typed peak is the interleaving in which both are resident at once;
+ a measurement could come in lower if the two searches' peaks did not
+ coincide. Here they did: each search's memory plateaus once its database is
+ loaded.
+
+== Reading the numbers
+
+A recorded `peak_rss_kib` is a reading, not an occupancy: what a stage occupied
+depends on what the next stage inherits from it. So the probe grades a stage's
+whole window (`MemoryProbe`'s `task_grades`, `stage_grade`,
+`predicted_peak_rss_kib`, `cost_bound_rss_kib`, `reclaimed_kib`) and attaches the
+result to a recorded stage as `grades`; a record with no sample windows is left
+ungraded rather than graded on an assumed order. Tasks are read in sampled order
+(first sample, ties by record order). Each handover keeps only the tasks still
+live at it, so a task's `drift_kib` is the change in the live peak sum across its
+boundary — negative when the boundary also releases an earlier task — and
+`release_kib` is everything freed between its start and the next boundary, which
+can exceed that task's own peak. The folded drift is the sum of the peaks still
+live when the stage ends; the pinned instance's own `drift` is that non-negative
+live sum, the signed per-task delta being the extension its header records as
+H-2.
+
+For the three orders above, the algebra's predicted peak reproduces the measured
+stage peak exactly — 9204, 9192 and 18380 KiB — and the slack against the cost
+bound is what the cuts reclaimed: 9180, 9152 and 0 KiB.
+`scripts/check-occupancy-grades.jl` checks that equality, occupancy≤cost and
+non-negative reclamation against this page's raw record, rebuilding each order's
+windows from `docs/occupancy/schedules.yml` (the file records no windows of its
+own; the Session IR texts in this directory state the same interleavings). The
+reclamation readings are theorems over the pinned siblings at these numbers:
+`docs/occupancy/GradedStages.agda` — `no-reclaim` for the concurrent stage,
+`reclaimed` for the serial one, where 9204 KiB are released at the frontier and
+the cost reading counts them — and the two protocol-level statements
+(`occupancy-at-cut`, `protocol-occupancy≤cost`) as the siblings' own two-step
+over the stage's timeline. `docs/occupancy/reject` gains a fourth rejection, for
+reading the concurrent stage's drift as 0.
+
+`scripts/lint-occupancy-readings.sh` keeps production code from reading the key
+directly: outside `src/core/memory_probe.jl`, an occupancy figure must go through
+those helpers.
+
+== Status
+
+[cols="1,3,1,2",options="header"]
+|===
+| ID | Claim | Status | Artefact
+| MMW-01.1 | With the flag off, a stage record has exactly its earlier keys; with it on, it gains `occupancy`; old records still parse and merge | TESTED | `test/occupancy_disagreement_test.jl` ("flag off", "round trip", "through the pipeline's _attest")
+| MMW-01.2 | For this workload, the vsearch stage's cost (Julia allocation) is the same in all three orders | TESTED | same file, "two-thread disagreement" (asserted, within 1%)
+| MMW-01.3 | For this workload, the stage peak depends on the order (A-3), and each search's own peak does not | TESTED | same file, recorded in the test log; table above
+| MMW-01.4 | For the three Session IR texts, the stepper's counted bytes equal the HWM typed peak | TESTED | `scripts/check-occupancy.sh` (stepper output against `TypedPeak.agda`; three rejections in `docs/occupancy/reject`)
+| MMW-01.5 | The recorded run's grades reproduce its measured stage peaks, and the reclamation the pinned siblings' laws read off them is what the cuts freed | TESTED | `scripts/check-occupancy-grades.jl` (the recorded record), `docs/occupancy/GradedStages.agda` (`no-reclaim`, `reclaimed`, `occupancy-at-cut` at the measured numbers), `test/unit/test_occupancy_grade.jl`
+| bridge | Counted bytes are bounded by the typed peak in general (occupancy-types' v0 → typed bridge) | OPEN | unchanged; this page gives three instances, not a proof
+|===
+
+== Limits
+
+* *Sampled.* A per-task peak is the largest VmRSS seen at 10 ms intervals. VmHWM
+ is exact up to the last sample, but growth after it is not seen. A child that
+ lives for less than one interval may not be sampled at all (`samples: 0`).
+* *Child memory only.* The children are counted; the Julia process is not. Its
+ `julia_live_bytes_delta` is recorded, but without a forced collection it
+ reflects when the GC last ran, not what the stage kept.
+* *Allocation is process-wide.* While the study loop runs several runs'
+ pipelines at once, a stage's `julia_allocated_bytes` includes the others'
+ allocations too. The child figures are per stage, since each stage samples
+ only the children it started.
+* *Linux only*, since it reads /proc. For a stage offloaded with `remote:`,
+ the local child is an ssh client, so its memory says nothing about the work.
+* *One thread for vsearch.* With several threads, vsearch writes its hits in
+ whatever order the threads finish. `_sort_hits_by_query!` then does a
+ different amount of work, because its sort key splits each line on every
+ comparison. That moved the stage's allocation total by up to 12% between
+ repeats of the same order. So in production the cost is order-independent
+ only up to that noise; precomputing the sort key would remove it. This PR
+ does not change it.
+* *One host, one day.* CI runs the same test on every push and logs its own
+ numbers; they are not compared with these.
+
+== Reproducing
+
+----
+julia --project=. -t 2 test/occupancy_disagreement_test.jl # set OCCUPANCY_RESULTS= for YAML
+bash scripts/check-occupancy.sh # needs julia and agda 2.6.4.x
+julia --project=. scripts/check-occupancy-grades.jl # the recorded run's grades
+bash scripts/lint-occupancy-readings.sh # no ungraded readings in src/
+----
+
+`scripts/fetch-siblings.sh` fetches the siblings at their pins into
+`_build/dependencies`: occupancy-types `af95728` and resource-algebra
+`cf9235c`.
+
+== As the proofs progress
+
+* *Per-buffer (n-ary) occupancy* in occupancy-types: each search is already one
+ buffer in the Session IR texts, so the typed peak could then be read per
+ search.
+* *Signed drift*: frees could be graded as negative drift, so the Julia side's
+ collections (the pipeline's `GC.gc(false)` hints) become modelled
+ reclamation rather than unmodelled noise.
+* *choreographic-types' operational `step` as a function*: the three orders
+ become three traces of one typed protocol. The frontier certificate its
+ two-thread application note describes could then be displayed in the WebUI.
diff --git a/docs/occupancy/GradedStages.agda b/docs/occupancy/GradedStages.agda
new file mode 100644
index 00000000..3c45730d
--- /dev/null
+++ b/docs/occupancy/GradedStages.agda
@@ -0,0 +1,96 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- The vsearch stage's grades, and occupancy-types' reclamation theorems at the
+-- measured numbers. Fork issue #105 asks for exactly this: grade the stages
+-- (alloc/hold/release) and compare the predicted peak with the measured one.
+-- TypedPeak.agda computes the typed peak by the algebra's sequence law; this
+-- module adds the grades the measurement itself gives, and the laws that say
+-- what the difference between a peak and a cost reading is: occupancy≤cost, and
+-- the strict reclamation at a cut where something is released.
+--
+-- Consumed, never re-declared, from the siblings scripts/fetch-siblings.sh pins
+-- and scripts/check-occupancy.sh checks:
+-- occupancy-types af957280075699c659de0abd9a3b0defade77309
+-- resource-algebra cf9235c532712a783a404c359a1055f40134794a
+-- Everything is stated in this file's own Timeline instance (`TH`, as
+-- TypedPeak.agda does): the Grade-level laws are Reclaim's as they stand, and
+-- the two protocol-level ones are Reclaim's own two-step — its `cut-factor` over
+-- its `reclaim-law`/`occupancy≤cost` — applied to `xs` and `ys` of that instance.
+--
+-- A grade is `hwm peak drift _`: what an interaction reached, and what it still
+-- holds when the next one begins. The peaks below are the 2026-10-14 run's
+-- (docs/occupancy-2026-10.adoc, docs/occupancy/results-2026-10-14.yml), in KiB.
+module GradedStages where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import OccupancyTypes.Order
+open import OccupancyTypes.Reclaim
+
+import OccupancyTypes.Timeline
+module TH = OccupancyTypes.Timeline HWM
+
+-- A search that has not been waited for when the next one starts: it still holds
+-- everything it reached, so the cut reclaims none of it.
+still : Nat → Grade
+still p = hwm p p (≤-refl p)
+
+-- A search that has exited before the next one starts: it holds nothing, so the
+-- cut reclaims all of it.
+gone : Nat → Grade
+gone p = hwm p 0 z≤n
+
+-- The full pipeline runs the two searches together: ASV is still resident when
+-- OTU starts, so the stage peak is their sum and nothing is reclaimed —
+-- Reclaim.no-reclaim's equality, whose `refl` argument discharges the theorem's
+-- own hypothesis (drift = peak) at the measured grade.
+run-together : peak (still 9176 ⊗h gone 9204) ≡ 18380
+run-together = no-reclaim (still 9176) refl (gone 9204)
+
+-- A single-stage re-run runs them one after the other: ASV has exited, so the
+-- cut reclaims its 9204 KiB and the stage peak is the larger search's
+-- (Reclaim.reclaim-law, by `refl`).
+run-in-turn : peak (gone 9204 ⊗h gone 9180) ≡ 9204
+run-in-turn = refl
+
+-- The same claim as Reclaim.reclaimed states it: with drift < peak at the cut
+-- and a suffix that does anything, occupancy is strictly below cost. 9204 KiB
+-- are released at the frontier, so the cost reading (18384 KiB) overstates the
+-- stage.
+run-in-turn
+--
+-- The vsearch stage's typed peak, computed (TESTED, by refl) with
+-- occupancy-types' Timeline over resource-algebra's HWM grade, both consumed
+-- by pin through scripts/fetch-siblings.sh:
+-- occupancy-types af957280075699c659de0abd9a3b0defade77309
+-- resource-algebra cf9235c532712a783a404c359a1055f40134794a
+-- Nothing here re-declares their definitions.
+--
+-- Each search is one interaction graded by its measured peak, in KiB, from the
+-- 2026-10-14 run in docs/occupancy-2026-10.adoc. A search's process exits
+-- holding nothing, so run alone its drift is 0. Run concurrently, the first is
+-- still resident when the second starts, so that is graded with drift = peak.
+-- That is the interleaving the measurement observed (both resident at once).
+-- The algebra's sequence law, max (peak a) (drift a + peak b), then gives:
+-- serial = the larger peak; concurrent = the sum.
+module TypedPeak where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+
+import OccupancyTypes.Timeline
+module TH = OccupancyTypes.Timeline HWM
+
+-- A search whose process has exited before the next one starts.
+exits : Nat → Grade
+exits p = hwm p 0 z≤n
+
+-- A search still resident when the next one starts.
+resident : Nat → Grade
+resident p = hwm p p (≤-refl p)
+
+-- ASV 9204 then OTU 9180.
+asv-then-otu : peak (TH.grade (exits 9204 TH.∷ exits 9180 TH.∷ TH.[])) ≡ 9204
+asv-then-otu = refl
+
+-- OTU 9192 then ASV 9152.
+otu-then-asv : peak (TH.grade (exits 9192 TH.∷ exits 9152 TH.∷ TH.[])) ≡ 9192
+otu-then-asv = refl
+
+-- ASV 9176 and OTU 9204 together.
+concurrent : peak (TH.grade (resident 9176 TH.∷ exits 9204 TH.∷ TH.[])) ≡ 18380
+concurrent = refl
diff --git a/docs/occupancy/reject/ConcurrentDriftIsZero.agda b/docs/occupancy/reject/ConcurrentDriftIsZero.agda
new file mode 100644
index 00000000..749c213f
--- /dev/null
+++ b/docs/occupancy/reject/ConcurrentDriftIsZero.agda
@@ -0,0 +1,18 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- Must be REFUSED: reading the concurrent stage as one that hands on nothing.
+-- Running the two searches together leaves the ASV search resident when the OTU
+-- search starts, so the stage's drift is that search's peak — 9176 KiB, what a
+-- suffix would see — and not 0. Grading the drift as 0 would read the stage's
+-- COST as its occupancy, which is the very reading the measured overlap exists
+-- to refute.
+module ConcurrentDriftIsZero where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import GradedStages
+
+wrong : drift (still 9176 ⊗h gone 9204) ≡ 0
+wrong = refl
diff --git a/docs/occupancy/reject/ConcurrentIsSerial.agda b/docs/occupancy/reject/ConcurrentIsSerial.agda
new file mode 100644
index 00000000..592de6ee
--- /dev/null
+++ b/docs/occupancy/reject/ConcurrentIsSerial.agda
@@ -0,0 +1,14 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- Must be REFUSED: the claim that running the two searches concurrently leaves
+-- the peak where a serial run puts it (the larger of the two).
+module ConcurrentIsSerial where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import TypedPeak
+
+wrong : peak (TH.grade (resident 9176 TH.∷ exits 9204 TH.∷ TH.[])) ≡ 9204
+wrong = refl
diff --git a/docs/occupancy/reject/SerialIsCost.agda b/docs/occupancy/reject/SerialIsCost.agda
new file mode 100644
index 00000000..dbb6f4ba
--- /dev/null
+++ b/docs/occupancy/reject/SerialIsCost.agda
@@ -0,0 +1,14 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- Must be REFUSED: reading a serial run's peak as the sum of the two peaks,
+-- i.e. occupancy as cost (occupancy-types R-6).
+module SerialIsCost where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import TypedPeak
+
+wrong : peak (TH.grade (exits 9204 TH.∷ exits 9180 TH.∷ TH.[])) ≡ 18384
+wrong = refl
diff --git a/docs/occupancy/results-2026-10-14.yml b/docs/occupancy/results-2026-10-14.yml
new file mode 100644
index 00000000..394236d6
--- /dev/null
+++ b/docs/occupancy/results-2026-10-14.yml
@@ -0,0 +1,63 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# The raw MemoryProbe records behind docs/occupancy-2026-10.adoc, written by
+# test/occupancy_disagreement_test.jl with OCCUPANCY_RESULTS set (2026-10-14).
+vsearch: "vsearch v2.30.5_linux_x86_64, 15.7GB RAM, 4 cores"
+julia: "1.12.5"
+threads: 2
+cpus: 4
+cost_spread: 0.00039231071008238524
+orders:
+ asv_then_otu:
+ probe: "MemoryProbe 1"
+ interval_ms: 10
+ samples: 56
+ julia_allocated_bytes: 978432
+ julia_live_bytes_delta: 978544
+ child_peak_rss_kib: 9204
+ tasks:
+ - label: "vsearch:asv"
+ processes: 1
+ samples: 33
+ peak_rss_kib: 9204
+ hwm_kib: 9204
+ - label: "vsearch:otu"
+ processes: 1
+ samples: 20
+ peak_rss_kib: 9180
+ hwm_kib: 9180
+ otu_then_asv:
+ probe: "MemoryProbe 1"
+ interval_ms: 10
+ samples: 57
+ julia_allocated_bytes: 978720
+ julia_live_bytes_delta: 978832
+ child_peak_rss_kib: 9192
+ tasks:
+ - label: "vsearch:otu"
+ processes: 1
+ samples: 22
+ peak_rss_kib: 9192
+ hwm_kib: 9192
+ - label: "vsearch:asv"
+ processes: 1
+ samples: 33
+ peak_rss_kib: 9152
+ hwm_kib: 9152
+ concurrent:
+ probe: "MemoryProbe 1"
+ interval_ms: 10
+ samples: 33
+ julia_allocated_bytes: 978816
+ julia_live_bytes_delta: 978928
+ child_peak_rss_kib: 18380
+ tasks:
+ - label: "vsearch:asv"
+ processes: 1
+ samples: 30
+ peak_rss_kib: 9176
+ hwm_kib: 9176
+ - label: "vsearch:otu"
+ processes: 1
+ samples: 24
+ peak_rss_kib: 9204
+ hwm_kib: 9204
diff --git a/docs/occupancy/schedules.yml b/docs/occupancy/schedules.yml
new file mode 100644
index 00000000..dfdedb81
--- /dev/null
+++ b/docs/occupancy/schedules.yml
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# The interleaving each order in results-2026-10-14.yml ran with, as the sample
+# window [first, last] of each of its tasks, in the task order the record keeps.
+# These are the same schedules the Session IR texts in this directory state
+# (vsearch-concurrent.sir runs the two searches together; the other two run them
+# one after the other), expressed in the probe's own sample indices: the windows
+# of a serial order do not overlap, and the concurrent order's do.
+#
+# Read by test/unit/test_occupancy_grade.jl and scripts/check-occupancy-grades.jl,
+# so the two cannot disagree about what the recorded run did.
+asv_then_otu:
+ - [1, 2]
+ - [3, 4]
+otu_then_asv:
+ - [1, 2]
+ - [3, 4]
+concurrent:
+ - [1, 4]
+ - [2, 5]
diff --git a/docs/occupancy/vsearch-asv-then-otu.sir b/docs/occupancy/vsearch-asv-then-otu.sir
new file mode 100644
index 00000000..e462fb5e
--- /dev/null
+++ b/docs/occupancy/vsearch-asv-then-otu.sir
@@ -0,0 +1,13 @@
+(* SPDX-License-Identifier: AGPL-3.0-only *)
+(* vsearch-asv-then-otu.sir - the vsearch stage with the two searches run one
+ after the other, ASV first: the single-stage re-run's order. Each search is
+ one buffer, sized by its measured peak in KiB (docs/occupancy-2026-10.adoc,
+ 2026-10-14 run); each process exits holding nothing, so its buffer is dropped
+ before the next search starts.
+
+ Expected: steps = 0 (no communication), peak_live = max 9204 9180 = 9204. *)
+let asv = alloc 9204 in
+let _ = drop asv in
+let otu = alloc 9180 in
+let _ = drop otu in
+unit
diff --git a/docs/occupancy/vsearch-concurrent.sir b/docs/occupancy/vsearch-concurrent.sir
new file mode 100644
index 00000000..93be40bf
--- /dev/null
+++ b/docs/occupancy/vsearch-concurrent.sir
@@ -0,0 +1,12 @@
+(* SPDX-License-Identifier: AGPL-3.0-only *)
+(* vsearch-concurrent.sir - the two searches spawned together, as the full
+ pipeline runs them. Session IR has no parallel composition, so this is the
+ interleaving the measurement observed: both buffers live at once, then both
+ dropped.
+
+ Expected: steps = 0, peak_live = 9176 + 9204 = 18380. *)
+let asv = alloc 9176 in
+let otu = alloc 9204 in
+let _ = drop asv in
+let _ = drop otu in
+unit
diff --git a/docs/occupancy/vsearch-otu-then-asv.sir b/docs/occupancy/vsearch-otu-then-asv.sir
new file mode 100644
index 00000000..ba575591
--- /dev/null
+++ b/docs/occupancy/vsearch-otu-then-asv.sir
@@ -0,0 +1,9 @@
+(* SPDX-License-Identifier: AGPL-3.0-only *)
+(* vsearch-otu-then-asv.sir - the same two searches, OTU first.
+
+ Expected: steps = 0, peak_live = max 9192 9152 = 9192. *)
+let otu = alloc 9192 in
+let _ = drop otu in
+let asv = alloc 9152 in
+let _ = drop asv in
+unit
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 742f42a5..11ec980d 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -7,226 +7,128 @@
"dependencies": {
"@upsetjs/react": "^1.11.0",
"jspdf": "^4.2.1",
- "plotly.js-dist-min": "^2.35.2",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "react-router-dom": "^6.28.0",
+ "plotly.js-dist-min": "^4.1.2",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router-dom": "^7.18.4",
"svg2pdf.js": "^2.8.1",
},
"devDependencies": {
- "@types/plotly.js": "^2.33.4",
- "@types/react": "^18.3.18",
- "@types/react-dom": "^18.3.5",
- "@vitejs/plugin-react": "^4.3.4",
- "typescript": "^5.7.2",
- "vite": "^6.1.0",
+ "@types/plotly.js": "^3.0.15",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.4",
},
},
},
"overrides": {
- "@types/react": "^18.3.18",
- "@types/react-dom": "^18.3.5",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
},
"packages": {
- "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
-
- "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
-
- "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
-
- "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
-
- "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
-
- "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
-
- "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
-
- "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
-
- "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
-
- "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
-
- "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
-
- "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
-
- "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
-
- "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
-
- "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
-
- "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
-
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
- "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
-
- "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
-
- "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
-
- "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
-
- "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
-
- "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
-
- "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
-
- "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
-
- "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
-
- "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
-
- "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
-
- "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
-
- "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
-
- "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
-
- "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
-
- "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
-
- "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
-
- "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
-
- "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
-
- "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
-
- "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
-
- "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+ "@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="],
- "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="],
- "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="],
- "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw=="],
- "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA=="],
- "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA=="],
- "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="],
- "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="],
- "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="],
- "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="],
- "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="],
- "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="],
- "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="],
- "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="],
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="],
- "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA=="],
- "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ=="],
- "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
- "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
-
- "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
-
- "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
-
- "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
-
- "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
-
- "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
-
- "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
-
- "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
-
- "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
-
- "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
+ "@types/lz-string": ["@types/lz-string@1.5.0", "", { "dependencies": { "lz-string": "*" } }, "sha512-s84fKOrzqqNCAPljhVyC5TjAo6BH4jKHw9NRNFNiRUY5QSgZCmVm5XILlWbisiKl+0OcS7eWihmKGS5akc2iQw=="],
- "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
+ "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
- "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
+ "@types/plotly.js": ["@types/plotly.js@3.0.15", "", {}, "sha512-ftBxk2fB7BxxeMpT0IVEb/3HKpYTXO5nCdR5mGEz6gHpLd6GGRFxvDVoG1gpD/gBAgoN36KIyRflxCnOfWlskQ=="],
- "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
+ "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
- "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
+ "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
- "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
+ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
- "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
- "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
+ "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
- "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
+ "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
- "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
+ "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
- "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
+ "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
- "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
+ "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
- "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
+ "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
- "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
+ "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
- "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+ "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
- "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+ "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
- "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
+ "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
- "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+ "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
- "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+ "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
- "@types/lz-string": ["@types/lz-string@1.5.0", "", { "dependencies": { "lz-string": "*" } }, "sha512-s84fKOrzqqNCAPljhVyC5TjAo6BH4jKHw9NRNFNiRUY5QSgZCmVm5XILlWbisiKl+0OcS7eWihmKGS5akc2iQw=="],
+ "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
- "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
+ "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
- "@types/plotly.js": ["@types/plotly.js@2.35.14", "", {}, "sha512-CcD/32JcK19+xWH4FFpmYez/5X9kOjUcBr8Hxh7gQ/3Z32gIoLLy/L9xvC7DG5YikPvJjq6QN05B9+MCRu/Ncw=="],
+ "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
- "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
+ "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
- "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
+ "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
- "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+ "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
- "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+ "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
- "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@upsetjs/model": ["@upsetjs/model@1.11.0", "", {}, "sha512-pQEdunei2WC9mdhFR6umzE45j8VJfei/cG0TyVaaC0YE5zh1WI/TJRJJQC5jCiGhylISy99ANJs6XmQI33lqCw=="],
"@upsetjs/react": ["@upsetjs/react@1.11.0", "", { "dependencies": { "@types/lz-string": "^1.3.34", "@types/react": "^17.0.38", "@upsetjs/model": "~1.11.0", "lz-string": "^1.4.4" }, "peerDependencies": { "react": ">=17" } }, "sha512-APDFkXowPk1U94Iz5dtzpjcSiR5GcJkhLdMdeTnyLDYxIBf1mA+ElbBrEdAzaSGCiZo76ZKfgmuCMSMEfLtN8g=="],
- "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
-
- "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
-
- "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
-
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
- "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
@@ -236,16 +138,10 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
- "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
-
- "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
-
- "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
-
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -256,31 +152,39 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
- "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
-
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
- "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
- "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+ "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
- "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
- "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
- "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
- "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
- "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
- "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
- "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
- "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
+ "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
@@ -288,35 +192,33 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
- "plotly.js-dist-min": ["plotly.js-dist-min@2.35.3", "", {}, "sha512-sz2HLP8gkysLx/BanM2PtJTtZ1PLPwdHwMWNri2YxLBy3IOeuDsVQtlmWa4hoK3j/fi4naaD3uZJqH5ozM3zGg=="],
+ "plotly.js-dist-min": ["plotly.js-dist-min@4.1.2", "", {}, "sha512-VSmS9KVNun16S8qFYYp9LlL/XBby4ueeGqB+K9AqRCVsup8dSpGULap4UyR1f2mz4pV3qjWp1sR5chwLgIKPoQ=="],
- "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
+ "postcss": ["postcss@8.5.30", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
- "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
-
- "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+ "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
- "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
+ "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
- "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
+ "react-router": ["react-router@7.18.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ=="],
- "react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
+ "react-router-dom": ["react-router-dom@7.18.4", "", { "dependencies": { "react-router": "7.18.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
- "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+ "rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="],
- "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+ "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
- "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
- "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="],
@@ -330,16 +232,12 @@
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
- "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
- "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
-
- "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 8793ef05..ab8e600e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,19 +13,19 @@
"dependencies": {
"@upsetjs/react": "^1.11.0",
"jspdf": "^4.2.1",
- "plotly.js-dist-min": "^2.35.2",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "react-router-dom": "^6.28.0",
+ "plotly.js-dist-min": "^4.1.2",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router-dom": "^7.18.4",
"svg2pdf.js": "^2.8.1"
},
"devDependencies": {
- "@types/plotly.js": "^2.33.4",
- "@types/react": "^18.3.18",
- "@types/react-dom": "^18.3.5",
- "@vitejs/plugin-react": "^4.3.4",
- "typescript": "^5.7.2",
- "vite": "^6.1.0"
+ "@types/plotly.js": "^3.0.15",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.4"
},
"overrides": {
"@types/react": "$@types/react",
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 10410bd9..87457dad 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -243,15 +243,32 @@ export interface ComparisonRequest extends AnalysisRequest {
aggregate?: boolean
}
+export interface PermanovaTerm {
+ term: string
+ r2: number
+ f_statistic: number | null
+ /** Null when the term could not be tested; `untestable_reason` says why. */
+ p_value: number | null
+ /** Set when within-block permutation cannot change this term. */
+ untestable_reason?: string | null
+}
+
export interface PermanovaResult {
text: string
+ /** The first term (group, when it varies); r2, f_statistic and p_value are its own. */
+ term?: string
r2: number | null
f_statistic: number | null
p_value: number | null
+ untestable_reason?: string | null
+ /** Permutations actually performed: fewer than 999 when the blocks admit fewer. */
+ permutations?: number
+ /** Smallest p-value attainable with that many permutations. */
+ min_p_value?: number
/** True when permutations were restricted within individuals. */
blocked?: boolean
/** One row per model term, tested sequentially in formula order. */
- terms?: { term: string; r2: number; f_statistic: number | null; p_value: number | null }[]
+ terms?: PermanovaTerm[]
/** PERMDISP on the same distances: do the groups differ in spread? */
dispersion?: PermdispResult | { error: string }
}
@@ -381,18 +398,32 @@ export interface DifferentialRow {
taxon: string
status: DifferentialStatus
note: string
- /** Natural-log fold change, contrast over reference. */
+ /**
+ * nb_glm: natural-log fold change, contrast over reference. clr_welch: mean
+ * CLR of contrast minus mean CLR of reference, which is not a fold change.
+ */
estimate: number | null
+ /** nb_glm only; null for clr_welch. */
log2_fold_change: number | null
standard_error: number | null
+ /** z (nb_glm) or Welch's t (clr_welch). */
statistic: number | null
pvalue: number | null
/** Benjamini-Hochberg adjusted; null outside the tested family. */
padj: number | null
+ /** nb_glm only. */
dispersion_theta: number | null
prevalence: number
}
+export type DifferentialMethod = 'nb_glm' | 'clr_welch'
+
+/** Which row field holds the effect size, and what to call it. */
+export interface DifferentialEffect {
+ key: 'log2_fold_change' | 'estimate'
+ label: string
+}
+
export interface DifferentialResult {
status: 'ok' | 'partial'
method: string
@@ -400,9 +431,26 @@ export interface DifferentialResult {
table: string
groups: { reference: string; contrast: string }
n_samples: Record
+ effect: DifferentialEffect
+ /** Empty for clr_welch, which takes no library-size offset. */
size_factors: { sample: string; group: string; factor: number }[]
- config: { offset: 'tss' | 'rle'; min_prevalence: number }
- diagnostics: { n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number }
+ config: {
+ method: DifferentialMethod
+ min_prevalence: number
+ /** nb_glm only. */
+ offset?: 'tss' | 'rle'
+ /** clr_welch only. */
+ replacement_delta?: number
+ }
+ diagnostics: {
+ n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number
+ /** clr_welch only. */
+ zero_replacement?: {
+ method: string; delta: number; zeros_replaced: number; n_taxa_in_composition: number
+ n_taxa_unobserved: number; median_imputed_fraction: number; max_imputed_fraction: number
+ warnings: string[]
+ }
+ }
rows: DifferentialRow[]
figure: unknown
}
diff --git a/frontend/src/components/AnalysisChart.tsx b/frontend/src/components/AnalysisChart.tsx
index 3e40f157..17e8a437 100644
--- a/frontend/src/components/AnalysisChart.tsx
+++ b/frontend/src/components/AnalysisChart.tsx
@@ -1,13 +1,14 @@
// © 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
import Plotly from 'plotly.js-dist-min'
+import { stripHtmlTags } from '../utils/text'
import { AddToReport } from './AddToReport'
import { PlotlyChart } from './PlotlyChart'
const figureTitle = (fig: unknown): string | null => {
const t = (fig as { layout?: { title?: unknown } })?.layout?.title
const text = typeof t === 'string' ? t : (t as { text?: string } | undefined)?.text
- return text ? text.replace(/<[^>]+>/g, '') : null
+ return text ? stripHtmlTags(text) : null
}
// The chart as standalone SVG, at a fixed print width.
diff --git a/frontend/src/components/AnalysisWorkspace.tsx b/frontend/src/components/AnalysisWorkspace.tsx
index c8c6b5af..e7295004 100644
--- a/frontend/src/components/AnalysisWorkspace.tsx
+++ b/frontend/src/components/AnalysisWorkspace.tsx
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import type { AnnotationSource, ComparisonRunSpec } from '../api/types'
import { AnalysisSourceContext } from './analysisSource'
-import { useSharedResultsTables } from './annotationShared'
+import { useSharedResultsTablesState } from './annotationShared'
import { DiversityPanel } from './DiversityPanel'
import { TaxaCompositionChart } from './TaxaCompositionChart'
import { VennPanel } from './VennPanel'
@@ -80,7 +80,7 @@ export function AnalysisWorkspace({ study, runs: baseRuns, source, perRun }: {
}, [aggregate, selectedRuns])
//## Dataset: results tables shared by every selected run
- const options = useSharedResultsTables(study, selectedRuns)
+ const { options, error: optionsError } = useSharedResultsTablesState(study, selectedRuns)
const [analysisKey, setAnalysisKey] = useState(null)
useEffect(() => {
setAnalysisKey(current => current && options.some(o => o.key === current) ? current : options[0]?.key ?? null)
@@ -138,6 +138,8 @@ export function AnalysisWorkspace({ study, runs: baseRuns, source, perRun }: {
onChange={e => setAnalysisKey(e.target.value)}>
{options.map(o => )}
+ ) : optionsError ? (
+ could not list the results tables: {optionsError}
) : (
no results table shared by the selected runs
)}
diff --git a/frontend/src/components/CompositionPanel.tsx b/frontend/src/components/CompositionPanel.tsx
index 77ad8869..5e677683 100644
--- a/frontend/src/components/CompositionPanel.tsx
+++ b/frontend/src/components/CompositionPanel.tsx
@@ -16,6 +16,12 @@ import type {
// Fixed colour of the catch-all "Unassigned" bucket, mirroring the backend.
const UNASSIGNED_COLOUR = '#95a5a6'
+/**
+ * Browse a run's composition by taxonomy rank or category, with sub-group and
+ * results-table selectors and controls to save category colours. The analysis
+ * context's annotation source takes precedence over `source`; absent both,
+ * VSEARCH is used. A failed table lookup clears the options and shows a toast.
+ */
export function CompositionPanel({
study, run, group, subgroups, source: configuredSource,
}: {
@@ -53,6 +59,10 @@ export function CompositionPanel({
setTableOptions(opts)
setTable(current => opts.some(o => o.key === current) ? current
: opts.some(o => o.key === 'merged') ? 'merged' : opts[0]?.key ?? current)
+ }).catch(err => {
+ if (cancelled) return
+ setTableOptions([])
+ toast.error(`Could not list the results tables: ${errorMessage(err)}`)
})
return () => { cancelled = true }
}, [study, run, group])
diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx
index ff15b507..825fb812 100644
--- a/frontend/src/components/DifferentialPanel.tsx
+++ b/frontend/src/components/DifferentialPanel.tsx
@@ -1,16 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { errorMessage } from '../api/errorMessage'
import { useToast } from './Toast'
import type { ComparisonRunSpec, DifferentialResult } from '../api/types'
-import { uniqueRuns } from '../api/types'
+import { sharedRanks } from './sharedRanks'
import type { AnalysisOption } from './annotationShared'
import { AnalysisChart } from './AnalysisChart'
import { AddToReport } from './AddToReport'
import { saveBlob } from '../utils/download'
-import { differentialCsv, formatStat } from './differentialTable'
+import { differentialCsv, effectValue, formatStat } from './differentialTable'
const cell = { padding: '3px 10px', textAlign: 'right' } as const
const head = { padding: '4px 10px', textAlign: 'right' } as const
@@ -21,10 +21,10 @@ function csvBlob(result: DifferentialResult): Blob {
}
/**
- * Differential abundance between exactly two runs or sub-groups: a
- * negative-binomial model per taxon at the chosen rank, with BH-adjusted
- * p-values, shown as a volcano plot and a table that can be downloaded or
- * added to the report. The first selected condition is the reference.
+ * Differential abundance between exactly two runs or sub-groups: a model per
+ * taxon at the chosen rank (negative-binomial GLM or CLR linear model, as
+ * configured), with BH-adjusted p-values, shown as a volcano plot and a table
+ * that can be downloaded or added to the report. The first selected condition is the reference.
*/
export function DifferentialPanel({ study, runs, option, aggregate }: {
study: string
@@ -36,42 +36,58 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
const [ranks, setRanks] = useState([])
const [rank, setRank] = useState('')
const [ranksReady, setRanksReady] = useState(false)
+ const [ranksError, setRanksError] = useState(null)
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
// The ranks shared by every selected run.
useEffect(() => {
+ setRanksError(null)
if (!option || runs.length === 0) { setRanks([]); setRank(''); setRanksReady(false); return }
let cancelled = false
setRanksReady(false)
- Promise.all(
- uniqueRuns(runs).map(r =>
- api.analysis.ranks(study, r.run, {
- table: option.table,
- group: r.group,
- ...(r.source ? { source: r.source } : {}),
- }).catch(() => [] as string[])
- )
- ).then(results => {
+ sharedRanks(runs, r => api.analysis.ranks(study, r.run, {
+ table: option.table,
+ group: r.group,
+ ...(r.source ? { source: r.source } : {}),
+ })).then(shared => {
if (cancelled) return
- const shared = results.reduce((acc, cur) => acc.filter(r => cur.includes(r)), results[0] ?? [])
setRanks(shared)
setRanksReady(true)
setRank(current => shared.includes(current) ? current : (shared[shared.length - 1] ?? ''))
+ }).catch(err => {
+ if (cancelled) return
+ setRanks([])
+ setRank('')
+ setRanksError(errorMessage(err))
})
return () => { cancelled = true }
}, [study, runs, option])
+ // A result answers the inputs it was computed from; new inputs discard it,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setResult(null)
+ setLoading(false)
+ }, [study, runs, option?.table, rank, aggregate])
+
+ /** Fit the model for the current inputs and show it, unless the inputs changed meanwhile. */
const run = async () => {
if (!option || !rank) return
+ const req = ++request.current
setLoading(true)
try {
- setResult(await api.analysis.differential(study, { runs, table: option.table, rank, aggregate }))
+ const res = await api.analysis.differential(study, { runs, table: option.table, rank, aggregate })
+ if (req === request.current) setResult(res)
} catch (err) {
+ if (req !== request.current) return
setResult(null)
toast.error(`Differential abundance failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
@@ -94,9 +110,12 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{ranksReady && ranks.length === 0 && (
No taxonomy rank shared by the selected runs.
)}
+ {ranksError && (
+ Could not read the taxonomy ranks: {ranksError}
+ )}
{runs.length === 2 && (
- Reference: {runs[0].prefix ?? runs[0].run}; positive fold changes mean more abundant in {runs[1].prefix ?? runs[1].run}.
+ Reference: {runs[0].prefix ?? runs[0].run}; a positive effect means more abundant in {runs[1].prefix ?? runs[1].run}.
)}
@@ -109,6 +128,12 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.diagnostics.n_boundary > 0 && `, ${result.diagnostics.n_boundary} at a dispersion boundary`}
{result.diagnostics.n_filtered > 0 && `, ${result.diagnostics.n_filtered} below the prevalence threshold`}
. Samples: {Object.entries(result.n_samples).map(([g, n]) => `${g} ${n}`).join(', ')}.
+ {result.diagnostics.zero_replacement && (() => {
+ const z = result.diagnostics.zero_replacement
+ return ` ${z.zeros_replaced} zeros replaced by ${z.method} over ${z.n_taxa_in_composition} taxa; ` +
+ `replaced values hold ${(100 * z.median_imputed_fraction).toPrecision(2)}% of the median sample ` +
+ `and at most ${(100 * z.max_imputed_fraction).toPrecision(2)}% of any, and are not measurements.`
+ })()}
@@ -126,8 +151,8 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
| {result.rank} |
- log2 FC |
- Estimate (ln) |
+ {result.effect.label} |
+ {result.config.method === 'nb_glm' && Estimate (ln) | }
SE |
p |
padj (BH) |
@@ -138,8 +163,8 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.rows.map(r => (
| {r.taxon} |
- {formatStat(r.log2_fold_change)} |
- {formatStat(r.estimate)} |
+ {formatStat(effectValue(result, r))} |
+ {result.config.method === 'nb_glm' && {formatStat(r.estimate)} | }
{formatStat(r.standard_error)} |
{formatStat(r.pvalue)} |
{formatStat(r.padj)} |
diff --git a/frontend/src/components/DiversityPanel.tsx b/frontend/src/components/DiversityPanel.tsx
index b3d16dd2..ae84d450 100644
--- a/frontend/src/components/DiversityPanel.tsx
+++ b/frontend/src/components/DiversityPanel.tsx
@@ -1,6 +1,6 @@
// © 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { AnalysisChart } from './AnalysisChart'
import { useAlphaMetricSelection, AlphaMetricToggles, ALPHA_METRICS, extractAlphaPanel } from './alphaMetrics'
@@ -22,11 +22,27 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
const [permanova, setPermanova] = useState(null)
const [loading, setLoading] = useState(null)
const [rAvailable, setRAvailable] = useState(null)
+ const [capabilitiesError, setCapabilitiesError] = useState(null)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
+ // A failed check is reported as such, not as "R is unavailable".
useEffect(() => {
- api.analysis.capabilities().then(c => setRAvailable(c.r_available)).catch(() => setRAvailable(false))
+ api.analysis.capabilities()
+ .then(c => setRAvailable(c.r_available))
+ .catch(err => setCapabilitiesError(errorMessage(err)))
}, [])
+ // Results answer the inputs they were computed from; new inputs discard them,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setAlphaFig(null)
+ setNmdsFig(null)
+ setPermanova(null)
+ setLoading(null)
+ }, [study, runs, option?.table, aggregate])
+
const body = option && runs.length >= 2
? {
table: option.table,
@@ -36,22 +52,33 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
}
: null
+ /** Compute one analysis for the current inputs and show it, unless the inputs changed meanwhile. */
const run = async (type: 'alpha' | 'nmds' | 'permanova') => {
if (!body) return
+ const req = ++request.current
+ const current = () => req === request.current
setLoading(type)
try {
if (type === 'alpha') {
- setAlphaFig(await api.analysis.compareAlpha(study, body))
+ const fig = await api.analysis.compareAlpha(study, body)
+ if (current()) setAlphaFig(fig)
} else if (type === 'nmds') {
- setNmdsFig(await api.analysis.nmds(study, body))
+ const fig = await api.analysis.nmds(study, body)
+ if (current()) setNmdsFig(fig)
} else {
- setPermanova(await api.analysis.permanova(study, body))
+ const res = await api.analysis.permanova(study, body)
+ if (current()) setPermanova(res)
}
} catch (err) {
+ if (!current()) return
+ // A failed rerun must not leave the previous result looking current.
+ if (type === 'alpha') setAlphaFig(null)
+ else if (type === 'nmds') setNmdsFig(null)
+ else setPermanova(null)
const label = type === 'alpha' ? 'Alpha comparison' : type === 'nmds' ? 'NMDS' : 'PERMANOVA'
toast.error(`${label} failed: ${errorMessage(err)}`)
} finally {
- setLoading(null)
+ if (current()) setLoading(null)
}
}
@@ -82,6 +109,11 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
NMDS and PERMANOVA need R with vegan.
)}
+ {capabilitiesError && (
+
+ Could not check whether R is available: {capabilitiesError}
+
+ )}
{alphaFig != null && (
@@ -99,19 +131,20 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
PERMANOVA Results
{permanova.text}
- {(permanova.terms?.length
- ? permanova.terms
- : permanova.p_value != null
- ? [{ term: 'model', r2: permanova.r2 ?? NaN, f_statistic: permanova.f_statistic, p_value: permanova.p_value }]
- : []
- ).map(t => (
+ {(permanova.terms ?? []).map(t => (
- {t.term}: R² = {t.r2.toFixed(3)} · F = {t.f_statistic?.toFixed(2) ?? 'n/a'} · p = {t.p_value?.toFixed(4) ?? 'n/a'}
+ {t.term}: R² = {t.r2.toFixed(3)} · F = {t.f_statistic?.toFixed(2) ?? 'n/a'} ·{' '}
+ {t.untestable_reason ? `p not reported: ${t.untestable_reason}` : `p = ${t.p_value?.toFixed(4) ?? 'n/a'}`}
))}
{(permanova.terms?.length ?? 0) > 1 && (
Terms are tested in order, each after the ones above it.
)}
+ {permanova.permutations != null && permanova.min_p_value != null && (
+
+ {permanova.permutations} permutations; the smallest attainable p is {permanova.min_p_value.toFixed(4)}.
+
+ )}
{permanova.blocked && (
Permutations restricted within individuals.
)}
diff --git a/frontend/src/components/PipelineStages.tsx b/frontend/src/components/PipelineStages.tsx
index 8a96ab41..85129267 100644
--- a/frontend/src/components/PipelineStages.tsx
+++ b/frontend/src/components/PipelineStages.tsx
@@ -150,8 +150,10 @@ export const CONFIG_DESCRIPTIONS: Record
= {
'analysis.alpha.rarefaction_iterations': 'rarefy draws averaged for each alpha diversity value.',
'analysis.beta.normalisation': 'Preparation before the Bray-Curtis dissimilarity used by NMDS and PERMANOVA: hellinger (square root of relative abundances), none, rarefy or srs.',
'analysis.beta.depth': 'Reads per sample for rarefy or srs; 0 uses the smallest non-empty sample.',
- 'analysis.differential.offset': 'Library-size offset in each negative-binomial model: tss (total reads per sample) or rle (median-of-ratios over taxa present in every sample; refused when there are none).',
+ 'analysis.differential.method': 'Model fitted to each taxon: nb_glm (negative-binomial GLM on read counts; effect is a log2 fold change) or clr_welch (Welch\'s t-test on centred log-ratios over the taxa passing min_prevalence, after zCompositions::cmultRepl zero replacement; effect is a CLR difference, not a fold change).',
+ 'analysis.differential.offset': 'Library-size offset in each negative-binomial model: tss (total reads per sample) or rle (median-of-ratios over taxa present in every sample; refused when there are none). Used by nb_glm only.',
'analysis.differential.min_prevalence': 'Fraction of samples, 0 to 1, in which a taxon must have reads to be tested; taxa below it are listed as filtered.',
+ 'analysis.differential.replacement_delta': 'clr_welch only: cmultRepl\'s frac, between 0 and 1; an imputed proportion above its taxon\'s smallest observed proportion is lowered to this fraction of it. Replaced zeros are not measurements.',
'analysis.alpha.show_points': 'Show individual samples overlaid on alpha comparison boxplots.',
'analysis.alpha.annotate_significance': 'Annotate alpha comparison panels with Kruskal-Wallis significance.',
'analysis.alpha.pairwise_brackets': 'Run BH-adjusted pairwise Wilcoxon rank-sum tests between every group pair and draw brackets, including n.s. results.',
@@ -248,8 +250,10 @@ export const CONFIG_TYPES: Record = {
'analysis.alpha.rarefaction_iterations': { kind: 'int' },
'analysis.beta.normalisation': { kind: 'enum', options: ['hellinger', 'none', 'rarefy', 'srs'] },
'analysis.beta.depth': { kind: 'int' },
+ 'analysis.differential.method': { kind: 'enum', options: ['nb_glm', 'clr_welch'] },
'analysis.differential.offset': { kind: 'enum', options: ['tss', 'rle'] },
'analysis.differential.min_prevalence': { kind: 'float', min: 0, max: 1, step: 0.05 },
+ 'analysis.differential.replacement_delta': { kind: 'float', min: 0.01, max: 0.99, step: 0.05 },
'analysis.alpha.show_points': { kind: 'boolean' },
'analysis.alpha.annotate_significance': { kind: 'boolean' },
'analysis.alpha.pairwise_brackets': { kind: 'boolean' },
diff --git a/frontend/src/components/TaxaCompositionChart.tsx b/frontend/src/components/TaxaCompositionChart.tsx
index 2b8221a4..9601aebd 100644
--- a/frontend/src/components/TaxaCompositionChart.tsx
+++ b/frontend/src/components/TaxaCompositionChart.tsx
@@ -1,6 +1,6 @@
// (c) 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { useAnalysisSource } from './analysisSource'
import { errorMessage } from '../api/errorMessage'
@@ -24,6 +24,13 @@ const LABEL_STYLE: React.CSSProperties = {
gap: 4,
}
+/**
+ * Select and compute a taxon or category bar chart for one run, or for `runs`
+ * when that list is non-empty, with optional facets across runs. `table`
+ * defaults to merged. Providing `subgroup`, including null for all sub-groups,
+ * controls that selection and hides the internal selector; facets set their
+ * own scope. Changed inputs clear the figure; chart failures show a toast.
+ */
export function TaxaCompositionChart({
study,
run,
@@ -78,6 +85,8 @@ export function TaxaCompositionChart({
//## Chart state
const [figure, setFigure] = useState(null)
const [loading, setLoading] = useState(false)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
//## Derived flags
const isCrossRun = runs !== undefined && runs.length > 0
@@ -102,9 +111,11 @@ export function TaxaCompositionChart({
useEffect(() => {
if (tag !== 'rank') return
if (!refRun) { setRanks([]); return }
+ let cancelled = false
api.analysis
.ranks(study, refRun, { table: effectiveTable, group: refGroup ?? undefined })
.then(result => {
+ if (cancelled) return
setRanks(result)
setRank(current =>
current && result.includes(current)
@@ -112,7 +123,8 @@ export function TaxaCompositionChart({
: result[result.length - 1] ?? null
)
})
- .catch(() => setRanks([]))
+ .catch(() => { if (!cancelled) setRanks([]) })
+ return () => { cancelled = true }
}, [study, refRun, refGroup, tag, effectiveTable])
//## Fetch category sets when tag='category'
@@ -131,7 +143,22 @@ export function TaxaCompositionChart({
const gridRows =
(figure as { layout?: { grid?: { rows?: number } } } | null)?.layout?.grid?.rows ?? 1
+ // Everything a request is built from. A figure answers the inputs it was
+ // computed from, so new inputs discard it, along with any request still in
+ // flight for the old ones. Keyed on content so a new-but-equal `runs` array
+ // does not clear the chart.
+ const inputsKey = JSON.stringify([
+ study, run, group, runs, effectiveTable, source, tag, rank, catSet, topN,
+ relative, mode, keepEmpty, isFaceted, facetRows, facetCols, subgroup,
+ ])
+ useEffect(() => {
+ request.current++
+ setFigure(null)
+ setLoading(false)
+ }, [inputsKey])
+
//## Compute handler
+ /** Build the chart for the current inputs and show it, unless the inputs changed meanwhile. */
const handleShow = async () => {
const value = tag === 'rank' ? rank : catSet
if (!value) return
@@ -140,6 +167,7 @@ export function TaxaCompositionChart({
toast.error('No run specified')
return
}
+ const req = ++request.current
setLoading(true)
try {
const body = {
@@ -151,27 +179,31 @@ export function TaxaCompositionChart({
keep_empty: keepEmpty,
...(tag === 'rank' ? { top_n: topN } : {}),
}
+ let fig: unknown
if (isFaceted) {
- setFigure(await api.analysis.chartFacet(study, {
+ fig = await api.analysis.chartFacet(study, {
...body,
runs: runs!,
rows: facetRows,
cols: facetCols,
- }))
+ })
} else if (isCrossRun) {
- setFigure(await api.analysis.chartCompare(study, {
+ fig = await api.analysis.chartCompare(study, {
...body,
subgroup: subgroup ?? null,
runs: runs!,
- }))
+ })
} else {
- setFigure(await api.analysis.chart(study, run!,
- { ...body, subgroup: subgroup ?? null, ...(source ? { source } : {}) }, group))
+ fig = await api.analysis.chart(study, run!,
+ { ...body, subgroup: subgroup ?? null, ...(source ? { source } : {}) }, group)
}
+ if (req === request.current) setFigure(fig)
} catch (err) {
+ if (req !== request.current) return
+ setFigure(null)
toast.error(`Chart failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
diff --git a/frontend/src/components/VennPanel.tsx b/frontend/src/components/VennPanel.tsx
index e276f24d..4ed37a0f 100644
--- a/frontend/src/components/VennPanel.tsx
+++ b/frontend/src/components/VennPanel.tsx
@@ -8,11 +8,12 @@ import { api } from '../api/client'
import { errorMessage } from '../api/errorMessage'
import { useToast } from './Toast'
import type { ComparisonRunSpec, VennResult } from '../api/types'
-import { uniqueRuns } from '../api/types'
+import { sharedRanks } from './sharedRanks'
import type { AnalysisOption } from './annotationShared'
type Mode = 'euler' | 'upset'
+/** Taxon overlap between the selected runs at one shared rank, as an Euler or UpSet diagram. */
export function VennPanel({ study, runs, option }: {
study: string
runs: ComparisonRunSpec[]
@@ -25,6 +26,9 @@ export function VennPanel({ study, runs, option }: {
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [ranksReady, setRanksReady] = useState(false)
+ const [ranksError, setRanksError] = useState(null)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
const box = useRef(null)
const [width, setWidth] = useState(580)
@@ -38,35 +42,43 @@ export function VennPanel({ study, runs, option }: {
// Discover the intersection of taxonomy ranks available across all selected runs.
useEffect(() => {
+ setRanksError(null)
if (!option || runs.length === 0) { setRanks([]); setRank(''); setRanksReady(false); return }
let cancelled = false
setRanksReady(false)
- Promise.all(
- uniqueRuns(runs).map(r =>
- api.analysis.ranks(study, r.run, {
- table: option.table,
- group: r.group,
- ...(r.source ? { source: r.source } : {}),
- }).catch(() => [] as string[])
- )
- ).then(results => {
+ sharedRanks(runs, r => api.analysis.ranks(study, r.run, {
+ table: option.table,
+ group: r.group,
+ ...(r.source ? { source: r.source } : {}),
+ })).then(intersection => {
if (cancelled) return
- const intersection = results.reduce((acc, cur) =>
- acc.filter(r => cur.includes(r)),
- results[0] ?? []
- )
setRanks(intersection)
setRanksReady(true)
// Default to the deepest (last) shared rank.
setRank(current =>
intersection.includes(current) ? current : (intersection[intersection.length - 1] ?? '')
)
+ }).catch(err => {
+ if (cancelled) return
+ setRanks([])
+ setRank('')
+ setRanksError(errorMessage(err))
})
return () => { cancelled = true }
}, [study, runs, option])
+ // A diagram answers the inputs it was computed from; new inputs discard it,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setResult(null)
+ setLoading(false)
+ }, [study, runs, option?.table, rank])
+
+ /** Compute the overlap for the current inputs and show it, unless the inputs changed meanwhile. */
const runVenn = async () => {
if (!option || !rank) return
+ const req = ++request.current
setLoading(true)
try {
const res = await api.analysis.venn(study, {
@@ -74,11 +86,13 @@ export function VennPanel({ study, runs, option }: {
table: option.table,
rank,
})
- setResult(res)
+ if (req === request.current) setResult(res)
} catch (err) {
+ if (req !== request.current) return
+ setResult(null)
toast.error(`Taxon overlap failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
@@ -111,6 +125,9 @@ export function VennPanel({ study, runs, option }: {
{ranksReady && ranks.length === 0 && (
No taxonomy rank shared by the selected runs.
)}
+ {ranksError && (
+ Could not read the taxonomy ranks: {ranksError}
+ )}