diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 912daaba..10cf5483 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,7 @@ jobs:
sudo chmod 777 "$RENV_PATHS_CACHE"
sudo env R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)'
sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)'
- Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
+ Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS","zCompositions")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
# Saved on failure too, since the packages built before a failure are the ones
# the next attempt should reuse. Root writes the cache, so it is made readable first.
@@ -230,7 +247,7 @@ jobs:
julia --project=. -e '
using RCall
R"cat(.libPaths(), sep=\"\n\")"
- for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS"]
+ for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS", "zCompositions"]
R"if (!require($pkg, character.only=TRUE, quietly=TRUE)) stop($pkg, \" not found\")"
end
'
@@ -282,3 +299,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/CONTRIBUTING.md b/CONTRIBUTING.md
index 504ad6b0..930ec561 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -98,7 +98,7 @@ A pull request does not reopen these. An issue can discuss them.
- `config/defaults/tool_versions.yml` pins Julia, R, bun and the external tools,
and `install.jl` and CI both install from it. The Julia version is repeated in
- the CI matrix. Change a pin by editing that file, and expect the change to be
+ the CI workflow. Change a pin by editing that file, and expect the change to be
discussed.
- R packages are pinned in `renv.lock`.
- CI job names carry no version numbers, so that a pin change leaves required
diff --git a/README.md b/README.md
index bb9883cc..1fe02ca8 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:
@@ -644,6 +645,30 @@ bash start.sh
Open `http://localhost:8080`. The backend serves the frontend.
+### Split deployment (frontend and API on different origins)
+
+To serve the frontend from a different address than the API, set `apiBase` in
+`frontend/public/config.json` to the API's address, and list the frontend's
+address when starting the server:
+
+```bash
+JULIA_METAMANIFOLD_ALLOWED_ORIGINS=https://frontend.example bash start.sh
+```
+
+The server accepts browser requests from its own origin (`http://localhost:`
+and `http://127.0.0.1:`), from the Vite dev server (`localhost:5173`), and
+from the origins listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`, separated by
+commas. An origin has to match exactly, scheme and port included:
+`https://frontend.example` does not cover `http://frontend.example` or
+`https://frontend.example:8443`. A page on any other origin gets
+`403 Forbidden: origin not allowed` on every request that carries an `Origin`
+header, which is every write and every cross-origin read.
+
+This also applies to an SSH tunnel on a different local port (e.g.
+`http://localhost:9000`): the browser's origin is then `http://localhost:9000`,
+not the server's `http://localhost:8080`, so start the server with
+`JULIA_METAMANIFOLD_ALLOWED_ORIGINS=http://localhost:9000`.
+
## Input data
Place paired-end FASTQ files under `data/{project_name}/` following Illumina naming:
diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml
index 11198e7a..4e4db88f 100644
--- a/config/defaults/pipeline.yml
+++ b/config/defaults/pipeline.yml
@@ -347,10 +347,24 @@ analysis:
# comparing two conditions on the raw read counts, with Benjamini-Hochberg
# adjusted p-values.
differential:
+ # The model fitted to each taxon. nb_glm: a negative-binomial GLM on the read
+ # counts with a library-size offset; the effect is a log2 fold change. clr_welch:
+ # Welch's t-test on centred log-ratios over the taxa passing min_prevalence,
+ # after zCompositions::cmultRepl replaces their zeros; the effect is a
+ # difference in CLR units, not a fold change.
+ method: nb_glm
# How library size enters each model, as a log offset: tss (total reads per
# sample) or rle (median-of-ratios over the taxa present in every sample).
# rle is refused when no taxon is present in every sample.
+ # Used by nb_glm only; clr_welch ignores it, since log-ratios carry no library size.
offset: tss
# Fraction of samples, 0 to 1, in which a taxon must have reads to be
- # tested. Taxa below it are listed as filtered, without a p-value.
+ # tested. Taxa below it are listed as filtered, without a p-value. For
+ # clr_welch it is applied before zero replacement, so on a sparse table it also
+ # keeps imputed values for rare taxa out of every sample; clr_welch refuses a
+ # taxon seen in fewer than 2 samples.
min_prevalence: 0.0
+ # clr_welch only: cmultRepl's frac, between 0 and 1. An imputed proportion above
+ # its taxon's smallest observed proportion is lowered to this fraction of it.
+ # A replaced zero is not a measurement.
+ replacement_delta: 0.65
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 10410bd9..87457dad 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -243,15 +243,32 @@ export interface ComparisonRequest extends AnalysisRequest {
aggregate?: boolean
}
+export interface PermanovaTerm {
+ term: string
+ r2: number
+ f_statistic: number | null
+ /** Null when the term could not be tested; `untestable_reason` says why. */
+ p_value: number | null
+ /** Set when within-block permutation cannot change this term. */
+ untestable_reason?: string | null
+}
+
export interface PermanovaResult {
text: string
+ /** The first term (group, when it varies); r2, f_statistic and p_value are its own. */
+ term?: string
r2: number | null
f_statistic: number | null
p_value: number | null
+ untestable_reason?: string | null
+ /** Permutations actually performed: fewer than 999 when the blocks admit fewer. */
+ permutations?: number
+ /** Smallest p-value attainable with that many permutations. */
+ min_p_value?: number
/** True when permutations were restricted within individuals. */
blocked?: boolean
/** One row per model term, tested sequentially in formula order. */
- terms?: { term: string; r2: number; f_statistic: number | null; p_value: number | null }[]
+ terms?: PermanovaTerm[]
/** PERMDISP on the same distances: do the groups differ in spread? */
dispersion?: PermdispResult | { error: string }
}
@@ -381,18 +398,32 @@ export interface DifferentialRow {
taxon: string
status: DifferentialStatus
note: string
- /** Natural-log fold change, contrast over reference. */
+ /**
+ * nb_glm: natural-log fold change, contrast over reference. clr_welch: mean
+ * CLR of contrast minus mean CLR of reference, which is not a fold change.
+ */
estimate: number | null
+ /** nb_glm only; null for clr_welch. */
log2_fold_change: number | null
standard_error: number | null
+ /** z (nb_glm) or Welch's t (clr_welch). */
statistic: number | null
pvalue: number | null
/** Benjamini-Hochberg adjusted; null outside the tested family. */
padj: number | null
+ /** nb_glm only. */
dispersion_theta: number | null
prevalence: number
}
+export type DifferentialMethod = 'nb_glm' | 'clr_welch'
+
+/** Which row field holds the effect size, and what to call it. */
+export interface DifferentialEffect {
+ key: 'log2_fold_change' | 'estimate'
+ label: string
+}
+
export interface DifferentialResult {
status: 'ok' | 'partial'
method: string
@@ -400,9 +431,26 @@ export interface DifferentialResult {
table: string
groups: { reference: string; contrast: string }
n_samples: Record
+ effect: DifferentialEffect
+ /** Empty for clr_welch, which takes no library-size offset. */
size_factors: { sample: string; group: string; factor: number }[]
- config: { offset: 'tss' | 'rle'; min_prevalence: number }
- diagnostics: { n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number }
+ config: {
+ method: DifferentialMethod
+ min_prevalence: number
+ /** nb_glm only. */
+ offset?: 'tss' | 'rle'
+ /** clr_welch only. */
+ replacement_delta?: number
+ }
+ diagnostics: {
+ n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number
+ /** clr_welch only. */
+ zero_replacement?: {
+ method: string; delta: number; zeros_replaced: number; n_taxa_in_composition: number
+ n_taxa_unobserved: number; median_imputed_fraction: number; max_imputed_fraction: number
+ warnings: string[]
+ }
+ }
rows: DifferentialRow[]
figure: unknown
}
diff --git a/frontend/src/components/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 => {o.label} )}
+ ) : 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/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx
index ff15b507..825fb812 100644
--- a/frontend/src/components/DifferentialPanel.tsx
+++ b/frontend/src/components/DifferentialPanel.tsx
@@ -1,16 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { errorMessage } from '../api/errorMessage'
import { useToast } from './Toast'
import type { ComparisonRunSpec, DifferentialResult } from '../api/types'
-import { uniqueRuns } from '../api/types'
+import { sharedRanks } from './sharedRanks'
import type { AnalysisOption } from './annotationShared'
import { AnalysisChart } from './AnalysisChart'
import { AddToReport } from './AddToReport'
import { saveBlob } from '../utils/download'
-import { differentialCsv, formatStat } from './differentialTable'
+import { differentialCsv, effectValue, formatStat } from './differentialTable'
const cell = { padding: '3px 10px', textAlign: 'right' } as const
const head = { padding: '4px 10px', textAlign: 'right' } as const
@@ -21,10 +21,10 @@ function csvBlob(result: DifferentialResult): Blob {
}
/**
- * Differential abundance between exactly two runs or sub-groups: a
- * negative-binomial model per taxon at the chosen rank, with BH-adjusted
- * p-values, shown as a volcano plot and a table that can be downloaded or
- * added to the report. The first selected condition is the reference.
+ * Differential abundance between exactly two runs or sub-groups: a model per
+ * taxon at the chosen rank (negative-binomial GLM or CLR linear model, as
+ * configured), with BH-adjusted p-values, shown as a volcano plot and a table
+ * that can be downloaded or added to the report. The first selected condition is the reference.
*/
export function DifferentialPanel({ study, runs, option, aggregate }: {
study: string
@@ -36,42 +36,58 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
const [ranks, setRanks] = useState([])
const [rank, setRank] = useState('')
const [ranksReady, setRanksReady] = useState(false)
+ const [ranksError, setRanksError] = useState(null)
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
// The ranks shared by every selected run.
useEffect(() => {
+ setRanksError(null)
if (!option || runs.length === 0) { setRanks([]); setRank(''); setRanksReady(false); return }
let cancelled = false
setRanksReady(false)
- Promise.all(
- uniqueRuns(runs).map(r =>
- api.analysis.ranks(study, r.run, {
- table: option.table,
- group: r.group,
- ...(r.source ? { source: r.source } : {}),
- }).catch(() => [] as string[])
- )
- ).then(results => {
+ sharedRanks(runs, r => api.analysis.ranks(study, r.run, {
+ table: option.table,
+ group: r.group,
+ ...(r.source ? { source: r.source } : {}),
+ })).then(shared => {
if (cancelled) return
- const shared = results.reduce((acc, cur) => acc.filter(r => cur.includes(r)), results[0] ?? [])
setRanks(shared)
setRanksReady(true)
setRank(current => shared.includes(current) ? current : (shared[shared.length - 1] ?? ''))
+ }).catch(err => {
+ if (cancelled) return
+ setRanks([])
+ setRank('')
+ setRanksError(errorMessage(err))
})
return () => { cancelled = true }
}, [study, runs, option])
+ // A result answers the inputs it was computed from; new inputs discard it,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setResult(null)
+ setLoading(false)
+ }, [study, runs, option?.table, rank, aggregate])
+
+ /** Fit the model for the current inputs and show it, unless the inputs changed meanwhile. */
const run = async () => {
if (!option || !rank) return
+ const req = ++request.current
setLoading(true)
try {
- setResult(await api.analysis.differential(study, { runs, table: option.table, rank, aggregate }))
+ const res = await api.analysis.differential(study, { runs, table: option.table, rank, aggregate })
+ if (req === request.current) setResult(res)
} catch (err) {
+ if (req !== request.current) return
setResult(null)
toast.error(`Differential abundance failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
@@ -94,9 +110,12 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{ranksReady && ranks.length === 0 && (
No taxonomy rank shared by the selected runs.
)}
+ {ranksError && (
+ Could not read the taxonomy ranks: {ranksError}
+ )}
{runs.length === 2 && (
- Reference: {runs[0].prefix ?? runs[0].run}; positive fold changes mean more abundant in {runs[1].prefix ?? runs[1].run}.
+ Reference: {runs[0].prefix ?? runs[0].run}; a positive effect means more abundant in {runs[1].prefix ?? runs[1].run}.
)}
@@ -109,6 +128,12 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.diagnostics.n_boundary > 0 && `, ${result.diagnostics.n_boundary} at a dispersion boundary`}
{result.diagnostics.n_filtered > 0 && `, ${result.diagnostics.n_filtered} below the prevalence threshold`}
. Samples: {Object.entries(result.n_samples).map(([g, n]) => `${g} ${n}`).join(', ')}.
+ {result.diagnostics.zero_replacement && (() => {
+ const z = result.diagnostics.zero_replacement
+ return ` ${z.zeros_replaced} zeros replaced by ${z.method} over ${z.n_taxa_in_composition} taxa; ` +
+ `replaced values hold ${(100 * z.median_imputed_fraction).toPrecision(2)}% of the median sample ` +
+ `and at most ${(100 * z.max_imputed_fraction).toPrecision(2)}% of any, and are not measurements.`
+ })()}
@@ -126,8 +151,8 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.rank}
- log2 FC
- Estimate (ln)
+ {result.effect.label}
+ {result.config.method === 'nb_glm' && Estimate (ln) }
SE
p
padj (BH)
@@ -138,8 +163,8 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.rows.map(r => (
{r.taxon}
- {formatStat(r.log2_fold_change)}
- {formatStat(r.estimate)}
+ {formatStat(effectValue(result, r))}
+ {result.config.method === 'nb_glm' && {formatStat(r.estimate)} }
{formatStat(r.standard_error)}
{formatStat(r.pvalue)}
{formatStat(r.padj)}
diff --git a/frontend/src/components/DiversityPanel.tsx b/frontend/src/components/DiversityPanel.tsx
index b3d16dd2..ae84d450 100644
--- a/frontend/src/components/DiversityPanel.tsx
+++ b/frontend/src/components/DiversityPanel.tsx
@@ -1,6 +1,6 @@
// © 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { AnalysisChart } from './AnalysisChart'
import { useAlphaMetricSelection, AlphaMetricToggles, ALPHA_METRICS, extractAlphaPanel } from './alphaMetrics'
@@ -22,11 +22,27 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
const [permanova, setPermanova] = useState(null)
const [loading, setLoading] = useState(null)
const [rAvailable, setRAvailable] = useState(null)
+ const [capabilitiesError, setCapabilitiesError] = useState(null)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
+ // A failed check is reported as such, not as "R is unavailable".
useEffect(() => {
- api.analysis.capabilities().then(c => setRAvailable(c.r_available)).catch(() => setRAvailable(false))
+ api.analysis.capabilities()
+ .then(c => setRAvailable(c.r_available))
+ .catch(err => setCapabilitiesError(errorMessage(err)))
}, [])
+ // Results answer the inputs they were computed from; new inputs discard them,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setAlphaFig(null)
+ setNmdsFig(null)
+ setPermanova(null)
+ setLoading(null)
+ }, [study, runs, option?.table, aggregate])
+
const body = option && runs.length >= 2
? {
table: option.table,
@@ -36,22 +52,33 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
}
: null
+ /** Compute one analysis for the current inputs and show it, unless the inputs changed meanwhile. */
const run = async (type: 'alpha' | 'nmds' | 'permanova') => {
if (!body) return
+ const req = ++request.current
+ const current = () => req === request.current
setLoading(type)
try {
if (type === 'alpha') {
- setAlphaFig(await api.analysis.compareAlpha(study, body))
+ const fig = await api.analysis.compareAlpha(study, body)
+ if (current()) setAlphaFig(fig)
} else if (type === 'nmds') {
- setNmdsFig(await api.analysis.nmds(study, body))
+ const fig = await api.analysis.nmds(study, body)
+ if (current()) setNmdsFig(fig)
} else {
- setPermanova(await api.analysis.permanova(study, body))
+ const res = await api.analysis.permanova(study, body)
+ if (current()) setPermanova(res)
}
} catch (err) {
+ if (!current()) return
+ // A failed rerun must not leave the previous result looking current.
+ if (type === 'alpha') setAlphaFig(null)
+ else if (type === 'nmds') setNmdsFig(null)
+ else setPermanova(null)
const label = type === 'alpha' ? 'Alpha comparison' : type === 'nmds' ? 'NMDS' : 'PERMANOVA'
toast.error(`${label} failed: ${errorMessage(err)}`)
} finally {
- setLoading(null)
+ if (current()) setLoading(null)
}
}
@@ -82,6 +109,11 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
NMDS and PERMANOVA need R with vegan.
)}
+ {capabilitiesError && (
+
+ Could not check whether R is available: {capabilitiesError}
+
+ )}
{alphaFig != null && (
@@ -99,19 +131,20 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
PERMANOVA Results
{permanova.text}
- {(permanova.terms?.length
- ? permanova.terms
- : permanova.p_value != null
- ? [{ term: 'model', r2: permanova.r2 ?? NaN, f_statistic: permanova.f_statistic, p_value: permanova.p_value }]
- : []
- ).map(t => (
+ {(permanova.terms ?? []).map(t => (
- {t.term}: R² = {t.r2.toFixed(3)} · F = {t.f_statistic?.toFixed(2) ?? 'n/a'} · p = {t.p_value?.toFixed(4) ?? 'n/a'}
+ {t.term}: R² = {t.r2.toFixed(3)} · F = {t.f_statistic?.toFixed(2) ?? 'n/a'} ·{' '}
+ {t.untestable_reason ? `p not reported: ${t.untestable_reason}` : `p = ${t.p_value?.toFixed(4) ?? 'n/a'}`}
))}
{(permanova.terms?.length ?? 0) > 1 && (
Terms are tested in order, each after the ones above it.
)}
+ {permanova.permutations != null && permanova.min_p_value != null && (
+
+ {permanova.permutations} permutations; the smallest attainable p is {permanova.min_p_value.toFixed(4)}.
+
+ )}
{permanova.blocked && (
Permutations restricted within individuals.
)}
diff --git a/frontend/src/components/PipelineStages.tsx b/frontend/src/components/PipelineStages.tsx
index 8a96ab41..85129267 100644
--- a/frontend/src/components/PipelineStages.tsx
+++ b/frontend/src/components/PipelineStages.tsx
@@ -150,8 +150,10 @@ export const CONFIG_DESCRIPTIONS: Record
= {
'analysis.alpha.rarefaction_iterations': 'rarefy draws averaged for each alpha diversity value.',
'analysis.beta.normalisation': 'Preparation before the Bray-Curtis dissimilarity used by NMDS and PERMANOVA: hellinger (square root of relative abundances), none, rarefy or srs.',
'analysis.beta.depth': 'Reads per sample for rarefy or srs; 0 uses the smallest non-empty sample.',
- 'analysis.differential.offset': 'Library-size offset in each negative-binomial model: tss (total reads per sample) or rle (median-of-ratios over taxa present in every sample; refused when there are none).',
+ 'analysis.differential.method': 'Model fitted to each taxon: nb_glm (negative-binomial GLM on read counts; effect is a log2 fold change) or clr_welch (Welch\'s t-test on centred log-ratios over the taxa passing min_prevalence, after zCompositions::cmultRepl zero replacement; effect is a CLR difference, not a fold change).',
+ 'analysis.differential.offset': 'Library-size offset in each negative-binomial model: tss (total reads per sample) or rle (median-of-ratios over taxa present in every sample; refused when there are none). Used by nb_glm only.',
'analysis.differential.min_prevalence': 'Fraction of samples, 0 to 1, in which a taxon must have reads to be tested; taxa below it are listed as filtered.',
+ 'analysis.differential.replacement_delta': 'clr_welch only: cmultRepl\'s frac, between 0 and 1; an imputed proportion above its taxon\'s smallest observed proportion is lowered to this fraction of it. Replaced zeros are not measurements.',
'analysis.alpha.show_points': 'Show individual samples overlaid on alpha comparison boxplots.',
'analysis.alpha.annotate_significance': 'Annotate alpha comparison panels with Kruskal-Wallis significance.',
'analysis.alpha.pairwise_brackets': 'Run BH-adjusted pairwise Wilcoxon rank-sum tests between every group pair and draw brackets, including n.s. results.',
@@ -248,8 +250,10 @@ export const CONFIG_TYPES: Record = {
'analysis.alpha.rarefaction_iterations': { kind: 'int' },
'analysis.beta.normalisation': { kind: 'enum', options: ['hellinger', 'none', 'rarefy', 'srs'] },
'analysis.beta.depth': { kind: 'int' },
+ 'analysis.differential.method': { kind: 'enum', options: ['nb_glm', 'clr_welch'] },
'analysis.differential.offset': { kind: 'enum', options: ['tss', 'rle'] },
'analysis.differential.min_prevalence': { kind: 'float', min: 0, max: 1, step: 0.05 },
+ 'analysis.differential.replacement_delta': { kind: 'float', min: 0.01, max: 0.99, step: 0.05 },
'analysis.alpha.show_points': { kind: 'boolean' },
'analysis.alpha.annotate_significance': { kind: 'boolean' },
'analysis.alpha.pairwise_brackets': { kind: 'boolean' },
diff --git a/frontend/src/components/TaxaCompositionChart.tsx b/frontend/src/components/TaxaCompositionChart.tsx
index 2b8221a4..f14657a3 100644
--- a/frontend/src/components/TaxaCompositionChart.tsx
+++ b/frontend/src/components/TaxaCompositionChart.tsx
@@ -1,6 +1,6 @@
// (c) 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { useAnalysisSource } from './analysisSource'
import { errorMessage } from '../api/errorMessage'
@@ -78,6 +78,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 +104,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 +116,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 +136,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 +160,7 @@ export function TaxaCompositionChart({
toast.error('No run specified')
return
}
+ const req = ++request.current
setLoading(true)
try {
const body = {
@@ -151,27 +172,31 @@ export function TaxaCompositionChart({
keep_empty: keepEmpty,
...(tag === 'rank' ? { top_n: topN } : {}),
}
+ let fig: unknown
if (isFaceted) {
- setFigure(await api.analysis.chartFacet(study, {
+ fig = await api.analysis.chartFacet(study, {
...body,
runs: runs!,
rows: facetRows,
cols: facetCols,
- }))
+ })
} else if (isCrossRun) {
- setFigure(await api.analysis.chartCompare(study, {
+ fig = await api.analysis.chartCompare(study, {
...body,
subgroup: subgroup ?? null,
runs: runs!,
- }))
+ })
} else {
- setFigure(await api.analysis.chart(study, run!,
- { ...body, subgroup: subgroup ?? null, ...(source ? { source } : {}) }, group))
+ fig = await api.analysis.chart(study, run!,
+ { ...body, subgroup: subgroup ?? null, ...(source ? { source } : {}) }, group)
}
+ if (req === request.current) setFigure(fig)
} catch (err) {
+ if (req !== request.current) return
+ setFigure(null)
toast.error(`Chart failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
diff --git a/frontend/src/components/VennPanel.tsx b/frontend/src/components/VennPanel.tsx
index e276f24d..4ed37a0f 100644
--- a/frontend/src/components/VennPanel.tsx
+++ b/frontend/src/components/VennPanel.tsx
@@ -8,11 +8,12 @@ import { api } from '../api/client'
import { errorMessage } from '../api/errorMessage'
import { useToast } from './Toast'
import type { ComparisonRunSpec, VennResult } from '../api/types'
-import { uniqueRuns } from '../api/types'
+import { sharedRanks } from './sharedRanks'
import type { AnalysisOption } from './annotationShared'
type Mode = 'euler' | 'upset'
+/** Taxon overlap between the selected runs at one shared rank, as an Euler or UpSet diagram. */
export function VennPanel({ study, runs, option }: {
study: string
runs: ComparisonRunSpec[]
@@ -25,6 +26,9 @@ export function VennPanel({ study, runs, option }: {
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [ranksReady, setRanksReady] = useState(false)
+ const [ranksError, setRanksError] = useState(null)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
const box = useRef(null)
const [width, setWidth] = useState(580)
@@ -38,35 +42,43 @@ export function VennPanel({ study, runs, option }: {
// Discover the intersection of taxonomy ranks available across all selected runs.
useEffect(() => {
+ setRanksError(null)
if (!option || runs.length === 0) { setRanks([]); setRank(''); setRanksReady(false); return }
let cancelled = false
setRanksReady(false)
- Promise.all(
- uniqueRuns(runs).map(r =>
- api.analysis.ranks(study, r.run, {
- table: option.table,
- group: r.group,
- ...(r.source ? { source: r.source } : {}),
- }).catch(() => [] as string[])
- )
- ).then(results => {
+ sharedRanks(runs, r => api.analysis.ranks(study, r.run, {
+ table: option.table,
+ group: r.group,
+ ...(r.source ? { source: r.source } : {}),
+ })).then(intersection => {
if (cancelled) return
- const intersection = results.reduce((acc, cur) =>
- acc.filter(r => cur.includes(r)),
- results[0] ?? []
- )
setRanks(intersection)
setRanksReady(true)
// Default to the deepest (last) shared rank.
setRank(current =>
intersection.includes(current) ? current : (intersection[intersection.length - 1] ?? '')
)
+ }).catch(err => {
+ if (cancelled) return
+ setRanks([])
+ setRank('')
+ setRanksError(errorMessage(err))
})
return () => { cancelled = true }
}, [study, runs, option])
+ // A diagram answers the inputs it was computed from; new inputs discard it,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setResult(null)
+ setLoading(false)
+ }, [study, runs, option?.table, rank])
+
+ /** Compute the overlap for the current inputs and show it, unless the inputs changed meanwhile. */
const runVenn = async () => {
if (!option || !rank) return
+ const req = ++request.current
setLoading(true)
try {
const res = await api.analysis.venn(study, {
@@ -74,11 +86,13 @@ export function VennPanel({ study, runs, option }: {
table: option.table,
rank,
})
- setResult(res)
+ if (req === request.current) setResult(res)
} catch (err) {
+ if (req !== request.current) return
+ setResult(null)
toast.error(`Taxon overlap failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
@@ -111,6 +125,9 @@ export function VennPanel({ study, runs, option }: {
{ranksReady && ranks.length === 0 && (
No taxonomy rank shared by the selected runs.
)}
+ {ranksError && (
+ Could not read the taxonomy ranks: {ranksError}
+ )}
+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/proofs/agda/MetaManifold/All.agda b/proofs/agda/MetaManifold/All.agda
new file mode 100644
index 00000000..7cedd39a
--- /dev/null
+++ b/proofs/agda/MetaManifold/All.agda
@@ -0,0 +1,14 @@
+-- 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
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
+--
+-- 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 and size-factor scaling
+
+`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`). The functions were added by the
+differential-abundance change (branch `feat/differential-abundance-nb`,
+commits `6c3e8b2` and `23dc89c`). These proofs depend on that change being
+merged first.
+
+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.
+
+Earlier planning material and the archived proofs used Agda 2.7.0.1. These
+proofs are written for, and checked with, 2.6.4.3. They have not been checked
+with 2.7.0.1 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.
+
+## Theorem map
+
+| Requirement | Agda theorem (module) | Plain statement | Julia function | D1 test (`test/unit/test_differential.jl`) |
+|---|---|---|---|---|
+| R-BH-1 | `bh-nonneg` (BenjaminiHochberg) | For valid p-values, every adjusted value is ≥ 0. | `bh_adjust` | lines 106–110, parity 117–124 |
+| R-BH-2a | `bh-dominates-p` | Every adjusted value is ≥ its own p-value. | `bh_adjust` | line 107 (`0.001 → 0.003`), parity |
+| R-BH-2b | `bh-at-most-one` | Every adjusted value is ≤ 1. | `bh_adjust` | line 107 (`0.5 → 0.5`), line 110 (`[1.0]`) |
+| R-BH-2c | `bh-monotone` | For any two inputs, `p_i ≤ p_j` implies `adj_i ≤ adj_j`. This covers ties in both directions. | `bh_adjust` | line 109 (`[0.04, 0.01] → [0.04, 0.02]`) |
+| R-BH-2d | `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 | line 106 (all → 0.04), parity |
+| – | `bh-length` | The output has one entry per input. | `bh_adjust` | line 111 (empty → empty) |
+| – | `monotone-in-family-size` | A larger family never gives a smaller scaled value. | `n / rank * p` | – |
+| TSS | `total-positive` (Scaling) | 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` | lines 137–139 (empty row refused) |
+| TSS | `proportions-sum-to-1` | A row's proportions `x / Σx` sum to 1. | (relative abundance) | – |
+| TSS | `proportions-bounded` | Every proportion lies in `[0, 1]`. | (relative abundance) | – |
+| TSS/RLE | `centre-positive`, `centre-monotone` | Dividing factors by any positive `g` keeps them positive and keeps their order. | `lib ./ _geomean(lib)`, `raw ./ _geomean(raw)` | – |
+| RLE | `ratio-positive` | A positive count over a positive normaliser is positive. | `view(sub, i, :) ./ gm` | – |
+| RLE | `median-positive` | The median (taken as `_median` takes it) of a non-empty list of positive values is positive. | `_median`, `rle_factors` | lines 143–153 |
+
+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, proved because
+the requirement names them. They do not describe a value the Julia code
+returns.
+
+## Corrections to the requirement wording
+
+- **R-BH-2a is true for the procedure.** An earlier archived note recorded
+ "q_i ≥ p_i is FALSE". It is false only for a scaling factor below 1. In BH
+ the rank `r` is at most `n`, so `n / r ≥ 1` (`rankFactor-≥1`), and the
+ clamp at 1 keeps the bound because `p ≤ 1`. The theorem is proved as
+ worded, for valid inputs.
+- **R-BH-2c is stated over input positions, not over sorted ranks.** A
+ sorted-rank statement would leave open how tied inputs are written back.
+ The membership form `x ∈ zip ps (bhAdjust ps)` covers ties.
+- **Empty input** is covered. All the `All`/membership statements are vacuous
+ on `[]`, and `bh-length` gives the empty result.
+
+## What is not proved, and why
+
+- **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)`, line 136;
+ the same for RLE, line 148). This is a property of geometric-mean
+ centring and is not proved.
+- **The RLE "doubled sample" ratio test** (lines 150–151) 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.
+- **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.
+
+## Positive controls (run when the proofs were written)
+
+1. **Audit.** Each of the following was planted in a scratch copy, and each
+ made the audit exit 1 with a named finding: a `postulate` block, a
+ mid-line `private postulate`, `?`, `{! !}`, `{-# TERMINATING #-}`, a
+ module not imported by `All.agda`, and a removed `--without-K`. A comment
+ that mentions "postulate" and "?" does not trigger it.
+2. **Monotonicity needs the cumulative minimum.** Removing it makes Agda
+ reject the proofs:
+ - from `running`: rejected in `head-eq`, the envelope proof;
+ - from the sorted stage (`Z`): rejected in `Z-bounded`;
+ - from the stage the monotonicity proof alone uses: rejected in
+ `Z-linked`, at `ℚₚ.p⊓q≤q s F`.
+
+ A concrete counterexample is `p = [0.01, 0.011]`, `n = 2`. Without the
+ minimum the scaled values are `0.02` and `0.011`, which are not monotone.
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..dfac546e 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",
@@ -2907,6 +2926,36 @@
"Maintainer": "Hadley Wickham ",
"Repository": "CRAN"
},
+ "survival": {
+ "Package": "survival",
+ "Version": "3.8-3",
+ "Source": "Repository",
+ "Title": "Survival Analysis",
+ "Priority": "recommended",
+ "Date": "2024-12-17",
+ "Depends": [
+ "R (>= 3.5.0)"
+ ],
+ "Imports": [
+ "graphics",
+ "Matrix",
+ "methods",
+ "splines",
+ "stats",
+ "utils"
+ ],
+ "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": "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"
+ },
"tibble": {
"Package": "tibble",
"Version": "3.3.1",
@@ -3015,6 +3064,28 @@
"Maintainer": "Lionel Henry ",
"Repository": "CRAN"
},
+ "truncnorm": {
+ "Package": "truncnorm",
+ "Version": "1.0-9",
+ "Source": "Repository",
+ "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 (>= 3.4.0)"
+ ],
+ "Suggests": [
+ "testthat"
+ ],
+ "License": "GPL (>= 2)",
+ "Encoding": "UTF-8",
+ "NeedsCompilation": "yes",
+ "Author": "Olaf Mersmann [aut, cre], Heike Trautmann [aut], Detlef Steuer [aut], Björn Bornkamp [aut]",
+ "Maintainer": "Olaf Mersmann ",
+ "Repository": "CRAN"
+ },
"utf8": {
"Package": "utf8",
"Version": "1.2.6",
@@ -3192,6 +3263,33 @@
"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: )"
}
}
}
diff --git a/src/analysis/analysis.jl b/src/analysis/analysis.jl
index 029d57b7..f40bcc2e 100644
--- a/src/analysis/analysis.jl
+++ b/src/analysis/analysis.jl
@@ -16,6 +16,7 @@ export sample_columns, filtered_counts, filtered_df, taxonomy_levels, taxon_colu
natural_less, natural_sort, short_sample_labels, pinned_segment_order,
alpha_boxplot, nmds_chart,
run_nmds, 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))
-# 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.
+"""
+ was_computed(r::AlphaSignificance) -> Bool
+
+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}(
@@ -1328,14 +1423,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 +1467,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 +1480,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 +1492,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 +1548,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 +1563,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..d2c78de0 100644
--- a/src/analysis/differential.jl
+++ b/src/analysis/differential.jl
@@ -2,24 +2,32 @@
# 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, ZCompositionsUnavailable,
+ OFFSET_METHODS, METHODS, tss_factors, rle_factors, size_factors, bh_adjust,
+ validate_counts, replace_zeros, clr_transform, fit_nb, fit_welch,
+ differential_abundance, volcano_chart
-export DifferentialConfig, ScalingRefusal, MASSUnavailable, OFFSET_METHODS,
- tss_factors, rle_factors, size_factors, bh_adjust, validate_counts,
- fit_nb, differential_abundance, volcano_chart
+# Methods accepted under `analysis.differential.method`.
+const METHODS = ("nb_glm", "clr_welch")
# Offset (size factor) methods accepted under `analysis.differential.offset`.
const OFFSET_METHODS = ("tss", "rle")
@@ -30,24 +38,42 @@ 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)
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.
"""
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
+ function DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0;
+ method::AbstractString="nb_glm",
+ replacement_delta::Real=DEFAULT_DELTA)
+ 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'"))
(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))
end
end
@@ -77,6 +103,18 @@ 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()')")
+
"""
_geomean(x) -> Float64
@@ -179,7 +217,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 +228,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
@@ -212,6 +250,18 @@ for (da_j in seq_len(da_n)) {
da_result[da_j, "note"] <- "the counts are constant across samples: there is nothing to estimate"
next
}
+ # Complete separation: with no reads in every sample of one group the group
+ # coefficient runs off to infinity, its standard error with it, and the Wald
+ # p-value collapses towards 1 although the fit reports convergence. 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"] <- paste0("no reads in any '", da_absent[[1]], "' sample (complete ",
+ "separation): the fold change is unbounded and the Wald test ",
+ "is undefined, so no p-value is reported")
+ next
+ }
da_warns <- character(0)
da_fit <- tryCatch(
withCallingHandlers(
@@ -298,7 +348,7 @@ function fit_nb(counts::AbstractMatrix{<:Integer}, groups::AbstractVector{<:Abst
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_row', 'da_theta', 'da_absent'))); invisible(gc())")
end
end
DataFrame(status = String.(raw.status),
@@ -310,23 +360,260 @@ 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.
+
+`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, 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`.
+carry `padj = nothing`. The result's `effect` names the row key that holds the
+effect size and its label.
-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.
+Throws `ArgumentError` for unusable input (including a zero replacement that
+cannot be made), `ScalingRefusal` when the offset cannot be computed,
+`MASSUnavailable` or `ZCompositionsUnavailable` when the R package a method
+needs is missing, and an `ErrorException` when no taxon could be fitted at all.
"""
function differential_abundance(counts::AbstractMatrix{<:Real},
samples::AbstractVector{<:AbstractString},
@@ -348,38 +635,82 @@ function differential_abundance(counts::AbstractMatrix{<:Real},
(n_ref >= 1 && n_con >= 1) || throw(ArgumentError(
"each group needs at least one sample; '$reference' has $n_ref and '$contrast' has $n_con"))
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"))
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}()
+
+ 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)
+ 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
+ method_text = "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"
+ 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)
+ 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"))
+ 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 +727,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 +747,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 +771,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/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/server/routes/analysis.jl b/src/server/routes/analysis.jl
index 93cd59f9..0dd506a3 100644
--- a/src/server/routes/analysis.jl
+++ b/src/server/routes/analysis.jl
@@ -1201,7 +1201,9 @@ 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)))
end
"""
@@ -1279,6 +1281,7 @@ 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 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..2f927ba1 100644
--- a/src/server/routes/config.jl
+++ b/src/server/routes/config.jl
@@ -106,6 +106,7 @@ 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,
"phylogeny.reference.align.strategy" => Validation.PHYLOGENY_ALIGN_STRATEGIES,
"phylogeny.placement.align.strategy" => Validation.PHYLOGENY_ALIGN_STRATEGIES,
@@ -114,11 +115,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 +144,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 +296,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/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/server.jl b/src/server/server.jl
index 56537a71..cef70d33 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
@@ -342,10 +390,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
@@ -354,10 +409,7 @@ module Server
listen || return nothing
_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/runtests.jl b/test/runtests.jl
index 4a8f47fa..162363a7 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
diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl
index 9f612952..3b034896 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,8 @@ 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"
# Rows per run: Up is split over two ASVs so the route must pool them by genus.
const _DA_ROWS = Dict(
@@ -163,6 +166,15 @@ 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")
+ for d in (0, 1, -0.5, NaN)
+ @test_throws ArgumentError DifferentialConfig(; method="clr_welch", replacement_delta=d)
+ end
end
@testset "Input validation" begin
@@ -268,6 +280,41 @@ end
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 +351,255 @@ 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"))
+ 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 +641,83 @@ 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
end
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_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..59c930b1 100644
--- a/test/unit/test_r_runtime.jl
+++ b/test/unit/test_r_runtime.jl
@@ -94,4 +94,104 @@ 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
+
end
diff --git a/test/unit/test_routes.jl b/test/unit/test_routes.jl
index a240e470..62998944 100644
--- a/test/unit/test_routes.jl
+++ b/test/unit/test_routes.jl
@@ -522,6 +522,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 +796,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