From b21d005df3f82dfad880d17e100b310aed160b55 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:16:37 +0100 Subject: [PATCH 01/32] Add a CLR linear-model method for differential abundance analysis.differential.method selects nb_glm (the default, unchanged) or clr_lm: multiplicative zero replacement (Martin-Fernandez et al. 2003, the zCompositions::multRepl operator) over every observed taxon, the centred log-ratio, then stats::lm per taxon with BH over the fitted taxa. min_prevalence is applied after the transform, so a filtered taxon still shapes every sample's CLR. Replacement keeps each sample total and the ratios among its observed taxa exactly, checked at runtime, and refuses with a reason rather than guessing. The result carries an effect key so the volcano plot, table and CSV label a CLR difference as such, not as a fold change, and reports how many zeros were replaced and the largest share of a sample they hold. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Qbn8ECdtai37Nzdyf5so3D --- config/defaults/pipeline.yml | 10 + frontend/src/api/types.ts | 36 +- frontend/src/components/DifferentialPanel.tsx | 25 +- frontend/src/components/PipelineStages.tsx | 6 +- .../src/components/differentialTable.test.ts | 26 +- frontend/src/components/differentialTable.ts | 21 +- src/MetaManifold.jl | 1 + src/analysis/differential.jl | 339 ++++++++++++++---- src/analysis/zero_replacement.jl | 168 +++++++++ src/server/routes/analysis.jl | 4 +- src/server/routes/config.jl | 1 + test/runtests.jl | 1 + test/unit/test_differential.jl | 146 ++++++++ test/unit/test_zero_replacement.jl | 78 ++++ 14 files changed, 770 insertions(+), 92 deletions(-) create mode 100644 src/analysis/zero_replacement.jl create mode 100644 test/unit/test_zero_replacement.jl diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml index 11198e7a..15c4f74d 100644 --- a/config/defaults/pipeline.yml +++ b/config/defaults/pipeline.yml @@ -347,10 +347,20 @@ 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_lm: + # a linear model on centred log-ratios after multiplicative replacement of + # 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_lm 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. min_prevalence: 0.0 + # clr_lm only: a zero is replaced by this fraction, between 0 and 1, of the + # taxon's smallest observed count, and the other counts in the sample are + # scaled down so its total is unchanged. 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..6c8e8d20 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -381,18 +381,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_lm: mean + * CLR of contrast minus mean CLR of reference, which is not a fold change. + */ estimate: number | null + /** nb_glm only; null for clr_lm. */ log2_fold_change: number | null standard_error: number | null + /** z (nb_glm) or t (clr_lm). */ 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_lm' + +/** 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 +414,25 @@ export interface DifferentialResult { table: string groups: { reference: string; contrast: string } n_samples: Record + effect: DifferentialEffect + /** Empty for clr_lm, 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_lm only. */ + replacement_delta?: number + } + diagnostics: { + n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number + /** clr_lm only. */ + zero_replacement?: { + delta: number; zeros_replaced: number; n_taxa_in_composition: number + n_taxa_unobserved: number; max_imputed_fraction: number + } + } rows: DifferentialRow[] figure: unknown } diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx index ff15b507..1eb999bd 100644 --- a/frontend/src/components/DifferentialPanel.tsx +++ b/frontend/src/components/DifferentialPanel.tsx @@ -10,7 +10,7 @@ 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 @@ -96,7 +96,7 @@ export function DifferentialPanel({ study, runs, option, aggregate }: { )} {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 +109,11 @@ 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 (delta ${z.delta}); replaced values hold at most ` + + `${(100 * z.max_imputed_fraction).toPrecision(2)}% of any sample, and are not measurements.` + })()}

@@ -126,8 +131,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 +143,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/PipelineStages.tsx b/frontend/src/components/PipelineStages.tsx index 8a96ab41..0614432f 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_lm (linear model on centred log-ratios after multiplicative 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_lm only: fraction, between 0 and 1, of a taxon\'s smallest observed count given to each of its zeros; the rest of the sample is scaled so its total is unchanged. 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_lm'] }, '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/differentialTable.test.ts b/frontend/src/components/differentialTable.test.ts index cdf06fc2..092e6a98 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_lm results', () => { + const clr: DifferentialResult = { + ...result, + method: 'CLR LM', + effect: { key: 'estimate', label: 'CLR difference' }, + config: { method: 'clr_lm', 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_lm, 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..be40d987 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_lm, so each line + * names only its own method's parameters. + */ +export function configLine(result: DifferentialResult): string { + const c = result.config + return c.method === 'clr_lm' + ? `method clr_lm, 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/src/MetaManifold.jl b/src/MetaManifold.jl index e3c70714..3cf0c8ad 100644 --- a/src/MetaManifold.jl +++ b/src/MetaManifold.jl @@ -29,6 +29,7 @@ include("pipeline/phylogeny.jl") # Analysis include("analysis/diversity.jl") include("analysis/analysis.jl") +include("analysis/zero_replacement.jl") include("analysis/differential.jl") # Web server diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl index 5fad6b45..471fb47b 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_lm: a Gaussian linear model per taxon (stats::lm) on centred log-ratios, +# after multiplicative replacement of the zeros (`ZeroReplacement`). 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 +using ..ZeroReplacement: DEFAULT_DELTA, multiplicative_replacement -export DifferentialConfig, ScalingRefusal, MASSUnavailable, OFFSET_METHODS, +export DifferentialConfig, ScalingRefusal, MASSUnavailable, OFFSET_METHODS, METHODS, tss_factors, rle_factors, size_factors, bh_adjust, validate_counts, - fit_nb, differential_abundance, volcano_chart + clr_transform, fit_nb, fit_lm, differential_abundance, volcano_chart + +# Methods accepted under `analysis.differential.method`. +const METHODS = ("nb_glm", "clr_lm") # Offset (size factor) methods accepted under `analysis.differential.offset`. const OFFSET_METHODS = ("tss", "rle") @@ -31,23 +39,37 @@ const THETA_UPPER = 1e7 const THETA_LOWER = 1e-8 """ - 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_lm"`. `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 Gaussian +model of 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 +fraction of a taxon's detection limit given to a replaced zero by `clr_lm`. """ 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 @@ -179,7 +201,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 +212,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 @@ -310,23 +332,153 @@ 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 + +# The per-taxon Gaussian fit loop, with the same `da_` naming and statuses as +# `_FIT_R` but no `boundary`: an lm has no dispersion parameter to run to a bound. +const _FIT_LM_R = raw""" +da_g <- factor(da_group, levels = da_levels) +da_term <- paste0("da_g", da_levels[2]) +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_co <- tryCatch( + withCallingHandlers( + summary(stats::lm(da_y ~ da_g))[["coefficients"]], + warning = function(w) { + da_warns <<- c(da_warns, conditionMessage(w)) + invokeRestart("muffleWarning") + }), + error = function(e) e) + if (inherits(da_co, "error")) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- paste("lm stopped with an error:", conditionMessage(da_co)) + next + } + if (!(da_term %in% rownames(da_co))) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- "the group coefficient is not estimable (aliased)" + next + } + da_row <- da_co[da_term, ] + if (!all(is.finite(da_row)) || da_row[[2]] <= 0 || da_row[[4]] < 0 || da_row[[4]] > 1) { + da_result[da_j, "status"] <- "failed" + da_result[da_j, "note"] <- paste(c("the fit returned a non-finite or zero standard error, so there is no test (the residual variance is zero)", + unique(da_warns)), collapse = "; ") + next + } + da_result[da_j, c("estimate", "se", "statistic", "pvalue")] <- unname(da_row[1:4]) + if (length(da_warns) > 0L) { + da_result[da_j, "note"] <- paste("R warned:", paste(unique(da_warns), collapse = "; ")) + } +} +""" + +""" + fit_lm(values, groups; levels) -> DataFrame + +Fit `stats::lm(y ~ group)` to each column of the samples x taxa matrix +`values` (for `clr_lm`, centred log-ratios). `levels` is +`(reference, contrast)`, so the estimate is the mean of `contrast` minus the +mean of `reference`, and the test is the two-sample t-test with pooled +variance. Returns one row per taxon with `status` (`ok` or `failed`), `note`, +`estimate`, `se`, `statistic` (t) and `pvalue`; a failed taxon has `nothing` +for every statistic. Throws `RBusyError` when the R runtime stays busy. +""" +function fit_lm(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_LM_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_term', 'da_n', 'da_result', 'da_j', 'da_y', 'da_warns', " * + "'da_co', '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_lm`, in this order: (1) taxa with no reads in any of the samples are set +aside, because a taxon never observed has no detection limit and is not part of +the composition; (2) the zeros of the remaining taxa are replaced +multiplicatively with `config.replacement_delta`; (3) each sample is +transformed to centred log-ratios over all of those taxa; (4) only then is +`min_prevalence` applied, choosing which taxa are tested. Filtering before the +transform would change every sample's geometric mean and so every other +taxon's value. The estimate is the difference in mean CLR, contrast minus +reference: a difference in centred-log-ratio units, not a fold change, and it +is relative to the geometric mean of the observed taxa. Replaced zeros are not +measurements, so a taxon with many zeros carries an estimate that depends on +`replacement_delta`. + +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` when MASS is missing, and an `ErrorException` when no taxon +could be fitted at all. """ function differential_abundance(counts::AbstractMatrix{<:Real}, samples::AbstractVector{<:AbstractString}, @@ -348,38 +500,79 @@ 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) - 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), - "standard_error" => f.se, "statistic" => f.statistic, - "pvalue" => f.pvalue, "dispersion_theta" => f.theta)) + 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 + observed = findall(>(0), present) + for j in setdiff(axes(counts, 2), observed) + rows[j]["note"] = "no reads in any sample of either group, so it has no " * + "detection limit and is not part of the composition" + end + length(observed) >= 2 || throw(ArgumentError( + "the centred log-ratio needs at least 2 taxa with reads; there are $(length(observed))")) + replaced = multiplicative_replacement(counts[:, observed]; + delta=config.replacement_delta, + samples, taxa=taxa[observed]) + clr = clr_transform(replaced.counts) + tested = findall(k -> prevalence[observed[k]] >= config.min_prevalence, eachindex(observed)) + fits = isempty(tested) ? nothing : fit_lm(clr[:, tested], groups; levels) + if !isnothing(fits) + for (k, c) in enumerate(tested) + f = fits[k, :] + merge!(rows[observed[c]], Dict{String,Any}( + "status" => f.status, "note" => f.note, "estimate" => f.estimate, + "standard_error" => f.se, "statistic" => f.statistic, + "pvalue" => f.pvalue)) + end end + method_text = "Gaussian linear model per taxon (stats::lm) on centred log-ratios, after " * + "multiplicative replacement of zeros (delta = $(config.replacement_delta) of " * + "each taxon's smallest observed count); t test of the group coefficient; " * + "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) + diagnostics["zero_replacement"] = Dict{String,Any}( + "delta" => replaced.delta, "zeros_replaced" => replaced.zeros_replaced, + "n_taxa_in_composition" => length(observed), + "n_taxa_unobserved" => length(taxa) - length(observed), + "max_imputed_fraction" => maximum(replaced.imputed_mass)) end family = findall(r -> r["status"] in ("ok", "boundary") && !isnothing(r["pvalue"]), rows) @@ -396,21 +589,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 +609,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 +633,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/analysis/zero_replacement.jl b/src/analysis/zero_replacement.jl new file mode 100644 index 00000000..3ca7f7f3 --- /dev/null +++ b/src/analysis/zero_replacement.jl @@ -0,0 +1,168 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) + +## Zero replacement for compositional transforms +# Multiplicative replacement (Martín-Fernández, Barceló-Vidal & Pawlowsky-Glahn +# 2003, Mathematical Geology 35(3):253-278), the operator of +# `zCompositions::multRepl` with `frac = delta` and a per-taxon detection limit. +# A log-ratio transform is undefined at zero, so a zero has to be given some +# positive value first; this operator does that while keeping each sample's +# total and the ratios among its observed taxa exactly as they were, which a +# pseudocount does not. +# +# Matrices here are samples x taxa, as everywhere else in `Analysis` (the +# reference and the original paper write compositions as rows too). +# +# A replaced value is not a measurement. The replacement is biased, and no rule +# that sees only the observed data can avoid that; the result records it. +module ZeroReplacement + +export DEFAULT_DELTA, ReplacementOutcome, multiplicative_replacement, + replacement_invariants_hold + +""" + DEFAULT_DELTA + +The fraction of a taxon's detection limit that a replaced zero receives by +default, 0.65, as in `zCompositions::multRepl` and the original paper. +""" +const DEFAULT_DELTA = 0.65 + +# Relative tolerance of the runtime invariant checks. +const TOLERANCE = 1e-9 + +""" + ReplacementOutcome + +The result of `multiplicative_replacement`: the replaced samples x taxa table, +the `delta` used, the per-taxon `detection_limits`, the number of +`zeros_replaced`, and `imputed_mass`, the fraction of each sample's total now +held by replaced values. +""" +struct ReplacementOutcome + counts :: Matrix{Float64} + delta :: Float64 + detection_limits :: Vector{Float64} + zeros_replaced :: Int + imputed_mass :: Vector{Float64} +end + +""" + _invariant_violation(original, replaced) -> Union{String,Nothing} + +The first way in which `replaced` fails to be a multiplicative replacement of +`original`, as a sentence, or `nothing` when it is one: same shape, every +sample total preserved, every observed taxon in a sample scaled by the same +factor, and no zero left. +""" +function _invariant_violation(original::AbstractMatrix{<:Real}, replaced::AbstractMatrix{<:Real}) + size(original) == size(replaced) || + return "the replaced table is $(size(replaced)) for an input of $(size(original))" + for i in axes(original, 1) + before = sum(view(original, i, :)) + before > 0 || return "sample $i has no reads" + after = sum(view(replaced, i, :)) + abs(after - before) <= TOLERANCE * before || + return "sample $i changed total from $before to $after" + scale = nothing + for j in axes(original, 2) + if original[i, j] > 0 + r = replaced[i, j] / original[i, j] + scale = something(scale, r) + abs(r - scale) <= TOLERANCE * max(1.0, abs(scale)) || + return "sample $i scaled taxon $j by $r and an earlier taxon by $scale" + elseif !(replaced[i, j] > 0) + return "sample $i still holds $(replaced[i, j]) for taxon $j" + end + end + end + nothing +end + +""" + replacement_invariants_hold(original, replaced) -> Bool + +Whether `replaced` keeps every sample total of `original`, scales the observed +taxa of each sample by one common factor, and leaves no zero. +""" +replacement_invariants_hold(original::AbstractMatrix{<:Real}, replaced::AbstractMatrix{<:Real}) = + isnothing(_invariant_violation(original, replaced)) + +""" + multiplicative_replacement(counts; delta=DEFAULT_DELTA, samples, taxa) -> ReplacementOutcome + +Replace the zeros of the samples x taxa table `counts`. With `DL_j` the +detection limit of taxon `j` (its smallest observed value anywhere in the +table), `S_i` the total of sample `i`, `Z_i` its zero taxa and +`Δ_i = Σ_{j ∈ Z_i} delta·DL_j / S_i`: + + x̃_ij = delta · DL_j for j ∈ Z_i + x̃_ij = (1 - Δ_i) · x_ij otherwise + +Each sample total and the ratios among each sample's observed taxa are kept +exactly; every replaced value is positive and below its taxon's detection +limit. On a table of proportions this equals `zCompositions::multRepl(X, +label = 0, dl, frac = delta)`; on counts with unequal totals, dividing each row +by its total gives the reference's output. + +`samples` and `taxa` name rows and columns in error messages. Throws +`ArgumentError` when `delta` is not in (0, 1), when a value is negative or not +finite, when a taxon is never observed (its detection limit cannot be +estimated), when a sample has no reads, or when a sample's replaced values +would take its whole total (`Δ_i ≥ 1`); that message gives the largest +`delta` the sample admits. +""" +function multiplicative_replacement(counts::AbstractMatrix{<:Real}; + delta::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)]) + δ = Float64(delta) + 0 < δ < 1 || throw(ArgumentError( + "the replacement delta must be in (0, 1), not $delta: it is the fraction of a " * + "taxon's detection limit that a replaced zero receives (0.65 is the published default)")) + size(counts) == (length(samples), length(taxa)) || throw(ArgumentError( + "the table is $(size(counts)) but there are $(length(samples)) samples and $(length(taxa)) taxa")) + X = Matrix{Float64}(counts) + for j in axes(X, 2), i in axes(X, 1) + (isfinite(X[i, j]) && X[i, j] >= 0) || throw(ArgumentError( + "the value for taxon '$(taxa[j])' in sample '$(samples[i])' is $(X[i, j]); " * + "zero replacement needs non-negative finite values")) + end + + dl = Vector{Float64}(undef, size(X, 2)) + for j in axes(X, 2) + observed = filter(>(0), view(X, :, j)) + isempty(observed) && throw(ArgumentError( + "taxon '$(taxa[j])' has no reads in any sample, so its detection limit " * + "cannot be estimated from this table")) + dl[j] = minimum(observed) + end + + out = copy(X) + mass = zeros(Float64, size(X, 1)) + n_zero = 0 + for i in axes(X, 1) + total = sum(view(X, i, :)) + total > 0 || throw(ArgumentError("sample '$(samples[i])' has no reads")) + zeros_i = findall(==(0), view(X, i, :)) + isempty(zeros_i) && continue + limit = sum(dl[zeros_i]) + Δ = δ * limit / total + Δ < 1 || throw(ArgumentError( + "sample '$(samples[i])' has $(length(zeros_i)) zeros whose detection limits sum " * + "to $limit against a total of $total, so delta = $δ would give replaced values " * + "$(round(100Δ; digits=1))% of the sample. The largest delta this sample admits " * + "is below $(total / limit); lower delta or raise min_prevalence")) + for j in axes(X, 2) + out[i, j] = X[i, j] == 0 ? δ * dl[j] : (1 - Δ) * X[i, j] + end + mass[i] = Δ + n_zero += length(zeros_i) + end + + problem = _invariant_violation(X, out) + isnothing(problem) || error("multiplicative replacement broke its own invariant: $problem") + ReplacementOutcome(out, δ, dl, n_zero, mass) +end + +end # module ZeroReplacement diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl index 93cd59f9..fdd529b7 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 """ diff --git a/src/server/routes/config.jl b/src/server/routes/config.jl index 02b662c3..dbb48989 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, diff --git a/test/runtests.jl b/test/runtests.jl index 4a8f47fa..82c97629 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -32,6 +32,7 @@ const UNIT_FILES = [ "test_publication_tables.jl", "test_heatmap.jl", "test_trees.jl", + "test_zero_replacement.jl", "test_report_funnel.jl", "test_phylogeny.jl", "test_composition.jl", diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index 9f612952..b2eee27f 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -163,6 +163,13 @@ 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_lm", replacement_delta=0.3).replacement_delta == 0.3 + @test_throws ArgumentError DifferentialConfig(; method="ancombc") + for d in (0, 1, -0.5, NaN) + @test_throws ArgumentError DifferentialConfig(; method="clr_lm", replacement_delta=d) + end end @testset "Input validation" begin @@ -304,6 +311,114 @@ 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_lm")) + @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_lm") + + @testset "CLR linear model: estimate is the difference of group mean CLRs" begin + if !HAVE_R + @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_lm" && !haskey(res["config"], "offset") + @test res["diagnostics"]["zero_replacement"]["zeros_replaced"] == 0 + # Known answer, independent of R: with a two-level factor, the lm + # coefficient 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 stats::lm call" begin + y = z[:, 1]; g = effect_groups + direct = rcopy(R""" + fit <- summary(stats::lm($y ~ factor($g, levels = c("A", "B")))) + unname(fit$coefficients[2, c(1, 2, 4)]) + """) + @test byt["t_up"]["estimate"] ≈ direct[1] rtol=1e-10 + @test byt["t_up"]["standard_error"] ≈ direct[2] rtol=1e-10 + @test byt["t_up"]["pvalue"] ≈ direct[3] rtol=1e-8 + end + end + end + + @testset "CLR: zeros, unobserved taxa and prevalence" begin + if !HAVE_R + @test_skip false + else + # Five samples x four taxa: 'ghost' has no reads anywhere, 'rare' + # one read in one sample, 'p' and 'q' are always present. + x = [10 20 0 0; 12 18 0 0; 11 19 3 0; 30 8 0 0; 28 9 0 0; 33 7 0 0] + s = ["s$i" for i in 1:6] + taxa = ["p", "q", "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_lm")) + 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"]) + # 'rare' is in the composition (it shapes the CLR of every sample) + # but is below the prevalence threshold, so it is not tested. + @test byt["rare"]["status"] == "filtered" + @test occursin("min_prevalence", byt["rare"]["note"]) + @test byt["p"]["status"] == "ok" && byt["q"]["status"] == "ok" + zr = res["diagnostics"]["zero_replacement"] + @test zr["n_taxa_in_composition"] == 3 && zr["n_taxa_unobserved"] == 1 + @test zr["zeros_replaced"] == 5 + @test 0 < zr["max_imputed_fraction"] < 1 + + # The composition the fit saw includes 'rare', replaced: the estimate + # for 'p' is computed on the 3-taxon CLR, not on p and q alone. + obs = Float64.(x[:, 1:3]) + z = clr_transform(MetaManifold.ZeroReplacement.multiplicative_replacement(obs).counts) + a = g .== "A" + want = sum(z[.!a, 1]) / 3 - sum(z[a, 1]) / 3 + @test byt["p"]["estimate"] ≈ want rtol=1e-8 + + @test_throws ArgumentError differential_abundance(x[:, [1, 4]], s, ["p", "ghost"], g; + reference="A", contrast="B", config=clr_config) + end + end + + @testset "CLR: constant log-ratios are refused with their reason" begin + if !HAVE_R + @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 +460,35 @@ end @test r.status == 404 end end + + @testset "Route: clr_lm 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_lm\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_R + @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_lm" && body.config.replacement_delta == 0.5 + @test isempty(body.size_factors) + @test body.diagnostics.zero_replacement.zeros_replaced == 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_lm\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 end diff --git a/test/unit/test_zero_replacement.jl b/test/unit/test_zero_replacement.jl new file mode 100644 index 00000000..4215312b --- /dev/null +++ b/test/unit/test_zero_replacement.jl @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Multiplicative zero replacement: a hand-computed known answer, the +# invariants (totals kept, observed ratios kept, no zero left), every refusal, +# and a control showing the invariant check can fail. Pure Julia; no R needed. + +using MetaManifold +using MetaManifold.ZeroReplacement +using Test + +@testset "Zero replacement" begin + + @testset "Known answer, computed by hand" begin + # Detection limits: taxon 1 -> 4, taxon 2 -> 2, taxon 3 -> 6. With + # delta = 0.5, sample 1 replaces one zero worth 0.5*4 = 2 of a total of + # 10 (Δ = 0.2) and sample 2 one worth 0.5*2 = 1 (Δ = 0.1). + x = [0 2 8; 4 0 6] + out = multiplicative_replacement(x; delta=0.5) + @test out.counts ≈ [2.0 1.6 6.4; 3.6 1.0 5.4] + @test out.detection_limits == [4.0, 2.0, 6.0] + @test out.zeros_replaced == 2 + @test out.imputed_mass ≈ [0.2, 0.1] + @test out.delta == 0.5 + end + + @testset "Invariants on a larger table" begin + x = [0 3 0 12 5; 7 0 1 0 9; 2 2 2 2 2; 0 0 4 30 1] + out = multiplicative_replacement(x) + @test replacement_invariants_hold(x, out.counts) + @test vec(sum(out.counts; dims=2)) ≈ vec(sum(x; dims=2)) + @test all(>(0), out.counts) + # Every replaced value is below its taxon's detection limit. + for i in axes(x, 1), j in axes(x, 2) + x[i, j] == 0 && @test out.counts[i, j] < out.detection_limits[j] + end + # A sample without zeros is returned unchanged. + @test out.counts[3, :] == Float64.(x[3, :]) + @test out.imputed_mass[3] == 0.0 + @test out.zeros_replaced == 6 + end + + @testset "The invariant check can fail (control)" begin + x = [0 2 8; 4 0 6] + good = multiplicative_replacement(x; delta=0.5).counts + bad_total = copy(good); bad_total[1, 1] += 1 + @test !replacement_invariants_hold(x, bad_total) + bad_ratio = copy(good); bad_ratio[1, 2] += 0.1; bad_ratio[1, 3] -= 0.1 + @test !replacement_invariants_hold(x, bad_ratio) + @test !replacement_invariants_hold(x, Float64.(x)) + @test !replacement_invariants_hold(x, good[:, 1:2]) + end + + @testset "Refusals" begin + x = [0 2 8; 4 0 6] + for d in (0, 1, -0.5, 1.5, NaN) + @test_throws ArgumentError multiplicative_replacement(x; delta=d) + end + @test_throws ArgumentError multiplicative_replacement([-1 2; 3 4]) + @test_throws ArgumentError multiplicative_replacement([Inf 2; 3 4]) + @test_throws ArgumentError multiplicative_replacement([0 2; 0 4]) + @test_throws ArgumentError multiplicative_replacement([0 0; 3 4]) + @test_throws ArgumentError multiplicative_replacement(x; samples=["a"], taxa=["p", "q", "r"]) + + msg = try + multiplicative_replacement([0 0 1; 50 50 1]; samples=["s1", "s2"], taxa=["p", "q", "r"]) + "" + catch e + e.msg + end + # Sample s1: limits 50 + 50 against a total of 1, so delta must stay below 0.01. + @test occursin("'s1'", msg) + @test occursin("below 0.01", msg) + + msg = try multiplicative_replacement([0 2; 0 4]; taxa=["ghost", "real"]); "" catch e; e.msg end + @test occursin("'ghost'", msg) + end +end From 3acf6072f0cfeec489afc6826185123becc273ac Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:01:59 +0100 Subject: [PATCH 02/32] clr_lm: filter by prevalence before cmultRepl, and test with Welch Address review on #25: - Apply min_prevalence before zero replacement. Zeros are now replaced only for the taxa that are tested, so rare taxa no longer put imputed values into every sample, and the CLR is taken over the same taxa. - Replace zeros with zCompositions::cmultRepl (GBM, the count-data method) through RCall, in place of the Julia multiplicative replacement, which is removed with its tests. z.warning = 1 and z.delete = FALSE stop cmultRepl from dropping rows or columns. A taxon seen in fewer than 2 samples is refused with a message naming the min_prevalence that excludes it; a missing zCompositions raises ZCompositionsUnavailable (503 from the route). - Test each taxon with Welch's t-test (t.test, var.equal = FALSE) in place of the pooled-variance lm; each group needs 2 samples. - Pin zCompositions 1.6.2 and its new dependencies (NADA, truncnorm, survival) in renv.lock; check it in CI alongside MASS. - Report the median as well as the maximum imputed share per sample. - Tests: parity with direct cmultRepl and Welch t.test calls (with a pooled-variance control), prevalence-before-replacement, and a sparse fixture with rare taxa that is refused unfiltered and runs once min_prevalence is set. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +- config/defaults/pipeline.yml | 16 +- frontend/src/api/types.ts | 7 +- frontend/src/components/DifferentialPanel.tsx | 5 +- frontend/src/components/PipelineStages.tsx | 4 +- renv.lock | 98 +++++++ src/MetaManifold.jl | 1 - src/analysis/differential.jl | 274 ++++++++++++------ src/analysis/zero_replacement.jl | 168 ----------- src/server/routes/analysis.jl | 1 + test/runtests.jl | 1 - test/unit/test_differential.jl | 198 ++++++++++--- test/unit/test_zero_replacement.jl | 78 ----- 13 files changed, 476 insertions(+), 379 deletions(-) delete mode 100644 src/analysis/zero_replacement.jl delete mode 100644 test/unit/test_zero_replacement.jl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 912daaba..2d3a0274 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,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 +230,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 ' diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml index 15c4f74d..49c83b6b 100644 --- a/config/defaults/pipeline.yml +++ b/config/defaults/pipeline.yml @@ -349,8 +349,9 @@ analysis: 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_lm: - # a linear model on centred log-ratios after multiplicative replacement of - # zeros; the effect is a difference in CLR units, not a fold change. + # 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). @@ -358,9 +359,12 @@ analysis: # Used by nb_glm only; clr_lm 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_lm it is applied before zero replacement, so on a sparse table it also + # keeps imputed values for rare taxa out of every sample; clr_lm refuses a + # taxon seen in fewer than 2 samples. min_prevalence: 0.0 - # clr_lm only: a zero is replaced by this fraction, between 0 and 1, of the - # taxon's smallest observed count, and the other counts in the sample are - # scaled down so its total is unchanged. A replaced zero is not a measurement. + # clr_lm 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 6c8e8d20..c4f1515e 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -389,7 +389,7 @@ export interface DifferentialRow { /** nb_glm only; null for clr_lm. */ log2_fold_change: number | null standard_error: number | null - /** z (nb_glm) or t (clr_lm). */ + /** z (nb_glm) or Welch's t (clr_lm). */ statistic: number | null pvalue: number | null /** Benjamini-Hochberg adjusted; null outside the tested family. */ @@ -429,8 +429,9 @@ export interface DifferentialResult { n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number /** clr_lm only. */ zero_replacement?: { - delta: number; zeros_replaced: number; n_taxa_in_composition: number - n_taxa_unobserved: number; max_imputed_fraction: number + 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[] diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx index 1eb999bd..9e5534c1 100644 --- a/frontend/src/components/DifferentialPanel.tsx +++ b/frontend/src/components/DifferentialPanel.tsx @@ -111,8 +111,9 @@ export function DifferentialPanel({ study, runs, option, aggregate }: { . 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 (delta ${z.delta}); replaced values hold at most ` + - `${(100 * z.max_imputed_fraction).toPrecision(2)}% of any sample, and are not measurements.` + 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.` })()}

diff --git a/frontend/src/components/PipelineStages.tsx b/frontend/src/components/PipelineStages.tsx index 0614432f..85ad30ad 100644 --- a/frontend/src/components/PipelineStages.tsx +++ b/frontend/src/components/PipelineStages.tsx @@ -150,10 +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.method': 'Model fitted to each taxon: nb_glm (negative-binomial GLM on read counts; effect is a log2 fold change) or clr_lm (linear model on centred log-ratios after multiplicative zero replacement; effect is a CLR difference, not a fold change).', + 'analysis.differential.method': 'Model fitted to each taxon: nb_glm (negative-binomial GLM on read counts; effect is a log2 fold change) or clr_lm (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_lm only: fraction, between 0 and 1, of a taxon\'s smallest observed count given to each of its zeros; the rest of the sample is scaled so its total is unchanged. Replaced zeros are not measurements.', + 'analysis.differential.replacement_delta': 'clr_lm 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.', 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/MetaManifold.jl b/src/MetaManifold.jl index 3cf0c8ad..e3c70714 100644 --- a/src/MetaManifold.jl +++ b/src/MetaManifold.jl @@ -29,7 +29,6 @@ include("pipeline/phylogeny.jl") # Analysis include("analysis/diversity.jl") include("analysis/analysis.jl") -include("analysis/zero_replacement.jl") include("analysis/differential.jl") # Web server diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl index 471fb47b..cf226409 100644 --- a/src/analysis/differential.jl +++ b/src/analysis/differential.jl @@ -7,9 +7,10 @@ # # - 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_lm: a Gaussian linear model per taxon (stats::lm) on centred log-ratios, -# after multiplicative replacement of the zeros (`ZeroReplacement`). The -# estimate is a difference in CLR units, not a fold change. +# - clr_lm: 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`. # Anything that is not a non-negative integer count is refused rather than @@ -20,11 +21,10 @@ module Differential using DataFrames, RCall using ..RRuntime: with_r_lock using ..Analysis: _palette_hex, R_WAIT_SECONDS -using ..ZeroReplacement: DEFAULT_DELTA, multiplicative_replacement - -export DifferentialConfig, ScalingRefusal, MASSUnavailable, OFFSET_METHODS, METHODS, - tss_factors, rle_factors, size_factors, bh_adjust, validate_counts, - clr_transform, fit_nb, fit_lm, differential_abundance, volcano_chart +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 # Methods accepted under `analysis.differential.method`. const METHODS = ("nb_glm", "clr_lm") @@ -38,6 +38,9 @@ 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="tss", min_prevalence=0.0; method="nb_glm", replacement_delta=0.65) @@ -45,11 +48,12 @@ const THETA_LOWER = 1e-8 Settings for one differential abundance analysis, read from `analysis.differential` in the pipeline config. `method` is `"nb_glm"` or `"clr_lm"`. `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 Gaussian -model of log-ratios, which are already free of library size. +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 -fraction of a taxon's detection limit given to a replaced zero by `clr_lm`. +`frac` passed to `zCompositions::cmultRepl` by `clr_lm`: an imputed proportion +above its taxon's smallest observed proportion is lowered to this fraction of it. """ struct DifferentialConfig method :: String @@ -99,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_lm; restore the R library from renv.lock (Rscript -e 'renv::restore()')") + """ _geomean(x) -> Float64 @@ -346,11 +362,107 @@ function clr_transform(x::AbstractMatrix{<:Real}) l .- sum(l; dims=2) ./ size(l, 2) end -# The per-taxon Gaussian fit loop, with the same `da_` naming and statuses as -# `_FIT_R` but no `boundary`: an lm has no dispersion parameter to run to a bound. -const _FIT_LM_R = raw""" +# 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)) +""" + +""" + 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 + size(out) == size(counts) || error( + "cmultRepl returned a $(size(out)) table for $(size(counts)) 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 + 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_term <- paste0("da_g", da_levels[2]) 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), @@ -364,32 +476,28 @@ for (da_j in seq_len(da_n)) { next } da_warns <- character(0) - da_co <- tryCatch( + da_tt <- tryCatch( withCallingHandlers( - summary(stats::lm(da_y ~ da_g))[["coefficients"]], + 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_co, "error")) { - da_result[da_j, "status"] <- "failed" - da_result[da_j, "note"] <- paste("lm stopped with an error:", conditionMessage(da_co)) - next - } - if (!(da_term %in% rownames(da_co))) { + if (inherits(da_tt, "error")) { da_result[da_j, "status"] <- "failed" - da_result[da_j, "note"] <- "the group coefficient is not estimable (aliased)" + da_result[da_j, "note"] <- paste("t.test stopped with an error:", conditionMessage(da_tt)) next } - da_row <- da_co[da_term, ] + 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 fit returned a non-finite or zero standard error, so there is no test (the residual variance is zero)", + 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")] <- unname(da_row[1:4]) + 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 = "; ")) } @@ -397,18 +505,19 @@ for (da_j in seq_len(da_n)) { """ """ - fit_lm(values, groups; levels) -> DataFrame + fit_welch(values, groups; levels) -> DataFrame -Fit `stats::lm(y ~ group)` to each column of the samples x taxa matrix -`values` (for `clr_lm`, centred log-ratios). `levels` is -`(reference, contrast)`, so the estimate is the mean of `contrast` minus the -mean of `reference`, and the test is the two-sample t-test with pooled -variance. Returns one row per taxon with `status` (`ok` or `failed`), `note`, -`estimate`, `se`, `statistic` (t) and `pvalue`; a failed taxon has `nothing` -for every statistic. Throws `RBusyError` when the R runtime stays busy. +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_lm`, +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_lm(values::AbstractMatrix{<:Real}, groups::AbstractVector{<:AbstractString}; - levels::NTuple{2,String}) +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 @@ -416,12 +525,12 @@ function fit_lm(values::AbstractMatrix{<:Real}, groups::AbstractVector{<:Abstrac RCall.globalEnv[:da_group] = String.(groups) RCall.globalEnv[:da_levels] = collect(levels) try - RCall.reval(_FIT_LM_R) + 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_term', 'da_n', 'da_result', 'da_j', 'da_y', 'da_warns', " * - "'da_co', 'da_row'))); invisible(gc())") + "'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), @@ -456,18 +565,18 @@ labelled `reference` in `groups`, by `config.method`. enter each negative-binomial model as `log(factor)`; the estimate is a natural-log fold change. -`clr_lm`, in this order: (1) taxa with no reads in any of the samples are set -aside, because a taxon never observed has no detection limit and is not part of -the composition; (2) the zeros of the remaining taxa are replaced -multiplicatively with `config.replacement_delta`; (3) each sample is -transformed to centred log-ratios over all of those taxa; (4) only then is -`min_prevalence` applied, choosing which taxa are tested. Filtering before the -transform would change every sample's geometric mean and so every other -taxon's value. The estimate is the difference in mean CLR, contrast minus -reference: a difference in centred-log-ratio units, not a fold change, and it -is relative to the geometric mean of the observed taxa. Replaced zeros are not -measurements, so a taxon with many zeros carries an estimate that depends on -`replacement_delta`. +`clr_lm`, 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 @@ -477,8 +586,8 @@ effect size and its label. Throws `ArgumentError` for unusable input (including a zero replacement that cannot be made), `ScalingRefusal` when the offset cannot be computed, -`MASSUnavailable` when MASS is missing, and an `ErrorException` when no taxon -could be fitted at all. +`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}, @@ -536,31 +645,30 @@ function differential_abundance(counts::AbstractMatrix{<:Real}, config_echo = Dict{String,Any}("method" => config.method, "offset" => config.offset, "min_prevalence" => config.min_prevalence) else - observed = findall(>(0), present) - for j in setdiff(axes(counts, 2), observed) - rows[j]["note"] = "no reads in any sample of either group, so it has no " * - "detection limit and is not part of the composition" + (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 - length(observed) >= 2 || throw(ArgumentError( - "the centred log-ratio needs at least 2 taxa with reads; there are $(length(observed))")) - replaced = multiplicative_replacement(counts[:, observed]; - delta=config.replacement_delta, - samples, taxa=taxa[observed]) - clr = clr_transform(replaced.counts) - tested = findall(k -> prevalence[observed[k]] >= config.min_prevalence, eachindex(observed)) - fits = isempty(tested) ? nothing : fit_lm(clr[:, tested], groups; levels) - if !isnothing(fits) - for (k, c) in enumerate(tested) - f = fits[k, :] - merge!(rows[observed[c]], Dict{String,Any}( - "status" => f.status, "note" => f.note, "estimate" => f.estimate, - "standard_error" => f.se, "statistic" => f.statistic, - "pvalue" => f.pvalue)) - 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, :] + merge!(rows[j], Dict{String,Any}( + "status" => f.status, "note" => f.note, "estimate" => f.estimate, + "standard_error" => f.se, "statistic" => f.statistic, + "pvalue" => f.pvalue)) end - method_text = "Gaussian linear model per taxon (stats::lm) on centred log-ratios, after " * - "multiplicative replacement of zeros (delta = $(config.replacement_delta) of " * - "each taxon's smallest observed count); t test of the group coefficient; " * + 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") @@ -568,11 +676,15 @@ function differential_abundance(counts::AbstractMatrix{<:Real}, 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}( - "delta" => replaced.delta, "zeros_replaced" => replaced.zeros_replaced, - "n_taxa_in_composition" => length(observed), - "n_taxa_unobserved" => length(taxa) - length(observed), - "max_imputed_fraction" => maximum(replaced.imputed_mass)) + "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) diff --git a/src/analysis/zero_replacement.jl b/src/analysis/zero_replacement.jl deleted file mode 100644 index 3ca7f7f3..00000000 --- a/src/analysis/zero_replacement.jl +++ /dev/null @@ -1,168 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) - -## Zero replacement for compositional transforms -# Multiplicative replacement (Martín-Fernández, Barceló-Vidal & Pawlowsky-Glahn -# 2003, Mathematical Geology 35(3):253-278), the operator of -# `zCompositions::multRepl` with `frac = delta` and a per-taxon detection limit. -# A log-ratio transform is undefined at zero, so a zero has to be given some -# positive value first; this operator does that while keeping each sample's -# total and the ratios among its observed taxa exactly as they were, which a -# pseudocount does not. -# -# Matrices here are samples x taxa, as everywhere else in `Analysis` (the -# reference and the original paper write compositions as rows too). -# -# A replaced value is not a measurement. The replacement is biased, and no rule -# that sees only the observed data can avoid that; the result records it. -module ZeroReplacement - -export DEFAULT_DELTA, ReplacementOutcome, multiplicative_replacement, - replacement_invariants_hold - -""" - DEFAULT_DELTA - -The fraction of a taxon's detection limit that a replaced zero receives by -default, 0.65, as in `zCompositions::multRepl` and the original paper. -""" -const DEFAULT_DELTA = 0.65 - -# Relative tolerance of the runtime invariant checks. -const TOLERANCE = 1e-9 - -""" - ReplacementOutcome - -The result of `multiplicative_replacement`: the replaced samples x taxa table, -the `delta` used, the per-taxon `detection_limits`, the number of -`zeros_replaced`, and `imputed_mass`, the fraction of each sample's total now -held by replaced values. -""" -struct ReplacementOutcome - counts :: Matrix{Float64} - delta :: Float64 - detection_limits :: Vector{Float64} - zeros_replaced :: Int - imputed_mass :: Vector{Float64} -end - -""" - _invariant_violation(original, replaced) -> Union{String,Nothing} - -The first way in which `replaced` fails to be a multiplicative replacement of -`original`, as a sentence, or `nothing` when it is one: same shape, every -sample total preserved, every observed taxon in a sample scaled by the same -factor, and no zero left. -""" -function _invariant_violation(original::AbstractMatrix{<:Real}, replaced::AbstractMatrix{<:Real}) - size(original) == size(replaced) || - return "the replaced table is $(size(replaced)) for an input of $(size(original))" - for i in axes(original, 1) - before = sum(view(original, i, :)) - before > 0 || return "sample $i has no reads" - after = sum(view(replaced, i, :)) - abs(after - before) <= TOLERANCE * before || - return "sample $i changed total from $before to $after" - scale = nothing - for j in axes(original, 2) - if original[i, j] > 0 - r = replaced[i, j] / original[i, j] - scale = something(scale, r) - abs(r - scale) <= TOLERANCE * max(1.0, abs(scale)) || - return "sample $i scaled taxon $j by $r and an earlier taxon by $scale" - elseif !(replaced[i, j] > 0) - return "sample $i still holds $(replaced[i, j]) for taxon $j" - end - end - end - nothing -end - -""" - replacement_invariants_hold(original, replaced) -> Bool - -Whether `replaced` keeps every sample total of `original`, scales the observed -taxa of each sample by one common factor, and leaves no zero. -""" -replacement_invariants_hold(original::AbstractMatrix{<:Real}, replaced::AbstractMatrix{<:Real}) = - isnothing(_invariant_violation(original, replaced)) - -""" - multiplicative_replacement(counts; delta=DEFAULT_DELTA, samples, taxa) -> ReplacementOutcome - -Replace the zeros of the samples x taxa table `counts`. With `DL_j` the -detection limit of taxon `j` (its smallest observed value anywhere in the -table), `S_i` the total of sample `i`, `Z_i` its zero taxa and -`Δ_i = Σ_{j ∈ Z_i} delta·DL_j / S_i`: - - x̃_ij = delta · DL_j for j ∈ Z_i - x̃_ij = (1 - Δ_i) · x_ij otherwise - -Each sample total and the ratios among each sample's observed taxa are kept -exactly; every replaced value is positive and below its taxon's detection -limit. On a table of proportions this equals `zCompositions::multRepl(X, -label = 0, dl, frac = delta)`; on counts with unequal totals, dividing each row -by its total gives the reference's output. - -`samples` and `taxa` name rows and columns in error messages. Throws -`ArgumentError` when `delta` is not in (0, 1), when a value is negative or not -finite, when a taxon is never observed (its detection limit cannot be -estimated), when a sample has no reads, or when a sample's replaced values -would take its whole total (`Δ_i ≥ 1`); that message gives the largest -`delta` the sample admits. -""" -function multiplicative_replacement(counts::AbstractMatrix{<:Real}; - delta::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)]) - δ = Float64(delta) - 0 < δ < 1 || throw(ArgumentError( - "the replacement delta must be in (0, 1), not $delta: it is the fraction of a " * - "taxon's detection limit that a replaced zero receives (0.65 is the published default)")) - size(counts) == (length(samples), length(taxa)) || throw(ArgumentError( - "the table is $(size(counts)) but there are $(length(samples)) samples and $(length(taxa)) taxa")) - X = Matrix{Float64}(counts) - for j in axes(X, 2), i in axes(X, 1) - (isfinite(X[i, j]) && X[i, j] >= 0) || throw(ArgumentError( - "the value for taxon '$(taxa[j])' in sample '$(samples[i])' is $(X[i, j]); " * - "zero replacement needs non-negative finite values")) - end - - dl = Vector{Float64}(undef, size(X, 2)) - for j in axes(X, 2) - observed = filter(>(0), view(X, :, j)) - isempty(observed) && throw(ArgumentError( - "taxon '$(taxa[j])' has no reads in any sample, so its detection limit " * - "cannot be estimated from this table")) - dl[j] = minimum(observed) - end - - out = copy(X) - mass = zeros(Float64, size(X, 1)) - n_zero = 0 - for i in axes(X, 1) - total = sum(view(X, i, :)) - total > 0 || throw(ArgumentError("sample '$(samples[i])' has no reads")) - zeros_i = findall(==(0), view(X, i, :)) - isempty(zeros_i) && continue - limit = sum(dl[zeros_i]) - Δ = δ * limit / total - Δ < 1 || throw(ArgumentError( - "sample '$(samples[i])' has $(length(zeros_i)) zeros whose detection limits sum " * - "to $limit against a total of $total, so delta = $δ would give replaced values " * - "$(round(100Δ; digits=1))% of the sample. The largest delta this sample admits " * - "is below $(total / limit); lower delta or raise min_prevalence")) - for j in axes(X, 2) - out[i, j] = X[i, j] == 0 ? δ * dl[j] : (1 - Δ) * X[i, j] - end - mass[i] = Δ - n_zero += length(zeros_i) - end - - problem = _invariant_violation(X, out) - isnothing(problem) || error("multiplicative replacement broke its own invariant: $problem") - ReplacementOutcome(out, δ, dl, n_zero, mass) -end - -end # module ZeroReplacement diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl index fdd529b7..0dd506a3 100644 --- a/src/server/routes/analysis.jl +++ b/src/server/routes/analysis.jl @@ -1281,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/test/runtests.jl b/test/runtests.jl index 82c97629..4a8f47fa 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -32,7 +32,6 @@ const UNIT_FILES = [ "test_publication_tables.jl", "test_heatmap.jl", "test_trees.jl", - "test_zero_replacement.jl", "test_report_funnel.jl", "test_phylogeny.jl", "test_composition.jl", diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index b2eee27f..96617457 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_lm 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_lm tests are SKIPPED" # Rows per run: Up is split over two ASVs so the route must pool them by genus. const _DA_ROWS = Dict( @@ -327,8 +330,8 @@ end clr_config = DifferentialConfig(; method="clr_lm") - @testset "CLR linear model: estimate is the difference of group mean CLRs" begin - if !HAVE_R + @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; @@ -337,8 +340,9 @@ end @test isempty(res["size_factors"]) @test res["config"]["method"] == "clr_lm" && !haskey(res["config"], "offset") @test res["diagnostics"]["zero_replacement"]["zeros_replaced"] == 0 - # Known answer, independent of R: with a two-level factor, the lm - # coefficient is mean(B) - mean(A) of each taxon's CLR. + @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) @@ -351,59 +355,181 @@ end 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 stats::lm call" begin - y = z[:, 1]; g = effect_groups - direct = rcopy(R""" - fit <- summary(stats::lm($y ~ factor($g, levels = c("A", "B")))) - unname(fit$coefficients[2, c(1, 2, 4)]) - """) - @test byt["t_up"]["estimate"] ≈ direct[1] rtol=1e-10 - @test byt["t_up"]["standard_error"] ≈ direct[2] rtol=1e-10 - @test byt["t_up"]["pvalue"] ≈ direct[3] rtol=1e-8 + + @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 "CLR: zeros, unobserved taxa and prevalence" begin - if !HAVE_R + @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: min_prevalence is applied before zero replacement" begin + if !HAVE_ZCOMP @test_skip false else - # Five samples x four taxa: 'ghost' has no reads anywhere, 'rare' - # one read in one sample, 'p' and 'q' are always present. - x = [10 20 0 0; 12 18 0 0; 11 19 3 0; 30 8 0 0; 28 9 0 0; 33 7 0 0] + # 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", "rare", "ghost"] + 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_lm")) 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"]) - # 'rare' is in the composition (it shapes the CLR of every sample) - # but is below the prevalence threshold, so it is not tested. @test byt["rare"]["status"] == "filtered" @test occursin("min_prevalence", byt["rare"]["note"]) - @test byt["p"]["status"] == "ok" && byt["q"]["status"] == "ok" + @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 - @test zr["zeros_replaced"] == 5 + # 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 composition the fit saw includes 'rare', replaced: the estimate - # for 'p' is computed on the 3-taxon CLR, not on p and q alone. - obs = Float64.(x[:, 1:3]) - z = clr_transform(MetaManifold.ZeroReplacement.multiplicative_replacement(obs).counts) + # The fit saw the 3-taxon composition: 'rare' contributed nothing. + z = clr_transform(replace_zeros(x[:, 1:3]).proportions) a = g .== "A" - want = sum(z[.!a, 1]) / 3 - sum(z[a, 1]) / 3 - @test byt["p"]["estimate"] ≈ want rtol=1e-8 + @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, 4]], s, ["p", "ghost"], g; + @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_lm")) + 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_R + if !HAVE_ZCOMP @test_skip false else # p and q in the same 1:1 ratio everywhere: both CLRs are constant @@ -469,14 +595,16 @@ end r = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")], "table" => "merged", "rank" => "Genus")) body = JSON3.read(String(r.body)) - if !HAVE_R - @test_skip r.status == 200 + 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_lm" && 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) diff --git a/test/unit/test_zero_replacement.jl b/test/unit/test_zero_replacement.jl deleted file mode 100644 index 4215312b..00000000 --- a/test/unit/test_zero_replacement.jl +++ /dev/null @@ -1,78 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-only -# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -# -# Multiplicative zero replacement: a hand-computed known answer, the -# invariants (totals kept, observed ratios kept, no zero left), every refusal, -# and a control showing the invariant check can fail. Pure Julia; no R needed. - -using MetaManifold -using MetaManifold.ZeroReplacement -using Test - -@testset "Zero replacement" begin - - @testset "Known answer, computed by hand" begin - # Detection limits: taxon 1 -> 4, taxon 2 -> 2, taxon 3 -> 6. With - # delta = 0.5, sample 1 replaces one zero worth 0.5*4 = 2 of a total of - # 10 (Δ = 0.2) and sample 2 one worth 0.5*2 = 1 (Δ = 0.1). - x = [0 2 8; 4 0 6] - out = multiplicative_replacement(x; delta=0.5) - @test out.counts ≈ [2.0 1.6 6.4; 3.6 1.0 5.4] - @test out.detection_limits == [4.0, 2.0, 6.0] - @test out.zeros_replaced == 2 - @test out.imputed_mass ≈ [0.2, 0.1] - @test out.delta == 0.5 - end - - @testset "Invariants on a larger table" begin - x = [0 3 0 12 5; 7 0 1 0 9; 2 2 2 2 2; 0 0 4 30 1] - out = multiplicative_replacement(x) - @test replacement_invariants_hold(x, out.counts) - @test vec(sum(out.counts; dims=2)) ≈ vec(sum(x; dims=2)) - @test all(>(0), out.counts) - # Every replaced value is below its taxon's detection limit. - for i in axes(x, 1), j in axes(x, 2) - x[i, j] == 0 && @test out.counts[i, j] < out.detection_limits[j] - end - # A sample without zeros is returned unchanged. - @test out.counts[3, :] == Float64.(x[3, :]) - @test out.imputed_mass[3] == 0.0 - @test out.zeros_replaced == 6 - end - - @testset "The invariant check can fail (control)" begin - x = [0 2 8; 4 0 6] - good = multiplicative_replacement(x; delta=0.5).counts - bad_total = copy(good); bad_total[1, 1] += 1 - @test !replacement_invariants_hold(x, bad_total) - bad_ratio = copy(good); bad_ratio[1, 2] += 0.1; bad_ratio[1, 3] -= 0.1 - @test !replacement_invariants_hold(x, bad_ratio) - @test !replacement_invariants_hold(x, Float64.(x)) - @test !replacement_invariants_hold(x, good[:, 1:2]) - end - - @testset "Refusals" begin - x = [0 2 8; 4 0 6] - for d in (0, 1, -0.5, 1.5, NaN) - @test_throws ArgumentError multiplicative_replacement(x; delta=d) - end - @test_throws ArgumentError multiplicative_replacement([-1 2; 3 4]) - @test_throws ArgumentError multiplicative_replacement([Inf 2; 3 4]) - @test_throws ArgumentError multiplicative_replacement([0 2; 0 4]) - @test_throws ArgumentError multiplicative_replacement([0 0; 3 4]) - @test_throws ArgumentError multiplicative_replacement(x; samples=["a"], taxa=["p", "q", "r"]) - - msg = try - multiplicative_replacement([0 0 1; 50 50 1]; samples=["s1", "s2"], taxa=["p", "q", "r"]) - "" - catch e - e.msg - end - # Sample s1: limits 50 + 50 against a total of 1, so delta must stay below 0.01. - @test occursin("'s1'", msg) - @test occursin("below 0.01", msg) - - msg = try multiplicative_replacement([0 2; 0 4]; taxa=["ghost", "real"]); "" catch e; e.msg end - @test occursin("'ghost'", msg) - end -end From c548451664f2ae1dfff213e2c0da696fdc48f61f Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:59:36 +0100 Subject: [PATCH 03/32] Rename the clr_lm method to clr_welch The method now tests each taxon with Welch's t-test rather than a linear model, so the config value, UI options and labels say so. clr_lm was never on main, so no alias is kept; a test checks it is refused. Co-Authored-By: Claude Opus 5.5 --- config/defaults/pipeline.yml | 10 +++---- frontend/src/api/types.ts | 14 +++++----- frontend/src/components/PipelineStages.tsx | 6 ++-- .../src/components/differentialTable.test.ts | 8 +++--- frontend/src/components/differentialTable.ts | 6 ++-- src/analysis/differential.jl | 14 +++++----- test/unit/test_differential.jl | 28 ++++++++++--------- 7 files changed, 44 insertions(+), 42 deletions(-) diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml index 49c83b6b..4e4db88f 100644 --- a/config/defaults/pipeline.yml +++ b/config/defaults/pipeline.yml @@ -348,7 +348,7 @@ analysis: # 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_lm: + # 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. @@ -356,15 +356,15 @@ analysis: # 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_lm ignores it, since log-ratios carry no library size. + # 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. For - # clr_lm it is applied before zero replacement, so on a sparse table it also - # keeps imputed values for rare taxa out of every sample; clr_lm refuses a + # 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_lm only: cmultRepl's frac, between 0 and 1. An imputed proportion above + # 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 c4f1515e..f148fda4 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -382,14 +382,14 @@ export interface DifferentialRow { status: DifferentialStatus note: string /** - * nb_glm: natural-log fold change, contrast over reference. clr_lm: mean + * 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_lm. */ + /** nb_glm only; null for clr_welch. */ log2_fold_change: number | null standard_error: number | null - /** z (nb_glm) or Welch's t (clr_lm). */ + /** z (nb_glm) or Welch's t (clr_welch). */ statistic: number | null pvalue: number | null /** Benjamini-Hochberg adjusted; null outside the tested family. */ @@ -399,7 +399,7 @@ export interface DifferentialRow { prevalence: number } -export type DifferentialMethod = 'nb_glm' | 'clr_lm' +export type DifferentialMethod = 'nb_glm' | 'clr_welch' /** Which row field holds the effect size, and what to call it. */ export interface DifferentialEffect { @@ -415,19 +415,19 @@ export interface DifferentialResult { groups: { reference: string; contrast: string } n_samples: Record effect: DifferentialEffect - /** Empty for clr_lm, which takes no library-size offset. */ + /** Empty for clr_welch, which takes no library-size offset. */ size_factors: { sample: string; group: string; factor: number }[] config: { method: DifferentialMethod min_prevalence: number /** nb_glm only. */ offset?: 'tss' | 'rle' - /** clr_lm only. */ + /** clr_welch only. */ replacement_delta?: number } diagnostics: { n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number - /** clr_lm only. */ + /** 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 diff --git a/frontend/src/components/PipelineStages.tsx b/frontend/src/components/PipelineStages.tsx index 85ad30ad..85129267 100644 --- a/frontend/src/components/PipelineStages.tsx +++ b/frontend/src/components/PipelineStages.tsx @@ -150,10 +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.method': 'Model fitted to each taxon: nb_glm (negative-binomial GLM on read counts; effect is a log2 fold change) or clr_lm (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.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_lm 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.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.', @@ -250,7 +250,7 @@ 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_lm'] }, + '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 }, diff --git a/frontend/src/components/differentialTable.test.ts b/frontend/src/components/differentialTable.test.ts index 092e6a98..f2143e07 100644 --- a/frontend/src/components/differentialTable.test.ts +++ b/frontend/src/components/differentialTable.test.ts @@ -54,12 +54,12 @@ describe('formatStat', () => { }) }) -describe('clr_lm results', () => { +describe('clr_welch results', () => { const clr: DifferentialResult = { ...result, - method: 'CLR LM', + method: 'CLR Welch', effect: { key: 'estimate', label: 'CLR difference' }, - config: { method: 'clr_lm', min_prevalence: 0, replacement_delta: 0.65 }, + 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 }], } @@ -69,7 +69,7 @@ describe('clr_lm results', () => { }) test('the settings line names the delta and says the estimate is not a fold change', () => { - expect(configLine(clr)).toBe('method clr_lm, replacement_delta 0.65, min_prevalence 0; estimate is a CLR difference, 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 be40d987..90a547a8 100644 --- a/frontend/src/components/differentialTable.ts +++ b/frontend/src/components/differentialTable.ts @@ -41,13 +41,13 @@ export function formatStat(value: number | null): string { /** * 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_lm, so each line + * 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_lm' - ? `method clr_lm, replacement_delta ${c.replacement_delta}, min_prevalence ${c.min_prevalence}; estimate is a CLR difference, not a fold change` + 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}` } diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl index cf226409..851cb3ea 100644 --- a/src/analysis/differential.jl +++ b/src/analysis/differential.jl @@ -7,7 +7,7 @@ # # - 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_lm: Welch's two-sample t-test per taxon (stats::t.test) on centred +# - 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. @@ -27,7 +27,7 @@ export DifferentialConfig, ScalingRefusal, MASSUnavailable, ZCompositionsUnavail differential_abundance, volcano_chart # Methods accepted under `analysis.differential.method`. -const METHODS = ("nb_glm", "clr_lm") +const METHODS = ("nb_glm", "clr_welch") # Offset (size factor) methods accepted under `analysis.differential.offset`. const OFFSET_METHODS = ("tss", "rle") @@ -47,12 +47,12 @@ const DEFAULT_DELTA = 0.65 Settings for one differential abundance analysis, read from `analysis.differential` in the pipeline config. `method` is `"nb_glm"` or -`"clr_lm"`. `offset` names the size factor method (`"tss"` or `"rle"`) and is +`"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_lm`: an imputed proportion +`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 @@ -113,7 +113,7 @@ 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_lm; restore the R library from renv.lock (Rscript -e 'renv::restore()')") + "clr_welch; restore the R library from renv.lock (Rscript -e 'renv::restore()')") """ _geomean(x) -> Float64 @@ -508,7 +508,7 @@ for (da_j in seq_len(da_n)) { 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_lm`, +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 @@ -565,7 +565,7 @@ labelled `reference` in `groups`, by `config.method`. enter each negative-binomial model as `log(factor)`; the estimate is a natural-log fold change. -`clr_lm`, in this order: (1) `min_prevalence` chooses the taxa, and taxa with +`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 diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index 96617457..80ce146c 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -3,7 +3,7 @@ # # Differential abundance: Benjamini-Hochberg, TSS/RLE size factors, input # validation, the negative-binomial fit checked against a direct MASS::glm.nb -# call, and clr_lm checked against direct zCompositions::cmultRepl and Welch +# 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 @@ -30,7 +30,7 @@ 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_lm tests are SKIPPED" +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( @@ -168,10 +168,12 @@ end @test_throws ArgumentError DifferentialConfig("tss", NaN) @test DifferentialConfig().method == "nb_glm" @test DifferentialConfig().replacement_delta == 0.65 - @test DifferentialConfig(; method="clr_lm", replacement_delta=0.3).replacement_delta == 0.3 + @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_lm", replacement_delta=d) + @test_throws ArgumentError DifferentialConfig(; method="clr_welch", replacement_delta=d) end end @@ -314,7 +316,7 @@ 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_lm")) + @test isnothing(SV._config_value_error("analysis.differential.method", "clr_welch")) @test !isnothing(SV._config_value_error("analysis.differential.method", "ancombc")) end @@ -328,7 +330,7 @@ end @test_throws ArgumentError clr_transform([NaN 1 2]) end - clr_config = DifferentialConfig(; method="clr_lm") + clr_config = DifferentialConfig(; method="clr_welch") @testset "CLR + Welch: estimate is the difference of group mean CLRs" begin if !HAVE_ZCOMP @@ -338,7 +340,7 @@ end 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_lm" && !haskey(res["config"], "offset") + @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) @@ -432,7 +434,7 @@ end 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_lm")) + 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"]) @@ -504,7 +506,7 @@ end # 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_lm")) + 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 @@ -587,11 +589,11 @@ end end end - @testset "Route: clr_lm selected by the study config" begin + @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_lm\n replacement_delta: 0.5\n") + "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)) @@ -601,7 +603,7 @@ end else @test r.status == 200 @test body.effect.key == "estimate" && body.effect.label == "CLR difference" - @test body.config.method == "clr_lm" && body.config.replacement_delta == 0.5 + @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 @@ -612,7 +614,7 @@ end end write(joinpath(root, "data", "studyD", "pipeline.yml"), - "analysis:\n differential:\n method: clr_lm\n replacement_delta: 1.5\n") + "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 From 657120cf1b7a541eeeece1bcff77b32c36a8c11a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:04:53 +0100 Subject: [PATCH 04/32] Test clr_welch when zCompositions is missing CI installs zCompositions, so the ZCompositionsUnavailable path and the route's 503 were never run there. The new testset unloads the package and drops its library from .libPaths() for its duration, checks the error and its restore hint, checks the route answers 503 r_unavailable, and restores the library path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- test/unit/test_differential.jl | 50 ++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index 80ce146c..b0099240 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -621,4 +621,54 @@ end @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, so this hides it for the + # testset: unloaded, and its library dropped from .libPaths() until restored. + @testset "clr_welch without zCompositions is a 503" begin + if !HAVE_ZCOMP + @test_skip false # the clr_welch route test above covers this case + else + saved_libs = rcopy(Vector{String}, reval(".libPaths()")) + hidden = rcopy(Bool, reval("""local({ + lib <- dirname(find.package("zCompositions")) + try(unloadNamespace("zCompositions"), silent = TRUE) + .libPaths(setdiff(.libPaths(), lib)) + !requireNamespace("zCompositions", quietly = TRUE) + })""")) + try + if !hidden + @warn "zCompositions is in R's base library and cannot be hidden; SKIPPED" + @test_skip hidden + else + 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 + end + finally + RCall.globalEnv[:da_saved_libs] = saved_libs + reval(".libPaths(da_saved_libs); rm(da_saved_libs)") + end + @test rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)")) + end + end end From b86d59fb3d0ec5f5ee889e43ae7118a6843ded2c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:04:08 +0100 Subject: [PATCH 05/32] Run the zCompositions-absent test in CI instead of skipping it CI installs zCompositions into R's base library, which .libPaths() cannot drop, so the testset took its "cannot be hidden; SKIPPED" branch on every CI run and the absence path (ZCompositionsUnavailable, its showerror, and the route's 503 r_unavailable) was never executed there. The testset now binds a requireNamespace in R's global environment that reports zCompositions missing and defers to base::requireNamespace for every other package. The production probe is evaluated in globalenv, so it sees the package as absent wherever the library lives. The binding is removed in a finally block and the test checks it is gone. The skip branch for an unhideable library is no longer needed. Checked: deleting the route's ZCompositionsUnavailable => 503 line makes this testset fail (3 failed, 1 errored); restored, test_differential.jl passes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- test/unit/test_differential.jl | 74 ++++++++++++++++------------------ 1 file changed, 35 insertions(+), 39 deletions(-) diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index b0099240..2db4bfc2 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -623,52 +623,48 @@ end end ## Without zCompositions, clr_welch refuses with ZCompositionsUnavailable and - # the route answers 503. CI installs the package, so this hides it for the - # testset: unloaded, and its library dropped from .libPaths() until restored. + # 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_ZCOMP + if !HAVE_R @test_skip false # the clr_welch route test above covers this case else - saved_libs = rcopy(Vector{String}, reval(".libPaths()")) - hidden = rcopy(Bool, reval("""local({ - lib <- dirname(find.package("zCompositions")) - try(unloadNamespace("zCompositions"), silent = TRUE) - .libPaths(setdiff(.libPaths(), lib)) - !requireNamespace("zCompositions", quietly = TRUE) - })""")) + reval("""requireNamespace <- function(package, ...) { + if (identical(package, "zCompositions")) FALSE + else base::requireNamespace(package, ...) + }""") try - if !hidden - @warn "zCompositions is in R's base library and cannot be hidden; SKIPPED" - @test_skip hidden - else - 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 + @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 - RCall.globalEnv[:da_saved_libs] = saved_libs - reval(".libPaths(da_saved_libs); rm(da_saved_libs)") + reval("rm(requireNamespace)") end - @test rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)")) + @test !rcopy(Bool, reval("exists('requireNamespace', envir = globalenv(), inherits = FALSE)")) + @test rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)")) == HAVE_ZCOMP end end end From 7799de853a8161e0b8abe28c6cd8e1678a540b9a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 7 Oct 2026 07:53:18 +0100 Subject: [PATCH 06/32] Test the cmultRepl output check directly The "not a valid composition" refusal after cmultRepl was the PR's one uncovered line: cmultRepl never returns such output on an honest input, so no end-to-end test reaches it. Move the check into _check_compositions, which replace_zeros calls unchanged, and test it in pure Julia with malformed output: a replaced zero left at 0, a NaN, a row that does not sum to 1, a failure in the second sample, and a dropped column. Weakening the guard fails 3 of the 6 assertions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- src/analysis/differential.jl | 36 +++++++++++++++++++++++----------- test/unit/test_differential.jl | 18 +++++++++++++++++ 2 files changed, 43 insertions(+), 11 deletions(-) diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl index 851cb3ea..4801d41d 100644 --- a/src/analysis/differential.jl +++ b/src/analysis/differential.jl @@ -381,6 +381,30 @@ da_out <- tryCatch( 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 @@ -444,17 +468,7 @@ function replace_zeros(counts::AbstractMatrix{<:Real}; "invisible(gc())") end end - size(out) == size(counts) || error( - "cmultRepl returned a $(size(out)) table for $(size(counts)) 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 + _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 diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index 2db4bfc2..099c4778 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -423,6 +423,24 @@ end 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 From 3f9a5bc062250b87dde184196dab4515e0c96ab4 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:51:53 +0100 Subject: [PATCH 07/32] proofs(agda): Benjamini-Hochberg adjustment over exact rationals bhAdjust mirrors bh_adjust step for step (stable sort as lexicographic (p, index) order, backwards running minimum, clamp, scatter-back). Proved under --safe --without-K with no postulates: length, R-BH-1, R-BH-2a, R-BH-2b, R-BH-2c (ties included) and R-BH-2d (envelope). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JPmNukJT5PUzQ9d74mhHhj --- proofs/agda/MetaManifold/All.agda | 13 + .../agda/MetaManifold/BenjaminiHochberg.agda | 565 ++++++++++++++++++ proofs/agda/MetaManifold/Prelude.agda | 83 +++ proofs/agda/metamanifold.agda-lib | 4 + 4 files changed, 665 insertions(+) create mode 100644 proofs/agda/MetaManifold/All.agda create mode 100644 proofs/agda/MetaManifold/BenjaminiHochberg.agda create mode 100644 proofs/agda/MetaManifold/Prelude.agda create mode 100644 proofs/agda/metamanifold.agda-lib diff --git a/proofs/agda/MetaManifold/All.agda b/proofs/agda/MetaManifold/All.agda new file mode 100644 index 00000000..46935fd8 --- /dev/null +++ b/proofs/agda/MetaManifold/All.agda @@ -0,0 +1,13 @@ +-- 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 diff --git a/proofs/agda/MetaManifold/BenjaminiHochberg.agda b/proofs/agda/MetaManifold/BenjaminiHochberg.agda new file mode 100644 index 00000000..ab9ddd10 --- /dev/null +++ b/proofs/agda/MetaManifold/BenjaminiHochberg.agda @@ -0,0 +1,565 @@ +-- 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 mirrors `bh_adjust` in `src/analysis/estimation.jl` step for +-- step: +-- +-- Julia here +-- ───────────────────────────────────────── ────────────────────────────── +-- n = length(pvalues) m = length ps +-- order = sortperm(pvalues) (stable) order = sort (indexFrom 0 ps), +-- lexicographic on (p, index), +-- which is what a stable sort +-- by p produces +-- sorted = pvalues[order] sortedP = map proj₁ order +-- running = Inf `minInf`: a value with nothing +-- for i in n:-1:1 after it is its own running +-- running = min(running, sorted[i]*n/i) minimum (min(Inf, x) = x) +-- adjusted[i] = min(running, 1) stepUp = map (_⊓ 1ℚ) ∘ running +-- for (rank, idx) in enumerate(order) restore: position idx of the +-- out[idx] = adjusted[rank] output reads the adjusted +-- value whose key has index idx +-- +-- Julia's ranks are one-based (`i`); here they are zero-based (`j = i - 1`), +-- so Julia's `n / i` 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 docs/formal/agda-bh-scaling-proofs.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 `sorted[i] * n / i` at zero-based rank j. +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))) + +-- Julia's `running = Inf; running = min(running, x)`: the first value folded +-- in (the largest rank) is its own running minimum. +minInf : ℚ → List ℚ → ℚ +minInf x [] = x +minInf x (r ∷ _) = r ⊓ x + +-- The running minima of Julia's backwards loop, 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 + +-- `adjusted[i] = 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 + +-- Julia's `sortperm` is stable, i.e. it sorts by p and breaks ties by index: +-- the lexicographic order on (p, index). +keyOrder : DecTotalOrder _ _ _ +keyOrder = Lex.×-decTotalOrder ℚₚ.≤-decTotalOrder ℕₚ.≤-decTotalOrder + +open import Data.List.Sort.MergeSort keyOrder using (sort; sort-↭; sort-↗) + +-- `out[idx] = adjusted[rank]` read from the other side: the value at output +-- position i is the adjusted value of the (first, and in fact only) 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 Date: Fri, 2 Oct 2026 04:54:22 +0100 Subject: [PATCH 08/32] proofs(agda): TSS proportions and RLE median positivity over exact rationals Proportions sum to 1 and lie in [0,1]; a non-negative column with a positive count has a positive total; centring by any positive constant keeps factors positive and ordered; the median of a non-empty list of positive ratios is positive. Geometric means, exp and log are not rational and are abstracted as an arbitrary positive divisor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JPmNukJT5PUzQ9d74mhHhj --- proofs/agda/MetaManifold/All.agda | 1 + proofs/agda/MetaManifold/Scaling.agda | 233 ++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 proofs/agda/MetaManifold/Scaling.agda diff --git a/proofs/agda/MetaManifold/All.agda b/proofs/agda/MetaManifold/All.agda index 46935fd8..7cedd39a 100644 --- a/proofs/agda/MetaManifold/All.agda +++ b/proofs/agda/MetaManifold/All.agda @@ -11,3 +11,4 @@ module MetaManifold.All where import MetaManifold.Prelude import MetaManifold.BenjaminiHochberg +import MetaManifold.Scaling diff --git a/proofs/agda/MetaManifold/Scaling.agda b/proofs/agda/MetaManifold/Scaling.agda new file mode 100644 index 00000000..c05c9a97 --- /dev/null +++ b/proofs/agda/MetaManifold/Scaling.agda @@ -0,0 +1,233 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +-- +-- Library-size scaling (TSS) and median-of-ratios (RLE) over exact rationals: +-- the parts of `src/analysis/scaling.jl` 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 every factor vector by +-- its geometric mean and turns factors into offsets with `log`; RLE divides +-- each count by the feature's geometric mean. None of these is a rational +-- function, so none can be computed over ℚ. Wherever Julia divides by a +-- geometric mean, the theorems below take the divisor as an ARBITRARY +-- positive rational `g`. Every result therefore holds for the real +-- geometric mean as soon as it is positive (it is: a geometric mean of +-- positive numbers is positive), but that last step is a fact about the +-- reals, not a theorem here. +-- * Floating point. Julia computes in Float64; these are exact statements. +-- `_require_positive` can fire in floating point (underflow to 0.0) where +-- the exact quantity is positive; it is not modelled. +-- +-- Theorems (names as in docs/formal/agda-bh-scaling-proofs.md): +-- +-- total-positive a column of non-negative counts with at least one +-- positive count has a positive total (so TSS refuses +-- exactly the all-zero columns) +-- proportions-sum-to-1 the relative-abundance transform sums 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 of a non-empty list of positive ratios is +-- positive (so RLE's `_require_positive` cannot fire in +-- exact arithmetic once a usable feature exists) + +{-# 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 + +-- The column total: Julia's `sum(counts[:, j])`. +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 column of non-negative counts with at least one positive count has a +-- positive total. So `_require_positive(totals, "tss", …)` refuses exactly +-- the columns whose counts are all zero. +total-positive : ∀ xs → All (0ℚ ≤_) xs → Any (0ℚ <_) xs → 0ℚ < sum xs +total-positive (x ∷ xs) (_ ∷ nn) (here 0 Date: Fri, 2 Oct 2026 08:55:01 +0100 Subject: [PATCH 09/32] proofs: axiom audit for the Agda tree; align headers with D1's Julia The audit refuses postulates, holes, check-disabling pragmas and flags, modules without --safe/--without-K, and modules unreachable from MetaManifold/All.agda. Module headers now cite bh_adjust, tss_factors, rle_factors and _median in src/analysis/differential.jl as D1 wrote them, including the descending-sort tie order and the 1.0-seeded running minimum. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JPmNukJT5PUzQ9d74mhHhj --- .../agda/MetaManifold/BenjaminiHochberg.agda | 71 +++++---- proofs/agda/MetaManifold/Scaling.agda | 83 ++++++----- proofs/tests/axiom-audit.sh | 141 ++++++++++++++++++ 3 files changed, 232 insertions(+), 63 deletions(-) create mode 100755 proofs/tests/axiom-audit.sh diff --git a/proofs/agda/MetaManifold/BenjaminiHochberg.agda b/proofs/agda/MetaManifold/BenjaminiHochberg.agda index ab9ddd10..331a3cbe 100644 --- a/proofs/agda/MetaManifold/BenjaminiHochberg.agda +++ b/proofs/agda/MetaManifold/BenjaminiHochberg.agda @@ -4,27 +4,43 @@ -- 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 mirrors `bh_adjust` in `src/analysis/estimation.jl` step for --- step: +-- `bhAdjust` below models `bh_adjust` in `src/analysis/differential.jl` +-- (branch feat/differential-abundance-nb, commit 6c3e8b2): -- -- Julia here -- ───────────────────────────────────────── ────────────────────────────── --- n = length(pvalues) m = length ps --- order = sortperm(pvalues) (stable) order = sort (indexFrom 0 ps), --- lexicographic on (p, index), --- which is what a stable sort --- by p produces --- sorted = pvalues[order] sortedP = map proj₁ order --- running = Inf `minInf`: a value with nothing --- for i in n:-1:1 after it is its own running --- running = min(running, sorted[i]*n/i) minimum (min(Inf, x) = x) --- adjusted[i] = min(running, 1) stepUp = map (_⊓ 1ℚ) ∘ running --- for (rank, idx) in enumerate(order) restore: position idx of the --- out[idx] = adjusted[rank] output reads the adjusted --- value whose key has index idx +-- 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 -- --- Julia's ranks are one-based (`i`); here they are zero-based (`j = i - 1`), --- so Julia's `n / i` is `rankFactor m j = m / (j + 1)` from the Prelude. +-- 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 @@ -123,7 +139,7 @@ monotone-in-family-size M M′ j n d M≤M′ {{np}} = Valid : ℚ → Set Valid p = 0ℚ ≤ p × p ≤ 1ℚ --- Julia's `sorted[i] * n / i` at zero-based rank j. +-- Julia's `n / rank * p[idx]` at zero-based rank j = rank - 1. scaled : ℕ → ℕ → ℚ → ℚ scaled m j p = p * rankFactor m j @@ -144,20 +160,21 @@ scaled≃bhScale m j p@record{} = ℚᵘₚ.≃-trans (ℚₚ.toℚᵘ-homo-* p (trans (sym (ℕₚ.+-assoc j d (d ℕ.* j))) (cong ((j ℕ.+ d) ℕ.+_) (ℕₚ.*-comm d j))) --- Julia's `running = Inf; running = min(running, x)`: the first value folded --- in (the largest rank) is its own running minimum. +-- 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 backwards loop, listed in rank order, for a +-- 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 --- `adjusted[i] = min(running, 1.0)`. +-- Julia's `min(running, 1.0)`. stepUp : ℕ → List ℚ → List ℚ stepUp m ps = map (_⊓ 1ℚ) (running m 0 ps) @@ -166,15 +183,15 @@ indexFrom : ℕ → List ℚ → List (ℚ × ℕ) indexFrom k [] = [] indexFrom k (p ∷ ps) = (p , k) ∷ indexFrom (suc k) ps --- Julia's `sortperm` is stable, i.e. it sorts by p and breaks ties by index: --- the lexicographic order on (p, index). +-- 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] = adjusted[rank]` read from the other side: the value at output --- position i is the adjusted value of the (first, and in fact only) sorted +-- `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 ((ℚ × ℕ) × ℚ) → ℕ → ℚ @@ -479,7 +496,7 @@ module _ (ps : List ℚ) where -- Every (p, adjusted) pair of the output is a (p_(k), envelope_k) pair of -- the sorted stage. This is the whole content of Julia's scatter-back - -- `out[order[rank]] = adjusted[rank]` that the theorems need. + -- `out[idx] = ...` that the theorems need. bh-output-is-envelope : ∀ {x} → x ∈ zip ps (bhAdjust ps) → x ∈ Z m 0 sortedP bh-output-is-envelope {x} x∈ = subst (x ∈_) zip-sorted-adjusted x∈Zs where diff --git a/proofs/agda/MetaManifold/Scaling.agda b/proofs/agda/MetaManifold/Scaling.agda index c05c9a97..318afd70 100644 --- a/proofs/agda/MetaManifold/Scaling.agda +++ b/proofs/agda/MetaManifold/Scaling.agda @@ -2,37 +2,47 @@ -- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) -- -- Library-size scaling (TSS) and median-of-ratios (RLE) over exact rationals: --- the parts of `src/analysis/scaling.jl` that are statements about rational --- arithmetic, proved; the parts that are not, named. +-- 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 every factor vector by --- its geometric mean and turns factors into offsets with `log`; RLE divides --- each count by the feature's geometric mean. None of these is a rational --- function, so none can be computed over ℚ. Wherever Julia divides by a --- geometric mean, the theorems below take the divisor as an ARBITRARY --- positive rational `g`. Every result therefore holds for the real --- geometric mean as soon as it is positive (it is: a geometric mean of --- positive numbers is positive), but that last step is a fact about the --- reals, not a theorem here. +-- * 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. --- `_require_positive` can fire in floating point (underflow to 0.0) where --- the exact quantity is positive; it is not modelled. +-- 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 docs/formal/agda-bh-scaling-proofs.md): -- --- total-positive a column of non-negative counts with at least one --- positive count has a positive total (so TSS refuses --- exactly the all-zero columns) --- proportions-sum-to-1 the relative-abundance transform sums to 1 +-- 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 of a non-empty list of positive ratios is --- positive (so RLE's `_require_positive` cannot fire in --- exact arithmetic once a usable feature exists) +-- 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 #-} @@ -57,7 +67,7 @@ open import Data.List.Sort.MergeSort ℚₚ.≤-decTotalOrder using (sort; sort- ------------------------------------------------------------------------ -- Shared arithmetic --- The column total: Julia's `sum(counts[:, j])`. +-- A sample's library size: Julia's `vec(sum(counts; dims=2))`, one row. sum : List ℚ → ℚ sum = foldr _+_ 0ℚ @@ -108,27 +118,27 @@ private sum-scale r (x ∷ xs) = trans (cong (x * r +_) (sum-scale r xs)) (sym (ℚₚ.*-distribʳ-+ r x (sum xs))) --- A column of non-negative counts with at least one positive count has a --- positive total. So `_require_positive(totals, "tss", …)` refuses exactly --- the columns whose counts are all zero. +-- 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 +# +# 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" From a3ccd2ecdac1a3e8f37ad6892f3c24bf5b98035e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:58:35 +0100 Subject: [PATCH 10/32] proofs: ignore Agda interface files proofs/agda/_build/ is regenerated by every type-check and is specific to the Agda version that wrote it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JPmNukJT5PUzQ9d74mhHhj --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 6df0e56159cb14b5d973e7b8d5cf0a34c3e55d73 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:04:29 +0100 Subject: [PATCH 11/32] ci: type-check the Agda proofs; docs: map each theorem to its Julia code Adds one job, "Agda proofs", which installs Agda 2.6.4.3 and agda-stdlib 2.1 from Debian trixie in a digest-pinned image, runs the axiom audit, and type-checks proofs/agda/MetaManifold/All.agda. The existing test job is unchanged. docs/formal/agda-bh-scaling-proofs.md maps each theorem to the function in src/analysis/differential.jl and to the D1 test that exercises it, and lists what is not proved (geometric means, scale invariance, floating point) and why. docs/* is otherwise kept local, so .gitignore gains a single !docs/formal/ exception. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JPmNukJT5PUzQ9d74mhHhj --- .github/workflows/ci.yml | 42 ++++++++ .gitignore | 2 + docs/formal/agda-bh-scaling-proofs.md | 140 ++++++++++++++++++++++++++ proofs/agda/metamanifold.agda-lib | 2 + 4 files changed, 186 insertions(+) create mode 100644 docs/formal/agda-bh-scaling-proofs.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d3a0274..5356534f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -282,3 +282,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, docs/formal/agda-bh-scaling-proofs.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 6ef7e458..e76c29db 100644 --- a/.gitignore +++ b/.gitignore @@ -250,6 +250,8 @@ vignettes/*.pdf ## Documentation kept local except published release notes docs/* !docs/release-notes/ +# The theorem-to-code map for proofs/agda is published with the proofs it describes. +!docs/formal/ # translation temp files diff --git a/docs/formal/agda-bh-scaling-proofs.md b/docs/formal/agda-bh-scaling-proofs.md new file mode 100644 index 00000000..304738cc --- /dev/null +++ b/docs/formal/agda-bh-scaling-proofs.md @@ -0,0 +1,140 @@ + + +# 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 index ee80b842..6f46037a 100644 --- a/proofs/agda/metamanifold.agda-lib +++ b/proofs/agda/metamanifold.agda-lib @@ -1,3 +1,5 @@ +-- SPDX-License-Identifier: AGPL-3.0-only +-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) name: metamanifold include: . depend: standard-library From aacf5d15aa2d098fbcfeb2330b9fcbc3daaf0849 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:48:14 +0100 Subject: [PATCH 12/32] docs: keep the theorem-to-code map with the proofs Move docs/formal/agda-bh-scaling-proofs.md to proofs/agda/README.md and drop the .gitignore exception that published docs/formal/, so docs/ stays local as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JPmNukJT5PUzQ9d74mhHhj --- .github/workflows/ci.yml | 2 +- .gitignore | 2 -- proofs/agda/MetaManifold/BenjaminiHochberg.agda | 2 +- proofs/agda/MetaManifold/Scaling.agda | 2 +- docs/formal/agda-bh-scaling-proofs.md => proofs/agda/README.md | 0 5 files changed, 3 insertions(+), 5 deletions(-) rename docs/formal/agda-bh-scaling-proofs.md => proofs/agda/README.md (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5356534f..ca6aa209 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -284,7 +284,7 @@ jobs: fail_ci_if_error: false # Machine-checked proofs of the Benjamini-Hochberg adjustment and the TSS/RLE - # scaling algebra (proofs/agda, docs/formal/agda-bh-scaling-proofs.md). + # 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 diff --git a/.gitignore b/.gitignore index e76c29db..6ef7e458 100644 --- a/.gitignore +++ b/.gitignore @@ -250,8 +250,6 @@ vignettes/*.pdf ## Documentation kept local except published release notes docs/* !docs/release-notes/ -# The theorem-to-code map for proofs/agda is published with the proofs it describes. -!docs/formal/ # translation temp files diff --git a/proofs/agda/MetaManifold/BenjaminiHochberg.agda b/proofs/agda/MetaManifold/BenjaminiHochberg.agda index 331a3cbe..9a0b8371 100644 --- a/proofs/agda/MetaManifold/BenjaminiHochberg.agda +++ b/proofs/agda/MetaManifold/BenjaminiHochberg.agda @@ -47,7 +47,7 @@ -- 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 docs/formal/agda-bh-scaling-proofs.md): +-- 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 diff --git a/proofs/agda/MetaManifold/Scaling.agda b/proofs/agda/MetaManifold/Scaling.agda index 318afd70..97dbde75 100644 --- a/proofs/agda/MetaManifold/Scaling.agda +++ b/proofs/agda/MetaManifold/Scaling.agda @@ -29,7 +29,7 @@ -- 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 docs/formal/agda-bh-scaling-proofs.md): +-- 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 diff --git a/docs/formal/agda-bh-scaling-proofs.md b/proofs/agda/README.md similarity index 100% rename from docs/formal/agda-bh-scaling-proofs.md rename to proofs/agda/README.md From af75c8c55aa2adff45ae5d1e11524a1e69ae7566 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:36:28 +0100 Subject: [PATCH 13/32] =?UTF-8?q?fix(differential):=20refuse=20complete=20?= =?UTF-8?q?separation=20instead=20of=20reporting=20p=E2=89=881?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A taxon with no reads in every sample of one group drives the glm.nb group coefficient to infinity: the fit still reports convergence, with no warning and theta inside its bounds, while the standard error grows without limit and the Wald p-value collapses towards 1. Such a taxon was reported as `ok`, entered the Benjamini-Hochberg family and was drawn as non-significant - the taxa found in only one condition, often the most interesting ones, were published as negative findings. Measured on 4 vs 4 samples, reference all zero, contrast 50/80/120/30: estimate 25.5 (log2 FC ~37), SE 12812, p = 0.998, converged. The fit loop now checks each taxon for a group in which it has no reads and reports it as `failed`, naming the group, with no estimate and no p-value, so it stays out of the multiple-testing family as the module already does for every fit it cannot trust. Test: a taxon absent from group A is refused with its reason, the BH family is the other taxa only, and a control shows the direct glm.nb call converges with p > 0.9. Disabling the check fails 5 assertions. Co-Authored-By: Claude Opus 5.5 --- src/analysis/differential.jl | 14 +++++++++++++- test/unit/test_differential.jl | 35 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl index 4801d41d..d2c78de0 100644 --- a/src/analysis/differential.jl +++ b/src/analysis/differential.jl @@ -250,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( @@ -336,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), diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index 099c4778..3b034896 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -280,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 From c86098098a2856458f44adf3a8dc28b0d1814a22 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 01:02:32 +0100 Subject: [PATCH 14/32] fix: separate a significance test that did not run from a null result `_alpha_significance` returned `(nothing, empty DataFrame)` in three cases that are not findings: R/vegan absent, the groups sharing no sample IDs for a paired test, and R's own `tryCatch` yielding `NA_real_` for the statistic. A genuine result in which no pair reached significance has the same shape, so each of those three was rendered the way a null result is rendered: the pairwise panel drew no brackets, which is exactly how "nothing significant" looks. `AlphaSignificance` makes `status` the primary field: `:computed` is the only status whose numbers may be read as evidence; `:r_unavailable`, `:no_paired_samples` and `:test_failed` carry a reader-facing `reason`. A panel whose pairwise tests did not run now carries an explicit "not run" notice, and the omnibus caption says "not run" with the reason instead of "n/a" beside a test name. A busy R runtime is unchanged: `RBusyError` still propagates to the server, which reports the chart as unavailable, and the existing `@test_throws` assertion stands. Tests: a not-computed result is asserted unequal to a computed null, the notice appears only when the test did not run (with a computed-null positive control), and each reason gets distinct caption wording. Mutants killed before this was believed: making `_not_computed` report `:computed` fails 12 assertions plus 1 error; restoring the silent early return in `_add_pairwise_annotations!` fails the notice test. Ported from hyperpolymath/MetaManifold-WebUI@f23a1d5 (fork issue hyperpolymath/MetaManifold-WebUI#31), adapted to upstream's RBusyError design. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F6qhB21oLqSPK261cRy5R8 --- src/analysis/analysis.jl | 125 +++++++++++++++++++++++++++++++----- test/unit/test_r_runtime.jl | 76 ++++++++++++++++++++++ 2 files changed, 186 insertions(+), 15 deletions(-) diff --git a/src/analysis/analysis.jl b/src/analysis/analysis.jl index 029d57b7..894fd85f 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)) + +""" + 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. +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}( diff --git a/test/unit/test_r_runtime.jl b/test/unit/test_r_runtime.jl index 936a7ac4..42825efa 100644 --- a/test/unit/test_r_runtime.jl +++ b/test/unit/test_r_runtime.jl @@ -94,4 +94,80 @@ 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 From f71fa03bd7d29015b693a7bd00bf023efe08ca1e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:59:08 +0100 Subject: [PATCH 15/32] Test that an omnibus statistic R cannot compute is a failed test Runs _alpha_significance through R with every sample in one group, so kruskal.test errors and R's tryCatch returns NA: the result must be :test_failed with no omnibus p-value. A two-group positive control shows the same path computes a p-value when it can. Covers the ismissing branch Codecov reported as missed; skips loudly without R/vegan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- test/unit/test_r_runtime.jl | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/unit/test_r_runtime.jl b/test/unit/test_r_runtime.jl index 42825efa..59c930b1 100644 --- a/test/unit/test_r_runtime.jl +++ b/test/unit/test_r_runtime.jl @@ -170,4 +170,28 @@ const RR = MetaManifold.RRuntime @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 From c4b2efc4d5a1fee02a884898e47f3c0cd7ccf5c8 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:04:59 +0100 Subject: [PATCH 16/32] fix(server): log an unreadable config at warn, not error `_read_databases` and `_read_primers` both answer `json_error(400, ...)`: the route's own verdict that a malformed `config/databases.yml` or `config/primers.yml` is an operator-fixable input, not a server fault. Logging that at `@error` with a full backtrace reports a routine bad file as if the server had crashed, and the backtrace pointed into YAML.jl's parser rather than at the offending line, so it carried no diagnostic value either. The reason string is kept -- `reason=sprint(showerror, e)` is what actually names the YAML defect. Only the backtrace goes. Deliberately NOT changed: the three 500-class sites, which stay at `@error` with their backtraces -- src/server/routes/analysis_config.jl:144 src/server/routes/annotations.jl:318 src/server/routes/composition.jl:370 A 500 is the route saying it did not expect this, and there the backtrace is the only thing separating a data problem from a code bug. Dropping it would be muting a diagnostic rather than fixing one. `analysis_config.jl` already draws this line itself: its `ArgumentError` branch returns 400 with no logging at all, while everything else logs at `@error` and returns 500. `src/server/routes/pipeline.jl:520` is untouched -- it is the silent-degradation shape tracked in #31, not a severity question. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm (cherry picked from commit e2147d5f4cf24c373e030d574359f89dbc7dd60f) --- src/server/routes/config.jl | 5 ++++- src/server/routes/databases.jl | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/server/routes/config.jl b/src/server/routes/config.jl index dbb48989..ca72236a 100644 --- a/src/server/routes/config.jl +++ b/src/server/routes/config.jl @@ -276,7 +276,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 From 144d0c17c11f56a2e0db4042878d1664330185da Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:50:02 +0100 Subject: [PATCH 17/32] Test that an unreadable primers.yml is logged as a warning Covers the _read_primers catch branch Codecov reported as missed, and asserts the log level at both unreadable-config sites, so a return to @error fails the suite rather than only the coverage figure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- test/unit/test_routes.jl | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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) From d0b31f4258d9a484adeb71101abd8c62f9d01a74 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:55:09 +0100 Subject: [PATCH 18/32] fix(ci): give the required status check a version-stable name Closes #38. The `test` job was named `Julia ${{ matrix.julia-version }} / ${{ matrix.os }}`, and that string is what branch ruleset 23793298 requires as a status check. A ruleset matches a required check by display name. Bumping either pin renamed the check, and a renamed check does not report at all -- it does not fail, it is simply absent, and an absent required check can never be satisfied. Every open pull request would have deadlocked until an admin bypassed the rule. The trigger was an ordinary version bump: the single most routine edit this repository gets. Both halves of the name interpolated, so the OS pin carried the same fault as the Julia pin. Measured, by parsing three hypothetical bumps through both versions of the file: matrix before after 1.12.5 / ubuntu-24.04 "Julia 1.12.5 / ubuntu-24.04" "Julia tests" 1.12.6 / ubuntu-24.04 "Julia 1.12.6 / ubuntu-24.04" "Julia tests" 1.13.0 / ubuntu-26.04 "Julia 1.13.0 / ubuntu-26.04" "Julia tests" The versions are not hidden, only moved off the identifier: they remain in `strategy.matrix` and in the `Set up Julia` step's log. `test/unit/test_install_pins.jl` gains a reflexive testset asserting that NO job name in ci.yml interpolates anything. It is written over every job rather than over the one job we require today, because a rule enforced at each door in turn is a rule the next door escapes. Verified by killing the mutant: against the unedited ci.yml the assertion fails on exactly one job of three (97 pass / 1 fail), and passes 98/98 after the rename. The job ID `test` is unchanged, so `needs: [test]` and the existing `ci["jobs"]["test"]` assertions are untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm (cherry picked from commit 8a098d24a7fc3a0e3f3867de9bb631ae04e482ce) --- .github/workflows/ci.yml | 15 ++++++++++++++- test/unit/test_install_pins.jl | 22 ++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca6aa209..06f3aaa7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,20 @@ concurrency: jobs: test: - name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }} + # The name is a fixed string, and deliberately carries no version. + # + # A branch ruleset matches a required status check by the DISPLAY NAME of the job + # that posts it. When that name was `Julia ${{ matrix.julia-version }} / ${{ matrix.os }}` + # a pin bump renamed the check, and a renamed check does not report at all -- it + # does not fail, it is simply absent, and an absent required check can never be + # satisfied. Every pull request would have deadlocked until an admin bypassed the + # rule, triggered by nothing more alarming than editing a version number. + # + # The versions have not been hidden, only moved somewhere that does not double as + # an identifier: `strategy.matrix` below, and the `Set up Julia` step's own log. + # `test/unit/test_install_pins.jl` asserts that no job name in this file + # interpolates anything, so this cannot regress silently. + name: Julia tests runs-on: ${{ matrix.os }} # 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 diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index df2e7847..096cf21c 100644 --- a/test/unit/test_install_pins.jl +++ b/test/unit/test_install_pins.jl @@ -235,4 +235,26 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) @test occursin("fetch_pinned \"$ref\"", runs) end end + + @testset "no job name interpolates a pin" begin + ci = YAML.load_file(CI_PATH) + + # A required status check is matched by the DISPLAY NAME of the job that posts + # it. `name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }}` therefore + # renames the check on every pin bump, and a renamed check does not report at + # all -- it does not fail, it is simply absent, and a required check that never + # reports can never be satisfied. Every pull request then deadlocks until an + # admin bypasses the rule, which is the one outcome branch protection exists to + # prevent. The bump that triggers it is a one-line edit to this very file's + # subject matter, so the deadlock arrives by way of an ordinary maintenance + # change that looks safe. + # + # Asserting this over EVERY job rather than over the one job we happen to + # require today is deliberate: 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. + for (id, job) in ci["jobs"] + name = get(job, "name", id) + @test !occursin("\${{", name) + end + end end From 5b45d090721e9ae73c213ac5bbcea4794fcef3ad Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 22 Sep 2026 00:06:42 +0100 Subject: [PATCH 19/32] fix(ci): drop the 1x1 matrix so the check name is actually stable #39 renamed this job to `Julia tests` and claimed the required status check was therefore version-stable. It was not, and the claim was never measured. MEASURED on PR #39 head 776e340d, after that change had landed: Julia tests (1.12.5, ubuntu-24.04) <- what GitHub actually posted Julia tests <- what the ruleset requires GitHub appends the matrix combination to a job's posted check name whenever the `name:` does not itself reference the matrix. The old interpolating name suppressed the suffix by accident; replacing it with a static string turned the suffix on. A 1x1 matrix is still a matrix, so both pins stayed embedded in the check name and the required context `Julia tests` was never reported at all -- so `main` has been sitting in precisely the deadlock issue #38 describes, with #39 itself merged only by an admin bypass. The matrix selected exactly one combination and bought nothing, so it is removed rather than worked around: `runs-on: ubuntu-24.04` and the setup-julia `version: "1.12.5"` are literals, each keeping the reasoning that used to sit beside the matrix entry. The non-matrix `repo-hygiene` job was the control -- it has always posted its `name:` verbatim. Why the guard did not catch it: `test_install_pins.jl` asserted that no job name interpolates anything -- a property of the YAML `name:` field -- while the consumer, the ruleset, matches the rendered check-run name. The guard asked a different question than its consumer, so it passed on a broken fix. It now asserts BOTH necessary conditions, over every job in the file: the name interpolates nothing AND the job has no `strategy.matrix`. Run against the previous commit that assertion fails on `test` alone (5 pass / 1 fail), so it discriminates rather than blanket-failing. `CI installs what is pinned` made the same claims about the same two values and has been repointed at where they now live, locating the setup step by its `uses:` rather than by index. Mutating either literal fails it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm (cherry picked from commit 6c29b53bfa2f5d99c538ecdff64ff2cd747555db) --- .github/workflows/ci.yml | 56 ++++++++++++++++--------------- test/unit/test_install_pins.jl | 61 +++++++++++++++++++++++++--------- 2 files changed, 75 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06f3aaa7..6be873af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,21 +17,33 @@ concurrency: jobs: test: - # The name is a fixed string, and deliberately carries no version. + # 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 of the job - # that posts it. When that name was `Julia ${{ matrix.julia-version }} / ${{ matrix.os }}` - # a pin bump renamed the check, and a renamed check does not report at all -- it - # does not fail, it is simply absent, and an absent required check can never be - # satisfied. Every pull request would have deadlocked until an admin bypassed the - # rule, triggered by nothing more alarming than editing a version number. + # 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. # - # The versions have not been hidden, only moved somewhere that does not double as - # an identifier: `strategy.matrix` below, and the `Set up Julia` step's own log. - # `test/unit/test_install_pins.jl` asserts that no job name in this file - # interpolates anything, so this cannot regress silently. + # MEASURED on PR #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 - runs-on: ${{ matrix.os }} + # 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 @@ -41,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 @@ -61,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 diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index 096cf21c..15e2b7b1 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 @@ -236,25 +253,37 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s) end end - @testset "no job name interpolates a pin" begin + @testset "a required check name is a stable identifier" begin ci = YAML.load_file(CI_PATH) - # A required status check is matched by the DISPLAY NAME of the job that posts - # it. `name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }}` therefore - # renames the check on every pin bump, and a renamed check does not report at - # all -- it does not fail, it is simply absent, and a required check that never - # reports can never be satisfied. Every pull request then deadlocks until an - # admin bypasses the rule, which is the one outcome branch protection exists to - # prevent. The bump that triggers it is a one-line edit to this very file's - # subject matter, so the deadlock arrives by way of an ordinary maintenance - # change that looks safe. + # 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 PR #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. # - # Asserting this over EVERY job rather than over the one job we happen to - # require today is deliberate: 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. + # 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 From f9cf729f4531df5c5ab4b8bba2cf71b0499380fa Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:23:56 +0100 Subject: [PATCH 20/32] docs(ci): name the fork PR that measured the matrix-suffixed check name Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F6qhB21oLqSPK261cRy5R8 --- .github/workflows/ci.yml | 2 +- test/unit/test_install_pins.jl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6be873af..10cf5483 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: # admin bypasses the rule, triggered by nothing more alarming than editing a version # number. # - # MEASURED on PR #39 (2026-09-21): this job, named exactly `Julia tests` but still + # 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 diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl index 15e2b7b1..a3053b97 100644 --- a/test/unit/test_install_pins.jl +++ b/test/unit/test_install_pins.jl @@ -268,7 +268,7 @@ end # 1. the name must not interpolate a pin; and # 2. the job must not have a `strategy.matrix`. # - # MEASURED 2026-09-21 on PR #39: a job named exactly `Julia tests` with a 1x1 + # 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 From 9d888f1062e48172d86673134ad4ce6b56e58466 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:38:22 +0100 Subject: [PATCH 21/32] docs: the Julia pin is repeated in the CI workflow, which has no matrix Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a37a642be31b1b2b978cb8b9f73d564601607a0a Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:20:49 +0100 Subject: [PATCH 22/32] Gate file-name config values and remote keys; guard pipeline tables on delete H2: _config_value_error only type-checked string keys, so PATCH /api/v1/config {"dada2.output.fasta_prefix": "../../tmp/pwn"} was stored, and DADA2 joins that stem onto its Tables directory (dada2.jl:148-150, chimera.jl, dada2_functions.r), writing outside the run. The three dada2.output.*_prefix keys and cutadapt.r1_suffix / r2_suffix (H8: spliced into a \Q...\E regex in tools.jl _mate_regex, so a "\E" escaped the quoting) must now be safe names (Validation.is_safe_name, letters, digits and . _ -). The remote block was only partly gated: remote.staging_dir (interpolated into the remote login shell's command), remote.stages and remote.threads were stored unchecked, although validate.jl says such values are refused at the write gate. The remote-block checks of _validate_pipeline_cfg are extracted into _validate_remote! (and _staging_dir_errors!) and exposed to the gate as Validation.remote_value_error, so the gate runs validation's own checks rather than a copy; staging_dir is checked even before a host is named. remote.host and remote.rscript keep the gate's existing stricter check. Validation behaviour of a full config is unchanged. H3: DELETE .../results/tables/{table} had no reserved-name guard, so it deleted merged.csv (the merge sentinel) and dropped the merged table. Save and delete now share _PIPELINE_TABLES (merged, merged_otu, merged_cdhit, cluster_membership); deleting one returns 400 reserved_name and touches nothing. Tests: test/unit/test_write_gates.jl (76 assertions): each bad value through PATCH /api/v1/config returns 400 with config/pipeline.yml byte-identical; good values are stored and pass _validate_remote!; DELETE of each pipeline table returns 400 with its CSV and DuckDB table intact, while a user table still deletes. Planted control (gate additions and delete guard removed): 26 pass / 46 fail / 4 error; with the fix 76/76. test_write_gates + test_validation + test_remote_stages + test_report_funnel + test_sample_reads + test_routes: 528/528 (Julia 1.12.6). Co-Authored-By: Claude Opus 5.5 --- src/core/validate.jl | 82 +++++++++++++++------ src/server/routes/config.jl | 26 ++++++- src/server/routes/results.jl | 10 ++- test/runtests.jl | 1 + test/unit/test_write_gates.jl | 129 ++++++++++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 25 deletions(-) create mode 100644 test/unit/test_write_gates.jl 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/config.jl b/src/server/routes/config.jl index ca72236a..2f927ba1 100644 --- a/src/server/routes/config.jl +++ b/src/server/routes/config.jl @@ -115,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) @@ -136,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" 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/test/runtests.jl b/test/runtests.jl index 4a8f47fa..f8659416 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -50,6 +50,7 @@ const UNIT_FILES = [ "test_sample_reads.jl", "test_determinism.jl", "test_differential.jl", + "test_write_gates.jl", ] # The files named on the command line ("test_routes.jl", "routes" or diff --git a/test/unit/test_write_gates.jl b/test/unit/test_write_gates.jl new file mode 100644 index 00000000..cba48141 --- /dev/null +++ b/test/unit/test_write_gates.jl @@ -0,0 +1,129 @@ +# 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) + 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 From 578915c4b3f63b56972b772c4aa51dbc36f94b17 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:53:29 +0100 Subject: [PATCH 23/32] Test that a remote host without a staging_dir is refused Covers the _staging_dir_errors! branch for a missing or non-string staging_dir, the line Codecov reported as missed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01X3hgXxWm6umMgZkjYyHnnm --- test/unit/test_write_gates.jl | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/unit/test_write_gates.jl b/test/unit/test_write_gates.jl index cba48141..60a4683e 100644 --- a/test/unit/test_write_gates.jl +++ b/test/unit/test_write_gates.jl @@ -73,6 +73,15 @@ _wg_request(method, path, body="") = 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) From 592ddd7214cf0e9a2f6dd4dafbb3c910dcdacf2b Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 14 Oct 2026 02:53:06 +0100 Subject: [PATCH 24/32] fix(permanova): test each term, report permutations, refuse untestable terms (#35) adonis2 in the pinned vegan 2.7-3 defaults to by = NULL: one test of the whole model. run_permanova read row 1 as the group term and a comment called the rows sequential terms, so the reported R2/F/p mixed group and run and the per-term list held a single "Model" row. adonis2 is now called with by = "terms"; the top-level result names its term (group). With permutations restricted within individuals (how(blocks = ...)), permute enumerates every permutation when there are fewer than 999: six individuals of two samples give 63, so the smallest attainable p is 1/64. That count was never reported. It is now (`permutations`, `min_p_value`), read from the control adonis2 used. A term constant within every block - group, when each individual belongs to one group - never has its labels exchanged by within-block permutation, so the p-value vegan prints for it does not test a group difference (measured: 0.03125 with a real effect, 0.0625 on noise; it is not fixed at 1). Its p-value is withheld with the reason; R2 and F are still reported, and terms that vary within blocks (run) are tested as before. Tests (test_permanova.jl, against direct vegan::adonis2 calls with the same seed): per-term names, R2, F and p match by = "terms"; 999 permutations unblocked; 63 permutations and floor 1/64 blocked; group's p withheld with its reason while run's p and F match vegan. 184/184 with test_determinism. Reverting by = "terms" and the refusal: 8 fail, 4 error. Frontend shows the reason and the permutation count; typecheck clean, 27/27. Co-authored-by: Claude Opus 5.5 Co-authored-by: Joshua Jewell --- frontend/src/api/types.ts | 19 +++- frontend/src/components/DiversityPanel.tsx | 15 +-- src/analysis/analysis.jl | 79 +++++++++++++--- test/runtests.jl | 1 + test/unit/test_permanova.jl | 103 +++++++++++++++++++++ 5 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 test/unit/test_permanova.jl diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f148fda4..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 } } diff --git a/frontend/src/components/DiversityPanel.tsx b/frontend/src/components/DiversityPanel.tsx index b3d16dd2..51fe3c08 100644 --- a/frontend/src/components/DiversityPanel.tsx +++ b/frontend/src/components/DiversityPanel.tsx @@ -99,19 +99,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/src/analysis/analysis.jl b/src/analysis/analysis.jl index 894fd85f..f40bcc2e 100644 --- a/src/analysis/analysis.jl +++ b/src/analysis/analysis.jl @@ -1423,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, @@ -1443,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) @@ -1452,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") @@ -1463,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] @@ -1506,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"))) @@ -1520,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/test/runtests.jl b/test/runtests.jl index f8659416..991d5683 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -50,6 +50,7 @@ const UNIT_FILES = [ "test_sample_reads.jl", "test_determinism.jl", "test_differential.jl", + "test_permanova.jl", "test_write_gates.jl", ] 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 From bcf52af76214dfd033287920d3022d00f2023d7c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:37 +0100 Subject: [PATCH 25/32] Clear stale analysis results and surface request failures in the UI What was wrong (review F1, F2, F3, F6): - F1: the analysis tabs stay mounted, and DiversityPanel (alpha, NMDS, PERMANOVA), VennPanel, DifferentialPanel and TaxaCompositionChart kept their last result after the runs, dataset, aggregate flag, rank or chart options that produced it changed. A request still in flight for the old inputs was stored on arrival under the new ones, and a failed rerun in VennPanel, DiversityPanel and TaxaCompositionChart left the previous result on screen. - F2: failures read as findings. VennPanel and DifferentialPanel caught each rank lookup as [] and so showed "No taxonomy rank shared by the selected runs"; discoverResultsTables caught as [] and AnalysisWorkspace showed "no results table shared by the selected runs"; DiversityPanel turned a failed capabilities check into "NMDS and PERMANOVA need R with vegan". - F3: useApi kept the previous data when a request failed, so DataTable showed rows for the old filters under the new ones next to the error. - F6: the TaxaCompositionChart rank effect had no cancelled flag, so a slow response for an earlier run or table could overwrite the current ranks. What changed: - Each panel clears its result when its request inputs change and tags each request with a counter (the useRef pattern already used in FigureBuilder and phylogeny/common), dropping responses, errors and loading resets from superseded requests. A failed rerun clears the old result. - New sharedRanks() helper (used by Venn and Differential) rejects when any run's ranks cannot be read; the panels show "Could not read the taxonomy ranks: " instead of the empty-intersection message. - discoverResultsTables() now rejects on failure. New useSharedResultsTablesState() returns { options, error } and AnalysisWorkspace shows the error; useSharedResultsTables() keeps its signature for its other callers. CompositionPanel, which calls discoverResultsTables directly, now catches and toasts the error. - DiversityPanel reports a failed capabilities check as an error, not as "R unavailable". - useApi drops data on error unless the failing request is a re-run of the very request that produced it (same fetcher), so polling refetches that fail transiently (RunView, Layout) keep data for the same inputs while a request for new inputs never shows the old data. - TaxaCompositionChart rank effect gets a cancelled flag. Tests (bun test src): 36 pass, 0 fail (27 before, 9 new) in useApi.test.ts, sharedRanks.test.ts, annotationShared.test.ts. bun run typecheck clean. Planted control: restoring the old behaviour (useApi keeping data on every error, the [] catch in discoverResultsTables, a per-run [] catch in sharedRanks) gave 33 pass, 3 fail, one per planted defect. The component reset and request-counter logic has no automated test: the frontend has no DOM or React rendering test setup. Co-Authored-By: Claude Opus 5.5 --- frontend/src/components/AnalysisWorkspace.tsx | 6 +- frontend/src/components/CompositionPanel.tsx | 4 ++ frontend/src/components/DifferentialPanel.tsx | 47 +++++++++----- frontend/src/components/DiversityPanel.tsx | 44 +++++++++++-- .../src/components/TaxaCompositionChart.tsx | 43 ++++++++++--- frontend/src/components/VennPanel.tsx | 49 ++++++++++----- .../src/components/annotationShared.test.ts | 38 ++++++++++++ frontend/src/components/annotationShared.ts | 61 ++++++++++++------- frontend/src/components/sharedRanks.test.ts | 36 +++++++++++ frontend/src/components/sharedRanks.ts | 17 ++++++ frontend/src/hooks/useApi.test.ts | 16 +++++ frontend/src/hooks/useApi.ts | 31 ++++++++-- 12 files changed, 320 insertions(+), 72 deletions(-) create mode 100644 frontend/src/components/annotationShared.test.ts create mode 100644 frontend/src/components/sharedRanks.test.ts create mode 100644 frontend/src/components/sharedRanks.ts create mode 100644 frontend/src/hooks/useApi.test.ts diff --git a/frontend/src/components/AnalysisWorkspace.tsx b/frontend/src/components/AnalysisWorkspace.tsx index c8c6b5af..e7295004 100644 --- a/frontend/src/components/AnalysisWorkspace.tsx +++ b/frontend/src/components/AnalysisWorkspace.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState, type ReactNode } from 'react' import type { AnnotationSource, ComparisonRunSpec } from '../api/types' import { AnalysisSourceContext } from './analysisSource' -import { useSharedResultsTables } from './annotationShared' +import { useSharedResultsTablesState } from './annotationShared' import { DiversityPanel } from './DiversityPanel' import { TaxaCompositionChart } from './TaxaCompositionChart' import { VennPanel } from './VennPanel' @@ -80,7 +80,7 @@ export function AnalysisWorkspace({ study, runs: baseRuns, source, perRun }: { }, [aggregate, selectedRuns]) //## Dataset: results tables shared by every selected run - const options = useSharedResultsTables(study, selectedRuns) + const { options, error: optionsError } = useSharedResultsTablesState(study, selectedRuns) const [analysisKey, setAnalysisKey] = useState(null) useEffect(() => { setAnalysisKey(current => current && options.some(o => o.key === current) ? current : options[0]?.key ?? null) @@ -138,6 +138,8 @@ export function AnalysisWorkspace({ study, runs: baseRuns, source, perRun }: { onChange={e => setAnalysisKey(e.target.value)}> {options.map(o => )} + ) : optionsError ? ( + could not list the results tables: {optionsError} ) : ( no results table shared by the selected runs )} diff --git a/frontend/src/components/CompositionPanel.tsx b/frontend/src/components/CompositionPanel.tsx index 77ad8869..08863fc3 100644 --- a/frontend/src/components/CompositionPanel.tsx +++ b/frontend/src/components/CompositionPanel.tsx @@ -53,6 +53,10 @@ export function CompositionPanel({ setTableOptions(opts) setTable(current => opts.some(o => o.key === current) ? current : opts.some(o => o.key === 'merged') ? 'merged' : opts[0]?.key ?? current) + }).catch(err => { + if (cancelled) return + setTableOptions([]) + toast.error(`Could not list the results tables: ${errorMessage(err)}`) }) return () => { cancelled = true } }, [study, run, group]) diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx index 9e5534c1..825fb812 100644 --- a/frontend/src/components/DifferentialPanel.tsx +++ b/frontend/src/components/DifferentialPanel.tsx @@ -1,11 +1,11 @@ // 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' @@ -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,6 +110,9 @@ 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}; a positive effect means more abundant in {runs[1].prefix ?? runs[1].run}. diff --git a/frontend/src/components/DiversityPanel.tsx b/frontend/src/components/DiversityPanel.tsx index 51fe3c08..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 && ( 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} + )}