diff --git a/.Rprofile b/.Rprofile index 81b960f5..941cd753 100644 --- a/.Rprofile +++ b/.Rprofile @@ -1 +1,8 @@ +# Copy packages into renv/library rather than linking them into renv's per-user +# cache. That cache lives under ~/.cache (~/Library/Caches on macOS), which cache +# cleaners treat as disposable; emptying it left every linked package dangling and +# the project's R library unusable until a full restore. Copies still come from +# the cache, so restores stay fast. Convert an existing linked library in place +# with renv::isolate(). +options(renv.config.cache.symlinks = FALSE) source("renv/activate.R") diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..f790ca1b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: Bug report +description: Something in MetaManifold does not work as documented. +labels: [bug] +body: + - type: markdown + attributes: + value: | + Search the existing issues first. Raise issues about a fork's own code on that fork. + - type: textarea + id: steps + attributes: + label: Steps or command + description: The command you ran, or the steps in the web interface, so that someone else can repeat them. + validations: + required: true + - type: textarea + id: expected + attributes: + label: What you expected + validations: + required: true + - type: textarea + id: actual + attributes: + label: What happened + description: Include the error message, the server log lines, or a screenshot. + validations: + required: true + - type: input + id: julia + attributes: + label: Julia version + placeholder: output of `julia --version` + validations: + required: true + - type: input + id: r + attributes: + label: R version + description: Only if the problem touches the R runtime; leave empty if you do not know it. + placeholder: output of `R --version | head -1` + - type: input + id: browser + attributes: + label: Browser and version + description: Only for problems in the web interface; leave empty for command-line reports. + - type: input + id: commit + attributes: + label: MetaManifold commit + placeholder: output of `git rev-parse --short HEAD` + - type: textarea + id: config + attributes: + label: Relevant configuration + description: The keys from `pipeline.yml` or the run's `run_config.yml` that bear on the problem. + render: yaml diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..312c2aec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,41 @@ +name: Feature request +description: Suggest a new feature or a change to an existing one. +labels: [enhancement] +body: + - type: markdown + attributes: + value: | + Search the existing issues first. To change an architectural decision, describe the change + here before writing any code. The settled decisions in CONTRIBUTING.md can be discussed in an + issue, but a pull request does not reopen them. + - type: textarea + id: problem + attributes: + label: Problem + description: What you are trying to do, and what stops you now. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed change + description: What MetaManifold should do instead. For a statistical method, name the method and a reference for it. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + - type: dropdown + id: area + attributes: + label: Area + options: + - Pipeline stages + - Analysis and statistics + - Web interface + - Configuration + - Installation and toolchain + - Other + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..f149e1d3 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,20 @@ +## Change and reason + + + +## Validation + + + +## Scope + + + +## Contribution checks + +- [ ] I followed [CONTRIBUTING.md](https://github.com/JoshuaJewell/MetaManifold-WebUI/blob/main/CONTRIBUTING.md). +- [ ] This branch starts at current upstream main and contains no merges from fork main. +- [ ] Every commit is signed and shows as Verified. +- [ ] CI has run and passes. +- [ ] Existing licensing, citation and acknowledgement text is unchanged. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912daaba..573768be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,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,19 +53,6 @@ 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] steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 @@ -48,7 +60,12 @@ jobs: - name: Set up Julia uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0 with: - version: ${{ matrix.julia-version }} + # 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. + version: "1.12.5" - name: Cache Julia packages uses: julia-actions/cache@d10a6fd8f31b12404a54613ebad242900567f2b9 # v2.1.0 @@ -139,7 +156,10 @@ 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","MASS","zCompositions","glmGamPoi")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")' + + - name: Check DADA2 CSV assembly + run: Rscript --vanilla test/unit/test_dada2_tables.R # 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. @@ -230,7 +250,7 @@ jobs: julia --project=. -e ' using RCall R"cat(.libPaths(), sep=\"\n\")" - for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS"] + for pkg in ["dada2", "vegan", "MASS", "zCompositions", "glmGamPoi"] R"if (!require($pkg, character.only=TRUE, quietly=TRUE)) stop($pkg, \" not found\")" end ' @@ -282,3 +302,45 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} slug: JoshuaJewell/MetaManifold-WebUI fail_ci_if_error: false + + # Machine-checked proofs of the Benjamini-Hochberg adjustment and the TSS/RLE + # scaling algebra (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@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - 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/.gitignore b/.gitignore index a6ed2b7d..6ef7e458 100644 --- a/.gitignore +++ b/.gitignore @@ -332,3 +332,6 @@ LocalPreferences.toml # Development server log (dev/dev.sh) dev/server.log + +# Agda interface files (proofs/agda) +proofs/agda/_build/ diff --git a/CITATION.cff b/CITATION.cff index 81b8b246..eeaa2937 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -9,7 +9,7 @@ authors: affiliation: "Royal Veterinary College" orcid: "https://orcid.org/0009-0007-0289-9741" version: "0.1.0" -date-released: "2026-05-21" +date-released: "2026-10-19" license: AGPL-3.0-only repository-code: "https://github.com/JoshuaJewell/MetaManifold-WebUI" url: "https://github.com/JoshuaJewell/MetaManifold-WebUI" @@ -17,9 +17,11 @@ abstract: >- MetaManifold is a Julia-native orchestrator for amplicon sequencing workflows. It wraps cutadapt, DADA2, SWARM, vsearch, and cd-hit-est into a single configurable pipeline, storing per-run results in DuckDB and exposing - them through an interactive React frontend. Analyses include alpha diversity, - taxonomic composition, NMDS, and PERMANOVA via R/vegan, together with - cross-run comparison and a functional-database annotation layer. + them through an interactive React frontend. ASVs can be placed on reference + trees with MAFFT, trimAl, IQ-TREE, RAxML-EPA and gappa. Analyses include + alpha diversity, taxonomic and organism-category composition, NMDS, + PERMANOVA and PERMDISP via R/vegan, and differential abundance, with + cross-run comparison throughout. keywords: - amplicon sequencing - metabarcoding @@ -28,6 +30,7 @@ keywords: - bioinformatics - Julia - DuckDB + - phylogenetic placement references: - type: article title: "DADA2: High-resolution sample inference from Illumina amplicon data" @@ -87,3 +90,210 @@ references: volume: 4 start: e2584 doi: "10.7717/peerj.2584" + - type: article + title: "Cutadapt removes adapter sequences from high-throughput sequencing reads" + authors: + - family-names: "Martin" + given-names: "Marcel" + journal: "EMBnet.journal" + year: 2011 + volume: 17 + issue: 1 + start: 10 + end: 12 + doi: "10.14806/ej.17.1.200" + - type: article + title: "CD-HIT: accelerated for clustering the next-generation sequencing data" + authors: + - family-names: "Fu" + given-names: "Limin" + - family-names: "Niu" + given-names: "Beifang" + - family-names: "Zhu" + given-names: "Zhengwei" + - family-names: "Wu" + given-names: "Sitao" + - family-names: "Li" + given-names: "Weizhong" + journal: "Bioinformatics" + year: 2012 + volume: 28 + issue: 23 + start: 3150 + end: 3152 + doi: "10.1093/bioinformatics/bts565" + - type: article + title: "MultiQC: summarize analysis results for multiple tools and samples in a single report" + authors: + - family-names: "Ewels" + given-names: "Philip" + - family-names: "Magnusson" + given-names: "Måns" + - family-names: "Lundin" + given-names: "Sverker" + - family-names: "Käller" + given-names: "Max" + journal: "Bioinformatics" + year: 2016 + volume: 32 + issue: 19 + start: 3047 + end: 3048 + doi: "10.1093/bioinformatics/btw354" + - type: article + title: "MAFFT Multiple Sequence Alignment Software Version 7: Improvements in Performance and Usability" + authors: + - family-names: "Katoh" + given-names: "Kazutaka" + - family-names: "Standley" + given-names: "Daron M." + journal: "Molecular Biology and Evolution" + year: 2013 + volume: 30 + issue: 4 + start: 772 + end: 780 + doi: "10.1093/molbev/mst010" + - type: article + title: "trimAl: a tool for automated alignment trimming in large-scale phylogenetic analyses" + authors: + - family-names: "Capella-Gutiérrez" + given-names: "Salvador" + - family-names: "Silla-Martínez" + given-names: "José M." + - family-names: "Gabaldón" + given-names: "Toni" + journal: "Bioinformatics" + year: 2009 + volume: 25 + issue: 15 + start: 1972 + end: 1973 + doi: "10.1093/bioinformatics/btp348" + - type: article + title: "IQ-TREE 3: phylogenomic inference software using complex evolutionary models" + authors: + - family-names: "Wong" + given-names: "Thomas K F" + - family-names: "Ly-Trong" + given-names: "Nhan" + - family-names: "Ren" + given-names: "Huaiyan" + - family-names: "Demotte" + given-names: "Piyumal" + - family-names: "Baños" + given-names: "Hector" + - family-names: "Roger" + given-names: "Andrew J" + - family-names: "Susko" + given-names: "Edward" + - family-names: "Bielow" + given-names: "Chris" + - family-names: "De Maio" + given-names: "Nicola" + - family-names: "Goldman" + given-names: "Nick" + - family-names: "Hahn" + given-names: "Matthew W" + - family-names: "dos Reis" + given-names: "Mario" + - family-names: "Vinh" + given-names: "Le Sy" + - family-names: "Huttley" + given-names: "Gavin" + - family-names: "Lanfear" + given-names: "Robert" + - family-names: "Minh" + given-names: "Bui Quang" + journal: "Molecular Biology and Evolution" + year: 2026 + volume: 43 + issue: 5 + doi: "10.1093/molbev/msag117" + - type: article + title: "RAxML version 8: a tool for phylogenetic analysis and post-analysis of large phylogenies" + authors: + - family-names: "Stamatakis" + given-names: "Alexandros" + journal: "Bioinformatics" + year: 2014 + volume: 30 + issue: 9 + start: 1312 + end: 1313 + doi: "10.1093/bioinformatics/btu033" + - type: article + title: "Performance, Accuracy, and Web Server for Evolutionary Placement of Short Sequence Reads under Maximum Likelihood" + authors: + - family-names: "Berger" + given-names: "Simon A." + - family-names: "Krompass" + given-names: "Denis" + - family-names: "Stamatakis" + given-names: "Alexandros" + journal: "Systematic Biology" + year: 2011 + volume: 60 + issue: 3 + start: 291 + end: 302 + doi: "10.1093/sysbio/syr010" + - type: article + title: "Genesis and Gappa: processing, analyzing and visualizing phylogenetic (placement) data" + authors: + - family-names: "Czech" + given-names: "Lucas" + - family-names: "Barbera" + given-names: "Pierre" + - family-names: "Stamatakis" + given-names: "Alexandros" + journal: "Bioinformatics" + year: 2020 + volume: 36 + issue: 10 + start: 3263 + end: 3265 + doi: "10.1093/bioinformatics/btaa070" + - type: article + title: "zCompositions - R package for multivariate imputation of left-censored data under a compositional approach" + authors: + - family-names: "Palarea-Albaladejo" + given-names: "Javier" + - family-names: "Martín-Fernández" + given-names: "Josep Antoni" + journal: "Chemometrics and Intelligent Laboratory Systems" + year: 2015 + volume: 143 + start: 85 + end: 96 + doi: "10.1016/j.chemolab.2015.02.019" + - type: software + title: "FastQC: a quality control tool for high throughput sequence data" + authors: + - family-names: Andrews + given-names: "Simon" + year: 2010 + url: "https://www.bioinformatics.babraham.ac.uk/projects/fastqc/" + - type: software + title: "vegan: Community Ecology Package" + authors: + - family-names: Oksanen + given-names: "Jari" + - name: "and others" + version: "2.7-3" + year: 2026 + doi: "10.32614/CRAN.package.vegan" + url: "https://CRAN.R-project.org/package=vegan" + - type: book + title: "Modern Applied Statistics with S" + authors: + - family-names: Venables + given-names: "William N." + - family-names: Ripley + given-names: "Brian D." + edition: "Fourth" + publisher: + name: "Springer" + city: "New York" + year: 2002 + isbn: "0-387-95457-0" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 504ad6b0..2bea6bd5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,6 +4,13 @@ This file sets out what a pull request must look like, how licensing and credit work, and which design decisions are settled. Pull requests that miss the requirements under [Pull requests](#pull-requests) are closed without review. +## Scope + +MetaManifold is an amplicon metabarcoding pipeline with a web interface. +Features that serve another project's research, and dependencies on packages +the contributor maintains, belong in a fork unless they are registered, +independently useful, and agreed in an issue first. + ## Issues Issues track defects and features in this repository. Raise issues about a fork's @@ -28,18 +35,35 @@ Each requirement is checked before the code is read. ``` `behind_by` must be `0`. Rebase when it is not. -2. **No merge commits from `main`** and no automated conflict-resolution commits. - A branch that cannot be re-applied cleanly on top of `main` is not ready. -3. **One logical change per PR.** -4. **CI must run and pass.** A PR with no reported checks is not reviewed. -5. **Write the description:** what the change does, the issues it closes, how to +2. **No merge commits,** whether from this repository's `main` or a fork's, and + no automated conflict-resolution commits. A branch that cannot be re-applied + cleanly on top of `main` is not ready. +3. **Sign every commit.** Each commit must show as Verified on GitHub, signed + with a GPG, SSH or S/MIME key registered to your account. Check before + pushing: + + ``` + git log --format='%h %G? %s' main..HEAD + ``` + + `N` means unsigned. (`E` usually means your local git cannot check the + signature, for example SSH signing without `gpg.ssh.allowedSignersFile`; + GitHub's Verified badge is what counts.) Unsigned commits are re-signed with + `git rebase --exec 'git commit --amend --no-edit -S' main`. +4. **One logical change per PR.** +5. **CI must run and pass.** A PR with no reported checks is not reviewed. +6. **Write the description:** what the change does, the issues it closes, how to run its tests, what it leaves out, and the Julia, R and bun versions you tested with. -6. **No generated or vendored files:** build output, `node_modules`, downloaded +7. **No generated or vendored files:** build output, `node_modules`, downloaded tool archives, or lockfile changes the work does not need. When a lockfile change is needed, the description says why. -7. **Once review has started, add commits,** and rebase only when asked to. - Squashing happens on merge. +8. **Merging is by fast-forward only.** The commits on your branch are the + commits that land on `main`, unchanged: nothing is squashed or rebased on + merge, so every commit must build, pass the tests and carry a message that + stands on its own. Once review has started, add commits, and rebase only when + asked to. If `main` moves before the merge, you will be asked to rebase onto + it and re-sign. ## Licence and copyright @@ -98,7 +122,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/R/_renv_dependencies.R b/R/_renv_dependencies.R index d9b4c570..589612fe 100644 --- a/R/_renv_dependencies.R +++ b/R/_renv_dependencies.R @@ -3,7 +3,7 @@ if (FALSE) { library(dada2) library(vegan) - library(dplyr) - library(tibble) + library(glmGamPoi) library(MASS) + library(zCompositions) } diff --git a/README.md b/README.md index bb9883cc..25fcfce9 100644 --- a/README.md +++ b/README.md @@ -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: @@ -239,6 +240,40 @@ The composition view classifies each ASV/OTU into a biological category and rend Organism-category composition of two runs as stacked bars

+### Differential abundance + +In a study's analysis workspace, select two conditions (runs or pooled-run +subgroups), a results table and a shared taxonomic rank. The first condition +is the reference. Counts are aggregated by taxon after applicable +`analysis.exclude_categories` filters. This workspace does not pass sample +read-count bounds to the differential-abundance request, so those bounds do +not exclude samples from this analysis. + +All `analysis.differential` options below (`method`, `offset`, +`min_prevalence` and `replacement_delta`) are resolved at study scope. +Set them in the study configuration; group and run overrides are not used +by this endpoint, including when called from a group Analysis workspace. + +`analysis.differential.method` chooses the method: + +- `nb_glm` (default): a negative-binomial GLM per taxon using `MASS::glm.nb`, + with a library-size offset. The effect is log2 fold change; positive values + indicate greater abundance in the second condition. `offset` selects `tss` + (default) or `rle` scaling. +- `clr_welch`: Welch's t-test on centred log-ratios after multiplicative zero + replacement with `zCompositions::cmultRepl`. The effect is a CLR difference, + not a fold change. `replacement_delta` controls the replacement fraction + (default 0.65); `offset` is not used. + +For either method, `min_prevalence` (default 0) filters taxa by the fraction of +samples with nonzero counts. Benjamini–Hochberg adjustment includes only taxa +that produced a p-value. Failed or filtered taxa have no substitute p-value; +inspect their status and note in the results table. Invalid input or an +unavailable required R package produces an explicit error. + +Implementation and reference tests: `src/analysis/differential.jl`, +`src/server/routes/analysis.jl`, and `test/unit/test_differential.jl`. + ### Figures and report The Figures tab lays analysis charts out on a page (A4, letter or a journal @@ -403,6 +438,32 @@ top level, run this once to keep completed taxonomy work current: julia --project=. -e 'include("scripts/migrate_r_threads.jl"); MigrateRThreads.run_migration("projects")' ``` +### Profiling stage memory (`occupancy_probe:` in `pipeline.yml`) + +A diagnostic for sizing a machine, off by default and not offered in the config +editor. Add it to a run's or study's `pipeline.yml` by hand: + +```yaml +occupancy_probe: + enabled: true + interval_ms: 10 # sampling interval in milliseconds (at least 1) +``` + +Each stage then records an `occupancy` entry in the run's `attestation.yml`: + +- `child_peak_rss_kib`: the largest total resident memory of the stage's tool + processes (vsearch, swarm, cd-hit and so on) at any one sample. Tools run + concurrently can approach the sum of their individual peaks. +- under `tasks`, each task's `peak_rss_kib` (largest sampled resident memory) + and `hwm_kib` (the kernel's high-water mark), with `samples: 0` for a process + too short-lived to be sampled. +- `julia_allocated_bytes`, the Julia allocation over the stage. It is + process-wide, so it includes other runs' pipelines when several run at once. + +Linux only, since it reads `/proc`. For stages run on a remote server, the +figures describe the local ssh client, not the work. With the probe off, the +records are unchanged. + ### Configuring DADA2 (`dada2:` in `pipeline.yml`) ```yaml @@ -644,6 +705,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: @@ -805,15 +890,19 @@ This pipeline draws on the following prior work: - **Frédéric Mahé**: [Fred's metabarcoding pipeline](https://github.com/frederic-mahe/swarm/wiki/Fred's-metabarcoding-pipeline) informed the overall workflow architecture, namely the sequencing of primer trimming, `swarm.jl`, vsearch-based taxonomy assignment, and the final table merge/filter stages. - **Benjamin J. Callahan _et al._**: [DADA2 tutorial](https://benjjneb.github.io/dada2/tutorial.html), used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), on which `dada2.jl` and its modules are based. +The following people contributed code to this project: + +- **Jonathan D.A. Jewell** ([@hyperpolymath](https://github.com/hyperpolymath)): the differential abundance analysis, statistical-correctness fixes to the omnibus tests and PERMANOVA, server hardening, CI and installer improvements, and the machine-checked Agda proofs in `proofs/agda/`, which he maintains. + The following colleagues at the **Department of Parasitology, Charles University** (Faculty of Science, BIOCEV, Vestec, Czech Republic) contributed to this work: -- **Mgr. Jiří Novák** (supervisor): scripts from which several modules and configurations were adapted. +- **Mgr. Jiří Novák** ([@novakj96](https://github.com/novakj96)) (supervisor): scripts from which several modules and configurations were adapted. - **doc. Mgr. Vladimír Hampl**: provided laboratory access and resources. - **Mgr. Paulína Pristašová**: <3. ## Licence -Copyright © 2026 Joshua Benjamin Jewell. +Copyright © 2026 Joshua Benjamin Jewell and contributors. Source code is licensed under the [GNU Affero General Public License v3.0](LICENSE). diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml index 11198e7a..debcff02 100644 --- a/config/defaults/pipeline.yml +++ b/config/defaults/pipeline.yml @@ -347,10 +347,49 @@ 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 + # nb_glm only: the fits and the test. glm_nb (the default) fits + # MASS::glm.nb per taxon with its maximum-likelihood theta and Wald-tests + # the group coefficient. glmGamPoi fits one glmGamPoi::glm_gp across the + # taxa passing min_prevalence, with overdispersion shrinkage toward the + # cross-taxon trend, and reports glmGamPoi::test_de's quasi-likelihood + # F-test of the group contrast with its log2 fold changes; the reported + # theta inverts glm_gp's negative-binomial overdispersion, and a taxon + # whose overdispersion is zero is marked at the Poisson boundary, as under + # glm_nb. Requires the pinned glmGamPoi R package. Refused with clr_welch. + dispersion: glm_nb # 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/release-notes/v0.1.0.md b/docs/release-notes/v0.1.0.md index a04245c6..8ac0907f 100644 --- a/docs/release-notes/v0.1.0.md +++ b/docs/release-notes/v0.1.0.md @@ -3,35 +3,56 @@ ## What MetaManifold is MetaManifold is a self-hostable platform for amplicon microbiome analysis by -metabarcoding. It composes established command-line tools of the field -(cutadapt, DADA2, SWARM, VSEARCH, cd-hit-est) into a single configurable -pipeline, stores per-run results in DuckDB, and presents them through an -interactive web interface. It gives particular attention to eukaryotic data -(18S amplicons classified against PR2) and treats comparison across sequencing -runs as a priority operation. +metabarcoding. It composes established command-line tools of the field into a +single configurable pipeline, stores per-run results in DuckDB, and presents +them through an interactive web interface. It gives particular attention to +eukaryotic data (18S amplicons classified against PR2) and treats comparison +across sequencing runs as a priority operation. This first release exists to provide a fixed, dependency-pinned point that other researchers can install, run, reproduce, and cite in their own work. ## Highlights -- End-to-end amplicon orchestrator in Julia: cutadapt, DADA2, SWARM, vsearch, - and cd-hit-est composed under a single configurable pipeline, with per-run - and cross-run DuckDB results. -- Interactive React/Plotly frontend for studies, runs, composition, alpha - diversity, NMDS, PERMANOVA, cross-run comparison, and a Venn / UpSet view. -- Functional annotation layer (`funcdb`) that matches taxa across a - eukaryote-aware rank hierarchy (PR2, from species up to supergroup), derives - a dual-classifier consensus rank and confidence score where VSEARCH and DADA2 - agree, and supports auditable manual curation through an append-only ledger. +- **Pipeline.** cutadapt primer trimming, then DADA2 (ASVs) and swarm (OTUs) + side by side, with vsearch and DADA2 taxonomy assignment, cd-hit-est, and a + final merge and filter into per-run DuckDB results. FastQC and MultiQC + provide raw-read QC. +- **Phylogenetic placement.** A shared library of reference trees (MAFFT, + trimAl, IQ-TREE), onto which a study's ASVs are placed (MAFFT + `--addfragments`, RAxML EPA, gappa), with alignment QC at each step. +- **Web interface.** Every setting is editable at any level of the + instance/study/group/run cascade, with stale stages flagged when settings + change. Runs and individual stages are launched from the browser with live + progress. It also has a filterable results explorer with saved presets and + `.xlsx` export, and an interactive tree viewer for Newick and jplace files. +- **Analysis.** Alpha diversity with significance testing, taxonomic and + organism-category composition, Euler and UpSet overlap, NMDS, PERMANOVA (each + term tested separately) with PERMDISP, and differential abundance between two + conditions (a negative-binomial GLM, or Welch's t-test on centred + log-ratios). A test that could not run is reported as such, never as a null + result. +- **Figures and report.** Analysis charts laid out on a page and exported. +- **Remote execution.** Any of the DADA2 and phylogeny stages can be sent to a + server over SSH. +- **Provenance.** Each run records the configuration, tool versions and + database hashes behind every stage in `attestation.yml`. - AGPL-3.0 licensed; CI on GitHub Actions; coverage tracked on Codecov. +## Platforms + +- **Linux:** supported and tested. +- **Windows:** supported through WSL, tested. Native Windows is not supported. +- **macOS:** the installer supports it and it is expected to work, but it has + not been tested for this release. Please report problems as issues. + ## Reproducibility -- Julia dependencies pinned in `Manifest.toml`. -- R dependencies (DADA2, vegan, and their transitive chain) pinned in - `renv.lock`; restore with `renv::restore()` from the repository root. -- Citation metadata in `CITATION.cff`. +- Julia dependencies are pinned in `Manifest.toml`. +- R dependencies (DADA2, vegan, MASS, zCompositions and their dependencies) + are pinned in `renv.lock` and restored by the installer. +- External tool versions are pinned in `config/defaults/tool_versions.yml`. +- Citation metadata is in `CITATION.cff`. ## How to cite diff --git a/frontend/src/about.ts b/frontend/src/about.ts index 738a7871..b1f01ea3 100644 --- a/frontend/src/about.ts +++ b/frontend/src/about.ts @@ -3,7 +3,7 @@ export const SOURCE_URL = 'https://github.com/JoshuaJewell/MetaManifold-WebUI' export const LICENSE_URL = `${SOURCE_URL}/blob/main/LICENSE` -export const COPYRIGHT = '© 2026 Joshua Benjamin Jewell' +export const COPYRIGHT = '© 2026 Joshua Benjamin Jewell and contributors' /** Set once Zenodo mints the DOI, e.g. '10.5281/zenodo.1234567'. */ export const ZENODO_DOI: string | null = null diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 10410bd9..0eed781f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1,5 +1,6 @@ export interface StudySummary { name: string + /** Every run in the study, including those inside groups. */ run_count: number group_count: number active_job_count: number @@ -243,15 +244,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 +399,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 +432,30 @@ 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' + /** nb_glm only: where each taxon's theta comes from. */ + dispersion?: 'glm_nb' | 'glmGamPoi' + /** clr_welch only. */ + replacement_delta?: number + } + diagnostics: { + n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number + /** Samples left out because they have no reads. */ + samples_without_reads?: string[] + /** 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/AnalysisControls.tsx b/frontend/src/components/AnalysisControls.tsx index 2e0cfab3..682baf8d 100644 --- a/frontend/src/components/AnalysisControls.tsx +++ b/frontend/src/components/AnalysisControls.tsx @@ -3,6 +3,7 @@ import { PlotlyChart } from './PlotlyChart' import { useAlphaMetricFilter, AlphaMetricToggles } from './alphaMetrics' import type { UseAnalysisResult } from '../hooks/useAnalysis' +import { ComputingNote } from './ComputingNote' interface AnalysisControlsProps extends UseAnalysisResult { /** Truthy when analysis is ready (e.g. a table is selected). */ @@ -25,6 +26,7 @@ export function AnalysisControls({ {loading ? 'Computing…' : 'Alpha Diversity'} + {alphaFig != null && ( <> 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..08863fc3 100644 --- a/frontend/src/components/CompositionPanel.tsx +++ b/frontend/src/components/CompositionPanel.tsx @@ -53,6 +53,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/ComputingNote.tsx b/frontend/src/components/ComputingNote.tsx new file mode 100644 index 00000000..5db46b43 --- /dev/null +++ b/frontend/src/components/ComputingNote.tsx @@ -0,0 +1,26 @@ +// © 2026 Joshua Benjamin Jewell. All rights reserved. +// Licensed under the GNU Affero General Public License version 3 (AGPLv3). +import { useEffect, useState } from 'react' + +// Shown under an analysis's buttons while its request runs. The R-backed +// analyses can take tens of seconds on a large study, and a disabled button +// alone reads as a page that has stopped responding. +export function ComputingNote({ active, label = 'Computing' }: { active: boolean; label?: string }) { + const [seconds, setSeconds] = useState(0) + + useEffect(() => { + if (!active) { setSeconds(0); return } + const started = Date.now() + const timer = setInterval(() => setSeconds(Math.floor((Date.now() - started) / 1000)), 1000) + return () => clearInterval(timer) + }, [active]) + + if (!active) return null + return ( +

+

+ ) +} diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx index ff15b507..6f4070d5 100644 --- a/frontend/src/components/DifferentialPanel.tsx +++ b/frontend/src/components/DifferentialPanel.tsx @@ -1,16 +1,17 @@ // 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' +import { ComputingNote } from './ComputingNote' const cell = { padding: '3px 10px', textAlign: 'right' } as const const head = { padding: '4px 10px', textAlign: 'right' } as const @@ -21,10 +22,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 +37,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,12 +111,16 @@ 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}. )} + {result && ( <> @@ -109,6 +130,15 @@ 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.samples_without_reads?.length ?? 0) > 0 && + ` Left out ${result.diagnostics.samples_without_reads!.length} sample(s) with no reads: ` + + `${result.diagnostics.samples_without_reads!.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 +156,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 +168,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..e24db917 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' @@ -8,6 +8,7 @@ import { useToast } from './Toast' import { errorMessage } from '../api/errorMessage' import type { AnalysisOption } from './annotationShared' import type { ColFilter, ComparisonRunSpec, PermanovaResult } from '../api/types' +import { ComputingNote } from './ComputingNote' /** Alpha comparison, NMDS and PERMANOVA across the runs/sub-groups in `runs`. */ export function DiversityPanel({ study, runs, option, aggregate }: { @@ -22,11 +23,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 +53,36 @@ 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) + for (const w of (fig as { warnings?: string[] }).warnings ?? []) toast.info(w) + } } 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,7 +113,13 @@ 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 +136,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.test.ts b/frontend/src/components/PipelineStages.test.ts new file mode 100644 index 00000000..dd950da3 --- /dev/null +++ b/frontend/src/components/PipelineStages.test.ts @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +import { expect, test } from 'bun:test' +import { CONFIG_TYPES } from './PipelineStages' + +test('dispersion uses the enum editor with both supported models', () => { + expect(CONFIG_TYPES['analysis.differential.dispersion']).toEqual({ + kind: 'enum', options: ['glm_nb', 'glmGamPoi'], + }) +}) diff --git a/frontend/src/components/PipelineStages.tsx b/frontend/src/components/PipelineStages.tsx index 8a96ab41..b938f77a 100644 --- a/frontend/src/components/PipelineStages.tsx +++ b/frontend/src/components/PipelineStages.tsx @@ -150,8 +150,11 @@ 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.dispersion': 'nb_glm only: glm_nb fits MASS::glm.nb per taxon with its own maximum-likelihood theta; glmGamPoi fits one glmGamPoi::glm_gp across taxa with overdispersion shrinkage and reports glmGamPoi::test_de’s quasi-likelihood F-test with its log2 fold changes.', '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 +251,11 @@ 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.dispersion': { kind: 'enum', options: ['glm_nb', 'glmGamPoi'] }, '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..a3e0d230 100644 --- a/frontend/src/components/TaxaCompositionChart.tsx +++ b/frontend/src/components/TaxaCompositionChart.tsx @@ -1,12 +1,13 @@ // (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' import { AnalysisChart } from './AnalysisChart' import { useToast } from './Toast' import { FACET_DIMENSIONS, type CategorySet, type ComparisonRunSpec, type FacetDimension } from '../api/types' +import { ComputingNote } from './ComputingNote' //## Select / checkbox style tokens (matching DiversityPanel.tsx) const SELECT_STYLE: React.CSSProperties = { @@ -78,6 +79,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 +105,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 +117,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 +137,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 +161,7 @@ export function TaxaCompositionChart({ toast.error('No run specified') return } + const req = ++request.current setLoading(true) try { const body = { @@ -151,27 +173,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) } } @@ -341,6 +367,7 @@ export function TaxaCompositionChart({ {loading ? 'Computing…' : 'Show'}
+ {/* A grid needs headroom for its rows. */} {figure != null && ( diff --git a/frontend/src/components/VennPanel.tsx b/frontend/src/components/VennPanel.tsx index e276f24d..4a661387 100644 --- a/frontend/src/components/VennPanel.tsx +++ b/frontend/src/components/VennPanel.tsx @@ -8,11 +8,13 @@ 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' +import { ComputingNote } from './ComputingNote' 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 +27,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 +43,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 +87,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) } } @@ -86,6 +101,13 @@ export function VennPanel({ study, runs, option }: { () => result ? asSets(result.sets.map(s => ({ name: s.name, elems: s.taxa }))) : null, [result], ) + // Set names such as "Multiplex/Large_Intestine" overflow upsetjs's default + // label column (19% of the width), so it is sized to the longest name. + const labelWidth = useMemo( + () => Math.min(Math.round(width * 0.4), + 12 + 7 * Math.max(0, ...(result?.sets ?? []).map(s => s.name.length))), + [result, width], + ) return (
@@ -111,6 +133,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} + )}
+
{upsetjsSets && (mode === 'euler' - ? - : )} + ? + // Exclusive intersections, as the Euler regions are: a taxon counts once, + // in the one combination of sets it belongs to. + : )}
) diff --git a/frontend/src/components/annotationShared.test.ts b/frontend/src/components/annotationShared.test.ts new file mode 100644 index 00000000..276564a2 --- /dev/null +++ b/frontend/src/components/annotationShared.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { api } from '../api/client' +import type { TableMeta } from '../api/types' +import { discoverResultsTables, sharedOptions } from './annotationShared' + +const meta = (id: string, label: string) => ({ id, label }) as TableMeta + +describe('discoverResultsTables', () => { + let spy: ReturnType | null = null + afterEach(() => { spy?.mockRestore(); spy = null }) + + test('lists the run\'s tables sorted by label', async () => { + spy = spyOn(api.results, 'runTables').mockResolvedValue([meta('merged_otu', 'OTUs'), meta('merged', 'Merged')]) + expect(await discoverResultsTables('s', 'r')).toEqual([ + { key: 'merged', table: 'merged', label: 'Merged' }, + { key: 'merged_otu', table: 'merged_otu', label: 'OTUs' }, + ]) + }) + + test('a failed lookup rejects instead of reading as no tables', async () => { + spy = spyOn(api.results, 'runTables').mockRejectedValue(new Error('HTTP 503')) + await expect(discoverResultsTables('s', 'r')).rejects.toThrow('HTTP 503') + }) +}) + +describe('sharedOptions', () => { + const o = (key: string, label = key) => ({ key, table: key, label }) + + test('keeps only the tables every run has', () => { + expect(sharedOptions([[o('b'), o('a'), o('c')], [o('c'), o('a')]])).toEqual([o('a'), o('c')]) + }) + + test('no runs means no tables', () => { + expect(sharedOptions([])).toEqual([]) + }) +}) diff --git a/frontend/src/components/annotationShared.ts b/frontend/src/components/annotationShared.ts index d1050796..336ede88 100644 --- a/frontend/src/components/annotationShared.ts +++ b/frontend/src/components/annotationShared.ts @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react' import type { ComparisonRunSpec, ConfigSource, TableMeta } from '../api/types' import { uniqueRuns } from '../api/types' import { api } from '../api/client' +import { errorMessage } from '../api/errorMessage' export const SOURCE_COLORS: Record = { default: 'var(--color-muted-fg)', @@ -23,39 +24,57 @@ export interface AnalysisOption { /** * Discover the analysable result tables for a single run (merged, merged_otu, * and any saved sub-tables) from the run's results database. Analysis reads the - * merged results table directly. + * merged results table directly. Rejects when the tables cannot be listed. */ export async function discoverResultsTables( study: string, run: string, group?: string | null, ): Promise { - const tables = await api.results.runTables(study, run, group).catch(() => [] as TableMeta[]) + const tables: TableMeta[] = await api.results.runTables(study, run, group) return tables .map(meta => ({ key: meta.id, table: meta.id, label: meta.label })) .sort((a, b) => a.label.localeCompare(b.label)) } -/** Result tables present in every run of `runs`, sorted by label. */ -export function useSharedResultsTables(study: string, runs: ComparisonRunSpec[]): AnalysisOption[] { - const [options, setOptions] = useState([]) +/** + * The options present in every list of `results` (keyed by `key`), sorted by + * label. No lists means no options. + */ +export function sharedOptions(results: AnalysisOption[][]): AnalysisOption[] { + const shared = new Map() + results.forEach((opts, index) => { + const keys = new Set(opts.map(o => o.key)) + if (index === 0) { + for (const o of opts) shared.set(o.key, o) + } else { + for (const key of [...shared.keys()]) { + if (!keys.has(key)) shared.delete(key) + } + } + }) + return [...shared.values()].sort((a, b) => a.label.localeCompare(b.label)) +} + +/** + * Result tables present in every run of `runs`, sorted by label, with the + * message of a failed lookup in `error` (and no options) so that a failure is + * not read as "no table shared". + */ +export function useSharedResultsTablesState( + study: string, runs: ComparisonRunSpec[], +): { options: AnalysisOption[]; error: string | null } { + const [state, setState] = useState<{ options: AnalysisOption[]; error: string | null }>( + { options: [], error: null }) useEffect(() => { let cancelled = false Promise.all(uniqueRuns(runs).map(r => discoverResultsTables(study, r.run, r.group))) - .then(results => { - if (cancelled) return - const shared = new Map() - results.forEach((opts, index) => { - const keys = new Set(opts.map(o => o.key)) - if (index === 0) { - for (const o of opts) shared.set(o.key, o) - } else { - for (const key of [...shared.keys()]) { - if (!keys.has(key)) shared.delete(key) - } - } - }) - setOptions([...shared.values()].sort((a, b) => a.label.localeCompare(b.label))) - }) + .then(results => { if (!cancelled) setState({ options: sharedOptions(results), error: null }) }) + .catch(err => { if (!cancelled) setState({ options: [], error: errorMessage(err) }) }) return () => { cancelled = true } }, [study, runs]) - return options + return state +} + +/** Result tables present in every run of `runs`, sorted by label; none when the lookup fails. */ +export function useSharedResultsTables(study: string, runs: ComparisonRunSpec[]): AnalysisOption[] { + return useSharedResultsTablesState(study, runs).options } diff --git a/frontend/src/components/differentialTable.test.ts b/frontend/src/components/differentialTable.test.ts index cdf06fc2..f2143e07 100644 --- a/frontend/src/components/differentialTable.test.ts +++ b/frontend/src/components/differentialTable.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) import { describe, expect, test } from 'bun:test' import type { DifferentialResult } from '../api/types' -import { csvField, differentialCsv, formatStat } from './differentialTable' +import { configLine, csvField, differentialCsv, effectValue, formatStat } from './differentialTable' const result: DifferentialResult = { status: 'partial', @@ -11,8 +11,9 @@ const result: DifferentialResult = { table: 'merged', groups: { reference: 'run_A', contrast: 'run_B' }, n_samples: { run_A: 2, run_B: 2 }, + effect: { key: 'log2_fold_change', label: 'log2 fold change' }, size_factors: [], - config: { offset: 'tss', min_prevalence: 0 }, + config: { method: 'nb_glm', offset: 'tss', min_prevalence: 0 }, diagnostics: { n_taxa: 2, n_tested: 1, n_failed: 1, n_boundary: 0, n_filtered: 0 }, rows: [ { taxon: 'Bacteroides', status: 'ok', note: '', estimate: 1.3862943611198906, @@ -52,3 +53,24 @@ describe('formatStat', () => { expect(formatStat(2.9e-8)).toBe('2.90e-8') }) }) + +describe('clr_welch results', () => { + const clr: DifferentialResult = { + ...result, + method: 'CLR Welch', + effect: { key: 'estimate', label: 'CLR difference' }, + config: { method: 'clr_welch', min_prevalence: 0, replacement_delta: 0.65 }, + rows: [{ ...result.rows[0], estimate: -0.75, log2_fold_change: null, dispersion_theta: null }], + } + + test('the effect comes from the field the result names, not log2_fold_change', () => { + expect(effectValue(clr, clr.rows[0])).toBe(-0.75) + expect(effectValue(result, result.rows[0])).toBe(2) + }) + + test('the settings line names the delta and says the estimate is not a fold change', () => { + expect(configLine(clr)).toBe('method clr_welch, replacement_delta 0.65, min_prevalence 0; estimate is a CLR difference, not a fold change') + expect(configLine(result)).toBe('method nb_glm, offset tss, min_prevalence 0') + expect(differentialCsv(clr).split('\r\n')[2]).toBe(`# ${configLine(clr)}`) + }) +}) diff --git a/frontend/src/components/differentialTable.ts b/frontend/src/components/differentialTable.ts index cba1d09f..90a547a8 100644 --- a/frontend/src/components/differentialTable.ts +++ b/frontend/src/components/differentialTable.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only // SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -import type { DifferentialResult } from '../api/types' +import type { DifferentialResult, DifferentialRow } from '../api/types' const COLUMNS = [ 'taxon', 'status', 'log2_fold_change', 'estimate', 'standard_error', 'statistic', @@ -25,7 +25,7 @@ export function differentialCsv(result: DifferentialResult): string { const header = [ `# Differential abundance: ${contrast} vs ${reference} (reference), rank ${result.rank}, table ${result.table}`, `# ${result.method}`, - `# offset ${result.config.offset}, min_prevalence ${result.config.min_prevalence}`, + `# ${configLine(result)}`, ].map(l => l.replace(/[\r\n]+/g, ' ')) const rows = result.rows.map(r => COLUMNS.map(c => csvField(r[c])).join(',')) return [...header, COLUMNS.join(','), ...rows].join('\r\n') + '\r\n' @@ -38,3 +38,20 @@ export function formatStat(value: number | null): string { const a = Math.abs(value) return a < 1e-3 || a >= 1e4 ? value.toExponential(2) : value.toPrecision(3) } + +/** + * The settings line of the CSV: the method and the parameters it used. The + * offset belongs to nb_glm and the replacement delta to clr_welch, so each line + * names only its own method's parameters. + */ +export function configLine(result: DifferentialResult): string { + const c = result.config + return c.method === 'clr_welch' + ? `method clr_welch, replacement_delta ${c.replacement_delta}, min_prevalence ${c.min_prevalence}; estimate is a CLR difference, not a fold change` + : `method nb_glm, offset ${c.offset}, min_prevalence ${c.min_prevalence}` +} + +/** The effect size of one row, from the field the result names in `effect`. */ +export function effectValue(result: DifferentialResult, row: DifferentialRow): number | null { + return row[result.effect.key] +} diff --git a/frontend/src/components/sharedRanks.test.ts b/frontend/src/components/sharedRanks.test.ts new file mode 100644 index 00000000..21b51b3e --- /dev/null +++ b/frontend/src/components/sharedRanks.test.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +import { describe, expect, test } from 'bun:test' +import type { ComparisonRunSpec } from '../api/types' +import { sharedRanks } from './sharedRanks' + +const runs: ComparisonRunSpec[] = [ + { run: 'A', group: null, prefix: 'x' }, + { run: 'A', group: null, prefix: 'y' }, + { run: 'B', group: 'g', prefix: null }, +] +const RANKS: Record = { + A: ['Kingdom', 'Phylum', 'Class', 'Genus'], + B: ['Kingdom', 'Phylum', 'Genus', 'Species'], +} + +describe('sharedRanks', () => { + test('intersects the ranks of each distinct run, in the first run\'s order', async () => { + const asked: string[] = [] + const ranks = await sharedRanks(runs, async r => { asked.push(r.run); return RANKS[r.run] }) + expect(ranks).toEqual(['Kingdom', 'Phylum', 'Genus']) + expect(asked).toEqual(['A', 'B']) + }) + + test('no shared rank is an empty list', async () => { + expect(await sharedRanks(runs, async r => r.run === 'A' ? ['Genus'] : ['Species'])).toEqual([]) + }) + + test('a failed request rejects instead of reading as no shared rank', async () => { + const failing = sharedRanks(runs, async r => { + if (r.run === 'B') throw new Error('HTTP 500: R error') + return RANKS[r.run] + }) + await expect(failing).rejects.toThrow('HTTP 500: R error') + }) +}) diff --git a/frontend/src/components/sharedRanks.ts b/frontend/src/components/sharedRanks.ts new file mode 100644 index 00000000..1d450f2f --- /dev/null +++ b/frontend/src/components/sharedRanks.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +import type { ComparisonRunSpec } from '../api/types' +import { uniqueRuns } from '../api/types' + +/** + * The taxonomy ranks offered by every run in `runs`, in the first run's order. + * Rejects when any run's ranks cannot be read: a failed request must not read + * as "no rank shared by the selected runs". + */ +export async function sharedRanks( + runs: ComparisonRunSpec[], + ranksOf: (run: ComparisonRunSpec) => Promise, +): Promise { + const results = await Promise.all(uniqueRuns(runs).map(ranksOf)) + return results.reduce((acc, cur) => acc.filter(r => cur.includes(r)), results[0] ?? []) +} diff --git a/frontend/src/hooks/useApi.test.ts b/frontend/src/hooks/useApi.test.ts new file mode 100644 index 00000000..bb30924a --- /dev/null +++ b/frontend/src/hooks/useApi.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from 'bun:test' +import { failedState, type State } from './useApi' + +const shown: State = { data: ['row from the old filters'], loading: true, error: null } + +describe('useApi failed request', () => { + test('drops data fetched for other inputs', () => { + expect(failedState(shown, 'HTTP 500', false)).toEqual({ data: null, loading: false, error: 'HTTP 500' }) + }) + + test('keeps data when the same request is re-run and fails', () => { + expect(failedState(shown, 'HTTP 500', true)).toEqual({ + data: ['row from the old filters'], loading: false, error: 'HTTP 500', + }) + }) +}) diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 18e14975..9b3b03e7 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1,14 +1,25 @@ import { useState, useEffect, useCallback, useRef } from 'react' -interface State { +export interface State { data: T | null loading: boolean error: string | null } +/** + * The state after a request fails. The previous data survives only when + * `sameRequest` says it came from this very request (a refetch of unchanged + * inputs); data fetched for other inputs, such as other filters, is dropped so + * it cannot be shown as if it answered the new request. + */ +export function failedState(prev: State, error: string, sameRequest: boolean): State { + return { data: sameRequest ? prev.data : null, loading: false, error } +} + /** * Minimal data-fetching hook. Re-fetches when `fetcher` reference changes. - * Data is kept across refetches unless `resetKey` changes. + * Data is kept across refetches unless `resetKey` changes, and is cleared when + * a request for different inputs (a new `fetcher`) fails. * Returns `{ data, loading, error, refetch }`. */ export function useApi(fetcher: () => Promise, resetKey?: unknown): State & { refetch: () => void } { @@ -17,16 +28,28 @@ export function useApi(fetcher: () => Promise, resetKey?: unknown): State< const refetch = useCallback(() => setTick(t => t + 1), []) const lastKey = useRef(resetKey) + // The fetcher whose response is in `state.data`, or null when there is none. + const dataFrom = useRef<(() => Promise) | null>(null) useEffect(() => { let cancelled = false // A new resetKey names a different resource, so its old data is cleared while loading. const changed = lastKey.current !== resetKey lastKey.current = resetKey + if (changed) dataFrom.current = null setState(s => ({ data: changed ? null : s.data, loading: true, error: null })) fetcher() - .then(data => { if (!cancelled) setState({ data, loading: false, error: null }) }) - .catch(err => { if (!cancelled) setState(s => ({ ...s, loading: false, error: String(err.message ?? err) })) }) + .then(data => { + if (cancelled) return + dataFrom.current = fetcher + setState({ data, loading: false, error: null }) + }) + .catch(err => { + if (cancelled) return + const same = dataFrom.current === fetcher + if (!same) dataFrom.current = null + setState(s => failedState(s, String(err.message ?? err), same)) + }) return () => { cancelled = true } }, [tick, fetcher, resetKey]) diff --git a/frontend/src/layout/Layout.tsx b/frontend/src/layout/Layout.tsx index fb1fe4ba..45f11ea6 100644 --- a/frontend/src/layout/Layout.tsx +++ b/frontend/src/layout/Layout.tsx @@ -69,54 +69,54 @@ export function Layout() { Studies {studies?.map((s: StudySummary) => ( - `sidebar-link sidebar-link-indented ${isActive ? 'active' : ''}`} - > - {s.name} - {s.active_job_count > 0 && ({s.active_job_count})} - - ))} - - {study && studyDetail && ( - <> - {studyDetail.groups && studyDetail.groups.length > 0 && ( -
Groups
- )} - {studyDetail.groups?.map((g: string) => ( - - `sidebar-link sidebar-link-indented2 ${isActive ? 'active' : ''}`} - > - {g} - - {activeGroup === g && groupRuns?.map(r => ( + + `sidebar-link sidebar-link-indented ${isActive ? 'active' : ''}`} + > + {s.name} + {s.active_job_count > 0 && ({s.active_job_count})} + + {s.name === study && studyDetail && ( + <> + {studyDetail.groups && studyDetail.groups.length > 0 && ( +
Groups
+ )} + {studyDetail.groups?.map((g: string) => ( + + `sidebar-link sidebar-link-indented2 ${isActive ? 'active' : ''}`} + > + {g} + + {activeGroup === g && groupRuns?.map(r => ( + `sidebar-link sidebar-link-indented3 ${isActive ? 'active' : ''}`} + > + {r.name} + + ))} + + ))} + {studyDetail.runs && studyDetail.runs.length > 0 && ( +
Runs
+ )} + {studyDetail.runs?.map((r: string) => ( `sidebar-link sidebar-link-indented3 ${isActive ? 'active' : ''}`} + key={r} + to={`/${study}/${r}`} + className={({ isActive }) => `sidebar-link sidebar-link-indented2 ${isActive ? 'active' : ''}`} > - {r.name} + {r} ))} -
- ))} - {studyDetail.runs && studyDetail.runs.length > 0 && ( -
Runs
+ )} - {studyDetail.runs?.map((r: string) => ( - `sidebar-link sidebar-link-indented2 ${isActive ? 'active' : ''}`} - > - {r} - - ))} - - )} +
+ ))}
@@ -131,8 +131,11 @@ export function Layout() { `sidebar-link ${isActive ? 'active' : ''}`}>Reference trees `sidebar-link ${isActive ? 'active' : ''}`}>About
- {COPYRIGHT} · AGPL-3.0 ·{' '} - Source and Documentation +
{COPYRIGHT}
+
diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index d74dc233..03f58daa 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -93,6 +93,7 @@ a:hover { text-decoration: underline; } opacity: .6; } .sidebar-legal a { color: inherit; text-decoration: underline; } +.sidebar-legal-links { white-space: nowrap; margin-top: 2px; } .sidebar-sub-label { font-size: .6rem; @@ -294,6 +295,18 @@ a:hover { text-decoration: underline; } padding: 20px 0; } +.computing-note { display: flex; align-items: center; gap: 8px; padding: 8px 0; } +.computing-spinner { + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: computing-spin .8s linear infinite; +} +@keyframes computing-spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .computing-spinner { animation: none; } } + .error-msg { color: #c92a2a; font-size: .9rem; diff --git a/frontend/src/views/TreeView.tsx b/frontend/src/views/TreeView.tsx index c0778282..9d7d3aa2 100644 --- a/frontend/src/views/TreeView.tsx +++ b/frontend/src/views/TreeView.tsx @@ -72,7 +72,12 @@ function TreeLoader({ source, file }: { source: TreeSource; file: string }) { const { data, loading, error } = useApi(fetcher) if (loading) return - if (error || !data) return

{error ?? 'Tree not found'}

+ if (error || !data) return ( + <> +
+

{error ?? 'Tree not found'}

+ + ) return } diff --git a/install.jl b/install.jl index 7e835e62..3be35d24 100644 --- a/install.jl +++ b/install.jl @@ -855,6 +855,17 @@ function r_sysdep_hint(missing::Vector{NTuple{5,String}})::String join([pre; unique(row[col] for row in missing)], " ") end +# Run R code from the project root and return only what it printed between two +# markers. Rscript started there sources .Rprofile, and renv's autoloader prints +# its status (e.g. "The project is out-of-sync") to stdout, which a bare read of +# stdout would take for the answer. Returns nothing if no marked value came back. +function r_value(code::AbstractString)::Union{String,Nothing} + wrapped = "cat('<>'); local({ $code }); cat('<>')" + out = read(Cmd(`Rscript -e $wrapped`; dir = PROJECT_ROOT), String) + m = match(r"<>(.*?)<>"s, out) + m === nothing ? nothing : String(strip(m.captures[1])) +end + # Reproduce renv/library from renv.lock. Returns (:ok, "") or (:incomplete, "pkg,pkg"). function setup_r_packages(; rebuild::Bool=false)::Tuple{Symbol,String} restore_call = rebuild ? "renv::restore(prompt = FALSE, rebuild = TRUE)" : @@ -868,16 +879,14 @@ function setup_r_packages(; rebuild::Bool=false)::Tuple{Symbol,String} # Verdict is whether the packages the pipeline loads are actually usable, not # renv's exit code: restore can fail on one leaf package and still leave a # working library, or "succeed" against a stale cache. - probe = raw""" - pkgs <- c("dada2", "vegan", "dplyr", "tibble") + # MASS, zCompositions and glmGamPoi back the differential abundance methods. + pkgs = ["dada2", "vegan", "MASS", "zCompositions", "glmGamPoi"] + probe = """ + pkgs <- c($(join(repr.(pkgs), ", "))) cat(paste(pkgs[!vapply(pkgs, requireNamespace, logical(1), quietly = TRUE)], collapse = ",")) """ - bad = try - strip(read(Cmd(`Rscript -e $probe`; dir = PROJECT_ROOT), String)) - catch - "dada2,vegan,dplyr,tibble" - end + bad = something(try r_value(probe) catch; nothing end, join(pkgs, ",")) isempty(bad) ? (:ok, "") : (:incomplete, String(bad)) end @@ -1193,7 +1202,7 @@ function main() record!("R packages", STEP_SKIPPED, "blocked on the R runtime above") else rver = try - strip(read(`Rscript -e "cat(as.character(getRversion()))"`, String)) + something(r_value("cat(as.character(getRversion()))"), "unknown") catch "unknown" end diff --git a/proofs/agda/MetaManifold/All.agda b/proofs/agda/MetaManifold/All.agda new file mode 100644 index 00000000..ff5c18c0 --- /dev/null +++ b/proofs/agda/MetaManifold/All.agda @@ -0,0 +1,15 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- The root of the proof tree. Typechecking this file typechecks every proof +-- module; the axiom audit (proofs/tests/axiom-audit.sh) refuses any module +-- under proofs/agda/ that this file does not reach. + +{-# OPTIONS --safe --without-K #-} + +module MetaManifold.All where + +import MetaManifold.Prelude +import MetaManifold.BenjaminiHochberg +import MetaManifold.Scaling +import MetaManifold.Permanova diff --git a/proofs/agda/MetaManifold/BenjaminiHochberg.agda b/proofs/agda/MetaManifold/BenjaminiHochberg.agda new file mode 100644 index 00000000..9a0b8371 --- /dev/null +++ b/proofs/agda/MetaManifold/BenjaminiHochberg.agda @@ -0,0 +1,582 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- The Benjamini–Hochberg step-up adjustment, over exact rationals, with the +-- properties a reader of a q-value table relies on proved rather than tested. +-- +-- `bhAdjust` below models `bh_adjust` in `src/analysis/differential.jl` +-- (branch feat/differential-abundance-nb, commit 6c3e8b2): +-- +-- Julia here +-- ───────────────────────────────────────── ────────────────────────────── +-- n = length(p) m = length ps +-- order = sortperm(p; rev=true) order = sort (indexFrom 0 ps), +-- ascending, lexicographic on +-- (p, index) +-- running = 1.0 running m j: the running +-- for (k, idx) in enumerate(order) minimum, listed in ascending +-- rank = n - k + 1 rank order; `minInf` starts +-- running = min(running, n/rank * p[idx]) it from the largest rank +-- out[idx] = min(running, 1.0) stepUp = map (_⊓ 1ℚ) ∘ running +-- restore: output position idx +-- reads the value whose key +-- has index idx +-- +-- Two differences. The first is proved harmless; the second follows from a +-- proved lemma by a short argument about the Julia code, which is not itself +-- formalised: +-- +-- * Julia seeds `running` with 1.0, so its running value at rank i is +-- min(1, min_{j ≥ i} n/j · p_(j)) and the final clamp is idempotent. +-- The model seeds with the first scaled value and clamps afterwards. +-- `stepUp-envelope` proves the model's values equal that same envelope, +-- so the two compute the same list. +-- * Tie-breaking. Julia walks a stable DESCENDING sort, so of two equal +-- p-values the earlier input index gets the HIGHER rank; the model's +-- ascending (p, index) sort gives it the lower rank. The sorted list of +-- p-values is the same either way, and tied p-values receive equal +-- adjusted values whatever their order (`bh-monotone` applied in both +-- directions), so which tied index reads which tied slot cannot change +-- the output. +-- +-- Julia's ranks are one-based; here they are zero-based (`j = rank - 1`), +-- so Julia's `n / rank` is `rankFactor m j = m / (j + 1)` from the Prelude. +-- +-- Julia rejects any p-value outside [0, 1] (and any non-finite value) with an +-- ArgumentError before it computes anything. Over ℚ there is nothing +-- non-finite, so that guard becomes the hypothesis `All Valid ps` on every +-- theorem: the theorems speak about exactly the inputs Julia accepts. +-- +-- Theorems (names as in proofs/agda/README.md): +-- +-- bh-length the output has one entry per input +-- bh-nonneg R-BH-1 every adjusted value is ≥ 0 +-- bh-dominates-p R-BH-2a adjusted_i ≥ p_i +-- bh-at-most-one R-BH-2b adjusted_i ≤ 1 +-- bh-monotone R-BH-2c p_i ≤ p_j ⇒ adjusted_i ≤ adjusted_j +-- (ties included, any tie-break) +-- bh-envelope R-BH-2d the adjusted value at sorted rank k is +-- min(1, min_{j ≥ k} p_(j) · m/(j+1)) +-- bh-output-is-envelope every (p, adjusted) pair of the output is a +-- (p_(k), envelope_k) pair of the sorted stage +-- +-- The archived residue file recorded "q_i ≥ p_i is FALSE in general". That is +-- true of the bare scaling `p · M/(j+1)` for an arbitrary M (take M < j + 1), +-- but not of the procedure: the rank of a p-value never exceeds the family +-- size, so every factor m/(j+1) is ≥ 1, and R-BH-2a holds. It is proved here. + +{-# OPTIONS --safe --without-K #-} + +module MetaManifold.BenjaminiHochberg where + +open import MetaManifold.Prelude + +open import Data.Integer.Base as ℤ using (ℤ; +_; +[1+_]) +import Data.Integer.Properties as ℤₚ +open import Data.Nat.Base as ℕ using (ℕ; zero; suc; z≤n; s≤s) +import Data.Nat.Properties as ℕₚ +open import Data.List.Base using (List; []; _∷_; map; zip; length; foldr) +import Data.List.Properties as Listₚ +open import Data.List.Relation.Unary.All as All using (All; []; _∷_) +import Data.List.Relation.Unary.All.Properties as Allₚ +open import Data.List.Relation.Unary.Any as Any using (Any; here; there) +open import Data.List.Relation.Unary.Linked as Linked using (Linked; []; [-]; _∷_) +import Data.List.Relation.Unary.Linked.Properties as Linkedₚ +open import Data.List.Relation.Unary.AllPairs as AllPairs using (AllPairs; []; _∷_) +open import Data.List.Membership.Propositional using (_∈_) +import Data.List.Membership.Propositional.Properties as ∈ₚ +open import Data.List.Relation.Binary.Permutation.Propositional using (_↭_; ↭-sym) +import Data.List.Relation.Binary.Permutation.Propositional.Properties as ↭ₚ +open import Data.Product.Base using (_×_; _,_; proj₁; proj₂; ∃; ∃-syntax) +import Data.Product.Relation.Binary.Lex.NonStrict as Lex +open import Data.Sum.Base using (_⊎_; inj₁; inj₂) +open import Data.Empty using (⊥-elim) +open import Data.Rational.Base as ℚ + using (ℚ; 0ℚ; 1ℚ; _*_; _⊓_; _≤_; toℚᵘ; fromℚᵘ) +import Data.Rational.Properties as ℚₚ +open import Data.Rational.Unnormalised.Base as ℚᵘ + using (ℚᵘ; mkℚᵘ; ↥_; ↧ₙ_; *≡*) + renaming (_≤_ to _≤ℚ_) +import Data.Rational.Unnormalised.Properties as ℚᵘₚ +open import Relation.Binary.Bundles using (DecTotalOrder) +open import Relation.Binary.PropositionalEquality + using (_≡_; refl; sym; trans; cong; cong₂; subst) +open import Relation.Nullary using (yes; no) + +------------------------------------------------------------------------ +-- Part 1. The scaling step over ℚᵘ (ported from the archive) + +-- `M/(j+1) · n/(d+1)`, written with the denominator expanded so that the +-- fraction is built by a single `mkℚᵘ` and carries no intermediate rounding. +-- `j` is a zero-based rank; `j + d + j * d` is `(j+1)(d+1) - 1`, the +-- denominator-minus-one that `mkℚᵘ` expects. +bhScale : ℕ → ℕ → ℤ → ℕ → ℚᵘ +bhScale M j n d = mkℚᵘ (+ M ℤ.* n) (j ℕ.+ d ℕ.+ j ℕ.* d) + +-- The numerator of the scaled value is `M·n`. +bhScale-numerator : ∀ M j n d → ↥ (bhScale M j n d) ≡ + M ℤ.* n +bhScale-numerator M j n d = refl + +-- The denominator of the scaled value is `(j+1)(d+1)`. +bhScale-denominator : + ∀ M j n d → ↧ₙ (bhScale M j n d) ≡ suc (j ℕ.+ d ℕ.+ j ℕ.* d) +bhScale-denominator M j n d = refl + +-- A bigger family can never produce a smaller scaled value. +monotone-in-family-size : + ∀ M M′ j n d → M ℕ.≤ M′ → .{{_ : ℤ.NonNegative n}} → + bhScale M j n d ≤ℚ bhScale M′ j n d +monotone-in-family-size M M′ j n d M≤M′ {{np}} = + numerator-monotone-≤ (+ M ℤ.* n) (+ M′ ℤ.* n) (j ℕ.+ d ℕ.+ j ℕ.* d) + (ℤₚ.≤-trans (ℤₚ.≤-reflexive (ℤₚ.*-comm (+ M) n)) + (ℤₚ.≤-trans (ℤₚ.*-monoˡ-≤-nonNeg n {{np}} (ℤ.+≤+ M≤M′)) + (ℤₚ.≤-reflexive (ℤₚ.*-comm n (+ M′))))) + +------------------------------------------------------------------------ +-- Part 2. The procedure over ℚ + +-- A p-value Julia accepts: a number in [0, 1]. +Valid : ℚ → Set +Valid p = 0ℚ ≤ p × p ≤ 1ℚ + +-- Julia's `n / rank * p[idx]` at zero-based rank j = rank - 1. +scaled : ℕ → ℕ → ℚ → ℚ +scaled m j p = p * rankFactor m j + +-- Bridge to Part 1: the ℚ scaling is, after forgetting normalisation, exactly +-- the archived ℚᵘ `bhScale` applied to the p-value's numerator and +-- denominator-minus-one. So Part 1's lemmas are lemmas about `scaled`. +scaled≃bhScale : ∀ m j p → + toℚᵘ (scaled m j p) ℚᵘ.≃ bhScale m j (ℚ.numerator p) (ℚ.denominator-1 p) +scaled≃bhScale m j p@record{} = ℚᵘₚ.≃-trans (ℚₚ.toℚᵘ-homo-* p (rankFactor m j)) + (ℚᵘₚ.≃-trans (ℚᵘₚ.*-cong (ℚᵘₚ.≃-refl {toℚᵘ p}) (ℚₚ.toℚᵘ-fromℚᵘ (mkℚᵘ (+ m) j))) + (*≡* (cong₂ ℤ._*_ (ℤₚ.*-comm (ℚ.numerator p) (+ m)) + (cong +[1+_] (sym denom))))) + where + d = ℚ.denominator-1 p + -- (d+1)(j+1) - 1 and (j+1)(d+1) - 1 agree. + denom : j ℕ.+ d ℕ.* suc j ≡ j ℕ.+ d ℕ.+ j ℕ.* d + denom = trans (cong (j ℕ.+_) (ℕₚ.*-suc d j)) + (trans (sym (ℕₚ.+-assoc j d (d ℕ.* j))) + (cong ((j ℕ.+ d) ℕ.+_) (ℕₚ.*-comm d j))) + +-- The running minimum seeded with the first value folded in (the largest +-- rank). Julia seeds with 1.0 instead; `stepUp-envelope` shows the two agree +-- once clamped. +minInf : ℚ → List ℚ → ℚ +minInf x [] = x +minInf x (r ∷ _) = r ⊓ x + +-- The running minima of Julia's loop over ranks n, n-1, ..., listed in rank order, for a +-- sorted list whose first element sits at zero-based rank j. +running : ℕ → ℕ → List ℚ → List ℚ +running m j [] = [] +running m j (p ∷ ps) = + minInf (scaled m j p) (running m (suc j) ps) ∷ running m (suc j) ps + +-- Julia's `min(running, 1.0)`. +stepUp : ℕ → List ℚ → List ℚ +stepUp m ps = map (_⊓ 1ℚ) (running m 0 ps) + +-- Pair each value with its (zero-based) position: Julia's index `idx`. +indexFrom : ℕ → List ℚ → List (ℚ × ℕ) +indexFrom k [] = [] +indexFrom k (p ∷ ps) = (p , k) ∷ indexFrom (suc k) ps + +-- Ascending order on (p, index). Julia sorts descending (stably); the +-- tie order differs and is harmless (see the header). +keyOrder : DecTotalOrder _ _ _ +keyOrder = Lex.×-decTotalOrder ℚₚ.≤-decTotalOrder ℕₚ.≤-decTotalOrder + +open import Data.List.Sort.MergeSort keyOrder using (sort; sort-↭; sort-↗) + +-- `out[idx] = min(running, 1.0)` read from the other side: the value at output +-- position i is the adjusted value of the first sorted +-- entry whose index is i. The `0ℚ` default is never reached (`restore-spec`), +-- as Julia's `undef` slots are never left unwritten. +restore : List ((ℚ × ℕ) × ℚ) → ℕ → ℚ +restore [] i = 0ℚ +restore (((p , k) , a) ∷ ts) i with k ℕₚ.≟ i +... | yes _ = a +... | no _ = restore ts i + +-- The intermediate values of `bh_adjust`, named as in Julia. +module Pipeline (ps : List ℚ) where + m : ℕ + m = length ps + + keyed : List (ℚ × ℕ) + keyed = indexFrom 0 ps + + order : List (ℚ × ℕ) + order = sort keyed + + sortedP : List ℚ + sortedP = map proj₁ order + + adjusted : List ℚ + adjusted = stepUp m sortedP + + triples : List ((ℚ × ℕ) × ℚ) + triples = zip order adjusted + +-- The Benjamini–Hochberg adjusted p-values, in input order. +bhAdjust : List ℚ → List ℚ +bhAdjust ps = map (λ k → restore triples (proj₂ k)) keyed + where open Pipeline ps + +------------------------------------------------------------------------ +-- Part 3. The sorted stage: the envelope form (R-BH-2d) + +-- The scaled values p_(j) · m/(j+1) of a sorted list starting at rank j. +scaledFrom : ℕ → ℕ → List ℚ → List ℚ +scaledFrom m j [] = [] +scaledFrom m j (p ∷ ps) = scaled m j p ∷ scaledFrom m (suc j) ps + +-- For each suffix, min(1, min of the suffix). +envelopes : List ℚ → List ℚ +envelopes [] = [] +envelopes (x ∷ xs) = foldr _⊓_ 1ℚ (x ∷ xs) ∷ envelopes xs + +-- The head of the clamped running minimum is the clamped suffix minimum. +private + head-eq : ∀ m j p ps → + minInf (scaled m j p) (running m (suc j) ps) ⊓ 1ℚ + ≡ scaled m j p ⊓ foldr _⊓_ 1ℚ (scaledFrom m (suc j) ps) + head-eq m j p [] = refl + head-eq m j p (q ∷ qs) = + trans (cong (_⊓ 1ℚ) (ℚₚ.⊓-comm r s)) + (trans (ℚₚ.⊓-assoc s r 1ℚ) + (cong (s ⊓_) (head-eq m (suc j) q qs))) + where + s = scaled m j p + r = minInf (scaled m (suc j) q) (running m (suc (suc j)) qs) + +-- R-BH-2d, sorted-stage form: the Julia loop computes the envelope. +stepUp-envelope : ∀ m j ps → + map (_⊓ 1ℚ) (running m j ps) ≡ envelopes (scaledFrom m j ps) +stepUp-envelope m j [] = refl +stepUp-envelope m j (p ∷ ps) = + cong₂ _∷_ (head-eq m j p ps) (stepUp-envelope m (suc j) ps) + +-- R-BH-2d for the pipeline: the adjusted values at sorted rank k are +-- min(1, min_{j ≥ k} p_(j) · m/(j+1)). +bh-envelope : ∀ ps → + Pipeline.adjusted ps + ≡ envelopes (scaledFrom (length ps) 0 (Pipeline.sortedP ps)) +bh-envelope ps = stepUp-envelope (length ps) 0 (Pipeline.sortedP ps) + +------------------------------------------------------------------------ +-- Part 4. Facts about the scaling and the fold + +private + 0≤1 : 0ℚ ≤ 1ℚ + 0≤1 = ℚₚ.toℚᵘ-cancel-≤ (ℚᵘ.*≤* (ℤ.+≤+ z≤n)) + + -- Scaling inside the family never lowers a non-negative p-value. + scaled-≥ : ∀ m j p → suc j ℕ.≤ m → 0ℚ ≤ p → p ≤ scaled m j p + scaled-≥ m j p j +-- +-- PERMANOVA's permutation bookkeeping: the two things `run_permanova` in +-- `src/analysis/analysis.jl` decides for itself rather than taking from vegan +-- (branch fix/permanova-terms, PR #35), proved. +-- +-- 1. `min_p_value = 1 / (n_perm + 1)`. vegan's permutation p-value is +-- (k + 1)/(B + 1), where B is the number of permutations performed and k +-- how many of them gave a statistic at least the observed one (0 ≤ k ≤ B). +-- The theorems say that 1/(B + 1) is the smallest p-value B permutations +-- can produce, that it is attained, and that no threshold below it can be +-- reached. +-- 2. Which terms have their p-value withheld. With permutations restricted +-- to blocks, Julia withholds the p-value of a term when +-- `_varies_within_any_block(values, blocks)` is false. The theorems say +-- that this happens exactly when every within-block permutation leaves the +-- term's labels where they were, so the permutations never exchange them. +-- They also say that when it is true, some within-block permutation does +-- exchange them. This characterises the block-constancy filter only; +-- it does not establish statistical testability or a reported p-value. +-- +-- What is NOT modelled, and why: +-- +-- * The pseudo-F statistic and vegan's distribution of it. The p-value +-- theorems take k as given; they are about the arithmetic of (k + 1)/(B + 1), +-- not about which permutations exceed the observed F. +-- * How many within-block permutations exist. B is whatever `permute` +-- reports (`attr(perm_res, "control")$nperm`). Six blocks of two samples +-- admit 2⁶ = 64 arrangements, the identity and 63 others, which is the 63 in +-- test_permanova.jl. That count is the product of the blocks' factorials; +-- it is not proved here. +-- * Unblocked runs. Julia only asks `_varies_within_any_block` when some +-- block label repeats (`blocked`). Without repeated blocks the +-- permutations are unrestricted and nothing is withheld; that branch is +-- Julia's `if`, not a theorem. +-- * Julia's `isequal` on NaN or `missing`. Equality of labels and of blocks +-- is an arbitrary decidable equality here. +-- * Floating point. `1 / (n_perm + 1)` is exact here and a Float64 in Julia. +-- +-- Theorems (names as in proofs/agda/README.md): +-- +-- minP-attained (0 + 1)/(B + 1) is 1/(B + 1) +-- p-floor every permutation p-value is at least 1/(B + 1) +-- p-at-most-one ... and at most 1 when k ≤ B +-- below-floor-unreachable a threshold below 1/(B + 1) is below every p-value +-- minP-anti more permutations never raise the floor +-- minP-63 with 63 permutations the floor is 1/64 +-- no-p-at-1%-with-63 with 63 permutations no p-value is at or below 0.01 +-- never-exchanged a block-constant term is fixed by every +-- within-block permutation +-- exchanged a term with two labels in one block is moved by a +-- within-block permutation (a transposition) +-- untestable⇔fixed block-constant ⇔ fixed by every within-block +-- permutation +-- varies-false⇔Constant the model of `_varies_within_any_block` returns +-- false exactly on block-constant input +-- withheld⇔never-exchanged ... so its false answer, which withholds the +-- p-value, holds exactly when no within-block +-- permutation exchanges the term's labels +-- tested⇔exchanged ... and its true answer, which passes this filter, +-- holds exactly when some within-block permutation +-- does +-- constant-in-blocks, varies-in-a-block +-- the two direct cases in test_permanova.jl +-- fixture-group-withheld, fixture-run-tested +-- the twelve-sample fixture in test_permanova.jl: group +-- is never exchanged, run is + +{-# OPTIONS --safe --without-K #-} + +module MetaManifold.Permanova where + +open import Data.Bool.Base using (Bool; true; false) +open import Data.Empty using (⊥-elim) +open import Data.Fin.Base using (Fin) +open import Data.Fin.Properties using (_≟_) +open import Data.Fin.Permutation using (Permutation′; _⟨$⟩ʳ_; transpose) +open import Data.Integer.Base as ℤ using (+_; +[1+_]) +import Data.Integer.Properties as ℤₚ +open import Data.List.Base using (List; []; _∷_; _++_; [_]; length; lookup) +open import Data.List.Membership.Propositional using (_∈_) +open import Data.List.Membership.Propositional.Properties + using (∈-lookup; ∈-++⁺ˡ; ∈-++⁺ʳ; ∈-++⁻) +open import Data.List.Properties using (++-assoc; ++-identityʳ) +open import Data.List.Relation.Unary.Any using (here; index) +open import Data.List.Relation.Unary.Any.Properties using (lookup-index) +open import Data.Maybe.Base using (Maybe; just; nothing) +open import Data.Nat.Base as ℕ using (ℕ; suc; z≤n; s≤s) +import Data.Nat.Properties as ℕₚ +open import Data.Product.Base using (Σ; ∃₂; _×_; _,_; proj₁; proj₂) +open import Data.Rational.Base as ℚ using (ℚ; _/_; 1ℚ; _≤_; _<_) +import Data.Rational.Properties as ℚₚ +open import Data.Rational.Unnormalised.Base using (mkℚᵘ; *≤*) +open import Data.Sum.Base using (inj₁; inj₂) +open import Function.Bundles using (_⇔_; mk⇔; Equivalence) +open import Relation.Binary.Definitions using (DecidableEquality) +open import Relation.Binary.PropositionalEquality + using (_≡_; _≢_; refl; sym; trans; cong; subst) +open import Relation.Nullary using (¬_; yes; no) +open import Relation.Nullary.Decidable using (toWitness) +open import Relation.Nullary.Negation using (contradiction) + +open import MetaManifold.Prelude using (numerator-monotone-≤; fromℚᵘ-mono-≤) + +------------------------------------------------------------------------ +-- 1. The smallest p-value B permutations can produce + +-- vegan's permutation p-value after B permutations, k of which gave a +-- statistic at least the observed one. +permP : ℕ → ℕ → ℚ +permP B k = + suc k / suc B + +-- Julia's `min_p_value = 1 / (Int(n_perm) + 1)`. +minP : ℕ → ℚ +minP B = + 1 / suc B + +-- The floor is a p-value: the one reported when no permutation reaches the +-- observed statistic. +minP-attained : ∀ B → permP B 0 ≡ minP B +minP-attained B = refl + +-- No permutation p-value is below the floor. +p-floor : ∀ B k → minP B ≤ permP B k +p-floor B k = fromℚᵘ-mono-≤ {mkℚᵘ (+ 1) B} {mkℚᵘ (+ suc k) B} + (numerator-monotone-≤ (+ 1) (+ suc k) B (ℤ.+≤+ (s≤s z≤n))) + +-- With k ≤ B, none is above 1. +p-at-most-one : ∀ B k → k ℕ.≤ B → permP B k ≤ 1ℚ +p-at-most-one B k k≤B = fromℚᵘ-mono-≤ {mkℚᵘ (+ suc k) B} {mkℚᵘ (+ 1) 0} + (*≤* (ℤₚ.≤-trans (ℤₚ.≤-reflexive (ℤₚ.*-identityʳ (+ suc k))) + (ℤₚ.≤-trans (ℤ.+≤+ (s≤s k≤B)) + (ℤₚ.≤-reflexive (sym (ℤₚ.*-identityˡ +[1+ B ])))))) + +-- A significance threshold below the floor is below every p-value, so no term +-- can be significant at it, whatever the data. +below-floor-unreachable : ∀ B k {α} → α < minP B → α < permP B k +below-floor-unreachable B k α +-- +-- Shared arithmetic for the machine-checked statistics core. +-- +-- Two kinds of fact live here: +-- +-- * `numerator-monotone-≤`, over the unnormalised rationals `ℚᵘ`, ported from +-- the archived proof tree because the Benjamini–Hochberg scaling lemmas are +-- stated over `ℚᵘ` and rest on it; +-- * the three facts about the Benjamini–Hochberg rank factor `m/(j+1)` that +-- every BH theorem needs: it is non-negative, it is at least one whenever +-- the rank is inside the family, and it shrinks as the rank grows. +-- +-- The rank-factor facts are stated over the normalised rationals `ℚ` (the type +-- the BH procedure itself is defined over) and proved by moving to `ℚᵘ`, where +-- `m/(j+1)` is literally `mkℚᵘ (+ m) j` and a comparison is a single +-- cross-multiplication of integers. +-- +-- Nothing in `proofs/agda` assumes anything beyond the Agda standard library. +-- `proofs/tests/axiom-audit.sh` checks that there is no postulate, hole or +-- unsafe pragma anywhere in the tree. + +{-# OPTIONS --safe --without-K #-} + +module MetaManifold.Prelude where + +open import Data.Integer.Base as ℤ using (ℤ; +_; +[1+_]) +import Data.Integer.Properties as ℤₚ +open import Data.Nat.Base as ℕ using (ℕ; zero; suc; z≤n; s≤s) +import Data.Nat.Properties as ℕₚ +open import Data.Rational.Base as ℚ + using (ℚ; _/_; toℚᵘ; fromℚᵘ; 0ℚ; 1ℚ) +import Data.Rational.Properties as ℚₚ +open import Data.Rational.Unnormalised.Base as ℚᵘ + using (ℚᵘ; mkℚᵘ; *≤*) +import Data.Rational.Unnormalised.Properties as ℚᵘₚ +open import Relation.Binary.PropositionalEquality using (_≡_; refl; sym; cong) + +------------------------------------------------------------------------ +-- Ported from the archive (over ℚᵘ) + +-- Over a common denominator, a larger numerator is a larger fraction. +numerator-monotone-≤ : + ∀ (n m : ℤ) (d : ℕ) → n ℤ.≤ m → mkℚᵘ n d ℚᵘ.≤ mkℚᵘ m d +numerator-monotone-≤ n m d n≤m = + *≤* (ℤₚ.*-monoʳ-≤-nonNeg +[1+ d ] n≤m) + +------------------------------------------------------------------------ +-- The Benjamini–Hochberg rank factor m/(j+1) + +-- The factor that multiplies the p-value at zero-based rank `j` in a family of +-- `m` tests. Julia writes `n / i` with a one-based `i`; this is the same +-- number with `i = j + 1`. +rankFactor : ℕ → ℕ → ℚ +rankFactor m j = + m / suc j + + +-- Moving an inequality from ℚᵘ to ℚ through normalisation. +fromℚᵘ-mono-≤ : ∀ {p q} → p ℚᵘ.≤ q → fromℚᵘ p ℚ.≤ fromℚᵘ q +fromℚᵘ-mono-≤ {p} {q} p≤q = ℚₚ.toℚᵘ-cancel-≤ + (ℚᵘₚ.≤-respˡ-≃ (ℚᵘₚ.≃-sym (ℚₚ.toℚᵘ-fromℚᵘ p)) + (ℚᵘₚ.≤-respʳ-≃ (ℚᵘₚ.≃-sym (ℚₚ.toℚᵘ-fromℚᵘ q)) p≤q)) + +-- A rank factor is never negative. +rankFactor-nonNeg : ∀ m j → 0ℚ ℚ.≤ rankFactor m j +rankFactor-nonNeg m j = fromℚᵘ-mono-≤ {mkℚᵘ (+ 0) 0} {mkℚᵘ (+ m) j} + (*≤* (ℤₚ.≤-trans (ℤₚ.≤-reflexive (ℤₚ.*-zeroˡ +[1+ j ])) + (ℤₚ.≤-trans (ℤ.+≤+ z≤n) + (ℤₚ.≤-reflexive (sym (ℤₚ.*-identityʳ (+ m))))))) + +-- Inside the family (rank j+1 ≤ m) the rank factor is at least one, so +-- scaling never lowers a non-negative p-value. +rankFactor-≥1 : ∀ m j → suc j ℕ.≤ m → 1ℚ ℚ.≤ rankFactor m j +rankFactor-≥1 m j j +-- +-- Library-size scaling (TSS) and median-of-ratios (RLE) over exact rationals: +-- the parts of `tss_factors`, `rle_factors` and `_median` in +-- `src/analysis/differential.jl` (branch feat/differential-abundance-nb, +-- commit 6c3e8b2) that are statements about rational arithmetic, proved; the +-- parts that are not, named. +-- +-- What is NOT modelled, and why: +-- +-- * Geometric means, `exp` and `log`. Julia centres each factor vector by +-- its geometric mean (`_geomean`), RLE divides each count by its taxon's +-- geometric mean, and the model uses `log` of the factors as offsets. +-- None of these is a rational function, and a geometric mean of +-- rationals is in general irrational, so none can be computed over ℚ. +-- Wherever Julia divides by a geometric mean, the theorems below divide +-- by an ARBITRARY positive rational `g`. The same algebra holds over the +-- reals for any positive divisor, but that transfer is an argument, not a +-- theorem here. In particular the scale invariance D1 tests +-- (`tss_factors(7.5 .* fixture) ≈ f`) is a property of the geometric +-- mean and is not proved. +-- * Floating point. Julia computes in Float64; these are exact statements. +-- A refusal can fire in floating point (underflow to 0.0) where the exact +-- quantity is positive; that is not modelled. +-- +-- D1's `tss_factors` computes library sizes (row sums of the samples x taxa +-- matrix) and centres them; it never forms proportions. The proportion +-- theorems below are the relative-abundance reading of TSS asked for in the +-- D2 brief, and hold for any row with a positive total. +-- +-- Theorems (names as in proofs/agda/README.md): +-- +-- total-positive a row of non-negative counts with at least one +-- positive count has a positive total (so TSS's +-- `l > 0` refusal fires exactly on all-zero rows) +-- proportions-sum-to-1 a row's proportions sum to 1 +-- proportions-bounded every proportion lies in [0, 1] +-- centre-positive dividing by any positive g keeps every factor positive +-- centre-monotone ... and keeps their order +-- ratio-positive a positive count over a positive normaliser is positive +-- median-positive the median (as `_median` takes it) of a non-empty list +-- of positive ratios is positive, so once one taxon has +-- reads in every sample, every RLE factor is positive in +-- exact arithmetic + +{-# OPTIONS --safe --without-K #-} + +module MetaManifold.Scaling where + +open import Data.Product.Base using (_×_; _,_) +open import Data.Bool.Base using (Bool; true; false; not; if_then_else_) +open import Data.Nat.Base as ℕ using (ℕ; zero; suc; ⌊_/2⌋) +open import Data.List.Base using (List; []; _∷_; map; foldr; length) +open import Data.List.Relation.Unary.All as All using (All; []; _∷_) +open import Data.List.Relation.Unary.Any using (Any; here; there) +import Data.List.Relation.Binary.Permutation.Propositional.Properties as ↭ₚ +open import Data.List.Relation.Binary.Permutation.Propositional using (↭-sym) +open import Data.Rational.Base as ℚ + using (ℚ; 0ℚ; 1ℚ; ½; _+_; _*_; 1/_; _≤_; _<_; Positive) +import Data.Rational.Properties as ℚₚ +open import Relation.Binary.PropositionalEquality + using (_≡_; refl; sym; trans; cong; subst) + +open import Data.List.Sort.MergeSort ℚₚ.≤-decTotalOrder using (sort; sort-↭) + +------------------------------------------------------------------------ +-- Shared arithmetic + +-- A sample's library size: Julia's `vec(sum(counts; dims=2))`, one row. +sum : List ℚ → ℚ +sum = foldr _+_ 0ℚ + +-- Division by a positive rational, as multiplication by its inverse. +inv : (g : ℚ) → .{{Positive g}} → ℚ +inv g = (1/ g) {{ℚₚ.pos⇒nonZero g}} + +private + inv-positive : ∀ g .{{_ : Positive g}} → Positive (inv g) + inv-positive g = ℚₚ.1/pos⇒pos g + + inv-nonNeg : ∀ g .{{_ : Positive g}} → 0ℚ ≤ inv g + inv-nonNeg g = ℚₚ.<⇒≤ (ℚₚ.positive⁻¹ (inv g) {{inv-positive g}}) + + -- x ↦ x · r is monotone for a non-negative r. + scale-≤ : ∀ r → 0ℚ ≤ r → ∀ {x y} → x ≤ y → x * r ≤ y * r + scale-≤ r 0≤r = ℚₚ.*-monoʳ-≤-nonNeg r {{ℚ.nonNegative 0≤r}} + + -- x ↦ x · r is strictly monotone for a positive r. + scale-< : ∀ r → .{{_ : Positive r}} → ∀ {x y} → x < y → x * r < y * r + scale-< r = ℚₚ.*-monoˡ-<-pos r + + 0+0 : 0ℚ + 0ℚ ≡ 0ℚ + 0+0 = ℚₚ.+-identityʳ 0ℚ + +------------------------------------------------------------------------ +-- TSS: totals and proportions + +private + sum-nonNeg : ∀ xs → All (0ℚ ≤_) xs → 0ℚ ≤ sum xs + sum-nonNeg [] [] = ℚₚ.≤-refl + sum-nonNeg (x ∷ xs) (0≤x ∷ nn) = + subst (_≤ x + sum xs) 0+0 (ℚₚ.+-mono-≤ 0≤x (sum-nonNeg xs nn)) + + -- Every non-negative summand is at most the total. + below-sum : ∀ xs → All (0ℚ ≤_) xs → All (_≤ sum xs) xs + below-sum [] [] = [] + below-sum (x ∷ xs) (0≤x ∷ nn) = + ℚₚ.≤-trans (ℚₚ.≤-reflexive (sym (ℚₚ.+-identityʳ x))) + (ℚₚ.+-mono-≤ (ℚₚ.≤-refl {x}) (sum-nonNeg xs nn)) + ∷ All.map (λ y≤s → ℚₚ.≤-trans y≤s + (ℚₚ.≤-trans (ℚₚ.≤-reflexive (sym (ℚₚ.+-identityˡ (sum xs)))) + (ℚₚ.+-mono-≤ 0≤x (ℚₚ.≤-refl {sum xs})))) + (below-sum xs nn) + + sum-scale : ∀ r xs → sum (map (_* r) xs) ≡ sum xs * r + sum-scale r [] = sym (ℚₚ.*-zeroˡ r) + sum-scale r (x ∷ xs) = + trans (cong (x * r +_) (sum-scale r xs)) (sym (ℚₚ.*-distribʳ-+ r x (sum xs))) + +-- A row of non-negative counts with at least one positive count has a +-- positive total. So `tss_factors`'s `l > 0` refusal fires exactly on the +-- rows whose counts are all zero (`validate_counts` guarantees non-negative). +total-positive : ∀ xs → All (0ℚ ≤_) xs → Any (0ℚ <_) xs → 0ℚ < sum xs +total-positive (x ∷ xs) (_ ∷ nn) (here 0 +--> + +# Machine-checked proofs: Benjamini–Hochberg, size-factor scaling and PERMANOVA permutations + +`proofs/agda/` holds Agda proofs of the exact-arithmetic properties of three +functions in `src/analysis/differential.jl`: `bh_adjust`, `tss_factors` and +`rle_factors` (with `_median`). + +`MetaManifold/Permanova.agda` covers the two things `run_permanova` in +`src/analysis/analysis.jl` decides for itself rather than taking from vegan: +the reported `min_p_value`, and which terms have their p-value withheld +(`_varies_within_any_block`). + +Every module is checked with `--safe --without-K`. There are no postulates, +no holes and no termination or positivity pragmas. +`proofs/tests/axiom-audit.sh` enforces this. It also fails if any module is +not imported by `MetaManifold/All.agda`, because a module that nothing imports +is never type-checked. + +## Toolchain + +The checker is **Agda 2.6.4.3** with **agda-stdlib 2.1**. The CI job +"Agda proofs" installs this pair from Debian trixie (`agda-bin=2.6.4.3-1+b2`, +`agda-stdlib=2.1-4`). It runs in a Debian image pinned by digest, because +Agda publishes no 2.6.4.3 binary and stdlib 2.1 needs Agda 2.6.4.x. + +The proofs have not been checked with Agda 2.7 or with stdlib 2.2 or later. + +To check locally: + +```sh +bash proofs/tests/axiom-audit.sh +cd proofs/agda && agda MetaManifold/All.agda # needs standard-library-2.1 in $AGDA_DIR/libraries +``` + +## What is modelled + +Values are exact rationals (`ℚ`), not `Float64`. A list stands in for a +vector. + +- **`bhAdjust`** follows the shape of the Julia function: + 1. Sort the p-values, keeping each one's input index. + 2. Scale the value at rank `r` by `n / r`. + 3. Take the running minimum from the largest rank down, then clamp it at 1. + 4. Write each value back to its input position (`restore`). + + It differs from the Julia code in two ways: + - **Running minimum.** Julia starts the running minimum at `1.0`; the model + starts from the first scaled value and clamps afterwards. + `stepUp-envelope` proves the two produce the same values. + - **Tie order.** Julia sorts in descending order. Among equal p-values, its + stable sort gives the earlier input the higher rank; the model's + ascending `(p, index)` sort gives it the lower rank. Either way the + sorted p-values are identical, and `bh-monotone` (applied in both + directions) proves that equal p-values receive equal adjusted values. So + the tie order cannot change the output. That last step is an argument + about the Julia code built on a proved lemma. It is not a formal + equivalence proof between Julia and Agda. +- **Input validation** (finite and in `[0, 1]`) is the hypothesis + `All Valid ps`. The Julia `ArgumentError` paths are therefore assumptions + of the theorems, not theorems. +- **`variesWithinAnyBlock`** follows the shape of + `_varies_within_any_block`. A list of `(value, block)` pairs stands in for + `zip(values, blocks)` and an association list for the `first_seen` + dictionary. As with `get!`, a block's value is stored only the first time + the block is seen, and the loop returns `true` at the first sample whose + value differs from its block's stored one. Equality of values and of blocks + is any decidable equality, standing in for `isequal`. +- **Within-block permutations** are permutations of the samples + (`Permutation′ n` from the standard library) that keep every sample in its + block. A term's labels are a function from samples to values. The + p-value is vegan's `(k + 1)/(B + 1)` for `B` permutations, `k` of which + reached the observed statistic. + +## Theorem map + +### Benjamini–Hochberg and scaling (`BenjaminiHochberg`, `Scaling`) + +Tests are testsets in `test/unit/test_differential.jl`. + +| Agda theorem | Plain statement | Julia function | Test | +|---|---|---|---| +| `bh-nonneg` | For valid p-values, every adjusted value is ≥ 0. | `bh_adjust` | "BH adjustment: known answers", "parity with R p.adjust" | +| `bh-dominates-p` | Every adjusted value is ≥ its own p-value. | `bh_adjust` | "BH adjustment: known answers" (`0.001 → 0.003`), parity | +| `bh-at-most-one` | Every adjusted value is ≤ 1. | `bh_adjust` | "BH adjustment: known answers" (`0.5 → 0.5`, `[1.0]`) | +| `bh-monotone` | For any two inputs, `p_i ≤ p_j` implies `adj_i ≤ adj_j`. This covers ties in both directions. | `bh_adjust` | "BH adjustment: known answers" (`[0.04, 0.01] → [0.04, 0.02]`) | +| `bh-envelope`, `stepUp-envelope` | In sorted order, the adjusted value at rank `k` is `min(1, min_{j ≥ k} p_(j) · n/j)`. | `bh_adjust` loop | "BH adjustment: known answers" (all → 0.04), parity | +| `bh-length` | The output has one entry per input; empty input gives empty output. | `bh_adjust` | "BH adjustment: known answers" (empty → empty) | +| `monotone-in-family-size` | A larger family never gives a smaller scaled value. | `n / rank * p` | – | +| `total-positive` | A row of non-negative counts with at least one positive count has a positive total. So the `l > 0` refusal fires exactly on all-zero rows. | `tss_factors` | "TSS size factors" (empty row refused) | +| `proportions-sum-to-1` | A row's proportions `x / Σx` sum to 1. | (relative abundance) | – | +| `proportions-bounded` | Every proportion lies in `[0, 1]`. | (relative abundance) | – | +| `centre-positive`, `centre-monotone` | Dividing factors by any positive `g` keeps them positive and keeps their order. | `lib ./ _geomean(lib)`, `raw ./ _geomean(raw)` | – | +| `ratio-positive` | A positive count over a positive normaliser is positive. | `view(sub, i, :) ./ gm` | – | +| `median-positive` | The median (taken as `_median` takes it) of a non-empty list of positive values is positive. | `_median`, `rle_factors` | "RLE size factors" | + +Taken together, the RLE rows show that once one taxon has reads in every +sample, every RLE factor is positive in exact arithmetic. This holds given a +positive geometric mean, which is assumed (see below). + +`tss_factors` never forms proportions; it centres library sizes. The +proportion theorems are the relative-abundance reading of TSS and do not +describe a value the Julia code returns. + +### PERMANOVA (`Permanova`) + +Tests are testsets in `test/unit/test_permanova.jl`. + +| Agda theorem | Plain statement | Julia | Test | +|---|---|---|---| +| `minP-attained`, `p-floor` | `1/(B + 1)` is the smallest p-value `B` permutations can give, and it is given when no permutation reaches the observed statistic. | `min_p_value = 1 / (Int(n_perm) + 1)` | "each term is tested on its own", "within-individual blocks: permutation count, floor and refusal" | +| `p-at-most-one` | With `k ≤ B`, no p-value is above 1. | (vegan) | – | +| `below-floor-unreachable` | A threshold below `1/(B + 1)` is below every p-value, so no term can meet it. | `min_p_value` | – | +| `minP-anti` | More permutations never raise the floor. | – | – | +| `minP-63`, `no-p-at-1%-with-63` | With 63 permutations the floor is `1/64`, and no p-value is at or below 0.01. | – | "within-individual blocks: permutation count, floor and refusal" | +| `never-exchanged` | A term that is constant within every block is left in place by every within-block permutation, so its labels are never exchanged. | the reason for withholding | – | +| `exchanged` | A term with two values in one block is moved by a within-block permutation: the swap of those two samples. | – | – | +| `untestable⇔fixed` | Constant within every block ⇔ fixed by every within-block permutation. | – | – | +| `varies-false⇔Constant` | The model of `_varies_within_any_block` returns `false` exactly when no block holds two different values. | `_varies_within_any_block` | "_varies_within_any_block" | +| `withheld⇔never-exchanged` | So `untestable` (the function returned `false`) holds exactly when no within-block permutation exchanges the term's labels. | `untestable`, `withheld` | "within-individual blocks: permutation count, floor and refusal" | +| `tested⇔exchanged` | The block-constancy filter admits a term exactly when some within-block permutation changes its labels; this does not guarantee a reported p-value. | `untestable`, `withheld` | "within-individual blocks: permutation count, floor and refusal" | +| `constant-in-blocks`, `varies-in-a-block` | The two direct cases in the test, by evaluation. | `_varies_within_any_block` | "_varies_within_any_block" | +| `fixture-group-withheld`, `fixture-run-tested` | On the test's twelve samples (six individuals, two runs each, three individuals per group), `group` is never exchanged and `run` is. | `run_permanova` | "within-individual blocks: permutation count, floor and refusal" | + +## What is not proved, and why + +- **Statistical validity and successful reporting.** Label exchangeability here + characterises only the block-constancy filter. It does not prove model + identifiability, assumptions for inference, or that vegan returns a p-value. + Julia also withholds missing p-values independently of this filter. + +- **Geometric means, `exp` and `log`.** A geometric mean of rationals is in + general irrational, so `_geomean` cannot be computed over `ℚ`. Wherever + Julia divides by a geometric mean, the theorems divide by an arbitrary + positive rational. That the real geometric mean of positive numbers is + positive is a fact about the reals, not a theorem here. +- **Scale invariance** (`tss_factors(7.5 .* x) ≈ tss_factors(x)`, and the + same for RLE). This is a property of geometric-mean centring and is not + proved. +- **The RLE "doubled sample" ratio test** is not proved. +- **Median equivariance** (`median(c·x) = c·median(x)`, translation) is not + proved. +- **Floating point.** Every statement is exact. `Float64` rounding, and a + refusal caused by underflow to `0.0`, are outside the model. +- **PERMANOVA: the pseudo-F statistic, and how many within-block + permutations exist.** The p-value theorems take `B` and `k` as given. `B` + is what `permute` reports, and `k` depends on the F distribution, neither of + which is modelled. Six blocks of two samples admit `2⁶ = 64` + arrangements, the identity and 63 others, which is the 63 in the test. + That count (the product of the blocks' factorials) is not proved. +- **PERMANOVA: the unblocked case.** Julia asks `_varies_within_any_block` + only when some block label repeats (`blocked`). Otherwise the permutations + are unrestricted and nothing is withheld; that is Julia's `if`, not a + theorem. +- **Equivalence of the Agda and Julia code** is argued, not formalised. The + model mirrors the loop, and the two differences above are each + discharged by a proved lemma plus a short argument. diff --git a/proofs/agda/metamanifold.agda-lib b/proofs/agda/metamanifold.agda-lib new file mode 100644 index 00000000..6f46037a --- /dev/null +++ b/proofs/agda/metamanifold.agda-lib @@ -0,0 +1,6 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +name: metamanifold +include: . +depend: standard-library +flags: --safe --without-K diff --git a/proofs/tests/axiom-audit.sh b/proofs/tests/axiom-audit.sh new file mode 100755 index 00000000..cb10d0e0 --- /dev/null +++ b/proofs/tests/axiom-audit.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Audit the Agda proof tree for anything that would let a "proof" pass +# without proving anything. The type-checker only guarantees what its rules +# allow; every check here is a rule this project refuses to relax. +# +# Usage: proofs/tests/axiom-audit.sh [AGDA_ROOT] +# AGDA_ROOT defaults to proofs/agda next to this script. Passing another +# root is how the positive control runs the audit against a scratch copy. +# +# Fails (exit 1) on: +# 1. a module without `--safe` and `--without-K` in its OPTIONS pragma +# 2. a `postulate`, anywhere in code (not only at the start of a line) +# 3. FOREIGN / COMPILE / BUILTIN pragmas +# 4. unsound flags (--type-in-type, --no-positivity-check, +# --no-termination-check, --allow-unsolved-metas, ...) +# 5. TERMINATING / NON_TERMINATING / NO_POSITIVITY_CHECK / +# NO_UNIVERSE_CHECK pragmas, trustMe, primTrust +# 6. holes: `{! !}` or a bare `?` token, anywhere in code +# 7. a module under the root that MetaManifold/All.agda does not reach +# by imports (an unreached module is never type-checked at all) +# Line comments (`-- ...`) are stripped before checks 2 and 6, so prose that +# mentions a postulate is not a finding. Block comments are not stripped: +# the proof modules do not use them, and a false positive is the safe side. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AGDA_DIR="$(cd "${1:-$SCRIPT_DIR/../agda}" && pwd)" +ENTRY="$AGDA_DIR/MetaManifold/All.agda" +failures=0 + +# Record one finding on stderr and count it; the audit fails at the end. +fail() { + printf 'axiom-audit: FAIL: %s\n' "$*" >&2 + failures=$((failures + 1)) +} + +# Print one progress line on stdout. +note() { + printf 'axiom-audit: %s\n' "$*" +} + +# Abort at once on a condition that makes the audit meaningless. +fatal() { + printf 'axiom-audit: FATAL: %s\n' "$*" >&2 + exit 1 +} + +# Print a file with every `-- ...` line comment removed, keeping line numbers. +# A `--` glued to a word (an option such as --safe) is not a comment. +code_only() { + sed -E 's/(^|[[:space:]])--([[:space:]].*)?$/\1/' "$1" +} + +# Run checks 1-6 on one module file, recording findings via `fail`. +audit_file() { + local f="$1" rel="${1#"$AGDA_DIR"/}" code hits + code="$(code_only "$f")" + + if ! grep -qE '^\{-#[[:space:]]*OPTIONS.*--safe' "$f"; then + fail "$rel does not declare --safe" + fi + if ! grep -qE '^\{-#[[:space:]]*OPTIONS.*--without-K' "$f"; then + fail "$rel does not declare --without-K" + fi + + if hits="$(grep -nE '(^|[^[:alnum:]_])postulate([^[:alnum:]_]|$)' <<<"$code")"; then + fail "$rel contains a postulate: $(head -3 <<<"$hits" | tr '\n' ' ')" + fi + + if grep -qE '\{-#[[:space:]]*(FOREIGN|COMPILE|BUILTIN)' "$f"; then + fail "$rel uses a FOREIGN/COMPILE/BUILTIN pragma" + fi + + if grep -qE -- '--(type-in-type|no-positivity-check|no-termination-check|allow-unsolved-metas|allow-incomplete-matches|no-guardedness|injective-type-constructors|omega-in-omega|cumulativity)' "$f"; then + fail "$rel enables an unsound or check-disabling flag" + fi + + if hits="$(grep -nE '\{-#[[:space:]]*(TERMINATING|NON_TERMINATING|NO_POSITIVITY_CHECK|NO_UNIVERSE_CHECK|NON_COVERING|INJECTIVE)\b|\b(trustMe|primTrust)\b' "$f")"; then + fail "$rel uses a check-disabling pragma or trusted primitive: $(head -3 <<<"$hits" | tr '\n' ' ')" + fi + + if hits="$(grep -nE '\{![^!]*!\}|(^|[[:space:](])\?([[:space:])]|$)' <<<"$code")"; then + fail "$rel contains a hole: $(head -3 <<<"$hits" | tr '\n' ' ')" + fi +} + +# Print, one per line, every MetaManifold.* module reachable from +# MetaManifold.All by imports. An import with no file is printed as +# `MISSING ` (this runs in a subshell, so it cannot call `fail`). +reachable_modules() { + local -A seen=() + local -a queue=("MetaManifold.All") + local mod path dep + while [[ ${#queue[@]} -gt 0 ]]; do + mod="${queue[0]}" + queue=("${queue[@]:1}") + [[ -n "${seen[$mod]:-}" ]] && continue + seen[$mod]=1 + printf '%s\n' "$mod" + path="$AGDA_DIR/${mod//.//}.agda" + [[ -f "$path" ]] || { printf "MISSING %s\n" "$mod"; continue; } + while read -r dep; do + [[ -n "$dep" ]] && queue+=("$dep") + done < <(code_only "$path" \ + | grep -oE '^[[:space:]]*(open[[:space:]]+)?import[[:space:]]+MetaManifold\.[A-Za-z0-9_.]+' \ + | awk '{print $NF}' || true) + done +} + +[[ -f "$ENTRY" ]] || fatal "missing $ENTRY" +mapfile -t FILES < <(find "$AGDA_DIR" -name '*.agda' -type f | sort) +[[ ${#FILES[@]} -gt 0 ]] || fatal "no .agda files under $AGDA_DIR" +note "auditing ${#FILES[@]} module(s) under $AGDA_DIR" + +for f in "${FILES[@]}"; do + audit_file "$f" +done + +REACHED="$(reachable_modules)" +while read -r missing; do + fail "imported module ${missing#MISSING } has no file" +done < <(grep "^MISSING " <<<"$REACHED" || true) +for f in "${FILES[@]}"; do + rel="${f#"$AGDA_DIR"/}" + mod="${rel%.agda}" + mod="${mod//\//.}" + if ! grep -qxF "$mod" <<<"$REACHED"; then + fail "$rel is not reachable from MetaManifold/All.agda, so it is never type-checked" + fi +done +note "reachability: $(wc -l <<<"$REACHED") module(s) reachable from MetaManifold.All" + +if [[ $failures -gt 0 ]]; then + printf 'axiom-audit: %d finding(s)\n' "$failures" >&2 + exit 1 +fi +note "clean" diff --git a/renv.lock b/renv.lock index d0c9e123..9944f58c 100644 --- a/renv.lock +++ b/renv.lock @@ -673,6 +673,25 @@ "Author": "Constantin Ahlmann-Eltze [aut] (ORCID: ), Peter Hickey [aut, cre] (ORCID: ), Hervé Pagès [aut]", "Maintainer": "Peter Hickey " }, + "NADA": { + "Package": "NADA", + "Version": "1.6-1.2", + "Source": "Repository", + "Title": "Nondetects and Data Analysis for Environmental Data", + "Author": "Lopaka Lee [aut, cre]", + "Authors@R": "person(given = \"Lopaka\", family = \"Lee\", role = c(\"aut\", \"cre\"), email = \"rclee@usgs.gov\")", + "Description": "Contains methods described by Dennis Helsel in his book \"Nondetects And Data Analysis: Statistics for Censored Environmental Data\".", + "Maintainer": "Lopaka Lee ", + "Depends": [ + "R (>= 2.10)", + "methods", + "survival" + ], + "License": "GPL (>= 2)", + "Collate": "All.R summary.R cen.R km.R reg.R mle.R ken.R ros.R icmle.R", + "NeedsCompilation": "no", + "Repository": "CRAN" + }, "R6": { "Package": "R6", "Version": "2.6.1", @@ -1649,65 +1668,6 @@ "NeedsCompilation": "yes", "Repository": "CRAN" }, - "dplyr": { - "Package": "dplyr", - "Version": "1.2.1", - "Source": "Repository", - "Type": "Package", - "Title": "A Grammar of Data Manipulation", - "Authors@R": "c( person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = c(\"aut\", \"cre\"), comment = c(ORCID = \"0000-0003-4757-117X\")), person(\"Romain\", \"François\", role = \"aut\", comment = c(ORCID = \"0000-0002-2444-4226\")), person(\"Lionel\", \"Henry\", role = \"aut\"), person(\"Kirill\", \"Müller\", role = \"aut\", comment = c(ORCID = \"0000-0002-1416-3412\")), person(\"Davis\", \"Vaughan\", , \"davis@posit.co\", role = \"aut\", comment = c(ORCID = \"0000-0003-4777-038X\")), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )", - "Description": "A fast, consistent tool for working with data frame like objects, both in memory and out of memory.", - "License": "MIT + file LICENSE", - "URL": "https://dplyr.tidyverse.org, https://github.com/tidyverse/dplyr", - "BugReports": "https://github.com/tidyverse/dplyr/issues", - "Depends": [ - "R (>= 4.1.0)" - ], - "Imports": [ - "cli (>= 3.6.2)", - "generics", - "glue (>= 1.3.2)", - "lifecycle (>= 1.0.5)", - "magrittr (>= 1.5)", - "methods", - "pillar (>= 1.9.0)", - "R6", - "rlang (>= 1.1.7)", - "tibble (>= 3.2.0)", - "tidyselect (>= 1.2.0)", - "utils", - "vctrs (>= 0.7.1)" - ], - "Suggests": [ - "broom", - "covr", - "DBI", - "dbplyr (>= 2.2.1)", - "ggplot2", - "knitr", - "Lahman", - "lobstr", - "nycflights13", - "purrr", - "rmarkdown", - "RSQLite", - "stringi (>= 1.7.6)", - "testthat (>= 3.1.5)", - "tidyr (>= 1.3.0)", - "withr" - ], - "VignetteBuilder": "knitr", - "Config/build/compilation-database": "true", - "Config/Needs/website": "tidyverse/tidytemplate", - "Config/testthat/edition": "3", - "Encoding": "UTF-8", - "LazyData": "true", - "RoxygenNote": "7.3.3", - "NeedsCompilation": "yes", - "Author": "Hadley Wickham [aut, cre] (ORCID: ), Romain François [aut] (ORCID: ), Lionel Henry [aut], Kirill Müller [aut] (ORCID: ), Davis Vaughan [aut] (ORCID: ), Posit Software, PBC [cph, fnd]", - "Maintainer": "Hadley Wickham ", - "Repository": "CRAN" - }, "farver": { "Package": "farver", "Version": "2.1.2", @@ -2444,87 +2404,6 @@ "Maintainer": "Gavin L. Simpson ", "Repository": "CRAN" }, - "pillar": { - "Package": "pillar", - "Version": "1.11.1", - "Source": "Repository", - "Title": "Coloured Formatting for Columns", - "Authors@R": "c(person(given = \"Kirill\", family = \"M\\u00fcller\", role = c(\"aut\", \"cre\"), email = \"kirill@cynkra.com\", comment = c(ORCID = \"0000-0002-1416-3412\")), person(given = \"Hadley\", family = \"Wickham\", role = \"aut\"), person(given = \"RStudio\", role = \"cph\"))", - "Description": "Provides 'pillar' and 'colonnade' generics designed for formatting columns of data using the full range of colours provided by modern terminals.", - "License": "MIT + file LICENSE", - "URL": "https://pillar.r-lib.org/, https://github.com/r-lib/pillar", - "BugReports": "https://github.com/r-lib/pillar/issues", - "Imports": [ - "cli (>= 2.3.0)", - "glue", - "lifecycle", - "rlang (>= 1.0.2)", - "utf8 (>= 1.1.0)", - "utils", - "vctrs (>= 0.5.0)" - ], - "Suggests": [ - "bit64", - "DBI", - "debugme", - "DiagrammeR", - "dplyr", - "formattable", - "ggplot2", - "knitr", - "lubridate", - "nanotime", - "nycflights13", - "palmerpenguins", - "rmarkdown", - "scales", - "stringi", - "survival", - "testthat (>= 3.1.1)", - "tibble", - "units (>= 0.7.2)", - "vdiffr", - "withr" - ], - "VignetteBuilder": "knitr", - "Encoding": "UTF-8", - "RoxygenNote": "7.3.3.9000", - "Config/testthat/edition": "3", - "Config/testthat/parallel": "true", - "Config/testthat/start-first": "format_multi_fuzz, format_multi_fuzz_2, format_multi, ctl_colonnade, ctl_colonnade_1, ctl_colonnade_2", - "Config/autostyle/scope": "line_breaks", - "Config/autostyle/strict": "true", - "Config/gha/extra-packages": "units=?ignore-before-r=4.3.0", - "Config/Needs/website": "tidyverse/tidytemplate", - "NeedsCompilation": "no", - "Author": "Kirill Müller [aut, cre] (ORCID: ), Hadley Wickham [aut], RStudio [cph]", - "Maintainer": "Kirill Müller ", - "Repository": "CRAN" - }, - "pkgconfig": { - "Package": "pkgconfig", - "Version": "2.0.3", - "Source": "Repository", - "Title": "Private Configuration for 'R' Packages", - "Author": "Gábor Csárdi", - "Maintainer": "Gábor Csárdi ", - "Description": "Set configuration options on a per-package basis. Options set by a given package only apply to that package, other packages are unaffected.", - "License": "MIT + file LICENSE", - "LazyData": "true", - "Imports": [ - "utils" - ], - "Suggests": [ - "covr", - "testthat", - "disposables (>= 1.0.3)" - ], - "URL": "https://github.com/r-lib/pkgconfig#readme", - "BugReports": "https://github.com/r-lib/pkgconfig/issues", - "Encoding": "UTF-8", - "NeedsCompilation": "no", - "Repository": "CRAN" - }, "plyr": { "Package": "plyr", "Version": "1.8.9", @@ -2907,143 +2786,56 @@ "Maintainer": "Hadley Wickham ", "Repository": "CRAN" }, - "tibble": { - "Package": "tibble", - "Version": "3.3.1", + "survival": { + "Package": "survival", + "Version": "3.8-3", "Source": "Repository", - "Title": "Simple Data Frames", - "Authors@R": "c( person(\"Kirill\", \"Müller\", , \"kirill@cynkra.com\", role = c(\"aut\", \"cre\"), comment = c(ORCID = \"0000-0002-1416-3412\")), person(\"Hadley\", \"Wickham\", , \"hadley@rstudio.com\", role = \"aut\"), person(\"Romain\", \"Francois\", , \"romain@r-enthusiasts.com\", role = \"ctb\"), person(\"Jennifer\", \"Bryan\", , \"jenny@rstudio.com\", role = \"ctb\"), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\"), comment = c(ROR = \"03wc8by49\")) )", - "Description": "Provides a 'tbl_df' class (the 'tibble') with stricter checking and better formatting than the traditional data frame.", - "License": "MIT + file LICENSE", - "URL": "https://tibble.tidyverse.org/, https://github.com/tidyverse/tibble", - "BugReports": "https://github.com/tidyverse/tibble/issues", + "Title": "Survival Analysis", + "Priority": "recommended", + "Date": "2024-12-17", "Depends": [ - "R (>= 3.4.0)" + "R (>= 3.5.0)" ], "Imports": [ - "cli", - "lifecycle (>= 1.0.0)", - "magrittr", + "graphics", + "Matrix", "methods", - "pillar (>= 1.8.1)", - "pkgconfig", - "rlang (>= 1.0.2)", - "utils", - "vctrs (>= 0.5.0)" - ], - "Suggests": [ - "bench", - "bit64", - "blob", - "brio", - "callr", - "DiagrammeR", - "dplyr", - "evaluate", - "formattable", - "ggplot2", - "here", - "hms", - "htmltools", - "knitr", - "lubridate", - "nycflights13", - "pkgload", - "purrr", - "rmarkdown", - "stringi", - "testthat (>= 3.0.2)", - "tidyr", - "withr" - ], - "VignetteBuilder": "knitr", - "Config/autostyle/rmd": "false", - "Config/autostyle/scope": "line_breaks", - "Config/autostyle/strict": "true", - "Config/Needs/website": "tidyverse/tidytemplate", - "Config/testthat/edition": "3", - "Config/testthat/parallel": "true", - "Config/testthat/start-first": "vignette-formats, as_tibble, add, invariants", - "Config/usethis/last-upkeep": "2025-06-07", - "Encoding": "UTF-8", - "RoxygenNote": "7.3.3.9000", - "NeedsCompilation": "yes", - "Author": "Kirill Müller [aut, cre] (ORCID: ), Hadley Wickham [aut], Romain Francois [ctb], Jennifer Bryan [ctb], Posit Software, PBC [cph, fnd] (ROR: )", - "Maintainer": "Kirill Müller ", - "Repository": "CRAN" - }, - "tidyselect": { - "Package": "tidyselect", - "Version": "1.2.1", - "Source": "Repository", - "Title": "Select from a Set of Strings", - "Authors@R": "c( person(\"Lionel\", \"Henry\", , \"lionel@posit.co\", role = c(\"aut\", \"cre\")), person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = \"aut\"), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )", - "Description": "A backend for the selecting functions of the 'tidyverse'. It makes it easy to implement select-like functions in your own packages in a way that is consistent with other 'tidyverse' interfaces for selection.", - "License": "MIT + file LICENSE", - "URL": "https://tidyselect.r-lib.org, https://github.com/r-lib/tidyselect", - "BugReports": "https://github.com/r-lib/tidyselect/issues", - "Depends": [ - "R (>= 3.4)" - ], - "Imports": [ - "cli (>= 3.3.0)", - "glue (>= 1.3.0)", - "lifecycle (>= 1.0.3)", - "rlang (>= 1.0.4)", - "vctrs (>= 0.5.2)", - "withr" - ], - "Suggests": [ - "covr", - "crayon", - "dplyr", - "knitr", - "magrittr", - "rmarkdown", - "stringr", - "testthat (>= 3.1.1)", - "tibble (>= 2.1.3)" + "splines", + "stats", + "utils" ], - "VignetteBuilder": "knitr", - "ByteCompile": "true", - "Config/testthat/edition": "3", - "Config/Needs/website": "tidyverse/tidytemplate", - "Encoding": "UTF-8", - "RoxygenNote": "7.3.0.9000", + "LazyData": "Yes", + "LazyDataCompression": "xz", + "ByteCompile": "Yes", + "Authors@R": "c(person(c(\"Terry\", \"M\"), \"Therneau\", email=\"therneau.terry@mayo.edu\", role=c(\"aut\", \"cre\")), person(\"Thomas\", \"Lumley\", role=c(\"ctb\", \"trl\"), comment=\"original S->R port and R maintainer until 2009\"), person(\"Atkinson\", \"Elizabeth\", role=\"ctb\"), person(\"Crowson\", \"Cynthia\", role=\"ctb\"))", + "Description": "Contains the core survival analysis routines, including definition of Surv objects, Kaplan-Meier and Aalen-Johansen (multi-state) curves, Cox models, and parametric accelerated failure time models.", + "License": "LGPL (>= 2)", + "URL": "https://github.com/therneau/survival", "NeedsCompilation": "yes", - "Author": "Lionel Henry [aut, cre], Hadley Wickham [aut], Posit Software, PBC [cph, fnd]", - "Maintainer": "Lionel Henry ", + "Author": "Terry M Therneau [aut, cre], Thomas Lumley [ctb, trl] (original S->R port and R maintainer until 2009), Atkinson Elizabeth [ctb], Crowson Cynthia [ctb]", + "Maintainer": "Terry M Therneau ", "Repository": "CRAN" }, - "utf8": { - "Package": "utf8", - "Version": "1.2.6", + "truncnorm": { + "Package": "truncnorm", + "Version": "1.0-9", "Source": "Repository", - "Title": "Unicode Text Processing", - "Authors@R": "c(person(given = c(\"Patrick\", \"O.\"), family = \"Perry\", role = c(\"aut\", \"cph\")), person(given = \"Kirill\", family = \"M\\u00fcller\", role = \"cre\", email = \"kirill@cynkra.com\", comment = c(ORCID = \"0000-0002-1416-3412\")), person(given = \"Unicode, Inc.\", role = c(\"cph\", \"dtc\"), comment = \"Unicode Character Database\"))", - "Description": "Process and print 'UTF-8' encoded international text (Unicode). Input, validate, normalize, encode, format, and display.", - "License": "Apache License (== 2.0) | file LICENSE", - "URL": "https://krlmlr.github.io/utf8/, https://github.com/krlmlr/utf8", - "BugReports": "https://github.com/krlmlr/utf8/issues", + "Title": "Truncated Normal Distribution", + "Description": "Density, probability, quantile and random number generation functions for the truncated normal distribution.", + "Authors@R": "c(person(\"Olaf\", \"Mersmann\", role=c(\"aut\", \"cre\"), email=\"olafm@p-value.net\"), person(\"Heike\", \"Trautmann\", role=c(\"aut\")), person(\"Detlef\", \"Steuer\", role=c(\"aut\")), person(\"Björn\", \"Bornkamp\", role=c(\"aut\")))", + "URL": "https://github.com/olafmersmann/truncnorm", + "BugReports": "https://github.com/olafmersmann/truncnorm/issues", "Depends": [ - "R (>= 2.10)" + "R (>= 3.4.0)" ], "Suggests": [ - "cli", - "covr", - "knitr", - "rlang", - "rmarkdown", - "testthat (>= 3.0.0)", - "withr" + "testthat" ], - "VignetteBuilder": "knitr, rmarkdown", - "Config/testthat/edition": "3", + "License": "GPL (>= 2)", "Encoding": "UTF-8", - "RoxygenNote": "7.3.2.9000", "NeedsCompilation": "yes", - "Author": "Patrick O. Perry [aut, cph], Kirill Müller [cre] (ORCID: ), Unicode, Inc. [cph, dtc] (Unicode Character Database)", - "Maintainer": "Kirill Müller ", + "Author": "Olaf Mersmann [aut, cre], Heike Trautmann [aut], Detlef Steuer [aut], Björn Bornkamp [aut]", + "Maintainer": "Olaf Mersmann ", "Repository": "CRAN" }, "vctrs": { @@ -3192,6 +2984,592 @@ "Author": "Jim Hester [aut], Lionel Henry [aut, cre], Kirill Müller [aut], Kevin Ushey [aut], Hadley Wickham [aut], Winston Chang [aut], Jennifer Bryan [ctb], Richard Cotton [ctb], Posit Software, PBC [cph, fnd]", "Maintainer": "Lionel Henry ", "Repository": "CRAN" + }, + "zCompositions": { + "Package": "zCompositions", + "Version": "1.6.2", + "Source": "Repository", + "Type": "Package", + "Title": "Treatment of Zeros, Left-Censored and Missing Values in Compositional Data Sets", + "Date": "2026-06-20", + "Authors@R": "c(person(given = \"Javier\", family = \"Palarea-Albaladejo\", role = c(\"cre\",\"aut\"), email = \"javier.palarea@udg.edu\", comment = c(ORCID = \"0000-0003-0162-669X\")), person(given = \"Josep Antoni\", family = \"Martin-Fernandez\", role = c(\"aut\"), email = \"josepantoni.martin@udg.edu\", comment = c(ORCID = \"0000-0003-2366-1592\")))", + "Maintainer": "Javier Palarea-Albaladejo ", + "Depends": [ + "R (>= 2.14.0)", + "methods", + "MASS", + "NADA", + "truncnorm" + ], + "ByteCompile": "yes", + "Description": "Principled methods for the imputation of zeros, left-censored and missing data in compositional data sets (Palarea-Albaladejo and Martin-Fernandez (2015) ).", + "License": "GPL (>= 2)", + "Repository": "CRAN", + "RoxygenNote": "7.3.3", + "Encoding": "UTF-8", + "URL": "https://github.com/Japal/zCompositions", + "BugReports": "https://github.com/Japal/zCompositions/issues", + "NeedsCompilation": "no", + "Author": "Javier Palarea-Albaladejo [cre, aut] (ORCID: ), Josep Antoni Martin-Fernandez [aut] (ORCID: )" + }, + "DelayedMatrixStats": { + "Package": "DelayedMatrixStats", + "Version": "1.32.0", + "Source": "Bioconductor", + "Type": "Package", + "Title": "Functions that Apply to Rows and Columns of 'DelayedMatrix' Objects", + "Date": "2025-01-09", + "Authors@R": "c(person(\"Peter\", \"Hickey\", role = c(\"aut\", \"cre\"), email = \"peter.hickey@gmail.com\", comment = c(ORCID = \"0000-0002-8153-6258\")), person(\"Hervé\", \"Pagès\", role = \"ctb\", email = \"hpages.on.github@gmail.com\"), person(\"Aaron\", \"Lun\", role = \"ctb\", email = \"infinite.monkeys.with.keyboards@gmail.com\"))", + "Description": "A port of the 'matrixStats' API for use with DelayedMatrix objects from the 'DelayedArray' package. High-performing functions operating on rows and columns of DelayedMatrix objects, e.g. col / rowMedians(), col / rowRanks(), and col / rowSds(). Functions optimized per data type and for subsetted calculations such that both memory usage and processing time is minimized.", + "License": "MIT + file LICENSE", + "Encoding": "UTF-8", + "Roxygen": "list(markdown = TRUE)", + "RoxygenNote": "7.3.2", + "Depends": [ + "MatrixGenerics (>= 1.15.1)", + "DelayedArray (>= 0.31.7)" + ], + "Imports": [ + "methods", + "sparseMatrixStats (>= 1.13.2)", + "Matrix (>= 1.5-0)", + "S4Vectors (>= 0.17.5)", + "IRanges (>= 2.25.10)", + "SparseArray (>= 1.5.19)" + ], + "Suggests": [ + "testthat", + "knitr", + "rmarkdown", + "BiocStyle", + "microbenchmark", + "profmem", + "HDF5Array", + "matrixStats (>= 1.0.0)" + ], + "VignetteBuilder": "knitr", + "URL": "https://github.com/PeteHaitch/DelayedMatrixStats", + "BugReports": "https://github.com/PeteHaitch/DelayedMatrixStats/issues", + "biocViews": "Infrastructure, DataRepresentation, Software", + "git_url": "https://git.bioconductor.org/packages/DelayedMatrixStats", + "git_branch": "RELEASE_3_22", + "git_last_commit": "cbf7d75", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "no", + "Author": "Peter Hickey [aut, cre] (ORCID: ), Hervé Pagès [ctb], Aaron Lun [ctb]", + "Maintainer": "Peter Hickey " + }, + "HDF5Array": { + "Package": "HDF5Array", + "Version": "1.38.0", + "Source": "Bioconductor", + "Title": "HDF5 datasets as array-like objects in R", + "Description": "The HDF5Array package is an HDF5 backend for DelayedArray objects. It implements the HDF5Array, H5SparseMatrix, H5ADMatrix, and TENxMatrix classes, 4 convenient and memory-efficient array-like containers for representing and manipulating either: (1) a conventional (a.k.a. dense) HDF5 dataset, (2) an HDF5 sparse matrix (stored in CSR/CSC/Yale format), (3) the central matrix of an h5ad file (or any matrix in the /layers group), or (4) a 10x Genomics sparse matrix. All these containers are DelayedArray extensions and thus support all operations (delayed or block-processed) supported by DelayedArray objects.", + "biocViews": "Infrastructure, DataRepresentation, DataImport, Sequencing, RNASeq, Coverage, Annotation, GenomeAnnotation, SingleCell, ImmunoOncology", + "URL": "https://bioconductor.org/packages/HDF5Array", + "BugReports": "https://github.com/Bioconductor/HDF5Array/issues", + "License": "Artistic-2.0", + "Encoding": "UTF-8", + "Authors@R": "person(\"Hervé\", \"Pagès\", role=c(\"aut\", \"cre\"), email=\"hpages.on.github@gmail.com\", comment=c(ORCID=\"0009-0002-8272-4522\"))", + "Depends": [ + "R (>= 3.4)", + "methods", + "SparseArray (>= 1.7.5)", + "DelayedArray (>= 0.33.5)", + "h5mread (>= 0.99.4)" + ], + "Imports": [ + "utils", + "stats", + "tools", + "Matrix", + "BiocGenerics (>= 0.51.2)", + "S4Vectors", + "IRanges", + "S4Arrays (>= 1.1.1)", + "rhdf5" + ], + "Suggests": [ + "BiocParallel", + "GenomicRanges", + "SummarizedExperiment (>= 1.15.1)", + "h5vcData", + "ExperimentHub", + "TENxBrainData", + "zellkonverter", + "GenomicFeatures", + "SingleCellExperiment", + "DelayedMatrixStats", + "genefilter", + "RSpectra", + "RUnit", + "knitr", + "rmarkdown", + "BiocStyle" + ], + "VignetteBuilder": "knitr", + "Collate": "utils.R h5utils.R HDF5ArraySeed-class.R HDF5Array-class.R ReshapedHDF5ArraySeed-class.R ReshapedHDF5Array-class.R dump-management.R writeHDF5Array.R saveHDF5SummarizedExperiment.R H5SparseMatrixSeed-class.R H5SparseMatrix-class.R H5ADMatrixSeed-class.R H5ADMatrix-class.R TENxMatrixSeed-class.R TENxMatrix-class.R writeTENxMatrix.R zzz.R", + "git_url": "https://git.bioconductor.org/packages/HDF5Array", + "git_branch": "RELEASE_3_22", + "git_last_commit": "9bca08f", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "no", + "Author": "Hervé Pagès [aut, cre] (ORCID: )", + "Maintainer": "Hervé Pagès " + }, + "RcppArmadillo": { + "Package": "RcppArmadillo", + "Version": "15.6.2-1", + "Source": "Repository", + "Type": "Package", + "Title": "'Rcpp' Integration for the 'Armadillo' Templated Linear Algebra Library", + "Date": "2026-10-16", + "Authors@R": "c(person(\"Dirk\", \"Eddelbuettel\", role = c(\"aut\", \"cre\"), email = \"edd@debian.org\", comment = c(ORCID = \"0000-0001-6419-907X\")), person(\"Romain\", \"Francois\", role = \"aut\", comment = c(ORCID = \"0000-0002-2444-4226\")), person(\"Doug\", \"Bates\", role = \"aut\", comment = c(ORCID = \"0000-0001-8316-9503\")), person(\"Binxiang\", \"Ni\", role = \"aut\"), person(\"Conrad\", \"Sanderson\", role = \"aut\", comment = c(ORCID = \"0000-0002-0049-4501\")))", + "Description": "'Armadillo' is a templated C++ linear algebra library aiming towards a good balance between speed and ease of use. It provides high-level syntax and functionality deliberately similar to Matlab. It is useful for algorithm development directly in C++, or quick conversion of research code into production environments. It provides efficient classes for vectors, matrices and cubes where dense and sparse matrices are supported. Integer, floating point and complex numbers are supported. A sophisticated expression evaluator (based on template meta-programming) automatically combines several operations to increase speed and efficiency. Dynamic evaluation automatically chooses optimal code paths based on detected matrix structures. Matrix decompositions are provided through integration with LAPACK, or one of its high performance drop-in replacements (such as 'MKL' or 'OpenBLAS'). It can automatically use 'OpenMP' multi-threading (parallelisation) to speed up computationally expensive operations. . The 'RcppArmadillo' package includes the header files from the 'Armadillo' library; users do not need to install 'Armadillo' itself in order to use 'RcppArmadillo'. Starting from release 15.0.0, the minimum compilation standard is C++14. . Since release 7.800.0, 'Armadillo' is licensed under Apache License 2; previous releases were under licensed as MPL 2.0 from version 3.800.0 onwards and LGPL-3 prior to that; 'RcppArmadillo' (the 'Rcpp' bindings/bridge to Armadillo) is licensed under the GNU GPL version 2 or later, as is the rest of 'Rcpp'.", + "License": "GPL (>= 2)", + "LazyLoad": "yes", + "Depends": [ + "R (>= 3.3.0)" + ], + "LinkingTo": [ + "Rcpp" + ], + "Imports": [ + "Rcpp (>= 1.1.1)", + "stats", + "utils", + "methods" + ], + "Suggests": [ + "tinytest", + "Matrix (>= 1.3.0)", + "pkgKitten", + "reticulate", + "slam" + ], + "URL": "https://github.com/RcppCore/RcppArmadillo, https://dirk.eddelbuettel.com/code/rcpp.armadillo.html", + "BugReports": "https://github.com/RcppCore/RcppArmadillo/issues", + "RoxygenNote": "6.0.1", + "Encoding": "UTF-8", + "VignetteBuilder": "Rcpp", + "NeedsCompilation": "yes", + "Author": "Dirk Eddelbuettel [aut, cre] (ORCID: ), Romain Francois [aut] (ORCID: ), Doug Bates [aut] (ORCID: ), Binxiang Ni [aut], Conrad Sanderson [aut] (ORCID: )", + "Maintainer": "Dirk Eddelbuettel ", + "Repository": "CRAN" + }, + "Rhdf5lib": { + "Package": "Rhdf5lib", + "Version": "1.32.0", + "Source": "Bioconductor", + "Type": "Package", + "Title": "hdf5 library as an R package", + "Authors@R": "c( person( \"Mike\", \"Smith\", role = \"ctb\", comment = c(ORCID = \"0000-0002-7800-3848\") ), person( \"Hugo\", \"Gruson\", role = \"cre\", email = \"hugo.gruson@embl.de\", comment = c(ORCID = \"0000-0002-4094-1476\")) , person( given = \"The HDF Group\", role = \"cph\" ) )", + "Description": "Provides C and C++ hdf5 libraries.", + "License": "Artistic-2.0", + "Copyright": "src/hdf5/COPYING", + "LazyLoad": "true", + "VignetteBuilder": "knitr", + "Depends": [ + "R (>= 4.2.0)" + ], + "Suggests": [ + "BiocStyle", + "knitr", + "rmarkdown", + "tinytest", + "mockery" + ], + "URL": "https://github.com/Huber-group-EMBL/Rhdf5lib", + "BugReports": "https://github.com/Huber-group-EMBL/Rhdf5lib/issues", + "SystemRequirements": "GNU make", + "Encoding": "UTF-8", + "biocViews": "Infrastructure", + "RoxygenNote": "7.1.2", + "git_url": "https://git.bioconductor.org/packages/Rhdf5lib", + "git_branch": "RELEASE_3_22", + "git_last_commit": "f62ae28", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "yes", + "Author": "Mike Smith [ctb] (ORCID: ), Hugo Gruson [cre] (ORCID: ), The HDF Group [cph]", + "Maintainer": "Hugo Gruson " + }, + "SingleCellExperiment": { + "Package": "SingleCellExperiment", + "Version": "1.32.0", + "Source": "Bioconductor", + "Date": "2025-06-27", + "Title": "S4 Classes for Single Cell Data", + "Authors@R": "c( person(\"Aaron\", \"Lun\", role=c(\"aut\", \"cph\"), email=\"infinite.monkeys.with.keyboards@gmail.com\"), person(\"Davide\",\"Risso\", role=c(\"aut\",\"cre\", \"cph\"), email=\"risso.davide@gmail.com\"), person(\"Keegan\", \"Korthauer\", role=\"ctb\"), person(\"Kevin\", \"Rue-Albrecht\", role=\"ctb\"), person(\"Luke\", \"Zappia\", role = \"ctb\", comment = c(ORCID = \"0000-0001-7744-8565\", github = \"lazappi\")))", + "Depends": [ + "SummarizedExperiment" + ], + "Imports": [ + "methods", + "utils", + "stats", + "S4Vectors", + "BiocGenerics", + "GenomicRanges", + "DelayedArray" + ], + "Suggests": [ + "testthat", + "BiocStyle", + "knitr", + "rmarkdown", + "Matrix", + "scRNAseq (>= 2.9.1)", + "Rtsne" + ], + "biocViews": "ImmunoOncology, DataRepresentation, DataImport, Infrastructure, SingleCell", + "Description": "Defines a S4 class for storing data from single-cell experiments. This includes specialized methods to store and retrieve spike-in information, dimensionality reduction coordinates and size factors for each cell, along with the usual metadata for genes and libraries.", + "License": "GPL-3", + "VignetteBuilder": "knitr", + "RoxygenNote": "7.3.2", + "git_url": "https://git.bioconductor.org/packages/SingleCellExperiment", + "git_branch": "RELEASE_3_22", + "git_last_commit": "db7133e", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "no", + "Author": "Aaron Lun [aut, cph], Davide Risso [aut, cre, cph], Keegan Korthauer [ctb], Kevin Rue-Albrecht [ctb], Luke Zappia [ctb] (ORCID: , github: lazappi)", + "Maintainer": "Davide Risso " + }, + "assorthead": { + "Package": "assorthead", + "Version": "1.4.0", + "Source": "Bioconductor", + "Date": "2025-10-27", + "Title": "Assorted Header-Only C++ Libraries", + "Authors@R": "person(\"Aaron\", \"Lun\", role=c(\"cre\", \"aut\"), email=\"infinite.monkeys.with.keyboards@gmail.com\")", + "Description": "Vendors an assortment of useful header-only C++ libraries. Bioconductor packages can use these libraries in their own C++ code by LinkingTo this package without introducing any additional dependencies. The use of a central repository avoids duplicate vendoring of libraries across multiple R packages, and enables better coordination of version updates across cohorts of interdependent C++ libraries.", + "License": "MIT + file LICENSE", + "Suggests": [ + "knitr", + "rmarkdown", + "BiocStyle" + ], + "VignetteBuilder": "knitr", + "URL": "https://github.com/LTLA/assorthead", + "BugReports": "https://github.com/LTLA/assorthead/issues", + "Encoding": "UTF-8", + "biocViews": "SingleCell, QualityControl, Normalization, DataRepresentation, DataImport, DifferentialExpression, Alignment", + "git_url": "https://git.bioconductor.org/packages/assorthead", + "git_branch": "RELEASE_3_22", + "git_last_commit": "9255f2f", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "no", + "Author": "Aaron Lun [cre, aut]", + "Maintainer": "Aaron Lun " + }, + "beachmat": { + "Package": "beachmat", + "Version": "2.26.0", + "Source": "Bioconductor", + "Date": "2025-08-19", + "Title": "Compiling Bioconductor to Handle Each Matrix Type", + "Authors@R": "c(person(\"Aaron\", \"Lun\", role = c(\"aut\", \"cre\"), email = \"infinite.monkeys.with.keyboards@gmail.com\"), person(\"Hervé\", \"Pagès\", role=\"aut\"), person(\"Mike\", \"Smith\", role=\"aut\"))", + "Imports": [ + "methods", + "DelayedArray (>= 0.27.2)", + "SparseArray", + "BiocGenerics", + "Matrix", + "Rcpp" + ], + "Suggests": [ + "testthat", + "BiocStyle", + "knitr", + "rmarkdown", + "rcmdcheck", + "BiocParallel", + "HDF5Array", + "beachmat.hdf5" + ], + "LinkingTo": [ + "Rcpp", + "assorthead (>= 1.3.3)" + ], + "biocViews": "DataRepresentation, DataImport, Infrastructure", + "Description": "Provides a consistent C++ class interface for reading from a variety of commonly used matrix types. Ordinary matrices and several sparse/dense Matrix classes are directly supported, along with a subset of the delayed operations implemented in the DelayedArray package. All other matrix-like objects are supported by calling back into R.", + "License": "GPL-3", + "NeedsCompilation": "yes", + "VignetteBuilder": "knitr", + "SystemRequirements": "C++17", + "URL": "https://github.com/tatami-inc/beachmat", + "BugReports": "https://github.com/tatami-inc/beachmat/issues", + "RoxygenNote": "7.3.2", + "Encoding": "UTF-8", + "git_url": "https://git.bioconductor.org/packages/beachmat", + "git_branch": "RELEASE_3_22", + "git_last_commit": "016c55e", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "Author": "Aaron Lun [aut, cre], Hervé Pagès [aut], Mike Smith [aut]", + "Maintainer": "Aaron Lun " + }, + "glmGamPoi": { + "Package": "glmGamPoi", + "Version": "1.22.0", + "Source": "Bioconductor", + "Type": "Package", + "Title": "Fit a Gamma-Poisson Generalized Linear Model", + "Authors@R": "c(person(\"Constantin\", \"Ahlmann-Eltze\", email = \"artjom31415@googlemail.com\", role = c(\"aut\", \"cre\"), comment = c(ORCID = \"0000-0002-3762-068X\")), person(\"Nathan\", \"Lubock\", email=\"lubock.nathan@gmail.com\", role = \"ctb\", comment = c(ORCID = \"0000-0001-8064-2465\")), person(\"Michael\", \"Love\", email=\"michaelisaiahlove@gmail.com\", role = \"ctb\"))", + "Description": "Fit linear models to overdispersed count data. The package can estimate the overdispersion and fit repeated models for matrix input. It is designed to handle large input datasets as they typically occur in single cell RNA-seq experiments.", + "License": "GPL-3", + "Encoding": "UTF-8", + "SystemRequirements": "C++17", + "Suggests": [ + "testthat (>= 2.1.0)", + "zoo", + "DESeq2", + "edgeR", + "limma", + "MASS", + "statmod", + "ggplot2", + "bench", + "BiocParallel", + "knitr", + "rmarkdown", + "BiocStyle", + "TENxPBMCData", + "muscData", + "scran", + "dplyr" + ], + "LinkingTo": [ + "Rcpp", + "RcppArmadillo", + "beachmat", + "assorthead" + ], + "Imports": [ + "Rcpp", + "beachmat", + "DelayedMatrixStats", + "matrixStats", + "MatrixGenerics", + "SparseArray (>= 1.5.21)", + "S4Vectors", + "DelayedArray", + "HDF5Array", + "Matrix", + "SummarizedExperiment", + "SingleCellExperiment", + "BiocGenerics", + "methods", + "stats", + "utils", + "splines", + "rlang", + "vctrs" + ], + "Roxygen": "list(markdown = TRUE)", + "RoxygenNote": "7.3.2", + "URL": "https://github.com/const-ae/glmGamPoi", + "BugReports": "https://github.com/const-ae/glmGamPoi/issues", + "biocViews": "Regression, RNASeq, Software, SingleCell", + "VignetteBuilder": "knitr", + "git_url": "https://git.bioconductor.org/packages/glmGamPoi", + "git_branch": "RELEASE_3_22", + "git_last_commit": "908cde9", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "yes", + "Author": "Constantin Ahlmann-Eltze [aut, cre] (ORCID: ), Nathan Lubock [ctb] (ORCID: ), Michael Love [ctb]", + "Maintainer": "Constantin Ahlmann-Eltze ", + "Depends": [ + "R (>= 4.1.0)" + ] + }, + "h5mread": { + "Package": "h5mread", + "Version": "1.2.1", + "Source": "Bioconductor", + "Title": "A fast HDF5 reader", + "Description": "The main function in the h5mread package is h5mread(), which allows reading arbitrary data from an HDF5 dataset into R, similarly to what the h5read() function from the rhdf5 package does. In the case of h5mread(), the implementation has been optimized to make it as fast and memory-efficient as possible.", + "biocViews": "Infrastructure, DataRepresentation, DataImport", + "URL": "https://bioconductor.org/packages/h5mread", + "BugReports": "https://github.com/Bioconductor/h5mread/issues", + "License": "Artistic-2.0", + "Encoding": "UTF-8", + "Authors@R": "person(\"Hervé\", \"Pagès\", role=c(\"aut\", \"cre\"), email=\"hpages.on.github@gmail.com\", comment=c(ORCID=\"0009-0002-8272-4522\"))", + "Depends": [ + "R (>= 4.5)", + "methods", + "rhdf5", + "BiocGenerics", + "SparseArray" + ], + "Imports": [ + "stats", + "tools", + "rhdf5filters", + "S4Vectors", + "IRanges", + "S4Arrays" + ], + "LinkingTo": [ + "Rhdf5lib", + "S4Vectors" + ], + "SystemRequirements": "GNU make", + "Suggests": [ + "BiocParallel", + "ExperimentHub", + "TENxBrainData", + "HDF5Array", + "testthat", + "knitr", + "rmarkdown", + "BiocStyle" + ], + "VignetteBuilder": "knitr", + "Collate": "utils.R h5dim.R H5File-class.R h5ls.R H5DSetDescriptor-class.R uaselection.R h5mread.R h5summarize.R h5mread_from_reshaped.R h5dimscales.R h5writeDimnames.R zzz.R", + "git_url": "https://git.bioconductor.org/packages/h5mread", + "git_branch": "RELEASE_3_22", + "git_last_commit": "b377076", + "git_last_commit_date": "2025-11-21", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "yes", + "Author": "Hervé Pagès [aut, cre] (ORCID: )", + "Maintainer": "Hervé Pagès " + }, + "rhdf5": { + "Package": "rhdf5", + "Version": "2.54.1", + "Source": "Bioconductor", + "Type": "Package", + "Title": "R Interface to HDF5", + "Authors@R": "c( person(\"Bernd\", \"Fischer\", role = \"aut\"), person( \"Mike\", \"Smith\", role= \"aut\", comment = c(ORCID = \"0000-0002-7800-3848\", \"Maintainer from 2017 to 2025\") ), person(\"Gregoire\", \"Pau\", role=\"aut\"), person(\"Martin\", \"Morgan\", role = \"ctb\"), person(\"Daniel\", \"van Twisk\", role = \"ctb\"), person( \"Hugo\", \"Gruson\", role = \"cre\", email = \"hugo.gruson@embl.de\", comment = c(ORCID = \"0000-0002-4094-1476\") ) )", + "Description": "This package provides an interface between HDF5 and R. HDF5's main features are the ability to store and access very large and/or complex datasets and a wide variety of metadata on mass storage (disk) through a completely portable file format. The rhdf5 package is thus suited for the exchange of large and/or complex datasets between R and other software package, and for letting R applications work on datasets that are larger than the available RAM.", + "License": "Artistic-2.0", + "URL": "https://github.com/Huber-group-EMBL/rhdf5", + "BugReports": "https://github.com/Huber-group-EMBL/rhdf5/issues", + "LazyLoad": "true", + "VignetteBuilder": "knitr", + "Imports": [ + "Rhdf5lib (>= 1.13.4)", + "rhdf5filters (>= 1.15.5)" + ], + "Depends": [ + "R (>= 4.0.0)", + "methods" + ], + "Suggests": [ + "bit64", + "BiocStyle", + "knitr", + "rmarkdown", + "testthat", + "bench", + "dplyr", + "ggplot2", + "mockery", + "BiocParallel" + ], + "LinkingTo": [ + "Rhdf5lib" + ], + "SystemRequirements": "GNU make", + "biocViews": "Infrastructure, DataImport", + "Encoding": "UTF-8", + "Roxygen": "list(markdown = TRUE)", + "RoxygenNote": "7.3.3", + "git_url": "https://git.bioconductor.org/packages/rhdf5", + "git_branch": "RELEASE_3_22", + "git_last_commit": "7f691e4", + "git_last_commit_date": "2025-12-02", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "yes", + "Author": "Bernd Fischer [aut], Mike Smith [aut] (ORCID: , Maintainer from 2017 to 2025), Gregoire Pau [aut], Martin Morgan [ctb], Daniel van Twisk [ctb], Hugo Gruson [cre] (ORCID: )", + "Maintainer": "Hugo Gruson " + }, + "rhdf5filters": { + "Package": "rhdf5filters", + "Version": "1.22.0", + "Source": "Bioconductor", + "Type": "Package", + "Title": "HDF5 Compression Filters", + "Authors@R": "c( person(\"Mike\", \"Smith\", role = c(\"aut\", \"ccp\"), comment = c(ORCID = \"0000-0002-7800-3848\") ), person(\"Hugo\", \"Gruson\", role = \"cre\", email = \"hugo.gruson@embl.de\", comment = c(ORCID = \"0000-0002-4094-1476\") ) )", + "Description": "Provides a collection of additional compression filters for HDF5 datasets. The package is intended to provide seemless integration with rhdf5, however the compiled filters can also be used with external applications.", + "License": "BSD_2_clause + file LICENSE", + "LazyLoad": "true", + "VignetteBuilder": "knitr", + "Suggests": [ + "BiocStyle", + "knitr", + "rmarkdown", + "tinytest", + "rhdf5 (>= 2.47.7)" + ], + "SystemRequirements": "GNU make", + "URL": "https://github.com/Huber-group-EMBL/rhdf5filters", + "BugReports": "https://github.com/Huber-group-EMBL/rhdf5filters/issues", + "LinkingTo": [ + "Rhdf5lib" + ], + "RoxygenNote": "7.3.2", + "Encoding": "UTF-8", + "biocViews": "Infrastructure, DataImport", + "git_url": "https://git.bioconductor.org/packages/rhdf5filters", + "git_branch": "RELEASE_3_22", + "git_last_commit": "3465c24", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "yes", + "Author": "Mike Smith [aut, ccp] (ORCID: ), Hugo Gruson [cre] (ORCID: )", + "Maintainer": "Hugo Gruson " + }, + "sparseMatrixStats": { + "Package": "sparseMatrixStats", + "Version": "1.22.0", + "Source": "Bioconductor", + "Type": "Package", + "Title": "Summary Statistics for Rows and Columns of Sparse Matrices", + "Authors@R": "person(\"Constantin\", \"Ahlmann-Eltze\", email = \"artjom31415@googlemail.com\", role = c(\"aut\", \"cre\"), comment = c(ORCID = \"0000-0002-3762-068X\"))", + "Description": "High performance functions for row and column operations on sparse matrices. For example: col / rowMeans2, col / rowMedians, col / rowVars etc. Currently, the optimizations are limited to data in the column sparse format. This package is inspired by the matrixStats package by Henrik Bengtsson.", + "License": "MIT + file LICENSE", + "Encoding": "UTF-8", + "RoxygenNote": "7.3.1", + "LinkingTo": [ + "Rcpp" + ], + "Imports": [ + "Rcpp", + "Matrix", + "matrixStats (>= 0.60.0)", + "methods" + ], + "Depends": [ + "MatrixGenerics (>= 1.5.3)" + ], + "Suggests": [ + "testthat (>= 2.1.0)", + "knitr", + "bench", + "rmarkdown", + "BiocStyle" + ], + "SystemRequirements": "C++11", + "VignetteBuilder": "knitr", + "URL": "https://github.com/const-ae/sparseMatrixStats", + "BugReports": "https://github.com/const-ae/sparseMatrixStats/issues", + "biocViews": "Infrastructure, Software, DataRepresentation", + "git_url": "https://git.bioconductor.org/packages/sparseMatrixStats", + "git_branch": "RELEASE_3_22", + "git_last_commit": "3acf348", + "git_last_commit_date": "2025-10-29", + "Repository": "Bioconductor 3.22", + "NeedsCompilation": "yes", + "Author": "Constantin Ahlmann-Eltze [aut, cre] (ORCID: )", + "Maintainer": "Constantin Ahlmann-Eltze " } } } diff --git a/src/MetaManifold.jl b/src/MetaManifold.jl index e3c70714..31db2a5c 100644 --- a/src/MetaManifold.jl +++ b/src/MetaManifold.jl @@ -5,6 +5,8 @@ include("core/types.jl") include("core/r_runtime.jl") # Provenance probes R through the shared runtime lock, so it follows r_runtime.jl. include("core/provenance.jl") +# Tools registers the children it starts with the probe, so it precedes pipeline/. +include("core/memory_probe.jl") include("core/log.jl") include("core/config.jl") include("core/databases.jl") diff --git a/src/analysis/analysis.jl b/src/analysis/analysis.jl index 029d57b7..4b0ccfba 100644 --- a/src/analysis/analysis.jl +++ b/src/analysis/analysis.jl @@ -15,7 +15,8 @@ export sample_columns, filtered_counts, filtered_df, taxonomy_levels, taxon_colu faceted_bar_chart, natural_less, natural_sort, short_sample_labels, pinned_segment_order, alpha_boxplot, nmds_chart, - run_nmds, run_permanova, r_available, + run_nmds, nmds_warnings, run_permanova, r_available, + AlphaSignificance, was_computed, venn_taxa_present ## DuckDB query helpers for analysis @@ -905,20 +906,85 @@ function _has_sample_pairs(labels::Vector{String}, sample_ids::Vector{String}) !isempty(reduce(intersect, sets)) end -_no_significance() = (nothing, DataFrame(group1=String[], group2=String[], p=Float64[])) +## Why a significance result carries a status rather than only a p-value +# +# "The test did not run" and "the test ran and found nothing" are different +# scientific claims, and the shape this once returned - `(nothing, empty +# DataFrame)` - could not tell them apart. Four distinct conditions collapsed onto +# that single value: R/vegan being absent, there being no common sample IDs to +# pair, the statistic itself erroring to `NA_real_`, and a genuine result in which +# no pair reached significance. Only the last is a finding. Rendering any of the +# other three the way a null result is rendered publishes a negative result that +# was never computed. (A busy R runtime is not among them: `RBusyError` +# propagates to the server, which reports the chart as unavailable.) +# +# `status` is therefore the primary field and the p-value is subordinate to it: +# `:computed` is the only status under which these numbers may be read as +# evidence. `reason` carries the wording shown to whoever is looking at the chart. +""" + AlphaSignificance + +Outcome of an alpha-diversity significance test: a `status` saying whether the +test ran, the omnibus p-value and pairwise table when it did, and a `reason` +shown to the reader when it did not. +""" +struct AlphaSignificance + status :: Symbol + omnibus :: Union{Float64,Nothing} + pairwise :: DataFrame + reason :: String +end + +"""Zero-row pairwise table with the columns `group1`, `group2`, `p`.""" +_empty_pairwise() = DataFrame(group1=String[], group2=String[], p=Float64[]) + +"""Wrap a test that actually ran: the only status whose numbers are evidence.""" +_computed(omnibus::Union{Float64,Nothing}, pairwise::DataFrame) = + AlphaSignificance(:computed, omnibus, pairwise, "") + +"""Record that no test result exists, with the `status` and reader-facing `reason`.""" +_not_computed(status::Symbol, reason::AbstractString; + pairwise::DataFrame=_empty_pairwise()) = + AlphaSignificance(status, nothing, pairwise, String(reason)) + +""" + was_computed(r::AlphaSignificance) -> Bool -# Waits for the R runtime like the other R analyses. While a pipeline run holds -# it, RBusyError reaches the server, which reports the chart as unavailable -# until the run completes. +Whether `r` holds an actual test result. False means no test was performed, so +neither `omnibus` nor `pairwise` may be presented as a finding. +""" +was_computed(r::AlphaSignificance) = r.status === :computed + +""" +Run the omnibus (and optionally pairwise) alpha-diversity test, returning an +`AlphaSignificance` whose status says whether a test was actually performed. + +Waits for the R runtime like the other R analyses. While a pipeline run holds +it, `RBusyError` reaches the server, which reports the chart as unavailable +until the run completes. +""" function _alpha_significance(values::Vector{Float64}, labels::Vector{String}, sample_ids::Vector{String}; pairwise::Bool=false, paired_samples::Bool=false) - _ensure_r() || return _no_significance() + _ensure_r() || return _not_computed(:r_unavailable, + "R/vegan is not available, so no significance test was performed") _alpha_significance_r(values, labels, sample_ids; pairwise, paired_samples) end +""" + _significance_caption(r, test_label) -> String + +The omnibus caption drawn on a panel. When the test did not run this says so in +words rather than printing "n/a" beside a test name, which reads as a result. +""" +function _significance_caption(r::AlphaSignificance, test_label::AbstractString) + was_computed(r) || return "$test_label not run
$(r.reason)" + "$test_label $(_significance_stars(r.omnibus))
$(_format_p_value(r.omnibus))" +end + +"""R side of `_alpha_significance`, run under the R lock once vegan is loaded.""" function _alpha_significance_r(values::Vector{Float64}, labels::Vector{String}, sample_ids::Vector{String}; @@ -932,7 +998,8 @@ function _alpha_significance_r(values::Vector{Float64}, [values[i] for i in eachindex(labels) if labels[i] == label], ) for label in groups_u) common_ids = reduce(intersect, [Set(keys(m)) for m in Base.values(paired_maps)]) - isempty(common_ids) && return nothing, DataFrame(group1=String[], group2=String[], p=Float64[]) + isempty(common_ids) && return _not_computed(:no_paired_samples, + "the groups share no sample IDs, so no paired test could be performed") common = sort(collect(common_ids)) RCall.globalEnv[:paired_groups] = groups_u @@ -1004,18 +1071,45 @@ function _alpha_significance_r(values::Vector{Float64}, pairwise_df = DataFrame(RCall.rcopy(RCall.reval("pairwise_df"))) RCall.reval("rm(values, groups, do_pairwise, groups_f, overall_p, pairwise_df); gc()") end - overall_p = ismissing(p_value) ? nothing : Float64(p_value) - overall_p, pairwise_df + # R returns `NA_real_` from its own `tryCatch` when the statistic cannot be + # computed at all (a degenerate group, say). That is a failed test, not a + # non-significant one, so it keeps whatever pairwise rows did come back but + # never claims an omnibus result. + if ismissing(p_value) + return _not_computed(:test_failed, + "the omnibus statistic could not be computed for these groups"; + pairwise=pairwise_df) + end + _computed(Float64(p_value), pairwise_df) end end +""" +Draw significance brackets for each pairwise result on one panel, or a single +"not run" notice when `significance` holds no computed result. +""" function _add_pairwise_annotations!(layout::Dict{String,Any}, xaxis_key::String, yaxis_ref::String, yaxis_layout_key::String, group_labels::Vector{String}, values_by_label::Dict{String, Vector{Float64}}, - pairwise_df::DataFrame) + significance::AlphaSignificance) + # Drawing no brackets is how "no pair reached significance" looks, so a test + # that never ran must say so instead of borrowing that appearance. + if !was_computed(significance) + annotations = get!(layout, "annotations", Any[]) + push!(annotations, Dict{String,Any}( + "xref" => "$xaxis_key domain", "yref" => "$yaxis_ref domain", + "x" => 0.5, "y" => 1.0, + "xanchor" => "center", "yanchor" => "bottom", + "text" => "Pairwise tests not run - $(significance.reason)", + "showarrow" => false, + "font" => Dict("size" => 10, "color" => "#b45309"), + )) + return + end + pairwise_df = significance.pairwise nrow(pairwise_df) == 0 && return all_vals = reduce(vcat, values(values_by_label); init=Float64[]) @@ -1087,7 +1181,7 @@ function alpha_boxplot(groups::AbstractVector{<:Tuple{String, Vector{String}, Ab ] traces = Any[] panel_annotations = Dict{Int, Vector{Dict{String,Any}}}() - panel_pairwise = Dict{Int, DataFrame}() + panel_pairwise = Dict{Int, AlphaSignificance}() for (pi, (yax, xax, _, _, _, panel_idx)) in enumerate(panels) panel_labels = String[] panel_values = Float64[] @@ -1139,9 +1233,9 @@ function alpha_boxplot(groups::AbstractVector{<:Tuple{String, Vector{String}, Ab if length(unique(panel_labels)) >= 2 && significance_test == "kruskal_wallis" need_pairwise = pairwise_brackets paired = paired_samples && _has_sample_pairs(panel_labels, panel_sample_ids) - p_value, pairwise_df = _alpha_significance(panel_values, panel_labels, panel_sample_ids; - pairwise=need_pairwise, - paired_samples=paired) + significance = _alpha_significance(panel_values, panel_labels, panel_sample_ids; + pairwise=need_pairwise, + paired_samples=paired) if annotate_significance anns = get!(panel_annotations, panel_idx, Dict{String,Any}[]) # Anchor to this panel's axis domain so the label remaps with its @@ -1150,12 +1244,13 @@ function alpha_boxplot(groups::AbstractVector{<:Tuple{String, Vector{String}, Ab "xref" => "$xax domain", "yref" => "$yax domain", "x" => 0.98, "y" => 0.98, "xanchor" => "right", "yanchor" => "top", - "text" => "$(paired ? (length(unique(panel_labels)) == 2 ? "Paired Wilcoxon" : "Friedman") : "KW") $(_significance_stars(p_value))
$(_format_p_value(p_value))", + "text" => _significance_caption(significance, + paired ? (length(unique(panel_labels)) == 2 ? "Paired Wilcoxon" : "Friedman") : "KW"), "showarrow" => false, "align" => "right", )) end - pairwise_brackets && (panel_pairwise[panel_idx] = pairwise_df) + pairwise_brackets && (panel_pairwise[panel_idx] = significance) end end layout = Dict{String,Any}( @@ -1287,6 +1382,37 @@ end r_available() = _ensure_r() +""" + nmds_warnings(mat, stress) -> Vector{String} + +Reasons an NMDS of the samples-by-features `mat` does not show meaningful +distances. Two samples that share no feature have a Bray-Curtis dissimilarity +of 1, the largest there is, so when the samples fall into groups that share +none, the distances between groups carry no information and the ordination +places those groups arbitrarily (`vegan::distconnected` finds the same groups). +Stress near zero is reported otherwise, usually a sign of too little data. +""" +function nmds_warnings(mat::AbstractMatrix{<:Real}, stress::Real) + n = size(mat, 1) + parent = collect(1:n) + root(i) = parent[i] == i ? i : (parent[i] = root(parent[i])) + for j in axes(mat, 2) + present = findall(>(0), view(mat, :, j)) + for i in present[2:end] + parent[root(i)] = root(present[1]) + end + end + groups = length(unique(root(i) for i in 1:n)) + if groups > 1 + return ["NMDS: the samples fall into $groups groups that share no taxa, so the " * + "distances between those groups are arbitrary. Ordinate each group separately."] + elseif isfinite(stress) && stress < 1e-3 + return ["NMDS: stress is near zero ($(round(stress; sigdigits=2))), which usually " * + "means too few samples or taxa for a meaningful ordination."] + end + String[] +end + """ run_nmds(mat; seed=123, transform="none") -> (coords::Matrix{Float64}, stress::Float64) @@ -1328,14 +1454,38 @@ function run_nmds(mat::Matrix{Float64}; seed::Integer=123, transform::String="no end end +""" + _varies_within_any_block(values, blocks) -> Bool + +Whether `values` takes at least two distinct values among the samples of some +block in `blocks` (one label per sample). +""" +function _varies_within_any_block(values::AbstractVector, blocks::AbstractVector) + first_seen = Dict{eltype(blocks),eltype(values)}() + for (v, b) in zip(values, blocks) + isequal(get!(first_seen, b, v), v) || return true + end + false +end + """ run_permanova(mat, metadata; seed=123, transform="none", blocks=nothing) -> Union{NamedTuple, Nothing} -PERMANOVA via vegan::adonis2 with 999 permutations. +PERMANOVA via vegan::adonis2 with up to 999 permutations. `metadata` is a DataFrame with one row per sample and covariate columns. +Each covariate is tested as its own term (`by = "terms"`), sequentially in +column order, so each row is conditioned on the terms before it. The top-level +`term`, `r2`, `f_statistic` and `p_value` are those of the first term. `blocks`, one label per sample (e.g. the individual a sample came from), restricts permutations to within blocks. It is ignored when no label repeats, -since singleton blocks admit no permutation at all. +since singleton blocks admit no permutation at all. A term that is constant +within every block cannot be tested by within-block permutation (each sample +never leaves its block, so the permutations never exchange that term's labels +and the p-value does not test it), so its p-value is withheld and +`untestable_reason` says why. +`permutations` is the number of permutations actually performed, which is fewer +than 999 when the blocks admit fewer, and `min_p_value` the smallest p-value +that number can produce. `transform` is applied before `vegdist`; see [`run_nmds`](@ref). """ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer=123, @@ -1348,6 +1498,10 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer= formula_rhs = join(covariates, " + ") mat = transform_counts(mat; method=transform) blocked = !isnothing(blocks) && length(blocks) == size(mat, 1) && !allunique(blocks) + # Within-block permutation never exchanges the labels of a term that is + # constant inside every block, so its p-value does not test that term. + untestable = blocked ? [c for c in covariates if !_varies_within_any_block(metadata[!, c], blocks)] : + String[] with_r_lock(; timeout=R_WAIT_SECONDS[]) do meta_r = copy(metadata) @@ -1357,6 +1511,7 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer= RCall.globalEnv[:seed] = Int(seed) RCall.globalEnv[:perm_blocks] = blocked ? String.(blocks) : String[] RCall.globalEnv[:disp_cols] = String.(covariates) + RCall.globalEnv[:perm_untestable] = String.(untestable) RCall.reval(""" set.seed(seed, kind = "Mersenne-Twister", normal.kind = "Inversion", sample.kind = "Rejection") dist_mat <- vegdist(mat, method = "bray") @@ -1368,12 +1523,25 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer= 999 } perm_err <- NULL + perm_n <- NA_integer_ perm_res <- tryCatch( - adonis2(form, data = meta_r, permutations = perm_ctrl, parallel = 1), - error = function(e) { perm_err <<- conditionMessage(e); NULL } - ) + # vegan 2.7 defaults to by = NULL, one test of the whole model. + adonis2(form, data = meta_r, permutations = perm_ctrl, by = "terms", parallel = 1), + error = function(e) { perm_err <<- conditionMessage(e); NULL }) if (!is.null(perm_res)) { - perm_text <- paste(capture.output(print(perm_res)), collapse = "\\n") + # permute enumerates every permutation instead of drawing 999 when + # the blocks admit fewer, which small blocks make likely; the + # control adonis2 used records how many it ran. + perm_n <- attr(perm_res, "control")[["nperm"]] + # Within-block permutation never exchanges such a term's labels + # between blocks, so its p-value does not test that term. + untestable_rows <- which(rownames(perm_res) %in% perm_untestable) + perm_res[untestable_rows, "Pr(>F)"] <- NA + perm_text <- paste(c(capture.output(print(perm_res)), + if (length(untestable_rows) > 0) + paste0("Pr(>F) withheld for ", paste(rownames(perm_res)[untestable_rows], collapse = ", "), + ": constant within every permutation block, so within-block permutation does not test it.")), + collapse = "\\n") perm_r2 <- perm_res\$R2[1] perm_f <- perm_res\$F[1] perm_p <- perm_res[["Pr(>F)"]][1] @@ -1411,6 +1579,7 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer= r2 = RCall.rcopy(RCall.reval("perm_r2")) f_stat = RCall.rcopy(RCall.reval("perm_f")) p_val = RCall.rcopy(RCall.reval("perm_p")) + n_perm = RCall.rcopy(RCall.reval("perm_n")) # adonis2 tests terms sequentially, so each row is conditioned on the terms above it. term_names = String.(vcat(RCall.rcopy(RCall.reval("perm_terms")))) term_r2 = vcat(RCall.rcopy(RCall.reval("perm_terms_r2"))) @@ -1425,20 +1594,32 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer= (; groups = [(; group = groups[k], mean = vcat(d[:mean])[k], median = vcat(d[:median])[k]) for k in eachindex(groups)], f_statistic = d[:f], df = [Int(d[:df1]), Int(d[:df2])], p_value = d[:p], text = String(d[:text])) end - RCall.reval("rm(mat, meta_r, formula_rhs, seed, perm_blocks, disp_cols, perm_ctrl, dist_mat, form, perm_res, perm_err, perm_text, perm_r2, perm_f, perm_p, perm_terms, perm_terms_r2, perm_terms_f, perm_terms_p, disp, disp_err); gc()") + RCall.reval("rm(mat, meta_r, formula_rhs, seed, perm_blocks, disp_cols, perm_untestable, perm_ctrl, dist_mat, form, perm_res, perm_err, perm_n, perm_text, perm_r2, perm_f, perm_p, perm_terms, perm_terms_r2, perm_terms_f, perm_terms_p, disp, disp_err); if (exists(\"untestable_rows\")) rm(untestable_rows); gc()") ismissing(txt) && return nothing # When R's adonis2 threw, txt is the error message and r2/f/p are missing. # Return a named tuple with :message so the route can distinguish and surface it. ismissing(r2) && return (; message=string(txt)) + reason(t) = t in untestable ? + "'$t' is constant within every permutation block, and permutations are restricted " * + "within blocks, so its labels are never exchanged and the p-value would not test it; " * + "a between-block term needs whole blocks permuted" : + nothing + withheld(t, p) = (t in untestable || ismissing(p)) ? nothing : p + terms = [(; term=term_names[k], + r2=term_r2[k], + f_statistic=ismissing(term_f[k]) ? nothing : term_f[k], + p_value=withheld(term_names[k], term_p[k]), + untestable_reason=reason(term_names[k])) for k in eachindex(term_names)] (; text=txt, + term=term_names[1], r2=r2, f_statistic=ismissing(f_stat) ? nothing : f_stat, - p_value=ismissing(p_val) ? nothing : p_val, - terms=[(; term=term_names[k], - r2=term_r2[k], - f_statistic=ismissing(term_f[k]) ? nothing : term_f[k], - p_value=ismissing(term_p[k]) ? nothing : term_p[k]) for k in eachindex(term_names)], + p_value=withheld(term_names[1], p_val), + untestable_reason=reason(term_names[1]), + terms, + permutations=Int(n_perm), + min_p_value=1 / (Int(n_perm) + 1), blocked, dispersion) end diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl index 5fad6b45..9d21d0f3 100644 --- a/src/analysis/differential.jl +++ b/src/analysis/differential.jl @@ -2,24 +2,42 @@ # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) ## Differential abundance -# Per-taxon negative-binomial GLMs comparing two groups of samples, fitted with -# MASS::glm.nb, with a library-size offset and Benjamini-Hochberg correction -# across the taxa that produced a p-value. +# Per-taxon models comparing two groups of samples, with Benjamini-Hochberg +# correction across the taxa that produced a p-value. Two methods: +# +# - nb_glm: a negative-binomial GLM per taxon (MASS::glm.nb) on the observed +# integer read counts, with a library-size offset. There are no pseudocounts. +# - clr_welch: Welch's two-sample t-test per taxon (stats::t.test) on centred +# log-ratios, over the taxa that pass `min_prevalence`, after their zeros are +# replaced by zCompositions::cmultRepl (Bayesian-multiplicative, for counts). +# The estimate is a difference in CLR units, not a fold change. # # Count matrices here are samples x taxa, as everywhere else in `Analysis`. -# The model is fitted on the observed integer read counts: there are no -# pseudocounts, and anything that is not a non-negative integer is refused -# rather than rounded. A taxon whose fit fails is reported with its reason and -# kept out of the multiple-testing family; it never receives a stand-in p-value. +# Anything that is not a non-negative integer count is refused rather than +# rounded. A taxon whose fit fails is reported with its reason and kept out of +# the multiple-testing family; it never receives a stand-in p-value. module Differential using DataFrames, RCall using ..RRuntime: with_r_lock using ..Analysis: _palette_hex, R_WAIT_SECONDS - -export DifferentialConfig, ScalingRefusal, MASSUnavailable, OFFSET_METHODS, - tss_factors, rle_factors, size_factors, bh_adjust, validate_counts, - fit_nb, differential_abundance, volcano_chart +export DifferentialConfig, ScalingRefusal, MASSUnavailable, ZCompositionsUnavailable, + GlmGamPoiUnavailable, OFFSET_METHODS, METHODS, DISPERSIONS, tss_factors, + rle_factors, size_factors, bh_adjust, validate_counts, replace_zeros, + clr_transform, fit_nb, fit_welch, differential_abundance, volcano_chart + +# Methods accepted under `analysis.differential.method`. +const METHODS = ("nb_glm", "clr_welch") + +# What `nb_glm` does and how it tests, accepted under +# `analysis.differential.dispersion`: `"glm_nb"` fits each taxon with +# MASS::glm.nb and its maximum-likelihood theta; `"glmGamPoi"` fits one +# glmGamPoi::glm_gp across the taxa and reports glmGamPoi::test_de's own +# quasi-likelihood F-test on that fit, with each taxon's dispersion shrunk +# toward the cross-taxon trend. The second shares information across taxa, +# which steadies low-count taxa and small studies. `"glmGamPoi"` is refused +# for `clr_welch`. +const DISPERSIONS = ("glm_nb", "glmGamPoi") # Offset (size factor) methods accepted under `analysis.differential.offset`. const OFFSET_METHODS = ("tss", "rle") @@ -30,24 +48,65 @@ const OFFSET_METHODS = ("tss", "rle") const THETA_UPPER = 1e7 const THETA_LOWER = 1e-8 +# zCompositions::cmultRepl's default `frac`, as in Martín-Fernández et al. (2003). +const DEFAULT_DELTA = 0.65 + """ - DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0) + DifferentialConfig(offset="tss", min_prevalence=0.0; method="nb_glm", + replacement_delta=0.65, dispersion="glm_nb") Settings for one differential abundance analysis, read from -`analysis.differential` in the pipeline config. `offset` names the size factor -method (`"tss"` or `"rle"`). `min_prevalence` is the fraction of samples, in -[0, 1], in which a taxon must have at least one read to be tested. +`analysis.differential` in the pipeline config. `method` is `"nb_glm"` or +`"clr_welch"`. `offset` names the size factor method (`"tss"` or `"rle"`) and is +used by `nb_glm` only: a library-size offset has no meaning for a test on +log-ratios, which are already free of library size. +`min_prevalence` is the fraction of samples, in [0, 1], in which a taxon must +have at least one read to be tested. `replacement_delta`, in (0, 1), is the +`frac` passed to `zCompositions::cmultRepl` by `clr_welch`: an imputed proportion +above its taxon's smallest observed proportion is lowered to this fraction of it. +`dispersion` names what `nb_glm` does and how it tests (`"glm_nb"` or +`"glmGamPoi"`): `"glm_nb"` (the default) fits `MASS::glm.nb` per taxon with +its maximum-likelihood theta and Wald-tests the group coefficient; +`"glmGamPoi"` fits one `glmGamPoi::glm_gp` across the taxa passing +`min_prevalence`, with overdispersion shrinkage toward the cross-taxon trend, +and reports `glmGamPoi::test_de`'s quasi-likelihood F-test of the group +contrast — glmGamPoi's own published inference (Lund et al., 2012) — with its +log2 fold changes. `"glmGamPoi"` is refused for `clr_welch`. +Method and offset names are stripped and lowercased; dispersion names are +stripped and matched without regard to case, then stored as `"glm_nb"` or +`"glmGamPoi"`. Throws `ArgumentError` for unsupported names, incompatible +method and dispersion choices, or non-finite or out-of-range fractions, +including settings unused by the selected method. """ struct DifferentialConfig - offset :: String - min_prevalence :: Float64 - function DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0) + method :: String + offset :: String + min_prevalence :: Float64 + replacement_delta :: Float64 + dispersion :: String + function DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0; + method::AbstractString="nb_glm", + replacement_delta::Real=DEFAULT_DELTA, + dispersion::AbstractString="glm_nb") + m = lowercase(strip(String(method))) + m in METHODS || throw(ArgumentError( + "analysis.differential.method must be one of $(join(METHODS, ", ")), not '$method'")) o = lowercase(strip(String(offset))) o in OFFSET_METHODS || throw(ArgumentError( "analysis.differential.offset must be one of $(join(OFFSET_METHODS, ", ")), not '$offset'")) + d = strip(String(dispersion)) + di = findfirst(s -> lowercase(s) == lowercase(d), DISPERSIONS) + di === nothing && throw(ArgumentError( + "analysis.differential.dispersion must be one of $(join(DISPERSIONS, ", ")), not '$dispersion'")) + d = DISPERSIONS[di] + (d == "glm_nb" || m == "nb_glm") || throw(ArgumentError( + "analysis.differential.dispersion is a negative-binomial setting, but method is " * + "'$m'; only nb_glm has a dispersion to choose")) (isfinite(min_prevalence) && 0 <= min_prevalence <= 1) || throw(ArgumentError( "analysis.differential.min_prevalence must be a fraction in [0, 1], not $min_prevalence")) - new(o, Float64(min_prevalence)) + (isfinite(replacement_delta) && 0 < replacement_delta < 1) || throw(ArgumentError( + "analysis.differential.replacement_delta must be in (0, 1), not $replacement_delta")) + new(m, o, Float64(min_prevalence), Float64(replacement_delta), d) end end @@ -77,6 +136,33 @@ Base.showerror(io::IO, ::MASSUnavailable) = print(io, "the R package MASS is not available, so negative-binomial models cannot be " * "fitted; install R with its recommended packages (MASS ships with them)") +""" + ZCompositionsUnavailable() + +Raised when the R package zCompositions, which provides `cmultRepl`, cannot be +loaded. +""" +struct ZCompositionsUnavailable <: Exception end + +Base.showerror(io::IO, ::ZCompositionsUnavailable) = print(io, + "the R package zCompositions is not available, so zeros cannot be replaced for " * + "clr_welch; restore the R library from renv.lock (Rscript -e 'renv::restore()')") + +""" + GlmGamPoiUnavailable() + +Raised when the R package glmGamPoi, which provides the shrunken dispersion +trend that `analysis.differential.dispersion: glmGamPoi` asks for, cannot be +loaded. There is no fallback to another dispersion estimator: the method is +refused outright. +""" +struct GlmGamPoiUnavailable <: Exception end + +Base.showerror(io::IO, ::GlmGamPoiUnavailable) = print(io, + "the R package glmGamPoi is not available, so the shrunken dispersion cannot be " * + "fitted; restore the pinned R library (Rscript -e 'renv::restore()') " * + "or use analysis.differential.dispersion: glm_nb") + """ _geomean(x) -> Float64 @@ -179,7 +265,7 @@ end Check that every entry of the samples x taxa matrix `counts` is a finite, non-negative integer. Throws an `ArgumentError` naming the first offending -sample and taxon: the model is defined on read counts, and a normalised, +sample and taxon: the models are defined on read counts, and a normalised, rarefied-and-averaged or pseudocounted value would be silently misfitted. """ function validate_counts(counts::AbstractMatrix{<:Real}, samples::AbstractVector{<:AbstractString}, @@ -190,7 +276,7 @@ function validate_counts(counts::AbstractMatrix{<:Real}, samples::AbstractVector x = counts[i, j] (isfinite(x) && x >= 0 && isinteger(x)) || throw(ArgumentError( "the count for taxon '$(taxa[j])' in sample '$(samples[i])' is $x: the " * - "negative-binomial model needs non-negative integer read counts")) + "models are defined on non-negative integer read counts")) end nothing end @@ -205,58 +291,144 @@ da_result <- data.frame(status = rep("ok", da_n), note = rep("", da_n), estimate = rep(NA_real_, da_n), se = rep(NA_real_, da_n), statistic = rep(NA_real_, da_n), pvalue = rep(NA_real_, da_n), theta = rep(NA_real_, da_n), stringsAsFactors = FALSE) -for (da_j in seq_len(da_n)) { - da_y <- da_counts[, da_j] - if (length(unique(da_y)) < 2L) { - da_result[da_j, "status"] <- "failed" - da_result[da_j, "note"] <- "the counts are constant across samples: there is nothing to estimate" - next - } - da_warns <- character(0) - da_fit <- tryCatch( - withCallingHandlers( - MASS::glm.nb(da_y ~ da_g + offset(da_offset), control = glm.control(maxit = 100)), - warning = function(w) { - da_warns <<- c(da_warns, conditionMessage(w)) - invokeRestart("muffleWarning") - }), +# "glmGamPoi": one glmGamPoi::glm_gp fit across all taxa supplies each taxon's +# "glmGamPoi": one glmGamPoi::glm_gp fit across all taxa, with each taxon's +# overdispersion shrunk toward the cross-taxon trend, supplies glmGamPoi's own +# published inference -- glmGamPoi::test_de's quasi-likelihood F-test of the +# group coefficient. Its genes x samples convention, so +# the counts are transposed, and size_factors takes the linear size factor +# (the per-taxon fits below use its log as their offset). A fit- or test-level +# error fails every taxon. No fallback to another dispersion estimator or +# significance test is permitted. +da_glp <- NULL +da_glp_failed <- "" +if (da_use_glp) { + da_gp <- tryCatch( + glmGamPoi::glm_gp(t(da_counts), design = model.matrix(~ da_g), + size_factors = exp(da_offset), overdispersion_shrinkage = TRUE), error = function(e) e) - if (inherits(da_fit, "error")) { - da_result[da_j, "status"] <- "failed" - da_result[da_j, "note"] <- paste("glm.nb stopped with an error:", conditionMessage(da_fit)) - next + if (inherits(da_gp, "error")) { + da_glp_failed <- paste("glm_gp stopped with an error:", conditionMessage(da_gp)) + } else { + da_glp <- tryCatch( + glmGamPoi::test_de(da_gp, contrast = da_term, max_lfc = Inf, compute_lfc_se = TRUE), + error = function(e) e) + if (inherits(da_glp, "error")) { + da_glp_failed <- paste("test_de stopped with an error:", conditionMessage(da_glp)) + da_glp <- NULL + } else if (nrow(da_glp) != da_n) { + da_glp_failed <- "test_de returned no row for each taxon" + da_glp <- NULL + } } - da_warns <- unique(da_warns) - if (!isTRUE(da_fit[["converged"]]) || !is.null(da_fit[["th.warn"]]) || - any(grepl("iteration limit|alternation limit|did not converge|NaNs produced", da_warns))) { +} +for (da_j in seq_len(da_n)) { + if (nzchar(da_glp_failed)) { da_result[da_j, "status"] <- "failed" - da_result[da_j, "note"] <- paste("the fit did not converge:", - paste(c(da_fit[["th.warn"]], da_warns), collapse = "; ")) + da_result[da_j, "note"] <- da_glp_failed next } - da_co <- summary(da_fit)[["coefficients"]] - if (!(da_term %in% rownames(da_co))) { + da_y <- da_counts[, da_j] + if (length(unique(da_y)) < 2L) { da_result[da_j, "status"] <- "failed" - da_result[da_j, "note"] <- "the group coefficient is not estimable (aliased)" + da_result[da_j, "note"] <- "the counts are constant across samples: there is nothing to estimate" next } - da_row <- da_co[da_term, ] - if (!all(is.finite(da_row)) || da_row[[4]] < 0 || da_row[[4]] > 1) { + # Complete separation: with no reads in every sample of one group the group + # coefficient runs off to infinity and its test loses its footing. That p is + # not evidence of no difference, so the taxon is refused with its reason. + da_absent <- levels(da_g)[vapply(levels(da_g), function(l) all(da_y[da_g == l] == 0), logical(1))] + if (length(da_absent) > 0L) { da_result[da_j, "status"] <- "failed" - da_result[da_j, "note"] <- "the fit returned a non-finite estimate, standard error or p-value" + da_result[da_j, "note"] <- paste0("no reads in any '", da_absent[[1]], "' sample (complete ", + "separation): the fold change is unbounded and its test ", + "is undefined, so no p-value is reported") next } - da_theta <- da_fit[["theta"]] - da_result[da_j, c("estimate", "se", "statistic", "pvalue")] <- unname(da_row[1:4]) - da_result[da_j, "theta"] <- da_theta - if (!is.finite(da_theta) || da_theta >= da_theta_upper) { - da_result[da_j, "status"] <- "boundary" - da_result[da_j, "note"] <- "the dispersion parameter theta reached its upper bound: these counts show no overdispersion, so the fit is effectively Poisson" - } else if (da_theta <= da_theta_lower) { - da_result[da_j, "status"] <- "boundary" - da_result[da_j, "note"] <- "the dispersion parameter theta reached its lower bound: the variance is extreme relative to the mean" - } else if (length(da_warns) > 0L) { - da_result[da_j, "note"] <- paste("R warned:", paste(da_warns, collapse = "; ")) + if (da_use_glp) { + # glmGamPoi's own test on its own fit. `lfc` is the log2 fold change of + # the group contrast and `lfc_se` its standard error; both are converted + # to the natural-log scale so every method reports one effect scale. The + # reported theta is the inverse of glm_gp's negative-binomial + # overdispersion for the taxon (`overdispersions`: variance = mu + mu^2 * + # overdispersion), the same parameter glm.nb's theta inverts. It is not + # the quasi-likelihood scale the F-test divides by. Zero overdispersion + # is the Poisson boundary: the row is marked, as under glm_nb, and keeps + # its statistics, since the F-test's denominator is the shrunken + # quasi-likelihood dispersion and stays defined. An unusable + # overdispersion fails only its own taxon. + da_od <- da_gp[["overdispersions"]][da_j] + if (!is.finite(da_od) || da_od < 0) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- "the negative-binomial overdispersion for this taxon is not usable" + next + } + da_est <- da_glp[["lfc"]][da_j] * log(2) + da_se <- da_glp[["lfc_se"]][da_j] * log(2) + da_stat <- da_glp[["f_statistic"]][da_j] + da_p <- da_glp[["pval"]][da_j] + if (!is.finite(da_est) || !is.finite(da_se) || !is.finite(da_stat) || + !is.finite(da_p) || da_p < 0 || da_p > 1) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- "the fit returned a non-finite estimate, standard error or p-value" + next + } + da_result[da_j, c("estimate", "se", "statistic", "pvalue", "theta")] <- + c(da_est, da_se, da_stat, da_p, 1 / da_od) + if (da_od == 0) { + da_result[da_j, "status"] <- "boundary" + da_result[da_j, "note"] <- "the negative-binomial overdispersion is zero: these counts show no overdispersion, so the fit is effectively Poisson and theta is unbounded" + } + } else { + # "glm_nb" estimates theta per taxon inside MASS::glm.nb's alternating + # fit, then Wald-tests the group coefficient at fixed scale 1, which is + # summary.negbin's own default. + da_warns <- character(0) + da_fit <- tryCatch( + withCallingHandlers( + MASS::glm.nb(da_y ~ da_g + offset(da_offset), control = glm.control(maxit = 100)), + warning = function(w) { + da_warns <<- c(da_warns, conditionMessage(w)) + invokeRestart("muffleWarning") + }), + error = function(e) e) + if (inherits(da_fit, "error")) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- paste("glm.nb stopped with an error:", conditionMessage(da_fit)) + next + } + da_warns <- unique(da_warns) + if (!isTRUE(da_fit[["converged"]]) || !is.null(da_fit[["th.warn"]]) || + any(grepl("iteration limit|alternation limit|did not converge|NaNs produced", da_warns))) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- paste("the fit did not converge:", + paste(c(da_fit[["th.warn"]], da_warns), collapse = "; ")) + next + } + da_co <- summary(da_fit, dispersion = 1)[["coefficients"]] + if (!(da_term %in% rownames(da_co))) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- "the group coefficient is not estimable (aliased)" + next + } + da_row <- da_co[da_term, ] + if (!all(is.finite(da_row)) || da_row[[4]] < 0 || da_row[[4]] > 1) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- "the fit returned a non-finite estimate, standard error or p-value" + next + } + da_theta <- da_fit[["theta"]] + da_result[da_j, c("estimate", "se", "statistic", "pvalue")] <- unname(da_row[1:4]) + da_result[da_j, "theta"] <- da_theta + if (!is.finite(da_theta) || da_theta >= da_theta_upper) { + da_result[da_j, "status"] <- "boundary" + da_result[da_j, "note"] <- "the dispersion parameter theta reached its upper bound: these counts show no overdispersion, so the fit is effectively Poisson" + } else if (da_theta <= da_theta_lower) { + da_result[da_j, "status"] <- "boundary" + da_result[da_j, "note"] <- "the dispersion parameter theta reached its lower bound: the variance is extreme relative to the mean" + } else if (length(da_warns) > 0L) { + da_result[da_j, "note"] <- paste("R warned:", paste(da_warns, collapse = "; ")) + } } } """ @@ -269,36 +441,73 @@ A finite number as `Float64`, or `nothing` for R's NA, NaN or an infinity. _num(x) = (ismissing(x) || isnothing(x) || !isfinite(x)) ? nothing : Float64(x) """ - fit_nb(counts, groups, offset; levels) -> DataFrame + fit_nb(counts, groups, offset; levels, dispersion="glm_nb") -> DataFrame + +Fit a negative-binomial GLM with a log link to each column of the samples x +taxa integer matrix `counts`. `offset` contains the natural logarithm of each +sample's size factor. `levels` is `(reference, contrast)`, so the estimate is +the natural-log fold change of `contrast` over `reference`. +`dispersion` names what the fits do and how they test: `"glm_nb"` (the +default) takes `MASS::glm.nb`'s maximum-likelihood theta per taxon and +Wald-tests the group coefficient at fixed scale 1; `"glmGamPoi"` fits one +`glmGamPoi::glm_gp` across all supplied taxa with overdispersion shrinkage +toward the cross-taxon trend and reports `glmGamPoi::test_de`'s +quasi-likelihood F-test of the group contrast — its `lfc` (log2 fold change) +and `lfc_se`, both converted to the natural-log scale so the columns carry one +effect scale. In that path `statistic` is the quasi-likelihood F, `pvalue` +its p-value, and `theta` the inverse of `glm_gp`'s negative-binomial +overdispersion for the taxon (its `overdispersions` element), the same +parameter `MASS::glm.nb`'s theta inverts; it is `nothing` where that +overdispersion is zero. Names are stripped and matched without regard to +case. -Fit `MASS::glm.nb(y ~ group + offset(offset))` to each column of the samples x -taxa integer matrix `counts`. `levels` is `(reference, contrast)`, so the -estimate is the natural-log fold change of `contrast` over `reference`. Returns one row per taxon with `status` (`ok`, `boundary` or `failed`), `note`, `estimate`, `se`, `statistic`, `pvalue` and `theta`; a failed taxon has -`nothing` for every statistic. Throws `MASSUnavailable` when MASS cannot be -loaded and `RBusyError` when the R runtime stays busy. +`nothing` for every statistic. Boundary fits retain their statistics: under +`"glm_nb"` theta at or above 1e7 marks the Poisson end and theta at or below +1e-8 marks extreme variance; under `"glmGamPoi"` a zero negative-binomial +overdispersion marks the Poisson boundary, and the row keeps its statistics, +since the F-test divides by the shrunken quasi-likelihood dispersion, which +stays defined there. Constant counts, complete separation and +unusable coefficients produce failed rows. Errors caught from the model-fitting +calls also produce failed rows; a caught `glm_gp` or `test_de` error fails every +taxon, with no fallback to `"glm_nb"`. + +Throws `ArgumentError` for an unsupported dispersion name or mismatched +sample, group and offset lengths, `MASSUnavailable` when MASS cannot be loaded, +`GlmGamPoiUnavailable` when `"glmGamPoi"` is selected and its package cannot +be loaded, and `RBusyError` when the R runtime stays busy. R errors outside the +caught model-fitting calls propagate. """ function fit_nb(counts::AbstractMatrix{<:Integer}, groups::AbstractVector{<:AbstractString}, - offset::AbstractVector{<:Real}; levels::NTuple{2,String}) + offset::AbstractVector{<:Real}; levels::NTuple{2,String}, + dispersion::AbstractString="glm_nb") + di = findfirst(s -> lowercase(s) == lowercase(strip(String(dispersion))), DISPERSIONS) + di === nothing && throw(ArgumentError( + "dispersion must be one of $(join(DISPERSIONS, ", ")), not '$dispersion'")) + use_glp = DISPERSIONS[di] == "glmGamPoi" size(counts, 1) == length(groups) == length(offset) || throw(ArgumentError( "counts have $(size(counts, 1)) samples, groups $(length(groups)), offset $(length(offset))")) raw = with_r_lock(; timeout=R_WAIT_SECONDS[]) do RCall.rcopy(RCall.reval("requireNamespace('MASS', quietly = TRUE)")) || throw(MASSUnavailable()) + use_glp && !RCall.rcopy(RCall.reval("requireNamespace('glmGamPoi', quietly = TRUE)")) && + throw(GlmGamPoiUnavailable()) RCall.globalEnv[:da_counts] = Matrix{Int}(counts) RCall.globalEnv[:da_group] = String.(groups) RCall.globalEnv[:da_levels] = collect(levels) RCall.globalEnv[:da_offset] = Float64.(offset) RCall.globalEnv[:da_theta_upper] = THETA_UPPER RCall.globalEnv[:da_theta_lower] = THETA_LOWER + RCall.globalEnv[:da_use_glp] = use_glp try RCall.reval(_FIT_R) DataFrame(RCall.rcopy(RCall.reval("da_result"))) finally RCall.reval("rm(list = intersect(ls(), c('da_counts', 'da_group', 'da_levels', " * - "'da_offset', 'da_theta_upper', 'da_theta_lower', 'da_g', 'da_term', " * - "'da_n', 'da_result', 'da_j', 'da_y', 'da_warns', 'da_fit', 'da_co', " * - "'da_row', 'da_theta'))); invisible(gc())") + "'da_offset', 'da_theta_upper', 'da_theta_lower', 'da_use_glp', 'da_g', " * + "'da_term', 'da_n', 'da_result', 'da_glp', 'da_glp_failed', 'da_gp', " * + "'da_j', 'da_y', 'da_absent', 'da_od', 'da_est', 'da_se', 'da_stat', " * + "'da_p', 'da_warns', 'da_fit', 'da_co', 'da_row', 'da_theta'))); invisible(gc())") end end DataFrame(status = String.(raw.status), @@ -310,23 +519,270 @@ function fit_nb(counts::AbstractMatrix{<:Integer}, groups::AbstractVector{<:Abst theta = _num.(raw.theta)) end +""" + clr_transform(x) -> Matrix{Float64} + +Centred log-ratio of each row (sample) of the samples x taxa matrix `x`: +`log(x_ij)` minus the mean of `log(x_i·)`, so every row sums to zero. Every +value must be positive; replace zeros first. +""" +function clr_transform(x::AbstractMatrix{<:Real}) + all(v -> isfinite(v) && v > 0, x) || throw(ArgumentError( + "the centred log-ratio needs positive finite values; replace zeros first")) + l = log.(Float64.(x)) + l .- sum(l; dims=2) ./ size(l, 2) +end + +# zCompositions::cmultRepl on the taxa that will be tested. z.warning = 1 turns +# off its own sparsity screen, and z.delete = FALSE stops it from dropping a +# row or column: either would silently change the taxa or misalign the samples +# with their groups. Which taxa are sparse enough to leave out is decided by +# `min_prevalence` before this runs. Errors and warnings are captured, not raised. +const _REPLACE_R = raw""" +da_warns <- character(0) +da_out <- tryCatch( + withCallingHandlers( + as.matrix(zCompositions::cmultRepl(da_counts, label = 0, method = "GBM", output = "prop", + frac = da_frac, z.warning = 1, z.delete = FALSE, + suppress.print = TRUE)), + warning = function(w) { + da_warns <<- c(da_warns, conditionMessage(w)) + invokeRestart("muffleWarning") + }), + error = function(e) conditionMessage(e)) +""" + +""" + _check_compositions(out, zero_mask, samples) -> out + +Check that cmultRepl's output `out` is one valid composition per sample: the +same shape as the input whose zeros `zero_mask` marks, and every row finite, +positive and summing to 1. Throws `ArgumentError` naming the first sample that +fails, pointing at `min_prevalence`. +""" +function _check_compositions(out::AbstractMatrix{<:Real}, zero_mask::AbstractMatrix{Bool}, + samples::AbstractVector{<:AbstractString}) + size(out) == size(zero_mask) || error( + "cmultRepl returned a $(size(out)) table for $(size(zero_mask)) input; a row or column was dropped") + for i in axes(out, 1) + row = view(out, i, :) + if !all(v -> isfinite(v) && v > 0, row) || abs(sum(row) - 1) > 1e-8 + throw(ArgumentError( + "zero replacement left sample '$(samples[i])' without a valid composition: " * + "its $(count(view(zero_mask, i, :))) replaced zeros would take the whole sample. " * + "Raise analysis.differential.min_prevalence so fewer rare taxa are tested")) + end + end + out +end + +""" + replace_zeros(counts; frac=DEFAULT_DELTA, samples, taxa) -> NamedTuple + +Replace the zeros of the samples x taxa read-count matrix `counts` with +`zCompositions::cmultRepl(counts, label = 0, method = "GBM", output = "prop", +frac = frac)`, the geometric Bayesian-multiplicative replacement for count +data. Returns `(proportions, zeros_replaced, imputed_mass, warnings)`: +`proportions` has the same shape as `counts`, each row summing to 1 with no +zero left; `imputed_mass` is the share of each sample's total held by replaced +values; `warnings` is whatever R warned. + +The caller chooses the taxa: everything passed in is replaced and kept, none is +dropped. Throws `ArgumentError` when there are fewer than 2 taxa, when a taxon +has reads in fewer than 2 samples (the GBM prior is then undefined), when a +sample has no reads, or when cmultRepl refuses or returns a value that is not a +positive proportion; the messages name the taxon or sample and point at +`min_prevalence`. Throws `ZCompositionsUnavailable` when zCompositions cannot +be loaded and `RBusyError` when the R runtime stays busy. +""" +function replace_zeros(counts::AbstractMatrix{<:Real}; + frac::Real=DEFAULT_DELTA, + samples::AbstractVector{<:AbstractString}=["sample $i" for i in axes(counts, 1)], + taxa::AbstractVector{<:AbstractString}=["taxon $j" for j in axes(counts, 2)]) + size(counts) == (length(samples), length(taxa)) || throw(ArgumentError( + "the table is $(size(counts)) but there are $(length(samples)) samples and $(length(taxa)) taxa")) + size(counts, 2) >= 2 || throw(ArgumentError( + "the centred log-ratio needs at least 2 taxa; there are $(size(counts, 2))")) + n = size(counts, 1) + sparse = [j for j in axes(counts, 2) if count(>(0), view(counts, :, j)) < 2] + isempty(sparse) || throw(ArgumentError( + "$(length(sparse)) taxa have reads in fewer than 2 samples, so cmultRepl's " * + "Bayesian-multiplicative prior cannot be estimated for them (" * + join(("'$(taxa[j])'" for j in first(sparse, 5)), ", ") * + (length(sparse) > 5 ? ", ..." : "") * "); raise analysis.differential.min_prevalence " * + "to at least $(round(2 / n; digits=3)) so that only taxa seen in 2 or more samples are tested")) + for i in axes(counts, 1) + sum(view(counts, i, :)) > 0 || throw(ArgumentError( + "sample '$(samples[i])' has no reads in the $(size(counts, 2)) taxa that pass " * + "min_prevalence, so it has no composition to transform")) + end + + zero_mask = counts .== 0 + n_zero = count(zero_mask) + out, warns = with_r_lock(; timeout=R_WAIT_SECONDS[]) do + RCall.rcopy(RCall.reval("requireNamespace('zCompositions', quietly = TRUE)")) || + throw(ZCompositionsUnavailable()) + # cmultRepl stops when there is no zero to replace; the closure of the + # data is then the whole answer. + n_zero == 0 && return (Float64.(counts) ./ sum(counts; dims=2), String[]) + RCall.globalEnv[:da_counts] = Matrix{Float64}(counts) + RCall.globalEnv[:da_frac] = Float64(frac) + try + RCall.reval(_REPLACE_R) + res = RCall.rcopy(RCall.reval("da_out")) + res isa AbstractString && throw(ArgumentError( + "zCompositions::cmultRepl refused the table: $res. Raising " * + "analysis.differential.min_prevalence leaves fewer, better-observed taxa")) + (Matrix{Float64}(res), String.(RCall.rcopy(Vector{String}, RCall.reval("da_warns")))) + finally + RCall.reval("rm(list = intersect(ls(), c('da_counts', 'da_frac', 'da_out', 'da_warns'))); " * + "invisible(gc())") + end + end + _check_compositions(out, zero_mask, samples) + mass = [sum(out[i, j] for j in axes(out, 2) if zero_mask[i, j]; init=0.0) for i in axes(out, 1)] + (proportions=out, zeros_replaced=n_zero, imputed_mass=mass, warnings=unique(warns)) +end + +# The per-taxon Welch t-test loop, with the same `da_` naming and statuses as +# `_FIT_R` but no `boundary`: a t-test has no dispersion parameter to run to a bound. +const _FIT_WELCH_R = raw""" +da_g <- factor(da_group, levels = da_levels) +da_n <- ncol(da_values) +da_result <- data.frame(status = rep("ok", da_n), note = rep("", da_n), + estimate = rep(NA_real_, da_n), se = rep(NA_real_, da_n), + statistic = rep(NA_real_, da_n), pvalue = rep(NA_real_, da_n), + stringsAsFactors = FALSE) +for (da_j in seq_len(da_n)) { + da_y <- da_values[, da_j] + if (length(unique(da_y)) < 2L) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- "the values are constant across samples: there is nothing to estimate" + next + } + da_warns <- character(0) + da_tt <- tryCatch( + withCallingHandlers( + stats::t.test(da_y[da_g == da_levels[2]], da_y[da_g == da_levels[1]], var.equal = FALSE), + warning = function(w) { + da_warns <<- c(da_warns, conditionMessage(w)) + invokeRestart("muffleWarning") + }), + error = function(e) e) + if (inherits(da_tt, "error")) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- paste("t.test stopped with an error:", conditionMessage(da_tt)) + next + } + da_row <- c(unname(da_tt[["estimate"]][1] - da_tt[["estimate"]][2]), unname(da_tt[["stderr"]]), + unname(da_tt[["statistic"]]), da_tt[["p.value"]]) + if (!all(is.finite(da_row)) || da_row[[2]] <= 0 || da_row[[4]] < 0 || da_row[[4]] > 1) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- paste(c("the test returned a non-finite or zero standard error, so there is no test (both groups have zero variance)", + unique(da_warns)), collapse = "; ") + next + } + da_result[da_j, c("estimate", "se", "statistic", "pvalue")] <- da_row + if (length(da_warns) > 0L) { + da_result[da_j, "note"] <- paste("R warned:", paste(unique(da_warns), collapse = "; ")) + } +} +""" + +""" + fit_welch(values, groups; levels) -> DataFrame + +Welch's two-sample t-test, `stats::t.test(contrast, reference, var.equal = +FALSE)`, on each column of the samples x taxa matrix `values` (for `clr_welch`, +centred log-ratios). `levels` is `(reference, contrast)`, so the estimate is +the mean of `contrast` minus the mean of `reference`; the variances of the two +groups are not assumed equal, since the groups often differ in size. Returns +one row per taxon with `status` (`ok` or `failed`), `note`, `estimate`, `se`, +`statistic` (Welch's t) and `pvalue`; a failed taxon has `nothing` for every +statistic. Throws `RBusyError` when the R runtime stays busy. +""" +function fit_welch(values::AbstractMatrix{<:Real}, groups::AbstractVector{<:AbstractString}; + levels::NTuple{2,String}) + size(values, 1) == length(groups) || throw(ArgumentError( + "values have $(size(values, 1)) samples and groups $(length(groups))")) + raw = with_r_lock(; timeout=R_WAIT_SECONDS[]) do + RCall.globalEnv[:da_values] = Matrix{Float64}(values) + RCall.globalEnv[:da_group] = String.(groups) + RCall.globalEnv[:da_levels] = collect(levels) + try + RCall.reval(_FIT_WELCH_R) + DataFrame(RCall.rcopy(RCall.reval("da_result"))) + finally + RCall.reval("rm(list = intersect(ls(), c('da_values', 'da_group', 'da_levels', " * + "'da_g', 'da_n', 'da_result', 'da_j', 'da_y', 'da_warns', 'da_tt', " * + "'da_row'))); invisible(gc())") + end + end + DataFrame(status = String.(raw.status), + note = String.(raw.note), + estimate = _num.(raw.estimate), + se = _num.(raw.se), + statistic = _num.(raw.statistic), + pvalue = _num.(raw.pvalue)) +end + +""" + _row(taxon, prevalence, note) -> Dict{String,Any} + +A results row with no statistics, as for a taxon that was not fitted. +""" +_row(taxon, prevalence, note) = Dict{String,Any}( + "taxon" => String(taxon), "status" => "filtered", "note" => note, + "estimate" => nothing, "log2_fold_change" => nothing, + "standard_error" => nothing, "statistic" => nothing, + "pvalue" => nothing, "padj" => nothing, "dispersion_theta" => nothing, + "prevalence" => prevalence) + """ differential_abundance(counts, samples, taxa, groups; reference, contrast, config=DifferentialConfig()) -> Dict{String,Any} Test every taxon (column) of the samples x taxa read-count matrix for a difference in abundance between the samples labelled `contrast` and those -labelled `reference` in `groups`. - -Size factors are computed on the full matrix by `config.offset` and enter each -model as `log(factor)`. Taxa present in fewer than `config.min_prevalence` of -the samples are reported as `filtered` and not fitted. The Benjamini-Hochberg +labelled `reference` in `groups`, by `config.method`. + +`nb_glm`: size factors are computed on the full matrix by `config.offset` and +enter each negative-binomial model as `log(factor)`; the estimate is a +natural-log fold change. `config.dispersion` selects the fits and the test: +`"glm_nb"` (per-taxon `MASS::glm.nb` with its maximum-likelihood theta) or +`"glmGamPoi"` (one `glmGamPoi::glm_gp` fit across the taxa passing the +prevalence filter, with overdispersion shrinkage, and `glmGamPoi::test_de`'s +quasi-likelihood F-test of the group contrast). The result's `config` +includes the selected dispersion name. + +`clr_welch`, in this order: (1) `min_prevalence` chooses the taxa, and taxa with +no reads in any sample are always left out; (2) the zeros of those taxa only +are replaced by `zCompositions::cmultRepl` (GBM, `frac = +config.replacement_delta`), so the rare taxa that are not tested contribute no +imputed values; (3) each sample is transformed to centred log-ratios over the +same taxa; (4) each taxon is tested by Welch's t-test. Replacing zeros across +every observed taxon first would let imputed values for rare taxa take over +the samples of a sparse table. The estimate is the difference in mean CLR, +contrast minus reference: a difference in centred-log-ratio units, not a fold +change, relative to the geometric mean of the tested taxa. Replaced zeros are +not measurements; `diagnostics.zero_replacement` reports their share of each +sample. Each group needs at least 2 samples. + +For both methods, samples with no reads are left out before anything else and +named in `diagnostics.samples_without_reads`; they have no library size and no +composition. Taxa present in fewer than `config.min_prevalence` of the +samples are reported as `filtered` and not fitted, and the Benjamini-Hochberg family is the fitted taxa that produced a p-value; failed and filtered taxa -carry `padj = nothing`. - -Throws `ArgumentError` for unusable input, `ScalingRefusal` when the offset -cannot be computed, `MASSUnavailable` when MASS is missing, and an -`ErrorException` when no taxon could be fitted at all. +carry `padj = nothing`. The result's `effect` names the row key that holds the +effect size and its label. + +Throws `ArgumentError` for unusable input (including a zero replacement that +cannot be made), `ScalingRefusal` when the offset cannot be computed, +`MASSUnavailable`, `GlmGamPoiUnavailable` or `ZCompositionsUnavailable` when a +required R package is missing, `RBusyError` when the R runtime stays busy, and +an `ErrorException` when no taxon could be fitted at all, including when the +`glm_gp` fit fails. Uncaught R errors from the fitting and zero-replacement +helpers propagate. """ function differential_abundance(counts::AbstractMatrix{<:Real}, samples::AbstractVector{<:AbstractString}, @@ -343,43 +799,108 @@ function differential_abundance(counts::AbstractMatrix{<:Real}, unknown = setdiff(unique(groups), (reference, contrast)) isempty(unknown) || throw(ArgumentError( "samples belong to groups other than '$reference' and '$contrast': $(join(unknown, ", "))")) + # A sample with no reads (a blank emptied by primer trimming, say) has no + # library size and no composition, so neither method can use it. It is left + # out, and named in `diagnostics.samples_without_reads`, rather than letting + # one empty sample refuse the whole analysis. + has_reads = [sum(view(counts, i, :)) > 0 for i in axes(counts, 1)] + without_reads = String.(samples[.!has_reads]) + if !isempty(without_reads) + counts, samples, groups = counts[has_reads, :], samples[has_reads], groups[has_reads] + end n_ref = count(==(reference), groups) n_con = count(==(contrast), groups) + dropped_note = isempty(without_reads) ? "" : + " after leaving out $(length(without_reads)) sample(s) with no reads ($(join(without_reads, ", ")))" (n_ref >= 1 && n_con >= 1) || throw(ArgumentError( - "each group needs at least one sample; '$reference' has $n_ref and '$contrast' has $n_con")) + "each group needs at least one sample; '$reference' has $n_ref and '$contrast' has $n_con$dropped_note")) n_ref + n_con >= 3 || throw(ArgumentError( - "a group effect and a dispersion cannot be estimated from $(n_ref + n_con) samples; at least 3 are needed")) - - factors = size_factors(counts, samples, config.offset) - offset = log.(factors) + "a group effect and a variance cannot be estimated from $(n_ref + n_con) samples; at least 3 are needed$dropped_note")) n = length(samples) - prevalence = [count(>(0), view(counts, :, j)) / n for j in axes(counts, 2)] - tested = findall(>=(config.min_prevalence), prevalence) - - fits = isempty(tested) ? nothing : - fit_nb(Int.(counts[:, tested]), groups, offset; levels=(String(reference), String(contrast))) - - rows = [Dict{String,Any}( - "taxon" => String(taxa[j]), "status" => "filtered", - "note" => "present in $(count(>(0), view(counts, :, j))) of $n samples, " * - "below analysis.differential.min_prevalence = $(config.min_prevalence)", - "estimate" => nothing, "log2_fold_change" => nothing, - "standard_error" => nothing, "statistic" => nothing, - "pvalue" => nothing, "padj" => nothing, "dispersion_theta" => nothing, - "prevalence" => prevalence[j]) + present = [count(>(0), view(counts, :, j)) for j in axes(counts, 2)] + prevalence = present ./ n + rows = [_row(taxa[j], prevalence[j], + "present in $(present[j]) of $n samples, below " * + "analysis.differential.min_prevalence = $(config.min_prevalence)") for j in axes(counts, 2)] - if !isnothing(fits) + levels = (String(reference), String(contrast)) + diagnostics = Dict{String,Any}("samples_without_reads" => without_reads) + + if config.method == "nb_glm" + factors = size_factors(counts, samples, config.offset) + tested = findall(>=(config.min_prevalence), prevalence) + fits = isempty(tested) ? nothing : + fit_nb(Int.(counts[:, tested]), groups, log.(factors); levels, + dispersion=config.dispersion) + if !isnothing(fits) + for (k, j) in enumerate(tested) + f = fits[k, :] + merge!(rows[j], Dict{String,Any}( + "status" => f.status, "note" => f.note, "estimate" => f.estimate, + "log2_fold_change" => isnothing(f.estimate) ? nothing : f.estimate / log(2), + "standard_error" => f.se, "statistic" => f.statistic, + "pvalue" => f.pvalue, "dispersion_theta" => f.theta)) + end + end + nb_fit = config.dispersion == "glm_nb" ? "MASS::glm.nb" : "glmGamPoi::glm_gp" + if config.dispersion == "glm_nb" + method_text = "Negative-binomial GLM per taxon ($nb_fit, log link) with a " * + "$(uppercase(config.offset)) size-factor offset; Wald test of the group " * + "coefficient; Benjamini-Hochberg adjustment over the fitted taxa" + else + method_text = "Negative-binomial GLM per taxon ($nb_fit, log link) with a " * + "$(uppercase(config.offset)) size-factor offset; quasi-likelihood F-test " * + "of the group contrast (glmGamPoi::test_de) with overdispersion shrinkage; " * + "Benjamini-Hochberg adjustment over the fitted taxa" + end + effect = Dict("key" => "log2_fold_change", "label" => "log2 fold change") + size_factor_rows = [Dict("sample" => String(samples[i]), "group" => String(groups[i]), + "factor" => factors[i]) for i in eachindex(samples)] + config_echo = Dict{String,Any}("method" => config.method, "offset" => config.offset, + "min_prevalence" => config.min_prevalence, + "dispersion" => config.dispersion) + else + (n_ref >= 2 && n_con >= 2) || throw(ArgumentError( + "Welch's t-test needs at least 2 samples in each group to estimate its variance; " * + "'$reference' has $n_ref and '$contrast' has $n_con$dropped_note")) + for j in findall(==(0), present) + rows[j]["note"] = "no reads in any sample of either group, so it is not part of the composition" + end + tested = findall(j -> present[j] > 0 && prevalence[j] >= config.min_prevalence, axes(counts, 2)) + length(tested) >= 2 || throw(ArgumentError( + "the centred log-ratio needs at least 2 taxa with reads at or above " * + "analysis.differential.min_prevalence = $(config.min_prevalence); there are $(length(tested))")) + replaced = replace_zeros(counts[:, tested]; frac=config.replacement_delta, + samples, taxa=taxa[tested]) + clr = clr_transform(replaced.proportions) + fits = fit_welch(clr, groups; levels) for (k, j) in enumerate(tested) f = fits[k, :] - est = f.estimate merge!(rows[j], Dict{String,Any}( - "status" => f.status, "note" => f.note, - "estimate" => est, - "log2_fold_change" => isnothing(est) ? nothing : est / log(2), + "status" => f.status, "note" => f.note, "estimate" => f.estimate, "standard_error" => f.se, "statistic" => f.statistic, - "pvalue" => f.pvalue, "dispersion_theta" => f.theta)) + "pvalue" => f.pvalue)) end + method_text = "Welch's two-sample t-test per taxon (stats::t.test, var.equal = FALSE) on " * + "centred log-ratios over the taxa passing min_prevalence, after their zeros " * + "were replaced by zCompositions::cmultRepl (GBM, frac = $(config.replacement_delta)); " * + "Benjamini-Hochberg adjustment over the fitted taxa. Estimates are " * + "differences in CLR units, not fold changes, and replaced zeros are not measurements" + effect = Dict("key" => "estimate", "label" => "CLR difference") + size_factor_rows = Dict{String,Any}[] + config_echo = Dict{String,Any}("method" => config.method, + "min_prevalence" => config.min_prevalence, + "replacement_delta" => config.replacement_delta) + sorted_mass = sort(replaced.imputed_mass) + diagnostics["zero_replacement"] = Dict{String,Any}( + "method" => "zCompositions::cmultRepl (GBM)", + "delta" => config.replacement_delta, "zeros_replaced" => replaced.zeros_replaced, + "n_taxa_in_composition" => length(tested), + "n_taxa_unobserved" => count(==(0), present), + "median_imputed_fraction" => _median(sorted_mass), + "max_imputed_fraction" => last(sorted_mass), + "warnings" => replaced.warnings) end family = findall(r -> r["status"] in ("ok", "boundary") && !isnothing(r["pvalue"]), rows) @@ -396,21 +917,19 @@ function differential_abundance(counts::AbstractMatrix{<:Real}, isnothing(r["pvalue"]) ? 2.0 : r["pvalue"], r["taxon"])) n_failed = count(r -> r["status"] == "failed", rows) - n_boundary = count(r -> r["status"] == "boundary", rows) - n_filtered = count(r -> r["status"] == "filtered", rows) + merge!(diagnostics, Dict{String,Any}( + "n_taxa" => length(taxa), "n_tested" => length(family), "n_failed" => n_failed, + "n_boundary" => count(r -> r["status"] == "boundary", rows), + "n_filtered" => count(r -> r["status"] == "filtered", rows))) Dict{String,Any}( "status" => n_failed == 0 ? "ok" : "partial", - "method" => "Negative-binomial GLM per taxon (MASS::glm.nb, log link) with a " * - "$(uppercase(config.offset)) size-factor offset; Wald test of the group " * - "coefficient; Benjamini-Hochberg adjustment over the fitted taxa", + "method" => method_text, + "effect" => effect, "groups" => Dict("reference" => String(reference), "contrast" => String(contrast)), "n_samples" => Dict(String(reference) => n_ref, String(contrast) => n_con), - "size_factors" => [Dict("sample" => String(samples[i]), "group" => String(groups[i]), - "factor" => factors[i]) for i in eachindex(samples)], - "config" => Dict("offset" => config.offset, "min_prevalence" => config.min_prevalence), - "diagnostics" => Dict("n_taxa" => length(taxa), "n_tested" => length(family), - "n_failed" => n_failed, "n_boundary" => n_boundary, - "n_filtered" => n_filtered), + "size_factors" => size_factor_rows, + "config" => config_echo, + "diagnostics" => diagnostics, "rows" => rows, ) end @@ -418,14 +937,16 @@ end """ volcano_chart(result; alpha=0.05) -> Dict -A Plotly volcano plot of a `differential_abundance` result: log2 fold change -against -log10 of the raw p-value, coloured by whether the BH-adjusted p-value -is below `alpha`. Taxa without a p-value are omitted here; they remain in the -results table with their reason. +A Plotly volcano plot of a `differential_abundance` result: the effect named by +`result["effect"]` (log2 fold change, or CLR difference) against -log10 of the +raw p-value, coloured by whether the BH-adjusted p-value is below `alpha`. Taxa +without a p-value are omitted here; they remain in the results table with their +reason. """ function volcano_chart(result::AbstractDict; alpha::Real=0.05) ref = result["groups"]["reference"] con = result["groups"]["contrast"] + effect = get(result, "effect", Dict("key" => "log2_fold_change", "label" => "log2 fold change")) fitted = filter(r -> !isnothing(r["padj"]), result["rows"]) colours = _palette_hex(2) traces = Any[] @@ -440,14 +961,14 @@ function volcano_chart(result::AbstractDict; alpha::Real=0.05) r["pvalue"] == 0 ? "
p underflowed to 0" : "") for r in sel] push!(traces, Dict{String,Any}( "type" => "scatter", "mode" => "markers", "name" => name, - "x" => [r["log2_fold_change"] for r in sel], "y" => y, + "x" => [r[effect["key"]] for r in sel], "y" => y, "text" => text, "hoverinfo" => "text+x+y", "marker" => Dict("color" => colour, "size" => 8), )) end layout = Dict{String,Any}( "title" => Dict("text" => "Differential abundance: $con vs $ref"), - "xaxis" => Dict("title" => "log2 fold change ($con / $ref)", "zeroline" => true), + "xaxis" => Dict("title" => "$(effect["label"]) ($con vs $ref)", "zeroline" => true), "yaxis" => Dict("title" => "-log10 p"), ) Dict("data" => traces, "layout" => layout) diff --git a/src/core/duckdb_store.jl b/src/core/duckdb_store.jl index bb4461a2..bc02d59d 100644 --- a/src/core/duckdb_store.jl +++ b/src/core/duckdb_store.jl @@ -184,6 +184,10 @@ function load_results_db(merge_dir::String; if !isnothing(tagging) _apply_tagging!(con, tagging) end + # Write the WAL into the file now. Closing checkpoints only when no + # other handle is still open, and a database renamed with its data + # still in the WAL arrives empty: the WAL keeps its .building name. + DBInterface.execute(con, "FORCE CHECKPOINT") ok = true finally DBInterface.close!(con) @@ -191,6 +195,9 @@ function load_results_db(merge_dir::String; if ok rm(db_path * ".wal"; force=true) mv(tmp_path, db_path; force=true) + # Should the checkpoint not have emptied it, the WAL travels with + # its database, where the next open replays it. + isfile(tmp_path * ".wal") && mv(tmp_path * ".wal", db_path * ".wal"; force=true) else rm(tmp_path; force=true) rm(tmp_path * ".wal"; force=true) diff --git a/src/core/log.jl b/src/core/log.jl index 1e9c4359..4aeda74c 100644 --- a/src/core/log.jl +++ b/src/core/log.jl @@ -177,22 +177,33 @@ export pipeline_log, log_written, reset_tool_logs, log_command const _TOOL_SECTION_CLOSE = "--- End of tool logs ---" # Drop any tool-log section a previous finalise_log left behind, keeping every - # timestamped narrative line before it. Without this the section is appended - # afresh on each re-run, and a run directory that has been re-run k times carries - # k copies of every tool log (fastqc.log for a 300-sample run is not small). - # The narrative IS a ledger and must accrete; the tool-log dump is a snapshot of - # the current files and must not. + # timestamped narrative line before and after it. Without this the section is + # appended afresh on each re-run, and a run directory that has been re-run k + # times carries k copies of every tool log (fastqc.log for a 300-sample run is + # not small). The narrative IS a ledger and must accrete; the tool-log dump is a + # snapshot of the current files and must not. A re-run's narrative lands after + # the previous section, so only the section itself, open marker to close + # marker, is removed; a section never closed (finalise_log interrupted) runs + # to the end of the file. function _strip_tool_section!(dir::String) path = joinpath(dir, "pipeline.log") isfile(path) || return nothing lines = readlines(path) - cut = findfirst(l -> endswith(l, _TOOL_SECTION_OPEN), lines) - isnothing(cut) && return nothing - open(path, "w") do io - for l in lines[1:cut-1] - println(io, l) + any(l -> endswith(l, _TOOL_SECTION_OPEN), lines) || return nothing + kept = String[] + inside = false + for l in lines + if inside + endswith(l, _TOOL_SECTION_CLOSE) && (inside = false) + elseif endswith(l, _TOOL_SECTION_OPEN) + inside = true + else + push!(kept, l) end end + open(path, "w") do io + foreach(l -> println(io, l), kept) + end nothing end diff --git a/src/core/memory_probe.jl b/src/core/memory_probe.jl new file mode 100644 index 00000000..6406c37a --- /dev/null +++ b/src/core/memory_probe.jl @@ -0,0 +1,343 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) + +module MemoryProbe + +## Occupancy probe +# Measures what an attested stage occupied, for the stage's Attestation record: +# +# * Julia-side allocation, as the `Base.gc_num()` delta over the stage. This is +# a cost: it only ever adds up, whatever order the stage's work ran in. +# * Each child process's resident memory, sampled from /proc. The tools run as +# child processes (vsearch, swarm, cd-hit, ...), so `Sys.maxrss()` of this +# process never sees them. +# +# Two figures come from the samples. A task's `peak_rss_kib` is the largest VmRSS +# sampled for the processes that task started. The stage's `child_peak_rss_kib` is +# the largest SUM of VmRSS over every process of the stage at one sampling +# instant. For two tasks run one after the other it is the larger of their peaks; +# for two that run concurrently it can approach their sum. That order dependence, +# invisible in every output file, is what the measurement exists to show. +# +# `hwm_kib` is the kernel's own high-water mark (VmHWM) for a task's processes, as +# last read before they exited. It is exact up to the last sample, so it is at +# least `peak_rss_kib`; growth after the last sample is not seen. A process that +# lives for less than one interval may not be sampled at all, which `samples: 0` +# reports rather than a misleading zero peak. +# +# The probe is off unless the run config sets `occupancy_probe.enabled: true`. +# Off, `start` returns `nothing` and every other entry point is a pass-through, +# so the stage records and the outputs are exactly what they were without it. +# +# Linux only (/proc). Elsewhere, and for stages offloaded with `remote:` (where +# the local child is an ssh client), the child figures do not describe the work. +# The allocation figure is process-wide: while the study loop runs several runs' +# pipelines at once, a stage's figure includes the others' allocations too. The +# child figures are not affected, since each stage samples only its own children. + +using Base.ScopedValues: ScopedValue, with +using OrderedCollections: OrderedDict + +const PROBE = "MemoryProbe 1" +const DEFAULT_INTERVAL_MS = 10 + +# One labelled unit of a stage's work (one spawned search, say) and the child +# processes it started. +mutable struct TaskTrace + label :: String + live :: Vector{Int} # pids started and not yet waited for + processes :: Int # every child started under this label + samples :: Int # samples that found at least one live process + peak_kib :: Int + hwm_kib :: Int +end +TaskTrace(label::AbstractString) = TaskTrace(String(label), Int[], 0, 0, 0, 0) + +mutable struct Probe + interval :: Float64 + lock :: ReentrantLock + tasks :: Vector{TaskTrace} + stage :: TaskTrace # children started outside any `task` + samples :: Int + peak_kib :: Int + gc_before :: Base.GC_Num + live_before :: Int + running :: Bool + sampler :: Union{Task,Nothing} + timer :: Union{Timer,Nothing} + record :: Union{OrderedDict{String,Any},Nothing} + error :: Union{String,Nothing} # why sampling stopped early, if it did + buf :: Vector{UInt8} # /proc reads; preallocated so a sample + path :: Vector{UInt8} # allocates nothing that would count as work +end + +const STAGE = ScopedValue{Union{Probe,Nothing}}(nothing) +const TASK = ScopedValue{Union{TaskTrace,Nothing}}(nothing) + +## Configuration +_section(cfg) = cfg isa AbstractDict ? get(cfg, "occupancy_probe", nothing) : nothing + +""" + enabled(run_cfg) -> Bool + +Whether the run config turns the probe on: `occupancy_probe.enabled: true`. Any +other value, or no `occupancy_probe` section at all, leaves it off. +""" +function enabled(cfg) + s = _section(cfg) + s isa AbstractDict && get(s, "enabled", false) === true +end + +""" + interval_seconds(run_cfg) -> Float64 + +The sampling interval, from `occupancy_probe.interval_ms` (default 10 ms, at +least 1 ms). +""" +function interval_seconds(cfg) + s = _section(cfg) + ms = s isa AbstractDict ? get(s, "interval_ms", DEFAULT_INTERVAL_MS) : DEFAULT_INTERVAL_MS + (ms isa Real && !(ms isa Bool) && ms >= 1) || (ms = DEFAULT_INTERVAL_MS) + Float64(ms) / 1000 +end + +## Reading /proc without allocating +# "/proc//status" into `path`, NUL-terminated. +function _status_path!(path::Vector{UInt8}, pid::Int) + k = 0 + for c in codeunits("/proc/"); k += 1; path[k] = c; end + nd = ndigits(pid); d = pid + for i in nd:-1:1 + path[k + i] = UInt8('0') + UInt8(d % 10); d ÷= 10 + end + k += nd + for c in codeunits("/status"); k += 1; path[k] = c; end + path[k + 1] = 0x00 + nothing +end + +# Bytes of /proc//status read into `buf`, or -1 if the process is gone. +function _read_status!(buf::Vector{UInt8}, path::Vector{UInt8}, pid::Int) + _status_path!(path, pid) + fd = GC.@preserve path ccall(:open, Cint, (Ptr{UInt8}, Cint), path, 0) + fd < 0 && return -1 + total = 0 + GC.@preserve buf while total < length(buf) + r = ccall(:read, Cssize_t, (Cint, Ptr{UInt8}, Csize_t), + fd, pointer(buf, total + 1), length(buf) - total) + r <= 0 && break + total += r + end + ccall(:close, Cint, (Cint,), fd) + total +end + +# The kB value of the line starting `key` (6 bytes, e.g. "VmRSS:"), or -1. +function _field_kib(buf::Vector{UInt8}, n::Int, key::NTuple{6,UInt8}) + i = 1 + while i <= n - 6 + if (i == 1 || buf[i - 1] == UInt8('\n')) && + buf[i] == key[1] && buf[i + 1] == key[2] && buf[i + 2] == key[3] && + buf[i + 3] == key[4] && buf[i + 4] == key[5] && buf[i + 5] == key[6] + j = i + 6 + while j <= n && (buf[j] == UInt8(' ') || buf[j] == UInt8('\t')); j += 1; end + v = 0 + while j <= n && UInt8('0') <= buf[j] <= UInt8('9') + v = 10v + Int(buf[j] - UInt8('0')); j += 1 + end + return v + end + i += 1 + end + -1 +end + +const VMRSS = Tuple(codeunits("VmRSS:")) +const VMHWM = Tuple(codeunits("VmHWM:")) + +## Sampling +# One sample: each live child's VmRSS and VmHWM, summed per task and over the stage. +function _sample!(p::Probe) + lock(p.lock) do + total = 0 + for t in p.tasks + total += _sample_task!(p, t) + end + total += _sample_task!(p, p.stage) + p.samples += 1 + p.peak_kib = max(p.peak_kib, total) + end + nothing +end + +function _sample_task!(p::Probe, t::TaskTrace) + isempty(t.live) && return 0 + rss = 0; seen = false + for pid in t.live + n = _read_status!(p.buf, p.path, pid) + n > 0 || continue + r = _field_kib(p.buf, n, VMRSS) + r < 0 && continue # a zombie has no memory lines + seen = true + rss += r + t.hwm_kib = max(t.hwm_kib, _field_kib(p.buf, n, VMHWM)) + end + seen || return 0 + t.samples += 1 + t.peak_kib = max(t.peak_kib, rss) + rss +end + +""" + start(run_cfg) -> Union{Probe,Nothing} + +A running probe for one stage, or `nothing` when the run config does not enable +it. Pass the result to `within` and then `finish!`. +""" +function start(cfg) + enabled(cfg) || return nothing + p = Probe(interval_seconds(cfg), ReentrantLock(), TaskTrace[], TaskTrace("(stage)"), + 0, 0, Base.gc_num(), Base.gc_live_bytes(), true, nothing, nothing, nothing, + nothing, Vector{UInt8}(undef, 8192), Vector{UInt8}(undef, 64)) + p.timer = Timer(0; interval = p.interval) + timer = p.timer + # A probe must never fail the stage it measures: a sampling error ends the + # sampling and is reported in the record instead. + p.sampler = Threads.@spawn begin + try + while p.running + _sample!(p) + isopen(timer) || break + wait(timer) + end + catch err + (err isa EOFError && !p.running) || (p.error = sprint(showerror, err)) + end + end + p +end + +""" + within(f, probe) + +Run `f()` with `probe` as the current stage's probe, so the children it starts +(directly or from tasks it spawns) are sampled. With `probe === nothing` this is +just `f()`. +""" +within(f, ::Nothing) = f() +within(f, p::Probe) = with(f, STAGE => p) + +""" + task(f, label) + +Run `f()` as one labelled unit of the current stage. Children it starts are +reported under `label`. Outside a probed stage this is just `f()`. +""" +function task(f, label::AbstractString) + p = STAGE[] + p === nothing && return f() + t = TaskTrace(label) + lock(() -> push!(p.tasks, t), p.lock) + with(f, TASK => t) +end + +# The trace a child started now belongs to, or nothing when nothing is probed. +function _current() + p = STAGE[] + p === nothing && return nothing, nothing + t = TASK[] + p, something(t, p.stage) +end + +""" + watch!(process) -> Union{Int,Nothing} + +Register a just-started child process with the current stage's probe, sample it +at once, and return its pid for `unwatch!`. Called by `Tools._run_killable`. +Returns `nothing`, having done nothing, when the stage is not probed. A child that +has already exited is counted but cannot be sampled. Never throws: a probe must +not fail the stage it measures. +""" +function watch!(proc::Base.Process) + p, t = _current() + p === nothing && return nothing + # `getpid` throws once the child has exited, so the pid is read now or never. + pid = try + Int(getpid(proc)) + catch + nothing + end + lock(p.lock) do + t.processes += 1 + pid === nothing || push!(t.live, pid) + end + pid === nothing || _sample!(p) + pid +end + +# A pipeline of commands: each of its processes, as one task's children. +function watch!(chain::Base.ProcessChain) + pids = Int[] + for proc in chain.processes + pid = watch!(proc) + pid === nothing || push!(pids, pid) + end + isempty(pids) ? nothing : pids +end + +# Anything else `run` can return is not sampled. +watch!(_) = nothing + +""" + unwatch!(pid) + +Stop sampling a child that has been waited for (the pid `watch!` returned), so +the pid cannot be mistaken for a later, unrelated process. A no-op for `nothing`. +""" +unwatch!(::Nothing) = nothing +unwatch!(pids::Vector{Int}) = foreach(unwatch!, pids) +function unwatch!(pid::Int) + p, t = _current() + p === nothing && return nothing + lock(p.lock) do + filter!(!=(pid), t.live) + end + nothing +end + +_task_record(t::TaskTrace) = OrderedDict{String,Any}( + "label" => t.label, + "processes" => t.processes, + "samples" => t.samples, + "peak_rss_kib" => t.peak_kib, + "hwm_kib" => t.hwm_kib, +) + +""" + finish!(probe) -> Union{OrderedDict,Nothing} + +Stop sampling and return the stage's `occupancy` record (the same record on every +later call). `nothing` for `nothing`. +""" +finish!(::Nothing) = nothing +function finish!(p::Probe) + p.record === nothing || return p.record + p.running = false + close(p.timer) + wait(p.sampler) + allocated = Base.GC_Diff(Base.gc_num(), p.gc_before).allocd + traces = [p.tasks; p.stage.processes > 0 ? [p.stage] : TaskTrace[]] + p.record = OrderedDict{String,Any}( + "probe" => PROBE, + "interval_ms" => round(Int, p.interval * 1000), + "samples" => p.samples, + "julia_allocated_bytes" => allocated, + "julia_live_bytes_delta" => Base.gc_live_bytes() - p.live_before, + "child_peak_rss_kib" => p.peak_kib, + "tasks" => Any[_task_record(t) for t in traces], + ) + p.error === nothing || (p.record["error"] = p.error) + p.record +end + +end # module MemoryProbe diff --git a/src/core/provenance.jl b/src/core/provenance.jl index aa232c06..b28912d4 100644 --- a/src/core/provenance.jl +++ b/src/core/provenance.jl @@ -818,11 +818,15 @@ _stage_index(att::Attestation, name::AbstractString) = findfirst(s -> get(s, "name", nothing) == String(name), stages(att)) """ - record_stage!(att, name, env; started, finished, status, commands, outputs) -> Attestation + record_stage!(att, name, env; started, finished, status, commands, outputs, occupancy) -> Attestation Record one stage's own captured environment, replacing any earlier record of that stage. +`occupancy` is the stage's `MemoryProbe` record. It is stored under the stage's +`occupancy` key only when given, so a stage recorded without the probe has +exactly the keys it always had. + Re-running a single stage of an existing run must update that stage alone, because a run directory is an accretion and the other stages' records remain true. The run-level summary is re-derived on every call. @@ -832,7 +836,8 @@ function record_stage!(att::Attestation, name::AbstractString, env::CapturedEnvi finished::AbstractString = timestamp(), status::AbstractString = "ok", commands::AbstractVector = String[], - outputs::AbstractVector = OrderedDict{String,Any}[]) + outputs::AbstractVector = OrderedDict{String,Any}[], + occupancy::Union{AbstractDict,Nothing} = nothing) stage = OrderedDict{String,Any}( "name" => String(name), "started" => String(started), @@ -847,6 +852,7 @@ function record_stage!(att::Attestation, name::AbstractString, env::CapturedEnvi "outputs" => Any[OrderedDict{String,Any}(string(k) => v for (k, v) in o) for o in outputs], ) + isnothing(occupancy) || (stage["occupancy"] = occupancy) index = _stage_index(att, name) isnothing(index) ? push!(stages(att), stage) : (stages(att)[index] = stage) return summarise!(att) diff --git a/src/core/r_runtime.jl b/src/core/r_runtime.jl index 1e25a7a8..5fca35e3 100644 --- a/src/core/r_runtime.jl +++ b/src/core/r_runtime.jl @@ -14,10 +14,22 @@ # stays live. module RRuntime -export RBusyError, with_r_lock, r_busy +import RCall + +export RBusyError, with_r_lock, r_busy, REQUIRED_R_PACKAGES, missing_r_packages const _R_LOCK = ReentrantLock() +# The R packages the app loads, each with what stops working without it. install.jl +# checks the same list after restoring (test_r_runtime.jl keeps the two equal). +const REQUIRED_R_PACKAGES = [ + "dada2" => "the DADA2 stages that run on this machine", + "vegan" => "NMDS, PERMANOVA and PERMDISP", + "glmGamPoi" => "shared dispersion-trend differential abundance", + "MASS" => "negative-binomial differential abundance", + "zCompositions" => "CLR differential abundance (clr_welch)", +] + # Thrown when a caller that declines to wait indefinitely gives up on the runtime. struct RBusyError <: Exception waited :: Float64 @@ -59,4 +71,30 @@ function with_r_lock(f::Function; timeout::Union{Real,Nothing}=nothing) end end +""" + missing_r_packages(packages=first.(REQUIRED_R_PACKAGES); timeout=nothing) -> Vector{String} + +The named packages that the embedded R cannot load, in the order given. + +Each is loaded, not merely looked up: a package can be listed in the library yet +fail to load, as when renv's links into an emptied cache dangle or a build no +longer matches the installed R. +""" +function missing_r_packages(packages::AbstractVector{<:AbstractString} = first.(REQUIRED_R_PACKAGES); + timeout::Union{Real,Nothing} = nothing) + isempty(packages) && return String[] + with_r_lock(; timeout) do + RCall.globalEnv[:mm_required_pkgs] = String.(packages) + try + missing = RCall.rcopy(RCall.reval(""" + paste(mm_required_pkgs[!vapply(mm_required_pkgs, function(p) + suppressWarnings(suppressMessages(requireNamespace(p, quietly = TRUE))), + logical(1))], collapse = "\\n")""")) + isempty(missing) ? String[] : String.(split(missing, '\n')) + finally + RCall.reval("rm(mm_required_pkgs)") + end + end +end + end # module RRuntime diff --git a/src/core/validate.jl b/src/core/validate.jl index a9a22b88..3a5cb1ba 100644 --- a/src/core/validate.jl +++ b/src/core/validate.jl @@ -7,7 +7,7 @@ module Validation export validate_environment, validate_project, ValidationError, DENOVO_METHODS, REMOTE_STAGES, DADA2_REMOTE_STAGES, PHYLOGENY_REMOTE_STAGES, PHYLOGENY_ALIGN_STRATEGIES, PHYLOGENY_BOOTSTRAPS, PHYLOGENY_TRIM_METHODS, SAFE_NAME_RE, is_safe_name, is_shell_safe, - is_shell_safe_arg, primer_document_errors, + is_shell_safe_arg, remote_value_error, primer_document_errors, database_document_errors using YAML, Logging @@ -338,12 +338,35 @@ export validate_environment, validate_project, ValidationError, end ## Pipeline Config Validation - function _validate_pipeline_cfg(errors::Vector{ValidationError}, cfg::Dict, ctx::String) - rt = get(cfg, "r_threads", nothing) - isnothing(rt) || _is_thread_count(rt) || - _err(errors, ctx, "r_threads must be a Bool or positive integer (got: $(repr(rt)))") + """ + _staging_dir_errors!(errors, sd, ctx) - rm_cfg = get(cfg, "remote", Dict()) + Record why `sd` cannot be `remote.staging_dir`: it must be an absolute path + a remote login shell would pass through as one word. + """ + function _staging_dir_errors!(errors::Vector{ValidationError}, sd, ctx::String) + if isnothing(sd) || !(sd isa AbstractString) + _err(errors, ctx, "remote.staging_dir must be set when remote.host is set") + else + startswith(sd, "/") || + _err(errors, ctx, "remote.staging_dir must be an absolute path on the " * + "server (got: $(repr(sd)))") + # staging_dir is interpolated into the command string the + # remote sshd runs through a login shell, so it is gated here + # exactly as databases.yml gates dada2.remote_path. + is_shell_safe_arg(sd) || + _err(errors, ctx, "remote.staging_dir may not contain shell " * + "metacharacters or spaces") + end + end + + """ + _validate_remote!(errors, rm_cfg, ctx) + + Record what is wrong with the `remote` block `rm_cfg`: its host, stages, + threads, staging root (once a host is named) and tool names. + """ + function _validate_remote!(errors::Vector{ValidationError}, rm_cfg, ctx::String) if rm_cfg isa Dict host = get(rm_cfg, "host", nothing) isnothing(host) || host isa AbstractString || @@ -370,20 +393,7 @@ export validate_environment, validate_project, ValidationError, # placeholder staging_dir, and refusing that would make every config # invalid until the user configures a server they may never want. if !isnothing(host) - sd = get(rm_cfg, "staging_dir", nothing) - if isnothing(sd) || !(sd isa AbstractString) - _err(errors, ctx, "remote.staging_dir must be set when remote.host is set") - else - startswith(sd, "/") || - _err(errors, ctx, "remote.staging_dir must be an absolute path on the " * - "server (got: $(repr(sd)))") - # staging_dir is interpolated into the command string the - # remote sshd runs through a login shell, so it is gated here - # exactly as databases.yml gates dada2.remote_path. - is_shell_safe_arg(sd) || - _err(errors, ctx, "remote.staging_dir may not contain shell " * - "metacharacters or spaces") - end + _staging_dir_errors!(errors, get(rm_cfg, "staging_dir", nothing), ctx) end else _err(errors, ctx, "remote must be a mapping (got: $(repr(rm_cfg)))") @@ -400,6 +410,38 @@ export validate_environment, validate_project, ValidationError, elseif !isnothing(rt_tools) _err(errors, ctx, "remote.tools must be a mapping (got: $(repr(rt_tools)))") end + end + + """ + remote_value_error(key, value) -> Union{String,Nothing} + + Why `value` cannot be stored as `remote.` (e.g. `key = "tools.mafft"`), + or nothing. Runs the checks `validate_project` makes of the remote block on + that one key, so the config write gate refuses what validation would; + `staging_dir` is checked even when no host is named yet. + """ + function remote_value_error(key::AbstractString, value) + errors = ValidationError[] + if key == "staging_dir" + _staging_dir_errors!(errors, value, "") + else + nested = foldr((k, inner) -> Dict{String,Any}(String(k) => inner), split(key, '.'); init=value) + _validate_remote!(errors, nested, "") + end + isempty(errors) ? nothing : join((e.message for e in errors), "; ") + end + + """ + _validate_pipeline_cfg(errors, cfg, ctx) + + Record what is wrong with a merged pipeline config `cfg`, section by section. + """ + function _validate_pipeline_cfg(errors::Vector{ValidationError}, cfg::Dict, ctx::String) + rt = get(cfg, "r_threads", nothing) + isnothing(rt) || _is_thread_count(rt) || + _err(errors, ctx, "r_threads must be a Bool or positive integer (got: $(repr(rt)))") + + _validate_remote!(errors, get(cfg, "remote", Dict()), ctx) ph = get(cfg, "phylogeny", nothing) ph isa Dict && _validate_phylogeny(errors, ph, ctx) diff --git a/src/pipeline/dada2/dada2_functions.r b/src/pipeline/dada2/dada2_functions.r index 8792317a..59bbd333 100644 --- a/src/pipeline/dada2/dada2_functions.r +++ b/src/pipeline/dada2/dada2_functions.r @@ -15,8 +15,6 @@ # https://creativecommons.org/licenses/by/4.0/. library(dada2) -library(dplyr) -library(tibble) # library(yaml) # BiocParallel defaults to MulticoreParam, which forks. ShortRead::qa(), reached @@ -36,7 +34,16 @@ BiocParallel::register(BiocParallel::SerialParam()) # Saves per-base quality score profiles for up to 3 forward and 3 reverse # samples. Inspect before and after filtering to guide truncLen / maxEE choices. +# Files with no reads are skipped: plotQualityProfile() stops on one, and a +# sample emptied by primer trimming (e.g. a blank control) is not an error. plot_quality_profiles <- function(fwd_files, rev_files, output_pdf) { + has_reads <- function(f) { + con <- gzfile(f, "r") + on.exit(close(con)) + length(readLines(con, n = 1L, warn = FALSE)) > 0L + } + if (!is.null(fwd_files)) fwd_files <- Filter(has_reads, fwd_files) + if (!is.null(rev_files)) rev_files <- Filter(has_reads, rev_files) pdf(output_pdf, width = 8, height = 6) if (!is.null(fwd_files) && length(fwd_files) > 0) { print(plotQualityProfile(fwd_files[seq_len(min(3L, length(fwd_files)))])) @@ -214,10 +221,50 @@ write_fasta <- function(seq_table, tables_dir, prefix) { # Writes taxonomy assignments to CSV. Bootstrap confidence values (0-100 per # taxonomic rank) # Returns taxa_df (SeqName + Sequence + taxonomy), used by write_combined_table(). +# +# Preserve left-row order, right-row order within matches, and the original +# column order when assembling the taxonomy and count tables. +base_left_join <- function(x, y, by) { + x_cols <- names(x) + y_cols <- names(y) + stopifnot(!anyDuplicated(x_cols), !anyDuplicated(y_cols), + all(by %in% x_cols), all(by %in% y_cols)) + suffix_names <- function(values, reserved, suffix) { + out <- c(reserved, values) + while (anyDuplicated(out)) { + duplicate <- which(duplicated(out)) + out[duplicate] <- paste0(out[duplicate], suffix) + } + tail(out, length(values)) + } + # Match left_join's repeated suffixing, including names such as Species.x. + x_out <- x_cols + x_aux <- !x_cols %in% by + y_aux <- !y_cols %in% by + x_out[x_aux] <- suffix_names(x_cols[x_aux], c(by, y_cols[y_aux]), ".x") + y_out <- suffix_names(y_cols, x_cols, ".y")[y_aux] + + # Merge only unique internal names, so character selection can never confuse + # a taxonomy column with a sample whose name resembles a generated suffix. + x_ids <- paste0(".x", seq_along(x_cols)) + y_ids <- paste0(".y", seq_along(y_cols)) + names(x) <- x_ids + names(y) <- y_ids + x$.left_position <- seq_len(nrow(x)) + y$.right_position <- seq_len(nrow(y)) + joined <- merge(x, y, by.x = x_ids[match(by, x_cols)], + by.y = y_ids[match(by, y_cols)], all.x = TRUE, sort = FALSE) + joined <- joined[order(joined$.left_position, joined$.right_position), + c(x_ids, y_ids[y_aux]), drop = FALSE] + names(joined) <- c(x_out, y_out) + rownames(joined) <- NULL + joined +} + write_taxa_table <- function(tax_matrix, boot_matrix, index, tables_dir, prefix) { taxa_df <- as.data.frame(tax_matrix, stringsAsFactors = FALSE) taxa_df$Sequence <- rownames(taxa_df) - taxa_df <- dplyr::left_join(index, taxa_df, by = "Sequence") + taxa_df <- base_left_join(index, taxa_df, by = "Sequence") write.csv(taxa_df, file.path(tables_dir, paste0(prefix, ".csv")), quote = FALSE, row.names = FALSE) @@ -225,11 +272,11 @@ write_taxa_table <- function(tax_matrix, boot_matrix, index, tables_dir, prefix) boot_df <- as.data.frame(boot_matrix, stringsAsFactors = FALSE) colnames(boot_df) <- paste0(colnames(boot_df), "_boot") boot_df$Sequence <- rownames(boot_matrix) - boot_df <- dplyr::left_join(index, boot_df, by = "Sequence") + boot_df <- base_left_join(index, boot_df, by = "Sequence") write.csv(boot_df, file.path(tables_dir, paste0(prefix, "_bootstraps.csv")), quote = FALSE, row.names = FALSE) - combined_df <- dplyr::left_join(taxa_df, boot_df, by = c("SeqName", "Sequence")) + combined_df <- base_left_join(taxa_df, boot_df, by = c("SeqName", "Sequence")) write.csv(combined_df, file.path(tables_dir, paste0(prefix, "_combined.csv")), quote = FALSE, row.names = FALSE) @@ -239,10 +286,13 @@ write_taxa_table <- function(tax_matrix, boot_matrix, index, tables_dir, prefix) # Joins taxonomy with per-sample counts write_combined_table <- function(taxa_df, index, seq_table, tables_dir, tax_filename, asv_filename) { seq_t <- as.data.frame(t(seq_table), stringsAsFactors = FALSE) - seq_t <- tibble::rownames_to_column(seq_t, "Sequence") - write.csv(dplyr::left_join(taxa_df, seq_t, by = "Sequence"), + # tibble::rownames_to_column(seq_t, "Sequence") put the row names in a leading + # column and dropped them from the row names; data.frame() does the same. + seq_t <- data.frame(Sequence = rownames(seq_t), seq_t, row.names = NULL, + check.names = FALSE, stringsAsFactors = FALSE) + write.csv(base_left_join(taxa_df, seq_t, by = "Sequence"), file.path(tables_dir, tax_filename), quote = FALSE, row.names = FALSE) - write.csv(dplyr::left_join(index, seq_t, by = "Sequence"), + write.csv(base_left_join(index, seq_t, by = "Sequence"), file.path(tables_dir, asv_filename), quote = FALSE, row.names = FALSE) invisible(NULL) } diff --git a/src/pipeline/tools.jl b/src/pipeline/tools.jl index d8e12062..61834c7f 100644 --- a/src/pipeline/tools.jl +++ b/src/pipeline/tools.jl @@ -14,6 +14,7 @@ export cutadapt, vsearch, multiqc, cdhit, tool_bin, _sq, _run_logged, _run_killa using ..PipelineTypes using ..PipelineLog using ..Config + import ..MemoryProbe _sq(s::AbstractString) = "'" * replace(s, "'" => "'\\''") * "'" @@ -24,13 +25,18 @@ export cutadapt, vsearch, multiqc, cdhit, tool_bin, _sq, _run_logged, _run_killa # Truncation is the stage's own affair, done once at entry via reset_tool_logs. # Run a command and wait for it. If the waiting task is interrupted (a job # cancel), the child process is killed so it cannot keep writing outputs. + # When the stage is probed (occupancy_probe.enabled), the child's memory is + # sampled while it runs; otherwise watch!/unwatch! do nothing. function _run_killable(cmd::Base.AbstractCmd) p = run(cmd; wait=false) + watched = MemoryProbe.watch!(p) try wait(p) catch process_running(p) && kill(p) rethrow() + finally + MemoryProbe.unwatch!(watched) end success(p) || throw(ProcessFailedException(p)) nothing @@ -71,7 +77,11 @@ export cutadapt, vsearch, multiqc, cdhit, tool_bin, _sq, _run_logged, _run_killa "raxml" => "raxmlHPC-PTHREADS-SSE3") tool_bin(key) = get(_tools, key, get(_BIN_DEFAULTS, key, key)) - const _tools = load_tools() + # Filled in __init__, not at definition: a top-level load_tools() runs once at + # precompile time and bakes that tools.yml into the package cache, so an install + # that rewrote tool paths afterwards went unseen until something forced a recompile. + const _tools = Dict{String,String}() + __init__() = merge!(empty!(_tools), load_tools()) ## Argument sanitisation # optional_args are interpolated into shell commands; reject shell metacharacters. @@ -339,9 +349,11 @@ export cutadapt, vsearch, multiqc, cdhit, tool_bin, _sq, _run_logged, _run_killa "> $(_sq(summary_basename))" log_command(cmd, cmd_log) - cd(log_dir) do - run(pipeline(`bash -lc $cmd`)) - end + # The directory is the command's, not the process's: runs execute on + # parallel threads, and two interleaved cd(log_dir) blocks restore each + # other's directory, leaving the server in one run's logs folder so every + # relative path after it (./databases among them) resolves from there. + run(Cmd(`bash -lc $cmd`; dir = log_dir)) @info("Cutadapt: Complete. Output available in $cutadapt_dir.") return cutadapt_dir diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl index 93cd59f9..29875bff 100644 --- a/src/server/routes/analysis.jl +++ b/src/server/routes/analysis.jl @@ -8,7 +8,7 @@ using CSV, DataFrames using ..Analysis: alpha_chart, sample_columns, taxonomy_levels, taxon_column, filtered_counts, aggregate_by_taxon, combined_counts_across_runs, - alpha_boxplot, nmds_chart, run_nmds, run_permanova, r_available, + alpha_boxplot, nmds_chart, run_nmds, nmds_warnings, run_permanova, r_available, sequence_column_name, normalise_counts, venn_taxa_present, pool_columns, auto_min_depth, bar_chart, faceted_bar_chart, natural_sort @@ -821,6 +821,7 @@ end end fig = nmds_chart(coords, all_samples; colour_by=colour_by, stress, shape_by=shape_by) + fig["warnings"] = nmds_warnings(mat, stress) HTTP.Response(200, ["Content-Type" => "application/json"], body=JSON3.write(fig)) end @@ -1195,13 +1196,19 @@ end _differential_config(study) -> DifferentialConfig The study's `analysis.differential` settings, resolved through the config -cascade. +cascade. Missing or `nothing` values use the defaults in `DifferentialConfig`, +including `dispersion="glm_nb"`. Invalid settings propagate the constructor's +`ArgumentError`; values that cannot be converted to strings or numbers also +raise their conversion errors. """ function _differential_config(study::String) resolved = _resolved_run_config(study, nothing, nothing, nothing) val(k, d) = something(get(get(resolved, k, (; value=d)), :value, d), d) DifferentialConfig(String(val("analysis.differential.offset", "tss")), - Float64(val("analysis.differential.min_prevalence", 0.0))) + Float64(val("analysis.differential.min_prevalence", 0.0)); + method=String(val("analysis.differential.method", "nb_glm")), + replacement_delta=Float64(val("analysis.differential.replacement_delta", 0.65)), + dispersion=String(val("analysis.differential.dispersion", "glm_nb"))) end """ @@ -1279,6 +1286,8 @@ function _differential(study::String, body) e isa ArgumentError && return json_error(400, "invalid_input", sprint(showerror, e)) e isa ScalingRefusal && return json_error(422, "scaling_refused", sprint(showerror, e)) e isa MASSUnavailable && return json_error(503, "r_unavailable", sprint(showerror, e)) + e isa ZCompositionsUnavailable && return json_error(503, "r_unavailable", sprint(showerror, e)) + e isa GlmGamPoiUnavailable && return json_error(503, "r_unavailable", sprint(showerror, e)) e isa ErrorException && return json_error(422, "no_taxon_fitted", e.msg) rethrow() end diff --git a/src/server/routes/config.jl b/src/server/routes/config.jl index 02b662c3..e434269f 100644 --- a/src/server/routes/config.jl +++ b/src/server/routes/config.jl @@ -106,7 +106,9 @@ const _ENUM_VALUES = Dict( "tagging.source" => ("VSEARCH", "DADA2"), "analysis.alpha.normalisation" => ("none", "rarefy", "srs"), "analysis.beta.normalisation" => ("hellinger", "none", "rarefy", "srs"), + "analysis.differential.method" => Differential.METHODS, "analysis.differential.offset" => Differential.OFFSET_METHODS, + "analysis.differential.dispersion" => Differential.DISPERSIONS, "phylogeny.reference.align.strategy" => Validation.PHYLOGENY_ALIGN_STRATEGIES, "phylogeny.placement.align.strategy" => Validation.PHYLOGENY_ALIGN_STRATEGIES, "phylogeny.reference.trim.method" => Validation.PHYLOGENY_TRIM_METHODS, @@ -114,11 +116,19 @@ const _ENUM_VALUES = Dict( "phylogeny.reference.tree.bootstrap" => Validation.PHYLOGENY_BOOTSTRAPS, ) +# String keys that become part of a file name: output stems and read suffixes. +const _FILE_NAME_PARTS = ("dada2.output.fasta_prefix", "dada2.output.seq_table_prefix", + "dada2.output.taxa_prefix", "cutadapt.r1_suffix", "cutadapt.r2_suffix") + # Numeric keys whose factory default is a number but which also take ~. const _NULLABLE_NUMBERS = ("phylogeny.placement.place.heuristic",) -# Why a value cannot be saved under this key, or nothing. The type follows the -# factory default; a key whose default is ~ accepts a string, number or boolean. +""" + _config_value_error(key, value) -> Union{String,Nothing} + +Why a value cannot be saved under this key, or nothing. The type follows the +factory default; a key whose default is ~ accepts a string, number or boolean. +""" function _config_value_error(key::String, value) v = value isa JSON3.Array ? collect(value) : value if haskey(_ENUM_VALUES, key) @@ -135,12 +145,24 @@ function _config_value_error(key::String, value) (isnothing(v) || (v isa Real && !(v isa Bool))) || return "$key must be a number or empty" return nothing end - if key in ("remote.host", "remote.rscript") || startswith(key, "remote.tools.") + if key in ("remote.host", "remote.rscript") isnothing(v) && return nothing (v isa AbstractString && Validation.is_shell_safe_arg(v) && !startswith(v, "-")) || return "$key may not start with '-' or contain spaces or shell metacharacters" return nothing end + # The rest of the remote block is refused here exactly as validation refuses it. + if startswith(key, "remote.") + bad = Validation.remote_value_error(key[length("remote.")+1:end], v) + isnothing(bad) || return bad + end + # Output stems and read-file suffixes become parts of file names (and the + # suffixes are spliced into a \Q...\E regex), so they are names, not paths. + if key in _FILE_NAME_PARTS + (v isa AbstractString && Validation.is_safe_name(v)) || + return "$key may contain only letters, digits and . _ -" + return nothing + end default = get(_FACTORY_VALUES, key, nothing) if default isa Bool v isa Bool || return "$key must be true or false" @@ -275,7 +297,10 @@ function _read_primers() try PrimersLibrary.load(_primers_path()) catch e - @error "primers.yml is not readable" path=_primers_path() exception=(e, catch_backtrace()) + # @warn, not @error: the 400 on the next line is this function's own verdict + # that an unreadable config/primers.yml is an operator-fixable input, not a + # server fault. See the matching note in routes/databases.jl. + @warn "primers.yml is not readable" path=_primers_path() reason=sprint(showerror, e) json_error(400, "primers_unreadable", "config/primers.yml cannot be read as a primers document and must be repaired by hand: $(sprint(showerror, e))") end diff --git a/src/server/routes/databases.jl b/src/server/routes/databases.jl index 75ade1f7..2d22fd0e 100644 --- a/src/server/routes/databases.jl +++ b/src/server/routes/databases.jl @@ -19,7 +19,13 @@ function _read_databases() try DatabasesLibrary.load(_databases_path()) catch e - @error "databases.yml is not readable" path=_databases_path() exception=(e, catch_backtrace()) + # @warn, not @error: the 400 on the next line is this function's own verdict + # that an unreadable config/databases.yml is an operator-fixable input, not a + # server fault. Error severity with a backtrace reports a routine bad file as + # a crash. The reason is kept -- it is what names the YAML defect -- and only + # the backtrace goes, which pointed into YAML.jl's parser, never at the + # offending line. + @warn "databases.yml is not readable" path=_databases_path() reason=sprint(showerror, e) json_error(400, "databases_unreadable", "config/databases.yml cannot be read as a databases document and must be repaired by hand: $(sprint(showerror, e))") end diff --git a/src/server/routes/pipeline.jl b/src/server/routes/pipeline.jl index 306d8c1a..032fce10 100644 --- a/src/server/routes/pipeline.jl +++ b/src/server/routes/pipeline.jl @@ -9,6 +9,7 @@ using JSON3, RCall using ..RRuntime: with_r_lock const Provenance = MetaManifold.Provenance +const MemoryProbe = MetaManifold.MemoryProbe ## Tagging helpers @@ -180,20 +181,55 @@ end # # A stage that skipped leaves its earlier record alone: writing the current # environment over it would attribute old outputs to new tool versions. +# +# With `occupancy_probe.enabled: true` in the run config, the stage's memory is +# measured (MemoryProbe) and recorded under the stage's `occupancy` key. Off, the +# probe is `nothing` and the record is exactly what it was without it. function _attest(f::Function, att, env, name::AbstractString; config_path::String) started = Provenance.timestamp() ran_before = stage_run_count(config_path) + probe = MemoryProbe.start(get(get(att.doc, "config", Dict()), "run_config", nothing)) try - result = f() + result = MemoryProbe.within(f, probe) stage_run_count(config_path) > ran_before && - Provenance.record_stage!(att, name, env; started, outputs=_stage_outputs(result)) + Provenance.record_stage!(att, name, env; started, outputs=_stage_outputs(result), + occupancy=MemoryProbe.finish!(probe)) return result catch - Provenance.record_stage!(att, name, env; started, status="failed") + Provenance.record_stage!(att, name, env; started, status="failed", + occupancy=MemoryProbe.finish!(probe)) rethrow() + finally + MemoryProbe.finish!(probe) end end +# The vsearch stage's two searches: `asv()`, and `otu()` unless it is `nothing`, each +# under its occupancy probe label. The full pipeline runs them concurrently; a +# single-stage re-run runs them one after the other. These are the two shapes whose +# memory peaks the probe tells apart, and test/unit/test_occupancy_disagreement.jl runs +# both through this function. Returns the two results, `nothing` for an absent OTU +# search. Concurrently, both are waited for before a failure is rethrown, so a +# failed ASV search cannot leave the OTU search running. +function _vsearch_searches(asv::Function, otu::Union{Function,Nothing}; concurrent::Bool) + if !concurrent + asv_tax = MemoryProbe.task(asv, "vsearch:asv") + return (asv_tax, isnothing(otu) ? nothing : MemoryProbe.task(otu, "vsearch:otu")) + end + asv_task = Threads.@spawn MemoryProbe.task(asv, "vsearch:asv") + otu_task = isnothing(otu) ? nothing : Threads.@spawn MemoryProbe.task(otu, "vsearch:otu") + tasks = filter(!isnothing, Any[asv_task, otu_task]) + foreach(t -> (try wait(t) catch end), tasks) + (fetch(asv_task), isnothing(otu_task) ? nothing : fetch(otu_task)) +end + +# The vsearch stage for one run: `search(project, result, db)` on the ASV result and, +# unless `otus` is `nothing`, on the OTU result (_vsearch_searches). `search` is the +# pipeline's `vsearch`; it is passed in so the stage can be tested without one. +_vsearch_stage(search::Function, project, asvs, otus, db; concurrent::Bool) = + _vsearch_searches(() -> search(project, asvs, db), + isnothing(otus) ? nothing : () -> search(project, otus, db); concurrent) + # The files a stage hands on, by content, for its provenance record. _output_paths(r::ASVResult) = [r.fasta, r.count_table, r.taxonomy] _output_paths(r::OTUResult) = [r.fasta, r.count_table] @@ -261,13 +297,7 @@ function _run_full_pipeline(study::String, run::String, db_config::String, # OTU search it claims to cover. asv_tax, otu_tax = if vsearch_enabled _attest(att, env, "vsearch"; config_path) do - asv_task = Threads.@spawn(vsearch(project, asvs, dbs["$(db_name)_vsearch"])) - otu_task = (swarm_enabled && !isnothing(otus)) ? - Threads.@spawn(vsearch(project, otus, dbs["$(db_name)_vsearch"])) : nothing - # Wait for both before rethrowing, so a failed ASV search cannot leave the OTU search running. - tasks = filter(!isnothing, Any[asv_task, otu_task]) - foreach(t -> (try wait(t) catch end), tasks) - (fetch(asv_task), isnothing(otu_task) ? nothing : fetch(otu_task)) + _vsearch_stage(vsearch, project, asvs, otus, dbs["$(db_name)_vsearch"]; concurrent=true) end else (nothing, nothing) @@ -438,13 +468,11 @@ function _run_stage(study::String, run::String, stage::String, end elseif step == "vsearch" if vsearch_enabled + # Serial here, concurrent in the full pipeline (_vsearch_searches). _record("vsearch") do asvs = _asvresult_from_disk(run_dir) - vsearch(project, asvs, dbs["$(db_name)_vsearch"]) - otus = _oturesult_from_disk(run_dir) - if !isnothing(otus) && swarm_enabled - vsearch(project, otus, dbs["$(db_name)_vsearch"]) - end + otus = swarm_enabled ? _oturesult_from_disk(run_dir) : nothing + _vsearch_stage(vsearch, project, asvs, otus, dbs["$(db_name)_vsearch"]; concurrent=false) end end # merge_taxa follows as its own step when it is in the list. diff --git a/src/server/routes/report.jl b/src/server/routes/report.jl index b3c37fa6..73ac45a6 100644 --- a/src/server/routes/report.jl +++ b/src/server/routes/report.jl @@ -126,6 +126,12 @@ const _REPORT_MIME = Dict(".svg" => "image/svg+xml", ".png" => "image/png", ".js ".pdf" => "application/pdf", ".tif" => "image/tiff", ".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") +# Uploaded items are stored as sent, so an .svg can carry script. Served with +# these, a file opened directly cannot run it or be sniffed as another type; +# an showing it is unaffected. +const _UPLOAD_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox" +const _UPLOAD_HEADERS = ["X-Content-Type-Options" => "nosniff", "Content-Security-Policy" => _UPLOAD_CSP] + @get "/api/v1/studies/{study}/report/{id}/file" function(req, study::String, id::String) err = _report_study_error(study) isnothing(err) || return err @@ -134,7 +140,8 @@ const _REPORT_MIME = Dict(".svg" => "image/svg+xml", ".png" => "image/png", ".js it = _report_items(study)[item] path = joinpath(_report_dir(study), it["file"]) isfile(path) || return json_error(404, "not_found", "The file for '$id' is missing") - HTTP.Response(200, ["Content-Type" => get(_REPORT_MIME, lowercase(splitext(path)[2]), "application/octet-stream")]; + HTTP.Response(200, ["Content-Type" => get(_REPORT_MIME, lowercase(splitext(path)[2]), "application/octet-stream"), + _UPLOAD_HEADERS...]; body=read(path)) end diff --git a/src/server/routes/results.jl b/src/server/routes/results.jl index 423e2180..c97b6627 100644 --- a/src/server/routes/results.jl +++ b/src/server/routes/results.jl @@ -400,6 +400,11 @@ function _filtered_table_df(con, table::String, columns::Vector{String}, end ## Save filtered table to merged directory +# Tables the pipeline writes. Saving over one would make it newer than its +# inputs, so merge_taxa would never rebuild it; deleting one removes the merged +# sentinel and the data the results views read. Neither route may touch them. +const _PIPELINE_TABLES = ("merged", "merged_otu", "merged_cdhit", "cluster_membership") + @post "/api/v1/studies/{study}/runs/{run}/results/tables/{table}/save" function(req, study::String, run::String, @@ -418,8 +423,7 @@ end save_name = string(save_name) Validation.is_safe_name(save_name) || return json_error(400, "invalid_name", "Name must contain only letters, numbers, dots, hyphens, and underscores") - # Overwriting a pipeline table would make it newer than its inputs, so merge_taxa would never rebuild it. - save_name in ("merged", "merged_otu", "merged_cdhit", "cluster_membership") && + save_name in _PIPELINE_TABLES && return json_error(400, "reserved_name", "'$save_name' is a pipeline table; choose another name") params = _body_filter_params(body) @@ -531,6 +535,8 @@ end table::String) err = _validate_run_request(study, run) isnothing(err) || return err + table in _PIPELINE_TABLES && + return json_error(400, "reserved_name", "'$table' is a pipeline table and cannot be deleted") group = _req_group(req) dir = _merge_dir(study, run; group) path = joinpath(dir, table * ".csv") diff --git a/src/server/routes/studies.jl b/src/server/routes/studies.jl index 10cbac70..8e928803 100644 --- a/src/server/routes/studies.jl +++ b/src/server/routes/studies.jl @@ -108,6 +108,10 @@ function _sample_names(study::String, run::String) unique(map(f -> Tools._lane_free(replace(f, r"_R1(_001)?\.fastq\.gz$" => "")), files)) end +# Every run in the study: those directly under it and those inside its groups. +_total_run_count(study::String, runs, groups) = + length(runs) + sum((length(_group_run_names(study, g)) for g in groups); init=0) + function _active_job_count(study::String) jobs = JobQueue.list_jobs(; study) count(j -> j.status in (JobQueue.queued, JobQueue.running), jobs) @@ -117,7 +121,7 @@ function _study_data(name::String) runs = _run_names(name) groups = _group_names(name) (; name, - run_count = length(runs), + run_count = _total_run_count(name, runs, groups), group_count = length(groups), active_job_count = _active_job_count(name), runs, @@ -173,7 +177,7 @@ end runs = _run_names(name) groups = _group_names(name) (; name, - run_count = length(runs), + run_count = _total_run_count(name, runs, groups), group_count = length(groups), active_job_count = _active_job_count(name)) end diff --git a/src/server/server.jl b/src/server/server.jl index 56537a71..e38616c8 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -207,8 +207,47 @@ module Server ## CORS middleware (needed when the frontend is served from a different origin) # Loopback binds also check Host, so a DNS-rebinding page cannot read the API. const _bound_host = Ref("127.0.0.1") + const _bound_port = Ref(8080) _is_loopback(h::AbstractString) = lowercase(h) in ("localhost", "127.0.0.1", "::1", "[::1]") + # The Vite dev server (frontend/vite.config.ts, default port) proxies /api and + # /files here without rewriting Origin, so its pages arrive with this origin. + const _DEV_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173") + + "An origin in the form `_allowed_origins` holds: lowercase, no trailing slash." + _normalise_origin(o::AbstractString) = rstrip(lowercase(strip(o)), '/') + + """ + _allowed_origins() -> Set{String} + + The browser origins allowed to call the API cross-origin: the origin this + server is served from (its bound port, spelled with `localhost` and + `127.0.0.1`, plus the bound host itself when it is a specific name), the Vite + dev server, and any listed comma-separated in + `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`. Every other origin is refused, including + other ports and schemes on localhost. + """ + function _allowed_origins() + port = _bound_port[] + host = lowercase(_bound_host[]) + hosts = Set(["localhost", "127.0.0.1"]) + host in ("0.0.0.0", "::", "[::]") || + push!(hosts, occursin(':', host) && !startswith(host, "[") ? "[$host]" : host) + allowed = Set(["http://$h:$port" for h in hosts]) + union!(allowed, _DEV_ORIGINS) + for o in split(get(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS", ""), ',') + isempty(strip(o)) || push!(allowed, _normalise_origin(o)) + end + allowed + end + + """ + _cors_middleware(next) + + Refuse requests whose Host is not loopback on a loopback bind, and requests + carrying an `Origin` outside `_allowed_origins()`. Allowed origins get CORS + headers and preflight answers; requests with no `Origin` pass unchanged. + """ function _cors_middleware(next) function(req::HTTP.Request) if _is_loopback(_bound_host[]) @@ -221,11 +260,8 @@ module Server # Same-origin request - no CORS headers needed return next(req) end - # Only allow localhost origins - origin_url = try HTTP.URIs.URI(origin) catch; nothing end - if isnothing(origin_url) || !(lowercase(origin_url.host) in ("localhost", "127.0.0.1", "::1")) - return HTTP.Response(403, "Forbidden: non-localhost origin") - end + _normalise_origin(origin) in _allowed_origins() || + return HTTP.Response(403, "Forbidden: origin not allowed") cors_headers = [ "Access-Control-Allow-Origin" => origin, "Access-Control-Allow-Methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS", @@ -252,7 +288,13 @@ module Server ".csv" => "text/csv", ".json" => "application/json", ) - # Static file serving middleware (Oxygen middleware signature: handler -> req -> response) + """ + _file_middleware(next) + + Serve run outputs under `/files/{study}/runs/...` from the projects tree and + the frontend build for every other non-API path, passing `/api/` requests on. + Run files are sent with `nosniff`; report uploads also get `_UPLOAD_CSP`. + """ function _file_middleware(next) function(req::HTTP.Request) uri = first(split(req.target, '?'; limit=2)) @@ -269,7 +311,13 @@ module Server return HTTP.Response(403, "Forbidden") ext = last(splitext(full)) mime = get(_mime_map, ext, "application/octet-stream") - return HTTP.Response(200, ["Content-Type" => mime]; body=read(full)) + # Report items are uploaded bytes, reachable here as well as + # through the report route: serve them as inert as that route does. + study_report = joinpath(projects, HTTP.URIs.unescapeuri(m[1]), "report") + uploaded = startswith(full, study_report * Base.Filesystem.path_separator) + headers = ["Content-Type" => mime, "X-Content-Type-Options" => "nosniff"] + uploaded && push!(headers, "Content-Security-Policy" => _UPLOAD_CSP) + return HTTP.Response(200, headers; body=read(full)) end # Pass API requests through to Oxygen @@ -301,6 +349,29 @@ module Server end end + ## Startup R package check + # Nothing else notices a missing R package until an analysis or pipeline stage + # needs it and fails, possibly hours into a run. Checked in the background, so + # the server is answering while R loads them; a request needing R meanwhile + # waits on the runtime lock as it would behind any other R work. + function _report_r_packages() + missing = try + MetaManifold.RRuntime.missing_r_packages() + catch e + @warn "Could not check the R packages" exception=e + return nothing + end + if isempty(missing) + @info "R packages: all required packages load" + return nothing + end + needed = Dict(MetaManifold.RRuntime.REQUIRED_R_PACKAGES) + affected = unique(needed[p] for p in missing) + @warn "R packages missing: $(join(missing, ", ")). Unavailable until restored: " * + "$(join(affected, "; ")). Restore them with: Rscript -e 'renv::restore(prompt = FALSE)'" + nothing + end + ## Startup warm-up # The precompile workload (precompile.jl) compiles the routes a first visit # uses into the package cache. A developer who turns it off in @@ -342,10 +413,17 @@ module Server end ## Entry point + const _MIDDLEWARE = [_bad_request_middleware, _r_busy_middleware, _cors_middleware, + _analysis_source_middleware, _file_middleware] + # Oxygen's docs (/docs, /docs/schema) and metrics sit outside the middleware + # above, so they would bypass the Host and Origin checks; nothing uses them. + const _SERVE_OPTIONS = (access_log=nothing, show_errors=false, docs=false, metrics=false) + function start(; root=pwd(), host="127.0.0.1", port=8080, listen::Bool=true) global_logger(_SuppressEpipe(global_logger())) ServerState.set_root!(root) _bound_host[] = host + _bound_port[] = port @info "MetaManifold server starting" root host port # Initialise all projects on startup @@ -353,11 +431,9 @@ module Server isempty(initialised) || @info "Initialised projects" initialised listen || return nothing + errormonitor(@async _report_r_packages()) _workload_enabled() || errormonitor(@async _warm_routes(host, port)) - serve(; host, port, access_log=nothing, - middleware=[_bad_request_middleware, _r_busy_middleware, _cors_middleware, - _analysis_source_middleware, _file_middleware], - show_errors=false) + serve(; host, port, middleware=_MIDDLEWARE, _SERVE_OPTIONS...) end """ diff --git a/test/integration/test_pipeline.jl b/test/integration/test_pipeline.jl index 00d5fca8..b76fb577 100644 --- a/test/integration/test_pipeline.jl +++ b/test/integration/test_pipeline.jl @@ -94,6 +94,10 @@ asvs_results = Vector{Union{ASVResult,Nothing}}(undef, length(projects)) ## Run pipeline for each project + # Runs share one process, so no stage may change its working directory: two + # runs doing so in parallel left the server in one run's folder, where every + # later relative path (./databases among them) resolved. + start_dir = pwd() Threads.@threads for (i, project) in collect(enumerate(projects)) n_samples = length(filter(f -> endswith(f, ".fastq.gz"), readdir(project.data_dir))) ÷ 2 @@ -206,6 +210,8 @@ end end + @test pwd() == start_dir + ## Differential abundance route on the two runs. run_A has 2 samples and # run_B 1, so this checks only that the route answers with a well-formed # result or an explicit error, never that anything is significant. @@ -235,4 +241,74 @@ end end + ## The server's own entry points, on the run directories the loop above built. + # Nothing up to here has called them: the loop drives the stages by hand, which + # is not what pressing a stage in the UI does. The full pipeline runs the + # vsearch stage's two searches concurrently and a single-stage re-run runs them + # one after the other (_vsearch_searches) - the two shapes whose peaks the + # occupancy probe exists to tell apart - and these calls are the only callers of + # that helper outside test/unit/test_occupancy_disagreement.jl. + # + # Each vsearch output is removed first. A stage whose outputs are current skips + # before doing any work, and a stage that skips records nothing: a re-run that + # skipped would exercise the skip guard rather than the stage. The stages before + # vsearch still skip on their own guards, so neither call redoes the DADA2 work. + # + # The probe is switched on for both, at the narrowest level of the config + # cascade (the run's own pipeline.yml) - the way the factory defaults file says + # to switch it on, since the config editor offers only keys that have a factory + # default. It sits in no stage's hashed section, so turning it on marks nothing + # stale, and it is on only here: every other call in the suite records exactly + # the stage keys it always did. + @testset "Pipeline and stage routes (vsearch re-run)" begin + SV = MetaManifold.Server + PV = MetaManifold.Provenance + MP = MetaManifold.MemoryProbe + run_dir(run) = joinpath(PROJECT_ROOT, "projects", PROJECT_NAME, run) + probe_cfg = Dict("occupancy_probe" => Dict("enabled" => true, "interval_ms" => 10)) + old_root = SV.ServerState._root[] + written = String[] + try + SV.ServerState.set_root!(PROJECT_ROOT) + + # run_A: one stage on its own - the serial shape. + rm(joinpath(run_dir("run_A"), "vsearch"); recursive=true, force=true) + rm(joinpath(run_dir("run_A"), "swarm", "vsearch"); recursive=true, force=true) + yaml_a = joinpath(PROJECT_ROOT, "data", PROJECT_NAME, "run_A", "pipeline.yml") + YAML.write_file(yaml_a, probe_cfg) + push!(written, yaml_a) + SV._run_stage(PROJECT_NAME, "run_A", "vsearch", db_config, dbs_loaded) + @test isfile(joinpath(run_dir("run_A"), "vsearch", "taxonomy.tsv")) + @test isfile(joinpath(run_dir("run_A"), "swarm", "vsearch", "taxonomy.tsv")) + + # run_B: the whole pipeline - the concurrent shape. + rm(joinpath(run_dir("run_B"), "vsearch"); recursive=true, force=true) + rm(joinpath(run_dir("run_B"), "swarm", "vsearch"); recursive=true, force=true) + yaml_b = joinpath(PROJECT_ROOT, "data", PROJECT_NAME, "run_B", "pipeline.yml") + YAML.write_file(yaml_b, probe_cfg) + push!(written, yaml_b) + merged = SV._run_full_pipeline(PROJECT_NAME, "run_B", db_config, dbs_loaded) + @test haskey(merged.tables, "merged") + @test isfile(joinpath(run_dir("run_B"), "vsearch", "taxonomy.tsv")) + @test isfile(joinpath(run_dir("run_B"), "swarm", "vsearch", "taxonomy.tsv")) + + # What each run recorded: its vsearch stage, and - the probe being on - + # that stage's occupancy, one labelled task per search. + for run in ("run_A", "run_B") + att = PV.read_attestation(joinpath(run_dir(run), "attestation.yml")) + stage = only([s for s in att.doc["stages"] if s["name"] == "vsearch"]) + @test haskey(stage, "occupancy") + occ = get(stage, "occupancy", nothing) + isnothing(occ) && continue + @test occ["probe"] == MP.PROBE + @test occ["interval_ms"] == 10 + labels = sort([String(t["label"]) for t in occ["tasks"]]) + @test labels == ["vsearch:asv", "vsearch:otu"] + end + finally + foreach(p -> rm(p; force=true), written) + SV.ServerState._root[] = old_root + end + end + end diff --git a/test/integration/test_server.jl b/test/integration/test_server.jl index 184e70fa..ef63be81 100644 --- a/test/integration/test_server.jl +++ b/test/integration/test_server.jl @@ -70,6 +70,21 @@ status_exception=false) @test r5.status in (403, 404) + ## PATCH /api/v1/config stores the dispersion spelling over the + ## real listener and refuses the pre-release "ml". + r6 = HTTP.patch("http://localhost:$port/api/v1/config", + ["Content-Type" => "application/json"], + JSON3.write(Dict("analysis.differential.dispersion" => "glmGamPoi")); + status_exception=false) + @test r6.status == 200 + saved = YAML.load_file(joinpath(tmp_root, "config", "pipeline.yml")) + @test saved["analysis"]["differential"]["dispersion"] == "glmGamPoi" + r7 = HTTP.patch("http://localhost:$port/api/v1/config", + ["Content-Type" => "application/json"], + JSON3.write(Dict("analysis.differential.dispersion" => "ml")); + status_exception=false) + @test r7.status == 400 + @info "Server smoke tests passed on port $port" end finally diff --git a/test/runtests.jl b/test/runtests.jl index 4a8f47fa..36a37dd3 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -50,6 +50,9 @@ const UNIT_FILES = [ "test_sample_reads.jl", "test_determinism.jl", "test_differential.jl", + "test_permanova.jl", + "test_write_gates.jl", + "test_server_security.jl", ] # The files named on the command line ("test_routes.jl", "routes" or @@ -83,6 +86,12 @@ using MetaManifold.Categories, MetaManifold.CompositionLibrary include(joinpath(@__DIR__, "unit", f)) end + ## Occupancy probe and the two-thread disagreement test (MMW-01), with the + ## full suite. Its vsearch part skips, saying so, when vsearch is not on PATH. + if isempty(filter(a -> !startswith(a, "--"), ARGS)) + include(joinpath(@__DIR__, "unit", "test_occupancy_disagreement.jl")) + end + ## Integration tests (opt-in) if RUN_INTEGRATION using MetaManifold.DADA2, MetaManifold.OTUPipeline diff --git a/test/unit/test_analysis.jl b/test/unit/test_analysis.jl index 42ded7be..f34282a0 100644 --- a/test/unit/test_analysis.jl +++ b/test/unit/test_analysis.jl @@ -59,6 +59,18 @@ @test length(trace_names) == 6 # top 5 + Other end + @testset "nmds_warnings" begin + # Samples 1-2 share taxon 1 and samples 3-4 share taxon 3; the pairs share nothing. + split = [5.0 1 0 0; 2 0 0 0; 0 0 4 1; 0 0 3 0] + w = Analysis.nmds_warnings(split, 0.0) + @test length(w) == 1 && occursin("2 groups that share no taxa", w[1]) + # One shared taxon links everything: no group warning, only the stress one. + linked = [5.0 1 0 0; 2 0 0 1; 0 0 4 1; 0 0 3 1] + @test isempty(Analysis.nmds_warnings(linked, 0.12)) + @test occursin("stress is near zero", only(Analysis.nmds_warnings(linked, 1e-5))) + @test isempty(Analysis.nmds_warnings(linked, NaN)) + end + @testset "nmds_chart" begin coords = [0.1 0.2; -0.3 0.4; 0.5 -0.1] labels = ["s1", "s2", "s3"] diff --git a/test/unit/test_dada2_tables.R b/test/unit/test_dada2_tables.R new file mode 100644 index 00000000..228bae1b --- /dev/null +++ b/test/unit/test_dada2_tables.R @@ -0,0 +1,74 @@ +# Regression checks for CSV assembly, without loading DADA2 or dplyr. +# Run from the repository root: Rscript --vanilla test/unit/test_dada2_tables.R +expressions <- parse("src/pipeline/dada2/dada2_functions.r") +wanted <- c("base_left_join", "write_taxa_table", "write_combined_table") +loaded <- character() +for (expr in expressions) { + if (is.call(expr) && identical(expr[[1]], as.name("<-")) && + is.symbol(expr[[2]]) && as.character(expr[[2]]) %in% wanted) { + eval(expr) + loaded <- c(loaded, as.character(expr[[2]])) + } +} +stopifnot(setequal(loaded, wanted)) + +# Unsorted keys, repeated matches, unmatched rows, overlapping column names, +# NA keys, and helper-column name collisions must preserve the intended order. +x <- data.frame(value = 1:4, key = c("b", "a", "z", NA), + .left_join_position = 11:14) +y <- data.frame(key = c("a", "b", "b", NA), value = 21:24, + .right_join_position = 31:34) +got <- base_left_join(x, y, "key") +stopifnot(identical(got$key, c("b", "b", "a", "z", NA_character_)), + identical(got$value.x, c(1L, 1L, 2L, 3L, 4L)), + identical(got$value.y, c(22L, 23L, 21L, NA_integer_, 24L)), + identical(names(got), c("value.x", "key", ".left_join_position", + "value.y", ".right_join_position"))) +stopifnot(nrow(base_left_join(x[FALSE, ], y, "key")) == 0L, + nrow(base_left_join(x, y[FALSE, ], "key")) == nrow(x)) +# Multiple keys: matching only one component must not produce a match. +a <- data.frame(id = c("a", "a"), key = c("x", "y"), value = 1:2) +b <- data.frame(id = c("a", "b"), key = c("y", "x"), other = 3:4) +stopifnot(identical(base_left_join(a, b, c("id", "key"))$other, + c(NA_integer_, 3L))) + +# Rank names and their suffixed variants are also valid sample IDs. +# Repeated suffixing must preserve both the taxonomy strings and count columns. +tax_collision <- data.frame(Sequence = c("AA", "GG"), Species = c("taxA", "taxG")) +count_collision <- data.frame(Sequence = c("GG", "AA"), Species = c(10L, 20L), + Species.x = c(30L, 40L), Species.x.x = c(50L, 60L)) +collision <- base_left_join(tax_collision, count_collision, "Sequence") +stopifnot(identical(names(collision), + c("Sequence", "Species.x.x.x", "Species.y", "Species.x", "Species.x.x")), + identical(collision[[2]], c("taxA", "taxG")), + identical(collision[[3]], c(20L, 10L)), + identical(collision[[4]], c(40L, 30L)), + identical(collision[[5]], c(60L, 50L))) + +# Fixed expected CSV bytes cover every writer output, including non-syntactic +# sample names and an unmatched taxonomy row. No sequencing dependencies needed. +out <- tempfile("dada2-tables-") +dir.create(out) +index <- data.frame(SeqName = c("ASV3", "ASV1", "ASV2"), + Sequence = c("TT", "GG", "AA")) +tax <- matrix(c("G", "A"), ncol = 1, + dimnames = list(c("GG", "AA"), "Kingdom")) +boot <- matrix(c(90L, 80L), ncol = 1, dimnames = dimnames(tax)) +taxa <- write_taxa_table(tax, boot, index, out, "taxonomy") +counts <- matrix(c(1L, 2L, 3L, 4L, 5L, 6L), nrow = 2, + dimnames = list(c("sample-1", "sample 2"), c("AA", "TT", "GG"))) +write_combined_table(taxa, index, counts, out, "tax_counts.csv", "asv_counts.csv") +expected <- list( + taxonomy.csv = c("SeqName,Sequence,Kingdom", "ASV3,TT,NA", "ASV1,GG,G", "ASV2,AA,A"), + taxonomy_bootstraps.csv = c("SeqName,Sequence,Kingdom_boot", "ASV3,TT,NA", "ASV1,GG,90", "ASV2,AA,80"), + taxonomy_combined.csv = c("SeqName,Sequence,Kingdom,Kingdom_boot", "ASV3,TT,NA,NA", "ASV1,GG,G,90", "ASV2,AA,A,80"), + tax_counts.csv = c("SeqName,Sequence,Kingdom,sample-1,sample 2", "ASV3,TT,NA,3,4", "ASV1,GG,G,5,6", "ASV2,AA,A,1,2"), + asv_counts.csv = c("SeqName,Sequence,sample-1,sample 2", "ASV3,TT,3,4", "ASV1,GG,5,6", "ASV2,AA,1,2") +) +for (name in names(expected)) { + path <- file.path(out, name) + bytes <- readBin(path, "raw", n = file.info(path)$size) + stopifnot(identical(bytes, charToRaw(paste0(paste(expected[[name]], collapse = "\n"), "\n")))) +} +unlink(out, recursive = TRUE) +cat("DADA2 join and five CSV fixtures passed\n") diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index 9f612952..cbf4120b 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -2,8 +2,9 @@ # SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) # # Differential abundance: Benjamini-Hochberg, TSS/RLE size factors, input -# validation, and the negative-binomial fit checked against a direct -# MASS::glm.nb call. Tests needing R or MASS skip loudly when either is absent. +# validation, the negative-binomial fit checked against a direct MASS::glm.nb +# call, and clr_welch checked against direct zCompositions::cmultRepl and Welch +# t.test calls. Tests needing R, MASS or zCompositions skip loudly when absent. using MetaManifold using MetaManifold.Differential @@ -28,6 +29,17 @@ end const HAVE_R = _r_has("stats") const HAVE_MASS = HAVE_R && _r_has("MASS") HAVE_MASS || @info "R or MASS unavailable - the negative-binomial fit tests are SKIPPED" +const HAVE_ZCOMP = HAVE_R && _r_has("zCompositions") +HAVE_ZCOMP || @info "R or zCompositions unavailable - the clr_welch tests are SKIPPED" +const HAVE_GLMGAMPOI = HAVE_MASS && _r_has("glmGamPoi") + +# Issue #37's test contract: the direct glmGamPoi comparisons fail, not skip, +# in CI when glmGamPoi is missing. Locally they skip loudly. This guard is the +# first statement of every glmGamPoi-dependent testset. +const _GLMGAMPOI_IN_CI = get(ENV, "CI", "") == "true" +HAVE_GLMGAMPOI || @info (_GLMGAMPOI_IN_CI ? + "glmGamPoi unavailable IN CI - the shrunken-dispersion tests FAIL" : + "R, MASS or glmGamPoi unavailable - the shrunken-dispersion fit tests are SKIPPED") # Rows per run: Up is split over two ASVs so the route must pool them by genus. const _DA_ROWS = Dict( @@ -163,6 +175,25 @@ end @test_throws ArgumentError DifferentialConfig("css") @test_throws ArgumentError DifferentialConfig("tss", 1.5) @test_throws ArgumentError DifferentialConfig("tss", NaN) + @test DifferentialConfig().method == "nb_glm" + @test DifferentialConfig().replacement_delta == 0.65 + @test DifferentialConfig(; method="clr_welch", replacement_delta=0.3).replacement_delta == 0.3 + @test_throws ArgumentError DifferentialConfig(; method="ancombc") + # The pre-review name, never released, is not accepted. + @test_throws ArgumentError DifferentialConfig(; method="clr_lm") + # The dispersion option: glm_nb is the default and keeps nb_glm's + # per-taxon maximum-likelihood theta; glmGamPoi is the alternative and + # is a negative-binomial setting only. + @test DifferentialConfig().dispersion == "glm_nb" + @test DifferentialConfig(dispersion="glmGamPoi").dispersion == "glmGamPoi" + @test DifferentialConfig(dispersion=" GLMGAMPOI ").dispersion == "glmGamPoi" + @test DifferentialConfig(dispersion=" Glm_Nb ").dispersion == "glm_nb" + @test_throws ArgumentError DifferentialConfig(dispersion="shrinkage") + @test_throws ArgumentError DifferentialConfig(method="clr_welch", dispersion="glmGamPoi") + @test DifferentialConfig(method="clr_welch").dispersion == "glm_nb" + for d in (0, 1, -0.5, NaN) + @test_throws ArgumentError DifferentialConfig(; method="clr_welch", replacement_delta=d) + end end @testset "Input validation" begin @@ -200,6 +231,9 @@ end byt = Dict(r["taxon"] => r for r in res["rows"]) @test res["status"] == "ok" @test res["diagnostics"]["n_tested"] == 3 + @test res["method"] == "Negative-binomial GLM per taxon (MASS::glm.nb, log link) " * + "with a TSS size-factor offset; Wald test of the group " * + "coefficient; Benjamini-Hochberg adjustment over the fitted taxa" @test all(r -> r["status"] == "ok", res["rows"]) @test isapprox(byt["t_up"]["estimate"], log(4); atol=0.5) @test isapprox(byt["t_up"]["log2_fold_change"], 2; atol=0.8) @@ -233,6 +267,103 @@ end @test r["pvalue"] ≈ direct[3] rtol=1e-6 @test r["dispersion_theta"] ≈ direct[4] rtol=1e-6 end + @test res["config"]["dispersion"] == "glm_nb" + end + end + end + + @testset "glmGamPoi path matches a direct glmGamPoi::test_de call" begin + if !HAVE_GLMGAMPOI + # Issue #37's contract: these comparisons fail, not skip, in CI. + @test !_GLMGAMPOI_IN_CI + @test_skip false + else + res = differential_abundance(effect, effect_samples, effect_taxa, effect_groups; + reference="A", contrast="B", + config=DifferentialConfig(dispersion="glmGamPoi")) + @test res["status"] == "ok" + @test res["method"] == "Negative-binomial GLM per taxon (glmGamPoi::glm_gp, log link) " * + "with a TSS size-factor offset; quasi-likelihood F-test of the " * + "group contrast (glmGamPoi::test_de) with overdispersion shrinkage; " * + "Benjamini-Hochberg adjustment over the fitted taxa" + @test res["config"]["dispersion"] == "glmGamPoi" + byt = Dict(r["taxon"] => r for r in res["rows"]) + off = log.(tss_factors(effect, effect_samples)) + g = effect_groups + # The reference is a direct glmGamPoi::test_de call here in R, not + # the fitted path under test: the same model and the same published + # quasi-likelihood F-test, computed independently. lfc is log2 and + # lfc_se its standard error, so both are scaled by log(2) to meet + # the natural-log convention of every method's estimate and se. + # theta inverts glm_gp's negative-binomial overdispersion, not the + # quasi-likelihood scale the F-test divides by. One matrix column + # per quantity, one row per taxon. + direct = rcopy(R""" + local({ + g <- factor($g, levels = c("A", "B")) + gp <- glmGamPoi::glm_gp(t($effect), design = model.matrix(~ g), + size_factors = exp($off), overdispersion_shrinkage = TRUE) + td <- glmGamPoi::test_de(gp, contrast = "gB", max_lfc = Inf, + compute_lfc_se = TRUE) + cbind(td$pval, td$f_statistic, td$lfc * log(2), td$lfc_se * log(2), + 1 / gp$overdispersions) + }) + """) + for (j, t) in enumerate(effect_taxa) + r = byt[t] + @test r["status"] == "ok" + @test r["pvalue"] ≈ direct[j, 1] rtol=1e-6 + @test r["statistic"] ≈ direct[j, 2] rtol=1e-6 + @test r["estimate"] ≈ direct[j, 3] rtol=1e-6 + @test r["standard_error"] ≈ direct[j, 4] rtol=1e-6 + @test r["log2_fold_change"] ≈ direct[j, 3] / log(2) rtol=1e-6 + @test r["dispersion_theta"] ≈ direct[j, 5] rtol=1e-6 + end + end + end + + @testset "glmGamPoi unusable overdispersions are isolated per taxon" begin + if !HAVE_GLMGAMPOI + # Issue #37's contract: these comparisons fail, not skip, in CI. + @test !_GLMGAMPOI_IN_CI + @test_skip false + else + # test_de refits the reduced model from the shrinkage list's + # dispersion trend and divides by ql_disp_shrunken, so altering + # `overdispersions` after the fit changes only what theta and the + # boundary check read, never the test itself. + MetaManifold.RRuntime.with_r_lock() do + reval(raw""" + test_original_gp <- get("glm_gp", asNamespace("glmGamPoi")) + test_od <- 0 + assignInNamespace("glm_gp", function(...) { + fit <- test_original_gp(...) + fit$overdispersions[1] <- test_od + fit + }, ns="glmGamPoi") + """) + try + off = log.(tss_factors(effect, effect_samples)) + # A zero overdispersion is the Poisson boundary: marked, with + # the statistics kept and no finite theta, as under glm_nb. + # A negative or non-finite one fails its taxon alone. + for (od, status, tested) in ((0.0, "boundary", true), (-1.0, "failed", false), + (NaN, "failed", false)) + RCall.globalEnv[:test_od] = od + rows = fit_nb(effect, effect_groups, off; levels=("A", "B"), + dispersion="glmGamPoi") + @test rows.status[1] == status + @test isnothing(rows.pvalue[1]) == !tested + @test isnothing(rows.theta[1]) + @test occursin("overdispersion", rows.note[1]) + @test all(rows.status[2:end] .== "ok") + end + finally + reval(raw""" + assignInNamespace("glm_gp", test_original_gp, ns="glmGamPoi") + rm(test_original_gp, test_od) + """) + end end end end @@ -268,6 +399,72 @@ end end end + @testset "A sample with no reads is left out and named, not a refusal" begin + if !HAVE_MASS + @test_skip false + else + # One empty sample per group, as a blank emptied by primer trimming would be. + counts = vcat(effect, zeros(Int, 2, 3)) + samples = vcat(effect_samples, ["blank_A", "blank_B"]) + groups = vcat(effect_groups, ["A", "B"]) + res = differential_abundance(counts, samples, effect_taxa, groups; + reference="A", contrast="B") + ref = differential_abundance(effect, effect_samples, effect_taxa, effect_groups; + reference="A", contrast="B") + @test res["diagnostics"]["samples_without_reads"] == ["blank_A", "blank_B"] + @test ref["diagnostics"]["samples_without_reads"] == String[] + @test res["n_samples"] == ref["n_samples"] + @test [r["pvalue"] for r in res["rows"]] ≈ [r["pvalue"] for r in ref["rows"]] + @test !any(s -> s["sample"] in ("blank_A", "blank_B"), res["size_factors"]) + # A group left with no samples is still refused, and the message says why. + e = try + differential_abundance(vcat(effect[13:24, :], zeros(Int, 1, 3)), + vcat(effect_samples[13:24], ["blank_A"]), effect_taxa, + vcat(effect_groups[13:24], ["A"]); reference="A", contrast="B") + nothing + catch err + err + end + @test e isa ArgumentError + @test occursin("blank_A", sprint(showerror, e)) + end + end + + @testset "A taxon absent from one group is refused, not reported as p≈1" begin + if !HAVE_MASS + @test_skip false + else + # 'only_b' has no reads in any A sample: complete separation. + only_b = vcat(fill(0, 12), [50, 80, 120, 30, 60, 90, 40, 70, 110, 55, 65, 85]) + counts = hcat(effect, only_b) + taxa = vcat(effect_taxa, ["only_b"]) + res = differential_abundance(counts, effect_samples, taxa, effect_groups; + reference="A", contrast="B") + byt = Dict(r["taxon"] => r for r in res["rows"]) + s = byt["only_b"] + @test s["status"] == "failed" + @test occursin("'A'", s["note"]) && occursin("complete separation", s["note"]) + @test all(isnothing, (s["estimate"], s["standard_error"], s["pvalue"], s["padj"])) + # The BH family is the three other taxa only. + @test res["diagnostics"]["n_tested"] == 3 + @test [byt[t]["padj"] for t in effect_taxa] ≈ + bh_adjust([byt[t]["pvalue"] for t in effect_taxa]) + + # Control: the direct fit converges and reports p near 1, which is + # the value the check above keeps out of the results. + off = log.(tss_factors(counts, effect_samples)) + g = effect_groups + direct = rcopy(R""" + local({ + fit <- MASS::glm.nb($only_b ~ factor($g, levels = c("A", "B")) + offset($off), + control = glm.control(maxit = 100)) + c(as.numeric(fit$converged), summary(fit)$coefficients[2, 4]) + }) + """) + @test direct[1] == 1 && direct[2] > 0.9 + end + end + @testset "No fittable taxon is an explicit error" begin if !HAVE_MASS @test_skip false @@ -304,6 +501,265 @@ end @test isnothing(SV._config_value_error("analysis.differential.offset", "rle")) @test !isnothing(SV._config_value_error("analysis.differential.offset", "css")) @test isnothing(SV._config_value_error("analysis.differential.min_prevalence", 0.1)) + @test isnothing(SV._config_value_error("analysis.differential.method", "clr_welch")) + @test !isnothing(SV._config_value_error("analysis.differential.method", "ancombc")) + # Like every other enum key, the config route takes exact spellings. + for dispersion in ("glm_nb", "glmGamPoi") + @test isnothing(SV._config_value_error("analysis.differential.dispersion", dispersion)) + end + # "ml" was the pre-release spelling of glm_nb and is not accepted; + # case and spaces are DifferentialConfig's lenience, not the route's. + for dispersion in ("shrinkage", "ml", "", nothing, 1, true, ["glm_nb"], + " GLM_NB ", " GLMGAMPOI ", "GLMGAMPOI") + @test !isnothing(SV._config_value_error("analysis.differential.dispersion", dispersion)) + end + end + + @testset "CLR transform" begin + @test clr_transform([1.0 exp(1) exp(2)]) ≈ [-1.0 0.0 1.0] + z = clr_transform([1.0 2 3 4; 5 1 1 9; 0.5 0.25 8 2]) + @test all(abs.(sum(z; dims=2)) .< 1e-12) + # Scale invariance: a sample's CLR does not depend on its library size. + @test clr_transform([10.0 20 30 40]) ≈ clr_transform([1.0 2 3 4]) + @test_throws ArgumentError clr_transform([0.0 1 2]) + @test_throws ArgumentError clr_transform([NaN 1 2]) + end + + clr_config = DifferentialConfig(; method="clr_welch") + + @testset "CLR + Welch: estimate is the difference of group mean CLRs" begin + if !HAVE_ZCOMP + @test_skip false + else + res = differential_abundance(effect, effect_samples, effect_taxa, effect_groups; + reference="A", contrast="B", config=clr_config) + @test res["effect"] == Dict("key" => "estimate", "label" => "CLR difference") + @test isempty(res["size_factors"]) + @test res["config"]["method"] == "clr_welch" && !haskey(res["config"], "offset") + @test res["diagnostics"]["zero_replacement"]["zeros_replaced"] == 0 + @test occursin("Welch", res["method"]) && occursin("cmultRepl", res["method"]) + # Known answer, independent of R: the estimate is mean(B) - mean(A) + # of each taxon's CLR. + z = clr_transform(Float64.(effect)) + a = effect_groups .== "A"; b = .!a + expected = vec(sum(z[b, :]; dims=1)) ./ count(b) .- vec(sum(z[a, :]; dims=1)) ./ count(a) + byt = Dict(r["taxon"] => r for r in res["rows"]) + for (j, t) in enumerate(effect_taxa) + @test byt[t]["status"] == "ok" + @test byt[t]["estimate"] ≈ expected[j] rtol=1e-8 + @test isnothing(byt[t]["log2_fold_change"]) && isnothing(byt[t]["dispersion_theta"]) + @test 0 <= byt[t]["pvalue"] <= 1 && !isnothing(byt[t]["padj"]) + end + @test byt["t_up"]["estimate"] > 0 && byt["t_down"]["estimate"] < 0 + @test byt["t_up"]["padj"] < 0.05 && byt["t_down"]["padj"] < 0.05 + + @testset "matches a direct Welch t.test, with unequal groups" begin + # 12 A against 3 B: the case where pooled and Welch variances differ. + keep = 1:15 + g = effect_groups[keep] + un = differential_abundance(effect[keep, :], effect_samples[keep], effect_taxa, g; + reference="A", contrast="B", config=clr_config) + ub = Dict(r["taxon"] => r for r in un["rows"]) + zk = clr_transform(Float64.(effect[keep, :])) + for (j, t) in enumerate(effect_taxa) + y = zk[:, j] + direct = rcopy(R""" + local({ + tt <- stats::t.test($y[$g == "B"], $y[$g == "A"], var.equal = FALSE) + c(tt$estimate[[1]] - tt$estimate[[2]], tt$stderr, tt$statistic[[1]], tt$p.value) + }) + """) + @test ub[t]["estimate"] ≈ direct[1] rtol=1e-10 + @test ub[t]["standard_error"] ≈ direct[2] rtol=1e-10 + @test ub[t]["statistic"] ≈ direct[3] rtol=1e-10 + @test ub[t]["pvalue"] ≈ direct[4] rtol=1e-8 + end + # Control: the pooled-variance test gives a different p-value + # here, so the parity above can tell the two apart. + y = zk[:, 1] + pooled = rcopy(R"stats::t.test($y[$g == 'B'], $y[$g == 'A'], var.equal = TRUE)$p.value") + @test !isapprox(ub["t_up"]["pvalue"], pooled; rtol=1e-3) + end + end + # Refused before R is reached: Welch needs a variance in each group. + @test_throws ArgumentError differential_abundance(effect[[1, 2, 13], :], effect_samples[[1, 2, 13]], + effect_taxa, ["A", "A", "B"]; reference="A", contrast="B", config=clr_config) + end + + @testset "Zero replacement matches a direct zCompositions::cmultRepl call" begin + if !HAVE_ZCOMP + @test_skip false + else + x = [0 3 0 12 5; 7 0 1 0 9; 2 2 2 2 2; 0 6 4 30 1; 5 1 0 3 0] + out = replace_zeros(x; frac=0.65) + direct = rcopy(R""" + as.matrix(zCompositions::cmultRepl($(Float64.(x)), label = 0, method = "GBM", + output = "prop", frac = 0.65, z.warning = 1, z.delete = FALSE, + suppress.print = TRUE)) + """) + @test out.proportions ≈ direct rtol=1e-12 + @test out.zeros_replaced == count(==(0), x) + @test all(>(0), out.proportions) + @test vec(sum(out.proportions; dims=2)) ≈ ones(5) + # The ratios among a sample's observed taxa are kept. + @test out.proportions[1, 2] / out.proportions[1, 4] ≈ 3 / 12 + # A sample without zeros is its closure; it has no imputed mass. + @test out.proportions[3, :] ≈ fill(0.2, 5) + @test out.imputed_mass[3] == 0.0 + @test out.imputed_mass[1] ≈ out.proportions[1, 1] + out.proportions[1, 3] + # No zero at all: cmultRepl is not called, the closure is returned. + @test replace_zeros([1 3; 2 2]).proportions ≈ [0.25 0.75; 0.5 0.5] + + msg = try replace_zeros([0 3 4; 1 0 5; 0 2 6]; taxa=["lone", "q", "r"]); "" catch e; e.msg end + @test occursin("'lone'", msg) && occursin("min_prevalence", msg) + msg = try replace_zeros([0 0; 3 4; 1 2]; samples=["empty", "b", "c"]); "" catch e; e.msg end + @test occursin("'empty'", msg) + @test_throws ArgumentError replace_zeros(reshape([1, 2, 3], 3, 1)) + end + end + + @testset "CLR: cmultRepl output is checked sample by sample" begin + # Pure Julia: no R is needed to check what cmultRepl hands back. + check = MetaManifold.Differential._check_compositions + mask = Bool[1 0 0; 0 0 0] + good = [0.1 0.3 0.6; 0.2 0.3 0.5] + @test check(good, mask, ["a", "b"]) === good + for (bad, why) in (([0.0 0.4 0.6; 0.2 0.3 0.5], "a replaced zero left at 0"), + ([NaN 0.4 0.6; 0.2 0.3 0.5], "a non-finite value"), + ([0.1 0.4 0.6; 0.2 0.3 0.5], "a row that does not sum to 1")) + msg = try check(bad, mask, ["first", "second"]); "" catch e; e.msg end + @test occursin("'first'", msg) && occursin("min_prevalence", msg) + end + # The failing sample is the one named, not always the first. + msg = try check([0.1 0.3 0.6; 0.2 0.3 0.6], mask, ["a", "b"]); "" catch e; e.msg end + @test occursin("'b'", msg) && occursin("0 replaced zeros", msg) + @test_throws ErrorException check(good[:, 1:2], mask, ["a", "b"]) + end + + @testset "CLR: min_prevalence is applied before zero replacement" begin + if !HAVE_ZCOMP + @test_skip false + else + # Six samples x five taxa: 'ghost' has no reads anywhere, 'rare' one + # read in one sample, 'r' is in four of six, 'p' and 'q' in all. + x = [10 20 2 0 0; 12 18 0 0 0; 11 19 3 3 0; 30 8 0 0 0; 28 9 4 0 0; 33 7 5 0 0] + s = ["s$i" for i in 1:6] + taxa = ["p", "q", "r", "rare", "ghost"] + g = ["A", "A", "A", "B", "B", "B"] + res = differential_abundance(x, s, taxa, g; reference="A", contrast="B", + config=DifferentialConfig("tss", 0.5; method="clr_welch")) + byt = Dict(r["taxon"] => r for r in res["rows"]) + @test byt["ghost"]["status"] == "filtered" + @test occursin("not part of the composition", byt["ghost"]["note"]) + @test byt["rare"]["status"] == "filtered" + @test occursin("min_prevalence", byt["rare"]["note"]) + @test all(t -> byt[t]["status"] == "ok", ("p", "q", "r")) + zr = res["diagnostics"]["zero_replacement"] + @test zr["n_taxa_in_composition"] == 3 && zr["n_taxa_unobserved"] == 1 + # Only r's two zeros are replaced; rare's five zeros are not. + @test zr["zeros_replaced"] == 2 + @test 0 < zr["max_imputed_fraction"] < 1 + @test zr["median_imputed_fraction"] == 0.0 + + # The fit saw the 3-taxon composition: 'rare' contributed nothing. + z = clr_transform(replace_zeros(x[:, 1:3]).proportions) + a = g .== "A" + @test byt["p"]["estimate"] ≈ sum(z[.!a, 1]) / 3 - sum(z[a, 1]) / 3 rtol=1e-8 + + # Without the threshold, 'rare' (one sample) reaches cmultRepl, + # whose prior is undefined for it; the refusal names the fix. + msg = try + differential_abundance(x, s, taxa, g; reference="A", contrast="B", config=clr_config) + "" + catch e + e.msg + end + @test occursin("'rare'", msg) && occursin("min_prevalence", msg) + + @test_throws ArgumentError differential_abundance(x[:, [1, 5]], s, ["p", "ghost"], g; + reference="A", contrast="B", config=clr_config) + end + end + + @testset "CLR: a sparse table with rare taxa runs once min_prevalence is set" begin + if !HAVE_ZCOMP + @test_skip false + else + # 24 samples (12 A, 12 B) x 211 taxa, about 90% zeros, built from a + # fixed formula: 6 common taxa in every sample ('c1' is four times + # as abundant in B), 4 moderate taxa in two thirds of the samples, + # one sparse taxon in 3 of 24 samples (87.5% zeros, which cmultRepl's + # default z.delete would drop), and 200 rare taxa in 1 or 2 samples. + n = 24 + grp = vcat(fill("A", 12), fill("B", 12)) + common = [100 + (37i + 11j) % 200 for i in 1:n, j in 1:6] + common[13:24, 1] .*= 4 + moderate = [(i + j) % 3 == 0 ? 0 : 5 + (i * j) % 7 for i in 1:n, j in 1:4] + sparse = [i in (1, 9, 17) ? 4 : 0 for i in 1:n] + rare = zeros(Int, n, 200) + for k in 1:200 + rare[(7k) % n + 1, k] = 1 + k % 3 + iseven(k) && (rare[(13k) % n + 1, k] = 1 + k % 2) + end + counts = hcat(common, moderate, sparse, rare) + taxa = vcat(["c$j" for j in 1:6], ["m$j" for j in 1:4], ["sparse"], ["rare$k" for k in 1:200]) + samples = ["s$i" for i in 1:n] + @test count(==(0), counts) / length(counts) > 0.85 + @test all(k -> 1 <= count(>(0), rare[:, k]) <= 2, 1:200) + + # Unfiltered, the rare taxa reach the replacement and are refused. + msg = try + differential_abundance(counts, samples, taxa, grp; reference="A", contrast="B", + config=clr_config) + "" + catch e + e.msg + end + @test occursin("min_prevalence", msg) + + # min_prevalence = 0.1 keeps taxa seen in 3 or more of 24 samples. + res = differential_abundance(counts, samples, taxa, grp; reference="A", contrast="B", + config=DifferentialConfig("tss", 0.1; method="clr_welch")) + byt = Dict(r["taxon"] => r for r in res["rows"]) + d = res["diagnostics"] + @test d["n_tested"] == 11 && d["n_filtered"] == 200 && d["n_failed"] == 0 + @test res["status"] == "ok" + @test all(k -> byt["rare$k"]["status"] == "filtered", 1:200) + # The sparse taxon is tested, not silently dropped by cmultRepl. + @test byt["sparse"]["status"] == "ok" && !isnothing(byt["sparse"]["pvalue"]) + @test byt["c1"]["estimate"] > 0 && byt["c1"]["padj"] < 0.05 + zr = d["zero_replacement"] + @test zr["n_taxa_in_composition"] == 11 + @test zr["zeros_replaced"] == count(==(0), counts[:, 1:11]) + @test isempty(zr["warnings"]) + + # What filtering bought: replacing over every taxon seen in 2 or + # more samples instead puts far more imputed mass in each sample. + wide = findall(j -> count(>(0), counts[:, j]) >= 2, axes(counts, 2)) + unfiltered = replace_zeros(counts[:, wide]) + med(v) = (s = sort(v); (s[n ÷ 2] + s[n ÷ 2 + 1]) / 2) + @test med(unfiltered.imputed_mass) > 5 * zr["median_imputed_fraction"] + @test maximum(unfiltered.imputed_mass) > zr["max_imputed_fraction"] + end + end + + @testset "CLR: constant log-ratios are refused with their reason" begin + if !HAVE_ZCOMP + @test_skip false + else + # p and q in the same 1:1 ratio everywhere: both CLRs are constant + # (zero), so neither can be fitted and the whole test is refused. + e = try + differential_abundance([5 5; 7 7; 9 9; 4 4], ["a", "b", "c", "d"], ["p", "q"], + ["A", "A", "B", "B"]; reference="A", contrast="B", config=clr_config) + nothing + catch err + err + end + @test e isa ErrorException + @test occursin("no taxon could be fitted", e.msg) + @test occursin("constant", e.msg) + end end @testset "Route: two runs through the in-process router" begin @@ -345,4 +801,134 @@ end @test r.status == 404 end end + + @testset "Route: clr_welch selected by the study config" begin + _da_fixture() do + root = MetaManifold.Server.ServerState._root[] + write(joinpath(root, "data", "studyD", "pipeline.yml"), + "analysis:\n differential:\n method: clr_welch\n replacement_delta: 0.5\n") + r = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")], + "table" => "merged", "rank" => "Genus")) + body = JSON3.read(String(r.body)) + if !HAVE_ZCOMP + HAVE_R ? (@test r.status == 503 && body.error == "r_unavailable") : + (@test_skip r.status == 200) + else + @test r.status == 200 + @test body.effect.key == "estimate" && body.effect.label == "CLR difference" + @test body.config.method == "clr_welch" && body.config.replacement_delta == 0.5 + @test isempty(body.size_factors) + @test body.diagnostics.zero_replacement.zeros_replaced == 0 + @test body.diagnostics.zero_replacement.median_imputed_fraction == 0 + byt = Dict(String(x.taxon) => x for x in body.rows) + @test byt["Up"].estimate > 0 && byt["Down"].estimate < 0 + @test all(x -> x.log2_fold_change === nothing, body.rows) + @test occursin("CLR difference", body.figure.layout.xaxis.title) + end + + write(joinpath(root, "data", "studyD", "pipeline.yml"), + "analysis:\n differential:\n method: clr_welch\n replacement_delta: 1.5\n") + r = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")], + "table" => "merged", "rank" => "Genus")) + @test r.status == 400 + @test JSON3.read(String(r.body)).error == "invalid_config" + end + end + + ## Without zCompositions, clr_welch refuses with ZCompositionsUnavailable and + # the route answers 503. CI installs the package into R's base library, where + # it cannot be hidden from .libPaths(), so the testset instead binds a + # `requireNamespace` in R's global environment that reports zCompositions + # missing and defers to base for every other package. reval evaluates there, + # so the production probe sees the package as absent on any machine. + @testset "clr_welch without zCompositions is a 503" begin + if !HAVE_R + @test_skip false # the clr_welch route test above covers this case + else + reval("""requireNamespace <- function(package, ...) { + if (identical(package, "zCompositions")) FALSE + else base::requireNamespace(package, ...) + }""") + try + @test !rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)")) + e = try + differential_abundance([5 6; 7 3; 9 4; 4 8], ["a", "b", "c", "d"], ["p", "q"], + ["A", "A", "B", "B"]; reference="A", contrast="B", + config=DifferentialConfig(; method="clr_welch")) + nothing + catch err + err + end + @test e isa ZCompositionsUnavailable + @test occursin("renv::restore()", sprint(showerror, e)) + + _da_fixture() do + root = MetaManifold.Server.ServerState._root[] + write(joinpath(root, "data", "studyD", "pipeline.yml"), + "analysis:\n differential:\n method: clr_welch\n") + r = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")], + "table" => "merged", "rank" => "Genus")) + body = JSON3.read(String(r.body)) + @test r.status == 503 + @test body.error == "r_unavailable" + @test occursin("zCompositions", body.message) + end + finally + reval("rm(requireNamespace)") + end + @test !rcopy(Bool, reval("exists('requireNamespace', envir = globalenv(), inherits = FALSE)")) + @test rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)")) == HAVE_ZCOMP + end + end + + ## Without glmGamPoi, dispersion: glmGamPoi refuses with + # GlmGamPoiUnavailable and the route answers 503. As above, a + # `requireNamespace` bound in R's global environment reports glmGamPoi + # missing and defers to base for every other package, so the production + # probe sees the package as absent on any machine. MASS is probed first + # and must be real, so this testset needs it. + @testset "nb_glm without glmGamPoi refuses with GlmGamPoiUnavailable and a 503" begin + if !HAVE_MASS + @test_skip false # without MASS the glm_nb path's own guard fires first + else + reval("""requireNamespace <- function(package, ...) { + if (identical(package, "glmGamPoi")) FALSE + else base::requireNamespace(package, ...) + }""") + try + @test !rcopy(Bool, reval("requireNamespace('glmGamPoi', quietly = TRUE)")) + e = try + fit_nb([5 6; 7 3; 9 4; 4 8], ["A", "A", "B", "B"], [0.0, 0.0, 0.0, 0.0]; + levels=("A", "B"), dispersion="glmGamPoi") + nothing + catch err + err + end + @test e isa GlmGamPoiUnavailable + @test occursin("renv::restore()", sprint(showerror, e)) + @test occursin("dispersion: glm_nb", sprint(showerror, e)) + _da_fixture() do + root = MetaManifold.Server.ServerState._root[] + write(joinpath(root, "data", "studyD", "pipeline.yml"), + "analysis:\n differential:\n dispersion: glmGamPoi\n") + response = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")], + "table" => "merged", "rank" => "Genus")) + body = JSON3.read(String(response.body)) + @test response.status == 503 + @test body.error == "r_unavailable" + @test occursin("glmGamPoi", body.message) + end + # The override defers to base for everything else, so the + # default dispersion still fits normally with it in place: + # the injected-effect dataset, measured with MASS::glm.nb, + # reaches status ok on its own. + r = fit_nb(effect, effect_groups, log.(tss_factors(effect, effect_samples)); + levels=("A", "B")) + @test all(r.status .== "ok") + finally + reval("rm(requireNamespace)") + end + @test !rcopy(Bool, reval("exists('requireNamespace', envir = globalenv(), inherits = FALSE)")) + end + end end diff --git a/test/unit/test_duckdb_store.jl b/test/unit/test_duckdb_store.jl index 1fd6c440..69a7dc33 100644 --- a/test/unit/test_duckdb_store.jl +++ b/test/unit/test_duckdb_store.jl @@ -25,6 +25,37 @@ rm(dir; recursive=true) end + @testset "load_results_db leaves no WAL behind, so tagging survives the swap" begin + # A database renamed into place with its data still in the WAL arrived + # with neither its tables nor its category columns: the WAL kept its + # .building name beside it. + dir = mktempdir() + ranks = ["Domain", "Supergroup", "Division", "Subdivision", "Class", + "Order", "Family", "Genus", "Species"] + header = join(vcat(["SeqName"], ranks, ranks .* "_boot", ["s1", "s2"]), ",") + row(name, taxa, boot, a, b) = join(vcat([name], taxa, fill(string(boot), length(ranks)), + [string(a), string(b)]), ",") + euk = ["Eukaryota", "Obazoa", "Opisthokonta", "Fungi", "Ascomycota", + "Saccharomycetales", "Saccharomycetaceae", "Saccharomyces", "Saccharomyces_cerevisiae"] + bac = ["Bacteria", "Bacteria_X", "Pseudomonadota", "Pseudomonadota_X", "Gammaproteobacteria", + "Enterobacterales", "Enterobacteriaceae", "Escherichia", "Escherichia_coli"] + write(joinpath(dir, "merged.csv"), + join([header, row("seq1", euk, 100, 10, 5), row("seq2", bac, 80, 3, 7)], "\n") * "\n") + tagging = Dict("source" => "DADA2", "max_x" => -1, "category_sets" => ["default"], + "library_path" => joinpath(@__DIR__, "..", "..", "config", "defaults", "composition.yml")) + + load_results_db(dir; tagging) + @test !isfile(joinpath(dir, "results.duckdb.building.wal")) + @test !isfile(joinpath(dir, "results.duckdb.building")) + with_results_db(dir) do con + cols = string.(DataFrame(DBInterface.execute(con, + "SELECT column_name FROM information_schema.columns WHERE table_name = 'merged'")).column_name) + @test "Category__default" in cols + @test nrow(DataFrame(DBInterface.execute(con, "SELECT * FROM merged"))) == 2 + end + rm(dir; recursive=true) + end + @testset "load_results_db with swarm_dir" begin merge_dir = mktempdir() swarm_dir = mktempdir() diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index df2e7847..a3053b97 100644 --- a/test/unit/test_install_pins.jl +++ b/test/unit/test_install_pins.jl @@ -29,6 +29,15 @@ const EXPECTED_PLATFORMS = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "ma is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) +"""Return the first step of `job` whose `uses:` names `action`, or nothing.""" +function ci_step_using(job, action) + for step in get(job, "steps", []) + startswith(get(step, "uses", ""), action) && return step + end + return nothing +end + + @testset "install pins" begin @test isfile(PINS_PATH) pins = YAML.load_file(PINS_PATH) @@ -176,16 +185,24 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) @testset "CI installs what is pinned" begin ci = YAML.load_file(CI_PATH) - matrix = ci["jobs"]["test"]["strategy"]["matrix"] + job = ci["jobs"]["test"] # Julia is the one version CI cannot read from the pin file, because nothing can # be read before Julia exists. It is therefore duplicated, and this is the test # that makes the duplication safe. - @test matrix["julia-version"] == [pins["runtimes"]["julia"]["version"]] + # + # Read from the `Set up Julia` step rather than from a matrix: the matrix was + # removed because GitHub appends a matrix combination to the posted check name + # (see "a required check name is a stable identifier" below). The step is located + # by its `uses:` rather than by index, so reordering the steps cannot make this + # assertion quietly vanish. + setup = ci_step_using(job, "julia-actions/setup-julia") + @test setup !== nothing + @test setup["with"]["version"] == pins["runtimes"]["julia"]["version"] # A floating runner would carry the R apt pin, which names a 24.04 build, off to # whatever the next LTS ships. - @test matrix["os"] == ["ubuntu-24.04"] + @test job["runs-on"] == "ubuntu-24.04" @test occursin("2404", pins["runtimes"]["r"]["apt_version"]) # Everything else CI installs must be read from the pin file at run time rather @@ -235,4 +252,38 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) @test occursin("fetch_pinned \"$ref\"", runs) end end + + @testset "a required check name is a stable identifier" begin + ci = YAML.load_file(CI_PATH) + + # 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 a + # required check that never reports can never be satisfied, so every pull request + # deadlocks until an admin bypasses the rule. The bump that triggers it is a + # one-line edit to this very file's subject matter. + # + # TWO conditions are needed for GitHub to post the `name:` field verbatim, and + # the first without the second is the trap this file exists to close: + # + # 1. the name must not interpolate a pin; and + # 2. the job must not have a `strategy.matrix`. + # + # MEASURED 2026-09-21 on hyperpolymath/MetaManifold-WebUI#39: a job named exactly `Julia tests` with a 1x1 + # 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 version was still embedded. A guard asserting + # only (1) PASSED on that broken fix, because it asked about the YAML field while + # the ruleset reads the rendered check name. + # + # Asserted over EVERY job rather than the one job we happen to require today: + # the rule is "a job name is a stable identifier", and a rule enforced at each + # door in turn is a rule that a new door escapes. If a matrix is ever genuinely + # needed, the required context must move to a matrix-free aggregator job and this + # assertion be re-scoped to that job -- not deleted. + for (id, job) in ci["jobs"] + name = get(job, "name", id) + @test !occursin("\${{", name) + @test !haskey(get(job, "strategy", Dict{String,Any}()), "matrix") + end + end end diff --git a/test/unit/test_log.jl b/test/unit/test_log.jl index b55dfb79..04940c81 100644 --- a/test/unit/test_log.jl +++ b/test/unit/test_log.jl @@ -69,6 +69,32 @@ rm(dir; recursive=true) end + @testset "a re-run keeps its narrative and one tool section" begin + dir = mktempdir() + PipelineLog.reset_log(dir) + mkpath(joinpath(dir, "cutadapt", "logs")) + stats = joinpath(dir, "cutadapt", "logs", "cutadapt_primer_trimming_stats.txt") + ctx = ProjectCtx(dir, "", "", "", "") + + PipelineLog.pipeline_log(dir, "first run complete") + write(stats, "first stats\n") + PipelineLog.finalise_log(ctx) + # The re-run's narrative is appended after the first run's tool section. + PipelineLog.pipeline_log(dir, "second run complete") + write(stats, "second stats\n") + PipelineLog.finalise_log(ctx) + + content = read(joinpath(dir, "pipeline.log"), String) + @test occursin("first run complete", content) + @test occursin("second run complete", content) + @test count("Tool logs appended", content) == 1 + @test !occursin("first stats", content) + @test occursin("second stats", content) + # The narrative precedes the single, current tool section. + @test findfirst("second run complete", content)[1] < findfirst("Tool logs appended", content)[1] + rm(dir; recursive=true) + end + ## Drift guard # A realistic populated run directory. Every path here was observed in a real # run (projects/Nutria_CvL/Multiplex, read-only) or is written by a code path diff --git a/test/unit/test_occupancy_disagreement.jl b/test/unit/test_occupancy_disagreement.jl new file mode 100644 index 00000000..c1338cb7 --- /dev/null +++ b/test/unit/test_occupancy_disagreement.jl @@ -0,0 +1,359 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# MMW-01: the occupancy probe, and the two-thread disagreement test. +# +# The vsearch stage runs two independent searches, ASV and OTU, concurrently in +# the full pipeline and one after the other in a single-stage re-run. The +# high-water-mark algebra (occupancy-types, resource-algebra `Instances/HWM`) +# predicts: +# +# * cost commutes: the work done, measured as Julia's allocation total for the +# stage, is the same in every order (choreographic-types A-2). This is +# ASSERTED, and it can fail. +# * each search's own peak is the same in every order; +# * the stage's peak does not commute: serially it is the larger of the two +# searches' peaks (each child exits holding nothing, so its drift is 0), while +# concurrently it can approach their sum (A-3). These are RECORDED in the test +# log, not asserted. +# +# If the stage peak came out the same in every order, the prediction would be +# falsified for this workload: the searches' memory would not overlap. +# +# The fixture is the smallest of a calibrated ladder (250, 500, 1000, 2000 +# reference sequences of 400 bp; 300 ASV and 150 OTU queries) for which, in five +# repeats each on a 4-core host, both searches were resident together for at +# least 15 samples at 10 ms and the concurrent stage peak was at least 1.9 times +# the larger search's: 1000 sequences (17-20 samples, 2.0 times). 250 and 500 +# also overlap, by 12-18 samples, too close to the edge for a slower runner. It is +# generated here, deterministically, so no data is checked in. +# +# vsearch runs with --threads 1 here. With several threads it writes its hits in +# whatever order its threads finish, and `_sort_hits_by_query!` then does a +# different amount of work (its sort key splits each line on every comparison), +# which moved the stage's allocation total by up to 12% between repeats of the +# SAME order. On one thread the output order is fixed, and the three orders +# agreed to within 0.08% over five repeats each. Run directly with +# julia --project=. -t 2 test/unit/test_occupancy_disagreement.jl +# and set OCCUPANCY_RESULTS= to also write the numbers as YAML. + +using Test, YAML, OrderedCollections +using MetaManifold + +const MP = MetaManifold.MemoryProbe +const PV = MetaManifold.Provenance +const TL = MetaManifold.Tools +# Not const: other test files bind SV in Main the same way, and runtests.jl +# includes them all into one module. +SV = MetaManifold.Server + +const PROBE_ON = Dict("occupancy_probe" => Dict("enabled" => true, "interval_ms" => 10)) + +# Relative spread of the stage's Julia allocation total across the three orders +# that still counts as "the same cost". Measured: under 0.08% (see above). +const COST_TOLERANCE = 0.01 + +## Fixture +# A 64-bit LCG, so the fixture is the same bytes on every Julia version (the +# streams of Random's generators are not guaranteed to be). +mutable struct _LCG + s :: UInt64 +end +_next!(g::_LCG) = (g.s = g.s * 0x5851f42d4c957f2d + 0x14057b7ef767814f; g.s >> 33) + +const _NT = (UInt8('A'), UInt8('C'), UInt8('G'), UInt8('T')) +_randseq(g, n) = String([_NT[_next!(g) % 4 + 1] for _ in 1:n]) + +# A copy of `s` with about `per_1000` substitutions per thousand bases: close +# enough to its reference (~96% identity) to pass vsearch's --id 0.75. +function _mutate(g, s, per_1000) + b = Vector{UInt8}(s) + for i in eachindex(b) + _next!(g) % 1000 < per_1000 && (b[i] = _NT[_next!(g) % 4 + 1]) + end + String(b) +end + +function occupancy_fixture(dir; refs = 1000, asv = 300, otu = 150, len = 400) + g = _LCG(0x4d4d57) + mkpath(dir) + seqs = [_randseq(g, len) for _ in 1:refs] + open(joinpath(dir, "ref.fasta"), "w") do io + for (i, s) in enumerate(seqs) + println(io, ">ref$i;tax=k:K$(i % 7)") + println(io, s) + end + end + for (name, n) in (("asv", asv), ("otu", otu)) + open(joinpath(dir, "$name.fasta"), "w") do io + for i in 1:n + println(io, ">$name$i") + println(io, _mutate(g, seqs[_next!(g) % refs + 1], 40)) + end + end + end + dir +end + +## Running the stage in one order +function _search(dir, out, name) + TL.vsearch(joinpath(dir, "$name.fasta"), joinpath(dir, "ref.fasta"), joinpath(out, name); + optional_args = "--id 0.75 --query_cov 0.8 --threads 1", + vsearch_bin = something(Sys.which("vsearch"), "vsearch")) +end + +const ORDERS = ("asv_then_otu", "otu_then_asv", "concurrent") + +# The stage's work in `order`, under a fresh probe. Returns the probe's record. +# ASV-then-OTU and concurrent are the stage's own two shapes, run through the +# pipeline's _vsearch_searches; OTU-then-ASV has no caller and is spelled out. +function run_order(dir, out, order) + mkpath(out) + probe = MP.start(PROBE_ON) + asv() = _search(dir, out, "asv") + otu() = _search(dir, out, "otu") + MP.within(probe) do + if order == "otu_then_asv" + MP.task(otu, "vsearch:otu") + MP.task(asv, "vsearch:asv") + else + SV._vsearch_searches(asv, otu; concurrent = order == "concurrent") + end + end + MP.finish!(probe) +end + +_task(rec, label) = only(t for t in rec["tasks"] if t["label"] == label) + +@testset "Occupancy probe (MMW-01)" begin + + @testset "off unless the run config enables it" begin + @test !MP.enabled(Dict()) + @test !MP.enabled(Dict("occupancy_probe" => Dict())) + @test !MP.enabled(Dict("occupancy_probe" => Dict("enabled" => "true"))) + @test !MP.enabled(Dict("occupancy_probe" => true)) + @test MP.enabled(PROBE_ON) + @test MP.start(Dict()) === nothing + @test MP.finish!(nothing) === nothing + @test MP.within(() -> 42, nothing) == 42 + @test MP.task(() -> 7, "outside a probe") == 7 + @test MP.interval_seconds(Dict()) == 0.010 + @test MP.interval_seconds(Dict("occupancy_probe" => Dict("interval_ms" => 25))) == 0.025 + @test MP.interval_seconds(Dict("occupancy_probe" => Dict("interval_ms" => 0))) == 0.010 + end + + @testset "flag off: the stage record is unchanged" begin + # The keys a stage record had before the probe existed. With the probe + # off, `finish!` gives `nothing` and the record must be exactly these. + before = ["name", "started", "finished", "status", "degraded", + "degraded_components", "runtimes", "tools", "databases", + "commands", "outputs"] + att = PV.Attestation() + PV.record_stage!(att, "vsearch", PV.CapturedEnvironment(); + occupancy = MP.finish!(MP.start(Dict()))) + @test collect(keys(only(att.doc["stages"]))) == before + + # Negative control: with the probe on, the key is there. + on = PV.Attestation() + PV.record_stage!(on, "vsearch", PV.CapturedEnvironment(); + occupancy = MP.finish!(MP.start(PROBE_ON))) + @test collect(keys(only(on.doc["stages"]))) == [before; "occupancy"] + end + + @testset "attestation round trip; older records still parse" begin + mktempdir() do tmp + att = PV.Attestation() + rec = MP.finish!(MP.start(PROBE_ON)) + PV.record_stage!(att, "vsearch", PV.CapturedEnvironment(); occupancy = rec) + PV.record_stage!(att, "swarm", PV.CapturedEnvironment()) # an unprobed stage + path = joinpath(tmp, "attestation.yml") + PV.write_attestation(att, path) + back = PV.read_attestation(path) + stages = Dict(s["name"] => s for s in back.doc["stages"]) + @test stages["vsearch"]["occupancy"]["probe"] == "MemoryProbe 1" + @test stages["vsearch"]["occupancy"]["interval_ms"] == 10 + @test !haskey(stages["swarm"], "occupancy") + + # A record written before the probe existed merges with a new one. + old = PV.Attestation() + PV.record_stage!(old, "cutadapt", PV.CapturedEnvironment()) + PV.write_attestation(old, path; merge_existing = false) + PV.write_attestation(att, path) + merged = Dict(s["name"] => s for s in PV.read_attestation(path).doc["stages"]) + @test !haskey(merged["cutadapt"], "occupancy") + @test haskey(merged["vsearch"], "occupancy") + end + end + + @testset "the vsearch stage's two shapes" begin + # Serial and concurrent return the same pair, and record one task per + # search; with no OTU search the pair ends in `nothing` and no OTU task + # is recorded. A single-stage re-run attests both searches' outputs. + for concurrent in (false, true) + probe = MP.start(PROBE_ON) + @test MP.within(() -> SV._vsearch_searches(() -> :asv, () -> :otu; concurrent), probe) == + (:asv, :otu) + @test MP.within(() -> SV._vsearch_searches(() -> :asv, nothing; concurrent), probe) == + (:asv, nothing) + @test sort([t["label"] for t in MP.finish!(probe)["tasks"]]) == + ["vsearch:asv", "vsearch:asv", "vsearch:otu"] + end + + # The pipeline's call: the same search on each result, against one database. + stub(project, result, db) = (project, result, db) + for concurrent in (false, true) + @test SV._vsearch_stage(stub, :p, :asvs, :otus, :db; concurrent) == + ((:p, :asvs, :db), (:p, :otus, :db)) + @test SV._vsearch_stage(stub, :p, :asvs, nothing, :db; concurrent) == + ((:p, :asvs, :db), nothing) + end + + # Concurrently, a failed ASV search is rethrown only once the OTU search + # has finished, so it cannot be left running. + otu_done = Threads.Atomic{Bool}(false) + @test_throws TaskFailedException SV._vsearch_searches(() -> error("ASV search failed"), + () -> (sleep(0.3); otu_done[] = true; :otu); + concurrent = true) + @test otu_done[] + end + + @testset "a handle the probe cannot watch is ignored" begin + # run(cmd; wait=false) returns a Process or a ProcessChain; anything else + # must leave the stage alone rather than fail it. + @test MP.watch!(:not_a_process) === nothing + @test MP.unwatch!(MP.watch!(:not_a_process)) === nothing + end + + if !Sys.islinux() + @info "Occupancy probe: not Linux, so there is no /proc to sample; the child-memory tests are SKIPPED" + else + @testset "samples a child's resident memory" begin + probe = MP.start(PROBE_ON) + MP.within(probe) do + # bash holds a 64 MiB string for half a second. + MP.task("64MiB") do + TL._run_killable(`bash -c 'x=$(head -c 67108864 /dev/zero | tr "\0" a); sleep 0.5; : "${#x}"'`) + end + # Negative control: a child that allocates nothing. + MP.task("sleep") do + TL._run_killable(`sleep 0.5`) + end + end + rec = MP.finish!(probe) + big, small = _task(rec, "64MiB"), _task(rec, "sleep") + @test big["processes"] == 1 && big["samples"] > 0 + @test big["peak_rss_kib"] >= 64 * 1024 + @test big["hwm_kib"] >= big["peak_rss_kib"] + @test small["samples"] > 0 + @test 0 < small["peak_rss_kib"] < 16 * 1024 + # Serial: the stage peak is the larger task's, not the sum. + @test rec["child_peak_rss_kib"] < big["peak_rss_kib"] + small["peak_rss_kib"] + @test !haskey(rec, "error") + end + + @testset "through the pipeline's _attest, flag off and on" begin + mktempdir() do tmp + cfg_path = joinpath(tmp, "run_config.yml") + for probed in (false, true) + cfg = Dict{String,Any}("vsearch" => Dict("enabled" => true)) + probed && (cfg["occupancy_probe"] = Dict("enabled" => true)) + YAML.write_file(cfg_path, cfg) + att = PV.Attestation(; config = cfg, config_sha256 = PV.file_sha256(cfg_path)) + SV._attest(att, PV.CapturedEnvironment(), "vsearch"; config_path = cfg_path) do + MetaManifold.Config._begin_section(cfg_path, "vsearch", joinpath(tmp, "vsearch.hash")) + MP.task(() -> TL._run_killable(`sleep 0.2`), "vsearch:asv") + nothing + end + stage = only(att.doc["stages"]) + @test stage["status"] == "ok" + @test haskey(stage, "occupancy") == probed + probed && @test only(stage["occupancy"]["tasks"])["label"] == "vsearch:asv" + + # A stage that throws is recorded as failed, with its + # occupancy when probed, and the error still propagates. + failed = PV.Attestation(; config = cfg, config_sha256 = PV.file_sha256(cfg_path)) + @test_throws ProcessFailedException SV._attest(failed, PV.CapturedEnvironment(), "swarm"; + config_path = cfg_path) do + TL._run_killable(`false`) + end + stage = only(failed.doc["stages"]) + @test stage["status"] == "failed" + @test haskey(stage, "occupancy") == probed + end + end + end + + @testset "a pipeline of commands counts each process" begin + probe = MP.start(PROBE_ON) + MP.within(() -> MP.task(() -> TL._run_killable(pipeline(`sleep 0.3`, `cat`)), "chain"), probe) + t = _task(MP.finish!(probe), "chain") + @test t["processes"] == 2 + @test t["samples"] > 0 + end + + @testset "a child that exits at once is counted, not sampled" begin + probe = MP.start(PROBE_ON) + MP.within(() -> MP.task(() -> TL._run_killable(`true`), "instant"), probe) + t = _task(MP.finish!(probe), "instant") + @test t["processes"] == 1 + @test t["samples"] == 0 || t["peak_rss_kib"] > 0 + end + + vsearch_bin = Sys.which("vsearch") + if isnothing(vsearch_bin) + @info "vsearch not on PATH - the two-thread disagreement test is SKIPPED" + else + @testset "two-thread disagreement: vsearch ASV and OTU in three orders" begin + mktempdir() do tmp + dir = occupancy_fixture(joinpath(tmp, "fixture")) + # Warm-up: compile every path once, so no order pays for it. + for order in ORDERS + run_order(dir, joinpath(tmp, "warmup", order), order) + end + GC.gc() + records = OrderedDict{String,Any}() + for order in ORDERS + GC.gc() + records[order] = run_order(dir, joinpath(tmp, order), order) + end + + # The same work: identical hits in every order. + for name in ("asv", "otu") + hits = [read(joinpath(tmp, o, name, "taxonomy.tsv"), String) for o in ORDERS] + @test !isempty(first(hits)) + @test allequal(hits) + end + + # Cost commutes (asserted). + cost = [records[o]["julia_allocated_bytes"] for o in ORDERS] + spread = (maximum(cost) - minimum(cost)) / maximum(cost) + @test spread <= COST_TOLERANCE + + # Peaks (recorded, not asserted). + for o in ORDERS + r = records[o] + a, u = _task(r, "vsearch:asv"), _task(r, "vsearch:otu") + @info "occupancy" order = o julia_allocated_bytes = r["julia_allocated_bytes"] julia_live_bytes_delta = r["julia_live_bytes_delta"] child_peak_rss_kib = r["child_peak_rss_kib"] asv_peak_rss_kib = a["peak_rss_kib"] asv_hwm_kib = a["hwm_kib"] otu_peak_rss_kib = u["peak_rss_kib"] otu_hwm_kib = u["hwm_kib"] samples = r["samples"] + end + conc = records["concurrent"] + serial_max = maximum(records[o]["child_peak_rss_kib"] for o in ORDERS[1:2]) + overlap = conc["child_peak_rss_kib"] > serial_max + @info "occupancy: concurrent stage peak vs the larger serial stage peak" concurrent = conc["child_peak_rss_kib"] serial = serial_max overlapped = overlap cost_spread = round(spread; sigdigits = 3) + overlap || @warn "occupancy: the concurrent searches did not overlap in memory on this runner, so this run cannot show the order dependence" + + path = get(ENV, "OCCUPANCY_RESULTS", "") + if !isempty(path) + io = IOBuffer() + run(pipeline(`$vsearch_bin --version`; stdout = io, stderr = io)) + version = first(split(String(take!(io)), '\n')) + YAML.write_file(path, OrderedDict{String,Any}( + "vsearch" => version, + "julia" => string(VERSION), "threads" => Threads.nthreads(), + "cpus" => Sys.CPU_THREADS, "cost_spread" => spread, + "orders" => records)) + end + end + end + end + end +end diff --git a/test/unit/test_permanova.jl b/test/unit/test_permanova.jl new file mode 100644 index 00000000..06ff287e --- /dev/null +++ b/test/unit/test_permanova.jl @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# PERMANOVA: each covariate is tested as its own sequential term, checked against +# a direct vegan::adonis2 call; the permutation count and p-value floor are +# reported; and a term within-block permutation cannot test is refused. +# Skips loudly when R/vegan is absent. + +using MetaManifold +using MetaManifold.Analysis +using RCall +using DataFrames +using Random +using Test + +const _PERM_R = MetaManifold.Server.r_available() +_PERM_R || @info "R/vegan unavailable - the PERMANOVA reference tests are SKIPPED" + +""" + _adonis2_reference(mat, meta, perm_expr) -> DataFrame + +Run vegan::adonis2 directly on Bray-Curtis distances of `mat`, with +`group + run` terms tested sequentially and permutations given by the R +expression `perm_expr` (which may refer to `ref_meta`), seeded as +`run_permanova` seeds it. Returns the term rows: term, r2, f, p. +""" +function _adonis2_reference(mat, meta, perm_expr) + MetaManifold.RRuntime.with_r_lock() do + RCall.globalEnv[:ref_mat] = mat + RCall.globalEnv[:ref_meta] = meta + RCall.reval(""" + set.seed(123, kind = "Mersenne-Twister", normal.kind = "Inversion", sample.kind = "Rejection") + ref_d <- vegdist(ref_mat, method = "bray") + ref_res <- adonis2(ref_d ~ group + run, data = ref_meta, + permutations = $perm_expr, by = "terms", parallel = 1) + ref_rows <- which(!(rownames(ref_res) %in% c("Residual", "Total"))) + ref_out <- data.frame(term = rownames(ref_res)[ref_rows], r2 = ref_res\$R2[ref_rows], + f = ref_res\$F[ref_rows], p = ref_res[["Pr(>F)"]][ref_rows]) + """) + out = rcopy(RCall.reval("ref_out")) + RCall.reval("rm(ref_mat, ref_meta, ref_d, ref_res, ref_rows, ref_out)") + out + end +end + +@testset "PERMANOVA" begin + @testset "_varies_within_any_block" begin + blocks = ["i1", "i1", "i2", "i2"] + @test !Analysis._varies_within_any_block(["a", "a", "b", "b"], blocks) + @test Analysis._varies_within_any_block(["r1", "r2", "r1", "r1"], blocks) + end + + if _PERM_R + rng = MersenneTwister(7) + # Six individuals, each sampled in two runs; three individuals per group. + mat = Float64.(rand(rng, 0:60, 12, 40)) + individuals = ["i$(cld(k, 2))" for k in 1:12] + meta = DataFrame(sample = ["s$k" for k in 1:12], + group = [cld(k, 2) <= 3 ? "A" : "B" for k in 1:12], + run = repeat(["r1", "r2"], 6)) + + @testset "each term is tested on its own, as vegan by = \"terms\"" begin + res = Analysis.run_permanova(mat, meta; seed=123) + ref = _adonis2_reference(mat, meta, "999") + @test [t.term for t in res.terms] == ["group", "run"] == ref.term + @test [t.r2 for t in res.terms] ≈ ref.r2 + @test [t.f_statistic for t in res.terms] ≈ ref.f + @test [t.p_value for t in res.terms] == ref.p + # The headline result is the group term, not the whole model. + @test res.term == "group" + @test res.r2 ≈ ref.r2[1] && res.p_value == ref.p[1] + @test res.permutations == 999 + @test res.min_p_value ≈ 1 / 1000 + @test all(t -> isnothing(t.untestable_reason), res.terms) + end + + @testset "within-individual blocks: permutation count, floor and refusal" begin + res = Analysis.run_permanova(mat, meta; seed=123, blocks=individuals) + ref = _adonis2_reference(mat, meta, + "permute::how(blocks = factor(rep(paste0('i', 1:6), each = 2)), nperm = 999)") + @test res.blocked + # Six blocks of two admit 2^6 orderings, the observed one excluded. + @test res.permutations == 63 + @test res.min_p_value ≈ 1 / 64 + # group is constant within every individual, so within-block + # permutation never exchanges its labels. vegan still prints an + # ordinary-looking probability for it (the control: that is the + # number withheld here), but it does not test a group difference. + @test 0 < ref.p[1] <= 1 + group = res.terms[1] + @test group.term == "group" && isnothing(group.p_value) + @test occursin("constant within every permutation block", group.untestable_reason) + @test isnothing(res.p_value) && res.untestable_reason == group.untestable_reason + @test group.r2 ≈ ref.r2[1] + @test occursin("withheld for group", res.text) + # run varies within individuals and is tested as vegan tests it. + run = res.terms[2] + @test run.term == "run" && isnothing(run.untestable_reason) + @test run.p_value == ref.p[2] + @test run.f_statistic ≈ ref.f[2] + end + end +end diff --git a/test/unit/test_r_runtime.jl b/test/unit/test_r_runtime.jl index 936a7ac4..68ad9ab7 100644 --- a/test/unit/test_r_runtime.jl +++ b/test/unit/test_r_runtime.jl @@ -94,4 +94,129 @@ const RR = MetaManifold.RRuntime @test fetch(holder) == :done end + ## A test that never ran must not compare equal to one that ran and found + # nothing. Both used to be `(nothing, 0-row DataFrame)`, so this assertion + # could not be written at all. + @testset "a test that did not run is not a null result" begin + AN = MetaManifold.Analysis + not_run = AN._not_computed(:r_unavailable, "R/vegan is not available") + @test !AN.was_computed(not_run) + @test isnothing(not_run.omnibus) + @test nrow(not_run.pairwise) == 0 + @test !isempty(not_run.reason) + + genuine_null = AN._computed(0.87, AN._empty_pairwise()) + @test AN.was_computed(genuine_null) + @test not_run.status !== genuine_null.status + @test not_run != genuine_null + + # R's own tryCatch yields NA for a degenerate group: a failed test keeps + # the pairwise rows it got but never claims an omnibus result. + failed = AN._not_computed(:test_failed, "the omnibus statistic could not be computed"; + pairwise=AN._empty_pairwise()) + @test !AN.was_computed(failed) + @test isnothing(failed.omnibus) + end + + ## The surface must say the test was not run. + # Drawing nothing is how "no pair reached significance" looks. A run that was + # never performed must therefore add something to the chart, not stay silent + # and borrow that appearance. + @testset "a chart states that pairwise tests were not run" begin + AN = MetaManifold.Analysis + groups = ["a", "b"] + vals = Dict("a" => [1.0, 2.0, 3.0], "b" => [4.0, 5.0, 6.0]) + + not_run = AN._not_computed(:r_unavailable, "R/vegan is not available") + layout_not_run = Dict{String,Any}() + AN._add_pairwise_annotations!(layout_not_run, "x", "y", "yaxis", + groups, vals, not_run) + notices = [a for a in get(layout_not_run, "annotations", Any[]) + if occursin("not run", String(a["text"]))] + @test length(notices) == 1 + @test occursin("R/vegan", String(notices[1]["text"])) + + ## The positive control that makes the assertion above mean something. + # A genuine result in which no pair reached significance must NOT gain the + # notice, or the notice would be noise rather than a signal. + genuine_null = AN._computed(0.87, AN._empty_pairwise()) + layout_null = Dict{String,Any}() + AN._add_pairwise_annotations!(layout_null, "x", "y", "yaxis", + groups, vals, genuine_null) + @test isempty([a for a in get(layout_null, "annotations", Any[]) + if occursin("not run", String(a["text"]))]) + end + + ## Each reason gets its own wording, because they call for different actions: + # an absent runtime is a deployment fault, unpairable groups are a property + # of the data, and a failed statistic is a property of these groups. + @testset "the caption distinguishes the reasons a test did not run" begin + AN = MetaManifold.Analysis + failed = AN._not_computed(:test_failed, "the omnibus statistic could not be computed") + absent = AN._not_computed(:r_unavailable, "R/vegan is not available") + unpaired = AN._not_computed(:no_paired_samples, "the groups share no sample IDs") + + for r in (failed, absent, unpaired) + @test occursin("not run", AN._significance_caption(r, "KW")) + @test !occursin("n/a", AN._significance_caption(r, "KW")) + end + @test AN._significance_caption(failed, "KW") != AN._significance_caption(absent, "KW") + @test AN._significance_caption(absent, "KW") != AN._significance_caption(unpaired, "KW") + + # A computed result still reads as a result. + computed = AN._computed(0.02, AN._empty_pairwise()) + caption = AN._significance_caption(computed, "KW") + @test occursin("p = 0.02", caption) + @test !occursin("not run", caption) + end + + ## End to end through R: kruskal.test errors when every sample is in one + # group, R's tryCatch turns that into NA, and the result must be a failed + # test rather than a p-value or a null result. + @testset "an omnibus statistic R cannot compute is a failed test" begin + AN = MetaManifold.Analysis + if !AN._ensure_r() + @warn "Skipping: R/vegan is not available" + @test_skip false + else + one_group = AN._alpha_significance([1.0, 2.0, 3.0, 4.0], ["a", "a", "a", "a"], + ["s1", "s2", "s3", "s4"]) + @test !AN.was_computed(one_group) + @test one_group.status === :test_failed + @test isnothing(one_group.omnibus) + + # Positive control: two separable groups give a computed p-value. + two_groups = AN._alpha_significance([1.0, 2.0, 3.0, 11.0, 12.0, 13.0], + ["a", "a", "a", "b", "b", "b"], + ["s1", "s2", "s3", "s4", "s5", "s6"]) + @test AN.was_computed(two_groups) + @test 0 < two_groups.omnibus < 1 + end + end + + ## The startup check: a package the embedded R cannot load is reported, one it + # can is not, and the order given is kept. `stats` ships with R and the other + # name exists nowhere, so neither depends on what the library holds. + @testset "missing_r_packages reports only what will not load" begin + @test RR.missing_r_packages(String[]) == String[] + @test RR.missing_r_packages(["stats"]) == String[] + @test RR.missing_r_packages(["mmNoSuchPackage1", "stats", "mmNoSuchPackage2"]) == + ["mmNoSuchPackage1", "mmNoSuchPackage2"] + # Its helper variable does not outlive the call in R's global environment. + @test !RR.RCall.rcopy(Bool, + RR.RCall.reval("exists('mm_required_pkgs')")) + @test !RR.r_busy() + end + + ## install.jl verifies the same packages after restoring that the server checks + # at startup. It is a standalone script and cannot import the list, so the two + # copies are compared here. + @testset "the installer checks the packages the server requires" begin + src = read(joinpath(@__DIR__, "..", "..", "install.jl"), String) + m = match(r"pkgs = \[([^\]]*)\]", src) + @test !isnothing(m) + installer = isnothing(m) ? String[] : [String(x.captures[1]) for x in eachmatch(r"\"([^\"]+)\"", m.captures[1])] + @test sort(installer) == sort(first.(RR.REQUIRED_R_PACKAGES)) + end + end diff --git a/test/unit/test_routes.jl b/test/unit/test_routes.jl index a240e470..1722a9b4 100644 --- a/test/unit/test_routes.jl +++ b/test/unit/test_routes.jl @@ -52,6 +52,8 @@ _saved_root = SV.ServerState._root[] @test Set(SV._run_names("studyA")) == Set(["run1", "run2", "pooledRun"]) @test SV._group_names("studyA") == ["groupG"] @test SV._group_run_names("studyA", "groupG") == ["runG1"] + ## The study's run count includes the runs inside its groups. + @test SV._study_data("studyA").run_count == 4 @test SV._run_group("studyA", "runG1") == "groupG" @test SV._run_group("studyA", "run1") === nothing @@ -522,6 +524,14 @@ _saved_root = SV.ServerState._root[] @test rej.status == 400 @test read(SV._primers_path(), String) == before + # An unreadable file is a 400 naming the file, logged as a warning: the + # operator can repair it, so it is not reported as a server fault. + write(SV._primers_path(), "Forward: [unterminated\n") + unreadable = @test_logs (:warn, "primers.yml is not readable") SV._read_primers() + @test unreadable.status == 400 + @test read(SV._primers_path(), String) == "Forward: [unterminated\n" + write(SV._primers_path(), before) + # A body that is not a JSON object is rejected with an actionable 400 # rather than throwing out of the route as a bare 500. This route backs # a Save button, where a malformed request is a plausible client bug. @@ -788,7 +798,7 @@ _saved_root = SV.ServerState._root[] # file be overwritten from an empty editor. before = read(dbpath, String) write(dbpath, "databases: [unterminated\n") - @test SV._read_databases().status == 400 + @test (@test_logs (:warn, "databases.yml is not readable") SV._read_databases()).status == 400 @test read(dbpath, String) == "databases: [unterminated\n" write(dbpath, before) diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl new file mode 100644 index 00000000..2bb7086d --- /dev/null +++ b/test/unit/test_server_security.jl @@ -0,0 +1,128 @@ +# Unit tests for the server's request guards: the Origin allow-list, Oxygen's +# docs and metrics being off, and the headers report uploads are served with. +# Requests go through the app middleware in-process; no socket is opened. +using MetaManifold +SV = MetaManifold.Server +using JSON3 + +# A request through the middleware `start` installs. +_ss_request(method, path, headers=Pair{String,String}[], body="") = + SV.Oxygen.internalrequest(SV.HTTP.Request(method, path, headers, body); middleware=SV._MIDDLEWARE) + +# A request through the handler Oxygen's `serve` builds from `_SERVE_OPTIONS`, +# docs and metrics included, without starting the listener. +function _ss_served(path) + ctx = SV.Oxygen.CONTEXT[] + opts = SV._SERVE_OPTIONS + saved = ctx.docs.router[] + try + ctx.docs.router[] = SV.HTTP.Router() + opts.docs && SV.Oxygen.Core.setupdocs(ctx) + opts.metrics && SV.Oxygen.Core.setupmetrics(ctx) + handler = SV.Oxygen.Core.setupmiddleware(ctx; middleware=SV._MIDDLEWARE, + docs=opts.docs, metrics=opts.metrics, show_errors=opts.show_errors) + handler(SV.HTTP.Request("GET", path, ["Host" => "127.0.0.1:8080"])) + finally + ctx.docs.router[] = saved + end +end + +@testset "Server request guards" begin + tmp = mktempdir() + old_root = SV.ServerState._root[] + old_host, old_port = SV._bound_host[], SV._bound_port[] + SV.ServerState.set_root!(tmp) + SV._bound_host[] = "127.0.0.1" + SV._bound_port[] = 8080 + mkpath(joinpath(tmp, "data", "StudyS", "run1")) + touch(joinpath(tmp, "data", "StudyS", "run1", "s1_R1.fastq.gz")) + mkpath(joinpath(tmp, "projects", "StudyS", "run1")) + try + @testset "Origin allow-list" begin + studies = "/api/v1/studies" + host = "Host" => "127.0.0.1:8080" + # No Origin (same-origin GET, curl): unchanged. + @test _ss_request("GET", studies, [host]).status == 200 + # The served origin, in both spellings, and the Vite dev server. + for o in ("http://127.0.0.1:8080", "http://localhost:8080", "http://localhost:5173", + "http://127.0.0.1:5173", "http://LOCALHOST:8080/") + r = _ss_request("GET", studies, [host, "Origin" => o]) + @test r.status == 200 + @test SV.HTTP.header(r, "Access-Control-Allow-Origin") == o + @test _ss_request("OPTIONS", studies, [host, "Origin" => o]).status == 204 + end + # Another loopback page: other port, other scheme, or no origin at all. + for o in ("http://localhost:3000", "http://127.0.0.1:9999", "https://localhost:8080", + "http://[::1]:8080", "null", "http://evil.example") + @test _ss_request("GET", studies, [host, "Origin" => o]).status == 403 + @test _ss_request("OPTIONS", studies, [host, "Origin" => o]).status == 403 + # A mutating request is refused before it reaches the route. + r = _ss_request("POST", "/api/v1/studies/StudyS/report?kind=figure&ext=.txt", + [host, "Origin" => o, "Content-Type" => "text/plain"], "x") + @test r.status == 403 + end + @test !isdir(joinpath(tmp, "projects", "StudyS", "report")) + # The allowed set follows the bound port. + SV._bound_port[] = 9090 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:9090"]).status == 200 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:8080"]).status == 403 + SV._bound_port[] = 8080 + # Extra origins come from JULIA_METAMANIFOLD_ALLOWED_ORIGINS. + withenv("JULIA_METAMANIFOLD_ALLOWED_ORIGINS" => " http://lab-box:8080 , http://localhost:5174/") do + @test _ss_request("GET", studies, [host, "Origin" => "http://lab-box:8080"]).status == 200 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:5174"]).status == 200 + @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:3000"]).status == 403 + end + # The Host check still applies first. + @test _ss_request("GET", studies, ["Host" => "evil.example:8080"]).status == 403 + end + + @testset "Oxygen docs and metrics are not served" begin + @test SV._SERVE_OPTIONS.docs === false + @test SV._SERVE_OPTIONS.metrics === false + # With a frontend build present, an unknown path falls through to the + # SPA catch-all and answers 200 with index.html. That is the app, not + # the docs, so accept exactly that body as well as a non-200 answer. + index = joinpath(SV._frontend_dir, "index.html") + spa = isfile(index) ? read(index) : nothing + for path in ("/docs", "/docs/schema", "/docs/swagger", "/docs/metrics") + r = _ss_served(path) + body = lowercase(String(copy(r.body))) + @test !occursin("openapi", body) + @test !occursin("swagger", body) + @test !occursin("redoc", body) + @test r.status != 200 || (spa !== nothing && r.body == spa) + end + # The served handler still answers the API. + @test _ss_served("/api/v1/studies").status == 200 + end + + @testset "Report uploads are served inert" begin + host = "Host" => "127.0.0.1:8080" + base = "/api/v1/studies/StudyS/report" + svg = "" + r = _ss_request("POST", "$base?kind=figure&title=X&ext=.svg", [host], svg) + @test r.status == 200 + item = JSON3.read(String(r.body)) + for path in ("$base/$(item.id)/file", "/files/StudyS/runs/report/$(item.file)") + f = _ss_request("GET", path, [host]) + @test f.status == 200 + @test String(f.body) == svg + @test SV.HTTP.header(f, "Content-Type") == "image/svg+xml" + @test SV.HTTP.header(f, "X-Content-Type-Options") == "nosniff" + @test SV.HTTP.header(f, "Content-Security-Policy") == + "default-src 'none'; style-src 'unsafe-inline'; sandbox" + end + # Pipeline output under /files/ is sniff-proof but keeps working inline. + write(joinpath(tmp, "projects", "StudyS", "run1", "plot.pdf"), "%PDF-1.4") + f = _ss_request("GET", "/files/StudyS/runs/run1/plot.pdf", [host]) + @test f.status == 200 + @test SV.HTTP.header(f, "X-Content-Type-Options") == "nosniff" + @test SV.HTTP.header(f, "Content-Security-Policy") == "" + end + finally + SV.ServerState._root[] = old_root + SV._bound_host[], SV._bound_port[] = old_host, old_port + rm(tmp; recursive=true, force=true) + end +end diff --git a/test/unit/test_write_gates.jl b/test/unit/test_write_gates.jl new file mode 100644 index 00000000..60a4683e --- /dev/null +++ b/test/unit/test_write_gates.jl @@ -0,0 +1,138 @@ +# Unit tests for the write gates that keep request values out of file paths: +# config values used as file-name parts or remote command words, and the +# pipeline tables the results routes may not delete. Requests run in-process +# through Oxygen's router. +using MetaManifold +SV = MetaManifold.Server +using JSON3, YAML, DuckDB, DBInterface + +_wg_request(method, path, body="") = + SV.Oxygen.internalrequest(SV.HTTP.Request(method, path, ["Content-Type" => "application/json"], body)) + +@testset "Config write gate" begin + root = mktempdir() + old_root = SV.ServerState._root[] + SV.ServerState.set_root!(root) + mkpath(joinpath(root, "data")) + mkpath(joinpath(root, "config")) + user_cfg = joinpath(root, "config", "pipeline.yml") + write(user_cfg, "vsearch:\n identity: 0.97\n") + try + bad = [ + "dada2.output.fasta_prefix" => "../../tmp/pwn", + "dada2.output.fasta_prefix" => "a/b", + "dada2.output.fasta_prefix" => "", + "dada2.output.seq_table_prefix" => "../seqtab", + "dada2.output.taxa_prefix" => "/tmp/taxonomy", + "dada2.output.taxa_prefix" => "tax onomy", + "cutadapt.r1_suffix" => "_R1\\E.*", + "cutadapt.r2_suffix" => "\\Q_R2", + "cutadapt.r2_suffix" => "_R2/x", + "remote.staging_dir" => "/srv/mm; rm -rf ~", + "remote.staging_dir" => "relative/dir", + "remote.staging_dir" => "/srv/m m", + "remote.stages" => ["denoise", "run_anything"], + "remote.threads" => 0, + "remote.threads" => "4", + "remote.tools.mafft" => "mafft;id", + "remote.tools.iqtree" => nothing, + ] + before = read(user_cfg, String) + for (k, v) in bad + r = _wg_request("PATCH", "/api/v1/config", JSON3.write(Dict(k => v))) + @test r.status == 400 + @test read(user_cfg, String) == before + end + + good = [ + "dada2.output.fasta_prefix" => "asvs_v2", + "dada2.output.seq_table_prefix" => "seqtab.nochim", + "dada2.output.taxa_prefix" => "taxonomy-silva", + "cutadapt.r1_suffix" => "_1", + "cutadapt.r2_suffix" => "_2", + "remote.host" => "user@server", + "remote.staging_dir" => "/scratch/metamanifold", + "remote.stages" => ["denoise", "phylogeny_tree"], + "remote.threads" => 16, + "remote.tools.mafft" => "/opt/mafft/bin/mafft", + ] + for (k, v) in good + r = _wg_request("PATCH", "/api/v1/config", JSON3.write(Dict(k => v))) + @test r.status == 200 + end + saved = YAML.load_file(user_cfg) + @test saved["dada2"]["output"]["fasta_prefix"] == "asvs_v2" + @test saved["cutadapt"]["r2_suffix"] == "_2" + @test saved["remote"]["staging_dir"] == "/scratch/metamanifold" + @test saved["remote"]["stages"] == ["denoise", "phylogeny_tree"] + @test saved["remote"]["threads"] == 16 + @test saved["remote"]["tools"]["mafft"] == "/opt/mafft/bin/mafft" + @test saved["vsearch"]["identity"] == 0.97 + + # The gate's remote checks are validation's own: what it stored passes them. + errs = SV.Validation.ValidationError[] + SV.Validation._validate_remote!(errs, saved["remote"], "test") + @test isempty(errs) + + # Naming a host without a staging_dir, or with one that is not a string, + # leaves the remote stages nowhere to stage, and validation says so. + for remote in (Dict{String,Any}("host" => "user@server"), + Dict{String,Any}("host" => "user@server", "staging_dir" => 42)) + errs = SV.Validation.ValidationError[] + SV.Validation._validate_remote!(errs, remote, "test") + @test any(e -> occursin("remote.staging_dir must be set", e.message), errs) + end + finally + SV.ServerState._root[] = old_root + rm(root; recursive=true, force=true) + end +end + +@testset "Pipeline tables cannot be deleted" begin + root = mktempdir() + old_root = SV.ServerState._root[] + SV.ServerState.set_root!(root) + data_run = joinpath(root, "data", "studyW", "runA") + mkpath(data_run) + touch(joinpath(data_run, "s1_R1.fastq.gz")) + merge_dir = joinpath(root, "projects", "studyW", "runA", "merged") + mkpath(merge_dir) + db_path = joinpath(merge_dir, "results.duckdb") + db = DuckDB.DB(db_path) + con = DBInterface.connect(db) + for t in (SV._PIPELINE_TABLES..., "my_view") + DBInterface.execute(con, "CREATE TABLE \"$t\" (\"SeqName\" VARCHAR, \"s1\" BIGINT)") + DBInterface.execute(con, "INSERT INTO \"$t\" VALUES ('seq1', 3)") + write(joinpath(merge_dir, "$t.csv"), "SeqName,s1\nseq1,3\n") + end + DBInterface.close!(con); close(db) + tables() = let db = DuckDB.DB(db_path), con = DBInterface.connect(db) + try + Set(string.(DBInterface.execute(con, "SHOW TABLES") |> DataFrame).name) + finally + DBInterface.close!(con); close(db) + end + end + try + @test Set(SV._PIPELINE_TABLES) == Set(["merged", "merged_otu", "merged_cdhit", "cluster_membership"]) + for t in SV._PIPELINE_TABLES + r = _wg_request("DELETE", "/api/v1/studies/studyW/runs/runA/results/tables/$t") + @test r.status == 400 + @test JSON3.read(String(r.body)).error == "reserved_name" + @test isfile(joinpath(merge_dir, "$t.csv")) + @test t in tables() + # The save route refuses the same names. + r = _wg_request("POST", "/api/v1/studies/studyW/runs/runA/results/tables/my_view/save", + JSON3.write(Dict("name" => t))) + @test r.status == 400 + end + # A user's own table still deletes. + r = _wg_request("DELETE", "/api/v1/studies/studyW/runs/runA/results/tables/my_view") + @test r.status == 200 + @test !isfile(joinpath(merge_dir, "my_view.csv")) + @test !("my_view" in tables()) + finally + SV.ServerState._root[] = old_root + rm(root; recursive=true, force=true) + end +end