Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b21d005
Add a CLR linear-model method for differential abundance
hyperpolymath Oct 2, 2026
3acf607
clr_lm: filter by prevalence before cmultRepl, and test with Welch
hyperpolymath Oct 5, 2026
c548451
Rename the clr_lm method to clr_welch
hyperpolymath Oct 5, 2026
657120c
Test clr_welch when zCompositions is missing
hyperpolymath Oct 6, 2026
b86d59f
Run the zCompositions-absent test in CI instead of skipping it
hyperpolymath Oct 6, 2026
7799de8
Test the cmultRepl output check directly
hyperpolymath Oct 7, 2026
3f9a5bc
proofs(agda): Benjamini-Hochberg adjustment over exact rationals
hyperpolymath Oct 2, 2026
1d8572f
proofs(agda): TSS proportions and RLE median positivity over exact ra…
hyperpolymath Oct 2, 2026
b3e871d
proofs: axiom audit for the Agda tree; align headers with D1's Julia
hyperpolymath Oct 2, 2026
a3ccd2e
proofs: ignore Agda interface files
hyperpolymath Oct 2, 2026
6df0e56
ci: type-check the Agda proofs; docs: map each theorem to its Julia code
hyperpolymath Oct 2, 2026
aacf5d1
docs: keep the theorem-to-code map with the proofs
hyperpolymath Oct 2, 2026
af75c8c
fix(differential): refuse complete separation instead of reporting p≈1
hyperpolymath Oct 5, 2026
c860980
fix: separate a significance test that did not run from a null result
hyperpolymath Sep 22, 2026
f71fa03
Test that an omnibus statistic R cannot compute is a failed test
hyperpolymath Oct 6, 2026
c4b2efc
fix(server): log an unreadable config at warn, not error
hyperpolymath Sep 21, 2026
144d0c1
Test that an unreadable primers.yml is logged as a warning
hyperpolymath Oct 6, 2026
d0b31f4
fix(ci): give the required status check a version-stable name
hyperpolymath Sep 21, 2026
5b45d09
fix(ci): drop the 1x1 matrix so the check name is actually stable
hyperpolymath Sep 21, 2026
f9cf729
docs(ci): name the fork PR that measured the matrix-suffixed check name
hyperpolymath Oct 1, 2026
9d888f1
docs: the Julia pin is repeated in the CI workflow, which has no matrix
hyperpolymath Oct 5, 2026
a37a642
Gate file-name config values and remote keys; guard pipeline tables o…
hyperpolymath Oct 6, 2026
578915c
Test that a remote host without a staging_dir is refused
hyperpolymath Oct 6, 2026
592ddd7
fix(permanova): test each term, report permutations, refuse untestabl…
hyperpolymath Oct 14, 2026
bcf52af
Clear stale analysis results and surface request failures in the UI
hyperpolymath Oct 5, 2026
eea7660
Harden server origin checks, disable Oxygen docs, serve uploads inert
hyperpolymath Oct 5, 2026
bec9c75
Accept the SPA index for /docs paths when a frontend build is present
hyperpolymath Oct 8, 2026
b29ff57
docs: describe split deployments and the origins the server must be t…
hyperpolymath Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 77 additions & 18 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,33 @@ concurrency:

jobs:
test:
name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
# The name is a fixed string, it carries no version, and the job has NO matrix.
# Both are required, and the first without the second is a trap that has already
# been sprung here once.
#
# A branch ruleset matches a required status check by the DISPLAY NAME GitHub posts
# for the job. A renamed check does not fail -- it is simply absent, and an absent
# required check can never be satisfied, so every pull request deadlocks until an
# admin bypasses the rule, triggered by nothing more alarming than editing a version
# number.
#
# MEASURED on hyperpolymath/MetaManifold-WebUI#39 (2026-09-21): this job, named exactly `Julia tests` but still
# carrying a 1x1 `strategy.matrix`, posted `Julia tests (1.12.5, ubuntu-24.04)`.
# GitHub appends the matrix combination whenever the name does not already reference
# the matrix, so a 1x1 matrix is still a matrix and the pins were still embedded in
# the check name. The matrix was therefore removed rather than merely renamed around:
# it selected exactly one combination and bought nothing.
#
# The two values it held are literals below, each keeping its own reasoning.
# `test/unit/test_install_pins.jl` asserts, for EVERY job in this file, that the name
# interpolates nothing AND that the job has no matrix -- so this cannot regress
# silently. If a matrix is ever genuinely wanted here, the required context must move
# to a matrix-free aggregator job first.
name: Julia tests
# Explicit, not ubuntu-latest: the R pin below names an apt package revision built
# for 24.04, and a runner that silently rolled to the next LTS would take that pin
# with it. `runs-on` cannot read `env`, so this is a literal.
runs-on: ubuntu-24.04
# The repository's root .Rprofile sources renv/activate.R, so R started from
# the checkout auto-activates renv and rewrites the library paths. renv is a
# local-development convenience; CI installs into the system library and
Expand All @@ -28,27 +53,19 @@ 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

- 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
Expand Down Expand Up @@ -139,7 +156,7 @@ jobs:
sudo chmod 777 "$RENV_PATHS_CACHE"
sudo env R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)'
sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)'
Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS","zCompositions")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'

# Saved on failure too, since the packages built before a failure are the ones
# the next attempt should reuse. Root writes the cache, so it is made readable first.
Expand Down Expand Up @@ -230,7 +247,7 @@ jobs:
julia --project=. -e '
using RCall
R"cat(.libPaths(), sep=\"\n\")"
for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS"]
for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS", "zCompositions"]
R"if (!require($pkg, character.only=TRUE, quietly=TRUE)) stop($pkg, \" not found\")"
end
'
Expand Down Expand Up @@ -282,3 +299,45 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
slug: JoshuaJewell/MetaManifold-WebUI
fail_ci_if_error: false

# Machine-checked proofs of the Benjamini-Hochberg adjustment and the TSS/RLE
# scaling algebra (proofs/agda, proofs/agda/README.md).
# Agda publishes no 2.6.4.3 binary, and agda-stdlib 2.1 requires Agda 2.6.4.x,
# so the pair comes from Debian trixie, which packages exactly that pair with
# pre-built library interfaces. The image is pinned by digest and the two
# packages by revision, so a rebuild in the archive fails loudly here instead
# of silently changing the checker. These pins live here rather than in
# config/defaults/tool_versions.yml because no other job and no installer
# reads them.
agda:
name: Agda proofs
runs-on: ubuntu-24.04
container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
timeout-minutes: 30
env:
DEBIAN_FRONTEND: noninteractive
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- name: Install Agda and the standard library
run: |
apt-get update -qq
apt-get install -y --no-install-recommends agda-bin=2.6.4.3-1+b2 agda-stdlib=2.1-4
agda --version | grep -qx 'Agda version 2.6.4.3'
grep -qx 'name: standard-library-2.1' /usr/share/agda-stdlib/standard-library.agda-lib

# Runs before the type-check: --safe already refuses postulates, but the
# audit also refuses holes, check-disabling pragmas and flags, and any
# module that MetaManifold/All.agda does not import (an unimported module
# is never type-checked at all).
- name: Axiom audit
run: bash proofs/tests/axiom-audit.sh

- name: Type-check
env:
AGDA_DIR: /tmp/agda-dir
run: |
mkdir -p "$AGDA_DIR"
echo /usr/share/agda-stdlib/standard-library.agda-lib > "$AGDA_DIR/libraries"
cd proofs/agda
agda MetaManifold/All.agda
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,6 @@ LocalPreferences.toml

# Development server log (dev/dev.sh)
dev/server.log

# Agda interface files (proofs/agda)
proofs/agda/_build/
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ The web UI lets you create studies, configure pipeline parameters, launch runs,
| `JULIA_METAMANIFOLD_PORT` | `8080` | Server port |
| `JULIA_METAMANIFOLD_ROOT` | working directory | Project root |
| `JULIA_THREADS` | `8` | Julia threads |
| `JULIA_METAMANIFOLD_ALLOWED_ORIGINS` | none | Extra browser origins allowed to call the API, comma-separated (the server's own origin and the Vite dev server at `localhost:5173` are always allowed) |

Or run the Julia server directly:

Expand Down Expand Up @@ -644,6 +645,30 @@ bash start.sh

Open `http://localhost:8080`. The backend serves the frontend.

### Split deployment (frontend and API on different origins)

To serve the frontend from a different address than the API, set `apiBase` in
`frontend/public/config.json` to the API's address, and list the frontend's
address when starting the server:

```bash
JULIA_METAMANIFOLD_ALLOWED_ORIGINS=https://frontend.example bash start.sh
```

The server accepts browser requests from its own origin (`http://localhost:<port>`
and `http://127.0.0.1:<port>`), from the Vite dev server (`localhost:5173`), and
from the origins listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`, separated by
commas. An origin has to match exactly, scheme and port included:
`https://frontend.example` does not cover `http://frontend.example` or
`https://frontend.example:8443`. A page on any other origin gets
`403 Forbidden: origin not allowed` on every request that carries an `Origin`
header, which is every write and every cross-origin read.

This also applies to an SSH tunnel on a different local port (e.g.
`http://localhost:9000`): the browser's origin is then `http://localhost:9000`,
not the server's `http://localhost:8080`, so start the server with
`JULIA_METAMANIFOLD_ALLOWED_ORIGINS=http://localhost:9000`.

## Input data

Place paired-end FASTQ files under `data/{project_name}/` following Illumina naming:
Expand Down
16 changes: 15 additions & 1 deletion config/defaults/pipeline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -347,10 +347,24 @@ analysis:
# comparing two conditions on the raw read counts, with Benjamini-Hochberg
# adjusted p-values.
differential:
# The model fitted to each taxon. nb_glm: a negative-binomial GLM on the read
# counts with a library-size offset; the effect is a log2 fold change. clr_welch:
# Welch's t-test on centred log-ratios over the taxa passing min_prevalence,
# after zCompositions::cmultRepl replaces their zeros; the effect is a
# difference in CLR units, not a fold change.
method: nb_glm
# How library size enters each model, as a log offset: tss (total reads per
# sample) or rle (median-of-ratios over the taxa present in every sample).
# rle is refused when no taxon is present in every sample.
# Used by nb_glm only; clr_welch ignores it, since log-ratios carry no library size.
offset: tss
# Fraction of samples, 0 to 1, in which a taxon must have reads to be
# tested. Taxa below it are listed as filtered, without a p-value.
# tested. Taxa below it are listed as filtered, without a p-value. For
# clr_welch it is applied before zero replacement, so on a sparse table it also
# keeps imputed values for rare taxa out of every sample; clr_welch refuses a
# taxon seen in fewer than 2 samples.
min_prevalence: 0.0
# clr_welch only: cmultRepl's frac, between 0 and 1. An imputed proportion above
# its taxon's smallest observed proportion is lowered to this fraction of it.
# A replaced zero is not a measurement.
replacement_delta: 0.65
56 changes: 52 additions & 4 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -381,28 +398,59 @@ export interface DifferentialRow {
taxon: string
status: DifferentialStatus
note: string
/** Natural-log fold change, contrast over reference. */
/**
* nb_glm: natural-log fold change, contrast over reference. clr_welch: mean
* CLR of contrast minus mean CLR of reference, which is not a fold change.
*/
estimate: number | null
/** nb_glm only; null for clr_welch. */
log2_fold_change: number | null
standard_error: number | null
/** z (nb_glm) or Welch's t (clr_welch). */
statistic: number | null
pvalue: number | null
/** Benjamini-Hochberg adjusted; null outside the tested family. */
padj: number | null
/** nb_glm only. */
dispersion_theta: number | null
prevalence: number
}

export type DifferentialMethod = 'nb_glm' | 'clr_welch'

/** Which row field holds the effect size, and what to call it. */
export interface DifferentialEffect {
key: 'log2_fold_change' | 'estimate'
label: string
}

export interface DifferentialResult {
status: 'ok' | 'partial'
method: string
rank: string
table: string
groups: { reference: string; contrast: string }
n_samples: Record<string, number>
effect: DifferentialEffect
/** Empty for clr_welch, which takes no library-size offset. */
size_factors: { sample: string; group: string; factor: number }[]
config: { offset: 'tss' | 'rle'; min_prevalence: number }
diagnostics: { n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number }
config: {
method: DifferentialMethod
min_prevalence: number
/** nb_glm only. */
offset?: 'tss' | 'rle'
/** clr_welch only. */
replacement_delta?: number
}
diagnostics: {
n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number
/** clr_welch only. */
zero_replacement?: {
method: string; delta: number; zeros_replaced: number; n_taxa_in_composition: number
n_taxa_unobserved: number; median_imputed_fraction: number; max_imputed_fraction: number
warnings: string[]
}
}
rows: DifferentialRow[]
figure: unknown
}
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/AnalysisWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string | null>(null)
useEffect(() => {
setAnalysisKey(current => current && options.some(o => o.key === current) ? current : options[0]?.key ?? null)
Expand Down Expand Up @@ -138,6 +138,8 @@ export function AnalysisWorkspace({ study, runs: baseRuns, source, perRun }: {
onChange={e => setAnalysisKey(e.target.value)}>
{options.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
</select>
) : optionsError ? (
<span className="error-msg" style={{ margin: 0 }}>could not list the results tables: {optionsError}</span>
) : (
<span style={{ color: 'var(--color-muted-fg)' }}>no results table shared by the selected runs</span>
)}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/CompositionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading
Loading