From bf68ba1639917714027fb9c38088c62e6bb32dc4 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 02:45:56 +0100
Subject: [PATCH 01/45] feat(pipeline): record stage memory as occupancy grades
and run the two-thread disagreement test (#106)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
MMW-01, part 1 of 2: the probe and the test. This is the unit that would
go upstream to JoshuaJewell once the fork is re-synced. Part 2, the
dated typed reading, is stacked on this branch.
## Why
The vsearch stage runs its ASV and OTU searches concurrently in a full
run, and one after the other in a single-stage re-run. Nothing measures
what either shape occupies. The searches are child processes, so
`Sys.maxrss()` of the Julia process never sees them.
The high-water-mark algebra (occupancy-types, choreographic-types
A-2/A-3) makes a falsifiable prediction about this shape:
- the cost is the same in every order;
- the memory peak is not: the larger search's peak serially, up to the
sum concurrently.
## What changes
**`MemoryProbe`** (`src/core/memory_probe.jl`). Off unless a
`pipeline.yml` sets `occupancy_probe.enabled: true`. For each attested
stage it records under a new optional `occupancy` key:
- `julia_allocated_bytes`: the `Base.gc_num()` delta over the stage;
- per task: the largest sampled VmRSS of the children it started
(`peak_rss_kib`) and their kernel VmHWM (`hwm_kib`);
- `child_peak_rss_kib`: the largest sum of the stage's children's VmRSS
at one sampling instant.
It samples `/proc` every 10 ms (`interval_ms`) with preallocated
buffers, so the sampling barely touches the allocation figure it
reports.
**Hooks.**
- `Tools._run_killable` registers each child.
- `_attest` wraps the stage and passes the record to
`Provenance.record_stage!(...; occupancy)`.
- Both vsearch call sites label their searches `vsearch:asv` and
`vsearch:otu`.
**With the flag off nothing changes.** `start` returns `nothing` and
every hook passes straight through, so a stage record has exactly the
keys it had before. The key is deliberately *not* added to
`config/defaults/pipeline.yml`, only documented there as a comment: the
run config is embedded in `attestation.yml`, so a default key would
change every run's record even with the flag off.
**Robustness.** The probe never throws into the stage it measures.
`getpid` is read once, while the child is alive.
## The disagreement test
`test/occupancy_disagreement_test.jl` runs with the full suite. It runs
the two searches in three orders, ASV then OTU, OTU then ASV, and
concurrent, on a generated fixture of 1000 reference sequences × 400 bp
with 300 ASV and 150 OTU queries.
**Why that fixture:** of a calibrated ladder (250, 500, 1000, 2000), it
is the smallest that kept both searches resident together for at least
15 samples in all of five repeats.
**Asserted:**
- the cost agrees within 1%;
- the hits are byte-identical in every order.
**Recorded, not asserted:** each search's peak and the stage peak.
Locally:
| order | ASV peak | OTU peak | stage peak (KiB) | Julia allocation
(bytes) |
|---|---|---|---|---|
| ASV then OTU | 9204 | 9180 | 9204 | 978432 |
| OTU then ASV | 9152 | 9192 | 9192 | 978720 |
| concurrent | 9176 | 9204 | **18380** | 978816 |
The cost spread is 0.04%. The concurrent peak is twice the serial one.
The prediction would have been falsified had the concurrent peak matched
the serial one; it did not.
**One finding, not changed here.** The test runs vsearch with `--threads
1`. With several threads, vsearch's output order varies from run to run,
and `_sort_hits_by_query!` then does a different amount of work, because
its `by=` key splits each line on every comparison. That moved the
stage's allocation by up to 12% between repeats of the *same* order.
Precomputing the sort key would make production cost order-independent
too.
The file also covers:
- the flag off and on through the real `_attest`;
- a failed stage, which keeps its occupancy and still rethrows;
- old records, which still parse and merge;
- a 64 MiB child against a `sleep` control;
- chained commands and an instantly exiting child.
## Verification
Run locally with Julia 1.12.5 `-t 2` and vsearch 2.30.5 (the pinned
version):
- `julia --project=. -t 2 test/occupancy_disagreement_test.jl`: 46/46.
- `test/runtests.jl provenance tools routes jobs config_hashing`: 547
passed, 10 skipped (`@test_skip`). One error: the live R probe, which
needs the dada2 R package that CI installs and this machine lacks.
- Five planted mutants are each caught by a failing test:
- children ignored;
- flag ignored;
- key written with the flag off;
- stage peak computed as the sum of the task peaks;
- extra work in one order (cost spread 0.249).
## What this leaves out
- Typed peaks, Session IR and the dated table: part 2, stacked on this
branch.
- The open bridge that the typed peak bounds the measured peak:
occupancy-types, OPEN.
- Memory on the R side.
- Stages offloaded with `remote:`, where the local child is an ssh
client.
- Non-Linux hosts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015pMjy981ie74uCttyYDanC
---
_Generated by [Claude
Code](https://claude.ai/code/session_015pMjy981ie74uCttyYDanC)_
Co-authored-by: Claude Opus 5.5
---
config/defaults/pipeline.yml | 15 ++
src/MetaManifold.jl | 2 +
src/core/memory_probe.jl | 343 ++++++++++++++++++++++++++++
src/core/provenance.jl | 10 +-
src/pipeline/tools.jl | 6 +
src/server/routes/pipeline.jl | 34 ++-
test/occupancy_disagreement_test.jl | 320 ++++++++++++++++++++++++++
test/runtests.jl | 6 +
8 files changed, 727 insertions(+), 9 deletions(-)
create mode 100644 src/core/memory_probe.jl
create mode 100644 test/occupancy_disagreement_test.jl
diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml
index 11198e7a..6f50ed68 100644
--- a/config/defaults/pipeline.yml
+++ b/config/defaults/pipeline.yml
@@ -354,3 +354,18 @@ analysis:
# 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
+
+## Occupancy probe (MMW-01)
+# Measures each attested stage's memory: Julia's allocation total, and the
+# sampled resident memory of the tools it runs as child processes (Linux /proc).
+# Recorded under the stage's `occupancy` key in attestation.yml. See the
+# header of src/core/memory_probe.jl.
+#
+# Off unless set, and deliberately left commented out here: the run config is
+# embedded in attestation.yml, so a default key would change every run's record
+# even with the probe off. Set it by editing a pipeline.yml (the config editor
+# only offers keys that have a factory default).
+# occupancy_probe:
+# enabled: true
+# # Sampling interval in milliseconds (at least 1).
+# interval_ms: 10
diff --git a/src/MetaManifold.jl b/src/MetaManifold.jl
index e3c70714..31db2a5c 100644
--- a/src/MetaManifold.jl
+++ b/src/MetaManifold.jl
@@ -5,6 +5,8 @@ include("core/types.jl")
include("core/r_runtime.jl")
# Provenance probes R through the shared runtime lock, so it follows r_runtime.jl.
include("core/provenance.jl")
+# Tools registers the children it starts with the probe, so it precedes pipeline/.
+include("core/memory_probe.jl")
include("core/log.jl")
include("core/config.jl")
include("core/databases.jl")
diff --git a/src/core/memory_probe.jl b/src/core/memory_probe.jl
new file mode 100644
index 00000000..6406c37a
--- /dev/null
+++ b/src/core/memory_probe.jl
@@ -0,0 +1,343 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+
+module MemoryProbe
+
+## Occupancy probe
+# Measures what an attested stage occupied, for the stage's Attestation record:
+#
+# * Julia-side allocation, as the `Base.gc_num()` delta over the stage. This is
+# a cost: it only ever adds up, whatever order the stage's work ran in.
+# * Each child process's resident memory, sampled from /proc. The tools run as
+# child processes (vsearch, swarm, cd-hit, ...), so `Sys.maxrss()` of this
+# process never sees them.
+#
+# Two figures come from the samples. A task's `peak_rss_kib` is the largest VmRSS
+# sampled for the processes that task started. The stage's `child_peak_rss_kib` is
+# the largest SUM of VmRSS over every process of the stage at one sampling
+# instant. For two tasks run one after the other it is the larger of their peaks;
+# for two that run concurrently it can approach their sum. That order dependence,
+# invisible in every output file, is what the measurement exists to show.
+#
+# `hwm_kib` is the kernel's own high-water mark (VmHWM) for a task's processes, as
+# last read before they exited. It is exact up to the last sample, so it is at
+# least `peak_rss_kib`; growth after the last sample is not seen. A process that
+# lives for less than one interval may not be sampled at all, which `samples: 0`
+# reports rather than a misleading zero peak.
+#
+# The probe is off unless the run config sets `occupancy_probe.enabled: true`.
+# Off, `start` returns `nothing` and every other entry point is a pass-through,
+# so the stage records and the outputs are exactly what they were without it.
+#
+# Linux only (/proc). Elsewhere, and for stages offloaded with `remote:` (where
+# the local child is an ssh client), the child figures do not describe the work.
+# The allocation figure is process-wide: while the study loop runs several runs'
+# pipelines at once, a stage's figure includes the others' allocations too. The
+# child figures are not affected, since each stage samples only its own children.
+
+using Base.ScopedValues: ScopedValue, with
+using OrderedCollections: OrderedDict
+
+const PROBE = "MemoryProbe 1"
+const DEFAULT_INTERVAL_MS = 10
+
+# One labelled unit of a stage's work (one spawned search, say) and the child
+# processes it started.
+mutable struct TaskTrace
+ label :: String
+ live :: Vector{Int} # pids started and not yet waited for
+ processes :: Int # every child started under this label
+ samples :: Int # samples that found at least one live process
+ peak_kib :: Int
+ hwm_kib :: Int
+end
+TaskTrace(label::AbstractString) = TaskTrace(String(label), Int[], 0, 0, 0, 0)
+
+mutable struct Probe
+ interval :: Float64
+ lock :: ReentrantLock
+ tasks :: Vector{TaskTrace}
+ stage :: TaskTrace # children started outside any `task`
+ samples :: Int
+ peak_kib :: Int
+ gc_before :: Base.GC_Num
+ live_before :: Int
+ running :: Bool
+ sampler :: Union{Task,Nothing}
+ timer :: Union{Timer,Nothing}
+ record :: Union{OrderedDict{String,Any},Nothing}
+ error :: Union{String,Nothing} # why sampling stopped early, if it did
+ buf :: Vector{UInt8} # /proc reads; preallocated so a sample
+ path :: Vector{UInt8} # allocates nothing that would count as work
+end
+
+const STAGE = ScopedValue{Union{Probe,Nothing}}(nothing)
+const TASK = ScopedValue{Union{TaskTrace,Nothing}}(nothing)
+
+## Configuration
+_section(cfg) = cfg isa AbstractDict ? get(cfg, "occupancy_probe", nothing) : nothing
+
+"""
+ enabled(run_cfg) -> Bool
+
+Whether the run config turns the probe on: `occupancy_probe.enabled: true`. Any
+other value, or no `occupancy_probe` section at all, leaves it off.
+"""
+function enabled(cfg)
+ s = _section(cfg)
+ s isa AbstractDict && get(s, "enabled", false) === true
+end
+
+"""
+ interval_seconds(run_cfg) -> Float64
+
+The sampling interval, from `occupancy_probe.interval_ms` (default 10 ms, at
+least 1 ms).
+"""
+function interval_seconds(cfg)
+ s = _section(cfg)
+ ms = s isa AbstractDict ? get(s, "interval_ms", DEFAULT_INTERVAL_MS) : DEFAULT_INTERVAL_MS
+ (ms isa Real && !(ms isa Bool) && ms >= 1) || (ms = DEFAULT_INTERVAL_MS)
+ Float64(ms) / 1000
+end
+
+## Reading /proc without allocating
+# "/proc//status" into `path`, NUL-terminated.
+function _status_path!(path::Vector{UInt8}, pid::Int)
+ k = 0
+ for c in codeunits("/proc/"); k += 1; path[k] = c; end
+ nd = ndigits(pid); d = pid
+ for i in nd:-1:1
+ path[k + i] = UInt8('0') + UInt8(d % 10); d ÷= 10
+ end
+ k += nd
+ for c in codeunits("/status"); k += 1; path[k] = c; end
+ path[k + 1] = 0x00
+ nothing
+end
+
+# Bytes of /proc//status read into `buf`, or -1 if the process is gone.
+function _read_status!(buf::Vector{UInt8}, path::Vector{UInt8}, pid::Int)
+ _status_path!(path, pid)
+ fd = GC.@preserve path ccall(:open, Cint, (Ptr{UInt8}, Cint), path, 0)
+ fd < 0 && return -1
+ total = 0
+ GC.@preserve buf while total < length(buf)
+ r = ccall(:read, Cssize_t, (Cint, Ptr{UInt8}, Csize_t),
+ fd, pointer(buf, total + 1), length(buf) - total)
+ r <= 0 && break
+ total += r
+ end
+ ccall(:close, Cint, (Cint,), fd)
+ total
+end
+
+# The kB value of the line starting `key` (6 bytes, e.g. "VmRSS:"), or -1.
+function _field_kib(buf::Vector{UInt8}, n::Int, key::NTuple{6,UInt8})
+ i = 1
+ while i <= n - 6
+ if (i == 1 || buf[i - 1] == UInt8('\n')) &&
+ buf[i] == key[1] && buf[i + 1] == key[2] && buf[i + 2] == key[3] &&
+ buf[i + 3] == key[4] && buf[i + 4] == key[5] && buf[i + 5] == key[6]
+ j = i + 6
+ while j <= n && (buf[j] == UInt8(' ') || buf[j] == UInt8('\t')); j += 1; end
+ v = 0
+ while j <= n && UInt8('0') <= buf[j] <= UInt8('9')
+ v = 10v + Int(buf[j] - UInt8('0')); j += 1
+ end
+ return v
+ end
+ i += 1
+ end
+ -1
+end
+
+const VMRSS = Tuple(codeunits("VmRSS:"))
+const VMHWM = Tuple(codeunits("VmHWM:"))
+
+## Sampling
+# One sample: each live child's VmRSS and VmHWM, summed per task and over the stage.
+function _sample!(p::Probe)
+ lock(p.lock) do
+ total = 0
+ for t in p.tasks
+ total += _sample_task!(p, t)
+ end
+ total += _sample_task!(p, p.stage)
+ p.samples += 1
+ p.peak_kib = max(p.peak_kib, total)
+ end
+ nothing
+end
+
+function _sample_task!(p::Probe, t::TaskTrace)
+ isempty(t.live) && return 0
+ rss = 0; seen = false
+ for pid in t.live
+ n = _read_status!(p.buf, p.path, pid)
+ n > 0 || continue
+ r = _field_kib(p.buf, n, VMRSS)
+ r < 0 && continue # a zombie has no memory lines
+ seen = true
+ rss += r
+ t.hwm_kib = max(t.hwm_kib, _field_kib(p.buf, n, VMHWM))
+ end
+ seen || return 0
+ t.samples += 1
+ t.peak_kib = max(t.peak_kib, rss)
+ rss
+end
+
+"""
+ start(run_cfg) -> Union{Probe,Nothing}
+
+A running probe for one stage, or `nothing` when the run config does not enable
+it. Pass the result to `within` and then `finish!`.
+"""
+function start(cfg)
+ enabled(cfg) || return nothing
+ p = Probe(interval_seconds(cfg), ReentrantLock(), TaskTrace[], TaskTrace("(stage)"),
+ 0, 0, Base.gc_num(), Base.gc_live_bytes(), true, nothing, nothing, nothing,
+ nothing, Vector{UInt8}(undef, 8192), Vector{UInt8}(undef, 64))
+ p.timer = Timer(0; interval = p.interval)
+ timer = p.timer
+ # A probe must never fail the stage it measures: a sampling error ends the
+ # sampling and is reported in the record instead.
+ p.sampler = Threads.@spawn begin
+ try
+ while p.running
+ _sample!(p)
+ isopen(timer) || break
+ wait(timer)
+ end
+ catch err
+ (err isa EOFError && !p.running) || (p.error = sprint(showerror, err))
+ end
+ end
+ p
+end
+
+"""
+ within(f, probe)
+
+Run `f()` with `probe` as the current stage's probe, so the children it starts
+(directly or from tasks it spawns) are sampled. With `probe === nothing` this is
+just `f()`.
+"""
+within(f, ::Nothing) = f()
+within(f, p::Probe) = with(f, STAGE => p)
+
+"""
+ task(f, label)
+
+Run `f()` as one labelled unit of the current stage. Children it starts are
+reported under `label`. Outside a probed stage this is just `f()`.
+"""
+function task(f, label::AbstractString)
+ p = STAGE[]
+ p === nothing && return f()
+ t = TaskTrace(label)
+ lock(() -> push!(p.tasks, t), p.lock)
+ with(f, TASK => t)
+end
+
+# The trace a child started now belongs to, or nothing when nothing is probed.
+function _current()
+ p = STAGE[]
+ p === nothing && return nothing, nothing
+ t = TASK[]
+ p, something(t, p.stage)
+end
+
+"""
+ watch!(process) -> Union{Int,Nothing}
+
+Register a just-started child process with the current stage's probe, sample it
+at once, and return its pid for `unwatch!`. Called by `Tools._run_killable`.
+Returns `nothing`, having done nothing, when the stage is not probed. A child that
+has already exited is counted but cannot be sampled. Never throws: a probe must
+not fail the stage it measures.
+"""
+function watch!(proc::Base.Process)
+ p, t = _current()
+ p === nothing && return nothing
+ # `getpid` throws once the child has exited, so the pid is read now or never.
+ pid = try
+ Int(getpid(proc))
+ catch
+ nothing
+ end
+ lock(p.lock) do
+ t.processes += 1
+ pid === nothing || push!(t.live, pid)
+ end
+ pid === nothing || _sample!(p)
+ pid
+end
+
+# A pipeline of commands: each of its processes, as one task's children.
+function watch!(chain::Base.ProcessChain)
+ pids = Int[]
+ for proc in chain.processes
+ pid = watch!(proc)
+ pid === nothing || push!(pids, pid)
+ end
+ isempty(pids) ? nothing : pids
+end
+
+# Anything else `run` can return is not sampled.
+watch!(_) = nothing
+
+"""
+ unwatch!(pid)
+
+Stop sampling a child that has been waited for (the pid `watch!` returned), so
+the pid cannot be mistaken for a later, unrelated process. A no-op for `nothing`.
+"""
+unwatch!(::Nothing) = nothing
+unwatch!(pids::Vector{Int}) = foreach(unwatch!, pids)
+function unwatch!(pid::Int)
+ p, t = _current()
+ p === nothing && return nothing
+ lock(p.lock) do
+ filter!(!=(pid), t.live)
+ end
+ nothing
+end
+
+_task_record(t::TaskTrace) = OrderedDict{String,Any}(
+ "label" => t.label,
+ "processes" => t.processes,
+ "samples" => t.samples,
+ "peak_rss_kib" => t.peak_kib,
+ "hwm_kib" => t.hwm_kib,
+)
+
+"""
+ finish!(probe) -> Union{OrderedDict,Nothing}
+
+Stop sampling and return the stage's `occupancy` record (the same record on every
+later call). `nothing` for `nothing`.
+"""
+finish!(::Nothing) = nothing
+function finish!(p::Probe)
+ p.record === nothing || return p.record
+ p.running = false
+ close(p.timer)
+ wait(p.sampler)
+ allocated = Base.GC_Diff(Base.gc_num(), p.gc_before).allocd
+ traces = [p.tasks; p.stage.processes > 0 ? [p.stage] : TaskTrace[]]
+ p.record = OrderedDict{String,Any}(
+ "probe" => PROBE,
+ "interval_ms" => round(Int, p.interval * 1000),
+ "samples" => p.samples,
+ "julia_allocated_bytes" => allocated,
+ "julia_live_bytes_delta" => Base.gc_live_bytes() - p.live_before,
+ "child_peak_rss_kib" => p.peak_kib,
+ "tasks" => Any[_task_record(t) for t in traces],
+ )
+ p.error === nothing || (p.record["error"] = p.error)
+ p.record
+end
+
+end # module MemoryProbe
diff --git a/src/core/provenance.jl b/src/core/provenance.jl
index aa232c06..b28912d4 100644
--- a/src/core/provenance.jl
+++ b/src/core/provenance.jl
@@ -818,11 +818,15 @@ _stage_index(att::Attestation, name::AbstractString) =
findfirst(s -> get(s, "name", nothing) == String(name), stages(att))
"""
- record_stage!(att, name, env; started, finished, status, commands, outputs) -> Attestation
+ record_stage!(att, name, env; started, finished, status, commands, outputs, occupancy) -> Attestation
Record one stage's own captured environment, replacing any earlier record of that
stage.
+`occupancy` is the stage's `MemoryProbe` record. It is stored under the stage's
+`occupancy` key only when given, so a stage recorded without the probe has
+exactly the keys it always had.
+
Re-running a single stage of an existing run must update that stage alone, because
a run directory is an accretion and the other stages' records remain true. The
run-level summary is re-derived on every call.
@@ -832,7 +836,8 @@ function record_stage!(att::Attestation, name::AbstractString, env::CapturedEnvi
finished::AbstractString = timestamp(),
status::AbstractString = "ok",
commands::AbstractVector = String[],
- outputs::AbstractVector = OrderedDict{String,Any}[])
+ outputs::AbstractVector = OrderedDict{String,Any}[],
+ occupancy::Union{AbstractDict,Nothing} = nothing)
stage = OrderedDict{String,Any}(
"name" => String(name),
"started" => String(started),
@@ -847,6 +852,7 @@ function record_stage!(att::Attestation, name::AbstractString, env::CapturedEnvi
"outputs" => Any[OrderedDict{String,Any}(string(k) => v for (k, v) in o)
for o in outputs],
)
+ isnothing(occupancy) || (stage["occupancy"] = occupancy)
index = _stage_index(att, name)
isnothing(index) ? push!(stages(att), stage) : (stages(att)[index] = stage)
return summarise!(att)
diff --git a/src/pipeline/tools.jl b/src/pipeline/tools.jl
index d8e12062..ac212228 100644
--- a/src/pipeline/tools.jl
+++ b/src/pipeline/tools.jl
@@ -14,6 +14,7 @@ export cutadapt, vsearch, multiqc, cdhit, tool_bin, _sq, _run_logged, _run_killa
using ..PipelineTypes
using ..PipelineLog
using ..Config
+ import ..MemoryProbe
_sq(s::AbstractString) = "'" * replace(s, "'" => "'\\''") * "'"
@@ -24,13 +25,18 @@ export cutadapt, vsearch, multiqc, cdhit, tool_bin, _sq, _run_logged, _run_killa
# Truncation is the stage's own affair, done once at entry via reset_tool_logs.
# Run a command and wait for it. If the waiting task is interrupted (a job
# cancel), the child process is killed so it cannot keep writing outputs.
+ # When the stage is probed (occupancy_probe.enabled), the child's memory is
+ # sampled while it runs; otherwise watch!/unwatch! do nothing.
function _run_killable(cmd::Base.AbstractCmd)
p = run(cmd; wait=false)
+ watched = MemoryProbe.watch!(p)
try
wait(p)
catch
process_running(p) && kill(p)
rethrow()
+ finally
+ MemoryProbe.unwatch!(watched)
end
success(p) || throw(ProcessFailedException(p))
nothing
diff --git a/src/server/routes/pipeline.jl b/src/server/routes/pipeline.jl
index 306d8c1a..ee1bdedd 100644
--- a/src/server/routes/pipeline.jl
+++ b/src/server/routes/pipeline.jl
@@ -9,6 +9,7 @@
using JSON3, RCall
using ..RRuntime: with_r_lock
const Provenance = MetaManifold.Provenance
+const MemoryProbe = MetaManifold.MemoryProbe
## Tagging helpers
@@ -180,17 +181,26 @@ end
#
# A stage that skipped leaves its earlier record alone: writing the current
# environment over it would attribute old outputs to new tool versions.
+#
+# With `occupancy_probe.enabled: true` in the run config, the stage's memory is
+# measured (MemoryProbe) and recorded under the stage's `occupancy` key. Off, the
+# probe is `nothing` and the record is exactly what it was without it.
function _attest(f::Function, att, env, name::AbstractString; config_path::String)
started = Provenance.timestamp()
ran_before = stage_run_count(config_path)
+ probe = MemoryProbe.start(get(get(att.doc, "config", Dict()), "run_config", nothing))
try
- result = f()
+ result = MemoryProbe.within(f, probe)
stage_run_count(config_path) > ran_before &&
- Provenance.record_stage!(att, name, env; started, outputs=_stage_outputs(result))
+ Provenance.record_stage!(att, name, env; started, outputs=_stage_outputs(result),
+ occupancy=MemoryProbe.finish!(probe))
return result
catch
- Provenance.record_stage!(att, name, env; started, status="failed")
+ Provenance.record_stage!(att, name, env; started, status="failed",
+ occupancy=MemoryProbe.finish!(probe))
rethrow()
+ finally
+ MemoryProbe.finish!(probe)
end
end
@@ -261,9 +271,13 @@ function _run_full_pipeline(study::String, run::String, db_config::String,
# OTU search it claims to cover.
asv_tax, otu_tax = if vsearch_enabled
_attest(att, env, "vsearch"; config_path) do
- asv_task = Threads.@spawn(vsearch(project, asvs, dbs["$(db_name)_vsearch"]))
+ asv_task = Threads.@spawn MemoryProbe.task("vsearch:asv") do
+ vsearch(project, asvs, dbs["$(db_name)_vsearch"])
+ end
otu_task = (swarm_enabled && !isnothing(otus)) ?
- Threads.@spawn(vsearch(project, otus, dbs["$(db_name)_vsearch"])) : nothing
+ Threads.@spawn(MemoryProbe.task("vsearch:otu") do
+ vsearch(project, otus, dbs["$(db_name)_vsearch"])
+ end) : nothing
# Wait for both before rethrowing, so a failed ASV search cannot leave the OTU search running.
tasks = filter(!isnothing, Any[asv_task, otu_task])
foreach(t -> (try wait(t) catch end), tasks)
@@ -438,12 +452,18 @@ function _run_stage(study::String, run::String, stage::String,
end
elseif step == "vsearch"
if vsearch_enabled
+ # Serial here, concurrent in the full pipeline: the same two
+ # searches in the two shapes the occupancy probe distinguishes.
_record("vsearch") do
asvs = _asvresult_from_disk(run_dir)
- vsearch(project, asvs, dbs["$(db_name)_vsearch"])
+ MemoryProbe.task("vsearch:asv") do
+ vsearch(project, asvs, dbs["$(db_name)_vsearch"])
+ end
otus = _oturesult_from_disk(run_dir)
if !isnothing(otus) && swarm_enabled
- vsearch(project, otus, dbs["$(db_name)_vsearch"])
+ MemoryProbe.task("vsearch:otu") do
+ vsearch(project, otus, dbs["$(db_name)_vsearch"])
+ end
end
end
end
diff --git a/test/occupancy_disagreement_test.jl b/test/occupancy_disagreement_test.jl
new file mode 100644
index 00000000..b609ed38
--- /dev/null
+++ b/test/occupancy_disagreement_test.jl
@@ -0,0 +1,320 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# MMW-01: the occupancy probe, and the two-thread disagreement test.
+#
+# The vsearch stage runs two independent searches, ASV and OTU, concurrently in
+# the full pipeline and one after the other in a single-stage re-run. The
+# high-water-mark algebra (occupancy-types, resource-algebra `Instances/HWM`)
+# predicts:
+#
+# * cost commutes: the work done, measured as Julia's allocation total for the
+# stage, is the same in every order (choreographic-types A-2). This is
+# ASSERTED, and it can fail.
+# * each search's own peak is the same in every order;
+# * the stage's peak does not commute: serially it is the larger of the two
+# searches' peaks (each child exits holding nothing, so its drift is 0), while
+# concurrently it can approach their sum (A-3). These are RECORDED in the test
+# log, not asserted.
+#
+# If the stage peak came out the same in every order, the prediction would be
+# falsified for this workload: the searches' memory would not overlap.
+#
+# The fixture is the smallest of a calibrated ladder (250, 500, 1000, 2000
+# reference sequences of 400 bp; 300 ASV and 150 OTU queries) for which, in five
+# repeats each on a 4-core host, both searches were resident together for at
+# least 15 samples at 10 ms and the concurrent stage peak was at least 1.9 times
+# the larger search's: 1000 sequences (17-20 samples, 2.0 times). 250 and 500
+# also overlap, by 12-18 samples, too close to the edge for a slower runner. It is
+# generated here, deterministically, so no data is checked in.
+#
+# vsearch runs with --threads 1 here. With several threads it writes its hits in
+# whatever order its threads finish, and `_sort_hits_by_query!` then does a
+# different amount of work (its sort key splits each line on every comparison),
+# which moved the stage's allocation total by up to 12% between repeats of the
+# SAME order. On one thread the output order is fixed, and the three orders
+# agreed to within 0.08% over five repeats each. Run directly with
+# julia --project=. -t 2 test/occupancy_disagreement_test.jl
+# and set OCCUPANCY_RESULTS= to also write the numbers as YAML.
+
+using Test, YAML, OrderedCollections
+using MetaManifold
+
+const MP = MetaManifold.MemoryProbe
+const PV = MetaManifold.Provenance
+const TL = MetaManifold.Tools
+
+const PROBE_ON = Dict("occupancy_probe" => Dict("enabled" => true, "interval_ms" => 10))
+
+# Relative spread of the stage's Julia allocation total across the three orders
+# that still counts as "the same cost". Measured: under 0.08% (see above).
+const COST_TOLERANCE = 0.01
+
+## Fixture
+# A 64-bit LCG, so the fixture is the same bytes on every Julia version (the
+# streams of Random's generators are not guaranteed to be).
+mutable struct _LCG
+ s :: UInt64
+end
+_next!(g::_LCG) = (g.s = g.s * 0x5851f42d4c957f2d + 0x14057b7ef767814f; g.s >> 33)
+
+const _NT = (UInt8('A'), UInt8('C'), UInt8('G'), UInt8('T'))
+_randseq(g, n) = String([_NT[_next!(g) % 4 + 1] for _ in 1:n])
+
+# A copy of `s` with about `per_1000` substitutions per thousand bases: close
+# enough to its reference (~96% identity) to pass vsearch's --id 0.75.
+function _mutate(g, s, per_1000)
+ b = Vector{UInt8}(s)
+ for i in eachindex(b)
+ _next!(g) % 1000 < per_1000 && (b[i] = _NT[_next!(g) % 4 + 1])
+ end
+ String(b)
+end
+
+function occupancy_fixture(dir; refs = 1000, asv = 300, otu = 150, len = 400)
+ g = _LCG(0x4d4d57)
+ mkpath(dir)
+ seqs = [_randseq(g, len) for _ in 1:refs]
+ open(joinpath(dir, "ref.fasta"), "w") do io
+ for (i, s) in enumerate(seqs)
+ println(io, ">ref$i;tax=k:K$(i % 7)")
+ println(io, s)
+ end
+ end
+ for (name, n) in (("asv", asv), ("otu", otu))
+ open(joinpath(dir, "$name.fasta"), "w") do io
+ for i in 1:n
+ println(io, ">$name$i")
+ println(io, _mutate(g, seqs[_next!(g) % refs + 1], 40))
+ end
+ end
+ end
+ dir
+end
+
+## Running the stage in one order
+function _search(dir, out, name)
+ TL.vsearch(joinpath(dir, "$name.fasta"), joinpath(dir, "ref.fasta"), joinpath(out, name);
+ optional_args = "--id 0.75 --query_cov 0.8 --threads 1",
+ vsearch_bin = something(Sys.which("vsearch"), "vsearch"))
+end
+
+const ORDERS = ("asv_then_otu", "otu_then_asv", "concurrent")
+
+# The stage's work in `order`, under a fresh probe. Returns the probe's record.
+function run_order(dir, out, order)
+ mkpath(out)
+ probe = MP.start(PROBE_ON)
+ MP.within(probe) do
+ if order == "asv_then_otu"
+ MP.task(() -> _search(dir, out, "asv"), "vsearch:asv")
+ MP.task(() -> _search(dir, out, "otu"), "vsearch:otu")
+ elseif order == "otu_then_asv"
+ MP.task(() -> _search(dir, out, "otu"), "vsearch:otu")
+ MP.task(() -> _search(dir, out, "asv"), "vsearch:asv")
+ else
+ # The shape of the full pipeline's vsearch stage.
+ a = Threads.@spawn MP.task(() -> _search(dir, out, "asv"), "vsearch:asv")
+ o = Threads.@spawn MP.task(() -> _search(dir, out, "otu"), "vsearch:otu")
+ fetch(a); fetch(o)
+ end
+ end
+ MP.finish!(probe)
+end
+
+_task(rec, label) = only(t for t in rec["tasks"] if t["label"] == label)
+
+@testset "Occupancy probe (MMW-01)" begin
+
+ @testset "off unless the run config enables it" begin
+ @test !MP.enabled(Dict())
+ @test !MP.enabled(Dict("occupancy_probe" => Dict()))
+ @test !MP.enabled(Dict("occupancy_probe" => Dict("enabled" => "true")))
+ @test !MP.enabled(Dict("occupancy_probe" => true))
+ @test MP.enabled(PROBE_ON)
+ @test MP.start(Dict()) === nothing
+ @test MP.finish!(nothing) === nothing
+ @test MP.within(() -> 42, nothing) == 42
+ @test MP.task(() -> 7, "outside a probe") == 7
+ @test MP.interval_seconds(Dict()) == 0.010
+ @test MP.interval_seconds(Dict("occupancy_probe" => Dict("interval_ms" => 25))) == 0.025
+ @test MP.interval_seconds(Dict("occupancy_probe" => Dict("interval_ms" => 0))) == 0.010
+ end
+
+ @testset "flag off: the stage record is unchanged" begin
+ # The keys a stage record had before the probe existed. With the probe
+ # off, `finish!` gives `nothing` and the record must be exactly these.
+ before = ["name", "started", "finished", "status", "degraded",
+ "degraded_components", "runtimes", "tools", "databases",
+ "commands", "outputs"]
+ att = PV.Attestation()
+ PV.record_stage!(att, "vsearch", PV.CapturedEnvironment();
+ occupancy = MP.finish!(MP.start(Dict())))
+ @test collect(keys(only(att.doc["stages"]))) == before
+
+ # Negative control: with the probe on, the key is there.
+ on = PV.Attestation()
+ PV.record_stage!(on, "vsearch", PV.CapturedEnvironment();
+ occupancy = MP.finish!(MP.start(PROBE_ON)))
+ @test collect(keys(only(on.doc["stages"]))) == [before; "occupancy"]
+ end
+
+ @testset "attestation round trip; older records still parse" begin
+ mktempdir() do tmp
+ att = PV.Attestation()
+ rec = MP.finish!(MP.start(PROBE_ON))
+ PV.record_stage!(att, "vsearch", PV.CapturedEnvironment(); occupancy = rec)
+ PV.record_stage!(att, "swarm", PV.CapturedEnvironment()) # an unprobed stage
+ path = joinpath(tmp, "attestation.yml")
+ PV.write_attestation(att, path)
+ back = PV.read_attestation(path)
+ stages = Dict(s["name"] => s for s in back.doc["stages"])
+ @test stages["vsearch"]["occupancy"]["probe"] == "MemoryProbe 1"
+ @test stages["vsearch"]["occupancy"]["interval_ms"] == 10
+ @test !haskey(stages["swarm"], "occupancy")
+
+ # A record written before the probe existed merges with a new one.
+ old = PV.Attestation()
+ PV.record_stage!(old, "cutadapt", PV.CapturedEnvironment())
+ PV.write_attestation(old, path; merge_existing = false)
+ PV.write_attestation(att, path)
+ merged = Dict(s["name"] => s for s in PV.read_attestation(path).doc["stages"])
+ @test !haskey(merged["cutadapt"], "occupancy")
+ @test haskey(merged["vsearch"], "occupancy")
+ end
+ end
+
+ if !Sys.islinux()
+ @info "Occupancy probe: not Linux, so there is no /proc to sample; the child-memory tests are SKIPPED"
+ else
+ @testset "samples a child's resident memory" begin
+ probe = MP.start(PROBE_ON)
+ MP.within(probe) do
+ # bash holds a 64 MiB string for half a second.
+ MP.task("64MiB") do
+ TL._run_killable(`bash -c 'x=$(head -c 67108864 /dev/zero | tr "\0" a); sleep 0.5; : "${#x}"'`)
+ end
+ # Negative control: a child that allocates nothing.
+ MP.task("sleep") do
+ TL._run_killable(`sleep 0.5`)
+ end
+ end
+ rec = MP.finish!(probe)
+ big, small = _task(rec, "64MiB"), _task(rec, "sleep")
+ @test big["processes"] == 1 && big["samples"] > 0
+ @test big["peak_rss_kib"] >= 64 * 1024
+ @test big["hwm_kib"] >= big["peak_rss_kib"]
+ @test small["samples"] > 0
+ @test 0 < small["peak_rss_kib"] < 16 * 1024
+ # Serial: the stage peak is the larger task's, not the sum.
+ @test rec["child_peak_rss_kib"] < big["peak_rss_kib"] + small["peak_rss_kib"]
+ @test !haskey(rec, "error")
+ end
+
+ @testset "through the pipeline's _attest, flag off and on" begin
+ SV = MetaManifold.Server
+ mktempdir() do tmp
+ cfg_path = joinpath(tmp, "run_config.yml")
+ for probed in (false, true)
+ cfg = Dict{String,Any}("vsearch" => Dict("enabled" => true))
+ probed && (cfg["occupancy_probe"] = Dict("enabled" => true))
+ YAML.write_file(cfg_path, cfg)
+ att = PV.Attestation(; config = cfg, config_sha256 = PV.file_sha256(cfg_path))
+ SV._attest(att, PV.CapturedEnvironment(), "vsearch"; config_path = cfg_path) do
+ MetaManifold.Config._begin_section(cfg_path, "vsearch", joinpath(tmp, "vsearch.hash"))
+ MP.task(() -> TL._run_killable(`sleep 0.2`), "vsearch:asv")
+ nothing
+ end
+ stage = only(att.doc["stages"])
+ @test stage["status"] == "ok"
+ @test haskey(stage, "occupancy") == probed
+ probed && @test only(stage["occupancy"]["tasks"])["label"] == "vsearch:asv"
+
+ # A stage that throws is recorded as failed, with its
+ # occupancy when probed, and the error still propagates.
+ failed = PV.Attestation(; config = cfg, config_sha256 = PV.file_sha256(cfg_path))
+ @test_throws ProcessFailedException SV._attest(failed, PV.CapturedEnvironment(), "swarm";
+ config_path = cfg_path) do
+ TL._run_killable(`false`)
+ end
+ stage = only(failed.doc["stages"])
+ @test stage["status"] == "failed"
+ @test haskey(stage, "occupancy") == probed
+ end
+ end
+ end
+
+ @testset "a pipeline of commands counts each process" begin
+ probe = MP.start(PROBE_ON)
+ MP.within(() -> MP.task(() -> TL._run_killable(pipeline(`sleep 0.3`, `cat`)), "chain"), probe)
+ t = _task(MP.finish!(probe), "chain")
+ @test t["processes"] == 2
+ @test t["samples"] > 0
+ end
+
+ @testset "a child that exits at once is counted, not sampled" begin
+ probe = MP.start(PROBE_ON)
+ MP.within(() -> MP.task(() -> TL._run_killable(`true`), "instant"), probe)
+ t = _task(MP.finish!(probe), "instant")
+ @test t["processes"] == 1
+ @test t["samples"] == 0 || t["peak_rss_kib"] > 0
+ end
+
+ vsearch_bin = Sys.which("vsearch")
+ if isnothing(vsearch_bin)
+ @info "vsearch not on PATH - the two-thread disagreement test is SKIPPED"
+ else
+ @testset "two-thread disagreement: vsearch ASV and OTU in three orders" begin
+ mktempdir() do tmp
+ dir = occupancy_fixture(joinpath(tmp, "fixture"))
+ # Warm-up: compile every path once, so no order pays for it.
+ for order in ORDERS
+ run_order(dir, joinpath(tmp, "warmup", order), order)
+ end
+ GC.gc()
+ records = OrderedDict{String,Any}()
+ for order in ORDERS
+ GC.gc()
+ records[order] = run_order(dir, joinpath(tmp, order), order)
+ end
+
+ # The same work: identical hits in every order.
+ for name in ("asv", "otu")
+ hits = [read(joinpath(tmp, o, name, "taxonomy.tsv"), String) for o in ORDERS]
+ @test !isempty(first(hits))
+ @test allequal(hits)
+ end
+
+ # Cost commutes (asserted).
+ cost = [records[o]["julia_allocated_bytes"] for o in ORDERS]
+ spread = (maximum(cost) - minimum(cost)) / maximum(cost)
+ @test spread <= COST_TOLERANCE
+
+ # Peaks (recorded, not asserted).
+ for o in ORDERS
+ r = records[o]
+ a, u = _task(r, "vsearch:asv"), _task(r, "vsearch:otu")
+ @info "occupancy" order = o julia_allocated_bytes = r["julia_allocated_bytes"] julia_live_bytes_delta = r["julia_live_bytes_delta"] child_peak_rss_kib = r["child_peak_rss_kib"] asv_peak_rss_kib = a["peak_rss_kib"] asv_hwm_kib = a["hwm_kib"] otu_peak_rss_kib = u["peak_rss_kib"] otu_hwm_kib = u["hwm_kib"] samples = r["samples"]
+ end
+ conc = records["concurrent"]
+ serial_max = maximum(records[o]["child_peak_rss_kib"] for o in ORDERS[1:2])
+ overlap = conc["child_peak_rss_kib"] > serial_max
+ @info "occupancy: concurrent stage peak vs the larger serial stage peak" concurrent = conc["child_peak_rss_kib"] serial = serial_max overlapped = overlap cost_spread = round(spread; sigdigits = 3)
+ overlap || @warn "occupancy: the concurrent searches did not overlap in memory on this runner, so this run cannot show the order dependence"
+
+ path = get(ENV, "OCCUPANCY_RESULTS", "")
+ if !isempty(path)
+ io = IOBuffer()
+ run(pipeline(`$vsearch_bin --version`; stdout = io, stderr = io))
+ version = first(split(String(take!(io)), '\n'))
+ YAML.write_file(path, OrderedDict{String,Any}(
+ "vsearch" => version,
+ "julia" => string(VERSION), "threads" => Threads.nthreads(),
+ "cpus" => Sys.CPU_THREADS, "cost_spread" => spread,
+ "orders" => records))
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/test/runtests.jl b/test/runtests.jl
index 4a8f47fa..a406baa1 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -83,6 +83,12 @@ using MetaManifold.Categories, MetaManifold.CompositionLibrary
include(joinpath(@__DIR__, "unit", f))
end
+ ## Occupancy probe and the two-thread disagreement test (MMW-01), with the
+ ## full suite. Its vsearch part skips, saying so, when vsearch is not on PATH.
+ if isempty(filter(a -> !startswith(a, "--"), ARGS))
+ include("occupancy_disagreement_test.jl")
+ end
+
## Integration tests (opt-in)
if RUN_INTEGRATION
using MetaManifold.DADA2, MetaManifold.OTUPipeline
From 4dc19c05fd01963a21d53c39417896745c476cc1 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 02:46:01 +0100
Subject: [PATCH 02/45] docs(occupancy): dated reading of the vsearch stage,
with a gate (#107)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
MMW-01, part 2 of 2. Stacked on hyperpolymath/MetaManifold-WebUI#106
(the probe and the test). This part is fork-side, about the estate's
proof siblings, and is not meant for the upstream PR.
## Summary
**`docs/occupancy-2026-10.adoc`** is the dated table (2026-10-14). For
each order it gives:
- each search's peak;
- the measured stage peak;
- the Session IR counted bytes;
- the HWM typed peak;
- the Julia allocation.
It also has a status ledger, the limits, and what the next proofs would
change.
| order | stage peak, measured | counted, Session IR | typed peak, HWM |
|---|---|---|---|
| ASV then OTU | 9204 | 9204 | 9204 |
| OTU then ASV | 9192 | 9192 | 9192 |
| concurrent | 18380 | 18380 | 18380 |
(KiB.)
**The pieces:**
- **`docs/occupancy/vsearch-*.sir`:** the stage as occupancy-types
Session IR, one buffer per search, in each order.
- **`docs/occupancy/TypedPeak.agda`:** the typed peak, computed by
`refl` with occupancy-types' `Timeline` over resource-algebra's HWM
grade.
- A search alone exits holding nothing (drift 0).
- The first concurrent search is still resident when the second starts
(drift = peak).
- `docs/occupancy/reject/` holds two claims that must be refused:
*concurrent = serial*, and *serial = sum*, which reads occupancy as cost
(R-6).
- **`scripts/fetch-siblings.sh`:** pins occupancy-types `af95728` and
resource-algebra `cf9235c` (the CONTEXT pins) into
`_build/dependencies`. Their definitions are consumed, never copied.
occupancy-types type-checks against both resource-algebra `cf9235c` and
its own pin `b2c9f1f`.
- **`scripts/check-occupancy.sh`:** the gate. It runs, in order:
- Session IR `check` and `run --trace`;
- `TypedPeak.agda`;
- the two rejections, each required to fail at its own declaration;
- counted bytes against the typed peak, order by order.
- **`.github/workflows/occupancy.yml`:** runs the gate when these files
change, on any base branch, so this stacked PR is checked too. It uses
the digest-pinned Debian trixie image with Agda 2.6.4.3 that upstream's
proof job uses, plus `setup-julia` 1.12.5.
- **`docs/occupancy/results-2026-10-14.yml`:** the raw probe records
behind the table.
- **`.gitignore`:** unignores exactly these docs (`docs/*` is otherwise
kept local), and ignores `_build/` and `*.agdai`.
## Status rows
| ID | Claim | Status |
|---|---|---|
| MMW-01.1 | flag off ⇒ record unchanged; on ⇒ `occupancy`; old records
parse | TESTED (#106's test file) |
| MMW-01.2 | cost commutes for this workload | TESTED (asserted ≤ 1%) |
| MMW-01.3 | stage peak order-dependent, task peaks not (A-3) for this
workload | TESTED (recorded) |
| MMW-01.4 | counted bytes = typed peak for the three programs | TESTED
(gate) |
| bridge | counted bytes ≤ typed peak in general | OPEN (unchanged:
three instances, not a proof) |
## Verification
- `bash scripts/check-occupancy.sh` exits 0 locally. It ran with Agda
from the PyPI 2.6.4.3 wheel (which reports itself as 2.6.4.1) and Julia
1.12.5. CI runs the Debian 2.6.4.3 package.
- Negative controls, each failing the gate:
- one byte changed in a Session IR text (`18381 != 18380`);
- a true claim planted as a rejection ("invalid claim accepted");
- a rejection failing on a scope error ("failed for the wrong reason").
- `zizmor` finds nothing in the workflow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015pMjy981ie74uCttyYDanC
---
_Generated by [Claude
Code](https://claude.ai/code/session_015pMjy981ie74uCttyYDanC)_
Co-authored-by: Claude Opus 5.5
---
.github/workflows/occupancy.yml | 64 +++++++++
.gitignore | 7 +
docs/occupancy-2026-10.adoc | 125 ++++++++++++++++++
docs/occupancy/TypedPeak.agda | 45 +++++++
docs/occupancy/reject/ConcurrentIsSerial.agda | 14 ++
docs/occupancy/reject/SerialIsCost.agda | 14 ++
docs/occupancy/results-2026-10-14.yml | 63 +++++++++
docs/occupancy/vsearch-asv-then-otu.sir | 13 ++
docs/occupancy/vsearch-concurrent.sir | 12 ++
docs/occupancy/vsearch-otu-then-asv.sir | 9 ++
scripts/check-occupancy.sh | 69 ++++++++++
scripts/fetch-siblings.sh | 29 ++++
12 files changed, 464 insertions(+)
create mode 100644 .github/workflows/occupancy.yml
create mode 100644 docs/occupancy-2026-10.adoc
create mode 100644 docs/occupancy/TypedPeak.agda
create mode 100644 docs/occupancy/reject/ConcurrentIsSerial.agda
create mode 100644 docs/occupancy/reject/SerialIsCost.agda
create mode 100644 docs/occupancy/results-2026-10-14.yml
create mode 100644 docs/occupancy/vsearch-asv-then-otu.sir
create mode 100644 docs/occupancy/vsearch-concurrent.sir
create mode 100644 docs/occupancy/vsearch-otu-then-asv.sir
create mode 100755 scripts/check-occupancy.sh
create mode 100755 scripts/fetch-siblings.sh
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
new file mode 100644
index 00000000..a1c7136d
--- /dev/null
+++ b/.github/workflows/occupancy.yml
@@ -0,0 +1,64 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# The occupancy reading of the vsearch stage (docs/occupancy-2026-10.adoc):
+# Session IR's counted bytes against the typed peak computed in Agda from the
+# pinned proof siblings. Kept apart from ci.yml because it is about the docs and
+# their pinned siblings, not the application; it runs only when they change.
+name: Occupancy reading
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - docs/occupancy/**
+ - docs/occupancy-2026-10.adoc
+ - scripts/check-occupancy.sh
+ - scripts/fetch-siblings.sh
+ - .github/workflows/occupancy.yml
+ # Any base branch, so a PR stacked on another one is checked too.
+ pull_request:
+ paths:
+ - docs/occupancy/**
+ - docs/occupancy-2026-10.adoc
+ - scripts/check-occupancy.sh
+ - scripts/fetch-siblings.sh
+ - .github/workflows/occupancy.yml
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name != 'push' }}
+
+jobs:
+ typed-peak:
+ name: Occupancy typed peak
+ runs-on: ubuntu-24.04
+ # The Agda 2.6.4.3 Debian trixie packages, in a digest-pinned image, as the
+ # upstream proof job uses. The siblings need no standard library.
+ container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
+ timeout-minutes: 20
+ env:
+ DEBIAN_FRONTEND: noninteractive
+ steps:
+ # git before checkout, so the checkout is a repository and the siblings can be fetched.
+ - name: Install git and Agda
+ run: |
+ apt-get update -qq
+ apt-get install -y --no-install-recommends ca-certificates git agda-bin=2.6.4.3-1+b2
+ agda --version | grep -qx 'Agda version 2.6.4.3'
+
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ persist-credentials: false # the siblings are public; nothing here pushes
+
+ # The Session IR stepper is Julia (standard library only).
+ - name: Set up Julia
+ uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0
+ with:
+ version: "1.12.5"
+
+ - name: Counted bytes against typed peak
+ run: bash scripts/check-occupancy.sh
diff --git a/.gitignore b/.gitignore
index a6ed2b7d..1436ebab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -250,6 +250,9 @@ vignettes/*.pdf
## Documentation kept local except published release notes
docs/*
!docs/release-notes/
+# and the dated occupancy reading (MMW-01), whose table the gate checks
+!docs/occupancy-2026-10.adoc
+!docs/occupancy/
# translation temp files
@@ -332,3 +335,7 @@ LocalPreferences.toml
# Development server log (dev/dev.sh)
dev/server.log
+
+# Proof siblings fetched by scripts/fetch-siblings.sh, and Agda interface files
+/_build/
+*.agdai
diff --git a/docs/occupancy-2026-10.adoc b/docs/occupancy-2026-10.adoc
new file mode 100644
index 00000000..b7323e45
--- /dev/null
+++ b/docs/occupancy-2026-10.adoc
@@ -0,0 +1,125 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+= Occupancy of the vsearch stage
+:revdate: 2026-10-14
+
+The vsearch stage runs two independent searches, ASV and OTU. The full
+pipeline spawns them together; a single-stage re-run runs them one after the
+other. The high-water-mark (HWM) algebra of occupancy-types predicts that the
+stage's cost is the same in every order but its memory peak is not: serially
+the peak is the larger search's, concurrently it can be their sum
+(choreographic-types A-2 and A-3). This page records one measurement of that,
+on 2026-10-14, and checks it against the typed reading.
+
+== What was measured
+
+`MemoryProbe` (`src/core/memory_probe.jl`), off unless a pipeline.yml sets
+`occupancy_probe.enabled: true`, records for each attested stage:
+
+* `julia_allocated_bytes`: the `Base.gc_num()` delta over the stage (cost);
+* per task (one spawned search): the largest sampled VmRSS of the child
+ process it started (`peak_rss_kib`), and the kernel's own VmHWM for it
+ (`hwm_kib`);
+* `child_peak_rss_kib`: the largest sum of the stage's children's VmRSS at
+ one sampling instant (the stage's occupancy).
+
+The run: `test/occupancy_disagreement_test.jl`, which runs the same two searches
+in three orders under the probe, on a fixture it generates: 1000 reference
+sequences of 400 bp, 300 ASV and 150 OTU queries. Linux 6.18 with 4 CPUs,
+Julia 1.12.5 with 2 threads, vsearch 2.30.5 with `--threads 1`. Sampling
+interval 10 ms.
+
+== Results
+
+[cols="2,1,1,1,1,1,1",options="header"]
+|===
+| Order | ASV peak (KiB) | OTU peak (KiB) | Stage peak, measured (KiB) | Counted bytes, Session IR | Typed peak, HWM | Julia allocation (bytes)
+| ASV then OTU | 9204 | 9180 | 9204 | 9204 | 9204 | 978432
+| OTU then ASV | 9152 | 9192 | 9192 | 9192 | 9192 | 978720
+| Concurrent | 9176 | 9204 | 18380 | 18380 | 18380 | 978816
+|===
+
+* *Cost commutes.* The allocation totals agree to 0.04%. The test asserts
+ agreement within 1%; a planted extra search in one order breaks it (spread
+ 25%).
+* *Each search's peak is the same in every order*, to within 0.6%
+ (9152-9204 KiB). Each search's VmHWM equals its sampled peak, so the
+ sampling caught the peaks.
+* *The stage peak is not the same.* Serially it is the larger search's
+ (9192-9204 KiB); concurrently it is 18380 KiB, twice that. This is the A-3
+ order dependence, and nothing in the stage's outputs shows it: the hits are
+ byte-identical in all three orders. The prediction would have been falsified
+ had the concurrent peak matched the serial one; it did not.
+* *Counted bytes equal the typed peak*, in all three orders. The counted bytes
+ come from occupancy-types' Session IR stepper running
+ `docs/occupancy/vsearch-*.sir`, one buffer per search. The typed peak is
+ computed, by `refl`, from occupancy-types' `Timeline` over resource-algebra's
+ HWM grade (`docs/occupancy/TypedPeak.agda`). A search alone exits holding
+ nothing (drift 0); in the concurrent order the first is still resident
+ when the second starts (drift = peak). The sequence law
+ `max (peak a) (drift a + peak b)` then gives the larger peak serially and the
+ sum concurrently.
+* *The measured stage peak also equals the typed peak* here. For the concurrent
+ order the typed peak is the interleaving in which both are resident at once;
+ a measurement could come in lower if the two searches' peaks did not
+ coincide. Here they did: each search's memory plateaus once its database is
+ loaded.
+
+== Status
+
+[cols="1,3,1,2",options="header"]
+|===
+| ID | Claim | Status | Artefact
+| MMW-01.1 | With the flag off, a stage record has exactly its earlier keys; with it on, it gains `occupancy`; old records still parse and merge | TESTED | `test/occupancy_disagreement_test.jl` ("flag off", "round trip", "through the pipeline's _attest")
+| MMW-01.2 | For this workload, the vsearch stage's cost (Julia allocation) is the same in all three orders | TESTED | same file, "two-thread disagreement" (asserted, within 1%)
+| MMW-01.3 | For this workload, the stage peak depends on the order (A-3), and each search's own peak does not | TESTED | same file, recorded in the test log; table above
+| MMW-01.4 | For the three Session IR texts, the stepper's counted bytes equal the HWM typed peak | TESTED | `scripts/check-occupancy.sh` (stepper output against `TypedPeak.agda`; two rejections in `docs/occupancy/reject`)
+| bridge | Counted bytes are bounded by the typed peak in general (occupancy-types' v0 → typed bridge) | OPEN | unchanged; this page gives three instances, not a proof
+|===
+
+== Limits
+
+* *Sampled.* A per-task peak is the largest VmRSS seen at 10 ms intervals. VmHWM
+ is exact up to the last sample, but growth after it is not seen. A child that
+ lives for less than one interval may not be sampled at all (`samples: 0`).
+* *Child memory only.* The children are counted; the Julia process is not. Its
+ `julia_live_bytes_delta` is recorded, but without a forced collection it
+ reflects when the GC last ran, not what the stage kept.
+* *Allocation is process-wide.* While the study loop runs several runs'
+ pipelines at once, a stage's `julia_allocated_bytes` includes the others'
+ allocations too. The child figures are per stage, since each stage samples
+ only the children it started.
+* *Linux only*, since it reads /proc. For a stage offloaded with `remote:`,
+ the local child is an ssh client, so its memory says nothing about the work.
+* *One thread for vsearch.* With several threads, vsearch writes its hits in
+ whatever order the threads finish. `_sort_hits_by_query!` then does a
+ different amount of work, because its sort key splits each line on every
+ comparison. That moved the stage's allocation total by up to 12% between
+ repeats of the same order. So in production the cost is order-independent
+ only up to that noise; precomputing the sort key would remove it. This PR
+ does not change it.
+* *One host, one day.* CI runs the same test on every push and logs its own
+ numbers; they are not compared with these.
+
+== Reproducing
+
+----
+julia --project=. -t 2 test/occupancy_disagreement_test.jl # set OCCUPANCY_RESULTS= for YAML
+bash scripts/check-occupancy.sh # needs julia and agda 2.6.4.x
+----
+
+`scripts/fetch-siblings.sh` fetches the siblings at their pins into
+`_build/dependencies`: occupancy-types `af95728` and resource-algebra
+`cf9235c`.
+
+== As the proofs progress
+
+* *Per-buffer (n-ary) occupancy* in occupancy-types: each search is already one
+ buffer in the Session IR texts, so the typed peak could then be read per
+ search.
+* *Signed drift*: frees could be graded as negative drift, so the Julia side's
+ collections (the pipeline's `GC.gc(false)` hints) become modelled
+ reclamation rather than unmodelled noise.
+* *choreographic-types' operational `step` as a function*: the three orders
+ become three traces of one typed protocol. The frontier certificate its
+ two-thread application note describes could then be displayed in the WebUI.
diff --git a/docs/occupancy/TypedPeak.agda b/docs/occupancy/TypedPeak.agda
new file mode 100644
index 00000000..6899efb2
--- /dev/null
+++ b/docs/occupancy/TypedPeak.agda
@@ -0,0 +1,45 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- The vsearch stage's typed peak, computed (TESTED, by refl) with
+-- occupancy-types' Timeline over resource-algebra's HWM grade, both consumed
+-- by pin through scripts/fetch-siblings.sh:
+-- occupancy-types af957280075699c659de0abd9a3b0defade77309
+-- resource-algebra cf9235c532712a783a404c359a1055f40134794a
+-- Nothing here re-declares their definitions.
+--
+-- Each search is one interaction graded by its measured peak, in KiB, from the
+-- 2026-10-14 run in docs/occupancy-2026-10.adoc. A search's process exits
+-- holding nothing, so run alone its drift is 0. Run concurrently, the first is
+-- still resident when the second starts, so that is graded with drift = peak.
+-- That is the interleaving the measurement observed (both resident at once).
+-- The algebra's sequence law, max (peak a) (drift a + peak b), then gives:
+-- serial = the larger peak; concurrent = the sum.
+module TypedPeak where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+
+import OccupancyTypes.Timeline
+module TH = OccupancyTypes.Timeline HWM
+
+-- A search whose process has exited before the next one starts.
+exits : Nat → Grade
+exits p = hwm p 0 z≤n
+
+-- A search still resident when the next one starts.
+resident : Nat → Grade
+resident p = hwm p p (≤-refl p)
+
+-- ASV 9204 then OTU 9180.
+asv-then-otu : peak (TH.grade (exits 9204 TH.∷ exits 9180 TH.∷ TH.[])) ≡ 9204
+asv-then-otu = refl
+
+-- OTU 9192 then ASV 9152.
+otu-then-asv : peak (TH.grade (exits 9192 TH.∷ exits 9152 TH.∷ TH.[])) ≡ 9192
+otu-then-asv = refl
+
+-- ASV 9176 and OTU 9204 together.
+concurrent : peak (TH.grade (resident 9176 TH.∷ exits 9204 TH.∷ TH.[])) ≡ 18380
+concurrent = refl
diff --git a/docs/occupancy/reject/ConcurrentIsSerial.agda b/docs/occupancy/reject/ConcurrentIsSerial.agda
new file mode 100644
index 00000000..592de6ee
--- /dev/null
+++ b/docs/occupancy/reject/ConcurrentIsSerial.agda
@@ -0,0 +1,14 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- Must be REFUSED: the claim that running the two searches concurrently leaves
+-- the peak where a serial run puts it (the larger of the two).
+module ConcurrentIsSerial where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import TypedPeak
+
+wrong : peak (TH.grade (resident 9176 TH.∷ exits 9204 TH.∷ TH.[])) ≡ 9204
+wrong = refl
diff --git a/docs/occupancy/reject/SerialIsCost.agda b/docs/occupancy/reject/SerialIsCost.agda
new file mode 100644
index 00000000..dbb6f4ba
--- /dev/null
+++ b/docs/occupancy/reject/SerialIsCost.agda
@@ -0,0 +1,14 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- Must be REFUSED: reading a serial run's peak as the sum of the two peaks,
+-- i.e. occupancy as cost (occupancy-types R-6).
+module SerialIsCost where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import TypedPeak
+
+wrong : peak (TH.grade (exits 9204 TH.∷ exits 9180 TH.∷ TH.[])) ≡ 18384
+wrong = refl
diff --git a/docs/occupancy/results-2026-10-14.yml b/docs/occupancy/results-2026-10-14.yml
new file mode 100644
index 00000000..394236d6
--- /dev/null
+++ b/docs/occupancy/results-2026-10-14.yml
@@ -0,0 +1,63 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# The raw MemoryProbe records behind docs/occupancy-2026-10.adoc, written by
+# test/occupancy_disagreement_test.jl with OCCUPANCY_RESULTS set (2026-10-14).
+vsearch: "vsearch v2.30.5_linux_x86_64, 15.7GB RAM, 4 cores"
+julia: "1.12.5"
+threads: 2
+cpus: 4
+cost_spread: 0.00039231071008238524
+orders:
+ asv_then_otu:
+ probe: "MemoryProbe 1"
+ interval_ms: 10
+ samples: 56
+ julia_allocated_bytes: 978432
+ julia_live_bytes_delta: 978544
+ child_peak_rss_kib: 9204
+ tasks:
+ - label: "vsearch:asv"
+ processes: 1
+ samples: 33
+ peak_rss_kib: 9204
+ hwm_kib: 9204
+ - label: "vsearch:otu"
+ processes: 1
+ samples: 20
+ peak_rss_kib: 9180
+ hwm_kib: 9180
+ otu_then_asv:
+ probe: "MemoryProbe 1"
+ interval_ms: 10
+ samples: 57
+ julia_allocated_bytes: 978720
+ julia_live_bytes_delta: 978832
+ child_peak_rss_kib: 9192
+ tasks:
+ - label: "vsearch:otu"
+ processes: 1
+ samples: 22
+ peak_rss_kib: 9192
+ hwm_kib: 9192
+ - label: "vsearch:asv"
+ processes: 1
+ samples: 33
+ peak_rss_kib: 9152
+ hwm_kib: 9152
+ concurrent:
+ probe: "MemoryProbe 1"
+ interval_ms: 10
+ samples: 33
+ julia_allocated_bytes: 978816
+ julia_live_bytes_delta: 978928
+ child_peak_rss_kib: 18380
+ tasks:
+ - label: "vsearch:asv"
+ processes: 1
+ samples: 30
+ peak_rss_kib: 9176
+ hwm_kib: 9176
+ - label: "vsearch:otu"
+ processes: 1
+ samples: 24
+ peak_rss_kib: 9204
+ hwm_kib: 9204
diff --git a/docs/occupancy/vsearch-asv-then-otu.sir b/docs/occupancy/vsearch-asv-then-otu.sir
new file mode 100644
index 00000000..e462fb5e
--- /dev/null
+++ b/docs/occupancy/vsearch-asv-then-otu.sir
@@ -0,0 +1,13 @@
+(* SPDX-License-Identifier: AGPL-3.0-only *)
+(* vsearch-asv-then-otu.sir - the vsearch stage with the two searches run one
+ after the other, ASV first: the single-stage re-run's order. Each search is
+ one buffer, sized by its measured peak in KiB (docs/occupancy-2026-10.adoc,
+ 2026-10-14 run); each process exits holding nothing, so its buffer is dropped
+ before the next search starts.
+
+ Expected: steps = 0 (no communication), peak_live = max 9204 9180 = 9204. *)
+let asv = alloc 9204 in
+let _ = drop asv in
+let otu = alloc 9180 in
+let _ = drop otu in
+unit
diff --git a/docs/occupancy/vsearch-concurrent.sir b/docs/occupancy/vsearch-concurrent.sir
new file mode 100644
index 00000000..93be40bf
--- /dev/null
+++ b/docs/occupancy/vsearch-concurrent.sir
@@ -0,0 +1,12 @@
+(* SPDX-License-Identifier: AGPL-3.0-only *)
+(* vsearch-concurrent.sir - the two searches spawned together, as the full
+ pipeline runs them. Session IR has no parallel composition, so this is the
+ interleaving the measurement observed: both buffers live at once, then both
+ dropped.
+
+ Expected: steps = 0, peak_live = 9176 + 9204 = 18380. *)
+let asv = alloc 9176 in
+let otu = alloc 9204 in
+let _ = drop asv in
+let _ = drop otu in
+unit
diff --git a/docs/occupancy/vsearch-otu-then-asv.sir b/docs/occupancy/vsearch-otu-then-asv.sir
new file mode 100644
index 00000000..ba575591
--- /dev/null
+++ b/docs/occupancy/vsearch-otu-then-asv.sir
@@ -0,0 +1,9 @@
+(* SPDX-License-Identifier: AGPL-3.0-only *)
+(* vsearch-otu-then-asv.sir - the same two searches, OTU first.
+
+ Expected: steps = 0, peak_live = max 9192 9152 = 9192. *)
+let otu = alloc 9192 in
+let _ = drop otu in
+let asv = alloc 9152 in
+let _ = drop asv in
+unit
diff --git a/scripts/check-occupancy.sh b/scripts/check-occupancy.sh
new file mode 100755
index 00000000..f9bcba36
--- /dev/null
+++ b/scripts/check-occupancy.sh
@@ -0,0 +1,69 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# Gate for the occupancy reading of the vsearch stage (docs/occupancy-2026-10.adoc):
+# 1. each Session IR text checks, and occupancy-types' stepper counts its bytes;
+# 2. TypedPeak.agda type-checks against the pinned siblings, and each module in
+# docs/occupancy/reject is refused at its own declaration;
+# 3. the counted bytes equal the typed peak, order by order.
+# Needs julia and agda (2.6.4.x) on PATH. Exit 0 only if all three hold.
+set -euo pipefail
+cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
+export LC_ALL="${LC_ALL:-C.UTF-8}"
+
+bash scripts/fetch-siblings.sh
+deps=_build/dependencies
+sir="$deps/occupancy-types/src/julia"
+agda --version | head -1
+flags=(--safe --without-K --no-libraries --ignore-interfaces --double-check
+ -i docs/occupancy -i "$deps/occupancy-types/src" -i "$deps/resource-algebra/src")
+logs=_build/check-logs
+mkdir -p "$logs"
+failures=0
+
+# Order -> Session IR file and TypedPeak lemma.
+orders=(asv-then-otu otu-then-asv concurrent)
+
+echo "== Session IR (occupancy-types v0 stepper)"
+declare -A counted
+for o in "${orders[@]}"; do
+ file="docs/occupancy/vsearch-$o.sir"
+ julia --startup-file=no --project="$sir" "$sir/bin/sessionir.jl" check "$file"
+ out="$(julia --startup-file=no --project="$sir" "$sir/bin/sessionir.jl" run "$file" --trace)"
+ echo "$out"
+ counted[$o]="$(sed -n 's/.*peak_live=\([0-9]*\).*/\1/p' <<<"$out" | head -1)"
+done
+
+echo "== Typed peak (Agda, HWM Timeline)"
+agda "${flags[@]}" docs/occupancy/TypedPeak.agda >"$logs/TypedPeak.log" 2>&1 ||
+ { cat "$logs/TypedPeak.log"; echo "ERROR: TypedPeak.agda does not type-check" >&2; exit 1; }
+echo "PASS: TypedPeak.agda"
+for file in docs/occupancy/reject/*.agda; do
+ name="$(basename -- "$file" .agda)"
+ log="$logs/$name.log"
+ if agda "${flags[@]}" -i docs/occupancy/reject "$file" >"$log" 2>&1; then
+ cat "$log"; echo "ERROR: invalid claim accepted: $name" >&2; failures=$((failures + 1)); continue
+ fi
+ if ! grep -Eq "/$name\.agda:[0-9]+,[0-9]+" "$log" || ! grep -q 'when checking that' "$log" ||
+ grep -Eq 'Not in scope|Failed to find|Unsolved|Parse error' "$log"; then
+ cat "$log"; echo "ERROR: $name failed for the wrong reason" >&2; failures=$((failures + 1)); continue
+ fi
+ echo "PASS: rejected $name ($(grep -m1 -E '[0-9]+ != [0-9]+' "$log"))"
+done
+
+echo "== Counted bytes against typed peak (KiB)"
+printf '%-14s %10s %10s\n' order counted typed
+for o in "${orders[@]}"; do
+ typed="$(grep -A1 -E "^$o :" docs/occupancy/TypedPeak.agda | sed -n 's/.*≡ \([0-9]*\)$/\1/p' | head -1)"
+ printf '%-14s %10s %10s\n' "$o" "${counted[$o]}" "$typed"
+ if [ -z "${counted[$o]}" ] || [ -z "$typed" ] || [ "${counted[$o]}" != "$typed" ]; then
+ echo "ERROR: $o: counted ${counted[$o]:-?} != typed ${typed:-?}" >&2; failures=$((failures + 1))
+ fi
+done
+
+if (( failures > 0 )); then
+ echo "check-occupancy: $failures failure(s)" >&2
+ exit 1
+fi
+echo "check-occupancy: OK"
diff --git a/scripts/fetch-siblings.sh b/scripts/fetch-siblings.sh
new file mode 100755
index 00000000..31a98bc1
--- /dev/null
+++ b/scripts/fetch-siblings.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# Fetch the proof siblings the occupancy reading (docs/occupancy-2026-10.adoc)
+# consumes, each at its pinned commit, into _build/dependencies. Their
+# definitions are used, never copied. Re-pin one sibling per commit.
+set -euo pipefail
+cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.."
+
+# occupancy-types: the HWM Timeline (Agda) and the SessionIR stepper (Julia).
+OCCUPANCY_TYPES_COMMIT="${OCCUPANCY_TYPES_COMMIT:-af957280075699c659de0abd9a3b0defade77309}"
+# resource-algebra: the HWM grade algebra occupancy-types builds on.
+RESOURCE_ALGEBRA_COMMIT="${RESOURCE_ALGEBRA_COMMIT:-cf9235c532712a783a404c359a1055f40134794a}"
+
+fetch() {
+ local name="$1" commit="$2" dir="_build/dependencies/$1"
+ if [ ! -d "$dir/.git" ]; then
+ git init --quiet "$dir"
+ git -C "$dir" remote add origin "https://github.com/hyperpolymath/$name.git"
+ fi
+ git -C "$dir" fetch --quiet --depth 1 origin "$commit"
+ git -C "$dir" checkout --quiet --detach "$commit"
+ echo "$name at $(git -C "$dir" rev-parse --short HEAD)"
+}
+
+mkdir -p _build/dependencies
+fetch occupancy-types "$OCCUPANCY_TYPES_COMMIT"
+fetch resource-algebra "$RESOURCE_ALGEBRA_COMMIT"
From 91f3bb83fd8259a85c9c1f5a10f0e78c9f585800 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 02:56:06 +0100
Subject: [PATCH 03/45] ci(occupancy): install from pinned tarballs, and run
the disagreement test here (#108)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Follow-up to hyperpolymath/MetaManifold-WebUI#106 and
hyperpolymath/MetaManifold-WebUI#107. Both were merged before their
checks ran, so this is the part that never ran.
## Summary
**The problem.** The `Occupancy reading` workflow ends in
`startup_failure`: on #107's PR run, and again on `main` at `4dc19c0`.
This account's Actions policy allows only GitHub's own actions and
hyperpolymath's own, pinned to a full commit SHA, and
`julia-actions/setup-julia` is neither.
The same policy is why `ci.yml` has not started on this fork since at
least 2026-10-02: it uses `julia-actions/*`, `oven-sh/setup-bun` and
`codecov/codecov-action`. So the three-order test that #106 added has
never run here.
**The fix.** The workflow now uses no third-party action. It installs:
- **Julia 1.12.5** from julialang's tarball, checked against julialang's
published SHA256 (`41b84d72…8bae7`, as docker-library/julia `a39df01`
records it);
- **vsearch** from its `config/defaults/tool_versions.yml` pin, checked
against the pinned SHA256, exactly as `ci.yml` does.
It has two jobs:
- **Occupancy disagreement test:** Julia, R from apt (RCall builds
against it), `Pkg.instantiate()`, the pinned vsearch, then `julia
--project=. -t 2 test/occupancy_disagreement_test.jl`;
- **Occupancy typed peak:** `scripts/check-occupancy.sh` in the
digest-pinned Debian trixie image with Agda 2.6.4.3, as before.
The path filters now also cover the probe's sources and the test.
## Verification
- `zizmor` finds nothing.
- The only `uses:` is `actions/checkout`, SHA-pinned.
- The `eval` of the pin-reading Julia snippet was rehearsed locally:
`2.30.5`, the pinned URL and the SHA256.
- This PR's own run of the workflow is the real check.
The fork's `ci.yml` will keep failing at startup whatever this PR does.
The fix there is an account or repo setting (Settings → Actions →
General) that allows those actions.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015pMjy981ie74uCttyYDanC
---
_Generated by [Claude
Code](https://claude.ai/code/session_015pMjy981ie74uCttyYDanC)_
Co-authored-by: Claude Opus 5.5
---
.github/workflows/occupancy.yml | 99 ++++++++++++++++++++++++++++++---
1 file changed, 90 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
index a1c7136d..92433886 100644
--- a/.github/workflows/occupancy.yml
+++ b/.github/workflows/occupancy.yml
@@ -2,15 +2,26 @@
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
#
# The occupancy reading of the vsearch stage (docs/occupancy-2026-10.adoc):
-# Session IR's counted bytes against the typed peak computed in Agda from the
-# pinned proof siblings. Kept apart from ci.yml because it is about the docs and
-# their pinned siblings, not the application; it runs only when they change.
+# * the two-thread disagreement test (test/occupancy_disagreement_test.jl) on
+# the pinned vsearch, and
+# * Session IR's counted bytes against the typed peak computed in Agda from
+# the pinned proof siblings (scripts/check-occupancy.sh).
+#
+# This account allows only GitHub's own actions and its own, pinned by SHA, so
+# ci.yml (julia-actions, setup-bun, codecov) cannot start on this fork. That is
+# why everything here installs from pinned URLs checked against pinned SHA256s,
+# and why the disagreement test runs here as well as in ci.yml's full suite.
name: Occupancy reading
on:
push:
branches: [main]
paths:
+ - src/core/memory_probe.jl
+ - src/core/provenance.jl
+ - src/pipeline/tools.jl
+ - src/server/routes/pipeline.jl
+ - test/occupancy_disagreement_test.jl
- docs/occupancy/**
- docs/occupancy-2026-10.adoc
- scripts/check-occupancy.sh
@@ -19,6 +30,11 @@ on:
# Any base branch, so a PR stacked on another one is checked too.
pull_request:
paths:
+ - src/core/memory_probe.jl
+ - src/core/provenance.jl
+ - src/pipeline/tools.jl
+ - src/server/routes/pipeline.jl
+ - test/occupancy_disagreement_test.jl
- docs/occupancy/**
- docs/occupancy-2026-10.adoc
- scripts/check-occupancy.sh
@@ -32,7 +48,65 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name != 'push' }}
+env:
+ # Julia 1.12.5, the version Manifest.toml was resolved with. The checksum is
+ # julialang's own (bin/checksums/julia-1.12.5.sha256), as the official Docker
+ # image (docker-library/julia a39df01) records it.
+ JULIA_URL: https://julialang-s3.julialang.org/bin/linux/x64/1.12/julia-1.12.5-linux-x86_64.tar.gz
+ JULIA_SHA256: 41b84d727e4e96fbf3ed9e92fa195d773d247b9097f73fad688f8b699758bae7
+
jobs:
+ disagreement:
+ name: Occupancy disagreement test
+ runs-on: ubuntu-24.04
+ timeout-minutes: 45
+ env:
+ # As in ci.yml: the root .Rprofile would otherwise bootstrap renv.
+ RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE"
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ persist-credentials: false
+
+ - name: Install Julia
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$JULIA_URL" julia.tar.gz
+ echo "$JULIA_SHA256 julia.tar.gz" | sha256sum -c -
+ sudo mkdir -p /opt/julia
+ sudo tar xzf julia.tar.gz -C /opt/julia --strip-components=1
+ rm julia.tar.gz
+ echo /opt/julia/bin >> "$GITHUB_PATH"
+
+ # RCall builds against R. The occupancy test uses no R package.
+ - name: Install R
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends r-base-core
+
+ - name: Instantiate the project
+ run: julia --project=. -e 'using Pkg; Pkg.instantiate()'
+
+ # The pinned vsearch (config/defaults/tool_versions.yml), refused unless it
+ # hashes to the pinned SHA256, as ci.yml installs it.
+ - name: Install vsearch
+ run: |
+ eval "$(julia --project=. -e '
+ using YAML
+ v = YAML.load_file("config/defaults/tool_versions.yml")["tools"]["vsearch"]
+ a = v["archives"]["linux-x86_64"]
+ println("VSEARCH_VERSION=", v["version"]); println("VSEARCH_URL=", a["url"]); println("VSEARCH_SHA256=", a["sha256"])')"
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$VSEARCH_URL" vsearch.tar.gz
+ echo "$VSEARCH_SHA256 vsearch.tar.gz" | sha256sum -c -
+ tar xzf vsearch.tar.gz --warning=no-unknown-keyword
+ sudo mv "vsearch-$VSEARCH_VERSION-linux-x86_64/bin/vsearch" /usr/local/bin/vsearch
+ rm -rf vsearch.tar.gz "vsearch-$VSEARCH_VERSION-linux-x86_64"
+ vsearch --version
+
+ - name: Three orders of the vsearch stage
+ run: julia --project=. -t 2 test/occupancy_disagreement_test.jl
+
typed-peak:
name: Occupancy typed peak
runs-on: ubuntu-24.04
@@ -44,10 +118,10 @@ jobs:
DEBIAN_FRONTEND: noninteractive
steps:
# git before checkout, so the checkout is a repository and the siblings can be fetched.
- - name: Install git and Agda
+ - name: Install git, curl and Agda
run: |
apt-get update -qq
- apt-get install -y --no-install-recommends ca-certificates git agda-bin=2.6.4.3-1+b2
+ apt-get install -y --no-install-recommends ca-certificates curl git agda-bin=2.6.4.3-1+b2
agda --version | grep -qx 'Agda version 2.6.4.3'
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -55,10 +129,17 @@ jobs:
persist-credentials: false # the siblings are public; nothing here pushes
# The Session IR stepper is Julia (standard library only).
- - name: Set up Julia
- uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0
- with:
- version: "1.12.5"
+ - name: Install Julia
+ shell: bash
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$JULIA_URL" julia.tar.gz
+ echo "$JULIA_SHA256 julia.tar.gz" | sha256sum -c -
+ mkdir -p /opt/julia
+ tar xzf julia.tar.gz -C /opt/julia --strip-components=1
+ rm julia.tar.gz
+ echo /opt/julia/bin >> "$GITHUB_PATH"
- name: Counted bytes against typed peak
+ shell: bash
run: bash scripts/check-occupancy.sh
From 44a2273d4ead308a1811cbd5f54d5ae6d0bb57be Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 10:34:25 +0100
Subject: [PATCH 04/45] chore(sync): merge JoshuaJewell/MetaManifold-WebUI main
(bcf52af) into the fork (#109)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
The fork's `main` is upstream `257bcde` plus the three MMW-01 PRs (#106,
#107, #108). Upstream has 25 commits since, up to `bcf52af`. Among them:
- the PERMANOVA per-term test (JoshuaJewell/MetaManifold-WebUI#35);
- the Agda proofs job;
- the stable check name;
- the config write gates;
- clr_welch.
This merges them in. It does not rebase, so `main` keeps its history and
nothing is force-pushed. It is the base for the upstream PR of the probe
(branch `feat/occupancy-probe`), whose files are byte-identical to this
branch's.
**Conflicts.** There were two. Both were additions on each side, so both
sides are kept, upstream first:
- **`.gitignore`:** upstream's `proofs/agda/_build/`, then the fork's
`/_build/` and `*.agdai` for the fetched proof siblings.
- **`config/defaults/pipeline.yml`:** upstream's
`differential.replacement_delta`, then the fork's commented-out
`occupancy_probe` block at the end of the file.
## Verification
The unit suite ran on this tree with Julia 1.12.5, vsearch 2.30.5 and
`-t 2`.
- **Totals:** 2408 passed, 0 failed, 20 broken (tests the suite marks as
known-broken), 1 error.
- **The error is environmental.** `live probes of what is actually
installed` (test/unit/test_provenance.jl:478) reports `package 'dada2'
is not installed in the R library in use`. That sandbox has no dada2; CI
restores it from `renv.lock`.
- **New upstream tests:** "Config write gate" passes 54/54 and "Pipeline
tables cannot be deleted" 24/24.
- **Occupancy probe:** 46/46, the test from #106.
**Expected CI on this PR:**
- **`CI`:** `startup_failure`. This branch still carries upstream's
`ci.yml`, whose julia-actions, setup-bun and codecov steps this
account's Actions policy refuses. The stacked PR
`ci/no-third-party-actions` fixes that and is where `ci.yml` is actually
tested, on this merge plus its own commit.
- **`Occupancy reading`:** does not run. Its paths are untouched by the
merge.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015pMjy981ie74uCttyYDanC
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: x
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.github/workflows/ci.yml | 95 ++-
.gitignore | 2 +
CONTRIBUTING.md | 2 +-
config/defaults/pipeline.yml | 16 +-
frontend/src/api/types.ts | 56 +-
frontend/src/components/AnalysisWorkspace.tsx | 6 +-
frontend/src/components/CompositionPanel.tsx | 10 +
frontend/src/components/DifferentialPanel.tsx | 73 ++-
frontend/src/components/DiversityPanel.tsx | 59 +-
frontend/src/components/PipelineStages.tsx | 6 +-
.../src/components/TaxaCompositionChart.tsx | 50 +-
frontend/src/components/VennPanel.tsx | 49 +-
.../src/components/annotationShared.test.ts | 38 ++
frontend/src/components/annotationShared.ts | 61 +-
.../src/components/differentialTable.test.ts | 26 +-
frontend/src/components/differentialTable.ts | 21 +-
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 +-
proofs/agda/MetaManifold/All.agda | 14 +
.../agda/MetaManifold/BenjaminiHochberg.agda | 582 ++++++++++++++++++
proofs/agda/MetaManifold/Prelude.agda | 83 +++
proofs/agda/MetaManifold/Scaling.agda | 244 ++++++++
proofs/agda/README.md | 140 +++++
proofs/agda/metamanifold.agda-lib | 6 +
proofs/tests/axiom-audit.sh | 141 +++++
renv.lock | 98 +++
src/analysis/analysis.jl | 241 +++++++-
src/analysis/differential.jl | 492 ++++++++++++---
src/core/validate.jl | 82 ++-
src/server/routes/analysis.jl | 11 +-
src/server/routes/config.jl | 41 +-
src/server/routes/databases.jl | 15 +-
src/server/routes/results.jl | 10 +-
test/runtests.jl | 2 +
test/unit/test_differential.jl | 379 +++++++++++-
test/unit/test_install_pins.jl | 57 +-
test/unit/test_permanova.jl | 103 ++++
test/unit/test_r_runtime.jl | 100 +++
test/unit/test_routes.jl | 10 +-
test/unit/test_write_gates.jl | 138 +++++
42 files changed, 3406 insertions(+), 253 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
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/Scaling.agda
create mode 100644 proofs/agda/README.md
create mode 100644 proofs/agda/metamanifold.agda-lib
create mode 100755 proofs/tests/axiom-audit.sh
create mode 100644 test/unit/test_permanova.jl
create mode 100644 test/unit/test_write_gates.jl
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 912daaba..10cf5483 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -17,8 +17,33 @@ concurrency:
jobs:
test:
- name: Julia ${{ matrix.julia-version }} / ${{ matrix.os }}
- runs-on: ${{ matrix.os }}
+ # The name is a fixed string, it carries no version, and the job has NO matrix.
+ # Both are required, and the first without the second is a trap that has already
+ # been sprung here once.
+ #
+ # A branch ruleset matches a required status check by the DISPLAY NAME GitHub posts
+ # for the job. A renamed check does not fail -- it is simply absent, and an absent
+ # required check can never be satisfied, so every pull request deadlocks until an
+ # admin bypasses the rule, triggered by nothing more alarming than editing a version
+ # number.
+ #
+ # MEASURED on hyperpolymath/MetaManifold-WebUI#39 (2026-09-21): this job, named exactly `Julia tests` but still
+ # carrying a 1x1 `strategy.matrix`, posted `Julia tests (1.12.5, ubuntu-24.04)`.
+ # GitHub appends the matrix combination whenever the name does not already reference
+ # the matrix, so a 1x1 matrix is still a matrix and the pins were still embedded in
+ # the check name. The matrix was therefore removed rather than merely renamed around:
+ # it selected exactly one combination and bought nothing.
+ #
+ # The two values it held are literals below, each keeping its own reasoning.
+ # `test/unit/test_install_pins.jl` asserts, for EVERY job in this file, that the name
+ # interpolates nothing AND that the job has no matrix -- so this cannot regress
+ # silently. If a matrix is ever genuinely wanted here, the required context must move
+ # to a matrix-free aggregator job first.
+ name: Julia tests
+ # Explicit, not ubuntu-latest: the R pin below names an apt package revision built
+ # for 24.04, and a runner that silently rolled to the next LTS would take that pin
+ # with it. `runs-on` cannot read `env`, so this is a literal.
+ runs-on: ubuntu-24.04
# The repository's root .Rprofile sources renv/activate.R, so R started from
# the checkout auto-activates renv and rewrites the library paths. renv is a
# local-development convenience; CI installs into the system library and
@@ -28,19 +53,6 @@ jobs:
# the environment, so those commands pass --no-init-file instead.
env:
RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE"
- strategy:
- fail-fast: false
- matrix:
- # Pinned rather than tracking latest stable, because a pinned Manifest.toml
- # resolved by an unpinned Julia is not a reproducible build. This must equal
- # julia_version in Manifest.toml; it cannot be read from the pin file, since
- # nothing can be read before Julia exists. test/unit/test_install_pins.jl
- # fails if the three ever disagree.
- julia-version: ["1.12.5"]
- # Explicit, not ubuntu-latest: the R pin below names an apt package revision
- # built for 24.04, and a runner that silently rolled to the next LTS would
- # take that pin with it.
- os: [ubuntu-24.04]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -48,7 +60,12 @@ jobs:
- name: Set up Julia
uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0
with:
- version: ${{ matrix.julia-version }}
+ # Pinned rather than tracking latest stable, because a pinned Manifest.toml
+ # resolved by an unpinned Julia is not a reproducible build. This must equal
+ # julia_version in Manifest.toml; it cannot be read from the pin file, since
+ # nothing can be read before Julia exists. test/unit/test_install_pins.jl
+ # fails if the three ever disagree.
+ version: "1.12.5"
- name: Cache Julia packages
uses: julia-actions/cache@d10a6fd8f31b12404a54613ebad242900567f2b9 # v2.1.0
@@ -139,7 +156,7 @@ jobs:
sudo chmod 777 "$RENV_PATHS_CACHE"
sudo env R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'install.packages(c("renv", "BiocManager"), repos="https://cloud.r-project.org", lib=.Library)'
sudo env RENV_PATHS_CACHE="$RENV_PATHS_CACHE" R_CLI_DYNAMIC=false TERM=dumb Rscript --no-init-file -e 'renv::restore(project=".", library=.Library, prompt=FALSE)'
- Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
+ Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","dplyr","MASS","zCompositions")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
# Saved on failure too, since the packages built before a failure are the ones
# the next attempt should reuse. Root writes the cache, so it is made readable first.
@@ -230,7 +247,7 @@ jobs:
julia --project=. -e '
using RCall
R"cat(.libPaths(), sep=\"\n\")"
- for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS"]
+ for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS", "zCompositions"]
R"if (!require($pkg, character.only=TRUE, quietly=TRUE)) stop($pkg, \" not found\")"
end
'
@@ -282,3 +299,45 @@ jobs:
token: ${{ secrets.CODECOV_TOKEN }}
slug: JoshuaJewell/MetaManifold-WebUI
fail_ci_if_error: false
+
+ # Machine-checked proofs of the Benjamini-Hochberg adjustment and the TSS/RLE
+ # scaling algebra (proofs/agda, proofs/agda/README.md).
+ # Agda publishes no 2.6.4.3 binary, and agda-stdlib 2.1 requires Agda 2.6.4.x,
+ # so the pair comes from Debian trixie, which packages exactly that pair with
+ # pre-built library interfaces. The image is pinned by digest and the two
+ # packages by revision, so a rebuild in the archive fails loudly here instead
+ # of silently changing the checker. These pins live here rather than in
+ # config/defaults/tool_versions.yml because no other job and no installer
+ # reads them.
+ agda:
+ name: Agda proofs
+ runs-on: ubuntu-24.04
+ container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
+ timeout-minutes: 30
+ env:
+ DEBIAN_FRONTEND: noninteractive
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+
+ - name: Install Agda and the standard library
+ run: |
+ apt-get update -qq
+ apt-get install -y --no-install-recommends agda-bin=2.6.4.3-1+b2 agda-stdlib=2.1-4
+ agda --version | grep -qx 'Agda version 2.6.4.3'
+ grep -qx 'name: standard-library-2.1' /usr/share/agda-stdlib/standard-library.agda-lib
+
+ # Runs before the type-check: --safe already refuses postulates, but the
+ # audit also refuses holes, check-disabling pragmas and flags, and any
+ # module that MetaManifold/All.agda does not import (an unimported module
+ # is never type-checked at all).
+ - name: Axiom audit
+ run: bash proofs/tests/axiom-audit.sh
+
+ - name: Type-check
+ env:
+ AGDA_DIR: /tmp/agda-dir
+ run: |
+ mkdir -p "$AGDA_DIR"
+ echo /usr/share/agda-stdlib/standard-library.agda-lib > "$AGDA_DIR/libraries"
+ cd proofs/agda
+ agda MetaManifold/All.agda
diff --git a/.gitignore b/.gitignore
index 1436ebab..42d6c9c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -336,6 +336,8 @@ LocalPreferences.toml
# Development server log (dev/dev.sh)
dev/server.log
+# Agda interface files (proofs/agda)
+proofs/agda/_build/
# Proof siblings fetched by scripts/fetch-siblings.sh, and Agda interface files
/_build/
*.agdai
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 504ad6b0..930ec561 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -98,7 +98,7 @@ A pull request does not reopen these. An issue can discuss them.
- `config/defaults/tool_versions.yml` pins Julia, R, bun and the external tools,
and `install.jl` and CI both install from it. The Julia version is repeated in
- the CI matrix. Change a pin by editing that file, and expect the change to be
+ the CI workflow. Change a pin by editing that file, and expect the change to be
discussed.
- R packages are pinned in `renv.lock`.
- CI job names carry no version numbers, so that a pin change leaves required
diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml
index 6f50ed68..8181ae6d 100644
--- a/config/defaults/pipeline.yml
+++ b/config/defaults/pipeline.yml
@@ -347,13 +347,27 @@ analysis:
# comparing two conditions on the raw read counts, with Benjamini-Hochberg
# adjusted p-values.
differential:
+ # The model fitted to each taxon. nb_glm: a negative-binomial GLM on the read
+ # counts with a library-size offset; the effect is a log2 fold change. clr_welch:
+ # Welch's t-test on centred log-ratios over the taxa passing min_prevalence,
+ # after zCompositions::cmultRepl replaces their zeros; the effect is a
+ # difference in CLR units, not a fold change.
+ method: nb_glm
# How library size enters each model, as a log offset: tss (total reads per
# sample) or rle (median-of-ratios over the taxa present in every sample).
# rle is refused when no taxon is present in every sample.
+ # Used by nb_glm only; clr_welch ignores it, since log-ratios carry no library size.
offset: tss
# Fraction of samples, 0 to 1, in which a taxon must have reads to be
- # tested. Taxa below it are listed as filtered, without a p-value.
+ # tested. Taxa below it are listed as filtered, without a p-value. For
+ # clr_welch it is applied before zero replacement, so on a sparse table it also
+ # keeps imputed values for rare taxa out of every sample; clr_welch refuses a
+ # taxon seen in fewer than 2 samples.
min_prevalence: 0.0
+ # clr_welch only: cmultRepl's frac, between 0 and 1. An imputed proportion above
+ # its taxon's smallest observed proportion is lowered to this fraction of it.
+ # A replaced zero is not a measurement.
+ replacement_delta: 0.65
## Occupancy probe (MMW-01)
# Measures each attested stage's memory: Julia's allocation total, and the
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 10410bd9..87457dad 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -243,15 +243,32 @@ export interface ComparisonRequest extends AnalysisRequest {
aggregate?: boolean
}
+export interface PermanovaTerm {
+ term: string
+ r2: number
+ f_statistic: number | null
+ /** Null when the term could not be tested; `untestable_reason` says why. */
+ p_value: number | null
+ /** Set when within-block permutation cannot change this term. */
+ untestable_reason?: string | null
+}
+
export interface PermanovaResult {
text: string
+ /** The first term (group, when it varies); r2, f_statistic and p_value are its own. */
+ term?: string
r2: number | null
f_statistic: number | null
p_value: number | null
+ untestable_reason?: string | null
+ /** Permutations actually performed: fewer than 999 when the blocks admit fewer. */
+ permutations?: number
+ /** Smallest p-value attainable with that many permutations. */
+ min_p_value?: number
/** True when permutations were restricted within individuals. */
blocked?: boolean
/** One row per model term, tested sequentially in formula order. */
- terms?: { term: string; r2: number; f_statistic: number | null; p_value: number | null }[]
+ terms?: PermanovaTerm[]
/** PERMDISP on the same distances: do the groups differ in spread? */
dispersion?: PermdispResult | { error: string }
}
@@ -381,18 +398,32 @@ export interface DifferentialRow {
taxon: string
status: DifferentialStatus
note: string
- /** Natural-log fold change, contrast over reference. */
+ /**
+ * nb_glm: natural-log fold change, contrast over reference. clr_welch: mean
+ * CLR of contrast minus mean CLR of reference, which is not a fold change.
+ */
estimate: number | null
+ /** nb_glm only; null for clr_welch. */
log2_fold_change: number | null
standard_error: number | null
+ /** z (nb_glm) or Welch's t (clr_welch). */
statistic: number | null
pvalue: number | null
/** Benjamini-Hochberg adjusted; null outside the tested family. */
padj: number | null
+ /** nb_glm only. */
dispersion_theta: number | null
prevalence: number
}
+export type DifferentialMethod = 'nb_glm' | 'clr_welch'
+
+/** Which row field holds the effect size, and what to call it. */
+export interface DifferentialEffect {
+ key: 'log2_fold_change' | 'estimate'
+ label: string
+}
+
export interface DifferentialResult {
status: 'ok' | 'partial'
method: string
@@ -400,9 +431,26 @@ export interface DifferentialResult {
table: string
groups: { reference: string; contrast: string }
n_samples: Record
+ effect: DifferentialEffect
+ /** Empty for clr_welch, which takes no library-size offset. */
size_factors: { sample: string; group: string; factor: number }[]
- config: { offset: 'tss' | 'rle'; min_prevalence: number }
- diagnostics: { n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number }
+ config: {
+ method: DifferentialMethod
+ min_prevalence: number
+ /** nb_glm only. */
+ offset?: 'tss' | 'rle'
+ /** clr_welch only. */
+ replacement_delta?: number
+ }
+ diagnostics: {
+ n_taxa: number; n_tested: number; n_failed: number; n_boundary: number; n_filtered: number
+ /** clr_welch only. */
+ zero_replacement?: {
+ method: string; delta: number; zeros_replaced: number; n_taxa_in_composition: number
+ n_taxa_unobserved: number; median_imputed_fraction: number; max_imputed_fraction: number
+ warnings: string[]
+ }
+ }
rows: DifferentialRow[]
figure: unknown
}
diff --git a/frontend/src/components/AnalysisWorkspace.tsx b/frontend/src/components/AnalysisWorkspace.tsx
index c8c6b5af..e7295004 100644
--- a/frontend/src/components/AnalysisWorkspace.tsx
+++ b/frontend/src/components/AnalysisWorkspace.tsx
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import type { AnnotationSource, ComparisonRunSpec } from '../api/types'
import { AnalysisSourceContext } from './analysisSource'
-import { useSharedResultsTables } from './annotationShared'
+import { useSharedResultsTablesState } from './annotationShared'
import { DiversityPanel } from './DiversityPanel'
import { TaxaCompositionChart } from './TaxaCompositionChart'
import { VennPanel } from './VennPanel'
@@ -80,7 +80,7 @@ export function AnalysisWorkspace({ study, runs: baseRuns, source, perRun }: {
}, [aggregate, selectedRuns])
//## Dataset: results tables shared by every selected run
- const options = useSharedResultsTables(study, selectedRuns)
+ const { options, error: optionsError } = useSharedResultsTablesState(study, selectedRuns)
const [analysisKey, setAnalysisKey] = useState(null)
useEffect(() => {
setAnalysisKey(current => current && options.some(o => o.key === current) ? current : options[0]?.key ?? null)
@@ -138,6 +138,8 @@ export function AnalysisWorkspace({ study, runs: baseRuns, source, perRun }: {
onChange={e => setAnalysisKey(e.target.value)}>
{options.map(o => {o.label} )}
+ ) : optionsError ? (
+ could not list the results tables: {optionsError}
) : (
no results table shared by the selected runs
)}
diff --git a/frontend/src/components/CompositionPanel.tsx b/frontend/src/components/CompositionPanel.tsx
index 77ad8869..5e677683 100644
--- a/frontend/src/components/CompositionPanel.tsx
+++ b/frontend/src/components/CompositionPanel.tsx
@@ -16,6 +16,12 @@ import type {
// Fixed colour of the catch-all "Unassigned" bucket, mirroring the backend.
const UNASSIGNED_COLOUR = '#95a5a6'
+/**
+ * Browse a run's composition by taxonomy rank or category, with sub-group and
+ * results-table selectors and controls to save category colours. The analysis
+ * context's annotation source takes precedence over `source`; absent both,
+ * VSEARCH is used. A failed table lookup clears the options and shows a toast.
+ */
export function CompositionPanel({
study, run, group, subgroups, source: configuredSource,
}: {
@@ -53,6 +59,10 @@ export function CompositionPanel({
setTableOptions(opts)
setTable(current => opts.some(o => o.key === current) ? current
: opts.some(o => o.key === 'merged') ? 'merged' : opts[0]?.key ?? current)
+ }).catch(err => {
+ if (cancelled) return
+ setTableOptions([])
+ toast.error(`Could not list the results tables: ${errorMessage(err)}`)
})
return () => { cancelled = true }
}, [study, run, group])
diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx
index ff15b507..8f78e326 100644
--- a/frontend/src/components/DifferentialPanel.tsx
+++ b/frontend/src/components/DifferentialPanel.tsx
@@ -1,16 +1,16 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { errorMessage } from '../api/errorMessage'
import { useToast } from './Toast'
import type { ComparisonRunSpec, DifferentialResult } from '../api/types'
-import { uniqueRuns } from '../api/types'
+import { sharedRanks } from './sharedRanks'
import type { AnalysisOption } from './annotationShared'
import { AnalysisChart } from './AnalysisChart'
import { AddToReport } from './AddToReport'
import { saveBlob } from '../utils/download'
-import { differentialCsv, formatStat } from './differentialTable'
+import { differentialCsv, effectValue, formatStat } from './differentialTable'
const cell = { padding: '3px 10px', textAlign: 'right' } as const
const head = { padding: '4px 10px', textAlign: 'right' } as const
@@ -21,10 +21,10 @@ function csvBlob(result: DifferentialResult): Blob {
}
/**
- * Differential abundance between exactly two runs or sub-groups: a
- * negative-binomial model per taxon at the chosen rank, with BH-adjusted
- * p-values, shown as a volcano plot and a table that can be downloaded or
- * added to the report. The first selected condition is the reference.
+ * Differential abundance between exactly two runs or sub-groups: a model per
+ * taxon at the chosen rank (negative-binomial GLM or Welch's t-test on CLR values, as
+ * configured), with BH-adjusted p-values, shown as a volcano plot and a table
+ * that can be downloaded or added to the report. The first selected condition is the reference.
*/
export function DifferentialPanel({ study, runs, option, aggregate }: {
study: string
@@ -36,42 +36,58 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
const [ranks, setRanks] = useState([])
const [rank, setRank] = useState('')
const [ranksReady, setRanksReady] = useState(false)
+ const [ranksError, setRanksError] = useState(null)
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
// The ranks shared by every selected run.
useEffect(() => {
+ setRanksError(null)
if (!option || runs.length === 0) { setRanks([]); setRank(''); setRanksReady(false); return }
let cancelled = false
setRanksReady(false)
- Promise.all(
- uniqueRuns(runs).map(r =>
- api.analysis.ranks(study, r.run, {
- table: option.table,
- group: r.group,
- ...(r.source ? { source: r.source } : {}),
- }).catch(() => [] as string[])
- )
- ).then(results => {
+ sharedRanks(runs, r => api.analysis.ranks(study, r.run, {
+ table: option.table,
+ group: r.group,
+ ...(r.source ? { source: r.source } : {}),
+ })).then(shared => {
if (cancelled) return
- const shared = results.reduce((acc, cur) => acc.filter(r => cur.includes(r)), results[0] ?? [])
setRanks(shared)
setRanksReady(true)
setRank(current => shared.includes(current) ? current : (shared[shared.length - 1] ?? ''))
+ }).catch(err => {
+ if (cancelled) return
+ setRanks([])
+ setRank('')
+ setRanksError(errorMessage(err))
})
return () => { cancelled = true }
}, [study, runs, option])
+ // A result answers the inputs it was computed from; new inputs discard it,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setResult(null)
+ setLoading(false)
+ }, [study, runs, option?.table, rank, aggregate])
+
+ /** Fit the model for the current inputs and show it, unless the inputs changed meanwhile. */
const run = async () => {
if (!option || !rank) return
+ const req = ++request.current
setLoading(true)
try {
- setResult(await api.analysis.differential(study, { runs, table: option.table, rank, aggregate }))
+ const res = await api.analysis.differential(study, { runs, table: option.table, rank, aggregate })
+ if (req === request.current) setResult(res)
} catch (err) {
+ if (req !== request.current) return
setResult(null)
toast.error(`Differential abundance failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
@@ -94,9 +110,12 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{ranksReady && ranks.length === 0 && (
No taxonomy rank shared by the selected runs.
)}
+ {ranksError && (
+ Could not read the taxonomy ranks: {ranksError}
+ )}
{runs.length === 2 && (
- Reference: {runs[0].prefix ?? runs[0].run}; positive fold changes mean more abundant in {runs[1].prefix ?? runs[1].run}.
+ Reference: {runs[0].prefix ?? runs[0].run}; a positive effect means more abundant in {runs[1].prefix ?? runs[1].run}.
)}
@@ -109,6 +128,12 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.diagnostics.n_boundary > 0 && `, ${result.diagnostics.n_boundary} at a dispersion boundary`}
{result.diagnostics.n_filtered > 0 && `, ${result.diagnostics.n_filtered} below the prevalence threshold`}
. Samples: {Object.entries(result.n_samples).map(([g, n]) => `${g} ${n}`).join(', ')}.
+ {result.diagnostics.zero_replacement && (() => {
+ const z = result.diagnostics.zero_replacement
+ return ` ${z.zeros_replaced} zeros replaced by ${z.method} over ${z.n_taxa_in_composition} taxa; ` +
+ `replaced values hold ${(100 * z.median_imputed_fraction).toPrecision(2)}% of the median sample ` +
+ `and at most ${(100 * z.max_imputed_fraction).toPrecision(2)}% of any, and are not measurements.`
+ })()}
@@ -126,8 +151,8 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.rank}
- log2 FC
- Estimate (ln)
+ {result.effect.label}
+ {result.config.method === 'nb_glm' && Estimate (ln) }
SE
p
padj (BH)
@@ -138,8 +163,8 @@ export function DifferentialPanel({ study, runs, option, aggregate }: {
{result.rows.map(r => (
{r.taxon}
- {formatStat(r.log2_fold_change)}
- {formatStat(r.estimate)}
+ {formatStat(effectValue(result, r))}
+ {result.config.method === 'nb_glm' && {formatStat(r.estimate)} }
{formatStat(r.standard_error)}
{formatStat(r.pvalue)}
{formatStat(r.padj)}
diff --git a/frontend/src/components/DiversityPanel.tsx b/frontend/src/components/DiversityPanel.tsx
index b3d16dd2..ae84d450 100644
--- a/frontend/src/components/DiversityPanel.tsx
+++ b/frontend/src/components/DiversityPanel.tsx
@@ -1,6 +1,6 @@
// © 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { AnalysisChart } from './AnalysisChart'
import { useAlphaMetricSelection, AlphaMetricToggles, ALPHA_METRICS, extractAlphaPanel } from './alphaMetrics'
@@ -22,11 +22,27 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
const [permanova, setPermanova] = useState(null)
const [loading, setLoading] = useState(null)
const [rAvailable, setRAvailable] = useState(null)
+ const [capabilitiesError, setCapabilitiesError] = useState(null)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
+ // A failed check is reported as such, not as "R is unavailable".
useEffect(() => {
- api.analysis.capabilities().then(c => setRAvailable(c.r_available)).catch(() => setRAvailable(false))
+ api.analysis.capabilities()
+ .then(c => setRAvailable(c.r_available))
+ .catch(err => setCapabilitiesError(errorMessage(err)))
}, [])
+ // Results answer the inputs they were computed from; new inputs discard them,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setAlphaFig(null)
+ setNmdsFig(null)
+ setPermanova(null)
+ setLoading(null)
+ }, [study, runs, option?.table, aggregate])
+
const body = option && runs.length >= 2
? {
table: option.table,
@@ -36,22 +52,33 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
}
: null
+ /** Compute one analysis for the current inputs and show it, unless the inputs changed meanwhile. */
const run = async (type: 'alpha' | 'nmds' | 'permanova') => {
if (!body) return
+ const req = ++request.current
+ const current = () => req === request.current
setLoading(type)
try {
if (type === 'alpha') {
- setAlphaFig(await api.analysis.compareAlpha(study, body))
+ const fig = await api.analysis.compareAlpha(study, body)
+ if (current()) setAlphaFig(fig)
} else if (type === 'nmds') {
- setNmdsFig(await api.analysis.nmds(study, body))
+ const fig = await api.analysis.nmds(study, body)
+ if (current()) setNmdsFig(fig)
} else {
- setPermanova(await api.analysis.permanova(study, body))
+ const res = await api.analysis.permanova(study, body)
+ if (current()) setPermanova(res)
}
} catch (err) {
+ if (!current()) return
+ // A failed rerun must not leave the previous result looking current.
+ if (type === 'alpha') setAlphaFig(null)
+ else if (type === 'nmds') setNmdsFig(null)
+ else setPermanova(null)
const label = type === 'alpha' ? 'Alpha comparison' : type === 'nmds' ? 'NMDS' : 'PERMANOVA'
toast.error(`${label} failed: ${errorMessage(err)}`)
} finally {
- setLoading(null)
+ if (current()) setLoading(null)
}
}
@@ -82,6 +109,11 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
NMDS and PERMANOVA need R with vegan.
)}
+ {capabilitiesError && (
+
+ Could not check whether R is available: {capabilitiesError}
+
+ )}
{alphaFig != null && (
@@ -99,19 +131,20 @@ export function DiversityPanel({ study, runs, option, aggregate }: {
PERMANOVA Results
{permanova.text}
- {(permanova.terms?.length
- ? permanova.terms
- : permanova.p_value != null
- ? [{ term: 'model', r2: permanova.r2 ?? NaN, f_statistic: permanova.f_statistic, p_value: permanova.p_value }]
- : []
- ).map(t => (
+ {(permanova.terms ?? []).map(t => (
- {t.term}: R² = {t.r2.toFixed(3)} · F = {t.f_statistic?.toFixed(2) ?? 'n/a'} · p = {t.p_value?.toFixed(4) ?? 'n/a'}
+ {t.term}: R² = {t.r2.toFixed(3)} · F = {t.f_statistic?.toFixed(2) ?? 'n/a'} ·{' '}
+ {t.untestable_reason ? `p not reported: ${t.untestable_reason}` : `p = ${t.p_value?.toFixed(4) ?? 'n/a'}`}
))}
{(permanova.terms?.length ?? 0) > 1 && (
Terms are tested in order, each after the ones above it.
)}
+ {permanova.permutations != null && permanova.min_p_value != null && (
+
+ {permanova.permutations} permutations; the smallest attainable p is {permanova.min_p_value.toFixed(4)}.
+
+ )}
{permanova.blocked && (
Permutations restricted within individuals.
)}
diff --git a/frontend/src/components/PipelineStages.tsx b/frontend/src/components/PipelineStages.tsx
index 8a96ab41..85129267 100644
--- a/frontend/src/components/PipelineStages.tsx
+++ b/frontend/src/components/PipelineStages.tsx
@@ -150,8 +150,10 @@ export const CONFIG_DESCRIPTIONS: Record
= {
'analysis.alpha.rarefaction_iterations': 'rarefy draws averaged for each alpha diversity value.',
'analysis.beta.normalisation': 'Preparation before the Bray-Curtis dissimilarity used by NMDS and PERMANOVA: hellinger (square root of relative abundances), none, rarefy or srs.',
'analysis.beta.depth': 'Reads per sample for rarefy or srs; 0 uses the smallest non-empty sample.',
- 'analysis.differential.offset': 'Library-size offset in each negative-binomial model: tss (total reads per sample) or rle (median-of-ratios over taxa present in every sample; refused when there are none).',
+ 'analysis.differential.method': 'Model fitted to each taxon: nb_glm (negative-binomial GLM on read counts; effect is a log2 fold change) or clr_welch (Welch\'s t-test on centred log-ratios over the taxa passing min_prevalence, after zCompositions::cmultRepl zero replacement; effect is a CLR difference, not a fold change).',
+ 'analysis.differential.offset': 'Library-size offset in each negative-binomial model: tss (total reads per sample) or rle (median-of-ratios over taxa present in every sample; refused when there are none). Used by nb_glm only.',
'analysis.differential.min_prevalence': 'Fraction of samples, 0 to 1, in which a taxon must have reads to be tested; taxa below it are listed as filtered.',
+ 'analysis.differential.replacement_delta': 'clr_welch only: cmultRepl\'s frac, between 0 and 1; an imputed proportion above its taxon\'s smallest observed proportion is lowered to this fraction of it. Replaced zeros are not measurements.',
'analysis.alpha.show_points': 'Show individual samples overlaid on alpha comparison boxplots.',
'analysis.alpha.annotate_significance': 'Annotate alpha comparison panels with Kruskal-Wallis significance.',
'analysis.alpha.pairwise_brackets': 'Run BH-adjusted pairwise Wilcoxon rank-sum tests between every group pair and draw brackets, including n.s. results.',
@@ -248,8 +250,10 @@ export const CONFIG_TYPES: Record = {
'analysis.alpha.rarefaction_iterations': { kind: 'int' },
'analysis.beta.normalisation': { kind: 'enum', options: ['hellinger', 'none', 'rarefy', 'srs'] },
'analysis.beta.depth': { kind: 'int' },
+ 'analysis.differential.method': { kind: 'enum', options: ['nb_glm', 'clr_welch'] },
'analysis.differential.offset': { kind: 'enum', options: ['tss', 'rle'] },
'analysis.differential.min_prevalence': { kind: 'float', min: 0, max: 1, step: 0.05 },
+ 'analysis.differential.replacement_delta': { kind: 'float', min: 0.01, max: 0.99, step: 0.05 },
'analysis.alpha.show_points': { kind: 'boolean' },
'analysis.alpha.annotate_significance': { kind: 'boolean' },
'analysis.alpha.pairwise_brackets': { kind: 'boolean' },
diff --git a/frontend/src/components/TaxaCompositionChart.tsx b/frontend/src/components/TaxaCompositionChart.tsx
index 2b8221a4..9601aebd 100644
--- a/frontend/src/components/TaxaCompositionChart.tsx
+++ b/frontend/src/components/TaxaCompositionChart.tsx
@@ -1,6 +1,6 @@
// (c) 2026 Joshua Benjamin Jewell. All rights reserved.
// Licensed under the GNU Affero General Public License version 3 (AGPLv3).
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { api } from '../api/client'
import { useAnalysisSource } from './analysisSource'
import { errorMessage } from '../api/errorMessage'
@@ -24,6 +24,13 @@ const LABEL_STYLE: React.CSSProperties = {
gap: 4,
}
+/**
+ * Select and compute a taxon or category bar chart for one run, or for `runs`
+ * when that list is non-empty, with optional facets across runs. `table`
+ * defaults to merged. Providing `subgroup`, including null for all sub-groups,
+ * controls that selection and hides the internal selector; facets set their
+ * own scope. Changed inputs clear the figure; chart failures show a toast.
+ */
export function TaxaCompositionChart({
study,
run,
@@ -78,6 +85,8 @@ export function TaxaCompositionChart({
//## Chart state
const [figure, setFigure] = useState(null)
const [loading, setLoading] = useState(false)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
//## Derived flags
const isCrossRun = runs !== undefined && runs.length > 0
@@ -102,9 +111,11 @@ export function TaxaCompositionChart({
useEffect(() => {
if (tag !== 'rank') return
if (!refRun) { setRanks([]); return }
+ let cancelled = false
api.analysis
.ranks(study, refRun, { table: effectiveTable, group: refGroup ?? undefined })
.then(result => {
+ if (cancelled) return
setRanks(result)
setRank(current =>
current && result.includes(current)
@@ -112,7 +123,8 @@ export function TaxaCompositionChart({
: result[result.length - 1] ?? null
)
})
- .catch(() => setRanks([]))
+ .catch(() => { if (!cancelled) setRanks([]) })
+ return () => { cancelled = true }
}, [study, refRun, refGroup, tag, effectiveTable])
//## Fetch category sets when tag='category'
@@ -131,7 +143,22 @@ export function TaxaCompositionChart({
const gridRows =
(figure as { layout?: { grid?: { rows?: number } } } | null)?.layout?.grid?.rows ?? 1
+ // Everything a request is built from. A figure answers the inputs it was
+ // computed from, so new inputs discard it, along with any request still in
+ // flight for the old ones. Keyed on content so a new-but-equal `runs` array
+ // does not clear the chart.
+ const inputsKey = JSON.stringify([
+ study, run, group, runs, effectiveTable, source, tag, rank, catSet, topN,
+ relative, mode, keepEmpty, isFaceted, facetRows, facetCols, subgroup,
+ ])
+ useEffect(() => {
+ request.current++
+ setFigure(null)
+ setLoading(false)
+ }, [inputsKey])
+
//## Compute handler
+ /** Build the chart for the current inputs and show it, unless the inputs changed meanwhile. */
const handleShow = async () => {
const value = tag === 'rank' ? rank : catSet
if (!value) return
@@ -140,6 +167,7 @@ export function TaxaCompositionChart({
toast.error('No run specified')
return
}
+ const req = ++request.current
setLoading(true)
try {
const body = {
@@ -151,27 +179,31 @@ export function TaxaCompositionChart({
keep_empty: keepEmpty,
...(tag === 'rank' ? { top_n: topN } : {}),
}
+ let fig: unknown
if (isFaceted) {
- setFigure(await api.analysis.chartFacet(study, {
+ fig = await api.analysis.chartFacet(study, {
...body,
runs: runs!,
rows: facetRows,
cols: facetCols,
- }))
+ })
} else if (isCrossRun) {
- setFigure(await api.analysis.chartCompare(study, {
+ fig = await api.analysis.chartCompare(study, {
...body,
subgroup: subgroup ?? null,
runs: runs!,
- }))
+ })
} else {
- setFigure(await api.analysis.chart(study, run!,
- { ...body, subgroup: subgroup ?? null, ...(source ? { source } : {}) }, group))
+ fig = await api.analysis.chart(study, run!,
+ { ...body, subgroup: subgroup ?? null, ...(source ? { source } : {}) }, group)
}
+ if (req === request.current) setFigure(fig)
} catch (err) {
+ if (req !== request.current) return
+ setFigure(null)
toast.error(`Chart failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
diff --git a/frontend/src/components/VennPanel.tsx b/frontend/src/components/VennPanel.tsx
index e276f24d..4ed37a0f 100644
--- a/frontend/src/components/VennPanel.tsx
+++ b/frontend/src/components/VennPanel.tsx
@@ -8,11 +8,12 @@ import { api } from '../api/client'
import { errorMessage } from '../api/errorMessage'
import { useToast } from './Toast'
import type { ComparisonRunSpec, VennResult } from '../api/types'
-import { uniqueRuns } from '../api/types'
+import { sharedRanks } from './sharedRanks'
import type { AnalysisOption } from './annotationShared'
type Mode = 'euler' | 'upset'
+/** Taxon overlap between the selected runs at one shared rank, as an Euler or UpSet diagram. */
export function VennPanel({ study, runs, option }: {
study: string
runs: ComparisonRunSpec[]
@@ -25,6 +26,9 @@ export function VennPanel({ study, runs, option }: {
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [ranksReady, setRanksReady] = useState(false)
+ const [ranksError, setRanksError] = useState(null)
+ // Only the response to the latest request, for the current inputs, is shown.
+ const request = useRef(0)
const box = useRef(null)
const [width, setWidth] = useState(580)
@@ -38,35 +42,43 @@ export function VennPanel({ study, runs, option }: {
// Discover the intersection of taxonomy ranks available across all selected runs.
useEffect(() => {
+ setRanksError(null)
if (!option || runs.length === 0) { setRanks([]); setRank(''); setRanksReady(false); return }
let cancelled = false
setRanksReady(false)
- Promise.all(
- uniqueRuns(runs).map(r =>
- api.analysis.ranks(study, r.run, {
- table: option.table,
- group: r.group,
- ...(r.source ? { source: r.source } : {}),
- }).catch(() => [] as string[])
- )
- ).then(results => {
+ sharedRanks(runs, r => api.analysis.ranks(study, r.run, {
+ table: option.table,
+ group: r.group,
+ ...(r.source ? { source: r.source } : {}),
+ })).then(intersection => {
if (cancelled) return
- const intersection = results.reduce((acc, cur) =>
- acc.filter(r => cur.includes(r)),
- results[0] ?? []
- )
setRanks(intersection)
setRanksReady(true)
// Default to the deepest (last) shared rank.
setRank(current =>
intersection.includes(current) ? current : (intersection[intersection.length - 1] ?? '')
)
+ }).catch(err => {
+ if (cancelled) return
+ setRanks([])
+ setRank('')
+ setRanksError(errorMessage(err))
})
return () => { cancelled = true }
}, [study, runs, option])
+ // A diagram answers the inputs it was computed from; new inputs discard it,
+ // along with any request still in flight for the old ones.
+ useEffect(() => {
+ request.current++
+ setResult(null)
+ setLoading(false)
+ }, [study, runs, option?.table, rank])
+
+ /** Compute the overlap for the current inputs and show it, unless the inputs changed meanwhile. */
const runVenn = async () => {
if (!option || !rank) return
+ const req = ++request.current
setLoading(true)
try {
const res = await api.analysis.venn(study, {
@@ -74,11 +86,13 @@ export function VennPanel({ study, runs, option }: {
table: option.table,
rank,
})
- setResult(res)
+ if (req === request.current) setResult(res)
} catch (err) {
+ if (req !== request.current) return
+ setResult(null)
toast.error(`Taxon overlap failed: ${errorMessage(err)}`)
} finally {
- setLoading(false)
+ if (req === request.current) setLoading(false)
}
}
@@ -111,6 +125,9 @@ export function VennPanel({ study, runs, option }: {
{ranksReady && ranks.length === 0 && (
No taxonomy rank shared by the selected runs.
)}
+ {ranksError && (
+ Could not read the taxonomy ranks: {ranksError}
+ )}
+import { afterEach, describe, expect, spyOn, test } from 'bun:test'
+import { api } from '../api/client'
+import type { TableMeta } from '../api/types'
+import { discoverResultsTables, sharedOptions } from './annotationShared'
+
+const meta = (id: string, label: string) => ({ id, label }) as TableMeta
+
+describe('discoverResultsTables', () => {
+ let spy: ReturnType | null = null
+ afterEach(() => { spy?.mockRestore(); spy = null })
+
+ test('lists the run\'s tables sorted by label', async () => {
+ spy = spyOn(api.results, 'runTables').mockResolvedValue([meta('merged_otu', 'OTUs'), meta('merged', 'Merged')])
+ expect(await discoverResultsTables('s', 'r')).toEqual([
+ { key: 'merged', table: 'merged', label: 'Merged' },
+ { key: 'merged_otu', table: 'merged_otu', label: 'OTUs' },
+ ])
+ })
+
+ test('a failed lookup rejects instead of reading as no tables', async () => {
+ spy = spyOn(api.results, 'runTables').mockRejectedValue(new Error('HTTP 503'))
+ await expect(discoverResultsTables('s', 'r')).rejects.toThrow('HTTP 503')
+ })
+})
+
+describe('sharedOptions', () => {
+ const o = (key: string, label = key) => ({ key, table: key, label })
+
+ test('keeps only the tables every run has', () => {
+ expect(sharedOptions([[o('b'), o('a'), o('c')], [o('c'), o('a')]])).toEqual([o('a'), o('c')])
+ })
+
+ test('no runs means no tables', () => {
+ expect(sharedOptions([])).toEqual([])
+ })
+})
diff --git a/frontend/src/components/annotationShared.ts b/frontend/src/components/annotationShared.ts
index d1050796..336ede88 100644
--- a/frontend/src/components/annotationShared.ts
+++ b/frontend/src/components/annotationShared.ts
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import type { ComparisonRunSpec, ConfigSource, TableMeta } from '../api/types'
import { uniqueRuns } from '../api/types'
import { api } from '../api/client'
+import { errorMessage } from '../api/errorMessage'
export const SOURCE_COLORS: Record = {
default: 'var(--color-muted-fg)',
@@ -23,39 +24,57 @@ export interface AnalysisOption {
/**
* Discover the analysable result tables for a single run (merged, merged_otu,
* and any saved sub-tables) from the run's results database. Analysis reads the
- * merged results table directly.
+ * merged results table directly. Rejects when the tables cannot be listed.
*/
export async function discoverResultsTables(
study: string, run: string, group?: string | null,
): Promise {
- const tables = await api.results.runTables(study, run, group).catch(() => [] as TableMeta[])
+ const tables: TableMeta[] = await api.results.runTables(study, run, group)
return tables
.map(meta => ({ key: meta.id, table: meta.id, label: meta.label }))
.sort((a, b) => a.label.localeCompare(b.label))
}
-/** Result tables present in every run of `runs`, sorted by label. */
-export function useSharedResultsTables(study: string, runs: ComparisonRunSpec[]): AnalysisOption[] {
- const [options, setOptions] = useState([])
+/**
+ * The options present in every list of `results` (keyed by `key`), sorted by
+ * label. No lists means no options.
+ */
+export function sharedOptions(results: AnalysisOption[][]): AnalysisOption[] {
+ const shared = new Map()
+ results.forEach((opts, index) => {
+ const keys = new Set(opts.map(o => o.key))
+ if (index === 0) {
+ for (const o of opts) shared.set(o.key, o)
+ } else {
+ for (const key of [...shared.keys()]) {
+ if (!keys.has(key)) shared.delete(key)
+ }
+ }
+ })
+ return [...shared.values()].sort((a, b) => a.label.localeCompare(b.label))
+}
+
+/**
+ * Result tables present in every run of `runs`, sorted by label, with the
+ * message of a failed lookup in `error` (and no options) so that a failure is
+ * not read as "no table shared".
+ */
+export function useSharedResultsTablesState(
+ study: string, runs: ComparisonRunSpec[],
+): { options: AnalysisOption[]; error: string | null } {
+ const [state, setState] = useState<{ options: AnalysisOption[]; error: string | null }>(
+ { options: [], error: null })
useEffect(() => {
let cancelled = false
Promise.all(uniqueRuns(runs).map(r => discoverResultsTables(study, r.run, r.group)))
- .then(results => {
- if (cancelled) return
- const shared = new Map()
- results.forEach((opts, index) => {
- const keys = new Set(opts.map(o => o.key))
- if (index === 0) {
- for (const o of opts) shared.set(o.key, o)
- } else {
- for (const key of [...shared.keys()]) {
- if (!keys.has(key)) shared.delete(key)
- }
- }
- })
- setOptions([...shared.values()].sort((a, b) => a.label.localeCompare(b.label)))
- })
+ .then(results => { if (!cancelled) setState({ options: sharedOptions(results), error: null }) })
+ .catch(err => { if (!cancelled) setState({ options: [], error: errorMessage(err) }) })
return () => { cancelled = true }
}, [study, runs])
- return options
+ return state
+}
+
+/** Result tables present in every run of `runs`, sorted by label; none when the lookup fails. */
+export function useSharedResultsTables(study: string, runs: ComparisonRunSpec[]): AnalysisOption[] {
+ return useSharedResultsTablesState(study, runs).options
}
diff --git a/frontend/src/components/differentialTable.test.ts b/frontend/src/components/differentialTable.test.ts
index cdf06fc2..f2143e07 100644
--- a/frontend/src/components/differentialTable.test.ts
+++ b/frontend/src/components/differentialTable.test.ts
@@ -2,7 +2,7 @@
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
import { describe, expect, test } from 'bun:test'
import type { DifferentialResult } from '../api/types'
-import { csvField, differentialCsv, formatStat } from './differentialTable'
+import { configLine, csvField, differentialCsv, effectValue, formatStat } from './differentialTable'
const result: DifferentialResult = {
status: 'partial',
@@ -11,8 +11,9 @@ const result: DifferentialResult = {
table: 'merged',
groups: { reference: 'run_A', contrast: 'run_B' },
n_samples: { run_A: 2, run_B: 2 },
+ effect: { key: 'log2_fold_change', label: 'log2 fold change' },
size_factors: [],
- config: { offset: 'tss', min_prevalence: 0 },
+ config: { method: 'nb_glm', offset: 'tss', min_prevalence: 0 },
diagnostics: { n_taxa: 2, n_tested: 1, n_failed: 1, n_boundary: 0, n_filtered: 0 },
rows: [
{ taxon: 'Bacteroides', status: 'ok', note: '', estimate: 1.3862943611198906,
@@ -52,3 +53,24 @@ describe('formatStat', () => {
expect(formatStat(2.9e-8)).toBe('2.90e-8')
})
})
+
+describe('clr_welch results', () => {
+ const clr: DifferentialResult = {
+ ...result,
+ method: 'CLR Welch',
+ effect: { key: 'estimate', label: 'CLR difference' },
+ config: { method: 'clr_welch', min_prevalence: 0, replacement_delta: 0.65 },
+ rows: [{ ...result.rows[0], estimate: -0.75, log2_fold_change: null, dispersion_theta: null }],
+ }
+
+ test('the effect comes from the field the result names, not log2_fold_change', () => {
+ expect(effectValue(clr, clr.rows[0])).toBe(-0.75)
+ expect(effectValue(result, result.rows[0])).toBe(2)
+ })
+
+ test('the settings line names the delta and says the estimate is not a fold change', () => {
+ expect(configLine(clr)).toBe('method clr_welch, replacement_delta 0.65, min_prevalence 0; estimate is a CLR difference, not a fold change')
+ expect(configLine(result)).toBe('method nb_glm, offset tss, min_prevalence 0')
+ expect(differentialCsv(clr).split('\r\n')[2]).toBe(`# ${configLine(clr)}`)
+ })
+})
diff --git a/frontend/src/components/differentialTable.ts b/frontend/src/components/differentialTable.ts
index cba1d09f..90a547a8 100644
--- a/frontend/src/components/differentialTable.ts
+++ b/frontend/src/components/differentialTable.ts
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
-import type { DifferentialResult } from '../api/types'
+import type { DifferentialResult, DifferentialRow } from '../api/types'
const COLUMNS = [
'taxon', 'status', 'log2_fold_change', 'estimate', 'standard_error', 'statistic',
@@ -25,7 +25,7 @@ export function differentialCsv(result: DifferentialResult): string {
const header = [
`# Differential abundance: ${contrast} vs ${reference} (reference), rank ${result.rank}, table ${result.table}`,
`# ${result.method}`,
- `# offset ${result.config.offset}, min_prevalence ${result.config.min_prevalence}`,
+ `# ${configLine(result)}`,
].map(l => l.replace(/[\r\n]+/g, ' '))
const rows = result.rows.map(r => COLUMNS.map(c => csvField(r[c])).join(','))
return [...header, COLUMNS.join(','), ...rows].join('\r\n') + '\r\n'
@@ -38,3 +38,20 @@ export function formatStat(value: number | null): string {
const a = Math.abs(value)
return a < 1e-3 || a >= 1e4 ? value.toExponential(2) : value.toPrecision(3)
}
+
+/**
+ * The settings line of the CSV: the method and the parameters it used. The
+ * offset belongs to nb_glm and the replacement delta to clr_welch, so each line
+ * names only its own method's parameters.
+ */
+export function configLine(result: DifferentialResult): string {
+ const c = result.config
+ return c.method === 'clr_welch'
+ ? `method clr_welch, replacement_delta ${c.replacement_delta}, min_prevalence ${c.min_prevalence}; estimate is a CLR difference, not a fold change`
+ : `method nb_glm, offset ${c.offset}, min_prevalence ${c.min_prevalence}`
+}
+
+/** The effect size of one row, from the field the result names in `effect`. */
+export function effectValue(result: DifferentialResult, row: DifferentialRow): number | null {
+ return row[result.effect.key]
+}
diff --git a/frontend/src/components/sharedRanks.test.ts b/frontend/src/components/sharedRanks.test.ts
new file mode 100644
index 00000000..21b51b3e
--- /dev/null
+++ b/frontend/src/components/sharedRanks.test.ts
@@ -0,0 +1,36 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+import { describe, expect, test } from 'bun:test'
+import type { ComparisonRunSpec } from '../api/types'
+import { sharedRanks } from './sharedRanks'
+
+const runs: ComparisonRunSpec[] = [
+ { run: 'A', group: null, prefix: 'x' },
+ { run: 'A', group: null, prefix: 'y' },
+ { run: 'B', group: 'g', prefix: null },
+]
+const RANKS: Record = {
+ A: ['Kingdom', 'Phylum', 'Class', 'Genus'],
+ B: ['Kingdom', 'Phylum', 'Genus', 'Species'],
+}
+
+describe('sharedRanks', () => {
+ test('intersects the ranks of each distinct run, in the first run\'s order', async () => {
+ const asked: string[] = []
+ const ranks = await sharedRanks(runs, async r => { asked.push(r.run); return RANKS[r.run] })
+ expect(ranks).toEqual(['Kingdom', 'Phylum', 'Genus'])
+ expect(asked).toEqual(['A', 'B'])
+ })
+
+ test('no shared rank is an empty list', async () => {
+ expect(await sharedRanks(runs, async r => r.run === 'A' ? ['Genus'] : ['Species'])).toEqual([])
+ })
+
+ test('a failed request rejects instead of reading as no shared rank', async () => {
+ const failing = sharedRanks(runs, async r => {
+ if (r.run === 'B') throw new Error('HTTP 500: R error')
+ return RANKS[r.run]
+ })
+ await expect(failing).rejects.toThrow('HTTP 500: R error')
+ })
+})
diff --git a/frontend/src/components/sharedRanks.ts b/frontend/src/components/sharedRanks.ts
new file mode 100644
index 00000000..1d450f2f
--- /dev/null
+++ b/frontend/src/components/sharedRanks.ts
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+import type { ComparisonRunSpec } from '../api/types'
+import { uniqueRuns } from '../api/types'
+
+/**
+ * The taxonomy ranks offered by every run in `runs`, in the first run's order.
+ * Rejects when any run's ranks cannot be read: a failed request must not read
+ * as "no rank shared by the selected runs".
+ */
+export async function sharedRanks(
+ runs: ComparisonRunSpec[],
+ ranksOf: (run: ComparisonRunSpec) => Promise,
+): Promise {
+ const results = await Promise.all(uniqueRuns(runs).map(ranksOf))
+ return results.reduce((acc, cur) => acc.filter(r => cur.includes(r)), results[0] ?? [])
+}
diff --git a/frontend/src/hooks/useApi.test.ts b/frontend/src/hooks/useApi.test.ts
new file mode 100644
index 00000000..bb30924a
--- /dev/null
+++ b/frontend/src/hooks/useApi.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, test } from 'bun:test'
+import { failedState, type State } from './useApi'
+
+const shown: State = { data: ['row from the old filters'], loading: true, error: null }
+
+describe('useApi failed request', () => {
+ test('drops data fetched for other inputs', () => {
+ expect(failedState(shown, 'HTTP 500', false)).toEqual({ data: null, loading: false, error: 'HTTP 500' })
+ })
+
+ test('keeps data when the same request is re-run and fails', () => {
+ expect(failedState(shown, 'HTTP 500', true)).toEqual({
+ data: ['row from the old filters'], loading: false, error: 'HTTP 500',
+ })
+ })
+})
diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts
index 18e14975..9b3b03e7 100644
--- a/frontend/src/hooks/useApi.ts
+++ b/frontend/src/hooks/useApi.ts
@@ -1,14 +1,25 @@
import { useState, useEffect, useCallback, useRef } from 'react'
-interface State {
+export interface State {
data: T | null
loading: boolean
error: string | null
}
+/**
+ * The state after a request fails. The previous data survives only when
+ * `sameRequest` says it came from this very request (a refetch of unchanged
+ * inputs); data fetched for other inputs, such as other filters, is dropped so
+ * it cannot be shown as if it answered the new request.
+ */
+export function failedState(prev: State, error: string, sameRequest: boolean): State {
+ return { data: sameRequest ? prev.data : null, loading: false, error }
+}
+
/**
* Minimal data-fetching hook. Re-fetches when `fetcher` reference changes.
- * Data is kept across refetches unless `resetKey` changes.
+ * Data is kept across refetches unless `resetKey` changes, and is cleared when
+ * a request for different inputs (a new `fetcher`) fails.
* Returns `{ data, loading, error, refetch }`.
*/
export function useApi(fetcher: () => Promise, resetKey?: unknown): State & { refetch: () => void } {
@@ -17,16 +28,28 @@ export function useApi(fetcher: () => Promise, resetKey?: unknown): State<
const refetch = useCallback(() => setTick(t => t + 1), [])
const lastKey = useRef(resetKey)
+ // The fetcher whose response is in `state.data`, or null when there is none.
+ const dataFrom = useRef<(() => Promise) | null>(null)
useEffect(() => {
let cancelled = false
// A new resetKey names a different resource, so its old data is cleared while loading.
const changed = lastKey.current !== resetKey
lastKey.current = resetKey
+ if (changed) dataFrom.current = null
setState(s => ({ data: changed ? null : s.data, loading: true, error: null }))
fetcher()
- .then(data => { if (!cancelled) setState({ data, loading: false, error: null }) })
- .catch(err => { if (!cancelled) setState(s => ({ ...s, loading: false, error: String(err.message ?? err) })) })
+ .then(data => {
+ if (cancelled) return
+ dataFrom.current = fetcher
+ setState({ data, loading: false, error: null })
+ })
+ .catch(err => {
+ if (cancelled) return
+ const same = dataFrom.current === fetcher
+ if (!same) dataFrom.current = null
+ setState(s => failedState(s, String(err.message ?? err), same))
+ })
return () => { cancelled = true }
}, [tick, fetcher, resetKey])
diff --git a/proofs/agda/MetaManifold/All.agda b/proofs/agda/MetaManifold/All.agda
new file mode 100644
index 00000000..7cedd39a
--- /dev/null
+++ b/proofs/agda/MetaManifold/All.agda
@@ -0,0 +1,14 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- The root of the proof tree. Typechecking this file typechecks every proof
+-- module; the axiom audit (proofs/tests/axiom-audit.sh) refuses any module
+-- under proofs/agda/ that this file does not reach.
+
+{-# OPTIONS --safe --without-K #-}
+
+module MetaManifold.All where
+
+import MetaManifold.Prelude
+import MetaManifold.BenjaminiHochberg
+import MetaManifold.Scaling
diff --git a/proofs/agda/MetaManifold/BenjaminiHochberg.agda b/proofs/agda/MetaManifold/BenjaminiHochberg.agda
new file mode 100644
index 00000000..9a0b8371
--- /dev/null
+++ b/proofs/agda/MetaManifold/BenjaminiHochberg.agda
@@ -0,0 +1,582 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- The Benjamini–Hochberg step-up adjustment, over exact rationals, with the
+-- properties a reader of a q-value table relies on proved rather than tested.
+--
+-- `bhAdjust` below models `bh_adjust` in `src/analysis/differential.jl`
+-- (branch feat/differential-abundance-nb, commit 6c3e8b2):
+--
+-- Julia here
+-- ───────────────────────────────────────── ──────────────────────────────
+-- n = length(p) m = length ps
+-- order = sortperm(p; rev=true) order = sort (indexFrom 0 ps),
+-- ascending, lexicographic on
+-- (p, index)
+-- running = 1.0 running m j: the running
+-- for (k, idx) in enumerate(order) minimum, listed in ascending
+-- rank = n - k + 1 rank order; `minInf` starts
+-- running = min(running, n/rank * p[idx]) it from the largest rank
+-- out[idx] = min(running, 1.0) stepUp = map (_⊓ 1ℚ) ∘ running
+-- restore: output position idx
+-- reads the value whose key
+-- has index idx
+--
+-- Two differences. The first is proved harmless; the second follows from a
+-- proved lemma by a short argument about the Julia code, which is not itself
+-- formalised:
+--
+-- * Julia seeds `running` with 1.0, so its running value at rank i is
+-- min(1, min_{j ≥ i} n/j · p_(j)) and the final clamp is idempotent.
+-- The model seeds with the first scaled value and clamps afterwards.
+-- `stepUp-envelope` proves the model's values equal that same envelope,
+-- so the two compute the same list.
+-- * Tie-breaking. Julia walks a stable DESCENDING sort, so of two equal
+-- p-values the earlier input index gets the HIGHER rank; the model's
+-- ascending (p, index) sort gives it the lower rank. The sorted list of
+-- p-values is the same either way, and tied p-values receive equal
+-- adjusted values whatever their order (`bh-monotone` applied in both
+-- directions), so which tied index reads which tied slot cannot change
+-- the output.
+--
+-- Julia's ranks are one-based; here they are zero-based (`j = rank - 1`),
+-- so Julia's `n / rank` is `rankFactor m j = m / (j + 1)` from the Prelude.
+--
+-- Julia rejects any p-value outside [0, 1] (and any non-finite value) with an
+-- ArgumentError before it computes anything. Over ℚ there is nothing
+-- non-finite, so that guard becomes the hypothesis `All Valid ps` on every
+-- theorem: the theorems speak about exactly the inputs Julia accepts.
+--
+-- Theorems (names as in proofs/agda/README.md):
+--
+-- bh-length the output has one entry per input
+-- bh-nonneg R-BH-1 every adjusted value is ≥ 0
+-- bh-dominates-p R-BH-2a adjusted_i ≥ p_i
+-- bh-at-most-one R-BH-2b adjusted_i ≤ 1
+-- bh-monotone R-BH-2c p_i ≤ p_j ⇒ adjusted_i ≤ adjusted_j
+-- (ties included, any tie-break)
+-- bh-envelope R-BH-2d the adjusted value at sorted rank k is
+-- min(1, min_{j ≥ k} p_(j) · m/(j+1))
+-- bh-output-is-envelope every (p, adjusted) pair of the output is a
+-- (p_(k), envelope_k) pair of the sorted stage
+--
+-- The archived residue file recorded "q_i ≥ p_i is FALSE in general". That is
+-- true of the bare scaling `p · M/(j+1)` for an arbitrary M (take M < j + 1),
+-- but not of the procedure: the rank of a p-value never exceeds the family
+-- size, so every factor m/(j+1) is ≥ 1, and R-BH-2a holds. It is proved here.
+
+{-# OPTIONS --safe --without-K #-}
+
+module MetaManifold.BenjaminiHochberg where
+
+open import MetaManifold.Prelude
+
+open import Data.Integer.Base as ℤ using (ℤ; +_; +[1+_])
+import Data.Integer.Properties as ℤₚ
+open import Data.Nat.Base as ℕ using (ℕ; zero; suc; z≤n; s≤s)
+import Data.Nat.Properties as ℕₚ
+open import Data.List.Base using (List; []; _∷_; map; zip; length; foldr)
+import Data.List.Properties as Listₚ
+open import Data.List.Relation.Unary.All as All using (All; []; _∷_)
+import Data.List.Relation.Unary.All.Properties as Allₚ
+open import Data.List.Relation.Unary.Any as Any using (Any; here; there)
+open import Data.List.Relation.Unary.Linked as Linked using (Linked; []; [-]; _∷_)
+import Data.List.Relation.Unary.Linked.Properties as Linkedₚ
+open import Data.List.Relation.Unary.AllPairs as AllPairs using (AllPairs; []; _∷_)
+open import Data.List.Membership.Propositional using (_∈_)
+import Data.List.Membership.Propositional.Properties as ∈ₚ
+open import Data.List.Relation.Binary.Permutation.Propositional using (_↭_; ↭-sym)
+import Data.List.Relation.Binary.Permutation.Propositional.Properties as ↭ₚ
+open import Data.Product.Base using (_×_; _,_; proj₁; proj₂; ∃; ∃-syntax)
+import Data.Product.Relation.Binary.Lex.NonStrict as Lex
+open import Data.Sum.Base using (_⊎_; inj₁; inj₂)
+open import Data.Empty using (⊥-elim)
+open import Data.Rational.Base as ℚ
+ using (ℚ; 0ℚ; 1ℚ; _*_; _⊓_; _≤_; toℚᵘ; fromℚᵘ)
+import Data.Rational.Properties as ℚₚ
+open import Data.Rational.Unnormalised.Base as ℚᵘ
+ using (ℚᵘ; mkℚᵘ; ↥_; ↧ₙ_; *≡*)
+ renaming (_≤_ to _≤ℚ_)
+import Data.Rational.Unnormalised.Properties as ℚᵘₚ
+open import Relation.Binary.Bundles using (DecTotalOrder)
+open import Relation.Binary.PropositionalEquality
+ using (_≡_; refl; sym; trans; cong; cong₂; subst)
+open import Relation.Nullary using (yes; no)
+
+------------------------------------------------------------------------
+-- Part 1. The scaling step over ℚᵘ (ported from the archive)
+
+-- `M/(j+1) · n/(d+1)`, written with the denominator expanded so that the
+-- fraction is built by a single `mkℚᵘ` and carries no intermediate rounding.
+-- `j` is a zero-based rank; `j + d + j * d` is `(j+1)(d+1) - 1`, the
+-- denominator-minus-one that `mkℚᵘ` expects.
+bhScale : ℕ → ℕ → ℤ → ℕ → ℚᵘ
+bhScale M j n d = mkℚᵘ (+ M ℤ.* n) (j ℕ.+ d ℕ.+ j ℕ.* d)
+
+-- The numerator of the scaled value is `M·n`.
+bhScale-numerator : ∀ M j n d → ↥ (bhScale M j n d) ≡ + M ℤ.* n
+bhScale-numerator M j n d = refl
+
+-- The denominator of the scaled value is `(j+1)(d+1)`.
+bhScale-denominator :
+ ∀ M j n d → ↧ₙ (bhScale M j n d) ≡ suc (j ℕ.+ d ℕ.+ j ℕ.* d)
+bhScale-denominator M j n d = refl
+
+-- A bigger family can never produce a smaller scaled value.
+monotone-in-family-size :
+ ∀ M M′ j n d → M ℕ.≤ M′ → .{{_ : ℤ.NonNegative n}} →
+ bhScale M j n d ≤ℚ bhScale M′ j n d
+monotone-in-family-size M M′ j n d M≤M′ {{np}} =
+ numerator-monotone-≤ (+ M ℤ.* n) (+ M′ ℤ.* n) (j ℕ.+ d ℕ.+ j ℕ.* d)
+ (ℤₚ.≤-trans (ℤₚ.≤-reflexive (ℤₚ.*-comm (+ M) n))
+ (ℤₚ.≤-trans (ℤₚ.*-monoˡ-≤-nonNeg n {{np}} (ℤ.+≤+ M≤M′))
+ (ℤₚ.≤-reflexive (ℤₚ.*-comm n (+ M′)))))
+
+------------------------------------------------------------------------
+-- Part 2. The procedure over ℚ
+
+-- A p-value Julia accepts: a number in [0, 1].
+Valid : ℚ → Set
+Valid p = 0ℚ ≤ p × p ≤ 1ℚ
+
+-- Julia's `n / rank * p[idx]` at zero-based rank j = rank - 1.
+scaled : ℕ → ℕ → ℚ → ℚ
+scaled m j p = p * rankFactor m j
+
+-- Bridge to Part 1: the ℚ scaling is, after forgetting normalisation, exactly
+-- the archived ℚᵘ `bhScale` applied to the p-value's numerator and
+-- denominator-minus-one. So Part 1's lemmas are lemmas about `scaled`.
+scaled≃bhScale : ∀ m j p →
+ toℚᵘ (scaled m j p) ℚᵘ.≃ bhScale m j (ℚ.numerator p) (ℚ.denominator-1 p)
+scaled≃bhScale m j p@record{} = ℚᵘₚ.≃-trans (ℚₚ.toℚᵘ-homo-* p (rankFactor m j))
+ (ℚᵘₚ.≃-trans (ℚᵘₚ.*-cong (ℚᵘₚ.≃-refl {toℚᵘ p}) (ℚₚ.toℚᵘ-fromℚᵘ (mkℚᵘ (+ m) j)))
+ (*≡* (cong₂ ℤ._*_ (ℤₚ.*-comm (ℚ.numerator p) (+ m))
+ (cong +[1+_] (sym denom)))))
+ where
+ d = ℚ.denominator-1 p
+ -- (d+1)(j+1) - 1 and (j+1)(d+1) - 1 agree.
+ denom : j ℕ.+ d ℕ.* suc j ≡ j ℕ.+ d ℕ.+ j ℕ.* d
+ denom = trans (cong (j ℕ.+_) (ℕₚ.*-suc d j))
+ (trans (sym (ℕₚ.+-assoc j d (d ℕ.* j)))
+ (cong ((j ℕ.+ d) ℕ.+_) (ℕₚ.*-comm d j)))
+
+-- The running minimum seeded with the first value folded in (the largest
+-- rank). Julia seeds with 1.0 instead; `stepUp-envelope` shows the two agree
+-- once clamped.
+minInf : ℚ → List ℚ → ℚ
+minInf x [] = x
+minInf x (r ∷ _) = r ⊓ x
+
+-- The running minima of Julia's loop over ranks n, n-1, ..., listed in rank order, for a
+-- sorted list whose first element sits at zero-based rank j.
+running : ℕ → ℕ → List ℚ → List ℚ
+running m j [] = []
+running m j (p ∷ ps) =
+ minInf (scaled m j p) (running m (suc j) ps) ∷ running m (suc j) ps
+
+-- Julia's `min(running, 1.0)`.
+stepUp : ℕ → List ℚ → List ℚ
+stepUp m ps = map (_⊓ 1ℚ) (running m 0 ps)
+
+-- Pair each value with its (zero-based) position: Julia's index `idx`.
+indexFrom : ℕ → List ℚ → List (ℚ × ℕ)
+indexFrom k [] = []
+indexFrom k (p ∷ ps) = (p , k) ∷ indexFrom (suc k) ps
+
+-- Ascending order on (p, index). Julia sorts descending (stably); the
+-- tie order differs and is harmless (see the header).
+keyOrder : DecTotalOrder _ _ _
+keyOrder = Lex.×-decTotalOrder ℚₚ.≤-decTotalOrder ℕₚ.≤-decTotalOrder
+
+open import Data.List.Sort.MergeSort keyOrder using (sort; sort-↭; sort-↗)
+
+-- `out[idx] = min(running, 1.0)` read from the other side: the value at output
+-- position i is the adjusted value of the first sorted
+-- entry whose index is i. The `0ℚ` default is never reached (`restore-spec`),
+-- as Julia's `undef` slots are never left unwritten.
+restore : List ((ℚ × ℕ) × ℚ) → ℕ → ℚ
+restore [] i = 0ℚ
+restore (((p , k) , a) ∷ ts) i with k ℕₚ.≟ i
+... | yes _ = a
+... | no _ = restore ts i
+
+-- The intermediate values of `bh_adjust`, named as in Julia.
+module Pipeline (ps : List ℚ) where
+ m : ℕ
+ m = length ps
+
+ keyed : List (ℚ × ℕ)
+ keyed = indexFrom 0 ps
+
+ order : List (ℚ × ℕ)
+ order = sort keyed
+
+ sortedP : List ℚ
+ sortedP = map proj₁ order
+
+ adjusted : List ℚ
+ adjusted = stepUp m sortedP
+
+ triples : List ((ℚ × ℕ) × ℚ)
+ triples = zip order adjusted
+
+-- The Benjamini–Hochberg adjusted p-values, in input order.
+bhAdjust : List ℚ → List ℚ
+bhAdjust ps = map (λ k → restore triples (proj₂ k)) keyed
+ where open Pipeline ps
+
+------------------------------------------------------------------------
+-- Part 3. The sorted stage: the envelope form (R-BH-2d)
+
+-- The scaled values p_(j) · m/(j+1) of a sorted list starting at rank j.
+scaledFrom : ℕ → ℕ → List ℚ → List ℚ
+scaledFrom m j [] = []
+scaledFrom m j (p ∷ ps) = scaled m j p ∷ scaledFrom m (suc j) ps
+
+-- For each suffix, min(1, min of the suffix).
+envelopes : List ℚ → List ℚ
+envelopes [] = []
+envelopes (x ∷ xs) = foldr _⊓_ 1ℚ (x ∷ xs) ∷ envelopes xs
+
+-- The head of the clamped running minimum is the clamped suffix minimum.
+private
+ head-eq : ∀ m j p ps →
+ minInf (scaled m j p) (running m (suc j) ps) ⊓ 1ℚ
+ ≡ scaled m j p ⊓ foldr _⊓_ 1ℚ (scaledFrom m (suc j) ps)
+ head-eq m j p [] = refl
+ head-eq m j p (q ∷ qs) =
+ trans (cong (_⊓ 1ℚ) (ℚₚ.⊓-comm r s))
+ (trans (ℚₚ.⊓-assoc s r 1ℚ)
+ (cong (s ⊓_) (head-eq m (suc j) q qs)))
+ where
+ s = scaled m j p
+ r = minInf (scaled m (suc j) q) (running m (suc (suc j)) qs)
+
+-- R-BH-2d, sorted-stage form: the Julia loop computes the envelope.
+stepUp-envelope : ∀ m j ps →
+ map (_⊓ 1ℚ) (running m j ps) ≡ envelopes (scaledFrom m j ps)
+stepUp-envelope m j [] = refl
+stepUp-envelope m j (p ∷ ps) =
+ cong₂ _∷_ (head-eq m j p ps) (stepUp-envelope m (suc j) ps)
+
+-- R-BH-2d for the pipeline: the adjusted values at sorted rank k are
+-- min(1, min_{j ≥ k} p_(j) · m/(j+1)).
+bh-envelope : ∀ ps →
+ Pipeline.adjusted ps
+ ≡ envelopes (scaledFrom (length ps) 0 (Pipeline.sortedP ps))
+bh-envelope ps = stepUp-envelope (length ps) 0 (Pipeline.sortedP ps)
+
+------------------------------------------------------------------------
+-- Part 4. Facts about the scaling and the fold
+
+private
+ 0≤1 : 0ℚ ≤ 1ℚ
+ 0≤1 = ℚₚ.toℚᵘ-cancel-≤ (ℚᵘ.*≤* (ℤ.+≤+ z≤n))
+
+ -- Scaling inside the family never lowers a non-negative p-value.
+ scaled-≥ : ∀ m j p → suc j ℕ.≤ m → 0ℚ ≤ p → p ≤ scaled m j p
+ scaled-≥ m j p j
+--
+-- Shared arithmetic for the machine-checked statistics core.
+--
+-- Two kinds of fact live here:
+--
+-- * `numerator-monotone-≤`, over the unnormalised rationals `ℚᵘ`, ported from
+-- the archived proof tree because the Benjamini–Hochberg scaling lemmas are
+-- stated over `ℚᵘ` and rest on it;
+-- * the three facts about the Benjamini–Hochberg rank factor `m/(j+1)` that
+-- every BH theorem needs: it is non-negative, it is at least one whenever
+-- the rank is inside the family, and it shrinks as the rank grows.
+--
+-- The rank-factor facts are stated over the normalised rationals `ℚ` (the type
+-- the BH procedure itself is defined over) and proved by moving to `ℚᵘ`, where
+-- `m/(j+1)` is literally `mkℚᵘ (+ m) j` and a comparison is a single
+-- cross-multiplication of integers.
+--
+-- Nothing in `proofs/agda` assumes anything beyond the Agda standard library.
+-- `proofs/tests/axiom-audit.sh` checks that there is no postulate, hole or
+-- unsafe pragma anywhere in the tree.
+
+{-# OPTIONS --safe --without-K #-}
+
+module MetaManifold.Prelude where
+
+open import Data.Integer.Base as ℤ using (ℤ; +_; +[1+_])
+import Data.Integer.Properties as ℤₚ
+open import Data.Nat.Base as ℕ using (ℕ; zero; suc; z≤n; s≤s)
+import Data.Nat.Properties as ℕₚ
+open import Data.Rational.Base as ℚ
+ using (ℚ; _/_; toℚᵘ; fromℚᵘ; 0ℚ; 1ℚ)
+import Data.Rational.Properties as ℚₚ
+open import Data.Rational.Unnormalised.Base as ℚᵘ
+ using (ℚᵘ; mkℚᵘ; *≤*)
+import Data.Rational.Unnormalised.Properties as ℚᵘₚ
+open import Relation.Binary.PropositionalEquality using (_≡_; refl; sym; cong)
+
+------------------------------------------------------------------------
+-- Ported from the archive (over ℚᵘ)
+
+-- Over a common denominator, a larger numerator is a larger fraction.
+numerator-monotone-≤ :
+ ∀ (n m : ℤ) (d : ℕ) → n ℤ.≤ m → mkℚᵘ n d ℚᵘ.≤ mkℚᵘ m d
+numerator-monotone-≤ n m d n≤m =
+ *≤* (ℤₚ.*-monoʳ-≤-nonNeg +[1+ d ] n≤m)
+
+------------------------------------------------------------------------
+-- The Benjamini–Hochberg rank factor m/(j+1)
+
+-- The factor that multiplies the p-value at zero-based rank `j` in a family of
+-- `m` tests. Julia writes `n / i` with a one-based `i`; this is the same
+-- number with `i = j + 1`.
+rankFactor : ℕ → ℕ → ℚ
+rankFactor m j = + m / suc j
+
+
+-- Moving an inequality from ℚᵘ to ℚ through normalisation.
+fromℚᵘ-mono-≤ : ∀ {p q} → p ℚᵘ.≤ q → fromℚᵘ p ℚ.≤ fromℚᵘ q
+fromℚᵘ-mono-≤ {p} {q} p≤q = ℚₚ.toℚᵘ-cancel-≤
+ (ℚᵘₚ.≤-respˡ-≃ (ℚᵘₚ.≃-sym (ℚₚ.toℚᵘ-fromℚᵘ p))
+ (ℚᵘₚ.≤-respʳ-≃ (ℚᵘₚ.≃-sym (ℚₚ.toℚᵘ-fromℚᵘ q)) p≤q))
+
+-- A rank factor is never negative.
+rankFactor-nonNeg : ∀ m j → 0ℚ ℚ.≤ rankFactor m j
+rankFactor-nonNeg m j = fromℚᵘ-mono-≤ {mkℚᵘ (+ 0) 0} {mkℚᵘ (+ m) j}
+ (*≤* (ℤₚ.≤-trans (ℤₚ.≤-reflexive (ℤₚ.*-zeroˡ +[1+ j ]))
+ (ℤₚ.≤-trans (ℤ.+≤+ z≤n)
+ (ℤₚ.≤-reflexive (sym (ℤₚ.*-identityʳ (+ m)))))))
+
+-- Inside the family (rank j+1 ≤ m) the rank factor is at least one, so
+-- scaling never lowers a non-negative p-value.
+rankFactor-≥1 : ∀ m j → suc j ℕ.≤ m → 1ℚ ℚ.≤ rankFactor m j
+rankFactor-≥1 m j j
+--
+-- Library-size scaling (TSS) and median-of-ratios (RLE) over exact rationals:
+-- the parts of `tss_factors`, `rle_factors` and `_median` in
+-- `src/analysis/differential.jl` (branch feat/differential-abundance-nb,
+-- commit 6c3e8b2) that are statements about rational arithmetic, proved; the
+-- parts that are not, named.
+--
+-- What is NOT modelled, and why:
+--
+-- * Geometric means, `exp` and `log`. Julia centres each factor vector by
+-- its geometric mean (`_geomean`), RLE divides each count by its taxon's
+-- geometric mean, and the model uses `log` of the factors as offsets.
+-- None of these is a rational function, and a geometric mean of
+-- rationals is in general irrational, so none can be computed over ℚ.
+-- Wherever Julia divides by a geometric mean, the theorems below divide
+-- by an ARBITRARY positive rational `g`. The same algebra holds over the
+-- reals for any positive divisor, but that transfer is an argument, not a
+-- theorem here. In particular the scale invariance D1 tests
+-- (`tss_factors(7.5 .* fixture) ≈ f`) is a property of the geometric
+-- mean and is not proved.
+-- * Floating point. Julia computes in Float64; these are exact statements.
+-- A refusal can fire in floating point (underflow to 0.0) where the exact
+-- quantity is positive; that is not modelled.
+--
+-- D1's `tss_factors` computes library sizes (row sums of the samples x taxa
+-- matrix) and centres them; it never forms proportions. The proportion
+-- theorems below are the relative-abundance reading of TSS asked for in the
+-- D2 brief, and hold for any row with a positive total.
+--
+-- Theorems (names as in proofs/agda/README.md):
+--
+-- total-positive a row of non-negative counts with at least one
+-- positive count has a positive total (so TSS's
+-- `l > 0` refusal fires exactly on all-zero rows)
+-- proportions-sum-to-1 a row's proportions sum to 1
+-- proportions-bounded every proportion lies in [0, 1]
+-- centre-positive dividing by any positive g keeps every factor positive
+-- centre-monotone ... and keeps their order
+-- ratio-positive a positive count over a positive normaliser is positive
+-- median-positive the median (as `_median` takes it) of a non-empty list
+-- of positive ratios is positive, so once one taxon has
+-- reads in every sample, every RLE factor is positive in
+-- exact arithmetic
+
+{-# OPTIONS --safe --without-K #-}
+
+module MetaManifold.Scaling where
+
+open import Data.Product.Base using (_×_; _,_)
+open import Data.Bool.Base using (Bool; true; false; not; if_then_else_)
+open import Data.Nat.Base as ℕ using (ℕ; zero; suc; ⌊_/2⌋)
+open import Data.List.Base using (List; []; _∷_; map; foldr; length)
+open import Data.List.Relation.Unary.All as All using (All; []; _∷_)
+open import Data.List.Relation.Unary.Any using (Any; here; there)
+import Data.List.Relation.Binary.Permutation.Propositional.Properties as ↭ₚ
+open import Data.List.Relation.Binary.Permutation.Propositional using (↭-sym)
+open import Data.Rational.Base as ℚ
+ using (ℚ; 0ℚ; 1ℚ; ½; _+_; _*_; 1/_; _≤_; _<_; Positive)
+import Data.Rational.Properties as ℚₚ
+open import Relation.Binary.PropositionalEquality
+ using (_≡_; refl; sym; trans; cong; subst)
+
+open import Data.List.Sort.MergeSort ℚₚ.≤-decTotalOrder using (sort; sort-↭)
+
+------------------------------------------------------------------------
+-- Shared arithmetic
+
+-- A sample's library size: Julia's `vec(sum(counts; dims=2))`, one row.
+sum : List ℚ → ℚ
+sum = foldr _+_ 0ℚ
+
+-- Division by a positive rational, as multiplication by its inverse.
+inv : (g : ℚ) → .{{Positive g}} → ℚ
+inv g = (1/ g) {{ℚₚ.pos⇒nonZero g}}
+
+private
+ inv-positive : ∀ g .{{_ : Positive g}} → Positive (inv g)
+ inv-positive g = ℚₚ.1/pos⇒pos g
+
+ inv-nonNeg : ∀ g .{{_ : Positive g}} → 0ℚ ≤ inv g
+ inv-nonNeg g = ℚₚ.<⇒≤ (ℚₚ.positive⁻¹ (inv g) {{inv-positive g}})
+
+ -- x ↦ x · r is monotone for a non-negative r.
+ scale-≤ : ∀ r → 0ℚ ≤ r → ∀ {x y} → x ≤ y → x * r ≤ y * r
+ scale-≤ r 0≤r = ℚₚ.*-monoʳ-≤-nonNeg r {{ℚ.nonNegative 0≤r}}
+
+ -- x ↦ x · r is strictly monotone for a positive r.
+ scale-< : ∀ r → .{{_ : Positive r}} → ∀ {x y} → x < y → x * r < y * r
+ scale-< r = ℚₚ.*-monoˡ-<-pos r
+
+ 0+0 : 0ℚ + 0ℚ ≡ 0ℚ
+ 0+0 = ℚₚ.+-identityʳ 0ℚ
+
+------------------------------------------------------------------------
+-- TSS: totals and proportions
+
+private
+ sum-nonNeg : ∀ xs → All (0ℚ ≤_) xs → 0ℚ ≤ sum xs
+ sum-nonNeg [] [] = ℚₚ.≤-refl
+ sum-nonNeg (x ∷ xs) (0≤x ∷ nn) =
+ subst (_≤ x + sum xs) 0+0 (ℚₚ.+-mono-≤ 0≤x (sum-nonNeg xs nn))
+
+ -- Every non-negative summand is at most the total.
+ below-sum : ∀ xs → All (0ℚ ≤_) xs → All (_≤ sum xs) xs
+ below-sum [] [] = []
+ below-sum (x ∷ xs) (0≤x ∷ nn) =
+ ℚₚ.≤-trans (ℚₚ.≤-reflexive (sym (ℚₚ.+-identityʳ x)))
+ (ℚₚ.+-mono-≤ (ℚₚ.≤-refl {x}) (sum-nonNeg xs nn))
+ ∷ All.map (λ y≤s → ℚₚ.≤-trans y≤s
+ (ℚₚ.≤-trans (ℚₚ.≤-reflexive (sym (ℚₚ.+-identityˡ (sum xs))))
+ (ℚₚ.+-mono-≤ 0≤x (ℚₚ.≤-refl {sum xs}))))
+ (below-sum xs nn)
+
+ sum-scale : ∀ r xs → sum (map (_* r) xs) ≡ sum xs * r
+ sum-scale r [] = sym (ℚₚ.*-zeroˡ r)
+ sum-scale r (x ∷ xs) =
+ trans (cong (x * r +_) (sum-scale r xs)) (sym (ℚₚ.*-distribʳ-+ r x (sum xs)))
+
+-- A row of non-negative counts with at least one positive count has a
+-- positive total. So `tss_factors`'s `l > 0` refusal fires exactly on the
+-- rows whose counts are all zero (`validate_counts` guarantees non-negative).
+total-positive : ∀ xs → All (0ℚ ≤_) xs → Any (0ℚ <_) xs → 0ℚ < sum xs
+total-positive (x ∷ xs) (_ ∷ nn) (here 0
+-->
+
+# Machine-checked proofs: Benjamini–Hochberg and size-factor scaling
+
+`proofs/agda/` holds Agda proofs of the exact-arithmetic properties of three
+functions in `src/analysis/differential.jl`: `bh_adjust`, `tss_factors` and
+`rle_factors` (with `_median`). The functions were added by the
+differential-abundance change (branch `feat/differential-abundance-nb`,
+commits `6c3e8b2` and `23dc89c`). These proofs depend on that change being
+merged first.
+
+Every module is checked with `--safe --without-K`. There are no postulates,
+no holes and no termination or positivity pragmas.
+`proofs/tests/axiom-audit.sh` enforces this. It also fails if any module is
+not imported by `MetaManifold/All.agda`, because a module that nothing imports
+is never type-checked.
+
+## Toolchain
+
+The checker is **Agda 2.6.4.3** with **agda-stdlib 2.1**. The CI job
+"Agda proofs" installs this pair from Debian trixie (`agda-bin=2.6.4.3-1+b2`,
+`agda-stdlib=2.1-4`). It runs in a Debian image pinned by digest, because
+Agda publishes no 2.6.4.3 binary and stdlib 2.1 needs Agda 2.6.4.x.
+
+Earlier planning material and the archived proofs used Agda 2.7.0.1. These
+proofs are written for, and checked with, 2.6.4.3. They have not been checked
+with 2.7.0.1 or with stdlib 2.2 or later.
+
+To check locally:
+
+```sh
+bash proofs/tests/axiom-audit.sh
+cd proofs/agda && agda MetaManifold/All.agda # needs standard-library-2.1 in $AGDA_DIR/libraries
+```
+
+## What is modelled
+
+Values are exact rationals (`ℚ`), not `Float64`. A list stands in for a
+vector.
+
+- **`bhAdjust`** follows the shape of the Julia function:
+ 1. Sort the p-values, keeping each one's input index.
+ 2. Scale the value at rank `r` by `n / r`.
+ 3. Take the running minimum from the largest rank down, then clamp it at 1.
+ 4. Write each value back to its input position (`restore`).
+
+ It differs from the Julia code in two ways:
+ - **Running minimum.** Julia starts the running minimum at `1.0`; the model
+ starts from the first scaled value and clamps afterwards.
+ `stepUp-envelope` proves the two produce the same values.
+ - **Tie order.** Julia sorts in descending order. Among equal p-values, its
+ stable sort gives the earlier input the higher rank; the model's
+ ascending `(p, index)` sort gives it the lower rank. Either way the
+ sorted p-values are identical, and `bh-monotone` (applied in both
+ directions) proves that equal p-values receive equal adjusted values. So
+ the tie order cannot change the output. That last step is an argument
+ about the Julia code built on a proved lemma. It is not a formal
+ equivalence proof between Julia and Agda.
+- **Input validation** (finite and in `[0, 1]`) is the hypothesis
+ `All Valid ps`. The Julia `ArgumentError` paths are therefore assumptions
+ of the theorems, not theorems.
+
+## Theorem map
+
+| Requirement | Agda theorem (module) | Plain statement | Julia function | D1 test (`test/unit/test_differential.jl`) |
+|---|---|---|---|---|
+| R-BH-1 | `bh-nonneg` (BenjaminiHochberg) | For valid p-values, every adjusted value is ≥ 0. | `bh_adjust` | lines 106–110, parity 117–124 |
+| R-BH-2a | `bh-dominates-p` | Every adjusted value is ≥ its own p-value. | `bh_adjust` | line 107 (`0.001 → 0.003`), parity |
+| R-BH-2b | `bh-at-most-one` | Every adjusted value is ≤ 1. | `bh_adjust` | line 107 (`0.5 → 0.5`), line 110 (`[1.0]`) |
+| R-BH-2c | `bh-monotone` | For any two inputs, `p_i ≤ p_j` implies `adj_i ≤ adj_j`. This covers ties in both directions. | `bh_adjust` | line 109 (`[0.04, 0.01] → [0.04, 0.02]`) |
+| R-BH-2d | `bh-envelope`, `stepUp-envelope` | In sorted order, the adjusted value at rank `k` is `min(1, min_{j ≥ k} p_(j) · n/j)`. | `bh_adjust` loop | line 106 (all → 0.04), parity |
+| – | `bh-length` | The output has one entry per input. | `bh_adjust` | line 111 (empty → empty) |
+| – | `monotone-in-family-size` | A larger family never gives a smaller scaled value. | `n / rank * p` | – |
+| TSS | `total-positive` (Scaling) | A row of non-negative counts with at least one positive count has a positive total. So the `l > 0` refusal fires exactly on all-zero rows. | `tss_factors` | lines 137–139 (empty row refused) |
+| TSS | `proportions-sum-to-1` | A row's proportions `x / Σx` sum to 1. | (relative abundance) | – |
+| TSS | `proportions-bounded` | Every proportion lies in `[0, 1]`. | (relative abundance) | – |
+| TSS/RLE | `centre-positive`, `centre-monotone` | Dividing factors by any positive `g` keeps them positive and keeps their order. | `lib ./ _geomean(lib)`, `raw ./ _geomean(raw)` | – |
+| RLE | `ratio-positive` | A positive count over a positive normaliser is positive. | `view(sub, i, :) ./ gm` | – |
+| RLE | `median-positive` | The median (taken as `_median` takes it) of a non-empty list of positive values is positive. | `_median`, `rle_factors` | lines 143–153 |
+
+Taken together, the RLE rows show that once one taxon has reads in every
+sample, every RLE factor is positive in exact arithmetic. This holds given a
+positive geometric mean, which is assumed (see below).
+
+`tss_factors` never forms proportions; it centres library sizes. The
+proportion theorems are the relative-abundance reading of TSS, proved because
+the requirement names them. They do not describe a value the Julia code
+returns.
+
+## Corrections to the requirement wording
+
+- **R-BH-2a is true for the procedure.** An earlier archived note recorded
+ "q_i ≥ p_i is FALSE". It is false only for a scaling factor below 1. In BH
+ the rank `r` is at most `n`, so `n / r ≥ 1` (`rankFactor-≥1`), and the
+ clamp at 1 keeps the bound because `p ≤ 1`. The theorem is proved as
+ worded, for valid inputs.
+- **R-BH-2c is stated over input positions, not over sorted ranks.** A
+ sorted-rank statement would leave open how tied inputs are written back.
+ The membership form `x ∈ zip ps (bhAdjust ps)` covers ties.
+- **Empty input** is covered. All the `All`/membership statements are vacuous
+ on `[]`, and `bh-length` gives the empty result.
+
+## What is not proved, and why
+
+- **Geometric means, `exp` and `log`.** A geometric mean of rationals is in
+ general irrational, so `_geomean` cannot be computed over `ℚ`. Wherever
+ Julia divides by a geometric mean, the theorems divide by an arbitrary
+ positive rational. That the real geometric mean of positive numbers is
+ positive is a fact about the reals, not a theorem here.
+- **Scale invariance** (`tss_factors(7.5 .* x) ≈ tss_factors(x)`, line 136;
+ the same for RLE, line 148). This is a property of geometric-mean
+ centring and is not proved.
+- **The RLE "doubled sample" ratio test** (lines 150–151) is not proved.
+- **Median equivariance** (`median(c·x) = c·median(x)`, translation) is not
+ proved.
+- **Floating point.** Every statement is exact. `Float64` rounding, and a
+ refusal caused by underflow to `0.0`, are outside the model.
+- **Equivalence of the Agda and Julia code** is argued, not formalised. The
+ model mirrors the loop, and the two differences above are each
+ discharged by a proved lemma plus a short argument.
+
+## Positive controls (run when the proofs were written)
+
+1. **Audit.** Each of the following was planted in a scratch copy, and each
+ made the audit exit 1 with a named finding: a `postulate` block, a
+ mid-line `private postulate`, `?`, `{! !}`, `{-# TERMINATING #-}`, a
+ module not imported by `All.agda`, and a removed `--without-K`. A comment
+ that mentions "postulate" and "?" does not trigger it.
+2. **Monotonicity needs the cumulative minimum.** Removing it makes Agda
+ reject the proofs:
+ - from `running`: rejected in `head-eq`, the envelope proof;
+ - from the sorted stage (`Z`): rejected in `Z-bounded`;
+ - from the stage the monotonicity proof alone uses: rejected in
+ `Z-linked`, at `ℚₚ.p⊓q≤q s F`.
+
+ A concrete counterexample is `p = [0.01, 0.011]`, `n = 2`. Without the
+ minimum the scaled values are `0.02` and `0.011`, which are not monotone.
diff --git a/proofs/agda/metamanifold.agda-lib b/proofs/agda/metamanifold.agda-lib
new file mode 100644
index 00000000..6f46037a
--- /dev/null
+++ b/proofs/agda/metamanifold.agda-lib
@@ -0,0 +1,6 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+name: metamanifold
+include: .
+depend: standard-library
+flags: --safe --without-K
diff --git a/proofs/tests/axiom-audit.sh b/proofs/tests/axiom-audit.sh
new file mode 100755
index 00000000..cb10d0e0
--- /dev/null
+++ b/proofs/tests/axiom-audit.sh
@@ -0,0 +1,141 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# Audit the Agda proof tree for anything that would let a "proof" pass
+# without proving anything. The type-checker only guarantees what its rules
+# allow; every check here is a rule this project refuses to relax.
+#
+# Usage: proofs/tests/axiom-audit.sh [AGDA_ROOT]
+# AGDA_ROOT defaults to proofs/agda next to this script. Passing another
+# root is how the positive control runs the audit against a scratch copy.
+#
+# Fails (exit 1) on:
+# 1. a module without `--safe` and `--without-K` in its OPTIONS pragma
+# 2. a `postulate`, anywhere in code (not only at the start of a line)
+# 3. FOREIGN / COMPILE / BUILTIN pragmas
+# 4. unsound flags (--type-in-type, --no-positivity-check,
+# --no-termination-check, --allow-unsolved-metas, ...)
+# 5. TERMINATING / NON_TERMINATING / NO_POSITIVITY_CHECK /
+# NO_UNIVERSE_CHECK pragmas, trustMe, primTrust
+# 6. holes: `{! !}` or a bare `?` token, anywhere in code
+# 7. a module under the root that MetaManifold/All.agda does not reach
+# by imports (an unreached module is never type-checked at all)
+# Line comments (`-- ...`) are stripped before checks 2 and 6, so prose that
+# mentions a postulate is not a finding. Block comments are not stripped:
+# the proof modules do not use them, and a false positive is the safe side.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+AGDA_DIR="$(cd "${1:-$SCRIPT_DIR/../agda}" && pwd)"
+ENTRY="$AGDA_DIR/MetaManifold/All.agda"
+failures=0
+
+# Record one finding on stderr and count it; the audit fails at the end.
+fail() {
+ printf 'axiom-audit: FAIL: %s\n' "$*" >&2
+ failures=$((failures + 1))
+}
+
+# Print one progress line on stdout.
+note() {
+ printf 'axiom-audit: %s\n' "$*"
+}
+
+# Abort at once on a condition that makes the audit meaningless.
+fatal() {
+ printf 'axiom-audit: FATAL: %s\n' "$*" >&2
+ exit 1
+}
+
+# Print a file with every `-- ...` line comment removed, keeping line numbers.
+# A `--` glued to a word (an option such as --safe) is not a comment.
+code_only() {
+ sed -E 's/(^|[[:space:]])--([[:space:]].*)?$/\1/' "$1"
+}
+
+# Run checks 1-6 on one module file, recording findings via `fail`.
+audit_file() {
+ local f="$1" rel="${1#"$AGDA_DIR"/}" code hits
+ code="$(code_only "$f")"
+
+ if ! grep -qE '^\{-#[[:space:]]*OPTIONS.*--safe' "$f"; then
+ fail "$rel does not declare --safe"
+ fi
+ if ! grep -qE '^\{-#[[:space:]]*OPTIONS.*--without-K' "$f"; then
+ fail "$rel does not declare --without-K"
+ fi
+
+ if hits="$(grep -nE '(^|[^[:alnum:]_])postulate([^[:alnum:]_]|$)' <<<"$code")"; then
+ fail "$rel contains a postulate: $(head -3 <<<"$hits" | tr '\n' ' ')"
+ fi
+
+ if grep -qE '\{-#[[:space:]]*(FOREIGN|COMPILE|BUILTIN)' "$f"; then
+ fail "$rel uses a FOREIGN/COMPILE/BUILTIN pragma"
+ fi
+
+ if grep -qE -- '--(type-in-type|no-positivity-check|no-termination-check|allow-unsolved-metas|allow-incomplete-matches|no-guardedness|injective-type-constructors|omega-in-omega|cumulativity)' "$f"; then
+ fail "$rel enables an unsound or check-disabling flag"
+ fi
+
+ if hits="$(grep -nE '\{-#[[:space:]]*(TERMINATING|NON_TERMINATING|NO_POSITIVITY_CHECK|NO_UNIVERSE_CHECK|NON_COVERING|INJECTIVE)\b|\b(trustMe|primTrust)\b' "$f")"; then
+ fail "$rel uses a check-disabling pragma or trusted primitive: $(head -3 <<<"$hits" | tr '\n' ' ')"
+ fi
+
+ if hits="$(grep -nE '\{![^!]*!\}|(^|[[:space:](])\?([[:space:])]|$)' <<<"$code")"; then
+ fail "$rel contains a hole: $(head -3 <<<"$hits" | tr '\n' ' ')"
+ fi
+}
+
+# Print, one per line, every MetaManifold.* module reachable from
+# MetaManifold.All by imports. An import with no file is printed as
+# `MISSING ` (this runs in a subshell, so it cannot call `fail`).
+reachable_modules() {
+ local -A seen=()
+ local -a queue=("MetaManifold.All")
+ local mod path dep
+ while [[ ${#queue[@]} -gt 0 ]]; do
+ mod="${queue[0]}"
+ queue=("${queue[@]:1}")
+ [[ -n "${seen[$mod]:-}" ]] && continue
+ seen[$mod]=1
+ printf '%s\n' "$mod"
+ path="$AGDA_DIR/${mod//.//}.agda"
+ [[ -f "$path" ]] || { printf "MISSING %s\n" "$mod"; continue; }
+ while read -r dep; do
+ [[ -n "$dep" ]] && queue+=("$dep")
+ done < <(code_only "$path" \
+ | grep -oE '^[[:space:]]*(open[[:space:]]+)?import[[:space:]]+MetaManifold\.[A-Za-z0-9_.]+' \
+ | awk '{print $NF}' || true)
+ done
+}
+
+[[ -f "$ENTRY" ]] || fatal "missing $ENTRY"
+mapfile -t FILES < <(find "$AGDA_DIR" -name '*.agda' -type f | sort)
+[[ ${#FILES[@]} -gt 0 ]] || fatal "no .agda files under $AGDA_DIR"
+note "auditing ${#FILES[@]} module(s) under $AGDA_DIR"
+
+for f in "${FILES[@]}"; do
+ audit_file "$f"
+done
+
+REACHED="$(reachable_modules)"
+while read -r missing; do
+ fail "imported module ${missing#MISSING } has no file"
+done < <(grep "^MISSING " <<<"$REACHED" || true)
+for f in "${FILES[@]}"; do
+ rel="${f#"$AGDA_DIR"/}"
+ mod="${rel%.agda}"
+ mod="${mod//\//.}"
+ if ! grep -qxF "$mod" <<<"$REACHED"; then
+ fail "$rel is not reachable from MetaManifold/All.agda, so it is never type-checked"
+ fi
+done
+note "reachability: $(wc -l <<<"$REACHED") module(s) reachable from MetaManifold.All"
+
+if [[ $failures -gt 0 ]]; then
+ printf 'axiom-audit: %d finding(s)\n' "$failures" >&2
+ exit 1
+fi
+note "clean"
diff --git a/renv.lock b/renv.lock
index d0c9e123..dfac546e 100644
--- a/renv.lock
+++ b/renv.lock
@@ -673,6 +673,25 @@
"Author": "Constantin Ahlmann-Eltze [aut] (ORCID: ), Peter Hickey [aut, cre] (ORCID: ), Hervé Pagès [aut]",
"Maintainer": "Peter Hickey "
},
+ "NADA": {
+ "Package": "NADA",
+ "Version": "1.6-1.2",
+ "Source": "Repository",
+ "Title": "Nondetects and Data Analysis for Environmental Data",
+ "Author": "Lopaka Lee [aut, cre]",
+ "Authors@R": "person(given = \"Lopaka\", family = \"Lee\", role = c(\"aut\", \"cre\"), email = \"rclee@usgs.gov\")",
+ "Description": "Contains methods described by Dennis Helsel in his book \"Nondetects And Data Analysis: Statistics for Censored Environmental Data\".",
+ "Maintainer": "Lopaka Lee ",
+ "Depends": [
+ "R (>= 2.10)",
+ "methods",
+ "survival"
+ ],
+ "License": "GPL (>= 2)",
+ "Collate": "All.R summary.R cen.R km.R reg.R mle.R ken.R ros.R icmle.R",
+ "NeedsCompilation": "no",
+ "Repository": "CRAN"
+ },
"R6": {
"Package": "R6",
"Version": "2.6.1",
@@ -2907,6 +2926,36 @@
"Maintainer": "Hadley Wickham ",
"Repository": "CRAN"
},
+ "survival": {
+ "Package": "survival",
+ "Version": "3.8-3",
+ "Source": "Repository",
+ "Title": "Survival Analysis",
+ "Priority": "recommended",
+ "Date": "2024-12-17",
+ "Depends": [
+ "R (>= 3.5.0)"
+ ],
+ "Imports": [
+ "graphics",
+ "Matrix",
+ "methods",
+ "splines",
+ "stats",
+ "utils"
+ ],
+ "LazyData": "Yes",
+ "LazyDataCompression": "xz",
+ "ByteCompile": "Yes",
+ "Authors@R": "c(person(c(\"Terry\", \"M\"), \"Therneau\", email=\"therneau.terry@mayo.edu\", role=c(\"aut\", \"cre\")), person(\"Thomas\", \"Lumley\", role=c(\"ctb\", \"trl\"), comment=\"original S->R port and R maintainer until 2009\"), person(\"Atkinson\", \"Elizabeth\", role=\"ctb\"), person(\"Crowson\", \"Cynthia\", role=\"ctb\"))",
+ "Description": "Contains the core survival analysis routines, including definition of Surv objects, Kaplan-Meier and Aalen-Johansen (multi-state) curves, Cox models, and parametric accelerated failure time models.",
+ "License": "LGPL (>= 2)",
+ "URL": "https://github.com/therneau/survival",
+ "NeedsCompilation": "yes",
+ "Author": "Terry M Therneau [aut, cre], Thomas Lumley [ctb, trl] (original S->R port and R maintainer until 2009), Atkinson Elizabeth [ctb], Crowson Cynthia [ctb]",
+ "Maintainer": "Terry M Therneau ",
+ "Repository": "CRAN"
+ },
"tibble": {
"Package": "tibble",
"Version": "3.3.1",
@@ -3015,6 +3064,28 @@
"Maintainer": "Lionel Henry ",
"Repository": "CRAN"
},
+ "truncnorm": {
+ "Package": "truncnorm",
+ "Version": "1.0-9",
+ "Source": "Repository",
+ "Title": "Truncated Normal Distribution",
+ "Description": "Density, probability, quantile and random number generation functions for the truncated normal distribution.",
+ "Authors@R": "c(person(\"Olaf\", \"Mersmann\", role=c(\"aut\", \"cre\"), email=\"olafm@p-value.net\"), person(\"Heike\", \"Trautmann\", role=c(\"aut\")), person(\"Detlef\", \"Steuer\", role=c(\"aut\")), person(\"Björn\", \"Bornkamp\", role=c(\"aut\")))",
+ "URL": "https://github.com/olafmersmann/truncnorm",
+ "BugReports": "https://github.com/olafmersmann/truncnorm/issues",
+ "Depends": [
+ "R (>= 3.4.0)"
+ ],
+ "Suggests": [
+ "testthat"
+ ],
+ "License": "GPL (>= 2)",
+ "Encoding": "UTF-8",
+ "NeedsCompilation": "yes",
+ "Author": "Olaf Mersmann [aut, cre], Heike Trautmann [aut], Detlef Steuer [aut], Björn Bornkamp [aut]",
+ "Maintainer": "Olaf Mersmann ",
+ "Repository": "CRAN"
+ },
"utf8": {
"Package": "utf8",
"Version": "1.2.6",
@@ -3192,6 +3263,33 @@
"Author": "Jim Hester [aut], Lionel Henry [aut, cre], Kirill Müller [aut], Kevin Ushey [aut], Hadley Wickham [aut], Winston Chang [aut], Jennifer Bryan [ctb], Richard Cotton [ctb], Posit Software, PBC [cph, fnd]",
"Maintainer": "Lionel Henry ",
"Repository": "CRAN"
+ },
+ "zCompositions": {
+ "Package": "zCompositions",
+ "Version": "1.6.2",
+ "Source": "Repository",
+ "Type": "Package",
+ "Title": "Treatment of Zeros, Left-Censored and Missing Values in Compositional Data Sets",
+ "Date": "2026-06-20",
+ "Authors@R": "c(person(given = \"Javier\", family = \"Palarea-Albaladejo\", role = c(\"cre\",\"aut\"), email = \"javier.palarea@udg.edu\", comment = c(ORCID = \"0000-0003-0162-669X\")), person(given = \"Josep Antoni\", family = \"Martin-Fernandez\", role = c(\"aut\"), email = \"josepantoni.martin@udg.edu\", comment = c(ORCID = \"0000-0003-2366-1592\")))",
+ "Maintainer": "Javier Palarea-Albaladejo ",
+ "Depends": [
+ "R (>= 2.14.0)",
+ "methods",
+ "MASS",
+ "NADA",
+ "truncnorm"
+ ],
+ "ByteCompile": "yes",
+ "Description": "Principled methods for the imputation of zeros, left-censored and missing data in compositional data sets (Palarea-Albaladejo and Martin-Fernandez (2015) ).",
+ "License": "GPL (>= 2)",
+ "Repository": "CRAN",
+ "RoxygenNote": "7.3.3",
+ "Encoding": "UTF-8",
+ "URL": "https://github.com/Japal/zCompositions",
+ "BugReports": "https://github.com/Japal/zCompositions/issues",
+ "NeedsCompilation": "no",
+ "Author": "Javier Palarea-Albaladejo [cre, aut] (ORCID: ), Josep Antoni Martin-Fernandez [aut] (ORCID: )"
}
}
}
diff --git a/src/analysis/analysis.jl b/src/analysis/analysis.jl
index 029d57b7..2b0105d2 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,96 @@ 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, "")
-# 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.
+"""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` has status `:computed`. False means no omnibus result is available, 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 distinguishes a computed omnibus result from
+an unavailable or failed test.
+
+`values`, group `labels` and `sample_ids` describe the same samples. Unpaired
+data use Kruskal-Wallis; `paired_samples` uses common sample IDs across all
+groups, averaging repeated IDs within a group, with paired Wilcoxon for two
+groups or Friedman for more. `pairwise` requests Wilcoxon comparisons with
+Benjamini-Hochberg adjusted p-values.
+
+Unavailable R/vegan, no common IDs for pairing, or an omnibus R error converted
+to a missing p-value return statuses `:r_unavailable`, `:no_paired_samples` or
+`:test_failed`, respectively. A failed omnibus result retains any pairwise rows.
+
+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 +1009,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 +1082,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[])
@@ -1069,6 +1174,22 @@ function _add_pairwise_annotations!(layout::Dict{String,Any},
layout[yaxis_layout_key] = axis
end
+"""
+ alpha_boxplot(groups; show_points=true, annotate_significance=false,
+ pairwise_brackets=false, paired_samples=false,
+ paired_lines=false, significance_test="kruskal_wallis") -> Dict
+
+Build a Plotly figure with richness, Shannon and Gini-Simpson boxplot panels.
+Each group is `(label, sample_ids, richness, shannon, simpson)`, with metric
+values aligned to its sample IDs. `paired_lines` joins matching IDs across
+groups and disables point jitter. `paired_samples` selects paired tests when
+an ID is shared by every group; otherwise tests are unpaired.
+
+With at least two group labels and `significance_test == "kruskal_wallis"`,
+tests run even when neither annotation option is enabled. The options display
+omnibus captions or pairwise brackets, including notices for unavailable or
+failed tests. `RBusyError` propagates when the R runtime stays busy.
+"""
function alpha_boxplot(groups::AbstractVector{<:Tuple{String, Vector{String}, AbstractVector{<:Real}, Vector{Float64}, Vector{Float64}}};
show_points::Bool=true,
annotate_significance::Bool=false,
@@ -1087,7 +1208,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 +1260,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 +1271,13 @@ function alpha_boxplot(groups::AbstractVector{<:Tuple{String, Vector{String}, Ab
"xref" => "$xax domain", "yref" => "$yax domain",
"x" => 0.98, "y" => 0.98,
"xanchor" => "right", "yanchor" => "top",
- "text" => "$(paired ? (length(unique(panel_labels)) == 2 ? "Paired Wilcoxon" : "Friedman") : "KW") $(_significance_stars(p_value)) $(_format_p_value(p_value))",
+ "text" => _significance_caption(significance,
+ paired ? (length(unique(panel_labels)) == 2 ? "Paired Wilcoxon" : "Friedman") : "KW"),
"showarrow" => false,
"align" => "right",
))
end
- pairwise_brackets && (panel_pairwise[panel_idx] = pairwise_df)
+ pairwise_brackets && (panel_pairwise[panel_idx] = significance)
end
end
layout = Dict{String,Any}(
@@ -1328,15 +1450,47 @@ 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.
+Columns named `sample` (case-insensitive) and constant columns are excluded.
+Each remaining 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.
+restricts permutations to within blocks. It is ignored when its length does
+not match the sample count or when no label repeats,
+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).
+
+Returns `nothing` when R/vegan is unavailable or no covariates remain. An
+`adonis2` error becomes a named tuple containing `message`; on success,
+`dispersion` contains PERMDISP results for the combined covariate groups or
+an `error` message. `RBusyError`, transformation errors and R errors outside
+these caught tests propagate.
"""
function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer=123,
transform::String="none",
@@ -1348,6 +1502,10 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer=
formula_rhs = join(covariates, " + ")
mat = transform_counts(mat; method=transform)
blocked = !isnothing(blocks) && length(blocks) == size(mat, 1) && !allunique(blocks)
+ # Within-block permutation never exchanges the labels of a term that is
+ # constant inside every block, so its p-value does not test that term.
+ untestable = blocked ? [c for c in covariates if !_varies_within_any_block(metadata[!, c], blocks)] :
+ String[]
with_r_lock(; timeout=R_WAIT_SECONDS[]) do
meta_r = copy(metadata)
@@ -1357,6 +1515,7 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer=
RCall.globalEnv[:seed] = Int(seed)
RCall.globalEnv[:perm_blocks] = blocked ? String.(blocks) : String[]
RCall.globalEnv[:disp_cols] = String.(covariates)
+ RCall.globalEnv[:perm_untestable] = String.(untestable)
RCall.reval("""
set.seed(seed, kind = "Mersenne-Twister", normal.kind = "Inversion", sample.kind = "Rejection")
dist_mat <- vegdist(mat, method = "bray")
@@ -1368,12 +1527,25 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer=
999
}
perm_err <- NULL
+ perm_n <- NA_integer_
perm_res <- tryCatch(
- adonis2(form, data = meta_r, permutations = perm_ctrl, parallel = 1),
- error = function(e) { perm_err <<- conditionMessage(e); NULL }
- )
+ # vegan 2.7 defaults to by = NULL, one test of the whole model.
+ adonis2(form, data = meta_r, permutations = perm_ctrl, by = "terms", parallel = 1),
+ error = function(e) { perm_err <<- conditionMessage(e); NULL })
if (!is.null(perm_res)) {
- perm_text <- paste(capture.output(print(perm_res)), collapse = "\\n")
+ # permute enumerates every permutation instead of drawing 999 when
+ # the blocks admit fewer, which small blocks make likely; the
+ # control adonis2 used records how many it ran.
+ perm_n <- attr(perm_res, "control")[["nperm"]]
+ # Within-block permutation never exchanges such a term's labels
+ # between blocks, so its p-value does not test that term.
+ untestable_rows <- which(rownames(perm_res) %in% perm_untestable)
+ perm_res[untestable_rows, "Pr(>F)"] <- NA
+ perm_text <- paste(c(capture.output(print(perm_res)),
+ if (length(untestable_rows) > 0)
+ paste0("Pr(>F) withheld for ", paste(rownames(perm_res)[untestable_rows], collapse = ", "),
+ ": constant within every permutation block, so within-block permutation does not test it.")),
+ collapse = "\\n")
perm_r2 <- perm_res\$R2[1]
perm_f <- perm_res\$F[1]
perm_p <- perm_res[["Pr(>F)"]][1]
@@ -1411,6 +1583,7 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer=
r2 = RCall.rcopy(RCall.reval("perm_r2"))
f_stat = RCall.rcopy(RCall.reval("perm_f"))
p_val = RCall.rcopy(RCall.reval("perm_p"))
+ n_perm = RCall.rcopy(RCall.reval("perm_n"))
# adonis2 tests terms sequentially, so each row is conditioned on the terms above it.
term_names = String.(vcat(RCall.rcopy(RCall.reval("perm_terms"))))
term_r2 = vcat(RCall.rcopy(RCall.reval("perm_terms_r2")))
@@ -1425,20 +1598,32 @@ function run_permanova(mat::Matrix{Float64}, metadata::DataFrame; seed::Integer=
(; groups = [(; group = groups[k], mean = vcat(d[:mean])[k], median = vcat(d[:median])[k]) for k in eachindex(groups)],
f_statistic = d[:f], df = [Int(d[:df1]), Int(d[:df2])], p_value = d[:p], text = String(d[:text]))
end
- RCall.reval("rm(mat, meta_r, formula_rhs, seed, perm_blocks, disp_cols, perm_ctrl, dist_mat, form, perm_res, perm_err, perm_text, perm_r2, perm_f, perm_p, perm_terms, perm_terms_r2, perm_terms_f, perm_terms_p, disp, disp_err); gc()")
+ RCall.reval("rm(mat, meta_r, formula_rhs, seed, perm_blocks, disp_cols, perm_untestable, perm_ctrl, dist_mat, form, perm_res, perm_err, perm_n, perm_text, perm_r2, perm_f, perm_p, perm_terms, perm_terms_r2, perm_terms_f, perm_terms_p, disp, disp_err); if (exists(\"untestable_rows\")) rm(untestable_rows); gc()")
ismissing(txt) && return nothing
# When R's adonis2 threw, txt is the error message and r2/f/p are missing.
# Return a named tuple with :message so the route can distinguish and surface it.
ismissing(r2) && return (; message=string(txt))
+ reason(t) = t in untestable ?
+ "'$t' is constant within every permutation block, and permutations are restricted " *
+ "within blocks, so its labels are never exchanged and the p-value would not test it; " *
+ "a between-block term needs whole blocks permuted" :
+ nothing
+ withheld(t, p) = (t in untestable || ismissing(p)) ? nothing : p
+ terms = [(; term=term_names[k],
+ r2=term_r2[k],
+ f_statistic=ismissing(term_f[k]) ? nothing : term_f[k],
+ p_value=withheld(term_names[k], term_p[k]),
+ untestable_reason=reason(term_names[k])) for k in eachindex(term_names)]
(; text=txt,
+ term=term_names[1],
r2=r2,
f_statistic=ismissing(f_stat) ? nothing : f_stat,
- p_value=ismissing(p_val) ? nothing : p_val,
- terms=[(; term=term_names[k],
- r2=term_r2[k],
- f_statistic=ismissing(term_f[k]) ? nothing : term_f[k],
- p_value=ismissing(term_p[k]) ? nothing : term_p[k]) for k in eachindex(term_names)],
+ p_value=withheld(term_names[1], p_val),
+ untestable_reason=reason(term_names[1]),
+ terms,
+ permutations=Int(n_perm),
+ min_p_value=1 / (Int(n_perm) + 1),
blocked,
dispersion)
end
diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl
index 5fad6b45..88a57d0d 100644
--- a/src/analysis/differential.jl
+++ b/src/analysis/differential.jl
@@ -2,24 +2,32 @@
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
## Differential abundance
-# Per-taxon negative-binomial GLMs comparing two groups of samples, fitted with
-# MASS::glm.nb, with a library-size offset and Benjamini-Hochberg correction
-# across the taxa that produced a p-value.
+# Per-taxon models comparing two groups of samples, with Benjamini-Hochberg
+# correction across the taxa that produced a p-value. Two methods:
+#
+# - nb_glm: a negative-binomial GLM per taxon (MASS::glm.nb) on the observed
+# integer read counts, with a library-size offset. There are no pseudocounts.
+# - clr_welch: Welch's two-sample t-test per taxon (stats::t.test) on centred
+# log-ratios, over the taxa that pass `min_prevalence`, after their zeros are
+# replaced by zCompositions::cmultRepl (Bayesian-multiplicative, for counts).
+# The estimate is a difference in CLR units, not a fold change.
#
# Count matrices here are samples x taxa, as everywhere else in `Analysis`.
-# The model is fitted on the observed integer read counts: there are no
-# pseudocounts, and anything that is not a non-negative integer is refused
-# rather than rounded. A taxon whose fit fails is reported with its reason and
-# kept out of the multiple-testing family; it never receives a stand-in p-value.
+# Anything that is not a non-negative integer count is refused rather than
+# rounded. A taxon whose fit fails is reported with its reason and kept out of
+# the multiple-testing family; it never receives a stand-in p-value.
module Differential
using DataFrames, RCall
using ..RRuntime: with_r_lock
using ..Analysis: _palette_hex, R_WAIT_SECONDS
+export DifferentialConfig, ScalingRefusal, MASSUnavailable, ZCompositionsUnavailable,
+ OFFSET_METHODS, METHODS, tss_factors, rle_factors, size_factors, bh_adjust,
+ validate_counts, replace_zeros, clr_transform, fit_nb, fit_welch,
+ differential_abundance, volcano_chart
-export DifferentialConfig, ScalingRefusal, MASSUnavailable, OFFSET_METHODS,
- tss_factors, rle_factors, size_factors, bh_adjust, validate_counts,
- fit_nb, differential_abundance, volcano_chart
+# Methods accepted under `analysis.differential.method`.
+const METHODS = ("nb_glm", "clr_welch")
# Offset (size factor) methods accepted under `analysis.differential.offset`.
const OFFSET_METHODS = ("tss", "rle")
@@ -30,24 +38,45 @@ const OFFSET_METHODS = ("tss", "rle")
const THETA_UPPER = 1e7
const THETA_LOWER = 1e-8
+# zCompositions::cmultRepl's default `frac`, as in Martín-Fernández et al. (2003).
+const DEFAULT_DELTA = 0.65
+
"""
- DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0)
+ DifferentialConfig(offset="tss", min_prevalence=0.0; method="nb_glm",
+ replacement_delta=0.65)
Settings for one differential abundance analysis, read from
-`analysis.differential` in the pipeline config. `offset` names the size factor
-method (`"tss"` or `"rle"`). `min_prevalence` is the fraction of samples, in
-[0, 1], in which a taxon must have at least one read to be tested.
+`analysis.differential` in the pipeline config. `method` is `"nb_glm"` or
+`"clr_welch"`. `offset` names the size factor method (`"tss"` or `"rle"`) and is
+used by `nb_glm` only: a library-size offset has no meaning for a test on
+log-ratios, which are already free of library size.
+`min_prevalence` is the fraction of samples, in [0, 1], in which a taxon must
+have at least one read to be tested. `replacement_delta`, in (0, 1), is the
+`frac` passed to `zCompositions::cmultRepl` by `clr_welch`: an imputed proportion
+above its taxon's smallest observed proportion is lowered to this fraction of it.
+Method and offset names are stripped and lowercased. Throws `ArgumentError`
+for unsupported names or out-of-range fractions, including settings unused by
+the selected method.
"""
struct DifferentialConfig
- offset :: String
- min_prevalence :: Float64
- function DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0)
+ method :: String
+ offset :: String
+ min_prevalence :: Float64
+ replacement_delta :: Float64
+ function DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0;
+ method::AbstractString="nb_glm",
+ replacement_delta::Real=DEFAULT_DELTA)
+ m = lowercase(strip(String(method)))
+ m in METHODS || throw(ArgumentError(
+ "analysis.differential.method must be one of $(join(METHODS, ", ")), not '$method'"))
o = lowercase(strip(String(offset)))
o in OFFSET_METHODS || throw(ArgumentError(
"analysis.differential.offset must be one of $(join(OFFSET_METHODS, ", ")), not '$offset'"))
(isfinite(min_prevalence) && 0 <= min_prevalence <= 1) || throw(ArgumentError(
"analysis.differential.min_prevalence must be a fraction in [0, 1], not $min_prevalence"))
- new(o, Float64(min_prevalence))
+ (isfinite(replacement_delta) && 0 < replacement_delta < 1) || throw(ArgumentError(
+ "analysis.differential.replacement_delta must be in (0, 1), not $replacement_delta"))
+ new(m, o, Float64(min_prevalence), Float64(replacement_delta))
end
end
@@ -77,6 +106,18 @@ Base.showerror(io::IO, ::MASSUnavailable) = print(io,
"the R package MASS is not available, so negative-binomial models cannot be " *
"fitted; install R with its recommended packages (MASS ships with them)")
+"""
+ ZCompositionsUnavailable()
+
+Raised when the R package zCompositions, which provides `cmultRepl`, cannot be
+loaded.
+"""
+struct ZCompositionsUnavailable <: Exception end
+
+Base.showerror(io::IO, ::ZCompositionsUnavailable) = print(io,
+ "the R package zCompositions is not available, so zeros cannot be replaced for " *
+ "clr_welch; restore the R library from renv.lock (Rscript -e 'renv::restore()')")
+
"""
_geomean(x) -> Float64
@@ -179,7 +220,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 +231,7 @@ function validate_counts(counts::AbstractMatrix{<:Real}, samples::AbstractVector
x = counts[i, j]
(isfinite(x) && x >= 0 && isinteger(x)) || throw(ArgumentError(
"the count for taxon '$(taxa[j])' in sample '$(samples[i])' is $x: the " *
- "negative-binomial model needs non-negative integer read counts"))
+ "models are defined on non-negative integer read counts"))
end
nothing
end
@@ -212,6 +253,18 @@ for (da_j in seq_len(da_n)) {
da_result[da_j, "note"] <- "the counts are constant across samples: there is nothing to estimate"
next
}
+ # Complete separation: with no reads in every sample of one group the group
+ # coefficient runs off to infinity, its standard error with it, and the Wald
+ # p-value collapses towards 1 although the fit reports convergence. That p is
+ # not evidence of no difference, so the taxon is refused with its reason.
+ da_absent <- levels(da_g)[vapply(levels(da_g), function(l) all(da_y[da_g == l] == 0), logical(1))]
+ if (length(da_absent) > 0L) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- paste0("no reads in any '", da_absent[[1]], "' sample (complete ",
+ "separation): the fold change is unbounded and the Wald test ",
+ "is undefined, so no p-value is reported")
+ next
+ }
da_warns <- character(0)
da_fit <- tryCatch(
withCallingHandlers(
@@ -298,7 +351,7 @@ function fit_nb(counts::AbstractMatrix{<:Integer}, groups::AbstractVector{<:Abst
RCall.reval("rm(list = intersect(ls(), c('da_counts', 'da_group', 'da_levels', " *
"'da_offset', 'da_theta_upper', 'da_theta_lower', 'da_g', 'da_term', " *
"'da_n', 'da_result', 'da_j', 'da_y', 'da_warns', 'da_fit', 'da_co', " *
- "'da_row', 'da_theta'))); invisible(gc())")
+ "'da_row', 'da_theta', 'da_absent'))); invisible(gc())")
end
end
DataFrame(status = String.(raw.status),
@@ -310,23 +363,273 @@ 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 finite and positive or an `ArgumentError` is thrown; replace
+zeros first.
+"""
+function clr_transform(x::AbstractMatrix{<:Real})
+ all(v -> isfinite(v) && v > 0, x) || throw(ArgumentError(
+ "the centred log-ratio needs positive finite values; replace zeros first"))
+ l = log.(Float64.(x))
+ l .- sum(l; dims=2) ./ size(l, 2)
+end
+
+# zCompositions::cmultRepl on the taxa that will be tested. z.warning = 1 turns
+# off its own sparsity screen, and z.delete = FALSE stops it from dropping a
+# row or column: either would silently change the taxa or misalign the samples
+# with their groups. Which taxa are sparse enough to leave out is decided by
+# `min_prevalence` before this runs. Errors and warnings are captured, not raised.
+const _REPLACE_R = raw"""
+da_warns <- character(0)
+da_out <- tryCatch(
+ withCallingHandlers(
+ as.matrix(zCompositions::cmultRepl(da_counts, label = 0, method = "GBM", output = "prop",
+ frac = da_frac, z.warning = 1, z.delete = FALSE,
+ suppress.print = TRUE)),
+ warning = function(w) {
+ da_warns <<- c(da_warns, conditionMessage(w))
+ invokeRestart("muffleWarning")
+ }),
+ error = function(e) conditionMessage(e))
+"""
+
+"""
+ _check_compositions(out, zero_mask, samples) -> out
+
+Check that cmultRepl's output `out` is one valid composition per sample: the
+same shape as the input whose zeros `zero_mask` marks, and every row finite,
+positive and summing to 1 within an absolute tolerance of 1e-8. Throws
+`ErrorException` if the shapes differ, or `ArgumentError` naming the first
+invalid sample and pointing at `min_prevalence`. Returns `out` unchanged.
+"""
+function _check_compositions(out::AbstractMatrix{<:Real}, zero_mask::AbstractMatrix{Bool},
+ samples::AbstractVector{<:AbstractString})
+ size(out) == size(zero_mask) || error(
+ "cmultRepl returned a $(size(out)) table for $(size(zero_mask)) input; a row or column was dropped")
+ for i in axes(out, 1)
+ row = view(out, i, :)
+ if !all(v -> isfinite(v) && v > 0, row) || abs(sum(row) - 1) > 1e-8
+ throw(ArgumentError(
+ "zero replacement left sample '$(samples[i])' without a valid composition: " *
+ "its $(count(view(zero_mask, i, :))) replaced zeros would take the whole sample. " *
+ "Raise analysis.differential.min_prevalence so fewer rare taxa are tested"))
+ end
+ end
+ out
+end
+
+"""
+ replace_zeros(counts; frac=DEFAULT_DELTA, samples, taxa) -> NamedTuple
+
+Replace the zeros of the samples x taxa read-count matrix `counts` with
+`zCompositions::cmultRepl(counts, label = 0, method = "GBM", output = "prop",
+frac = frac)`, the geometric Bayesian-multiplicative replacement for count
+data. Returns `(proportions, zeros_replaced, imputed_mass, warnings)`:
+`proportions` has the same shape as `counts`, each row summing to 1 with no
+zero left; `imputed_mass` is the share of each sample's total held by replaced
+values; `warnings` is whatever R warned.
+`frac` controls the fraction of a taxon's smallest observed proportion used
+to lower an excessive imputed proportion (see [`DifferentialConfig`](@ref)).
+Without zeros, returns row-normalised counts, but still requires zCompositions.
+
+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. Mismatched sample or
+taxon labels raise `ArgumentError`; a changed output shape raises `ErrorException`.
+"""
+function replace_zeros(counts::AbstractMatrix{<:Real};
+ frac::Real=DEFAULT_DELTA,
+ samples::AbstractVector{<:AbstractString}=["sample $i" for i in axes(counts, 1)],
+ taxa::AbstractVector{<:AbstractString}=["taxon $j" for j in axes(counts, 2)])
+ size(counts) == (length(samples), length(taxa)) || throw(ArgumentError(
+ "the table is $(size(counts)) but there are $(length(samples)) samples and $(length(taxa)) taxa"))
+ size(counts, 2) >= 2 || throw(ArgumentError(
+ "the centred log-ratio needs at least 2 taxa; there are $(size(counts, 2))"))
+ n = size(counts, 1)
+ sparse = [j for j in axes(counts, 2) if count(>(0), view(counts, :, j)) < 2]
+ isempty(sparse) || throw(ArgumentError(
+ "$(length(sparse)) taxa have reads in fewer than 2 samples, so cmultRepl's " *
+ "Bayesian-multiplicative prior cannot be estimated for them (" *
+ join(("'$(taxa[j])'" for j in first(sparse, 5)), ", ") *
+ (length(sparse) > 5 ? ", ..." : "") * "); raise analysis.differential.min_prevalence " *
+ "to at least $(round(2 / n; digits=3)) so that only taxa seen in 2 or more samples are tested"))
+ for i in axes(counts, 1)
+ sum(view(counts, i, :)) > 0 || throw(ArgumentError(
+ "sample '$(samples[i])' has no reads in the $(size(counts, 2)) taxa that pass " *
+ "min_prevalence, so it has no composition to transform"))
+ end
+
+ zero_mask = counts .== 0
+ n_zero = count(zero_mask)
+ out, warns = with_r_lock(; timeout=R_WAIT_SECONDS[]) do
+ RCall.rcopy(RCall.reval("requireNamespace('zCompositions', quietly = TRUE)")) ||
+ throw(ZCompositionsUnavailable())
+ # cmultRepl stops when there is no zero to replace; the closure of the
+ # data is then the whole answer.
+ n_zero == 0 && return (Float64.(counts) ./ sum(counts; dims=2), String[])
+ RCall.globalEnv[:da_counts] = Matrix{Float64}(counts)
+ RCall.globalEnv[:da_frac] = Float64(frac)
+ try
+ RCall.reval(_REPLACE_R)
+ res = RCall.rcopy(RCall.reval("da_out"))
+ res isa AbstractString && throw(ArgumentError(
+ "zCompositions::cmultRepl refused the table: $res. Raising " *
+ "analysis.differential.min_prevalence leaves fewer, better-observed taxa"))
+ (Matrix{Float64}(res), String.(RCall.rcopy(Vector{String}, RCall.reval("da_warns"))))
+ finally
+ RCall.reval("rm(list = intersect(ls(), c('da_counts', 'da_frac', 'da_out', 'da_warns'))); " *
+ "invisible(gc())")
+ end
+ end
+ _check_compositions(out, zero_mask, samples)
+ mass = [sum(out[i, j] for j in axes(out, 2) if zero_mask[i, j]; init=0.0) for i in axes(out, 1)]
+ (proportions=out, zeros_replaced=n_zero, imputed_mass=mass, warnings=unique(warns))
+end
+
+# The per-taxon Welch t-test loop, with the same `da_` naming and statuses as
+# `_FIT_R` but no `boundary`: a t-test has no dispersion parameter to run to a bound.
+const _FIT_WELCH_R = raw"""
+da_g <- factor(da_group, levels = da_levels)
+da_n <- ncol(da_values)
+da_result <- data.frame(status = rep("ok", da_n), note = rep("", da_n),
+ estimate = rep(NA_real_, da_n), se = rep(NA_real_, da_n),
+ statistic = rep(NA_real_, da_n), pvalue = rep(NA_real_, da_n),
+ stringsAsFactors = FALSE)
+for (da_j in seq_len(da_n)) {
+ da_y <- da_values[, da_j]
+ if (length(unique(da_y)) < 2L) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- "the values are constant across samples: there is nothing to estimate"
+ next
+ }
+ da_warns <- character(0)
+ da_tt <- tryCatch(
+ withCallingHandlers(
+ stats::t.test(da_y[da_g == da_levels[2]], da_y[da_g == da_levels[1]], var.equal = FALSE),
+ warning = function(w) {
+ da_warns <<- c(da_warns, conditionMessage(w))
+ invokeRestart("muffleWarning")
+ }),
+ error = function(e) e)
+ if (inherits(da_tt, "error")) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- paste("t.test stopped with an error:", conditionMessage(da_tt))
+ next
+ }
+ da_row <- c(unname(da_tt[["estimate"]][1] - da_tt[["estimate"]][2]), unname(da_tt[["stderr"]]),
+ unname(da_tt[["statistic"]]), da_tt[["p.value"]])
+ if (!all(is.finite(da_row)) || da_row[[2]] <= 0 || da_row[[4]] < 0 || da_row[[4]] > 1) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- paste(c("the test returned a non-finite or zero standard error, so there is no test (both groups have zero variance)",
+ unique(da_warns)), collapse = "; ")
+ next
+ }
+ da_result[da_j, c("estimate", "se", "statistic", "pvalue")] <- da_row
+ if (length(da_warns) > 0L) {
+ da_result[da_j, "note"] <- paste("R warned:", paste(unique(da_warns), collapse = "; "))
+ }
+}
+"""
+
+"""
+ fit_welch(values, groups; levels) -> DataFrame
+
+Welch's two-sample t-test, `stats::t.test(contrast, reference, var.equal =
+FALSE)`, on each column of the samples x taxa matrix `values` (for `clr_welch`,
+centred log-ratios). `levels` is `(reference, contrast)`, so the estimate is
+the mean of `contrast` minus the mean of `reference`; the variances of the two
+groups are not assumed equal, since the groups often differ in size. Returns
+one row per taxon with `status` (`ok` or `failed`), `note`, `estimate`, `se`,
+`statistic` (Welch's t) and `pvalue`; a failed taxon has `nothing` for every
+statistic. Errors from an individual R test become failed rows with a reason
+in `note`. Throws `ArgumentError` if the group labels do not match the number
+of samples and `RBusyError` when the R runtime stays busy; other R evaluation
+or conversion errors propagate.
+"""
+function fit_welch(values::AbstractMatrix{<:Real}, groups::AbstractVector{<:AbstractString};
+ levels::NTuple{2,String})
+ size(values, 1) == length(groups) || throw(ArgumentError(
+ "values have $(size(values, 1)) samples and groups $(length(groups))"))
+ raw = with_r_lock(; timeout=R_WAIT_SECONDS[]) do
+ RCall.globalEnv[:da_values] = Matrix{Float64}(values)
+ RCall.globalEnv[:da_group] = String.(groups)
+ RCall.globalEnv[:da_levels] = collect(levels)
+ try
+ RCall.reval(_FIT_WELCH_R)
+ DataFrame(RCall.rcopy(RCall.reval("da_result")))
+ finally
+ RCall.reval("rm(list = intersect(ls(), c('da_values', 'da_group', 'da_levels', " *
+ "'da_g', 'da_n', 'da_result', 'da_j', 'da_y', 'da_warns', 'da_tt', " *
+ "'da_row'))); invisible(gc())")
+ end
+ end
+ DataFrame(status = String.(raw.status),
+ note = String.(raw.note),
+ estimate = _num.(raw.estimate),
+ se = _num.(raw.se),
+ statistic = _num.(raw.statistic),
+ pvalue = _num.(raw.pvalue))
+end
+
+"""
+ _row(taxon, prevalence, note) -> Dict{String,Any}
+
+A results row with no statistics, as for a taxon that was not fitted.
+"""
+_row(taxon, prevalence, note) = Dict{String,Any}(
+ "taxon" => String(taxon), "status" => "filtered", "note" => note,
+ "estimate" => nothing, "log2_fold_change" => nothing,
+ "standard_error" => nothing, "statistic" => nothing,
+ "pvalue" => nothing, "padj" => nothing, "dispersion_theta" => nothing,
+ "prevalence" => prevalence)
+
"""
differential_abundance(counts, samples, taxa, groups; reference, contrast,
config=DifferentialConfig()) -> Dict{String,Any}
Test every taxon (column) of the samples x taxa read-count matrix for a
difference in abundance between the samples labelled `contrast` and those
-labelled `reference` in `groups`.
-
-Size factors are computed on the full matrix by `config.offset` and enter each
-model as `log(factor)`. Taxa present in fewer than `config.min_prevalence` of
-the samples are reported as `filtered` and not fitted. The Benjamini-Hochberg
+labelled `reference` in `groups`, by `config.method`.
+
+`nb_glm`: size factors are computed on the full matrix by `config.offset` and
+enter each negative-binomial model as `log(factor)`; the estimate is a
+natural-log fold change.
+
+`clr_welch`, in this order: (1) `min_prevalence` chooses the taxa, and taxa with
+no reads in any sample are always left out; (2) the zeros of those taxa only
+are replaced by `zCompositions::cmultRepl` (GBM, `frac =
+config.replacement_delta`), so the rare taxa that are not tested contribute no
+imputed values; (3) each sample is transformed to centred log-ratios over the
+same taxa; (4) each taxon is tested by Welch's t-test. Replacing zeros across
+every observed taxon first would let imputed values for rare taxa take over
+the samples of a sparse table. The estimate is the difference in mean CLR,
+contrast minus reference: a difference in centred-log-ratio units, not a fold
+change, relative to the geometric mean of the tested taxa. Replaced zeros are
+not measurements; `diagnostics.zero_replacement` reports the median and maximum
+share of sample mass held by replaced zeros. Each group needs at least 2 samples.
+
+For both methods, taxa present in fewer than `config.min_prevalence` of the
+samples are reported as `filtered` and not fitted, and the Benjamini-Hochberg
family is the fitted taxa that produced a p-value; failed and filtered taxa
-carry `padj = nothing`.
-
-Throws `ArgumentError` for unusable input, `ScalingRefusal` when the offset
-cannot be computed, `MASSUnavailable` when MASS is missing, and an
-`ErrorException` when no taxon could be fitted at all.
+carry `padj = nothing`. The result's `effect` names the row key that holds the
+effect size and its label.
+Rows are sorted by adjusted p-value, raw p-value and taxon name, with missing
+p-values last. The overall status is `partial` if any taxon failed, otherwise `ok`.
+
+Throws `ArgumentError` for unusable input (including a zero replacement that
+cannot be made), `ScalingRefusal` when the offset cannot be computed,
+`MASSUnavailable` or `ZCompositionsUnavailable` when the R package a method
+needs is missing, `RBusyError` when the R runtime stays busy, and an
+`ErrorException` when no taxon could be fitted at all or zero replacement changes
+the table's shape. Other R evaluation or conversion errors propagate.
"""
function differential_abundance(counts::AbstractMatrix{<:Real},
samples::AbstractVector{<:AbstractString},
@@ -348,38 +651,82 @@ function differential_abundance(counts::AbstractMatrix{<:Real},
(n_ref >= 1 && n_con >= 1) || throw(ArgumentError(
"each group needs at least one sample; '$reference' has $n_ref and '$contrast' has $n_con"))
n_ref + n_con >= 3 || throw(ArgumentError(
- "a group effect and a dispersion cannot be estimated from $(n_ref + n_con) samples; at least 3 are needed"))
-
- factors = size_factors(counts, samples, config.offset)
- offset = log.(factors)
+ "a group effect and a variance cannot be estimated from $(n_ref + n_con) samples; at least 3 are needed"))
n = length(samples)
- prevalence = [count(>(0), view(counts, :, j)) / n for j in axes(counts, 2)]
- tested = findall(>=(config.min_prevalence), prevalence)
-
- fits = isempty(tested) ? nothing :
- fit_nb(Int.(counts[:, tested]), groups, offset; levels=(String(reference), String(contrast)))
-
- rows = [Dict{String,Any}(
- "taxon" => String(taxa[j]), "status" => "filtered",
- "note" => "present in $(count(>(0), view(counts, :, j))) of $n samples, " *
- "below analysis.differential.min_prevalence = $(config.min_prevalence)",
- "estimate" => nothing, "log2_fold_change" => nothing,
- "standard_error" => nothing, "statistic" => nothing,
- "pvalue" => nothing, "padj" => nothing, "dispersion_theta" => nothing,
- "prevalence" => prevalence[j])
+ present = [count(>(0), view(counts, :, j)) for j in axes(counts, 2)]
+ prevalence = present ./ n
+ rows = [_row(taxa[j], prevalence[j],
+ "present in $(present[j]) of $n samples, below " *
+ "analysis.differential.min_prevalence = $(config.min_prevalence)")
for j in axes(counts, 2)]
- if !isnothing(fits)
+ levels = (String(reference), String(contrast))
+ diagnostics = Dict{String,Any}()
+
+ if config.method == "nb_glm"
+ factors = size_factors(counts, samples, config.offset)
+ tested = findall(>=(config.min_prevalence), prevalence)
+ fits = isempty(tested) ? nothing :
+ fit_nb(Int.(counts[:, tested]), groups, log.(factors); levels)
+ if !isnothing(fits)
+ for (k, j) in enumerate(tested)
+ f = fits[k, :]
+ merge!(rows[j], Dict{String,Any}(
+ "status" => f.status, "note" => f.note, "estimate" => f.estimate,
+ "log2_fold_change" => isnothing(f.estimate) ? nothing : f.estimate / log(2),
+ "standard_error" => f.se, "statistic" => f.statistic,
+ "pvalue" => f.pvalue, "dispersion_theta" => f.theta))
+ end
+ end
+ method_text = "Negative-binomial GLM per taxon (MASS::glm.nb, log link) with a " *
+ "$(uppercase(config.offset)) size-factor offset; Wald test of the group " *
+ "coefficient; Benjamini-Hochberg adjustment over the fitted taxa"
+ effect = Dict("key" => "log2_fold_change", "label" => "log2 fold change")
+ size_factor_rows = [Dict("sample" => String(samples[i]), "group" => String(groups[i]),
+ "factor" => factors[i]) for i in eachindex(samples)]
+ config_echo = Dict{String,Any}("method" => config.method, "offset" => config.offset,
+ "min_prevalence" => config.min_prevalence)
+ else
+ (n_ref >= 2 && n_con >= 2) || throw(ArgumentError(
+ "Welch's t-test needs at least 2 samples in each group to estimate its variance; " *
+ "'$reference' has $n_ref and '$contrast' has $n_con"))
+ for j in findall(==(0), present)
+ rows[j]["note"] = "no reads in any sample of either group, so it is not part of the composition"
+ end
+ tested = findall(j -> present[j] > 0 && prevalence[j] >= config.min_prevalence, axes(counts, 2))
+ length(tested) >= 2 || throw(ArgumentError(
+ "the centred log-ratio needs at least 2 taxa with reads at or above " *
+ "analysis.differential.min_prevalence = $(config.min_prevalence); there are $(length(tested))"))
+ replaced = replace_zeros(counts[:, tested]; frac=config.replacement_delta,
+ samples, taxa=taxa[tested])
+ clr = clr_transform(replaced.proportions)
+ fits = fit_welch(clr, groups; levels)
for (k, j) in enumerate(tested)
f = fits[k, :]
- est = f.estimate
merge!(rows[j], Dict{String,Any}(
- "status" => f.status, "note" => f.note,
- "estimate" => est,
- "log2_fold_change" => isnothing(est) ? nothing : est / log(2),
+ "status" => f.status, "note" => f.note, "estimate" => f.estimate,
"standard_error" => f.se, "statistic" => f.statistic,
- "pvalue" => f.pvalue, "dispersion_theta" => f.theta))
+ "pvalue" => f.pvalue))
end
+ method_text = "Welch's two-sample t-test per taxon (stats::t.test, var.equal = FALSE) on " *
+ "centred log-ratios over the taxa passing min_prevalence, after their zeros " *
+ "were replaced by zCompositions::cmultRepl (GBM, frac = $(config.replacement_delta)); " *
+ "Benjamini-Hochberg adjustment over the fitted taxa. Estimates are " *
+ "differences in CLR units, not fold changes, and replaced zeros are not measurements"
+ effect = Dict("key" => "estimate", "label" => "CLR difference")
+ size_factor_rows = Dict{String,Any}[]
+ config_echo = Dict{String,Any}("method" => config.method,
+ "min_prevalence" => config.min_prevalence,
+ "replacement_delta" => config.replacement_delta)
+ sorted_mass = sort(replaced.imputed_mass)
+ diagnostics["zero_replacement"] = Dict{String,Any}(
+ "method" => "zCompositions::cmultRepl (GBM)",
+ "delta" => config.replacement_delta, "zeros_replaced" => replaced.zeros_replaced,
+ "n_taxa_in_composition" => length(tested),
+ "n_taxa_unobserved" => count(==(0), present),
+ "median_imputed_fraction" => _median(sorted_mass),
+ "max_imputed_fraction" => last(sorted_mass),
+ "warnings" => replaced.warnings)
end
family = findall(r -> r["status"] in ("ok", "boundary") && !isnothing(r["pvalue"]), rows)
@@ -396,21 +743,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 +763,17 @@ 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 an adjusted p-value are omitted here; they remain in the results table
+with their reason. Without `effect`, uses log2 fold change. Zero raw p-values
+are plotted at the smallest positive normal `Float64` and identified in hover text.
"""
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 +788,14 @@ function volcano_chart(result::AbstractDict; alpha::Real=0.05)
r["pvalue"] == 0 ? " p underflowed to 0" : "") for r in sel]
push!(traces, Dict{String,Any}(
"type" => "scatter", "mode" => "markers", "name" => name,
- "x" => [r["log2_fold_change"] for r in sel], "y" => y,
+ "x" => [r[effect["key"]] for r in sel], "y" => y,
"text" => text, "hoverinfo" => "text+x+y",
"marker" => Dict("color" => colour, "size" => 8),
))
end
layout = Dict{String,Any}(
"title" => Dict("text" => "Differential abundance: $con vs $ref"),
- "xaxis" => Dict("title" => "log2 fold change ($con / $ref)", "zeroline" => true),
+ "xaxis" => Dict("title" => "$(effect["label"]) ($con vs $ref)", "zeroline" => true),
"yaxis" => Dict("title" => "-log10 p"),
)
Dict("data" => traces, "layout" => layout)
diff --git a/src/core/validate.jl b/src/core/validate.jl
index a9a22b88..3a5cb1ba 100644
--- a/src/core/validate.jl
+++ b/src/core/validate.jl
@@ -7,7 +7,7 @@ module Validation
export validate_environment, validate_project, ValidationError,
DENOVO_METHODS, REMOTE_STAGES, DADA2_REMOTE_STAGES, PHYLOGENY_REMOTE_STAGES,
PHYLOGENY_ALIGN_STRATEGIES, PHYLOGENY_BOOTSTRAPS, PHYLOGENY_TRIM_METHODS, SAFE_NAME_RE, is_safe_name, is_shell_safe,
- is_shell_safe_arg, primer_document_errors,
+ is_shell_safe_arg, remote_value_error, primer_document_errors,
database_document_errors
using YAML, Logging
@@ -338,12 +338,35 @@ export validate_environment, validate_project, ValidationError,
end
## Pipeline Config Validation
- function _validate_pipeline_cfg(errors::Vector{ValidationError}, cfg::Dict, ctx::String)
- rt = get(cfg, "r_threads", nothing)
- isnothing(rt) || _is_thread_count(rt) ||
- _err(errors, ctx, "r_threads must be a Bool or positive integer (got: $(repr(rt)))")
+ """
+ _staging_dir_errors!(errors, sd, ctx)
- rm_cfg = get(cfg, "remote", Dict())
+ Record why `sd` cannot be `remote.staging_dir`: it must be an absolute path
+ a remote login shell would pass through as one word.
+ """
+ function _staging_dir_errors!(errors::Vector{ValidationError}, sd, ctx::String)
+ if isnothing(sd) || !(sd isa AbstractString)
+ _err(errors, ctx, "remote.staging_dir must be set when remote.host is set")
+ else
+ startswith(sd, "/") ||
+ _err(errors, ctx, "remote.staging_dir must be an absolute path on the " *
+ "server (got: $(repr(sd)))")
+ # staging_dir is interpolated into the command string the
+ # remote sshd runs through a login shell, so it is gated here
+ # exactly as databases.yml gates dada2.remote_path.
+ is_shell_safe_arg(sd) ||
+ _err(errors, ctx, "remote.staging_dir may not contain shell " *
+ "metacharacters or spaces")
+ end
+ end
+
+ """
+ _validate_remote!(errors, rm_cfg, ctx)
+
+ Record what is wrong with the `remote` block `rm_cfg`: its host, stages,
+ threads, staging root (once a host is named) and tool names.
+ """
+ function _validate_remote!(errors::Vector{ValidationError}, rm_cfg, ctx::String)
if rm_cfg isa Dict
host = get(rm_cfg, "host", nothing)
isnothing(host) || host isa AbstractString ||
@@ -370,20 +393,7 @@ export validate_environment, validate_project, ValidationError,
# placeholder staging_dir, and refusing that would make every config
# invalid until the user configures a server they may never want.
if !isnothing(host)
- sd = get(rm_cfg, "staging_dir", nothing)
- if isnothing(sd) || !(sd isa AbstractString)
- _err(errors, ctx, "remote.staging_dir must be set when remote.host is set")
- else
- startswith(sd, "/") ||
- _err(errors, ctx, "remote.staging_dir must be an absolute path on the " *
- "server (got: $(repr(sd)))")
- # staging_dir is interpolated into the command string the
- # remote sshd runs through a login shell, so it is gated here
- # exactly as databases.yml gates dada2.remote_path.
- is_shell_safe_arg(sd) ||
- _err(errors, ctx, "remote.staging_dir may not contain shell " *
- "metacharacters or spaces")
- end
+ _staging_dir_errors!(errors, get(rm_cfg, "staging_dir", nothing), ctx)
end
else
_err(errors, ctx, "remote must be a mapping (got: $(repr(rm_cfg)))")
@@ -400,6 +410,38 @@ export validate_environment, validate_project, ValidationError,
elseif !isnothing(rt_tools)
_err(errors, ctx, "remote.tools must be a mapping (got: $(repr(rt_tools)))")
end
+ end
+
+ """
+ remote_value_error(key, value) -> Union{String,Nothing}
+
+ Why `value` cannot be stored as `remote.` (e.g. `key = "tools.mafft"`),
+ or nothing. Runs the checks `validate_project` makes of the remote block on
+ that one key, so the config write gate refuses what validation would;
+ `staging_dir` is checked even when no host is named yet.
+ """
+ function remote_value_error(key::AbstractString, value)
+ errors = ValidationError[]
+ if key == "staging_dir"
+ _staging_dir_errors!(errors, value, "")
+ else
+ nested = foldr((k, inner) -> Dict{String,Any}(String(k) => inner), split(key, '.'); init=value)
+ _validate_remote!(errors, nested, "")
+ end
+ isempty(errors) ? nothing : join((e.message for e in errors), "; ")
+ end
+
+ """
+ _validate_pipeline_cfg(errors, cfg, ctx)
+
+ Record what is wrong with a merged pipeline config `cfg`, section by section.
+ """
+ function _validate_pipeline_cfg(errors::Vector{ValidationError}, cfg::Dict, ctx::String)
+ rt = get(cfg, "r_threads", nothing)
+ isnothing(rt) || _is_thread_count(rt) ||
+ _err(errors, ctx, "r_threads must be a Bool or positive integer (got: $(repr(rt)))")
+
+ _validate_remote!(errors, get(cfg, "remote", Dict()), ctx)
ph = get(cfg, "phylogeny", nothing)
ph isa Dict && _validate_phylogeny(errors, ph, ctx)
diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl
index 93cd59f9..42efeeb0 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
"""
@@ -1210,7 +1212,11 @@ end
Run a differential abundance test between exactly two conditions (runs or
pooled-run sub-groups) and return the per-taxon table with a volcano plot. The
first condition is the reference, so a positive estimate means more abundant in
-the second. Every refusal is an explicit JSON error.
+the second: a natural-log fold change for `nb_glm`, or a mean CLR difference for
+`clr_welch`. `ArgumentError`s from config construction or the test return 400;
+scaling refusals and analysis `ErrorException`s return 422, and missing MASS or
+zCompositions returns 503. Missing studies or results return 404.
+`RBusyError` and other uncaught errors propagate to the caller.
"""
function _differential(study::String, body)
study in _study_names() || return json_error(404, "study_not_found",
@@ -1279,6 +1285,7 @@ function _differential(study::String, body)
e isa ArgumentError && return json_error(400, "invalid_input", sprint(showerror, e))
e isa ScalingRefusal && return json_error(422, "scaling_refused", sprint(showerror, e))
e isa MASSUnavailable && return json_error(503, "r_unavailable", sprint(showerror, e))
+ e isa ZCompositionsUnavailable && return json_error(503, "r_unavailable", sprint(showerror, e))
e isa ErrorException && return json_error(422, "no_taxon_fitted", e.msg)
rethrow()
end
diff --git a/src/server/routes/config.jl b/src/server/routes/config.jl
index 02b662c3..20b55a60 100644
--- a/src/server/routes/config.jl
+++ b/src/server/routes/config.jl
@@ -106,6 +106,7 @@ const _ENUM_VALUES = Dict(
"tagging.source" => ("VSEARCH", "DADA2"),
"analysis.alpha.normalisation" => ("none", "rarefy", "srs"),
"analysis.beta.normalisation" => ("hellinger", "none", "rarefy", "srs"),
+ "analysis.differential.method" => Differential.METHODS,
"analysis.differential.offset" => Differential.OFFSET_METHODS,
"phylogeny.reference.align.strategy" => Validation.PHYLOGENY_ALIGN_STRATEGIES,
"phylogeny.placement.align.strategy" => Validation.PHYLOGENY_ALIGN_STRATEGIES,
@@ -114,11 +115,21 @@ 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}
+
+Return a rejection message for `value` under the dotted pipeline `key`, or
+`nothing`. Enums, command arguments, remote settings and file-name parts have
+specific checks; otherwise the type follows the factory default, with no type
+restriction for a default of ~. Does not write config or check key recognition.
+"""
function _config_value_error(key::String, value)
v = value isa JSON3.Array ? collect(value) : value
if haskey(_ENUM_VALUES, key)
@@ -135,12 +146,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"
@@ -271,11 +294,21 @@ end
# Read the document; an unparseable file becomes a 400 naming it. PrimersLibrary.load
# raises on a bad file because an empty document would pass validation and the next
# Save would overwrite the real primers. Returns the document, or an HTTP.Response error.
+"""
+ _read_primers() -> Union{Dict,HTTP.Response}
+
+Read the canonical primers document, or an empty document if the file is absent.
+Loading errors become a 400 `primers_unreadable` response naming the file and
+reason; the file is not modified.
+"""
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..6ff74393 100644
--- a/src/server/routes/databases.jl
+++ b/src/server/routes/databases.jl
@@ -15,11 +15,24 @@ end
# Read the document; an unparseable file becomes a 400 naming it. DatabasesLibrary.load
# raises on a bad file because an empty document would pass validation and the next
# Save would overwrite the real config. Returns the document, or an HTTP.Response error.
+"""
+ _read_databases() -> Union{Dict,HTTP.Response}
+
+Read the canonical databases document, or an empty document if the file is absent.
+Loading errors become a 400 `databases_unreadable` response naming the file and
+reason; the file is not modified.
+"""
function _read_databases()
try
DatabasesLibrary.load(_databases_path())
catch e
- @error "databases.yml is not readable" path=_databases_path() exception=(e, catch_backtrace())
+ # @warn, not @error: the 400 on the next line is this function's own verdict
+ # that an unreadable config/databases.yml is an operator-fixable input, not a
+ # server fault. Error severity with a backtrace reports a routine bad file as
+ # a crash. The reason is kept -- it is what names the YAML defect -- and only
+ # the backtrace goes, which pointed into YAML.jl's parser, never at the
+ # offending line.
+ @warn "databases.yml is not readable" path=_databases_path() reason=sprint(showerror, e)
json_error(400, "databases_unreadable",
"config/databases.yml cannot be read as a databases document and must be repaired by hand: $(sprint(showerror, e))")
end
diff --git a/src/server/routes/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 a406baa1..81626927 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -50,6 +50,8 @@ const UNIT_FILES = [
"test_sample_reads.jl",
"test_determinism.jl",
"test_differential.jl",
+ "test_permanova.jl",
+ "test_write_gates.jl",
]
# The files named on the command line ("test_routes.jl", "routes" or
diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl
index 9f612952..3b034896 100644
--- a/test/unit/test_differential.jl
+++ b/test/unit/test_differential.jl
@@ -2,8 +2,9 @@
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
#
# Differential abundance: Benjamini-Hochberg, TSS/RLE size factors, input
-# validation, and the negative-binomial fit checked against a direct
-# MASS::glm.nb call. Tests needing R or MASS skip loudly when either is absent.
+# validation, the negative-binomial fit checked against a direct MASS::glm.nb
+# call, and clr_welch checked against direct zCompositions::cmultRepl and Welch
+# t.test calls. Tests needing R, MASS or zCompositions skip loudly when absent.
using MetaManifold
using MetaManifold.Differential
@@ -28,6 +29,8 @@ end
const HAVE_R = _r_has("stats")
const HAVE_MASS = HAVE_R && _r_has("MASS")
HAVE_MASS || @info "R or MASS unavailable - the negative-binomial fit tests are SKIPPED"
+const HAVE_ZCOMP = HAVE_R && _r_has("zCompositions")
+HAVE_ZCOMP || @info "R or zCompositions unavailable - the clr_welch tests are SKIPPED"
# Rows per run: Up is split over two ASVs so the route must pool them by genus.
const _DA_ROWS = Dict(
@@ -163,6 +166,15 @@ end
@test_throws ArgumentError DifferentialConfig("css")
@test_throws ArgumentError DifferentialConfig("tss", 1.5)
@test_throws ArgumentError DifferentialConfig("tss", NaN)
+ @test DifferentialConfig().method == "nb_glm"
+ @test DifferentialConfig().replacement_delta == 0.65
+ @test DifferentialConfig(; method="clr_welch", replacement_delta=0.3).replacement_delta == 0.3
+ @test_throws ArgumentError DifferentialConfig(; method="ancombc")
+ # The pre-review name, never released, is not accepted.
+ @test_throws ArgumentError DifferentialConfig(; method="clr_lm")
+ for d in (0, 1, -0.5, NaN)
+ @test_throws ArgumentError DifferentialConfig(; method="clr_welch", replacement_delta=d)
+ end
end
@testset "Input validation" begin
@@ -268,6 +280,41 @@ end
end
end
+ @testset "A taxon absent from one group is refused, not reported as p≈1" begin
+ if !HAVE_MASS
+ @test_skip false
+ else
+ # 'only_b' has no reads in any A sample: complete separation.
+ only_b = vcat(fill(0, 12), [50, 80, 120, 30, 60, 90, 40, 70, 110, 55, 65, 85])
+ counts = hcat(effect, only_b)
+ taxa = vcat(effect_taxa, ["only_b"])
+ res = differential_abundance(counts, effect_samples, taxa, effect_groups;
+ reference="A", contrast="B")
+ byt = Dict(r["taxon"] => r for r in res["rows"])
+ s = byt["only_b"]
+ @test s["status"] == "failed"
+ @test occursin("'A'", s["note"]) && occursin("complete separation", s["note"])
+ @test all(isnothing, (s["estimate"], s["standard_error"], s["pvalue"], s["padj"]))
+ # The BH family is the three other taxa only.
+ @test res["diagnostics"]["n_tested"] == 3
+ @test [byt[t]["padj"] for t in effect_taxa] ≈
+ bh_adjust([byt[t]["pvalue"] for t in effect_taxa])
+
+ # Control: the direct fit converges and reports p near 1, which is
+ # the value the check above keeps out of the results.
+ off = log.(tss_factors(counts, effect_samples))
+ g = effect_groups
+ direct = rcopy(R"""
+ local({
+ fit <- MASS::glm.nb($only_b ~ factor($g, levels = c("A", "B")) + offset($off),
+ control = glm.control(maxit = 100))
+ c(as.numeric(fit$converged), summary(fit)$coefficients[2, 4])
+ })
+ """)
+ @test direct[1] == 1 && direct[2] > 0.9
+ end
+ end
+
@testset "No fittable taxon is an explicit error" begin
if !HAVE_MASS
@test_skip false
@@ -304,6 +351,255 @@ end
@test isnothing(SV._config_value_error("analysis.differential.offset", "rle"))
@test !isnothing(SV._config_value_error("analysis.differential.offset", "css"))
@test isnothing(SV._config_value_error("analysis.differential.min_prevalence", 0.1))
+ @test isnothing(SV._config_value_error("analysis.differential.method", "clr_welch"))
+ @test !isnothing(SV._config_value_error("analysis.differential.method", "ancombc"))
+ end
+
+ @testset "CLR transform" begin
+ @test clr_transform([1.0 exp(1) exp(2)]) ≈ [-1.0 0.0 1.0]
+ z = clr_transform([1.0 2 3 4; 5 1 1 9; 0.5 0.25 8 2])
+ @test all(abs.(sum(z; dims=2)) .< 1e-12)
+ # Scale invariance: a sample's CLR does not depend on its library size.
+ @test clr_transform([10.0 20 30 40]) ≈ clr_transform([1.0 2 3 4])
+ @test_throws ArgumentError clr_transform([0.0 1 2])
+ @test_throws ArgumentError clr_transform([NaN 1 2])
+ end
+
+ clr_config = DifferentialConfig(; method="clr_welch")
+
+ @testset "CLR + Welch: estimate is the difference of group mean CLRs" begin
+ if !HAVE_ZCOMP
+ @test_skip false
+ else
+ res = differential_abundance(effect, effect_samples, effect_taxa, effect_groups;
+ reference="A", contrast="B", config=clr_config)
+ @test res["effect"] == Dict("key" => "estimate", "label" => "CLR difference")
+ @test isempty(res["size_factors"])
+ @test res["config"]["method"] == "clr_welch" && !haskey(res["config"], "offset")
+ @test res["diagnostics"]["zero_replacement"]["zeros_replaced"] == 0
+ @test occursin("Welch", res["method"]) && occursin("cmultRepl", res["method"])
+ # Known answer, independent of R: the estimate is mean(B) - mean(A)
+ # of each taxon's CLR.
+ z = clr_transform(Float64.(effect))
+ a = effect_groups .== "A"; b = .!a
+ expected = vec(sum(z[b, :]; dims=1)) ./ count(b) .- vec(sum(z[a, :]; dims=1)) ./ count(a)
+ byt = Dict(r["taxon"] => r for r in res["rows"])
+ for (j, t) in enumerate(effect_taxa)
+ @test byt[t]["status"] == "ok"
+ @test byt[t]["estimate"] ≈ expected[j] rtol=1e-8
+ @test isnothing(byt[t]["log2_fold_change"]) && isnothing(byt[t]["dispersion_theta"])
+ @test 0 <= byt[t]["pvalue"] <= 1 && !isnothing(byt[t]["padj"])
+ end
+ @test byt["t_up"]["estimate"] > 0 && byt["t_down"]["estimate"] < 0
+ @test byt["t_up"]["padj"] < 0.05 && byt["t_down"]["padj"] < 0.05
+
+ @testset "matches a direct Welch t.test, with unequal groups" begin
+ # 12 A against 3 B: the case where pooled and Welch variances differ.
+ keep = 1:15
+ g = effect_groups[keep]
+ un = differential_abundance(effect[keep, :], effect_samples[keep], effect_taxa, g;
+ reference="A", contrast="B", config=clr_config)
+ ub = Dict(r["taxon"] => r for r in un["rows"])
+ zk = clr_transform(Float64.(effect[keep, :]))
+ for (j, t) in enumerate(effect_taxa)
+ y = zk[:, j]
+ direct = rcopy(R"""
+ local({
+ tt <- stats::t.test($y[$g == "B"], $y[$g == "A"], var.equal = FALSE)
+ c(tt$estimate[[1]] - tt$estimate[[2]], tt$stderr, tt$statistic[[1]], tt$p.value)
+ })
+ """)
+ @test ub[t]["estimate"] ≈ direct[1] rtol=1e-10
+ @test ub[t]["standard_error"] ≈ direct[2] rtol=1e-10
+ @test ub[t]["statistic"] ≈ direct[3] rtol=1e-10
+ @test ub[t]["pvalue"] ≈ direct[4] rtol=1e-8
+ end
+ # Control: the pooled-variance test gives a different p-value
+ # here, so the parity above can tell the two apart.
+ y = zk[:, 1]
+ pooled = rcopy(R"stats::t.test($y[$g == 'B'], $y[$g == 'A'], var.equal = TRUE)$p.value")
+ @test !isapprox(ub["t_up"]["pvalue"], pooled; rtol=1e-3)
+ end
+ end
+ # Refused before R is reached: Welch needs a variance in each group.
+ @test_throws ArgumentError differential_abundance(effect[[1, 2, 13], :], effect_samples[[1, 2, 13]],
+ effect_taxa, ["A", "A", "B"]; reference="A", contrast="B", config=clr_config)
+ end
+
+ @testset "Zero replacement matches a direct zCompositions::cmultRepl call" begin
+ if !HAVE_ZCOMP
+ @test_skip false
+ else
+ x = [0 3 0 12 5; 7 0 1 0 9; 2 2 2 2 2; 0 6 4 30 1; 5 1 0 3 0]
+ out = replace_zeros(x; frac=0.65)
+ direct = rcopy(R"""
+ as.matrix(zCompositions::cmultRepl($(Float64.(x)), label = 0, method = "GBM",
+ output = "prop", frac = 0.65, z.warning = 1, z.delete = FALSE,
+ suppress.print = TRUE))
+ """)
+ @test out.proportions ≈ direct rtol=1e-12
+ @test out.zeros_replaced == count(==(0), x)
+ @test all(>(0), out.proportions)
+ @test vec(sum(out.proportions; dims=2)) ≈ ones(5)
+ # The ratios among a sample's observed taxa are kept.
+ @test out.proportions[1, 2] / out.proportions[1, 4] ≈ 3 / 12
+ # A sample without zeros is its closure; it has no imputed mass.
+ @test out.proportions[3, :] ≈ fill(0.2, 5)
+ @test out.imputed_mass[3] == 0.0
+ @test out.imputed_mass[1] ≈ out.proportions[1, 1] + out.proportions[1, 3]
+ # No zero at all: cmultRepl is not called, the closure is returned.
+ @test replace_zeros([1 3; 2 2]).proportions ≈ [0.25 0.75; 0.5 0.5]
+
+ msg = try replace_zeros([0 3 4; 1 0 5; 0 2 6]; taxa=["lone", "q", "r"]); "" catch e; e.msg end
+ @test occursin("'lone'", msg) && occursin("min_prevalence", msg)
+ msg = try replace_zeros([0 0; 3 4; 1 2]; samples=["empty", "b", "c"]); "" catch e; e.msg end
+ @test occursin("'empty'", msg)
+ @test_throws ArgumentError replace_zeros(reshape([1, 2, 3], 3, 1))
+ end
+ end
+
+ @testset "CLR: cmultRepl output is checked sample by sample" begin
+ # Pure Julia: no R is needed to check what cmultRepl hands back.
+ check = MetaManifold.Differential._check_compositions
+ mask = Bool[1 0 0; 0 0 0]
+ good = [0.1 0.3 0.6; 0.2 0.3 0.5]
+ @test check(good, mask, ["a", "b"]) === good
+ for (bad, why) in (([0.0 0.4 0.6; 0.2 0.3 0.5], "a replaced zero left at 0"),
+ ([NaN 0.4 0.6; 0.2 0.3 0.5], "a non-finite value"),
+ ([0.1 0.4 0.6; 0.2 0.3 0.5], "a row that does not sum to 1"))
+ msg = try check(bad, mask, ["first", "second"]); "" catch e; e.msg end
+ @test occursin("'first'", msg) && occursin("min_prevalence", msg)
+ end
+ # The failing sample is the one named, not always the first.
+ msg = try check([0.1 0.3 0.6; 0.2 0.3 0.6], mask, ["a", "b"]); "" catch e; e.msg end
+ @test occursin("'b'", msg) && occursin("0 replaced zeros", msg)
+ @test_throws ErrorException check(good[:, 1:2], mask, ["a", "b"])
+ end
+
+ @testset "CLR: min_prevalence is applied before zero replacement" begin
+ if !HAVE_ZCOMP
+ @test_skip false
+ else
+ # Six samples x five taxa: 'ghost' has no reads anywhere, 'rare' one
+ # read in one sample, 'r' is in four of six, 'p' and 'q' in all.
+ x = [10 20 2 0 0; 12 18 0 0 0; 11 19 3 3 0; 30 8 0 0 0; 28 9 4 0 0; 33 7 5 0 0]
+ s = ["s$i" for i in 1:6]
+ taxa = ["p", "q", "r", "rare", "ghost"]
+ g = ["A", "A", "A", "B", "B", "B"]
+ res = differential_abundance(x, s, taxa, g; reference="A", contrast="B",
+ config=DifferentialConfig("tss", 0.5; method="clr_welch"))
+ byt = Dict(r["taxon"] => r for r in res["rows"])
+ @test byt["ghost"]["status"] == "filtered"
+ @test occursin("not part of the composition", byt["ghost"]["note"])
+ @test byt["rare"]["status"] == "filtered"
+ @test occursin("min_prevalence", byt["rare"]["note"])
+ @test all(t -> byt[t]["status"] == "ok", ("p", "q", "r"))
+ zr = res["diagnostics"]["zero_replacement"]
+ @test zr["n_taxa_in_composition"] == 3 && zr["n_taxa_unobserved"] == 1
+ # Only r's two zeros are replaced; rare's five zeros are not.
+ @test zr["zeros_replaced"] == 2
+ @test 0 < zr["max_imputed_fraction"] < 1
+ @test zr["median_imputed_fraction"] == 0.0
+
+ # The fit saw the 3-taxon composition: 'rare' contributed nothing.
+ z = clr_transform(replace_zeros(x[:, 1:3]).proportions)
+ a = g .== "A"
+ @test byt["p"]["estimate"] ≈ sum(z[.!a, 1]) / 3 - sum(z[a, 1]) / 3 rtol=1e-8
+
+ # Without the threshold, 'rare' (one sample) reaches cmultRepl,
+ # whose prior is undefined for it; the refusal names the fix.
+ msg = try
+ differential_abundance(x, s, taxa, g; reference="A", contrast="B", config=clr_config)
+ ""
+ catch e
+ e.msg
+ end
+ @test occursin("'rare'", msg) && occursin("min_prevalence", msg)
+
+ @test_throws ArgumentError differential_abundance(x[:, [1, 5]], s, ["p", "ghost"], g;
+ reference="A", contrast="B", config=clr_config)
+ end
+ end
+
+ @testset "CLR: a sparse table with rare taxa runs once min_prevalence is set" begin
+ if !HAVE_ZCOMP
+ @test_skip false
+ else
+ # 24 samples (12 A, 12 B) x 211 taxa, about 90% zeros, built from a
+ # fixed formula: 6 common taxa in every sample ('c1' is four times
+ # as abundant in B), 4 moderate taxa in two thirds of the samples,
+ # one sparse taxon in 3 of 24 samples (87.5% zeros, which cmultRepl's
+ # default z.delete would drop), and 200 rare taxa in 1 or 2 samples.
+ n = 24
+ grp = vcat(fill("A", 12), fill("B", 12))
+ common = [100 + (37i + 11j) % 200 for i in 1:n, j in 1:6]
+ common[13:24, 1] .*= 4
+ moderate = [(i + j) % 3 == 0 ? 0 : 5 + (i * j) % 7 for i in 1:n, j in 1:4]
+ sparse = [i in (1, 9, 17) ? 4 : 0 for i in 1:n]
+ rare = zeros(Int, n, 200)
+ for k in 1:200
+ rare[(7k) % n + 1, k] = 1 + k % 3
+ iseven(k) && (rare[(13k) % n + 1, k] = 1 + k % 2)
+ end
+ counts = hcat(common, moderate, sparse, rare)
+ taxa = vcat(["c$j" for j in 1:6], ["m$j" for j in 1:4], ["sparse"], ["rare$k" for k in 1:200])
+ samples = ["s$i" for i in 1:n]
+ @test count(==(0), counts) / length(counts) > 0.85
+ @test all(k -> 1 <= count(>(0), rare[:, k]) <= 2, 1:200)
+
+ # Unfiltered, the rare taxa reach the replacement and are refused.
+ msg = try
+ differential_abundance(counts, samples, taxa, grp; reference="A", contrast="B",
+ config=clr_config)
+ ""
+ catch e
+ e.msg
+ end
+ @test occursin("min_prevalence", msg)
+
+ # min_prevalence = 0.1 keeps taxa seen in 3 or more of 24 samples.
+ res = differential_abundance(counts, samples, taxa, grp; reference="A", contrast="B",
+ config=DifferentialConfig("tss", 0.1; method="clr_welch"))
+ byt = Dict(r["taxon"] => r for r in res["rows"])
+ d = res["diagnostics"]
+ @test d["n_tested"] == 11 && d["n_filtered"] == 200 && d["n_failed"] == 0
+ @test res["status"] == "ok"
+ @test all(k -> byt["rare$k"]["status"] == "filtered", 1:200)
+ # The sparse taxon is tested, not silently dropped by cmultRepl.
+ @test byt["sparse"]["status"] == "ok" && !isnothing(byt["sparse"]["pvalue"])
+ @test byt["c1"]["estimate"] > 0 && byt["c1"]["padj"] < 0.05
+ zr = d["zero_replacement"]
+ @test zr["n_taxa_in_composition"] == 11
+ @test zr["zeros_replaced"] == count(==(0), counts[:, 1:11])
+ @test isempty(zr["warnings"])
+
+ # What filtering bought: replacing over every taxon seen in 2 or
+ # more samples instead puts far more imputed mass in each sample.
+ wide = findall(j -> count(>(0), counts[:, j]) >= 2, axes(counts, 2))
+ unfiltered = replace_zeros(counts[:, wide])
+ med(v) = (s = sort(v); (s[n ÷ 2] + s[n ÷ 2 + 1]) / 2)
+ @test med(unfiltered.imputed_mass) > 5 * zr["median_imputed_fraction"]
+ @test maximum(unfiltered.imputed_mass) > zr["max_imputed_fraction"]
+ end
+ end
+
+ @testset "CLR: constant log-ratios are refused with their reason" begin
+ if !HAVE_ZCOMP
+ @test_skip false
+ else
+ # p and q in the same 1:1 ratio everywhere: both CLRs are constant
+ # (zero), so neither can be fitted and the whole test is refused.
+ e = try
+ differential_abundance([5 5; 7 7; 9 9; 4 4], ["a", "b", "c", "d"], ["p", "q"],
+ ["A", "A", "B", "B"]; reference="A", contrast="B", config=clr_config)
+ nothing
+ catch err
+ err
+ end
+ @test e isa ErrorException
+ @test occursin("no taxon could be fitted", e.msg)
+ @test occursin("constant", e.msg)
+ end
end
@testset "Route: two runs through the in-process router" begin
@@ -345,4 +641,83 @@ end
@test r.status == 404
end
end
+
+ @testset "Route: clr_welch selected by the study config" begin
+ _da_fixture() do
+ root = MetaManifold.Server.ServerState._root[]
+ write(joinpath(root, "data", "studyD", "pipeline.yml"),
+ "analysis:\n differential:\n method: clr_welch\n replacement_delta: 0.5\n")
+ r = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")],
+ "table" => "merged", "rank" => "Genus"))
+ body = JSON3.read(String(r.body))
+ if !HAVE_ZCOMP
+ HAVE_R ? (@test r.status == 503 && body.error == "r_unavailable") :
+ (@test_skip r.status == 200)
+ else
+ @test r.status == 200
+ @test body.effect.key == "estimate" && body.effect.label == "CLR difference"
+ @test body.config.method == "clr_welch" && body.config.replacement_delta == 0.5
+ @test isempty(body.size_factors)
+ @test body.diagnostics.zero_replacement.zeros_replaced == 0
+ @test body.diagnostics.zero_replacement.median_imputed_fraction == 0
+ byt = Dict(String(x.taxon) => x for x in body.rows)
+ @test byt["Up"].estimate > 0 && byt["Down"].estimate < 0
+ @test all(x -> x.log2_fold_change === nothing, body.rows)
+ @test occursin("CLR difference", body.figure.layout.xaxis.title)
+ end
+
+ write(joinpath(root, "data", "studyD", "pipeline.yml"),
+ "analysis:\n differential:\n method: clr_welch\n replacement_delta: 1.5\n")
+ r = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")],
+ "table" => "merged", "rank" => "Genus"))
+ @test r.status == 400
+ @test JSON3.read(String(r.body)).error == "invalid_config"
+ end
+ end
+
+ ## Without zCompositions, clr_welch refuses with ZCompositionsUnavailable and
+ # the route answers 503. CI installs the package into R's base library, where
+ # it cannot be hidden from .libPaths(), so the testset instead binds a
+ # `requireNamespace` in R's global environment that reports zCompositions
+ # missing and defers to base for every other package. reval evaluates there,
+ # so the production probe sees the package as absent on any machine.
+ @testset "clr_welch without zCompositions is a 503" begin
+ if !HAVE_R
+ @test_skip false # the clr_welch route test above covers this case
+ else
+ reval("""requireNamespace <- function(package, ...) {
+ if (identical(package, "zCompositions")) FALSE
+ else base::requireNamespace(package, ...)
+ }""")
+ try
+ @test !rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)"))
+ e = try
+ differential_abundance([5 6; 7 3; 9 4; 4 8], ["a", "b", "c", "d"], ["p", "q"],
+ ["A", "A", "B", "B"]; reference="A", contrast="B",
+ config=DifferentialConfig(; method="clr_welch"))
+ nothing
+ catch err
+ err
+ end
+ @test e isa ZCompositionsUnavailable
+ @test occursin("renv::restore()", sprint(showerror, e))
+
+ _da_fixture() do
+ root = MetaManifold.Server.ServerState._root[]
+ write(joinpath(root, "data", "studyD", "pipeline.yml"),
+ "analysis:\n differential:\n method: clr_welch\n")
+ r = _da_post("studyD", Dict("runs" => [Dict("run" => "runA"), Dict("run" => "runB")],
+ "table" => "merged", "rank" => "Genus"))
+ body = JSON3.read(String(r.body))
+ @test r.status == 503
+ @test body.error == "r_unavailable"
+ @test occursin("zCompositions", body.message)
+ end
+ finally
+ reval("rm(requireNamespace)")
+ end
+ @test !rcopy(Bool, reval("exists('requireNamespace', envir = globalenv(), inherits = FALSE)"))
+ @test rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)")) == HAVE_ZCOMP
+ end
+ end
end
diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl
index df2e7847..a3053b97 100644
--- a/test/unit/test_install_pins.jl
+++ b/test/unit/test_install_pins.jl
@@ -29,6 +29,15 @@ const EXPECTED_PLATFORMS = ["linux-x86_64", "linux-aarch64", "macos-x86_64", "ma
is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s)
+"""Return the first step of `job` whose `uses:` names `action`, or nothing."""
+function ci_step_using(job, action)
+ for step in get(job, "steps", [])
+ startswith(get(step, "uses", ""), action) && return step
+ end
+ return nothing
+end
+
+
@testset "install pins" begin
@test isfile(PINS_PATH)
pins = YAML.load_file(PINS_PATH)
@@ -176,16 +185,24 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s)
@testset "CI installs what is pinned" begin
ci = YAML.load_file(CI_PATH)
- matrix = ci["jobs"]["test"]["strategy"]["matrix"]
+ job = ci["jobs"]["test"]
# Julia is the one version CI cannot read from the pin file, because nothing can
# be read before Julia exists. It is therefore duplicated, and this is the test
# that makes the duplication safe.
- @test matrix["julia-version"] == [pins["runtimes"]["julia"]["version"]]
+ #
+ # Read from the `Set up Julia` step rather than from a matrix: the matrix was
+ # removed because GitHub appends a matrix combination to the posted check name
+ # (see "a required check name is a stable identifier" below). The step is located
+ # by its `uses:` rather than by index, so reordering the steps cannot make this
+ # assertion quietly vanish.
+ setup = ci_step_using(job, "julia-actions/setup-julia")
+ @test setup !== nothing
+ @test setup["with"]["version"] == pins["runtimes"]["julia"]["version"]
# A floating runner would carry the R apt pin, which names a 24.04 build, off to
# whatever the next LTS ships.
- @test matrix["os"] == ["ubuntu-24.04"]
+ @test job["runs-on"] == "ubuntu-24.04"
@test occursin("2404", pins["runtimes"]["r"]["apt_version"])
# Everything else CI installs must be read from the pin file at run time rather
@@ -235,4 +252,38 @@ is_sha256(s) = s isa AbstractString && occursin(r"^[0-9a-f]{64}$", s)
@test occursin("fetch_pinned \"$ref\"", runs)
end
end
+
+ @testset "a required check name is a stable identifier" begin
+ ci = YAML.load_file(CI_PATH)
+
+ # A branch ruleset matches a required status check by the DISPLAY NAME GitHub
+ # posts for the job. A renamed check does not fail -- it is simply absent, and a
+ # required check that never reports can never be satisfied, so every pull request
+ # deadlocks until an admin bypasses the rule. The bump that triggers it is a
+ # one-line edit to this very file's subject matter.
+ #
+ # TWO conditions are needed for GitHub to post the `name:` field verbatim, and
+ # the first without the second is the trap this file exists to close:
+ #
+ # 1. the name must not interpolate a pin; and
+ # 2. the job must not have a `strategy.matrix`.
+ #
+ # MEASURED 2026-09-21 on hyperpolymath/MetaManifold-WebUI#39: a job named exactly `Julia tests` with a 1x1
+ # matrix posted `Julia tests (1.12.5, ubuntu-24.04)`. GitHub appends the matrix
+ # combination whenever the name does not already reference the matrix, so a 1x1
+ # matrix is still a matrix and the version was still embedded. A guard asserting
+ # only (1) PASSED on that broken fix, because it asked about the YAML field while
+ # the ruleset reads the rendered check name.
+ #
+ # Asserted over EVERY job rather than the one job we happen to require today:
+ # the rule is "a job name is a stable identifier", and a rule enforced at each
+ # door in turn is a rule that a new door escapes. If a matrix is ever genuinely
+ # needed, the required context must move to a matrix-free aggregator job and this
+ # assertion be re-scoped to that job -- not deleted.
+ for (id, job) in ci["jobs"]
+ name = get(job, "name", id)
+ @test !occursin("\${{", name)
+ @test !haskey(get(job, "strategy", Dict{String,Any}()), "matrix")
+ end
+ end
end
diff --git a/test/unit/test_permanova.jl b/test/unit/test_permanova.jl
new file mode 100644
index 00000000..06ff287e
--- /dev/null
+++ b/test/unit/test_permanova.jl
@@ -0,0 +1,103 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# PERMANOVA: each covariate is tested as its own sequential term, checked against
+# a direct vegan::adonis2 call; the permutation count and p-value floor are
+# reported; and a term within-block permutation cannot test is refused.
+# Skips loudly when R/vegan is absent.
+
+using MetaManifold
+using MetaManifold.Analysis
+using RCall
+using DataFrames
+using Random
+using Test
+
+const _PERM_R = MetaManifold.Server.r_available()
+_PERM_R || @info "R/vegan unavailable - the PERMANOVA reference tests are SKIPPED"
+
+"""
+ _adonis2_reference(mat, meta, perm_expr) -> DataFrame
+
+Run vegan::adonis2 directly on Bray-Curtis distances of `mat`, with
+`group + run` terms tested sequentially and permutations given by the R
+expression `perm_expr` (which may refer to `ref_meta`), seeded as
+`run_permanova` seeds it. Returns the term rows: term, r2, f, p.
+"""
+function _adonis2_reference(mat, meta, perm_expr)
+ MetaManifold.RRuntime.with_r_lock() do
+ RCall.globalEnv[:ref_mat] = mat
+ RCall.globalEnv[:ref_meta] = meta
+ RCall.reval("""
+ set.seed(123, kind = "Mersenne-Twister", normal.kind = "Inversion", sample.kind = "Rejection")
+ ref_d <- vegdist(ref_mat, method = "bray")
+ ref_res <- adonis2(ref_d ~ group + run, data = ref_meta,
+ permutations = $perm_expr, by = "terms", parallel = 1)
+ ref_rows <- which(!(rownames(ref_res) %in% c("Residual", "Total")))
+ ref_out <- data.frame(term = rownames(ref_res)[ref_rows], r2 = ref_res\$R2[ref_rows],
+ f = ref_res\$F[ref_rows], p = ref_res[["Pr(>F)"]][ref_rows])
+ """)
+ out = rcopy(RCall.reval("ref_out"))
+ RCall.reval("rm(ref_mat, ref_meta, ref_d, ref_res, ref_rows, ref_out)")
+ out
+ end
+end
+
+@testset "PERMANOVA" begin
+ @testset "_varies_within_any_block" begin
+ blocks = ["i1", "i1", "i2", "i2"]
+ @test !Analysis._varies_within_any_block(["a", "a", "b", "b"], blocks)
+ @test Analysis._varies_within_any_block(["r1", "r2", "r1", "r1"], blocks)
+ end
+
+ if _PERM_R
+ rng = MersenneTwister(7)
+ # Six individuals, each sampled in two runs; three individuals per group.
+ mat = Float64.(rand(rng, 0:60, 12, 40))
+ individuals = ["i$(cld(k, 2))" for k in 1:12]
+ meta = DataFrame(sample = ["s$k" for k in 1:12],
+ group = [cld(k, 2) <= 3 ? "A" : "B" for k in 1:12],
+ run = repeat(["r1", "r2"], 6))
+
+ @testset "each term is tested on its own, as vegan by = \"terms\"" begin
+ res = Analysis.run_permanova(mat, meta; seed=123)
+ ref = _adonis2_reference(mat, meta, "999")
+ @test [t.term for t in res.terms] == ["group", "run"] == ref.term
+ @test [t.r2 for t in res.terms] ≈ ref.r2
+ @test [t.f_statistic for t in res.terms] ≈ ref.f
+ @test [t.p_value for t in res.terms] == ref.p
+ # The headline result is the group term, not the whole model.
+ @test res.term == "group"
+ @test res.r2 ≈ ref.r2[1] && res.p_value == ref.p[1]
+ @test res.permutations == 999
+ @test res.min_p_value ≈ 1 / 1000
+ @test all(t -> isnothing(t.untestable_reason), res.terms)
+ end
+
+ @testset "within-individual blocks: permutation count, floor and refusal" begin
+ res = Analysis.run_permanova(mat, meta; seed=123, blocks=individuals)
+ ref = _adonis2_reference(mat, meta,
+ "permute::how(blocks = factor(rep(paste0('i', 1:6), each = 2)), nperm = 999)")
+ @test res.blocked
+ # Six blocks of two admit 2^6 orderings, the observed one excluded.
+ @test res.permutations == 63
+ @test res.min_p_value ≈ 1 / 64
+ # group is constant within every individual, so within-block
+ # permutation never exchanges its labels. vegan still prints an
+ # ordinary-looking probability for it (the control: that is the
+ # number withheld here), but it does not test a group difference.
+ @test 0 < ref.p[1] <= 1
+ group = res.terms[1]
+ @test group.term == "group" && isnothing(group.p_value)
+ @test occursin("constant within every permutation block", group.untestable_reason)
+ @test isnothing(res.p_value) && res.untestable_reason == group.untestable_reason
+ @test group.r2 ≈ ref.r2[1]
+ @test occursin("withheld for group", res.text)
+ # run varies within individuals and is tested as vegan tests it.
+ run = res.terms[2]
+ @test run.term == "run" && isnothing(run.untestable_reason)
+ @test run.p_value == ref.p[2]
+ @test run.f_statistic ≈ ref.f[2]
+ end
+ end
+end
diff --git a/test/unit/test_r_runtime.jl b/test/unit/test_r_runtime.jl
index 936a7ac4..59c930b1 100644
--- a/test/unit/test_r_runtime.jl
+++ b/test/unit/test_r_runtime.jl
@@ -94,4 +94,104 @@ const RR = MetaManifold.RRuntime
@test fetch(holder) == :done
end
+ ## A test that never ran must not compare equal to one that ran and found
+ # nothing. Both used to be `(nothing, 0-row DataFrame)`, so this assertion
+ # could not be written at all.
+ @testset "a test that did not run is not a null result" begin
+ AN = MetaManifold.Analysis
+ not_run = AN._not_computed(:r_unavailable, "R/vegan is not available")
+ @test !AN.was_computed(not_run)
+ @test isnothing(not_run.omnibus)
+ @test nrow(not_run.pairwise) == 0
+ @test !isempty(not_run.reason)
+
+ genuine_null = AN._computed(0.87, AN._empty_pairwise())
+ @test AN.was_computed(genuine_null)
+ @test not_run.status !== genuine_null.status
+ @test not_run != genuine_null
+
+ # R's own tryCatch yields NA for a degenerate group: a failed test keeps
+ # the pairwise rows it got but never claims an omnibus result.
+ failed = AN._not_computed(:test_failed, "the omnibus statistic could not be computed";
+ pairwise=AN._empty_pairwise())
+ @test !AN.was_computed(failed)
+ @test isnothing(failed.omnibus)
+ end
+
+ ## The surface must say the test was not run.
+ # Drawing nothing is how "no pair reached significance" looks. A run that was
+ # never performed must therefore add something to the chart, not stay silent
+ # and borrow that appearance.
+ @testset "a chart states that pairwise tests were not run" begin
+ AN = MetaManifold.Analysis
+ groups = ["a", "b"]
+ vals = Dict("a" => [1.0, 2.0, 3.0], "b" => [4.0, 5.0, 6.0])
+
+ not_run = AN._not_computed(:r_unavailable, "R/vegan is not available")
+ layout_not_run = Dict{String,Any}()
+ AN._add_pairwise_annotations!(layout_not_run, "x", "y", "yaxis",
+ groups, vals, not_run)
+ notices = [a for a in get(layout_not_run, "annotations", Any[])
+ if occursin("not run", String(a["text"]))]
+ @test length(notices) == 1
+ @test occursin("R/vegan", String(notices[1]["text"]))
+
+ ## The positive control that makes the assertion above mean something.
+ # A genuine result in which no pair reached significance must NOT gain the
+ # notice, or the notice would be noise rather than a signal.
+ genuine_null = AN._computed(0.87, AN._empty_pairwise())
+ layout_null = Dict{String,Any}()
+ AN._add_pairwise_annotations!(layout_null, "x", "y", "yaxis",
+ groups, vals, genuine_null)
+ @test isempty([a for a in get(layout_null, "annotations", Any[])
+ if occursin("not run", String(a["text"]))])
+ end
+
+ ## Each reason gets its own wording, because they call for different actions:
+ # an absent runtime is a deployment fault, unpairable groups are a property
+ # of the data, and a failed statistic is a property of these groups.
+ @testset "the caption distinguishes the reasons a test did not run" begin
+ AN = MetaManifold.Analysis
+ failed = AN._not_computed(:test_failed, "the omnibus statistic could not be computed")
+ absent = AN._not_computed(:r_unavailable, "R/vegan is not available")
+ unpaired = AN._not_computed(:no_paired_samples, "the groups share no sample IDs")
+
+ for r in (failed, absent, unpaired)
+ @test occursin("not run", AN._significance_caption(r, "KW"))
+ @test !occursin("n/a", AN._significance_caption(r, "KW"))
+ end
+ @test AN._significance_caption(failed, "KW") != AN._significance_caption(absent, "KW")
+ @test AN._significance_caption(absent, "KW") != AN._significance_caption(unpaired, "KW")
+
+ # A computed result still reads as a result.
+ computed = AN._computed(0.02, AN._empty_pairwise())
+ caption = AN._significance_caption(computed, "KW")
+ @test occursin("p = 0.02", caption)
+ @test !occursin("not run", caption)
+ end
+
+ ## End to end through R: kruskal.test errors when every sample is in one
+ # group, R's tryCatch turns that into NA, and the result must be a failed
+ # test rather than a p-value or a null result.
+ @testset "an omnibus statistic R cannot compute is a failed test" begin
+ AN = MetaManifold.Analysis
+ if !AN._ensure_r()
+ @warn "Skipping: R/vegan is not available"
+ @test_skip false
+ else
+ one_group = AN._alpha_significance([1.0, 2.0, 3.0, 4.0], ["a", "a", "a", "a"],
+ ["s1", "s2", "s3", "s4"])
+ @test !AN.was_computed(one_group)
+ @test one_group.status === :test_failed
+ @test isnothing(one_group.omnibus)
+
+ # Positive control: two separable groups give a computed p-value.
+ two_groups = AN._alpha_significance([1.0, 2.0, 3.0, 11.0, 12.0, 13.0],
+ ["a", "a", "a", "b", "b", "b"],
+ ["s1", "s2", "s3", "s4", "s5", "s6"])
+ @test AN.was_computed(two_groups)
+ @test 0 < two_groups.omnibus < 1
+ end
+ end
+
end
diff --git a/test/unit/test_routes.jl b/test/unit/test_routes.jl
index a240e470..62998944 100644
--- a/test/unit/test_routes.jl
+++ b/test/unit/test_routes.jl
@@ -522,6 +522,14 @@ _saved_root = SV.ServerState._root[]
@test rej.status == 400
@test read(SV._primers_path(), String) == before
+ # An unreadable file is a 400 naming the file, logged as a warning: the
+ # operator can repair it, so it is not reported as a server fault.
+ write(SV._primers_path(), "Forward: [unterminated\n")
+ unreadable = @test_logs (:warn, "primers.yml is not readable") SV._read_primers()
+ @test unreadable.status == 400
+ @test read(SV._primers_path(), String) == "Forward: [unterminated\n"
+ write(SV._primers_path(), before)
+
# A body that is not a JSON object is rejected with an actionable 400
# rather than throwing out of the route as a bare 500. This route backs
# a Save button, where a malformed request is a plausible client bug.
@@ -788,7 +796,7 @@ _saved_root = SV.ServerState._root[]
# file be overwritten from an empty editor.
before = read(dbpath, String)
write(dbpath, "databases: [unterminated\n")
- @test SV._read_databases().status == 400
+ @test (@test_logs (:warn, "databases.yml is not readable") SV._read_databases()).status == 400
@test read(dbpath, String) == "databases: [unterminated\n"
write(dbpath, before)
diff --git a/test/unit/test_write_gates.jl b/test/unit/test_write_gates.jl
new file mode 100644
index 00000000..60a4683e
--- /dev/null
+++ b/test/unit/test_write_gates.jl
@@ -0,0 +1,138 @@
+# Unit tests for the write gates that keep request values out of file paths:
+# config values used as file-name parts or remote command words, and the
+# pipeline tables the results routes may not delete. Requests run in-process
+# through Oxygen's router.
+using MetaManifold
+SV = MetaManifold.Server
+using JSON3, YAML, DuckDB, DBInterface
+
+_wg_request(method, path, body="") =
+ SV.Oxygen.internalrequest(SV.HTTP.Request(method, path, ["Content-Type" => "application/json"], body))
+
+@testset "Config write gate" begin
+ root = mktempdir()
+ old_root = SV.ServerState._root[]
+ SV.ServerState.set_root!(root)
+ mkpath(joinpath(root, "data"))
+ mkpath(joinpath(root, "config"))
+ user_cfg = joinpath(root, "config", "pipeline.yml")
+ write(user_cfg, "vsearch:\n identity: 0.97\n")
+ try
+ bad = [
+ "dada2.output.fasta_prefix" => "../../tmp/pwn",
+ "dada2.output.fasta_prefix" => "a/b",
+ "dada2.output.fasta_prefix" => "",
+ "dada2.output.seq_table_prefix" => "../seqtab",
+ "dada2.output.taxa_prefix" => "/tmp/taxonomy",
+ "dada2.output.taxa_prefix" => "tax onomy",
+ "cutadapt.r1_suffix" => "_R1\\E.*",
+ "cutadapt.r2_suffix" => "\\Q_R2",
+ "cutadapt.r2_suffix" => "_R2/x",
+ "remote.staging_dir" => "/srv/mm; rm -rf ~",
+ "remote.staging_dir" => "relative/dir",
+ "remote.staging_dir" => "/srv/m m",
+ "remote.stages" => ["denoise", "run_anything"],
+ "remote.threads" => 0,
+ "remote.threads" => "4",
+ "remote.tools.mafft" => "mafft;id",
+ "remote.tools.iqtree" => nothing,
+ ]
+ before = read(user_cfg, String)
+ for (k, v) in bad
+ r = _wg_request("PATCH", "/api/v1/config", JSON3.write(Dict(k => v)))
+ @test r.status == 400
+ @test read(user_cfg, String) == before
+ end
+
+ good = [
+ "dada2.output.fasta_prefix" => "asvs_v2",
+ "dada2.output.seq_table_prefix" => "seqtab.nochim",
+ "dada2.output.taxa_prefix" => "taxonomy-silva",
+ "cutadapt.r1_suffix" => "_1",
+ "cutadapt.r2_suffix" => "_2",
+ "remote.host" => "user@server",
+ "remote.staging_dir" => "/scratch/metamanifold",
+ "remote.stages" => ["denoise", "phylogeny_tree"],
+ "remote.threads" => 16,
+ "remote.tools.mafft" => "/opt/mafft/bin/mafft",
+ ]
+ for (k, v) in good
+ r = _wg_request("PATCH", "/api/v1/config", JSON3.write(Dict(k => v)))
+ @test r.status == 200
+ end
+ saved = YAML.load_file(user_cfg)
+ @test saved["dada2"]["output"]["fasta_prefix"] == "asvs_v2"
+ @test saved["cutadapt"]["r2_suffix"] == "_2"
+ @test saved["remote"]["staging_dir"] == "/scratch/metamanifold"
+ @test saved["remote"]["stages"] == ["denoise", "phylogeny_tree"]
+ @test saved["remote"]["threads"] == 16
+ @test saved["remote"]["tools"]["mafft"] == "/opt/mafft/bin/mafft"
+ @test saved["vsearch"]["identity"] == 0.97
+
+ # The gate's remote checks are validation's own: what it stored passes them.
+ errs = SV.Validation.ValidationError[]
+ SV.Validation._validate_remote!(errs, saved["remote"], "test")
+ @test isempty(errs)
+
+ # Naming a host without a staging_dir, or with one that is not a string,
+ # leaves the remote stages nowhere to stage, and validation says so.
+ for remote in (Dict{String,Any}("host" => "user@server"),
+ Dict{String,Any}("host" => "user@server", "staging_dir" => 42))
+ errs = SV.Validation.ValidationError[]
+ SV.Validation._validate_remote!(errs, remote, "test")
+ @test any(e -> occursin("remote.staging_dir must be set", e.message), errs)
+ end
+ finally
+ SV.ServerState._root[] = old_root
+ rm(root; recursive=true, force=true)
+ end
+end
+
+@testset "Pipeline tables cannot be deleted" begin
+ root = mktempdir()
+ old_root = SV.ServerState._root[]
+ SV.ServerState.set_root!(root)
+ data_run = joinpath(root, "data", "studyW", "runA")
+ mkpath(data_run)
+ touch(joinpath(data_run, "s1_R1.fastq.gz"))
+ merge_dir = joinpath(root, "projects", "studyW", "runA", "merged")
+ mkpath(merge_dir)
+ db_path = joinpath(merge_dir, "results.duckdb")
+ db = DuckDB.DB(db_path)
+ con = DBInterface.connect(db)
+ for t in (SV._PIPELINE_TABLES..., "my_view")
+ DBInterface.execute(con, "CREATE TABLE \"$t\" (\"SeqName\" VARCHAR, \"s1\" BIGINT)")
+ DBInterface.execute(con, "INSERT INTO \"$t\" VALUES ('seq1', 3)")
+ write(joinpath(merge_dir, "$t.csv"), "SeqName,s1\nseq1,3\n")
+ end
+ DBInterface.close!(con); close(db)
+ tables() = let db = DuckDB.DB(db_path), con = DBInterface.connect(db)
+ try
+ Set(string.(DBInterface.execute(con, "SHOW TABLES") |> DataFrame).name)
+ finally
+ DBInterface.close!(con); close(db)
+ end
+ end
+ try
+ @test Set(SV._PIPELINE_TABLES) == Set(["merged", "merged_otu", "merged_cdhit", "cluster_membership"])
+ for t in SV._PIPELINE_TABLES
+ r = _wg_request("DELETE", "/api/v1/studies/studyW/runs/runA/results/tables/$t")
+ @test r.status == 400
+ @test JSON3.read(String(r.body)).error == "reserved_name"
+ @test isfile(joinpath(merge_dir, "$t.csv"))
+ @test t in tables()
+ # The save route refuses the same names.
+ r = _wg_request("POST", "/api/v1/studies/studyW/runs/runA/results/tables/my_view/save",
+ JSON3.write(Dict("name" => t)))
+ @test r.status == 400
+ end
+ # A user's own table still deletes.
+ r = _wg_request("DELETE", "/api/v1/studies/studyW/runs/runA/results/tables/my_view")
+ @test r.status == 200
+ @test !isfile(joinpath(merge_dir, "my_view.csv"))
+ @test !("my_view" in tables())
+ finally
+ SV.ServerState._root[] = old_root
+ rm(root; recursive=true, force=true)
+ end
+end
From 659985453855c3eda7d60a99985a726bda04dee7 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 10:34:26 +0100
Subject: [PATCH 05/45] ci: install Julia and Bun from their pins so ci.yml can
start on this fork (#110)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Stacked on #109 (the upstream sync); review that first. This PR's own
diff is one commit.
## Summary
This account's Actions policy allows only GitHub's own actions
(`actions/*`) and its own, each pinned to a full commit SHA. `ci.yml`
used five actions outside that, so every run on this fork ended in
`startup_failure`, since at least 2026-10-02, and the suite never ran
here.
| Was | Now |
|---|---|
| `julia-actions/setup-julia` | Julia 1.12.5 from the
`tool_versions.yml` archive, fetched with `fetch_pinned` and refused
unless it hashes to the pinned SHA256 |
| `julia-actions/cache` | `actions/cache/restore` and
`actions/cache/save` over the depot's registries, packages, artifacts
and compiled caches, keyed on `Manifest.toml`. It is saved only after
`Pkg.instantiate()` succeeds. |
| `oven-sh/setup-bun` | Bun 1.3.10 from the `tool_versions.yml` archive,
checksummed the same way. The step then checks `bun --version` against
the pin. |
| `julia-actions/julia-processcoverage` | CoverageTools 1.4.2, the exact
version, writes `lcov.info` from `src/` |
| `codecov/codecov-action` | `actions/upload-artifact` keeps `lcov.info`
for 14 days. The Codecov project, its slug and its token are upstream's.
|
**Other changes:**
- `pull_request` runs for any base branch, so a stacked PR like this one
is tested.
- Both checkouts set `persist-credentials: false`.
- `occupancy.yml`'s header no longer says `ci.yml` cannot start.
The job names `Julia tests` and `Agda proofs` are unchanged, so the
required-check name is stable.
**`test/unit/test_install_pins.jl`:**
- Julia's URL and checksum are read from the job env and must equal the
pin file's `runtimes.julia.archives.linux-x86_64`. Its version must
already equal `Manifest.toml`'s.
- The Julia and Bun archives must go through `fetch_pinned` and
`sha256sum -c`.
- New: every `uses:` in `.github/workflows/*.yml` must be an `actions/*`
or `hyperpolymath/*` action pinned to 40 hex characters.
## Verification
- **`test_install_pins.jl`:** 222/222.
- **Negative controls,** each failing it:
1. `setup-julia` restored as a step;
2. one character of `JULIA_SHA256` changed;
3. `actions/checkout` pinned by tag.
- **Steps run locally** from this file's own YAML:
- **Pin export:** writes `BUN_URL` and `BUN_SHA256`.
- **Install Bun:** `bun.zip: OK`, then `bun --version` is 1.3.10.
- **Process coverage:** on a covered toy `src/`, it writes `DA:1,1` /
`DA:2,0`.
- **Install Julia:** identical to `occupancy.yml`'s, which has passed in
CI.
- **`zizmor` 1.30.1:** no findings on `ci.yml` or `occupancy.yml`.
Upstream's `ci.yml` has 2 medium findings ("artipacked": credentials
left in the checkout), which `persist-credentials: false` removes.
- **The real test is this PR's own `CI` run.** It is the first run of
`ci.yml` on this fork, so it starts with an empty depot cache, and the
renv restore builds R packages from source.
## Cost
This diverges `ci.yml` from upstream's for as long as the account policy
stands. A future sync will conflict wherever upstream edits the same
lines. The alternative is a settings change: allow those five actions in
Settings → Actions → General.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015pMjy981ie74uCttyYDanC
---
_Generated by [Claude
Code](https://claude.ai/code/session_015pMjy981ie74uCttyYDanC)_
Co-authored-by: x
Co-authored-by: Claude Opus 5.5
---
.github/workflows/ci.yml | 113 +++++++++++++++++++++++++-------
.github/workflows/occupancy.yml | 6 +-
test/unit/test_install_pins.jl | 59 +++++++++++------
3 files changed, 132 insertions(+), 46 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 10cf5483..cddd00c3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,20 @@
name: CI
+# This fork's account allows only GitHub's own actions (actions/*) and its own,
+# each pinned to a full commit SHA. Upstream's julia-actions, oven-sh/setup-bun
+# and codecov/codecov-action are none of these, and with them this workflow
+# ended in startup_failure on every run. So Julia and Bun are installed here
+# from the archives config/defaults/tool_versions.yml pins, each refused unless
+# it hashes to the pinned SHA256, and the Julia depot is cached with
+# actions/cache. Coverage is kept as a workflow artifact, since the Codecov
+# project is upstream's. test/unit/test_install_pins.jl asserts that every
+# `uses:` in .github/workflows is allowed by that policy.
+
on:
push:
branches: [main]
+ # Any base branch, so a PR stacked on another one is tested too.
pull_request:
- branches: [main]
permissions:
contents: read
@@ -53,22 +63,45 @@ jobs:
# the environment, so those commands pass --no-init-file instead.
env:
RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE"
+ # Pinned rather than tracking latest stable, because a pinned Manifest.toml
+ # resolved by an unpinned Julia is not a reproducible build. These must equal
+ # runtimes.julia.archives.linux-x86_64 in the pin file, whose version must
+ # equal julia_version in Manifest.toml; they cannot be read from the pin
+ # file, since nothing can be read before Julia exists.
+ # test/unit/test_install_pins.jl fails if any of them disagree.
+ JULIA_URL: https://julialang-s3.julialang.org/bin/linux/x64/1.12/julia-1.12.5-linux-x86_64.tar.gz
+ JULIA_SHA256: 41b84d727e4e96fbf3ed9e92fa195d773d247b9097f73fad688f8b699758bae7
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
-
- - name: Set up Julia
- uses: julia-actions/setup-julia@4c0cb0fce8556fdb04a90347310e5db8b1f98fb9 # v2.7.0
with:
- # 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"
+ persist-credentials: false # nothing here pushes
- - name: Cache Julia packages
- uses: julia-actions/cache@d10a6fd8f31b12404a54613ebad242900567f2b9 # v2.1.0
+ - name: Install Julia
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$JULIA_URL" julia.tar.gz
+ echo "$JULIA_SHA256 julia.tar.gz" | sha256sum -c -
+ sudo mkdir -p /opt/julia
+ sudo tar xzf julia.tar.gz -C /opt/julia --strip-components=1
+ rm julia.tar.gz
+ echo /opt/julia/bin >> "$GITHUB_PATH"
+
+ # The registry, packages, artifacts and precompiled caches, keyed on the
+ # Manifest (which also names the Julia version). Saved once instantiation
+ # has succeeded, so a failed run cannot store a half-built depot.
+ - name: Restore the Julia depot
+ id: julia-depot
+ uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ with:
+ path: |
+ ~/.julia/registries
+ ~/.julia/packages
+ ~/.julia/artifacts
+ ~/.julia/compiled
+ key: julia-depot-${{ runner.os }}-${{ hashFiles('Manifest.toml') }}
+ restore-keys: |
+ julia-depot-${{ runner.os }}-
# Every external version CI installs is read from the committed pin file, so CI
# and a developer's machine cannot drift apart. A temporary environment is used
@@ -84,7 +117,10 @@ jobs:
pins = YAML.load_file("config/defaults/tool_versions.yml")
open(ENV["GITHUB_ENV"], "a") do io
println(io, "R_APT_VERSION=", pins["runtimes"]["r"]["apt_version"])
- println(io, "BUN_VERSION=", pins["toolchain"]["bun"]["version"])
+ bun = pins["toolchain"]["bun"]
+ println(io, "BUN_VERSION=", bun["version"])
+ println(io, "BUN_URL=", bun["archives"]["linux-x86_64"]["url"])
+ println(io, "BUN_SHA256=", bun["archives"]["linux-x86_64"]["sha256"])
println(io, "CUTADAPT_SPEC=cutadapt==", pins["tools"]["cutadapt"]["version"])
println(io, "MULTIQC_SPEC=multiqc==", pins["tools"]["multiqc"]["version"])
fastqc = pins["tools"]["fastqc"]["archives"]["any"]
@@ -221,10 +257,28 @@ jobs:
- name: Instantiate Julia project
run: julia --project=. -e 'import Pkg; Pkg.instantiate()'
- - name: Set up Bun
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
+ - name: Save the Julia depot
+ if: steps.julia-depot.outputs.cache-hit != 'true'
+ uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
- bun-version: ${{ env.BUN_VERSION }}
+ path: |
+ ~/.julia/registries
+ ~/.julia/packages
+ ~/.julia/artifacts
+ ~/.julia/compiled
+ key: julia-depot-${{ runner.os }}-${{ hashFiles('Manifest.toml') }}
+
+ # Downloaded against the pinned URL and refused unless it hashes to the pinned
+ # SHA256, as install.jl does when no bun of this version is on PATH.
+ - name: Install Bun
+ run: |
+ source scripts/ci/fetch_pinned.sh
+ fetch_pinned "$BUN_URL" bun.zip
+ echo "$BUN_SHA256 bun.zip" | sha256sum -c -
+ unzip -q bun.zip
+ sudo mv bun-linux-x64/bun /usr/local/bin/bun
+ rm -rf bun.zip bun-linux-x64
+ test "$(bun --version)" = "$BUN_VERSION"
- name: Build frontend
working-directory: frontend
@@ -289,16 +343,25 @@ jobs:
fi
exit "$status"
+ # What julia-processcoverage did: the .cov files under src/ to lcov.info, with
+ # CoverageTools pinned to an exact release.
- name: Process coverage
- uses: julia-actions/julia-processcoverage@03114f09f119417c3242a9fb6e0b722676aedf38 # v1.2.2
-
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6.0.2
+ run: |
+ julia -e '
+ using Pkg
+ Pkg.activate(temp = true)
+ Pkg.add(name = "CoverageTools", version = "1.4.2")
+ using CoverageTools
+ LCOV.writefile("lcov.info", process_folder("src"))'
+
+ # The Codecov project (codecov.yml, the README badge) is upstream's, so the fork
+ # keeps its coverage as an artifact of the run instead.
+ - name: Keep coverage
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
- files: lcov.info
- token: ${{ secrets.CODECOV_TOKEN }}
- slug: JoshuaJewell/MetaManifold-WebUI
- fail_ci_if_error: false
+ name: lcov
+ path: lcov.info
+ retention-days: 14
# Machine-checked proofs of the Benjamini-Hochberg adjustment and the TSS/RLE
# scaling algebra (proofs/agda, proofs/agda/README.md).
@@ -318,6 +381,8 @@ jobs:
DEBIAN_FRONTEND: noninteractive
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ persist-credentials: false # nothing here pushes
- name: Install Agda and the standard library
run: |
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
index 92433886..4fc5ec49 100644
--- a/.github/workflows/occupancy.yml
+++ b/.github/workflows/occupancy.yml
@@ -8,9 +8,9 @@
# the pinned proof siblings (scripts/check-occupancy.sh).
#
# This account allows only GitHub's own actions and its own, pinned by SHA, so
-# ci.yml (julia-actions, setup-bun, codecov) cannot start on this fork. That is
-# why everything here installs from pinned URLs checked against pinned SHA256s,
-# and why the disagreement test runs here as well as in ci.yml's full suite.
+# everything here installs from pinned URLs checked against pinned SHA256s, as
+# ci.yml now does too. The disagreement test also runs in ci.yml's full suite;
+# it runs here as well so that a change to the probe gets its own check.
name: Occupancy reading
on:
diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl
index a3053b97..177e8f8a 100644
--- a/test/unit/test_install_pins.jl
+++ b/test/unit/test_install_pins.jl
@@ -16,6 +16,7 @@ const REPO_ROOT = normpath(joinpath(@__DIR__, "..", ".."))
const PINS_PATH = joinpath(REPO_ROOT, "config", "defaults", "tool_versions.yml")
const INSTALL_JL = joinpath(REPO_ROOT, "install.jl")
const CI_PATH = joinpath(REPO_ROOT, ".github", "workflows", "ci.yml")
+const WORKFLOWS = joinpath(REPO_ROOT, ".github", "workflows")
const MANIFEST = joinpath(REPO_ROOT, "Manifest.toml")
const RENV_LOCK = joinpath(REPO_ROOT, "renv.lock")
@@ -29,13 +30,10 @@ 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
+# This fork's account allows only GitHub's own actions and its own, each pinned to a
+# full commit SHA.
+allowed_action(uses) =
+ occursin(r"^(actions|hyperpolymath)/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$", uses)
@testset "install pins" begin
@@ -187,18 +185,18 @@ end
ci = YAML.load_file(CI_PATH)
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.
+ # Julia is the one runtime CI cannot read from the pin file, because nothing can
+ # be read before Julia exists. Its archive URL and checksum are therefore
+ # duplicated in the job's env, and this is the test that makes the duplication
+ # safe. The pinned URL names the version, which must equal Manifest.toml's
+ # (tested above).
#
- # 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"]
+ # Read from the job's env 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).
+ archive = pins["runtimes"]["julia"]["archives"]["linux-x86_64"]
+ @test job["env"]["JULIA_URL"] == archive["url"]
+ @test job["env"]["JULIA_SHA256"] == archive["sha256"]
# A floating runner would carry the R apt pin, which names a 24.04 build, off to
# whatever the next LTS ships.
@@ -248,9 +246,32 @@ end
@test !occursin("set +e", fetch_code)
@test occursin("source scripts/ci/fetch_pinned.sh", runs)
- for ref in (raw"$VSEARCH_URL", raw"$SWARM_URL", raw"$FASTQC_URL")
+ for ref in (raw"$VSEARCH_URL", raw"$SWARM_URL", raw"$FASTQC_URL",
+ raw"$JULIA_URL", raw"$BUN_URL")
@test occursin("fetch_pinned \"$ref\"", runs)
end
+
+ # Julia and Bun, which actions used to install, are refused unless they hash
+ # to their pins.
+ for (sha, file) in ((raw"$JULIA_SHA256", "julia.tar.gz"), (raw"$BUN_SHA256", "bun.zip"))
+ @test occursin("echo \"$sha $file\" | sha256sum -c -", runs)
+ end
+ end
+
+ @testset "every action is one this account allows" begin
+ # Anything else ends the whole workflow in startup_failure before any job runs,
+ # so the run names no failing step. That is how ci.yml failed on every run on
+ # this fork, from at least 2026-10-02 until it stopped using julia-actions,
+ # oven-sh/setup-bun and codecov/codecov-action.
+ files = filter(f -> endswith(f, ".yml") || endswith(f, ".yaml"), readdir(WORKFLOWS))
+ @test "ci.yml" in files
+ @testset "$file" for file in files
+ for (_, job) in YAML.load_file(joinpath(WORKFLOWS, file))["jobs"],
+ step in get(job, "steps", [])
+ uses = get(step, "uses", nothing)
+ uses === nothing || @test allowed_action(uses)
+ end
+ end
end
@testset "a required check name is a stable identifier" begin
From a0880df63c01dde6091f7fdb39bbc7a352f02778 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 11:18:47 +0100
Subject: [PATCH 06/45] refactor(pipeline): run the vsearch stage's two
searches through one tested helper (#112)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
This is the fork's copy of the commit that answers Codecov's patch
report on the upstream PR of the probe (branch `feat/occupancy-probe`,
now `4b32184`). Both files end up byte-identical to that branch's, so
the fork and upstream stay in step.
## Summary
Codecov reported the upstream PR's patch coverage at 94.70%:
- **`src/server/routes/pipeline.jl`:** 7 lines missed, 36.36% patch
coverage. They are the vsearch searches wrapped in `MemoryProbe.task` in
`_run_full_pipeline` and `_run_stage`. No test runs either function, so
these lines, like the vsearch calls they replaced, had never been
executed.
- **`src/core/memory_probe.jl`:** 1 line missed, the `watch!(_)`
fallback.
**The change:**
- **`_vsearch_searches(asv, otu; concurrent)`** holds the stage's two
shapes. Concurrently, as the full pipeline runs them, both searches are
waited for before a failure is rethrown. One after the other is how a
single-stage re-run does it.
- **`_vsearch_stage(search, project, asvs, otus, db; concurrent)`**
builds the two calls, so each call site is now one line.
- **`test/occupancy_disagreement_test.jl`** runs its ASV-then-OTU and
concurrent orders through `_vsearch_searches`, so it measures the
pipeline's own code rather than a copy. It also gains tests for:
- both shapes' return values and task labels;
- `_vsearch_stage` with a stub search;
- the rethrow-after-both guarantee;
- the `watch!` fallback.
**One behaviour change.** A single-stage vsearch re-run used to return
only the OTU search's result, or `nothing` when there was no OTU search.
Its provenance record therefore left out the ASV taxonomy file. It now
returns both results, as the full pipeline does.
## Verification
- **Occupancy test:** 60/60, up from 46.
- **Full unit suite** (Julia 1.12.5, vsearch 2.30.5, `-t 2`): 2422
passed, 0 failed, 20 broken (tests the suite marks as known-broken). The
one error is environmental: `dada2` is not installed in that sandbox's R
library.
- **A first full run caught a bug the test file alone hides.** Other
test files bind `SV` in `Main`, so a `const SV` failed to load. I
reproduced it, then changed it to a plain binding like theirs.
- **Patch coverage,** computed with CoverageTools 1.4.2 as CI processes
it, is 156/159 (98.11%):
- `memory_probe.jl`: 137/137;
- `pipeline.jl`: 16/19.
The 3 lines left are the two `_vsearch_stage` calls and the OTU read,
inside `_run_full_pipeline` and `_run_stage`, which no test runs.
This PR touches `src/server/routes/pipeline.jl` and the test, so the
`Occupancy reading` workflow runs here. `CI` will run here only once
#110 is in `main`.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_015pMjy981ie74uCttyYDanC
---
_Generated by [Claude
Code](https://claude.ai/code/session_015pMjy981ie74uCttyYDanC)_
Co-authored-by: Claude Opus 5.5
---
src/server/routes/pipeline.jl | 52 +++++++++++++-----------
test/occupancy_disagreement_test.jl | 61 +++++++++++++++++++++++------
2 files changed, 80 insertions(+), 33 deletions(-)
diff --git a/src/server/routes/pipeline.jl b/src/server/routes/pipeline.jl
index ee1bdedd..51243ecf 100644
--- a/src/server/routes/pipeline.jl
+++ b/src/server/routes/pipeline.jl
@@ -204,6 +204,32 @@ function _attest(f::Function, att, env, name::AbstractString; config_path::Strin
end
end
+# The vsearch stage's two searches: `asv()`, and `otu()` unless it is `nothing`, each
+# under its occupancy probe label. The full pipeline runs them concurrently; a
+# single-stage re-run runs them one after the other. These are the two shapes whose
+# memory peaks the probe tells apart, and test/occupancy_disagreement_test.jl runs
+# both through this function. Returns the two results, `nothing` for an absent OTU
+# search. Concurrently, both are waited for before a failure is rethrown, so a
+# failed ASV search cannot leave the OTU search running.
+function _vsearch_searches(asv::Function, otu::Union{Function,Nothing}; concurrent::Bool)
+ if !concurrent
+ asv_tax = MemoryProbe.task(asv, "vsearch:asv")
+ return (asv_tax, isnothing(otu) ? nothing : MemoryProbe.task(otu, "vsearch:otu"))
+ end
+ asv_task = Threads.@spawn MemoryProbe.task(asv, "vsearch:asv")
+ otu_task = isnothing(otu) ? nothing : Threads.@spawn MemoryProbe.task(otu, "vsearch:otu")
+ tasks = filter(!isnothing, Any[asv_task, otu_task])
+ foreach(t -> (try wait(t) catch end), tasks)
+ (fetch(asv_task), isnothing(otu_task) ? nothing : fetch(otu_task))
+end
+
+# The vsearch stage for one run: `search(project, result, db)` on the ASV result and,
+# unless `otus` is `nothing`, on the OTU result (_vsearch_searches). `search` is the
+# pipeline's `vsearch`; it is passed in so the stage can be tested without one.
+_vsearch_stage(search::Function, project, asvs, otus, db; concurrent::Bool) =
+ _vsearch_searches(() -> search(project, asvs, db),
+ isnothing(otus) ? nothing : () -> search(project, otus, db); concurrent)
+
# The files a stage hands on, by content, for its provenance record.
_output_paths(r::ASVResult) = [r.fasta, r.count_table, r.taxonomy]
_output_paths(r::OTUResult) = [r.fasta, r.count_table]
@@ -271,17 +297,7 @@ function _run_full_pipeline(study::String, run::String, db_config::String,
# OTU search it claims to cover.
asv_tax, otu_tax = if vsearch_enabled
_attest(att, env, "vsearch"; config_path) do
- asv_task = Threads.@spawn MemoryProbe.task("vsearch:asv") do
- vsearch(project, asvs, dbs["$(db_name)_vsearch"])
- end
- otu_task = (swarm_enabled && !isnothing(otus)) ?
- Threads.@spawn(MemoryProbe.task("vsearch:otu") do
- vsearch(project, otus, dbs["$(db_name)_vsearch"])
- end) : nothing
- # Wait for both before rethrowing, so a failed ASV search cannot leave the OTU search running.
- tasks = filter(!isnothing, Any[asv_task, otu_task])
- foreach(t -> (try wait(t) catch end), tasks)
- (fetch(asv_task), isnothing(otu_task) ? nothing : fetch(otu_task))
+ _vsearch_stage(vsearch, project, asvs, otus, dbs["$(db_name)_vsearch"]; concurrent=true)
end
else
(nothing, nothing)
@@ -452,19 +468,11 @@ function _run_stage(study::String, run::String, stage::String,
end
elseif step == "vsearch"
if vsearch_enabled
- # Serial here, concurrent in the full pipeline: the same two
- # searches in the two shapes the occupancy probe distinguishes.
+ # Serial here, concurrent in the full pipeline (_vsearch_searches).
_record("vsearch") do
asvs = _asvresult_from_disk(run_dir)
- MemoryProbe.task("vsearch:asv") do
- vsearch(project, asvs, dbs["$(db_name)_vsearch"])
- end
- otus = _oturesult_from_disk(run_dir)
- if !isnothing(otus) && swarm_enabled
- MemoryProbe.task("vsearch:otu") do
- vsearch(project, otus, dbs["$(db_name)_vsearch"])
- end
- end
+ otus = swarm_enabled ? _oturesult_from_disk(run_dir) : nothing
+ _vsearch_stage(vsearch, project, asvs, otus, dbs["$(db_name)_vsearch"]; concurrent=false)
end
end
# merge_taxa follows as its own step when it is in the list.
diff --git a/test/occupancy_disagreement_test.jl b/test/occupancy_disagreement_test.jl
index b609ed38..00837263 100644
--- a/test/occupancy_disagreement_test.jl
+++ b/test/occupancy_disagreement_test.jl
@@ -43,6 +43,9 @@ using MetaManifold
const MP = MetaManifold.MemoryProbe
const PV = MetaManifold.Provenance
const TL = MetaManifold.Tools
+# Not const: other test files bind SV in Main the same way, and runtests.jl
+# includes them all into one module.
+SV = MetaManifold.Server
const PROBE_ON = Dict("occupancy_probe" => Dict("enabled" => true, "interval_ms" => 10))
@@ -102,21 +105,19 @@ end
const ORDERS = ("asv_then_otu", "otu_then_asv", "concurrent")
# The stage's work in `order`, under a fresh probe. Returns the probe's record.
+# ASV-then-OTU and concurrent are the stage's own two shapes, run through the
+# pipeline's _vsearch_searches; OTU-then-ASV has no caller and is spelled out.
function run_order(dir, out, order)
mkpath(out)
probe = MP.start(PROBE_ON)
+ asv() = _search(dir, out, "asv")
+ otu() = _search(dir, out, "otu")
MP.within(probe) do
- if order == "asv_then_otu"
- MP.task(() -> _search(dir, out, "asv"), "vsearch:asv")
- MP.task(() -> _search(dir, out, "otu"), "vsearch:otu")
- elseif order == "otu_then_asv"
- MP.task(() -> _search(dir, out, "otu"), "vsearch:otu")
- MP.task(() -> _search(dir, out, "asv"), "vsearch:asv")
+ if order == "otu_then_asv"
+ MP.task(otu, "vsearch:otu")
+ MP.task(asv, "vsearch:asv")
else
- # The shape of the full pipeline's vsearch stage.
- a = Threads.@spawn MP.task(() -> _search(dir, out, "asv"), "vsearch:asv")
- o = Threads.@spawn MP.task(() -> _search(dir, out, "otu"), "vsearch:otu")
- fetch(a); fetch(o)
+ SV._vsearch_searches(asv, otu; concurrent = order == "concurrent")
end
end
MP.finish!(probe)
@@ -184,6 +185,45 @@ _task(rec, label) = only(t for t in rec["tasks"] if t["label"] == label)
end
end
+ @testset "the vsearch stage's two shapes" begin
+ # Serial and concurrent return the same pair, and record one task per
+ # search; with no OTU search the pair ends in `nothing` and no OTU task
+ # is recorded. A single-stage re-run attests both searches' outputs.
+ for concurrent in (false, true)
+ probe = MP.start(PROBE_ON)
+ @test MP.within(() -> SV._vsearch_searches(() -> :asv, () -> :otu; concurrent), probe) ==
+ (:asv, :otu)
+ @test MP.within(() -> SV._vsearch_searches(() -> :asv, nothing; concurrent), probe) ==
+ (:asv, nothing)
+ @test sort([t["label"] for t in MP.finish!(probe)["tasks"]]) ==
+ ["vsearch:asv", "vsearch:asv", "vsearch:otu"]
+ end
+
+ # The pipeline's call: the same search on each result, against one database.
+ stub(project, result, db) = (project, result, db)
+ for concurrent in (false, true)
+ @test SV._vsearch_stage(stub, :p, :asvs, :otus, :db; concurrent) ==
+ ((:p, :asvs, :db), (:p, :otus, :db))
+ @test SV._vsearch_stage(stub, :p, :asvs, nothing, :db; concurrent) ==
+ ((:p, :asvs, :db), nothing)
+ end
+
+ # Concurrently, a failed ASV search is rethrown only once the OTU search
+ # has finished, so it cannot be left running.
+ otu_done = Threads.Atomic{Bool}(false)
+ @test_throws TaskFailedException SV._vsearch_searches(() -> error("ASV search failed"),
+ () -> (sleep(0.3); otu_done[] = true; :otu);
+ concurrent = true)
+ @test otu_done[]
+ end
+
+ @testset "a handle the probe cannot watch is ignored" begin
+ # run(cmd; wait=false) returns a Process or a ProcessChain; anything else
+ # must leave the stage alone rather than fail it.
+ @test MP.watch!(:not_a_process) === nothing
+ @test MP.unwatch!(MP.watch!(:not_a_process)) === nothing
+ end
+
if !Sys.islinux()
@info "Occupancy probe: not Linux, so there is no /proc to sample; the child-memory tests are SKIPPED"
else
@@ -212,7 +252,6 @@ _task(rec, label) = only(t for t in rec["tasks"] if t["label"] == label)
end
@testset "through the pipeline's _attest, flag off and on" begin
- SV = MetaManifold.Server
mktempdir() do tmp
cfg_path = joinpath(tmp, "run_config.yml")
for probed in (false, true)
From 4c5aef3b3fa18f110091db84c2c69e6ef4f69171 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 20:22:43 +0100
Subject: [PATCH 07/45] =?UTF-8?q?chore(sync):=20merge=20upstream=20main=20?=
=?UTF-8?q?(bcf52af)=20into=20the=20fork=20=E2=80=94=20resolve=20the=20con?=
=?UTF-8?q?flicts=20(#115)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Resolves the conflicts that make the fork's `main` unmergeable with
upstream — the ones behind the "Sync fork" screen and behind PR #114
showing `dirty`.
## Why they exist
Upstream's `main` is 25 commits ahead, and the fork's history carries
none of them: #109 synced that content with a **squash**, so upstream's
work is in the fork's tree but not in its ancestry. `git merge-base`
therefore sat at `257bcde`, and every later merge conflicted on regions
where the two sides agree in substance and differ only in wording.
## What this merge does
Merges `JoshuaJewell/main` @ `bcf52af` into the fork's `main` (from
`a0880df`), resolving 8 conflicted files / 23 hunks. Every hunk was
resolved on the fork's side, for a stated reason, and all of upstream's
non-conflicting work was absorbed:
| file | hunks | resolution |
|---|---|---|
| `.github/workflows/ci.yml` | 4 | This fork's workflow: Julia and Bun
from the archives pinned in `tool_versions.yml`, coverage kept as a
workflow artifact, actions pinned to SHAs this account allows.
Upstream's side would reinstate `julia-actions/setup-julia` and
`codecov/codecov-action`, which this account refuses — the failure that
ends a run in `startup_failure` before any job starts. |
| `test/unit/test_install_pins.jl` | 2 | Kept `allowed_action` and the
`JULIA_URL`/`JULIA_SHA256` env assertions; dropped `ci_step_using`,
whose single use asserted a step this fork does not have. |
| `src/analysis/differential.jl` | 10 | The fork's docstrings, checked
against the merged code (`clr_transform`'s refusal of non-finite values,
`_check_compositions`' 1e-8 tolerance and `ErrorException`,
`fit_welch`'s failed rows with a note, `volcano_chart`'s
`floatmin(Float64)` y-values, `DifferentialConfig`'s strip/lowercase and
range checks). |
| `src/analysis/analysis.jl` | 3 | The fork's docstrings; the three
non-`:computed` statuses, the sample-column exclusion and the
length-checked `blocks` all exist in the merged code. |
| `src/server/routes/config.jl` | 1 | The fork's docstring, which
describes what `_config_value_error` actually checks (enums,
`optional_args`, `remote.*`, file-name parts). |
| `frontend/src/components/DifferentialPanel.tsx` | 1 | "Welch's t-test
on CLR values" — what `clr_welch` does; upstream's line still said "CLR
linear model" after the rename. |
| `.gitignore` | 1 | `/_build/` and `*.agdai`; upstream's
`proofs/agda/_build/` is already present and not duplicated. |
| `config/defaults/pipeline.yml` | 1 | The commented `occupancy_probe`
block, which upstream has nowhere. |
## Net effect
- **Content**: one addition, upstream's `a required check name is a
stable identifier` testset (+34 lines). Everything else upstream's 25
commits changed was already in the fork's tree — verified file by file:
the other 34 files upstream touched are byte-identical to upstream's
copy, or differ only by the fork's own text.
- **Ancestry**: upstream's `main` becomes an ancestor, so the next sync
merges without these conflicts, and `git merge-base`/the compare API
stop reporting the fork 25 commits behind.
## Verification before pushing
No Julia in this environment, so verification is what can be shown
statically:
- `ci.yml` and `pipeline.yml` parse as YAML; no conflict markers remain
anywhere (`git grep '^<<<<<<<'`).
- Every `uses:` in `.github/workflows/*` matches the fork's allow-list
regex; no `CODECOV_TOKEN`, `julia-actions` or `codecov-action` step
survives (only the explanatory comments name them).
- All 137 mechanical assertions of `test/unit/test_install_pins.jl`
emulated in Python against the merged tree: pass — including
`JULIA_URL`/`JULIA_SHA256` equalling the pin file, `runs-on:
ubuntu-24.04`, no version hard-coded in the workflow, and the new
no-matrix/no-interpolation testset.
CI on this PR is the real check; `test/unit/test_install_pins.jl` is
precisely the guard that the resolved workflow and the pins still agree.
Merging this (squash is fine — the content is what matters, the second
parent only fixes ancestry) puts the fork's `main` level with upstream.
PR #114 can then be rebuilt on top without the `dirty` state.
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
From 06ee15c0eb60f5a25a7c7b5d7531423e8e91a907 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 21:01:50 +0100
Subject: [PATCH 08/45] feat(occupancy): grade the probe's samples against the
HWM algebra (#105) (#116)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #105. First item of the ordered issue programme (#105 → #1 phase
1 → #21 → …).
#105 asks for the amplicon pipeline's stages to be *graded* —
alloc/hold/release — and for the predicted peak to be compared with the
measured one, with a disagreement meaning a bug in one of them. The
probe recorded what a stage reached but nothing read an occupancy out of
it, so a `peak_rss_kib` could only be compared against a cost. This
reads the occupancy with the algebra the reading is stated in:
`ResourceAlgebra.Instances.HWM`, whose reclamation laws
`OccupancyTypes.Reclaim` proves (the sibling's PR #9 the issue cites).
## The grade, read from the samples
- `watch!`/`unwatch!` now record each task's **sample window**
(`first_sample`, `last_sample`) beside its peak; `finish!` attaches the
grades read from those windows as the record's `grades` key.
- `Grade(peak, drift)` with `drift ≤ peak` (an `ArgumentError`
otherwise), `release = peak − drift`; `seq` is the algebra's sequencing
`max (peak a) (drift a + peak b)` (`Reclaim.reclaim-law`), `alt` the
componentwise join the pair of laws needs; `task_grades`, `stage_grade`,
`predicted_peak_rss_kib`, `cost_bound_rss_kib`, `reclaimed_kib`,
`graded`.
- Tasks are read in **sampled order** (first sample, ties by record
order — never the sort's). A task's drift is its peak when the next task
started at or before its last sample; the last task keeps its peak only
if it was still resident.
- A record carrying no windows is left **ungraded** (`nothing`):
skipped, never graded on an assumed order, and `disagreement` stays
silent — not measured is not a disagreement.
## What it reads out of the recorded run
`docs/occupancy/results-2026-10-14.yml`, windows from
`docs/occupancy/schedules.yml` (the file the gate and the unit test both
read; the raw record of that run predates the window fields, and the
Session IR texts in `docs/occupancy/` state the same interleavings):
| order | predicted | measured | cost bound | reclaimed |
|---|---|---|---|---|
| asv_then_otu | 9204 | 9204 | 18384 | 9180 |
| otu_then_asv | 9192 | 9192 | 18344 | 9152 |
| concurrent | 18380 | 18380 | 18380 | 0 |
The prediction is an upper bound (at a handover the task still live
holds at most its peak), so `disagreement` reports only what that bound
forbids: a measured peak *above* the prediction.
`scripts/check-occupancy-grades.jl` re-derives the table from the record
and fails on an inequality; the numbers are emulated-checked here
because this environment has no Julia.
## The same numbers, as theorems
`docs/occupancy/GradedStages.agda` consumes the pinned siblings
(occupancy-types `af95728`, resource-algebra `cf9235c` — nothing
re-declared; the Timeline names are Reclaim's own instance) and
instantiates three of `Reclaim`'s laws at the measured grades:
- `no-reclaim` on the concurrent stage — drift = peak, so the peak is
the sum (18380);
- `reclaimed` on the serial one — drift < peak with a non-empty suffix,
so strictly below cost (9204 < 18384);
- `occupancy-at-cut` over the stage's timeline, and
`protocol-occupancy≤cost` at every cut.
`docs/occupancy/reject/ConcurrentDriftIsZero.agda` is the negative
control: grading the concurrent stage's drift as 0 — reading the cost as
the occupancy — is refused, and `scripts/check-occupancy.sh` now
type-checks `GradedStages` beside `TypedPeak` and requires that refusal.
## No ungraded readings
`scripts/lint-occupancy-readings.sh` fails if production code outside
`src/core/memory_probe.jl` names the `peak_rss_kib` key: from here a
stage's occupancy figure has to come from the grading helpers. It passes
on this tree.
## Tests and CI
- `test/unit/test_occupancy_grade.jl` (new, wired into
`test/runtests.jl`): the grade invariant, the `seq`/`alt` laws, the two
stage shapes (serial 100/160/60, together 160/160/0), the `graded`
record, a measured-above-prediction disagreement, the ungraded no-window
record, a `finish!`/`watch!` run on Linux, and the recorded-run replay.
- `test/occupancy_disagreement_test.jl`: the two-thread testset now
asserts, per order, that every task carries both window keys, that the
record is graded, that the prediction reaches the measurement, that it
stays at or below the cost bound (`occupancy≤cost`) and that the slack
is never negative. The peaks themselves stay recorded, not asserted.
- `.github/workflows/occupancy.yml`: two new steps (the graded reading,
the lint) in the `disagreement` job, and the new files added to both
`paths` filters.
- `docs/occupancy-2026-10.adoc`: the window fields, a "Reading the
numbers" section, status row MMW-01.5, the new commands.
## Verification limits
No Julia and no Agda on this machine, so: the recorded arithmetic and
every fixture number were emulated exactly; the Agda follows the import
and proof style of `OccupancyTypes.Witness` (checked by the sibling's
`scripts/check-agda.sh`, same flags); the workflow YAML and the new
scripts were syntax-checked and the lint was run. CI is the verifier
here — including whether `OccupancyTypes.Reclaim` type-checks under this
repo's flags, which this change is the first to require.
## Not in this change
The adoc's `bridge` row — counted bytes bounded by the typed peak in
general (occupancy-types' v0 → typed bridge) — stays OPEN: this change
gives the graded reading, its instances and its laws, not that proof.
---------
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
---
.github/workflows/occupancy.yml | 15 ++
docs/occupancy-2026-10.adoc | 40 +++-
docs/occupancy/GradedStages.agda | 95 ++++++++
.../reject/ConcurrentDriftIsZero.agda | 18 ++
docs/occupancy/schedules.yml | 21 ++
scripts/check-occupancy-grades.jl | 72 ++++++
scripts/check-occupancy.sh | 48 +++-
scripts/lint-occupancy-readings.sh | 40 ++++
src/core/memory_probe.jl | 222 +++++++++++++++++-
test/occupancy_disagreement_test.jl | 19 ++
test/runtests.jl | 1 +
test/unit/test_occupancy_grade.jl | 179 ++++++++++++++
12 files changed, 758 insertions(+), 12 deletions(-)
create mode 100644 docs/occupancy/GradedStages.agda
create mode 100644 docs/occupancy/reject/ConcurrentDriftIsZero.agda
create mode 100644 docs/occupancy/schedules.yml
create mode 100755 scripts/check-occupancy-grades.jl
create mode 100755 scripts/lint-occupancy-readings.sh
create mode 100644 test/unit/test_occupancy_grade.jl
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
index 4fc5ec49..8e1fdf10 100644
--- a/.github/workflows/occupancy.yml
+++ b/.github/workflows/occupancy.yml
@@ -22,6 +22,9 @@ on:
- src/pipeline/tools.jl
- src/server/routes/pipeline.jl
- test/occupancy_disagreement_test.jl
+ - test/unit/test_occupancy_grade.jl
+ - scripts/check-occupancy-grades.jl
+ - scripts/lint-occupancy-readings.sh
- docs/occupancy/**
- docs/occupancy-2026-10.adoc
- scripts/check-occupancy.sh
@@ -35,6 +38,9 @@ on:
- src/pipeline/tools.jl
- src/server/routes/pipeline.jl
- test/occupancy_disagreement_test.jl
+ - test/unit/test_occupancy_grade.jl
+ - scripts/check-occupancy-grades.jl
+ - scripts/lint-occupancy-readings.sh
- docs/occupancy/**
- docs/occupancy-2026-10.adoc
- scripts/check-occupancy.sh
@@ -107,6 +113,15 @@ jobs:
- name: Three orders of the vsearch stage
run: julia --project=. -t 2 test/occupancy_disagreement_test.jl
+ # Fork issue #105: the grades the recorded run's windows imply, read back
+ # against its own measurement (docs/occupancy/schedules.yml).
+ - name: Graded reading of the recorded run
+ run: julia --project=. scripts/check-occupancy-grades.jl
+
+ # And no production code reads a peak without those grades.
+ - name: Occupancy readings are graded
+ run: bash scripts/lint-occupancy-readings.sh
+
typed-peak:
name: Occupancy typed peak
runs-on: ubuntu-24.04
diff --git a/docs/occupancy-2026-10.adoc b/docs/occupancy-2026-10.adoc
index b7323e45..274fb1ee 100644
--- a/docs/occupancy-2026-10.adoc
+++ b/docs/occupancy-2026-10.adoc
@@ -18,8 +18,9 @@ on 2026-10-14, and checks it against the typed reading.
* `julia_allocated_bytes`: the `Base.gc_num()` delta over the stage (cost);
* per task (one spawned search): the largest sampled VmRSS of the child
- process it started (`peak_rss_kib`), and the kernel's own VmHWM for it
- (`hwm_kib`);
+ process it started (`peak_rss_kib`), the kernel's own VmHWM for it
+ (`hwm_kib`), and the sample window its children were live in
+ (`first_sample`, `last_sample`) — what its grade is read from;
* `child_peak_rss_kib`: the largest sum of the stage's children's VmRSS at
one sampling instant (the stage's occupancy).
@@ -65,6 +66,36 @@ interval 10 ms.
coincide. Here they did: each search's memory plateaus once its database is
loaded.
+== Reading the numbers
+
+A recorded `peak_rss_kib` is a reading, not an occupancy: what a stage occupied
+depends on what the next stage inherits from it. So the probe grades a stage's
+whole window (`MemoryProbe`'s `task_grades`, `stage_grade`,
+`predicted_peak_rss_kib`, `cost_bound_rss_kib`, `reclaimed_kib`) and attaches the
+result to a recorded stage as `grades`; a record with no sample windows is left
+ungraded rather than graded on an assumed order. Tasks are read in sampled order
+(first sample, ties by record order); a task's drift is its peak when the next
+task still held it, and 0 when it had exited.
+
+For the three orders above, the algebra's predicted peak reproduces the measured
+stage peak exactly — 9204, 9192 and 18380 KiB — and the slack against the cost
+bound is what the cuts reclaimed: 9180, 9152 and 0 KiB.
+`scripts/check-occupancy-grades.jl` checks that equality, occupancy≤cost and
+non-negative reclamation against this page's raw record, rebuilding each order's
+windows from `docs/occupancy/schedules.yml` (the file records no windows of its
+own; the Session IR texts in this directory state the same interleavings). The
+reclamation readings are theorems over the pinned siblings at these numbers:
+`docs/occupancy/GradedStages.agda` — `no-reclaim` for the concurrent stage,
+`reclaimed` for the serial one, where 9204 KiB are released at the frontier and
+the cost reading counts them — and the two protocol-level statements
+(`occupancy-at-cut`, `protocol-occupancy≤cost`) as the siblings' own two-step
+over the stage's timeline. `docs/occupancy/reject` gains a fourth rejection, for
+reading the concurrent stage's drift as 0.
+
+`scripts/lint-occupancy-readings.sh` keeps production code from reading the key
+directly: outside `src/core/memory_probe.jl`, an occupancy figure must go through
+those helpers.
+
== Status
[cols="1,3,1,2",options="header"]
@@ -73,7 +104,8 @@ interval 10 ms.
| MMW-01.1 | With the flag off, a stage record has exactly its earlier keys; with it on, it gains `occupancy`; old records still parse and merge | TESTED | `test/occupancy_disagreement_test.jl` ("flag off", "round trip", "through the pipeline's _attest")
| MMW-01.2 | For this workload, the vsearch stage's cost (Julia allocation) is the same in all three orders | TESTED | same file, "two-thread disagreement" (asserted, within 1%)
| MMW-01.3 | For this workload, the stage peak depends on the order (A-3), and each search's own peak does not | TESTED | same file, recorded in the test log; table above
-| MMW-01.4 | For the three Session IR texts, the stepper's counted bytes equal the HWM typed peak | TESTED | `scripts/check-occupancy.sh` (stepper output against `TypedPeak.agda`; two rejections in `docs/occupancy/reject`)
+| MMW-01.4 | For the three Session IR texts, the stepper's counted bytes equal the HWM typed peak | TESTED | `scripts/check-occupancy.sh` (stepper output against `TypedPeak.agda`; three rejections in `docs/occupancy/reject`)
+| MMW-01.5 | The recorded run's grades reproduce its measured stage peaks, and the reclamation the pinned siblings' laws read off them is what the cuts freed | TESTED | `scripts/check-occupancy-grades.jl` (the recorded record), `docs/occupancy/GradedStages.agda` (`no-reclaim`, `reclaimed`, `occupancy-at-cut` at the measured numbers), `test/unit/test_occupancy_grade.jl`
| bridge | Counted bytes are bounded by the typed peak in general (occupancy-types' v0 → typed bridge) | OPEN | unchanged; this page gives three instances, not a proof
|===
@@ -106,6 +138,8 @@ interval 10 ms.
----
julia --project=. -t 2 test/occupancy_disagreement_test.jl # set OCCUPANCY_RESULTS= for YAML
bash scripts/check-occupancy.sh # needs julia and agda 2.6.4.x
+julia --project=. scripts/check-occupancy-grades.jl # the recorded run's grades
+bash scripts/lint-occupancy-readings.sh # no ungraded readings in src/
----
`scripts/fetch-siblings.sh` fetches the siblings at their pins into
diff --git a/docs/occupancy/GradedStages.agda b/docs/occupancy/GradedStages.agda
new file mode 100644
index 00000000..774c2edd
--- /dev/null
+++ b/docs/occupancy/GradedStages.agda
@@ -0,0 +1,95 @@
+{-# OPTIONS --safe --without-K #-}
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- The vsearch stage's grades, and occupancy-types' reclamation theorems at the
+-- measured numbers. Fork issue #105 asks for exactly this: grade the stages
+-- (alloc/hold/release) and compare the predicted peak with the measured one.
+-- TypedPeak.agda computes the typed peak by the algebra's sequence law; this
+-- module adds the grades the measurement itself gives, and the laws that say
+-- what the difference between a peak and a cost reading is: occupancy≤cost, and
+-- the strict reclamation at a cut where something is released.
+--
+-- Consumed, never re-declared, from the siblings scripts/fetch-siblings.sh pins
+-- and scripts/check-occupancy.sh checks:
+-- occupancy-types af957280075699c659de0abd9a3b0defade77309
+-- resource-algebra cf9235c532712a783a404c359a1055f40134794a
+-- Everything is stated in this file's own Timeline instance (`TH`, as
+-- TypedPeak.agda does): the Grade-level laws are Reclaim's as they stand, and
+-- the two protocol-level ones are Reclaim's own two-step — its `cut-factor` over
+-- its `reclaim-law`/`occupancy≤cost` — applied to `xs` and `ys` of that instance.
+--
+-- A grade is `hwm peak drift _`: what an interaction reached, and what it still
+-- holds when the next one begins. The peaks below are the 2026-10-14 run's
+-- (docs/occupancy-2026-10.adoc, docs/occupancy/results-2026-10-14.yml), in KiB.
+module GradedStages where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import OccupancyTypes.Order
+open import OccupancyTypes.Reclaim
+
+import OccupancyTypes.Timeline
+module TH = OccupancyTypes.Timeline HWM
+
+-- A search that has not been waited for when the next one starts: it still holds
+-- everything it reached, so the cut reclaims none of it.
+still : Nat → Grade
+still p = hwm p p (≤-refl p)
+
+-- A search that has exited before the next one starts: it holds nothing, so the
+-- cut reclaims all of it.
+gone : Nat → Grade
+gone p = hwm p 0 z≤n
+
+-- The full pipeline runs the two searches together: ASV is still resident when
+-- OTU starts, so the stage peak is their sum and nothing is reclaimed —
+-- Reclaim.no-reclaim's equality, whose `refl` argument discharges the theorem's
+-- own hypothesis (drift = peak) at the measured grade.
+run-together : peak (still 9176 ⊗h gone 9204) ≡ 18380
+run-together = no-reclaim (still 9176) refl (gone 9204)
+
+-- A single-stage re-run runs them one after the other: ASV has exited, so the
+-- cut reclaims its 9204 KiB and the stage peak is the larger search's
+-- (Reclaim.reclaim-law, by `refl`).
+run-in-turn : peak (gone 9204 ⊗h gone 9180) ≡ 9204
+run-in-turn = refl
+
+-- The same claim as Reclaim.reclaimed states it: with drift < peak at the cut
+-- and a suffix that does anything, occupancy is strictly below cost. 9204 KiB
+-- are released at the frontier, so the cost reading (18384 KiB) overstates the
+-- stage.
+run-in-turn
+--
+-- Must be REFUSED: reading the concurrent stage as one that hands on nothing.
+-- Running the two searches together leaves the ASV search resident when the OTU
+-- search starts, so the stage's drift is that search's peak — 9176 KiB, what a
+-- suffix would see — and not 0. Grading the drift as 0 would read the stage's
+-- COST as its occupancy, which is the very reading the measured overlap exists
+-- to refute.
+module ConcurrentDriftIsZero where
+
+open import ResourceAlgebra.Prelude
+open import ResourceAlgebra.Instances.HWM
+open import GradedStages
+
+wrong : drift (still 9176 ⊗h gone 9204) ≡ 0
+wrong = refl
diff --git a/docs/occupancy/schedules.yml b/docs/occupancy/schedules.yml
new file mode 100644
index 00000000..dfdedb81
--- /dev/null
+++ b/docs/occupancy/schedules.yml
@@ -0,0 +1,21 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# The interleaving each order in results-2026-10-14.yml ran with, as the sample
+# window [first, last] of each of its tasks, in the task order the record keeps.
+# These are the same schedules the Session IR texts in this directory state
+# (vsearch-concurrent.sir runs the two searches together; the other two run them
+# one after the other), expressed in the probe's own sample indices: the windows
+# of a serial order do not overlap, and the concurrent order's do.
+#
+# Read by test/unit/test_occupancy_grade.jl and scripts/check-occupancy-grades.jl,
+# so the two cannot disagree about what the recorded run did.
+asv_then_otu:
+ - [1, 2]
+ - [3, 4]
+otu_then_asv:
+ - [1, 2]
+ - [3, 4]
+concurrent:
+ - [1, 4]
+ - [2, 5]
diff --git a/scripts/check-occupancy-grades.jl b/scripts/check-occupancy-grades.jl
new file mode 100755
index 00000000..ce160d09
--- /dev/null
+++ b/scripts/check-occupancy-grades.jl
@@ -0,0 +1,72 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# Gate for the graded reading of the recorded run (fork issue #105): rebuild each
+# order's timeline from its sample window (docs/occupancy/schedules.yml), grade it
+# with the module the probe itself uses (src/core/memory_probe.jl — included
+# rather than the whole package, so this needs OrderedCollections and YAML only),
+# and compare the algebra's predicted peak with the measured one.
+#
+# The recorded run is fixed evidence, so the comparison is an equality: this is
+# the reading docs/occupancy-2026-10.adoc publishes, and a change that moves it
+# must be a deliberate one. Two further claims hold of every order and are
+# checked: the prediction is never above the cost bound (occupancy≤cost) and the
+# slack between them is never negative (what the cuts reclaim).
+#
+# Run from the repository root: julia --project=. scripts/check-occupancy-grades.jl
+include(joinpath(@__DIR__, "..", "src", "core", "memory_probe.jl"))
+using .MemoryProbe
+using YAML
+
+const ROOT = normpath(joinpath(@__DIR__, ".."))
+const RESULTS = joinpath(ROOT, "docs", "occupancy", "results-2026-10-14.yml")
+const SCHEDULES = joinpath(ROOT, "docs", "occupancy", "schedules.yml")
+
+orders = YAML.load_file(RESULTS)["orders"]
+schedules = YAML.load_file(SCHEDULES)
+
+failures = 0
+function fail(message)
+ global failures += 1
+ println("FAIL: ", message)
+end
+
+println(rpad("order", 16), rpad("predicted", 11), rpad("measured", 11),
+ rpad("cost", 8), "reclaimed")
+# An order's timeline, from the peaks the probe recorded and the window each task
+# ran in. Nothing else is taken from the schedule: the grades are the
+# measurement's.
+function timeline(rec, windows)
+ tasks = Any[Dict{String,Any}("label" => t["label"],
+ "peak_rss_kib" => t["peak_rss_kib"],
+ "first_sample" => windows[i][1],
+ "last_sample" => windows[i][2])
+ for (i, t) in enumerate(rec["tasks"])]
+ Dict{String,Any}("child_peak_rss_kib" => rec["child_peak_rss_kib"], "tasks" => tasks)
+end
+
+for name in sort(collect(keys(orders)))
+ rec = orders[name]
+ occ = timeline(rec, schedules[name])
+ predicted = MemoryProbe.predicted_peak_rss_kib(occ)
+ cost = MemoryProbe.cost_bound_rss_kib(occ)
+ reclaimed = MemoryProbe.reclaimed_kib(occ)
+ measured = rec["child_peak_rss_kib"]
+ println(rpad(name, 16), rpad(string(predicted), 11), rpad(string(measured), 11),
+ rpad(string(cost), 8), string(reclaimed))
+
+ predicted == measured ||
+ fail("$name: predicted $predicted KiB != measured $measured KiB")
+ predicted <= cost ||
+ fail("$name: predicted $predicted KiB above the cost bound $cost KiB (occupancy≤cost)")
+ reclaimed >= 0 ||
+ fail("$name: reclaimed $reclaimed KiB is negative")
+ d = MemoryProbe.disagreement(occ)
+ d === nothing || fail("$name: $d")
+end
+
+if failures > 0
+ println("check-occupancy-grades: $failures failure(s)")
+ exit(1)
+end
+println("check-occupancy-grades: OK — the prediction reproduces the recorded measurement")
diff --git a/scripts/check-occupancy.sh b/scripts/check-occupancy.sh
index f9bcba36..c43f28e4 100755
--- a/scripts/check-occupancy.sh
+++ b/scripts/check-occupancy.sh
@@ -4,8 +4,9 @@
#
# Gate for the occupancy reading of the vsearch stage (docs/occupancy-2026-10.adoc):
# 1. each Session IR text checks, and occupancy-types' stepper counts its bytes;
-# 2. TypedPeak.agda type-checks against the pinned siblings, and each module in
-# docs/occupancy/reject is refused at its own declaration;
+# 2. TypedPeak.agda and GradedStages.agda type-check against the pinned
+# siblings, and each module in docs/occupancy/reject is refused at its own
+# declaration;
# 3. the counted bytes equal the typed peak, order by order.
# Needs julia and agda (2.6.4.x) on PATH. Exit 0 only if all three hold.
set -euo pipefail
@@ -22,6 +23,29 @@ logs=_build/check-logs
mkdir -p "$logs"
failures=0
+# GitHub renders an `::error` line as a check annotation, and annotations -- unlike
+# job logs -- can be read back through the API. A failure that only happens in CI
+# must be readable from a machine that cannot download the log, so every failure
+# below says what it was and repeats the prover's own last lines: % escapes to
+# %25 and newlines to %0A, as the workflow-command syntax requires.
+annotate() { # title file log
+ [ -n "${GITHUB_ACTIONS:-}" ] || return 0
+ printf '::error file=%s,title=%s::%s\n' "$2" "$1" \
+ "$(awk 'NR > 1 { printf "%%0A" } { gsub(/%/, "%25"); printf "%s", $0 }' "$3" |
+ tail -c 6000)"
+}
+
+# One Agda module that must type-check, with its own log.
+check_agda() { # file label
+ if ! agda "${flags[@]}" "$1" >"$logs/$2.log" 2>&1; then
+ cat "$logs/$2.log"
+ annotate "Agda refused $2" "$1" "$logs/$2.log"
+ echo "ERROR: $1 does not type-check" >&2
+ exit 1
+ fi
+ echo "PASS: $2"
+}
+
# Order -> Session IR file and TypedPeak lemma.
orders=(asv-then-otu otu-then-asv concurrent)
@@ -36,18 +60,26 @@ for o in "${orders[@]}"; do
done
echo "== Typed peak (Agda, HWM Timeline)"
-agda "${flags[@]}" docs/occupancy/TypedPeak.agda >"$logs/TypedPeak.log" 2>&1 ||
- { cat "$logs/TypedPeak.log"; echo "ERROR: TypedPeak.agda does not type-check" >&2; exit 1; }
-echo "PASS: TypedPeak.agda"
+check_agda docs/occupancy/TypedPeak.agda TypedPeak.agda
+
+# The graded reading (fork issue #105): occupancy-types' Reclaim theorems at the
+# measured grades, from the same pinned siblings. The recorded run's grades
+# against its measurement are checked by scripts/check-occupancy-grades.jl, which
+# needs the project's Julia packages and so runs in the disagreement job.
+check_agda docs/occupancy/GradedStages.agda GradedStages.agda
for file in docs/occupancy/reject/*.agda; do
name="$(basename -- "$file" .agda)"
log="$logs/$name.log"
if agda "${flags[@]}" -i docs/occupancy/reject "$file" >"$log" 2>&1; then
- cat "$log"; echo "ERROR: invalid claim accepted: $name" >&2; failures=$((failures + 1)); continue
+ cat "$log"
+ annotate "Invalid claim accepted: $name" "$file" "$log"
+ echo "ERROR: invalid claim accepted: $name" >&2; failures=$((failures + 1)); continue
fi
if ! grep -Eq "/$name\.agda:[0-9]+,[0-9]+" "$log" || ! grep -q 'when checking that' "$log" ||
grep -Eq 'Not in scope|Failed to find|Unsolved|Parse error' "$log"; then
- cat "$log"; echo "ERROR: $name failed for the wrong reason" >&2; failures=$((failures + 1)); continue
+ cat "$log"
+ annotate "$name failed for the wrong reason" "$file" "$log"
+ echo "ERROR: $name failed for the wrong reason" >&2; failures=$((failures + 1)); continue
fi
echo "PASS: rejected $name ($(grep -m1 -E '[0-9]+ != [0-9]+' "$log"))"
done
@@ -59,6 +91,8 @@ for o in "${orders[@]}"; do
printf '%-14s %10s %10s\n' "$o" "${counted[$o]}" "$typed"
if [ -z "${counted[$o]}" ] || [ -z "$typed" ] || [ "${counted[$o]}" != "$typed" ]; then
echo "ERROR: $o: counted ${counted[$o]:-?} != typed ${typed:-?}" >&2; failures=$((failures + 1))
+ echo "counted ${counted[$o]:-?} KiB != typed ${typed:-?} KiB" >"$logs/counted-$o.log"
+ annotate "Counted bytes != typed peak ($o)" docs/occupancy/TypedPeak.agda "$logs/counted-$o.log"
fi
done
diff --git a/scripts/lint-occupancy-readings.sh b/scripts/lint-occupancy-readings.sh
new file mode 100755
index 00000000..114779a0
--- /dev/null
+++ b/scripts/lint-occupancy-readings.sh
@@ -0,0 +1,40 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# Fork issue #105: a task's recorded `peak_rss_kib` is a reading, not an
+# occupancy. What a stage occupied is determined only by what the next stage
+# inherits from it — the drift — which is why the probe grades a whole window and
+# why src/core/memory_probe.jl's helpers (`graded`, `predicted_peak_rss_kib`,
+# `cost_bound_rss_kib`, `reclaimed_kib`, `task_grades`) exist. Code outside the
+# probe that reaches for the key itself is comparing measured figures without
+# that context; on the recorded run that reading is off by 9180 KiB for
+# `asv_then_otu` and 9204 KiB for `otu_then_asv` (the reclaimed suffix).
+#
+# So: production code may name the key only in src/core/memory_probe.jl, where
+# the record is built and graded. Tests may read it (they assert the recorded
+# numbers directly, which is fine — they are evidence, not presentation).
+#
+# Run from the repository root: bash scripts/lint-occupancy-readings.sh
+set -u -o pipefail
+
+if [[ ! -f src/core/memory_probe.jl ]]; then
+ echo "lint-occupancy-readings: run from the repository root" >&2
+ exit 2
+fi
+
+failures=0
+while IFS= read -r hit; do
+ [[ -z "$hit" ]] && continue
+ echo "ERROR: ungraded occupancy reading: $hit" >&2
+ failures=$((failures + 1))
+done < <(grep -rn --include='*.jl' '"peak_rss_kib"' src/ |
+ grep -v '^src/core/memory_probe\.jl:' || true)
+
+if (( failures > 0 )); then
+ echo "lint-occupancy-readings: $failures ungraded reading(s); use the grading helpers in" >&2
+ echo "MemoryProbe (see docs/occupancy-2026-10.adoc, 'Reading the numbers') instead." >&2
+ exit 1
+fi
+
+echo "lint-occupancy-readings: OK — every peak_rss_kib reading in src/ goes through the probe's grades"
diff --git a/src/core/memory_probe.jl b/src/core/memory_probe.jl
index 6406c37a..163f8bc9 100644
--- a/src/core/memory_probe.jl
+++ b/src/core/memory_probe.jl
@@ -50,8 +50,16 @@ mutable struct TaskTrace
samples :: Int # samples that found at least one live process
peak_kib :: Int
hwm_kib :: Int
+ # The sample window the task's children were live in: the index of the first
+ # sample taken after the first of them started, and of the one taken after
+ # the last was waited for. 0 means never. Two tasks overlap when the later
+ # one's `first_sample` falls at or before the earlier one's `last_sample`,
+ # which is the interleaving the grade algebra sequences over.
+ first_sample :: Int
+ last_sample :: Int
end
-TaskTrace(label::AbstractString) = TaskTrace(String(label), Int[], 0, 0, 0, 0)
+TaskTrace(label::AbstractString) =
+ TaskTrace(String(label), Int[], 0, 0, 0, 0, 0, 0)
mutable struct Probe
interval :: Float64
@@ -269,7 +277,10 @@ function watch!(proc::Base.Process)
end
lock(p.lock) do
t.processes += 1
- pid === nothing || push!(t.live, pid)
+ if pid !== nothing
+ isempty(t.live) && (t.first_sample = p.samples)
+ push!(t.live, pid)
+ end
end
pid === nothing || _sample!(p)
pid
@@ -301,16 +312,218 @@ function unwatch!(pid::Int)
p === nothing && return nothing
lock(p.lock) do
filter!(!=(pid), t.live)
+ isempty(t.live) && (t.last_sample = p.samples)
+ end
+ nothing
+end
+
+## Grading a stage against the high-water-mark algebra
+# `docs/occupancy` reads a stage as a timeline of grades and checks the peak the
+# algebra predicts against the one the probe measured. The grade is the pinned
+# HWM instance's (`occupancy-types`' sibling `resource-algebra`,
+# `ResourceAlgebra.Instances.HWM`; both are fetched by `scripts/fetch-siblings.sh`):
+#
+# hwm peak drift with drift ≤ peak
+#
+# * `peak` — the high-water mark this interaction reached, in KiB;
+# * `drift` — what it still held when the next interaction began;
+# * `release` = peak − drift — what the frontier reclaims.
+#
+# Sequencing starts the next interaction from the previous one's drift:
+#
+# hwm p₁ n₁ ⊗ hwm p₂ n₂ = hwm (max p₁ (n₁ + p₂)) (n₁ + n₂)
+#
+# and `OccupancyTypes.Reclaim` proves of that definition
+#
+# peak (a ⊗ b) = max (peak a) (drift a + peak b) (reclaim-law)
+# peak (a ⊗ b) ≤ peak a + peak b (occupancy≤cost)
+# drift a = peak a ⇒ peak (a ⊗ c) = peak a + peak c (no-reclaim)
+# drift a < peak a, 0 < peak c ⇒ peak (a ⊗ c) < peak a + peak c (reclaimed)
+#
+# The last two are the two shapes a stage actually has. Run together, the first
+# task is still resident when the second starts (drift = peak) and the stage peak
+# is the sum; run one after the other it has exited (drift = 0) and the stage peak
+# is the larger. The samples already tell the two apart, so the drift is read from
+# the task windows and never assumed.
+#
+# The prediction is an upper bound on the measurement: at a handover the task
+# still live holds at most its peak, so the drift the algebra adds is at least
+# what a sample could have seen. `disagreement` therefore reports only what that
+# bound forbids — a measured stage peak above the prediction — which is the "one
+# of them is a bug" the occupancy reading asks about.
+
+const GRADE_ALGEBRA = "ResourceAlgebra.Instances.HWM"
+
+"A grade of the pinned high-water-mark algebra: a peak and the drift it holds."
+struct Grade
+ peak :: Int
+ drift :: Int
+ function Grade(peak::Integer, drift::Integer)
+ drift <= peak || throw(ArgumentError(
+ "a grade holds at most what it reached: drift $drift > peak $peak"))
+ new(Int(peak), Int(drift))
+ end
+end
+
+"`release(grade)` — what the frontier reclaims, `peak − drift`."
+release(g::Grade) = g.peak - g.drift
+
+"`seq(a, b)` — sequence `b` after `a`: the suffix starts from `a`'s drift."
+seq(a::Grade, b::Grade) = Grade(max(a.peak, a.drift + b.peak), a.drift + b.drift)
+
+"`alt(a, b)` — the larger grade, componentwise (what the timeline never needs, kept for the pair of laws)."
+alt(a::Grade, b::Grade) = Grade(max(a.peak, b.peak), max(a.drift, b.drift))
+
+"Nothing allocated and nothing held: the algebra's unit."
+const NO_GRADE = Grade(0, 0)
+
+# The trace's sample window, or `nothing` for a record that carries none (one
+# written before the windows were sampled).
+function _window(rec::AbstractDict)
+ first_seen = get(rec, "first_sample", nothing)
+ last_seen = get(rec, "last_sample", nothing)
+ (first_seen isa Integer && last_seen isa Integer) ? (Int(first_seen), Int(last_seen)) : nothing
+end
+
+_is_windowed(rec::AbstractDict) = _window(rec) !== nothing
+
+# Whether `prev` was still live when `next` started, so the handover hands its
+# peak on rather than reclaiming it. A `prev` never unwatched (window end 0) was
+# still live; a `next` that never started (window start 0) never overlapped.
+_overlaps(prev::Tuple{Int,Int}, next::Tuple{Int,Int}) =
+ next[1] != 0 && (prev[2] == 0 || next[1] <= prev[2])
+
+# Grades and labels in sampled order, or `nothing` when the record carries no
+# windows: a comparison is then skipped rather than made on an assumed order.
+function _grades_and_labels(occ::AbstractDict)
+ recs = get(occ, "tasks", nothing)
+ recs isa AbstractVector || return Grade[], String[]
+ all(_is_windowed, recs) || return nothing
+ # Sampled order, ties broken by record order so the result never depends on
+ # the sort's stability.
+ order = sort(collect(eachindex(recs)); by = i -> (_window(recs[i])[1], i))
+ ordered = collect(recs)[order]
+ windows = [_window(r) for r in ordered]
+ grades = Grade[]
+ labels = String[]
+ for (i, rec) in enumerate(ordered)
+ peak = Int(get(rec, "peak_rss_kib", 0))
+ drift = if i == length(ordered)
+ # The stage waits for its children: a task still resident at the end
+ # keeps its peak as the grade's drift, an exited one holds nothing.
+ (windows[i][1] != 0 && windows[i][2] == 0) ? peak : 0
+ else
+ _overlaps(windows[i], windows[i + 1]) ? peak : 0
+ end
+ push!(grades, Grade(peak, drift))
+ push!(labels, String(get(rec, "label", "?")))
end
+ grades, labels
+end
+
+"""
+ task_grades(occupancy) -> Union{Vector{Grade},Nothing}
+
+One grade per task of a stage's `occupancy` record, in sampled order: a task's
+drift is its peak when it was still live at the next task's start, and 0 when it
+had exited. `nothing` when the record carries no sample windows.
+"""
+function task_grades(occ::AbstractDict)
+ graded = _grades_and_labels(occ)
+ graded === nothing ? nothing : graded[1]
+end
+
+"""
+ stage_grade(occupancy) -> Union{Grade,Nothing}
+
+The stage's grade: its tasks' grades folded with `seq`, the algebra's sequencing.
+`nothing` when the record carries no sample windows.
+"""
+function stage_grade(occ::AbstractDict)
+ grades = task_grades(occ)
+ grades === nothing && return nothing
+ isempty(grades) ? NO_GRADE : foldl(seq, grades)
+end
+
+"`predicted_peak_rss_kib(occupancy)` — the peak the algebra predicts, or `nothing`."
+function predicted_peak_rss_kib(occ::AbstractDict)
+ g = stage_grade(occ)
+ g === nothing ? nothing : g.peak
+end
+
+"`cost_bound_rss_kib(occupancy)` — the peaks added: the peak if no cut reclaimed anything (occupancy≤cost's right-hand side)."
+function cost_bound_rss_kib(occ::AbstractDict)
+ grades = task_grades(occ)
+ grades === nothing ? nothing : sum(g -> g.peak, grades; init = 0)
+end
+
+"`reclaimed_kib(occupancy)` — the slack between the cost bound and the predicted peak: what the cuts reclaim."
+function reclaimed_kib(occ::AbstractDict)
+ cost = cost_bound_rss_kib(occ)
+ predicted = predicted_peak_rss_kib(occ)
+ (cost === nothing || predicted === nothing) ? nothing : cost - predicted
+end
+
+"""
+ disagreement(occupancy) -> Union{String,Nothing}
+
+Why the measured stage peak contradicts the grade algebra, or `nothing` when it
+does not. The prediction is an upper bound, so the one contradiction is a
+measured peak above it: a sample then saw more resident memory than the timed
+timeline of tasks can hold, and one of the two is wrong. A record with no
+windows, or none the probe graded, gives `nothing`: not measured is not a
+disagreement.
+"""
+function disagreement(occ::AbstractDict)
+ predicted = predicted_peak_rss_kib(occ)
+ measured = get(occ, "child_peak_rss_kib", nothing)
+ (predicted === nothing || !(measured isa Integer)) && return nothing
+ measured > predicted && return "measured $(measured) KiB above the predicted $(predicted) KiB"
nothing
end
+"""
+ graded(occupancy) -> Union{OrderedDict,Nothing}
+
+The grades of a stage's `occupancy` record, ready to sit beside it in the
+attestation: per task `peak_kib`/`drift_kib`/`release_kib`, the stage's
+`predicted_peak_kib`, the `cost_bound_kib` (its peaks added) and the
+`reclaimed_kib` slack between the two, the `measured_peak_kib` it is checked
+against, and the `disagreement` between them or `nothing`. `nothing` when the
+record carries no sample windows.
+"""
+function graded(occ::AbstractDict)
+ graded_pair = _grades_and_labels(occ)
+ graded_pair === nothing && return nothing
+ grades, labels = graded_pair
+ stage = isempty(grades) ? NO_GRADE : foldl(seq, grades)
+ cost = sum(g -> g.peak, grades; init = 0)
+ OrderedDict{String,Any}(
+ "algebra" => GRADE_ALGEBRA,
+ "tasks" => Any[OrderedDict{String,Any}(
+ "label" => labels[i],
+ "peak_kib" => grades[i].peak,
+ "drift_kib" => grades[i].drift,
+ "release_kib" => release(grades[i]))
+ for i in eachindex(grades)],
+ "predicted_peak_kib" => stage.peak,
+ "drift_kib" => stage.drift,
+ "release_kib" => release(stage),
+ "cost_bound_kib" => cost,
+ "reclaimed_kib" => cost - stage.peak,
+ "measured_peak_kib" => get(occ, "child_peak_rss_kib", nothing),
+ "disagreement" => disagreement(occ),
+ )
+end
+
_task_record(t::TaskTrace) = OrderedDict{String,Any}(
"label" => t.label,
"processes" => t.processes,
"samples" => t.samples,
"peak_rss_kib" => t.peak_kib,
"hwm_kib" => t.hwm_kib,
+ "first_sample" => t.first_sample,
+ "last_sample" => t.last_sample,
)
"""
@@ -337,6 +550,11 @@ function finish!(p::Probe)
"tasks" => Any[_task_record(t) for t in traces],
)
p.error === nothing || (p.record["error"] = p.error)
+ # The grades the sample windows imply, beside the samples they came from. A
+ # record whose tasks carry no windows cannot be graded, and is left as it is
+ # rather than graded on an assumed order.
+ g = graded(p.record)
+ g === nothing || (p.record["grades"] = g)
p.record
end
diff --git a/test/occupancy_disagreement_test.jl b/test/occupancy_disagreement_test.jl
index 00837263..15df70fd 100644
--- a/test/occupancy_disagreement_test.jl
+++ b/test/occupancy_disagreement_test.jl
@@ -341,6 +341,25 @@ _task(rec, label) = only(t for t in rec["tasks"] if t["label"] == label)
@info "occupancy: concurrent stage peak vs the larger serial stage peak" concurrent = conc["child_peak_rss_kib"] serial = serial_max overlapped = overlap cost_spread = round(spread; sigdigits = 3)
overlap || @warn "occupancy: the concurrent searches did not overlap in memory on this runner, so this run cannot show the order dependence"
+ # The grades the sample windows imply, on this same run
+ # (fork issue #105). The peak is recorded, not asserted — a
+ # runner that never has both searches resident cannot show it —
+ # but the algebra's own claims are asserted of every order:
+ # every task is timed, the prediction at least reaches what the
+ # samples saw (the prediction is an upper bound: at a handover
+ # the live task holds at most its peak), it never exceeds the
+ # cost bound (Reclaim.occupancy≤cost), and the slack between
+ # them is never negative.
+ for o in ORDERS
+ r = records[o]
+ @test all(t -> haskey(t, "first_sample") && haskey(t, "last_sample"), r["tasks"])
+ @test haskey(r, "grades")
+ @test MP.predicted_peak_rss_kib(r) >= r["child_peak_rss_kib"]
+ @test MP.predicted_peak_rss_kib(r) <= MP.cost_bound_rss_kib(r)
+ @test MP.reclaimed_kib(r) >= 0
+ @test MP.disagreement(r) === nothing
+ end
+
path = get(ENV, "OCCUPANCY_RESULTS", "")
if !isempty(path)
io = IOBuffer()
diff --git a/test/runtests.jl b/test/runtests.jl
index 81626927..73921614 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -45,6 +45,7 @@ const UNIT_FILES = [
"test_remote_stages.jl",
"test_jobs.jl",
"test_provenance.jl",
+ "test_occupancy_grade.jl",
"test_install_pins.jl",
"test_migrate_composition.jl",
"test_sample_reads.jl",
diff --git a/test/unit/test_occupancy_grade.jl b/test/unit/test_occupancy_grade.jl
new file mode 100644
index 00000000..4a3fc003
--- /dev/null
+++ b/test/unit/test_occupancy_grade.jl
@@ -0,0 +1,179 @@
+# © 2026 Joshua Benjamin Jewell. All rights reserved.
+# Licensed under the GNU Affero General Public License version 3 (AGPLv3).
+
+## Grading a stage against the high-water-mark algebra (MMW-01, fork issue #105)
+# The algebra is proved in occupancy-types' `OccupancyTypes.Reclaim`, over the HWM
+# instance in resource-algebra; `scripts/fetch-siblings.sh` pins both and
+# `scripts/check-occupancy.sh` type-checks what consumes them. Nothing here
+# restates a sibling's definition: what is tested is the *reading* of a probe's
+# samples as grades — that a task is sequenced from the previous one's drift,
+# that the siblings' laws come out of that reading, and that the prediction
+# reproduces the measurement the occupancy reading recorded on 2026-10-14.
+
+using MetaManifold.MemoryProbe
+using OrderedCollections, YAML
+
+const MG = MetaManifold.MemoryProbe
+
+const RECORDED_OCCUPANCY =
+ normpath(joinpath(@__DIR__, "..", "..", "docs", "occupancy", "results-2026-10-14.yml"))
+const RECORDED_SCHEDULES =
+ normpath(joinpath(@__DIR__, "..", "..", "docs", "occupancy", "schedules.yml"))
+
+# One task as the probe records it, with the sample window a schedule gives it.
+# The window is what the grade reads: a task still live when the next one starts
+# hands its peak on as drift, an exited one hands on nothing.
+function _occ_task(peak; label = "t", first_sample = 1, last_sample = 2)
+ OrderedDict{String,Any}("label" => label, "processes" => 1, "samples" => 3,
+ "peak_rss_kib" => peak, "hwm_kib" => peak,
+ "first_sample" => first_sample, "last_sample" => last_sample)
+end
+
+# One stage as `finish!` writes it: its tasks and the peak the samples saw.
+_occ_stage(tasks; measured) = OrderedDict{String,Any}(
+ "probe" => MG.PROBE, "interval_ms" => 10, "samples" => 10,
+ "julia_allocated_bytes" => 1000, "julia_live_bytes_delta" => 500,
+ "child_peak_rss_kib" => measured, "tasks" => Any[tasks...])
+
+@testset "occupancy grades" begin
+ @testset "a grade is (peak, drift), drift ≤ peak" begin
+ g = MG.Grade(90, 40)
+ @test g.peak == 90
+ @test g.drift == 40
+ @test MG.release(g) == 50 # what the frontier reclaims
+ # The invariant the HWM instance carries as its `bounded` field.
+ @test_throws ArgumentError MG.Grade(40, 90)
+ end
+
+ @testset "sequencing starts the suffix from the drift (Reclaim.reclaim-law)" begin
+ a = MG.Grade(100, 40)
+ b = MG.Grade(60, 10)
+ s = MG.seq(a, b)
+ # peak (a ⊗ b) ≡ max (peak a) (drift a + peak b)
+ @test s.peak == max(a.peak, a.drift + b.peak)
+ @test s.drift == a.drift + b.drift
+ @test MG.seq(a, MG.NO_GRADE) == a # the unit holds nothing
+ @test s.peak <= a.peak + b.peak # Reclaim.occupancy≤cost
+ # Alternatives keep the larger of each component.
+ @test MG.alt(a, b) == MG.Grade(100, 40)
+ end
+
+ @testset "the two shapes a stage has: serial and concurrent" begin
+ # One after the other: the first has exited (drift = 0), so the peak is
+ # the larger of the two and the cut reclaims the difference.
+ serial = _occ_stage([_occ_task(100; label = "asv", first_sample = 1, last_sample = 2),
+ _occ_task(60; label = "otu", first_sample = 3, last_sample = 4)];
+ measured = 100)
+ @test MG.predicted_peak_rss_kib(serial) == 100
+ @test MG.cost_bound_rss_kib(serial) == 160
+ @test MG.reclaimed_kib(serial) == 60 # Reclaim.reclaimed: strictly below cost
+ @test MG.disagreement(serial) === nothing
+
+ # Together: the first is still resident when the second starts
+ # (drift = peak), so the peak is the sum and nothing is reclaimed.
+ together = _occ_stage([_occ_task(100; label = "asv", first_sample = 1, last_sample = 4),
+ _occ_task(60; label = "otu", first_sample = 2, last_sample = 5)];
+ measured = 160)
+ @test MG.predicted_peak_rss_kib(together) == 160
+ @test MG.cost_bound_rss_kib(together) == 160
+ @test MG.reclaimed_kib(together) == 0 # Reclaim.no-reclaim: drift = peak
+ @test MG.disagreement(together) === nothing
+ end
+
+ @testset "the graded record says what it is" begin
+ rec = _occ_stage([_occ_task(100; label = "vsearch:asv", first_sample = 1, last_sample = 4),
+ _occ_task(60; label = "vsearch:otu", first_sample = 2, last_sample = 5)];
+ measured = 160)
+ g = MG.graded(rec)
+ @test g["algebra"] == "ResourceAlgebra.Instances.HWM"
+ @test g["predicted_peak_kib"] == 160
+ @test g["cost_bound_kib"] == 160
+ @test g["reclaimed_kib"] == 0
+ @test g["measured_peak_kib"] == 160
+ @test g["disagreement"] === nothing
+ @test [t["label"] for t in g["tasks"]] == ["vsearch:asv", "vsearch:otu"]
+ @test g["tasks"][1]["drift_kib"] == 100 # still resident at the handover
+ @test g["tasks"][2]["release_kib"] == 60 # the last one hands on nothing
+ end
+
+ @testset "a measured peak above the prediction is a disagreement" begin
+ # The prediction is an upper bound, so this is the one contradiction it
+ # allows: a sample saw more than the timed timeline can hold.
+ rec = _occ_stage([_occ_task(100; first_sample = 1, last_sample = 2)]; measured = 250)
+ d = MG.disagreement(rec)
+ @test d isa String
+ @test occursin("250", d) && occursin("100", d)
+ # At or below the bound there is nothing to report.
+ @test MG.disagreement(_occ_stage([_occ_task(100; first_sample = 1, last_sample = 2)];
+ measured = 100)) === nothing
+ end
+
+ @testset "a record with no sample windows is not graded" begin
+ # Records written before the windows were sampled cannot be read as a
+ # timeline, so nothing is predicted for them rather than an order assumed.
+ rec = OrderedDict{String,Any}("child_peak_rss_kib" => 100,
+ "tasks" => Any[OrderedDict{String,Any}("label" => "t", "peak_rss_kib" => 100)])
+ @test MG.predicted_peak_rss_kib(rec) === nothing
+ @test MG.graded(rec) === nothing
+ @test MG.disagreement(rec) === nothing # not measured is not a disagreement
+ end
+
+ @testset "finish! grades the record it writes" begin
+ # A record the probe writes itself carries its tasks' sample windows, so
+ # `finish!` attaches the grades read from them; the run with real vsearch
+ # children is test/occupancy_disagreement_test.jl.
+ if !Sys.islinux()
+ @info "occupancy grades: not Linux, so there is no /proc to sample; the finish! grading check is SKIPPED"
+ else
+ probe = MG.start(Dict("occupancy_probe" => Dict("enabled" => true,
+ "interval_ms" => 10)))
+ MG.within(probe) do
+ MG.task("sleep") do
+ proc = run(`sleep 0.2`; wait = false)
+ pids = MG.watch!(proc)
+ try
+ wait(proc)
+ finally
+ MG.unwatch!(pids)
+ end
+ end
+ end
+ rec = MG.finish!(probe)
+ @test length(rec["tasks"]) == 1 # one task; no stage trace
+ t = rec["tasks"][1]
+ @test t["label"] == "sleep" && t["samples"] > 0
+ @test t["peak_rss_kib"] > 0 # sampled from /proc
+ @test t["last_sample"] >= t["first_sample"] # the window watch!/unwatch! set
+ @test haskey(rec, "grades")
+ @test rec["grades"]["predicted_peak_kib"] == MG.predicted_peak_rss_kib(rec)
+ @test rec["grades"]["measured_peak_kib"] == rec["child_peak_rss_kib"]
+ @test MG.disagreement(rec) === nothing
+ end
+ end
+
+ @testset "the prediction reproduces the recorded measurement" begin
+ # docs/occupancy/results-2026-10-14.yml is the probe's own record of the
+ # run docs/occupancy-2026-10.adoc publishes. The schedules are the ones the
+ # .sir files in docs/occupancy state — serial for two orders, together for
+ # the third — and the peaks are the recorded ones: nothing here is invented.
+ orders = YAML.load_file(RECORDED_OCCUPANCY)["orders"]
+ # The interleavings the run used, from the same file the gate reads
+ # (scripts/check-occupancy-grades.jl), so the two cannot disagree.
+ schedules = YAML.load_file(RECORDED_SCHEDULES)
+ for (name, rec) in orders
+ windows = schedules[name]
+ tasks = [_occ_task(t["peak_rss_kib"]; label = t["label"],
+ first_sample = windows[i][1], last_sample = windows[i][2])
+ for (i, t) in enumerate(rec["tasks"])]
+ occ = _occ_stage(tasks; measured = rec["child_peak_rss_kib"])
+ graded = MG.graded(occ)
+ @test MG.predicted_peak_rss_kib(occ) == rec["child_peak_rss_kib"]
+ @test MG.disagreement(occ) === nothing
+ peaks = [t["peak_rss_kib"] for t in rec["tasks"]]
+ # Serial: the larger peak. Together: both resident, so nothing is reclaimed.
+ @test graded["reclaimed_kib"] ==
+ (name == "concurrent" ? 0 : sum(peaks) - maximum(peaks))
+ @test graded["cost_bound_kib"] == sum(peaks)
+ end
+ end
+end
From 129c76cf297fb48bba5bfeb6165130295c018c84 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Wed, 14 Oct 2026 21:45:08 +0100
Subject: [PATCH 09/45] fix(occupancy): the at-the-cut proof main carries a
stray `TH.[_]` (#117)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two lines of `docs/occupancy/GradedStages.agda` on `main` (from the #116
squash) still carry a draft placeholder: `TH.[_]` where `TH.[]` belongs.
Agda refuses it at the parse, before checking anything else:
```
docs/occupancy/GradedStages.agda:77,59-80
Could not parse the application gone 9180 TH.∷ TH.[_]
Operators used in the grammar:
TH.∷ (infixr operator, level 5)
when scope checking gone 9180 TH.∷ TH.[_]
```
So `main`'s **Occupancy typed peak** job is red at
`scripts/check-occupancy.sh`, and with it the graded reading the #116
squash landed. This PR is that fix and nothing else: `TH.[_]` → `TH.[]`
in both places, 2 lines, no other change (`git diff origin/main..HEAD` =
this file only).
## How the error became readable
The job's log is served from storage this environment cannot reach, so
#116 added `annotate` to `scripts/check-occupancy.sh`: every failure it
diagnoses now emits a `::error` line (with the prover's own last lines,
escaped as the workflow-command syntax requires), and check annotations
— unlike logs — are available through the API. That is how the parse
error above was read back from run
[38628333902](https://github.com/hyperpolymath/MetaManifold-WebUI/actions/runs/38628333902).
It stays silent outside Actions.
## Why the first push looked different
The first run on #116 (d49c2e3) failed before the annotations existed,
so its cause was never readable; the revision that added them (cf1b726)
is what reported this parse error, and it is what the squash carried
into `main`. Nothing else in the #116 change is affected:
`OccupancyTypes.Reclaim` itself type-checks under this repo's flags —
visible in the same job's log, which reaches my file only after checking
Prelude, Core, HWM, Order, Reclaim and Timeline.
CI on this PR is the check: the typed-peak job must go green, and the
disagreement job re-runs the recorded study as usual.
---------
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit
---
docs/occupancy/GradedStages.agda | 9 ++--
src/core/memory_probe.jl | 43 ++++++++++-------
test/unit/test_occupancy_grade.jl | 76 +++++++++++++++++++++++++++++--
3 files changed, 105 insertions(+), 23 deletions(-)
diff --git a/docs/occupancy/GradedStages.agda b/docs/occupancy/GradedStages.agda
index 774c2edd..3c45730d 100644
--- a/docs/occupancy/GradedStages.agda
+++ b/docs/occupancy/GradedStages.agda
@@ -67,16 +67,17 @@ run-in-turn (_window(recs[i])[1], i))
ordered = collect(recs)[order]
windows = [_window(r) for r in ordered]
+ peaks = [Int(get(r, "peak_rss_kib", 0)) for r in ordered]
grades = Grade[]
labels = String[]
+ live = Int[]
+ held = 0
for (i, rec) in enumerate(ordered)
- peak = Int(get(rec, "peak_rss_kib", 0))
- drift = if i == length(ordered)
+ push!(live, i)
+ if i == length(ordered)
# The stage waits for its children: a task still resident at the end
- # keeps its peak as the grade's drift, an exited one holds nothing.
- (windows[i][1] != 0 && windows[i][2] == 0) ? peak : 0
+ # keeps its peak in the folded drift, an exited one holds nothing.
+ filter!(j -> windows[j][1] != 0 && windows[j][2] == 0, live)
else
- _overlaps(windows[i], windows[i + 1]) ? peak : 0
+ filter!(j -> _overlaps(windows[j], windows[i + 1]), live)
end
- push!(grades, Grade(peak, drift))
+ remaining = sum(j -> peaks[j], live; init = 0)
+ # seq adds drifts, so reclaim expired predecessors as well as this task.
+ push!(grades, Grade(peaks[i], remaining - held))
+ held = remaining
push!(labels, String(get(rec, "label", "?")))
end
grades, labels
@@ -424,9 +433,11 @@ end
"""
task_grades(occupancy) -> Union{Vector{Grade},Nothing}
-One grade per task of a stage's `occupancy` record, in sampled order: a task's
-drift is its peak when it was still live at the next task's start, and 0 when it
-had exited. `nothing` when the record carries no sample windows.
+One grade per task of a stage's `occupancy` record, in sampled order. Its drift
+is the change in the live peak sum by the next task's start, including releases
+of earlier tasks; the final boundary retains only unclosed windows. Folding
+with `seq` carries exactly the peaks still live at each boundary. `nothing` when
+the record carries no sample windows.
"""
function task_grades(occ::AbstractDict)
graded = _grades_and_labels(occ)
diff --git a/test/unit/test_occupancy_grade.jl b/test/unit/test_occupancy_grade.jl
index 4a3fc003..215a176b 100644
--- a/test/unit/test_occupancy_grade.jl
+++ b/test/unit/test_occupancy_grade.jl
@@ -21,8 +21,7 @@ const RECORDED_SCHEDULES =
normpath(joinpath(@__DIR__, "..", "..", "docs", "occupancy", "schedules.yml"))
# One task as the probe records it, with the sample window a schedule gives it.
-# The window is what the grade reads: a task still live when the next one starts
-# hands its peak on as drift, an exited one hands on nothing.
+# The windows determine which peaks remain live at each handover.
function _occ_task(peak; label = "t", first_sample = 1, last_sample = 2)
OrderedDict{String,Any}("label" => label, "processes" => 1, "samples" => 3,
"peak_rss_kib" => peak, "hwm_kib" => peak,
@@ -80,6 +79,75 @@ _occ_stage(tasks; measured) = OrderedDict{String,Any}(
@test MG.disagreement(together) === nothing
end
+ @testset "overlap expires at each handover" begin
+ for (windows, predicted, remaining) in (
+ ([(1, 2), (2, 3), (4, 5)], 160, 0), # an overlapping pair, then a serial task
+ ([(1, 3), (2, 5), (4, 6)], 160, 0), # pairwise overlap, never all three
+ ([(1, 6), (2, 3), (4, 5)], 180, 0), # the first survives a shorter task
+ ([(1, 5), (2, 5), (3, 5)], 240, 0), # genuine three-way overlap
+ ([(1, 2), (1, 2), (3, 4)], 160, 0), # children registered before sample 1
+ ([(1, 0), (2, 3), (4, 0)], 180, 180), # unclosed windows remain live
+ )
+ tasks = [_occ_task(peak; label = "t$i", first_sample = window[1],
+ last_sample = window[2])
+ for (i, (peak, window)) in enumerate(zip((100, 60, 80), windows))]
+ occ = _occ_stage(tasks; measured = predicted)
+ grades = MG.task_grades(occ)
+ folded = foldl(MG.seq, grades)
+ @test folded.peak == predicted
+ @test folded.drift == remaining
+ @test MG.release(folded) == predicted - remaining
+ @test MG.cost_bound_rss_kib(occ) == 240
+ @test MG.reclaimed_kib(occ) == 240 - predicted
+ @test MG.disagreement(occ) === nothing
+ @test MG.graded(occ)["predicted_peak_kib"] == predicted
+ # Record order does not change the sampled order.
+ @test MG.stage_grade(_occ_stage(reverse(tasks); measured = predicted)) == folded
+ end
+ @test !MG._overlaps((0, 0), (1, 2)) # a never-started task is not live
+ @test !MG._overlaps((1, 0), (0, 0))
+ @test MG._overlaps((1, 2), (1, 2))
+ @test MG._overlaps((1, 2), (2, 3)) # the end sample is included
+ @test !MG._overlaps((1, 2), (3, 4))
+ end
+
+ @testset "registration keeps the first child's sample boundary" begin
+ # No background sampler: watch! takes the first sample deterministically.
+ probe = MG.Probe(0.01, ReentrantLock(), MG.TaskTrace[], MG.TaskTrace("(stage)"),
+ 0, 0, Base.gc_num(), Base.gc_live_bytes(), false, nothing,
+ nothing, nothing, nothing, Vector{UInt8}(undef, 8192),
+ Vector{UInt8}(undef, 64))
+ function child!()
+ proc = run(`sleep 0.01`; wait = false)
+ pid = MG.watch!(proc)
+ try
+ wait(proc)
+ finally
+ MG.unwatch!(pid)
+ end
+ end
+ MG.within(probe) do
+ MG.task("two children") do
+ child!()
+ @test probe.samples == 1
+ @test probe.tasks[1].first_sample == 1
+ @test probe.tasks[1].last_sample == 1
+ MG.task("between children") do
+ child!()
+ end
+ child!()
+ end
+ end
+ first_task, between = probe.tasks
+ @test first_task.processes == 2
+ @test first_task.first_sample == 1
+ @test first_task.last_sample == 3
+ @test between.first_sample == 2
+ @test MG._overlaps((first_task.first_sample, first_task.last_sample),
+ (between.first_sample, between.last_sample))
+ @test MG.TaskTrace("never started").first_sample == 0
+ end
+
@testset "the graded record says what it is" begin
rec = _occ_stage([_occ_task(100; label = "vsearch:asv", first_sample = 1, last_sample = 4),
_occ_task(60; label = "vsearch:otu", first_sample = 2, last_sample = 5)];
@@ -93,7 +161,9 @@ _occ_stage(tasks; measured) = OrderedDict{String,Any}(
@test g["disagreement"] === nothing
@test [t["label"] for t in g["tasks"]] == ["vsearch:asv", "vsearch:otu"]
@test g["tasks"][1]["drift_kib"] == 100 # still resident at the handover
- @test g["tasks"][2]["release_kib"] == 60 # the last one hands on nothing
+ @test g["tasks"][2]["drift_kib"] == -100 # the earlier task also exited
+ @test g["tasks"][2]["release_kib"] == 160 # both peaks are reclaimed
+ @test g["drift_kib"] == 0
end
@testset "a measured peak above the prediction is a disagreement" begin
From 17fef948add5c58507b69d0f40509f41b126dbcc Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 00:14:57 +0100
Subject: [PATCH 10/45] docs(occupancy): the page still described the
pre-expiry drift; pin the reviewer's example (stacked on #117) (#118)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Stacked on #117 (base is that branch, so this diff is the delta only,
not #117's work). #117 is the right fix; this is what its fix left
behind. If you would rather have these three files folded into #117
itself, ask CodeRabbit to apply them there — this session can only push
its own branch.
## The audit of CodeRabbit's guidance on #117 (all three findings)
| # | Finding (🟠 Major) | Verdict |
|---|---|---|
| 1 | **Preserve the first child's start boundary** — `isempty(t.live)
&& (t.first_sample = …)` re-stamped the window on every later child that
found `live` empty | **Fixed** by 05e56e7: `t.processes == 1 &&
(t.first_sample = p.samples + 1)`, set once per task |
| 2 | **Do not use zero for a started task's boundary** — two workers
registering before any `_sample!` both got `first_sample == 0`, which
`_overlaps` reads as never started | **Fixed** by 05e56e7: boundary is
`p.samples + 1` (≥ 1), and `_overlaps` now also requires `prev[1] != 0`.
The pid push and the boundary are set under the same lock acquisition
that precedes the first sample that can see the child, so `first_sample`
is exactly the first sample that could count it — no off-by-one remains
to chase |
| 3 | **Expire earlier drift before grading a later handover** (the
A/B/C `201` vs `101` example) | **Fixed** by 05e56e7: per-boundary live
sets, per-task drift as the signed delta of the live sum. Verified
independently below |
**Verification of #3** — the branch head's `_grades_and_labels` emulated
against an independent reading of the windows (a task is live at instant
`t` when `t ∈ [first, last]`, or `t ≥ first` while `last = 0`):
- the reviewer's own case, windows `[1,3] [2,10] [4,5]`, peaks `100, 1,
100`: drifts `[100, −99, −1]`, **predicted 101**, cost 201, reclaimed
100 — the `201` is gone;
- exhaustive over n ≤ 3 tasks with peaks 0–3 (**219,660** cases) and
300,000 random records up to six tasks: the fold **equals** the largest
simultaneous peak sum for every record the probe can write, and is
**never below** it otherwise — so `disagreement` still means "measured
above anything the windows allow";
- every task keeps `drift ≤ peak` (no `ArgumentError` path), the folded
drift stays ≥ 0, and the recorded run's figures are unchanged: 9204 /
9192 / 18380 KiB, reclaimed 9180 / 9152 / 0 — so
`scripts/check-occupancy-grades.jl` and the two-thread test's per-order
assertions still hold.
## What is still wrong after that fix (this PR)
- **`docs/occupancy-2026-10.adoc` documented the replaced rule**: "a
task's drift is its peak when the next task still held it, and 0 when it
had exited". It now states the boundary rule — each handover keeps only
the tasks still live at it; `drift_kib` is the change in that live sum,
negative when the boundary also releases an earlier task; `release_kib`
is everything freed between its start and the next boundary and can
exceed the task's own peak; the folded drift is the peaks still live at
the stage's end. The signed per-task delta is named as the extension
`ResourceAlgebra.Instances.HWM`'s header records as **H-2**; the
instance's own `drift` is the non-negative live sum. The
`first_sample`/`last_sample` bullet is pinned to the probe's own
wording.
- **The reviewer's example was not a test.** It is now, verbatim: drifts
`[100, −99, −1]`, predicted 101, cost 201, reclaimed 100, and — the
sharpened bound — 201 KiB resident at once is asserted to be the
*disagreement*, where before the fix it was the prediction.
- **`src/core/memory_probe.jl`'s own grading comment** said drift was
"the net change …" and `release` was "what the frontier reclaims"; both
now say precisely what the arithmetic does.
Comment-and-docstring changes only in `src/`; no behaviour change. The
occupancy workflow re-runs the recorded study on this branch anyway (the
path filter matches).
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: CodeRabbit
---
docs/occupancy-2026-10.adoc | 16 ++++++++++++----
src/core/memory_probe.jl | 12 +++++++++---
test/unit/test_occupancy_grade.jl | 21 +++++++++++++++++++++
3 files changed, 42 insertions(+), 7 deletions(-)
diff --git a/docs/occupancy-2026-10.adoc b/docs/occupancy-2026-10.adoc
index 274fb1ee..6d291b05 100644
--- a/docs/occupancy-2026-10.adoc
+++ b/docs/occupancy-2026-10.adoc
@@ -19,8 +19,10 @@ on 2026-10-14, and checks it against the typed reading.
* `julia_allocated_bytes`: the `Base.gc_num()` delta over the stage (cost);
* per task (one spawned search): the largest sampled VmRSS of the child
process it started (`peak_rss_kib`), the kernel's own VmHWM for it
- (`hwm_kib`), and the sample window its children were live in
- (`first_sample`, `last_sample`) — what its grade is read from;
+ (`hwm_kib`), and the sample window its children were live in — `first_sample`,
+ the first sample taken after its first child started, and `last_sample`, the
+ last taken before its last was waited for, 0 for never — which its grade is
+ read from;
* `child_peak_rss_kib`: the largest sum of the stage's children's VmRSS at
one sampling instant (the stage's occupancy).
@@ -74,8 +76,14 @@ whole window (`MemoryProbe`'s `task_grades`, `stage_grade`,
`predicted_peak_rss_kib`, `cost_bound_rss_kib`, `reclaimed_kib`) and attaches the
result to a recorded stage as `grades`; a record with no sample windows is left
ungraded rather than graded on an assumed order. Tasks are read in sampled order
-(first sample, ties by record order); a task's drift is its peak when the next
-task still held it, and 0 when it had exited.
+(first sample, ties by record order). Each handover keeps only the tasks still
+live at it, so a task's `drift_kib` is the change in the live peak sum across its
+boundary — negative when the boundary also releases an earlier task — and
+`release_kib` is everything freed between its start and the next boundary, which
+can exceed that task's own peak. The folded drift is the sum of the peaks still
+live when the stage ends; the pinned instance's own `drift` is that non-negative
+live sum, the signed per-task delta being the extension its header records as
+H-2.
For the three orders above, the algebra's predicted peak reproduces the measured
stage peak exactly — 9204, 9192 and 18380 KiB — and the slack against the cost
diff --git a/src/core/memory_probe.jl b/src/core/memory_probe.jl
index 8653949c..a4ad4304 100644
--- a/src/core/memory_probe.jl
+++ b/src/core/memory_probe.jl
@@ -326,8 +326,14 @@ end
# hwm peak drift with drift ≤ peak
#
# * `peak` — the high-water mark this interaction reached, in KiB;
-# * `drift` — the net change in memory held by the next interaction's start;
-# * `release` = peak − drift — what the frontier reclaims.
+# * `drift` — the change in the live peak sum across the interaction's
+# boundary, so a boundary that also releases an earlier task is negative;
+# * `release` = peak − drift — everything freed between the interaction's start
+# and the next boundary, which can exceed the interaction's own peak.
+#
+# A task's drift subtracts earlier tasks that exited before the next start, so it
+# can be negative — the signed-drift extension `Instances/HWM`'s header records
+# as H-2. The folded drift is the sum of peaks still live at the stage's end.
#
# A task's drift subtracts earlier tasks that exited before the next start, so
# it can be negative. The folded drift is the sum of peaks still live there.
@@ -368,7 +374,7 @@ struct Grade
end
end
-"`release(grade)` — what the frontier reclaims, `peak − drift`."
+"`release(grade)` — `peak − drift`: everything freed by the interaction's boundary."
release(g::Grade) = g.peak - g.drift
"`seq(a, b)` — sequence `b` after `a`: the suffix starts from `a`'s drift."
diff --git a/test/unit/test_occupancy_grade.jl b/test/unit/test_occupancy_grade.jl
index 215a176b..8b94a0a5 100644
--- a/test/unit/test_occupancy_grade.jl
+++ b/test/unit/test_occupancy_grade.jl
@@ -111,6 +111,27 @@ _occ_stage(tasks; measured) = OrderedDict{String,Any}(
@test !MG._overlaps((1, 2), (3, 4))
end
+ @testset "the reviewer's three-task example: expiry, not the sum of drifts" begin
+ # Windows [1,3], [2,10], [4,5] and peaks 100, 1, 100 KiB — CodeRabbit's
+ # example on #117. A overlaps B and exits before C starts, so the largest
+ # simultaneous sum is 101 (A+B at samples 2-3, then B+C at 4-5), not the
+ # 201 a rule that kept every earlier drift would read, and not the 201 KiB
+ # cost. A's release at C's boundary makes B's delta negative, which is how
+ # the fold stops charging for it.
+ tasks = [_occ_task(100; label = "A", first_sample = 1, last_sample = 3),
+ _occ_task(1; label = "B", first_sample = 2, last_sample = 10),
+ _occ_task(100; label = "C", first_sample = 4, last_sample = 5)]
+ occ = _occ_stage(tasks; measured = 101)
+ @test [g.drift for g in MG.task_grades(occ)] == [100, -99, -1]
+ @test MG.predicted_peak_rss_kib(occ) == 101
+ @test MG.cost_bound_rss_kib(occ) == 201
+ @test MG.reclaimed_kib(occ) == 100 # the 100 KiB that never coexist
+ @test MG.disagreement(occ) === nothing
+ # The bound the expiry sharpens: 201 KiB resident at once is now the
+ # disagreement, where before the fix it was the prediction.
+ @test MG.disagreement(_occ_stage(tasks; measured = 201)) isa String
+ end
+
@testset "registration keeps the first child's sample boundary" begin
# No background sampler: watch! takes the first sample deterministically.
probe = MG.Probe(0.01, ReentrantLock(), MG.TaskTrace[], MG.TaskTrace("(stage)"),
From 1856722edf695f1313b3c9c0e5730c611d1b0cb6 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 00:35:15 +0100
Subject: [PATCH 11/45] fix(charts): strip title markup to a fixpoint, not one
pass (code-scanning 4, 5) (#119)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
Both open
[code-scanning](https://github.com/hyperpolymath/MetaManifold-WebUI/security/code-scanning)
alerts —
[4](https://github.com/hyperpolymath/MetaManifold-WebUI/security/code-scanning/4)
and
[5](https://github.com/hyperpolymath/MetaManifold-WebUI/security/code-scanning/5)
— are the same rule, `js/incomplete-multi-character-sanitization` (high,
CWE-020/080/116), on the same shape: `text.replace(/<[^>]+>/g, '')`
stripping the pseudo-HTML markup Plotly accepts in titles. A single pass
is not a sanitizer: in `"ipt>"` the inner tag is removed and
the halves join into a fresh `')).not.toMatch(/<[^>]+>/)
+ })
+
+ test('text without markup, including a bare >, is unchanged', () => {
+ expect(stripHtmlTags('p > 0.05 for all')).toBe('p > 0.05 for all')
+ })
+})
+
+describe('splitLines', () => {
+ test('one trimmed value per line, blanks dropped', () => {
+ expect(splitLines(' a\n\n b \nc')).toEqual(['a', 'b', 'c'])
+ })
+})
diff --git a/frontend/src/utils/text.ts b/frontend/src/utils/text.ts
index dc076655..ffdecc9c 100644
--- a/frontend/src/utils/text.ts
+++ b/frontend/src/utils/text.ts
@@ -9,3 +9,20 @@
export function splitLines(text: string): string[] {
return text.split('\n').map(v => v.trim()).filter(v => v.length > 0)
}
+
+/**
+ * Strip the pseudo-HTML markup Plotly accepts in titles and axis labels (,
+ * , and friends), leaving the bare text for measuring or for a
+ * filename. A single pass is not enough: replacing "ipt>" once
+ * removes the inner tag and leaves the halves joined into another, so the
+ * strip repeats until the text stops changing.
+ */
+export function stripHtmlTags(text: string): string {
+ let stripped = text
+ let previous = ''
+ do {
+ previous = stripped
+ stripped = stripped.replace(/<[^>]+>/g, '')
+ } while (stripped !== previous)
+ return stripped
+}
From afc76f2281561fd8208250521b94308d1e0f9730 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 01:44:48 +0100
Subject: [PATCH 12/45] chore(sync): merge upstream main (2775faf) into the
fork (#120)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
Brings the fork back in line with upstream: a true merge of
`JoshuaJewell/MetaManifold-WebUI` `main` at `2775faf` into our `main`,
continuing the syncs of #109 and #115. Since our last sync, upstream
moved three commits past `bcf52af`:
- `8dda16d` — harden server origin checks
(`JULIA_METAMANIFOLD_ALLOWED_ORIGINS` allowlist), disable Oxygen docs,
serve report uploads inert (CSP, nosniff)
- `e877e13` — accept the SPA index for `/docs` paths when a frontend
build is present
- `2775faf` — docs: split deployments and the origins the server must be
told about
The net tree change against our `main` is 6 files, +226/-13:
`src/server/server.jl`, `src/server/routes/report.jl`,
`test/unit/test_server_security.jl` (new), `test/runtests.jl`,
`README.md`, `frontend/src/components/DifferentialPanel.tsx`. Everything
else upstream did since the last shared commit (`257bcde`) was already
absorbed here by #115.
## Conflict resolutions (all deliberate fork policy)
- `.github/workflows/ci.yml`, `test/unit/test_install_pins.jl` — keep
the fork's versions. This account allows only `actions/*` and
`hyperpolymath/*` pinned to full SHAs, so Julia and Bun install from the
pinned archives and the tests assert that policy. Upstream's `.github/`
is unchanged since `bcf52af`.
- `src/analysis/analysis.jl`, `src/analysis/differential.jl`,
`src/server/routes/config.jl` — keep the fork's docstrings; verified
line by line against the merged code (isfinite checks, the 1e-8
composition tolerance, median/max imputed fractions, result sort order,
p-underflow handling, the file-name and remote gates).
- `frontend/src/components/DifferentialPanel.tsx` — take upstream's
wording ("CLR linear model").
- `test/runtests.jl` — take upstream's `test_server_security.jl` entry,
keep the fork's occupancy entries.
- `.gitignore`, `config/defaults/pipeline.yml` — keep the fork's
occupancy blocks; upstream's Agda ignore line was already present.
The fork's occupancy work (memory probe, grades, disagreement test) is
untouched.
## How to run the tests
```
julia --project=. test/runtests.jl # full unit suite
cd frontend && bun run test && bun run typecheck
```
## Versions tested against
Julia 1.13.1 (local; CI installs the pinned 1.12.5), R 4.6.1
(conda-forge; lockfile pins 4.5.0), bun 1.4.3. Full unit suite locally:
2623 passed; the only failures are the "live probes" of pipeline tools
this machine does not have installed (cutadapt, vsearch, dada2's R
package, etc.), which CI installs by design. Frontend: typecheck clean,
38/38 tests pass.
## Notes
- This is a **merge commit**, not a squash: merging it as a merge (not
squash) advances the merge base with upstream to `2775faf`, so the next
sync is a clean three-way merge from a current base.
- The commit is unsigned — the signing key's passphrase was not
available in the session that produced it. Amend/rebase to sign if the
fork's history requires it.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: Mistral Vibe
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
README.md | 25 ++++
frontend/src/components/DifferentialPanel.tsx | 2 +-
src/server/routes/report.jl | 9 +-
src/server/server.jl | 99 ++++++++++++--
test/runtests.jl | 1 +
test/unit/test_server_security.jl | 128 ++++++++++++++++++
6 files changed, 251 insertions(+), 13 deletions(-)
create mode 100644 test/unit/test_server_security.jl
diff --git a/README.md b/README.md
index bb9883cc..1fe02ca8 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,7 @@ The web UI lets you create studies, configure pipeline parameters, launch runs,
| `JULIA_METAMANIFOLD_PORT` | `8080` | Server port |
| `JULIA_METAMANIFOLD_ROOT` | working directory | Project root |
| `JULIA_THREADS` | `8` | Julia threads |
+| `JULIA_METAMANIFOLD_ALLOWED_ORIGINS` | none | Extra browser origins allowed to call the API, comma-separated (the server's own origin and the Vite dev server at `localhost:5173` are always allowed) |
Or run the Julia server directly:
@@ -644,6 +645,30 @@ bash start.sh
Open `http://localhost:8080`. The backend serves the frontend.
+### Split deployment (frontend and API on different origins)
+
+To serve the frontend from a different address than the API, set `apiBase` in
+`frontend/public/config.json` to the API's address, and list the frontend's
+address when starting the server:
+
+```bash
+JULIA_METAMANIFOLD_ALLOWED_ORIGINS=https://frontend.example bash start.sh
+```
+
+The server accepts browser requests from its own origin (`http://localhost:`
+and `http://127.0.0.1:`), from the Vite dev server (`localhost:5173`), and
+from the origins listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`, separated by
+commas. An origin has to match exactly, scheme and port included:
+`https://frontend.example` does not cover `http://frontend.example` or
+`https://frontend.example:8443`. A page on any other origin gets
+`403 Forbidden: origin not allowed` on every request that carries an `Origin`
+header, which is every write and every cross-origin read.
+
+This also applies to an SSH tunnel on a different local port (e.g.
+`http://localhost:9000`): the browser's origin is then `http://localhost:9000`,
+not the server's `http://localhost:8080`, so start the server with
+`JULIA_METAMANIFOLD_ALLOWED_ORIGINS=http://localhost:9000`.
+
## Input data
Place paired-end FASTQ files under `data/{project_name}/` following Illumina naming:
diff --git a/frontend/src/components/DifferentialPanel.tsx b/frontend/src/components/DifferentialPanel.tsx
index 8f78e326..825fb812 100644
--- a/frontend/src/components/DifferentialPanel.tsx
+++ b/frontend/src/components/DifferentialPanel.tsx
@@ -22,7 +22,7 @@ function csvBlob(result: DifferentialResult): Blob {
/**
* Differential abundance between exactly two runs or sub-groups: a model per
- * taxon at the chosen rank (negative-binomial GLM or Welch's t-test on CLR values, as
+ * 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.
*/
diff --git a/src/server/routes/report.jl b/src/server/routes/report.jl
index b3c37fa6..73ac45a6 100644
--- a/src/server/routes/report.jl
+++ b/src/server/routes/report.jl
@@ -126,6 +126,12 @@ const _REPORT_MIME = Dict(".svg" => "image/svg+xml", ".png" => "image/png", ".js
".pdf" => "application/pdf", ".tif" => "image/tiff",
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+# Uploaded items are stored as sent, so an .svg can carry script. Served with
+# these, a file opened directly cannot run it or be sniffed as another type;
+# an showing it is unaffected.
+const _UPLOAD_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox"
+const _UPLOAD_HEADERS = ["X-Content-Type-Options" => "nosniff", "Content-Security-Policy" => _UPLOAD_CSP]
+
@get "/api/v1/studies/{study}/report/{id}/file" function(req, study::String, id::String)
err = _report_study_error(study)
isnothing(err) || return err
@@ -134,7 +140,8 @@ const _REPORT_MIME = Dict(".svg" => "image/svg+xml", ".png" => "image/png", ".js
it = _report_items(study)[item]
path = joinpath(_report_dir(study), it["file"])
isfile(path) || return json_error(404, "not_found", "The file for '$id' is missing")
- HTTP.Response(200, ["Content-Type" => get(_REPORT_MIME, lowercase(splitext(path)[2]), "application/octet-stream")];
+ HTTP.Response(200, ["Content-Type" => get(_REPORT_MIME, lowercase(splitext(path)[2]), "application/octet-stream"),
+ _UPLOAD_HEADERS...];
body=read(path))
end
diff --git a/src/server/server.jl b/src/server/server.jl
index 56537a71..0e8c2afd 100644
--- a/src/server/server.jl
+++ b/src/server/server.jl
@@ -207,8 +207,54 @@ module Server
## CORS middleware (needed when the frontend is served from a different origin)
# Loopback binds also check Host, so a DNS-rebinding page cannot read the API.
const _bound_host = Ref("127.0.0.1")
+ const _bound_port = Ref(8080)
_is_loopback(h::AbstractString) = lowercase(h) in ("localhost", "127.0.0.1", "::1", "[::1]")
+ # The Vite dev server (frontend/vite.config.ts, default port) proxies /api and
+ # /files here without rewriting Origin, so its pages arrive with this origin.
+ const _DEV_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173")
+
+ "An origin in the form `_allowed_origins` holds: lowercase, no trailing slash."
+ _normalise_origin(o::AbstractString) = rstrip(lowercase(strip(o)), '/')
+
+ """
+ _allowed_origins() -> Set{String}
+
+ The browser origins allowed to call the API cross-origin: the origin this
+ server is served from over HTTP (its bound port, spelled with `localhost` and
+ `127.0.0.1`, plus the bound host itself when it is a specific name), the Vite
+ dev server, and any listed comma-separated in
+ `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`. Every other origin is refused, including
+ other ports and schemes on localhost.
+
+ Wildcard bind addresses are not included. Read the environment on each call,
+ ignoring blank entries and normalising case, surrounding whitespace and
+ trailing slashes; entries are not validated as URLs.
+ """
+ function _allowed_origins()
+ port = _bound_port[]
+ host = lowercase(_bound_host[])
+ hosts = Set(["localhost", "127.0.0.1"])
+ host in ("0.0.0.0", "::", "[::]") ||
+ push!(hosts, occursin(':', host) && !startswith(host, "[") ? "[$host]" : host)
+ allowed = Set(["http://$h:$port" for h in hosts])
+ union!(allowed, _DEV_ORIGINS)
+ for o in split(get(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS", ""), ',')
+ isempty(strip(o)) || push!(allowed, _normalise_origin(o))
+ end
+ allowed
+ end
+
+ """
+ _cors_middleware(next)
+
+ Return a request handler that answers 403 for a non-empty, non-loopback Host
+ on a loopback bind, or a non-empty `Origin` outside `_allowed_origins()` after
+ normalisation. The Host check also applies to requests without an Origin.
+ Allowed origins get CORS headers echoing the supplied Origin; OPTIONS gets
+ 204 without calling `next`. Otherwise call `next(req)`, adding CORS headers
+ only when the Origin is non-empty. Errors from `next` propagate.
+ """
function _cors_middleware(next)
function(req::HTTP.Request)
if _is_loopback(_bound_host[])
@@ -221,11 +267,8 @@ module Server
# Same-origin request - no CORS headers needed
return next(req)
end
- # Only allow localhost origins
- origin_url = try HTTP.URIs.URI(origin) catch; nothing end
- if isnothing(origin_url) || !(lowercase(origin_url.host) in ("localhost", "127.0.0.1", "::1"))
- return HTTP.Response(403, "Forbidden: non-localhost origin")
- end
+ _normalise_origin(origin) in _allowed_origins() ||
+ return HTTP.Response(403, "Forbidden: origin not allowed")
cors_headers = [
"Access-Control-Allow-Origin" => origin,
"Access-Control-Allow-Methods" => "GET, POST, PUT, PATCH, DELETE, OPTIONS",
@@ -252,7 +295,18 @@ module Server
".csv" => "text/csv", ".json" => "application/json",
)
- # Static file serving middleware (Oxygen middleware signature: handler -> req -> response)
+ """
+ _file_middleware(next)
+
+ Serve run outputs under `/files/{study}/runs/...` from the projects tree and
+ the frontend build for other non-API paths, passing `/api/` requests to `next`.
+ Return a request handler; query strings are ignored when locating files.
+ Missing run files return 404; existing files resolving outside the projects
+ tree return 403. For other paths, serve a matching frontend file or fall back
+ to `index.html`, calling `next` if neither exists.
+ Run files are sent with `nosniff`; report uploads also get `_UPLOAD_CSP`.
+ File resolution and read errors, and errors from `next`, propagate.
+ """
function _file_middleware(next)
function(req::HTTP.Request)
uri = first(split(req.target, '?'; limit=2))
@@ -269,7 +323,13 @@ module Server
return HTTP.Response(403, "Forbidden")
ext = last(splitext(full))
mime = get(_mime_map, ext, "application/octet-stream")
- return HTTP.Response(200, ["Content-Type" => mime]; body=read(full))
+ # Report items are uploaded bytes, reachable here as well as
+ # through the report route: serve them as inert as that route does.
+ study_report = joinpath(projects, HTTP.URIs.unescapeuri(m[1]), "report")
+ uploaded = startswith(full, study_report * Base.Filesystem.path_separator)
+ headers = ["Content-Type" => mime, "X-Content-Type-Options" => "nosniff"]
+ uploaded && push!(headers, "Content-Security-Policy" => _UPLOAD_CSP)
+ return HTTP.Response(200, headers; body=read(full))
end
# Pass API requests through to Oxygen
@@ -342,10 +402,30 @@ module Server
end
## Entry point
+ const _MIDDLEWARE = [_bad_request_middleware, _r_busy_middleware, _cors_middleware,
+ _analysis_source_middleware, _file_middleware]
+ # Oxygen's docs (/docs, /docs/schema) and metrics sit outside the middleware
+ # above, so they would bypass the Host and Origin checks; nothing uses them.
+ const _SERVE_OPTIONS = (access_log=nothing, show_errors=false, docs=false, metrics=false)
+
+ """
+ start(; root=pwd(), host="127.0.0.1", port=8080, listen=true)
+
+ Set the server's root for `data/`, `projects/` and `config/`, record the bind
+ host and port for request guards, and initialise missing study projects.
+ Individual project creation failures are caught and startup continues;
+ errors enumerating studies propagate.
+
+ With `listen=false`, perform this initialisation and return `nothing` without
+ opening a listener. Otherwise serve the API and frontend with the request
+ guards enabled and Oxygen docs and metrics disabled, waiting until the server
+ stops and returning its server object. Listener startup errors propagate.
+ """
function start(; root=pwd(), host="127.0.0.1", port=8080, listen::Bool=true)
global_logger(_SuppressEpipe(global_logger()))
ServerState.set_root!(root)
_bound_host[] = host
+ _bound_port[] = port
@info "MetaManifold server starting" root host port
# Initialise all projects on startup
@@ -354,10 +434,7 @@ module Server
listen || return nothing
_workload_enabled() || errormonitor(@async _warm_routes(host, port))
- serve(; host, port, access_log=nothing,
- middleware=[_bad_request_middleware, _r_busy_middleware, _cors_middleware,
- _analysis_source_middleware, _file_middleware],
- show_errors=false)
+ serve(; host, port, middleware=_MIDDLEWARE, _SERVE_OPTIONS...)
end
"""
diff --git a/test/runtests.jl b/test/runtests.jl
index 73921614..d107e559 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -53,6 +53,7 @@ const UNIT_FILES = [
"test_differential.jl",
"test_permanova.jl",
"test_write_gates.jl",
+ "test_server_security.jl",
]
# The files named on the command line ("test_routes.jl", "routes" or
diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl
new file mode 100644
index 00000000..2bb7086d
--- /dev/null
+++ b/test/unit/test_server_security.jl
@@ -0,0 +1,128 @@
+# Unit tests for the server's request guards: the Origin allow-list, Oxygen's
+# docs and metrics being off, and the headers report uploads are served with.
+# Requests go through the app middleware in-process; no socket is opened.
+using MetaManifold
+SV = MetaManifold.Server
+using JSON3
+
+# A request through the middleware `start` installs.
+_ss_request(method, path, headers=Pair{String,String}[], body="") =
+ SV.Oxygen.internalrequest(SV.HTTP.Request(method, path, headers, body); middleware=SV._MIDDLEWARE)
+
+# A request through the handler Oxygen's `serve` builds from `_SERVE_OPTIONS`,
+# docs and metrics included, without starting the listener.
+function _ss_served(path)
+ ctx = SV.Oxygen.CONTEXT[]
+ opts = SV._SERVE_OPTIONS
+ saved = ctx.docs.router[]
+ try
+ ctx.docs.router[] = SV.HTTP.Router()
+ opts.docs && SV.Oxygen.Core.setupdocs(ctx)
+ opts.metrics && SV.Oxygen.Core.setupmetrics(ctx)
+ handler = SV.Oxygen.Core.setupmiddleware(ctx; middleware=SV._MIDDLEWARE,
+ docs=opts.docs, metrics=opts.metrics, show_errors=opts.show_errors)
+ handler(SV.HTTP.Request("GET", path, ["Host" => "127.0.0.1:8080"]))
+ finally
+ ctx.docs.router[] = saved
+ end
+end
+
+@testset "Server request guards" begin
+ tmp = mktempdir()
+ old_root = SV.ServerState._root[]
+ old_host, old_port = SV._bound_host[], SV._bound_port[]
+ SV.ServerState.set_root!(tmp)
+ SV._bound_host[] = "127.0.0.1"
+ SV._bound_port[] = 8080
+ mkpath(joinpath(tmp, "data", "StudyS", "run1"))
+ touch(joinpath(tmp, "data", "StudyS", "run1", "s1_R1.fastq.gz"))
+ mkpath(joinpath(tmp, "projects", "StudyS", "run1"))
+ try
+ @testset "Origin allow-list" begin
+ studies = "/api/v1/studies"
+ host = "Host" => "127.0.0.1:8080"
+ # No Origin (same-origin GET, curl): unchanged.
+ @test _ss_request("GET", studies, [host]).status == 200
+ # The served origin, in both spellings, and the Vite dev server.
+ for o in ("http://127.0.0.1:8080", "http://localhost:8080", "http://localhost:5173",
+ "http://127.0.0.1:5173", "http://LOCALHOST:8080/")
+ r = _ss_request("GET", studies, [host, "Origin" => o])
+ @test r.status == 200
+ @test SV.HTTP.header(r, "Access-Control-Allow-Origin") == o
+ @test _ss_request("OPTIONS", studies, [host, "Origin" => o]).status == 204
+ end
+ # Another loopback page: other port, other scheme, or no origin at all.
+ for o in ("http://localhost:3000", "http://127.0.0.1:9999", "https://localhost:8080",
+ "http://[::1]:8080", "null", "http://evil.example")
+ @test _ss_request("GET", studies, [host, "Origin" => o]).status == 403
+ @test _ss_request("OPTIONS", studies, [host, "Origin" => o]).status == 403
+ # A mutating request is refused before it reaches the route.
+ r = _ss_request("POST", "/api/v1/studies/StudyS/report?kind=figure&ext=.txt",
+ [host, "Origin" => o, "Content-Type" => "text/plain"], "x")
+ @test r.status == 403
+ end
+ @test !isdir(joinpath(tmp, "projects", "StudyS", "report"))
+ # The allowed set follows the bound port.
+ SV._bound_port[] = 9090
+ @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:9090"]).status == 200
+ @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:8080"]).status == 403
+ SV._bound_port[] = 8080
+ # Extra origins come from JULIA_METAMANIFOLD_ALLOWED_ORIGINS.
+ withenv("JULIA_METAMANIFOLD_ALLOWED_ORIGINS" => " http://lab-box:8080 , http://localhost:5174/") do
+ @test _ss_request("GET", studies, [host, "Origin" => "http://lab-box:8080"]).status == 200
+ @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:5174"]).status == 200
+ @test _ss_request("GET", studies, [host, "Origin" => "http://localhost:3000"]).status == 403
+ end
+ # The Host check still applies first.
+ @test _ss_request("GET", studies, ["Host" => "evil.example:8080"]).status == 403
+ end
+
+ @testset "Oxygen docs and metrics are not served" begin
+ @test SV._SERVE_OPTIONS.docs === false
+ @test SV._SERVE_OPTIONS.metrics === false
+ # With a frontend build present, an unknown path falls through to the
+ # SPA catch-all and answers 200 with index.html. That is the app, not
+ # the docs, so accept exactly that body as well as a non-200 answer.
+ index = joinpath(SV._frontend_dir, "index.html")
+ spa = isfile(index) ? read(index) : nothing
+ for path in ("/docs", "/docs/schema", "/docs/swagger", "/docs/metrics")
+ r = _ss_served(path)
+ body = lowercase(String(copy(r.body)))
+ @test !occursin("openapi", body)
+ @test !occursin("swagger", body)
+ @test !occursin("redoc", body)
+ @test r.status != 200 || (spa !== nothing && r.body == spa)
+ end
+ # The served handler still answers the API.
+ @test _ss_served("/api/v1/studies").status == 200
+ end
+
+ @testset "Report uploads are served inert" begin
+ host = "Host" => "127.0.0.1:8080"
+ base = "/api/v1/studies/StudyS/report"
+ svg = " "
+ r = _ss_request("POST", "$base?kind=figure&title=X&ext=.svg", [host], svg)
+ @test r.status == 200
+ item = JSON3.read(String(r.body))
+ for path in ("$base/$(item.id)/file", "/files/StudyS/runs/report/$(item.file)")
+ f = _ss_request("GET", path, [host])
+ @test f.status == 200
+ @test String(f.body) == svg
+ @test SV.HTTP.header(f, "Content-Type") == "image/svg+xml"
+ @test SV.HTTP.header(f, "X-Content-Type-Options") == "nosniff"
+ @test SV.HTTP.header(f, "Content-Security-Policy") ==
+ "default-src 'none'; style-src 'unsafe-inline'; sandbox"
+ end
+ # Pipeline output under /files/ is sniff-proof but keeps working inline.
+ write(joinpath(tmp, "projects", "StudyS", "run1", "plot.pdf"), "%PDF-1.4")
+ f = _ss_request("GET", "/files/StudyS/runs/run1/plot.pdf", [host])
+ @test f.status == 200
+ @test SV.HTTP.header(f, "X-Content-Type-Options") == "nosniff"
+ @test SV.HTTP.header(f, "Content-Security-Policy") == ""
+ end
+ finally
+ SV.ServerState._root[] = old_root
+ SV._bound_host[], SV._bound_port[] = old_host, old_port
+ rm(tmp; recursive=true, force=true)
+ end
+end
From b672663e9404e3e600afa6fae3d6a74d54323705 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 02:02:30 +0100
Subject: [PATCH 13/45] fix(server): admit the reached IP on a wildcard bind;
isolate the origin tests from the environment (#121)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
Both of CodeRabbit's functional-correctness findings from #120, accepted
and fixed.
**1. Wildcard-bind writes (Major).** On a wildcard bind
(`start(host="0.0.0.0")`) the origin allow-list excludes the wildcard
address and the Host check runs on loopback binds only, so a UI served
at e.g. `http://192.168.1.10:8080` received `403` on every write:
browsers send `Origin` on same-origin POSTs, and the served address was
not in `_allowed_origins()`. The guard now admits, on a wildcard bind
only, an origin that names the request's own IP: an `http://` origin
whose host is an **IP literal** (IPv4 or bracketed IPv6, never a name),
on the bound port, whose host-and-port equals the `Host` header. A
browser sends such an `Origin` only for a page it loaded from exactly
that address, so the request is same-origin by construction. A
DNS-rebinding page arrives with the attacker's domain as both `Host` and
`Origin`; a name is not an IP literal, so it is refused exactly as
before. Hostnames (e.g. `http://lab-box:8080`) and proxied ports still
have to be listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`.
**2. Test isolation (Minor).** The baseline origin assertions in
`test_server_security.jl` assumed `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`
was unset; the suite failed for a developer running it with the variable
set (a supported deployment setting). It is now cleared before the
testset and restored in the `finally`.
## Tests
A new "Wildcard bind" testset covers: the served IP admitted (GET with
CORS echo, preflight 204, a POST that reaches the route and fails there
with 400, not 403), an IPv6 literal admitted, other
addresses/ports/schemes refused, the DNS-rebinding shape (domain
matching its own Host) refused, and a listed hostname working only with
the variable set.
Verified locally, both with `JULIA_METAMANIFOLD_ALLOWED_ORIGINS` unset
(86/86 in this file; 2636 passed, 0 failed in the full unit suite) and
set to `http://localhost:3000, http://192.168.1.10:8080` (still 86/86;
the same run on `main` fails 4 tests).
## How to run the tests
```
julia --project=. test/runtests.jl test_server_security.jl
```
## Versions tested against
Julia 1.13.1 (local; CI installs the pinned 1.12.5), R 4.6.1
(conda-forge; lockfile pins 4.5.0). No frontend change.
## Notes
- The commit is unsigned — the signing key's passphrase was not
available in the session that produced it.
- Rejected the alternative CodeRabbit suggested (admitting a matching
request `Host` generally): a rebinding page matches its own Host too, so
only the IP-literal form is safe without new configuration.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
src/server/server.jl | 38 ++++++++++++++++++++++---
test/unit/test_server_security.jl | 47 +++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+), 4 deletions(-)
diff --git a/src/server/server.jl b/src/server/server.jl
index 0e8c2afd..880a3f84 100644
--- a/src/server/server.jl
+++ b/src/server/server.jl
@@ -245,15 +245,43 @@ module Server
allowed
end
+ _is_wildcard(h::AbstractString) = lowercase(h) in ("0.0.0.0", "::", "[::]")
+
+ """
+ _bound_ip_origin(origin, host_header) -> Bool
+
+ Whether `origin` names the exact IP address the browser reached a wildcard
+ bind on: an `http://` origin whose host is an IP literal (not a name), whose
+ port is the bound port, and whose host-and-port equals the request's `Host`
+ header. A browser only sends such an `Origin` for a page it loaded from that
+ address, so the request is same-origin by construction. A DNS-rebinding page
+ arrives with the attacker's domain as both `Host` and `Origin`; a name is not
+ an IP literal, so it is refused here as before. Hostnames (e.g.
+ `http://lab-box:8080`) and any address behind a proxy on another port must be
+ listed in `JULIA_METAMANIFOLD_ALLOWED_ORIGINS`.
+ """
+ function _bound_ip_origin(origin::AbstractString, host_header::AbstractString)
+ o = _normalise_origin(origin)
+ startswith(o, "http://") || return false
+ hostport = o[length("http://") + 1:end]
+ hostport == lowercase(strip(host_header)) || return false
+ m = match(r"^(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-f:]+\]):(\d+)$"i, hostport)
+ !isnothing(m) && parse(Int, m.captures[1]) == _bound_port[]
+ end
+
"""
_cors_middleware(next)
Return a request handler that answers 403 for a non-empty, non-loopback Host
on a loopback bind, or a non-empty `Origin` outside `_allowed_origins()` after
- normalisation. The Host check also applies to requests without an Origin.
- Allowed origins get CORS headers echoing the supplied Origin; OPTIONS gets
- 204 without calling `next`. Otherwise call `next(req)`, adding CORS headers
- only when the Origin is non-empty. Errors from `next` propagate.
+ normalisation. On a wildcard bind an origin naming the request's own IP host
+ on the bound port is also admitted (`_bound_ip_origin`): the UI is then
+ served, and writable, at whatever address the machine is reached on, without
+ trusting a hostname from the `Host` header. The Host check also applies to
+ requests without an Origin. Allowed origins get CORS headers echoing the
+ supplied Origin; OPTIONS gets 204 without calling `next`. Otherwise call
+ `next(req)`, adding CORS headers only when the Origin is non-empty. Errors
+ from `next` propagate.
"""
function _cors_middleware(next)
function(req::HTTP.Request)
@@ -268,6 +296,8 @@ module Server
return next(req)
end
_normalise_origin(origin) in _allowed_origins() ||
+ (_is_wildcard(_bound_host[]) &&
+ _bound_ip_origin(origin, HTTP.header(req, "Host", ""))) ||
return HTTP.Response(403, "Forbidden: origin not allowed")
cors_headers = [
"Access-Control-Allow-Origin" => origin,
diff --git a/test/unit/test_server_security.jl b/test/unit/test_server_security.jl
index 2bb7086d..9ef2fad6 100644
--- a/test/unit/test_server_security.jl
+++ b/test/unit/test_server_security.jl
@@ -34,6 +34,14 @@ end
SV.ServerState.set_root!(tmp)
SV._bound_host[] = "127.0.0.1"
SV._bound_port[] = 8080
+ # The baseline assertions below hold only with no extra origins
+ # configured. JULIA_METAMANIFOLD_ALLOWED_ORIGINS is a supported
+ # deployment setting (split deployments, tunnels), and a developer
+ # running the suite with it set would otherwise fail them, so it is
+ # cleared here and restored in the finally.
+ saved_origins = get(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS", nothing)
+ haskey(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS") &&
+ delete!(ENV, "JULIA_METAMANIFOLD_ALLOWED_ORIGINS")
mkpath(joinpath(tmp, "data", "StudyS", "run1"))
touch(joinpath(tmp, "data", "StudyS", "run1", "s1_R1.fastq.gz"))
mkpath(joinpath(tmp, "projects", "StudyS", "run1"))
@@ -77,6 +85,43 @@ end
@test _ss_request("GET", studies, ["Host" => "evil.example:8080"]).status == 403
end
+ @testset "Wildcard bind serves, and admits only, the reached IP" begin
+ studies = "/api/v1/studies"
+ SV._bound_host[] = "0.0.0.0"
+ lan = "Host" => "192.168.1.10:8080"
+ ui = "Origin" => "http://192.168.1.10:8080"
+ # The UI served at the machine's address: its origin is admitted,
+ # with CORS headers, a preflight answer, and writes that reach the
+ # route (this one fails in the route itself, not in the guard).
+ r = _ss_request("GET", studies, [lan, ui])
+ @test r.status == 200
+ @test SV.HTTP.header(r, "Access-Control-Allow-Origin") == "http://192.168.1.10:8080"
+ @test _ss_request("OPTIONS", studies, [lan, ui]).status == 204
+ @test _ss_request("POST", "/api/v1/studies/StudyS/report?kind=bogus&ext=.txt",
+ [lan, ui, "Content-Type" => "text/plain"], "x").status == 400
+ @test !isdir(joinpath(tmp, "projects", "StudyS", "report"))
+ # An IPv6 literal that matches its Host is admitted the same way.
+ @test _ss_request("GET", studies,
+ ["Host" => "[fd00::10]:8080", "Origin" => "http://[fd00::10]:8080"]).status == 200
+ # Another address, another port, or another scheme: still refused.
+ for o in ("http://192.168.1.11:8080", "http://192.168.1.10:9090",
+ "https://192.168.1.10:8080")
+ @test _ss_request("GET", studies, [lan, "Origin" => o]).status == 403
+ end
+ # A DNS-rebinding page: the domain matches its own Host, but a
+ # name is not an IP literal, so it is still refused.
+ @test _ss_request("GET", studies,
+ ["Host" => "evil.example:8080", "Origin" => "http://evil.example:8080"]).status == 403
+ # A hostname the machine is reached by still has to be listed.
+ withenv("JULIA_METAMANIFOLD_ALLOWED_ORIGINS" => "http://lab-box:8080") do
+ @test _ss_request("GET", studies,
+ ["Host" => "lab-box:8080", "Origin" => "http://lab-box:8080"]).status == 200
+ end
+ @test _ss_request("GET", studies,
+ ["Host" => "lab-box:8080", "Origin" => "http://lab-box:8080"]).status == 403
+ SV._bound_host[] = "127.0.0.1"
+ end
+
@testset "Oxygen docs and metrics are not served" begin
@test SV._SERVE_OPTIONS.docs === false
@test SV._SERVE_OPTIONS.metrics === false
@@ -121,6 +166,8 @@ end
@test SV.HTTP.header(f, "Content-Security-Policy") == ""
end
finally
+ saved_origins === nothing ||
+ (ENV["JULIA_METAMANIFOLD_ALLOWED_ORIGINS"] = saved_origins)
SV.ServerState._root[] = old_root
SV._bound_host[], SV._bound_port[] = old_host, old_port
rm(tmp; recursive=true, force=true)
From 9360cb9c315f32b8661d274895e6295ce2a07bc5 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 02:06:37 +0100
Subject: [PATCH 14/45] chore(dependabot): configure version updates for bun
and github-actions (#122)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
Adds `.github/dependabot.yml` — the repository never had one, which is
why Dependabot produced no version updates. (The "Dependabot Updates"
workflow runs that did exist were *security* updates, which need no
config; they failed 2026-09-26..27 because the `npm_and_yarn` updater
refuses a Bun-managed project — `bun.lock` with no npm/yarn/pnpm
lockfile — while trying to fix alerts against the since-removed
`test/doi` fixtures. Both alerts are already fixed.)
## Configuration
| Ecosystem | Directory | Cadence | Notes |
|---|---|---|---|
| `bun` | `/frontend` | weekly | The ecosystem Dependabot requires for
`bun.lock`; `npm_and_yarn` cannot update it |
| `github-actions` | `/` | weekly | Advances the full-SHA action pins;
the account's allowlist policy (only `actions/*` and `hyperpolymath/*`,
asserted by `test/unit/test_install_pins.jl`) survives, since Dependabot
updates the existing pinned refs |
Both grouped and prefixed `chore(deps)`.
## What Dependabot cannot cover here
Julia (`Project.toml`/`Manifest.toml`) and R (`renv.lock`) are not
Dependabot ecosystems. Their pins are managed by
`config/defaults/tool_versions.yml` and asserted by
`test/unit/test_install_pins.jl` and the CI workflow — that stays the
mechanism.
## Testing
Config-only change; no code path is touched. Dependabot validates the
file on merge and the first "Dependabot Updates" run will appear on the
default branch within the hour.
## Notes
The commit is unsigned — the signing key's passphrase was not available
in the session that produced it.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/dependabot.yml | 39 +++++++++++++++++++++++++++++++++++++++
1 file changed, 39 insertions(+)
create mode 100644 .github/dependabot.yml
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..1454ca3b
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,39 @@
+# Dependabot version updates. Until this file landed, only security updates
+# ran (those need no configuration), and those failed for a separate reason:
+# the "Dependabot Updates" runs of 2026-09-26..27 tried to fix alerts against
+# fixtures under test/doi using the npm_and_yarn updater, which refuses a
+# Bun-managed project ("bun.lock but no package-lock.json"). Those fixtures
+# have since left the repository and both alerts are fixed.
+#
+# What cannot be covered here: Julia (Project.toml / Manifest.toml) and R
+# (renv.lock) are not Dependabot package-ecosystems. Their pins live in
+# config/defaults/tool_versions.yml and are asserted by
+# test/unit/test_install_pins.jl and the CI workflow instead.
+#
+# The workflows pin every action to a full commit SHA (this account's
+# allowlist policy, asserted by test/unit/test_install_pins.jl). Dependabot
+# advances those pins to the current SHA of the tracked version, so the
+# pinning policy itself survives updates.
+version: 2
+updates:
+ # The frontend is Bun-managed: package.json plus bun.lock, no npm/yarn/pnpm
+ # lockfile, so it must be declared as the "bun" ecosystem.
+ - package-ecosystem: "bun"
+ directory: "/frontend"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "chore(deps)"
+ groups:
+ frontend:
+ patterns: ["*"]
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ commit-message:
+ prefix: "chore(deps)"
+ groups:
+ actions:
+ patterns: ["*"]
From ebebed2ea67132a3a456074144e1e03decfd042e Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 02:59:34 +0100
Subject: [PATCH 15/45] chore(root): tidy the repository root to the RSR basic
scheme (#126)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
The root currently holds 24 entries, several of which are loose scripts
and config files. This applies the root-cleanup doctrine of the RSR
template (`rsr-template-repo`, `docs/proposals/root-cleanup.adoc`): the
root holds authority files, the entry points a newcomer runs,
tool-mandated build metadata, and one directory per concern — nothing
else.
| Move | Why |
|---|---|
| `CONTRIBUTING.md` → `.github/` | RSR health-files doctrine; GitHub
auto-discovers it there |
| `codecov.yml` → `.github/codecov.yml` | Codecov's documented
alternative location; inert on this fork (coverage is kept as a run
artifact, the Codecov project is upstream's) |
| `install.jl` → `scripts/` | 49 KB, invoked only by `install.sh` and
the pin tests. `install.sh` stays at the root — the RSR rule that the
one file a newcomer runs stays at the root |
| `precompile_exec.jl` → `scripts/` | Referenced only by `install.jl` |
Plus a new testset, **"the root keeps its shape"**: a *bidirectional*
allowlist, per the doctrine that a one-directional allowlist only ever
ratchets open. A tracked root entry not listed there fails the suite; a
listed entry that goes missing fails it too. It reads the tracked tree
(`git ls-tree HEAD`), so local untracked build output (`bin/`, `web/`)
does not fail a developer's run.
**Kept at the root because the tooling requires it** — not clutter:
`Project.toml`/`Manifest.toml` (Julia Pkg), `renv.lock`, `renv/`,
`.Rprofile`, `R/` (renv scans `R/` for dependency discovery),
`CITATION.cff` (GitHub reads it from the default-branch root only —
moving it breaks the citation metadata), and the two launchers
`install.sh` / `start.sh`.
Reference updates: `install.sh`'s handoff (`julia --project=.
scripts/install.jl`), the pin tests' `INSTALL_JL`, `install.jl`'s usage
lines and `PRECOMPILE_EXEC_PATH`, the sysimage failure hint, the
README's mention of the installer path. `PROJECT_ROOT` in `install.jl`
now resolves to the repo root from `scripts/`.
## How to run the tests
```
julia --project=. test/runtests.jl test_install_pins.jl
```
## Versions tested against
Julia 1.13.1 (local; CI installs the pinned 1.12.5). Full unit suite
locally: 2658 passed, 0 failed (the one error is the live probe for the
`vegan` R package, absent from the local conda R; CI installs it). The
new root-shape testset: 23/23. Shell syntax of both launchers re-checked
(`bash -n`).
## Notes
- Divergence cost, stated plainly: upstream's root layout is the same as
this one was, so the moved paths will conflict at the next upstream sync
(rename/delete pairs on four files). The conflict set is small and
mechanical; the absorption register should record the relocations so a
future sync resolves them deliberately.
- Renames to full RSR names (`test/`→`tests/`, `bench/`→`benches/`,
`proofs/`→`verification/`) are deliberately NOT in this PR: cosmetic
value, but they would put every test and proof file in permanent
rename/delete conflict with upstream. Recommended only if the layout
change is proposed upstream as a unit, not as a fork-side divergence.
- The commit is unsigned — the signing key's passphrase was not
available in the session that produced it.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
CONTRIBUTING.md => .github/CONTRIBUTING.md | 0
codecov.yml => .github/codecov.yml | 0
README.md | 2 +-
install.sh | 2 +-
install.jl => scripts/install.jl | 8 ++--
.../precompile_exec.jl | 0
test/unit/test_install_pins.jl | 40 ++++++++++++++++++-
7 files changed, 45 insertions(+), 7 deletions(-)
rename CONTRIBUTING.md => .github/CONTRIBUTING.md (100%)
rename codecov.yml => .github/codecov.yml (100%)
rename install.jl => scripts/install.jl (99%)
rename precompile_exec.jl => scripts/precompile_exec.jl (100%)
diff --git a/CONTRIBUTING.md b/.github/CONTRIBUTING.md
similarity index 100%
rename from CONTRIBUTING.md
rename to .github/CONTRIBUTING.md
diff --git a/codecov.yml b/.github/codecov.yml
similarity index 100%
rename from codecov.yml
rename to .github/codecov.yml
diff --git a/README.md b/README.md
index 1fe02ca8..bdf25c98 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ bash install.sh
```
`install.sh` installs Julia (via juliaup) if missing, checks for R, then hands
-off to `install.jl`, which installs the Julia dependencies, locates or downloads
+off to `scripts/install.jl`, which installs the Julia dependencies, locates or downloads
each external tool (cutadapt, FastQC, MultiQC, vsearch, cd-hit-est, and the
phylogeny tools MAFFT, trimAl, IQ-TREE, RAxML and gappa), and reproduces the R
environment from `renv.lock`. It ends with a summary listing every dependency as
diff --git a/install.sh b/install.sh
index c5e0a18e..a8e0b718 100755
--- a/install.sh
+++ b/install.sh
@@ -283,4 +283,4 @@ fi
echo ""
echo "Running install.jl..."
echo ""
-julia --project=. install.jl "$@"
+julia --project=. scripts/install.jl "$@"
diff --git a/install.jl b/scripts/install.jl
similarity index 99%
rename from install.jl
rename to scripts/install.jl
index 7e835e62..f3465b3b 100644
--- a/install.jl
+++ b/scripts/install.jl
@@ -6,7 +6,7 @@
# required R packages. Writes resolved tool paths to config/tools.yml.
#
# Usage via install.sh, or directly by:
-# julia --project=. install.jl [--update] [--modify] [--sysimage]
+# julia --project=. scripts/install.jl [--update] [--modify] [--sysimage]
#
# Options:
# --update Re-assert the pinned tool versions and refresh managed binaries in bin/
@@ -41,7 +41,7 @@ import Downloads
const UPDATE_MODE = "--update" in ARGS
const MODIFY_MODE = "--modify" in ARGS
const SYSIMAGE_MODE = "--sysimage" in ARGS
-const PROJECT_ROOT = @__DIR__
+const PROJECT_ROOT = normpath(dirname(@__DIR__))
const BIN_DIR = joinpath(PROJECT_ROOT, "bin")
const CONFIG_DIR = joinpath(PROJECT_ROOT, "config")
const TOOLS_CONFIG = joinpath(CONFIG_DIR, "tools.yml")
@@ -1025,7 +1025,7 @@ end
## Sysimage creation
const SYSIMAGE_EXT = Sys.isapple() ? ".dylib" : ".so"
const SYSIMAGE_PATH = joinpath(PROJECT_ROOT, "MetaManifold$(SYSIMAGE_EXT)")
-const PRECOMPILE_EXEC_PATH = joinpath(PROJECT_ROOT, "precompile_exec.jl")
+const PRECOMPILE_EXEC_PATH = joinpath(@__DIR__, "precompile_exec.jl")
function build_sysimage()
# PackageCompiler is a project dependency, installed by Pkg.instantiate().
@@ -1247,7 +1247,7 @@ function main()
record!("Julia sysimage", STEP_OK, SYSIMAGE_PATH)
catch e
@error "Sysimage build failed: $e"
- record!("Julia sysimage", STEP_FAILED, "rerun: julia --project=. install.jl --sysimage")
+ record!("Julia sysimage", STEP_FAILED, "rerun: julia --project=. scripts/install.jl --sysimage")
end
else
record!("Julia sysimage", STEP_OK, "not requested (pass --sysimage to build one)")
diff --git a/precompile_exec.jl b/scripts/precompile_exec.jl
similarity index 100%
rename from precompile_exec.jl
rename to scripts/precompile_exec.jl
diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl
index 177e8f8a..3d84086d 100644
--- a/test/unit/test_install_pins.jl
+++ b/test/unit/test_install_pins.jl
@@ -14,7 +14,7 @@ using SHA
const REPO_ROOT = normpath(joinpath(@__DIR__, "..", ".."))
const PINS_PATH = joinpath(REPO_ROOT, "config", "defaults", "tool_versions.yml")
-const INSTALL_JL = joinpath(REPO_ROOT, "install.jl")
+const INSTALL_JL = joinpath(REPO_ROOT, "scripts", "install.jl")
const CI_PATH = joinpath(REPO_ROOT, ".github", "workflows", "ci.yml")
const WORKFLOWS = joinpath(REPO_ROOT, ".github", "workflows")
const MANIFEST = joinpath(REPO_ROOT, "Manifest.toml")
@@ -308,3 +308,41 @@ allowed_action(uses) =
end
end
end
+
+@testset "the root keeps its shape" begin
+ # Adapted from the RSR root-cleanup doctrine (rsr-template-repo,
+ # docs/proposals/root-cleanup.adoc): the repository root holds authority
+ # files, the entry points a newcomer runs, tool-mandated build metadata, and
+ # one directory per concern -- nothing else. The check is BIDIRECTIONAL,
+ # because a one-directional allowlist only ever ratchets open: a tracked
+ # root entry that is not listed here is drift, and a listed entry that has
+ # gone missing is drift too. The tracked tree is what is checked, so local
+ # untracked build output (bin/, web/) does not fail a developer's run. To
+ # add a root entry, add it here with its reason in the same commit.
+ root_entries = [
+ # Authority files. GitHub reads CITATION.cff from the default-branch
+ # root only, so it cannot move.
+ "README.md", "LICENSE", "CITATION.cff",
+ # The two entry points a newcomer runs (the launcher stays at the
+ # root); the installer itself lives under scripts/.
+ "install.sh", "start.sh",
+ # Build metadata rooted by its tooling: Julia Pkg, and renv, which
+ # also scans R/ for dependency discovery.
+ "Project.toml", "Manifest.toml", "renv.lock", "renv", ".Rprofile", "R",
+ # One directory per concern. Health files (CONTRIBUTING.md) and CI
+ # configs (codecov.yml) live under .github/.
+ ".github", "bench", "config", "data", "docs", "frontend",
+ "proofs", "scripts", "src", "test",
+ # VCS housekeeping.
+ ".gitignore",
+ ]
+ tracked = Set(split(readchomp(`git -C $(REPO_ROOT) ls-tree --name-only HEAD`), "\n"))
+ for entry in root_entries
+ @test entry in tracked
+ end
+ extras = sort(collect(setdiff(tracked, Set(root_entries))))
+ if !isempty(extras)
+ println("root entries outside the allowlist: ", join(extras, ", "))
+ end
+ @test isempty(extras)
+end
From 5be5a8b3d54a491d3a5b8574bbc8b7bc4c935c97 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 15 Oct 2026 03:05:36 +0100
Subject: [PATCH 16/45] chore(deps): bump the actions group with 3 updates
(#123)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the actions group with 3 updates:
[actions/checkout](https://github.com/actions/checkout),
[actions/cache/restore](https://github.com/actions/cache) and
[actions/cache/save](https://github.com/actions/cache).
Updates `actions/checkout` from 4.4.0 to 7.0.1
Release notes
Sourced from actions/checkout's
releases .
v7.0.1
What's Changed
Full Changelog : https://github.com/actions/checkout/compare/v7...v7.0.1
v7.0.0
What's Changed
New Contributors
Full Changelog : https://github.com/actions/checkout/compare/v6.0.3...v7.0.0
v6.1.0
What's Changed
https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/
for more details about this breaking change
Full Changelog : https://github.com/actions/checkout/compare/v6.0.3...v6.1.0
v6.0.3
What's Changed
New Contributors
Full Changelog : https://github.com/actions/checkout/compare/v6...v6.0.3
v6.0.2
What's Changed
... (truncated)
Changelog
Sourced from actions/checkout's
changelog .
Changelog
v7.0.1
v7.0.0
v6.0.3
v6.0.2
v6.0.1
v6.0.0
v5.0.1
v5.0.0
v4.3.1
v4.3.0
v4.2.2
v4.2.1
... (truncated)
Commits
Updates `actions/cache/restore` from 4.3.0 to 6.1.0
Release notes
Sourced from actions/cache/restore's
releases .
v6.1.0
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v6...v6.1.0
v6.0.0
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v5...v6.0.0
v5.1.0
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v5...v5.1.0
v5.0.5
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v5...v5.0.5
v5.0.4
What's Changed
New Contributors
Full Changelog : https://github.com/actions/cache/compare/v5...v5.0.4
v5.0.3
What's Changed
... (truncated)
Changelog
Sourced from actions/cache/restore's
changelog .
Releases
How to prepare a release
[!NOTE]
Relevant for maintainers with write access only.
Switch to a new branch from main.
Run npm test to ensure all tests are passing.
Update the version in https://github.com/actions/cache/blob/main/package.json .
Run npm run build to update the compiled files.
Update this https://github.com/actions/cache/blob/main/RELEASES.md
with the new version and changes in the ## Changelog
section.
Run licensed cache to update the license report.
Run licensed status and resolve any warnings by
updating the https://github.com/actions/cache/blob/main/.licensed.yml
file with the exceptions.
Commit your changes and push your branch upstream.
Open a pull request against main and get it reviewed
and merged.
Draft a new release https://github.com/actions/cache/releases
use the same version number used in package.json
Create a new tag with the version number.
Auto generate release notes and update them to match the changes you
made in RELEASES.md.
Toggle the set as the latest release option.
Publish the release.
Navigate to https://github.com/actions/cache/actions/workflows/release-new-action-version.yml
There should be a workflow run queued with the same version
number.
Approve the run to publish the new version and update the major tags
for this action.
Changelog
6.1.0
6.0.0
Updated @actions/cache to ^6.0.1,
@actions/core to ^3.0.1, @actions/exec to
^3.0.0, @actions/io to ^3.0.2
Migrated to ESM module system
Upgraded Jest to v30 and test infrastructure to be ESM
compatible
5.0.4
Bump minimatch to v3.1.5 (fixes ReDoS via globstar
patterns)
Bump undici to v6.24.1 (WebSocket decompression bomb
protection, header validation fixes)
Bump fast-xml-parser to v5.5.6
5.0.3
5.0.2
... (truncated)
Commits
55cc834
Merge pull request #1768
from jasongin/readonly-cache
d8cd72f
Bump @actions/cache to v6.1.0 - handle cache write error
due to RO token
2c8a9bd
Merge pull request #1760
from actions/samirat/esm_migration_and_package_update
e9b91fd
Prettier fixes
e4884b8
Rebuild dist
10baf01
Fixed licenses
e39b386
Fix test mock return order
b692820
PR feedback
6074912
Rebuild dist bundles as ESM to match type:module
5a912e8
Fix lint and jest issues
Additional commits viewable in compare
view
Updates `actions/cache/save` from 4.3.0 to 6.1.0
Release notes
Sourced from actions/cache/save's
releases .
v6.1.0
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v6...v6.1.0
v6.0.0
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v5...v6.0.0
v5.1.0
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v5...v5.1.0
v5.0.5
What's Changed
Full Changelog : https://github.com/actions/cache/compare/v5...v5.0.5
v5.0.4
What's Changed
New Contributors
Full Changelog : https://github.com/actions/cache/compare/v5...v5.0.4
v5.0.3
What's Changed
... (truncated)
Changelog
Sourced from actions/cache/save's
changelog .
Releases
How to prepare a release
[!NOTE]
Relevant for maintainers with write access only.
Switch to a new branch from main.
Run npm test to ensure all tests are passing.
Update the version in https://github.com/actions/cache/blob/main/package.json .
Run npm run build to update the compiled files.
Update this https://github.com/actions/cache/blob/main/RELEASES.md
with the new version and changes in the ## Changelog
section.
Run licensed cache to update the license report.
Run licensed status and resolve any warnings by
updating the https://github.com/actions/cache/blob/main/.licensed.yml
file with the exceptions.
Commit your changes and push your branch upstream.
Open a pull request against main and get it reviewed
and merged.
Draft a new release https://github.com/actions/cache/releases
use the same version number used in package.json
Create a new tag with the version number.
Auto generate release notes and update them to match the changes you
made in RELEASES.md.
Toggle the set as the latest release option.
Publish the release.
Navigate to https://github.com/actions/cache/actions/workflows/release-new-action-version.yml
There should be a workflow run queued with the same version
number.
Approve the run to publish the new version and update the major tags
for this action.
Changelog
6.1.0
6.0.0
Updated @actions/cache to ^6.0.1,
@actions/core to ^3.0.1, @actions/exec to
^3.0.0, @actions/io to ^3.0.2
Migrated to ESM module system
Upgraded Jest to v30 and test infrastructure to be ESM
compatible
5.0.4
Bump minimatch to v3.1.5 (fixes ReDoS via globstar
patterns)
Bump undici to v6.24.1 (WebSocket decompression bomb
protection, header validation fixes)
Bump fast-xml-parser to v5.5.6
5.0.3
5.0.2
... (truncated)
Commits
55cc834
Merge pull request #1768
from jasongin/readonly-cache
d8cd72f
Bump @actions/cache to v6.1.0 - handle cache write error
due to RO token
2c8a9bd
Merge pull request #1760
from actions/samirat/esm_migration_and_package_update
e9b91fd
Prettier fixes
e4884b8
Rebuild dist
10baf01
Fixed licenses
e39b386
Fix test mock return order
b692820
PR feedback
6074912
Rebuild dist bundles as ESM to match type:module
5a912e8
Fix lint and jest issues
Additional commits viewable in compare
view
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore ` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore ` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore ` will
remove the ignore condition of the specified dependency and ignore
conditions
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
---
.github/workflows/ci.yml | 12 ++++++------
.github/workflows/occupancy.yml | 4 ++--
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cddd00c3..20262517 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -73,7 +73,7 @@ jobs:
JULIA_SHA256: 41b84d727e4e96fbf3ed9e92fa195d773d247b9097f73fad688f8b699758bae7
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # nothing here pushes
@@ -92,7 +92,7 @@ jobs:
# has succeeded, so a failed run cannot store a half-built depot.
- name: Restore the Julia depot
id: julia-depot
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.julia/registries
@@ -175,7 +175,7 @@ jobs:
# the restore runs as root. The run id makes each key unique so every attempt
# saves its progress; restore-keys picks the newest match.
- name: Restore the renv cache
- uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /opt/renv-cache
key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }}
@@ -202,7 +202,7 @@ jobs:
- name: Save the renv cache
if: always()
- uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /opt/renv-cache
key: renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-${{ hashFiles('renv.lock') }}-${{ github.run_id }}
@@ -259,7 +259,7 @@ jobs:
- name: Save the Julia depot
if: steps.julia-depot.outputs.cache-hit != 'true'
- uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.julia/registries
@@ -380,7 +380,7 @@ jobs:
env:
DEBIAN_FRONTEND: noninteractive
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # nothing here pushes
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
index 8e1fdf10..9dba6fbe 100644
--- a/.github/workflows/occupancy.yml
+++ b/.github/workflows/occupancy.yml
@@ -70,7 +70,7 @@ jobs:
# As in ci.yml: the root .Rprofile would otherwise bootstrap renv.
RENV_CONFIG_AUTOLOADER_ENABLED: "FALSE"
steps:
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
@@ -139,7 +139,7 @@ jobs:
apt-get install -y --no-install-recommends ca-certificates curl git agda-bin=2.6.4.3-1+b2
agda --version | grep -qx 'Agda version 2.6.4.3'
- - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # the siblings are public; nothing here pushes
From 15a6b5db4dbf1fe307457dfb78ced38ab2231839 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 15 Oct 2026 03:06:07 +0100
Subject: [PATCH 17/45] chore(deps): bump the frontend group in /frontend with
10 updates (#124)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps the frontend group in /frontend with 10 updates:
| Package | From | To |
| --- | --- | --- |
| [plotly.js-dist-min](https://github.com/plotly/plotly.js) | `2.35.3` |
`4.1.2` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) |
`18.3.1` | `19.3.0` |
|
[@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react)
| `18.3.28` | `19.3.0` |
|
[react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom)
| `18.3.1` | `19.3.0` |
|
[@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom)
| `18.3.7` | `19.3.0` |
|
[react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom)
| `6.30.3` | `7.18.4` |
|
[@types/plotly.js](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/plotly.js)
| `2.35.14` | `3.0.15` |
|
[@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react)
| `4.7.0` | `6.1.2` |
| [typescript](https://github.com/microsoft/TypeScript) | `5.9.3` |
`7.0.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) |
`6.4.1` | `8.3.4` |
Updates `plotly.js-dist-min` from 2.35.3 to 4.1.2
Release notes
Sourced from plotly.js-dist-min's
releases .
v4.1.2
Changed
Use ResizeObserver for responsive chart resizing
instead of listening for window resize events [#8017 ],
with thanks to @Lexachoc for the
contribution!
Describe config.toImageButtonOptions in the plot schema
[#8066 ]
Fixed
Fix modebar hover colors not updating after color or
activecolor changes [#8024 ],
with thanks to @Lexachoc for the
contribution!
Correct the image export types: format now accepts
full-json, width and height
accept null, and ToImgopts.format,
ToImgopts.width, ToImgopts.height and
DownloadImgopts.filename are optional because
toImage and downloadImage supply them [#8066 ]
Pick the trace drawn last on a hover tie in scattergl
and splom (matching scatter) [#8079 ]
Allow typed arrays in the types for arrayOk number and
color attributes [#8087 ]
Redraw plots when a font that they use finishes loading [#8094 ]
v4.1.1
Changed
Update maplibre-gl to v6 to address CVE-2026-85061
[#8035 ]
maplibre-gl v6 dropped WebGL1 support, so some older
browsers won't be able to use the map traces. Safari 15, Chrome 56,
Firefox 51 and later are now required for the map traces.
Fixed
Box and lasso selection of scattermap points are now
supported on a rotated or pitched map [#8035 ]
v4.1.0
Added
Add an opt-in modebar button for downloading Plotly figures as JSON
[#7990 ,
#8022 ],
with thanks to @gokul-debugger
for the contribution!
Add legend.groupdoubleclick to set the group behavior
for a legend double-click [#7997 ],
with thanks to @rascal-sl for the
contribution!
Changed
Increase default double-click delay threshold to 500ms (from 300)
[#8014 ]
Fixed
Correct the showspikes axis attribute description to
clarify that it applies for all hovermode values except
when hovermode is false [#7981 ],
with thanks for @CAOShurong for
the contribution!
Update country-iso-search to v0.1.2 to fix issue with UTF-8
characters being decoded incorrectly [#7994 ]
Compile TypeScript files under src/ to JavaScript
during packaging to fix Node resolution [#8000 ]
Include type defs in appropriate partial bundles [#8001 ]
Add TypeScript declarations for the modular lib/ entry
points [#8009 ]
Handle regex enum values when generating schema types [#8010 ]
Include trace-contributed layout attributes in generated types [#8020 ]
Resolve the per-point marker color for hover labels in
scattergl, quiver traces [#8027 ]
v4.0.0
Added
Add minscale, maxscale geo plot attributes
[#7371 ],
with thanks to @mojoaxel for the
contribution!
Enable TypeScript compatibility within the library and start
exporting types [#7680 ]
Add quiver trace type to visualize vector fields using
arrows [#7710 ,
#7945 ],
with thanks to @degzhaus for the
contribution!
Use dashed markers in legend for shape traces with dash configured
[#7845 ]
Add direction attribute to the Sankey trace,
controlling the flow direction along the orientation axis
[#7870 ],
with thanks to @wf-r
for the contribution!
forward keeps sources on the left (horizontal) or top
(vertical)
reversed moves them to the right or bottom
Add sort option to Sankey links and nodes [#7873 ],
with thanks to @adamreeve for the
contribution!
Add support for MathJax v4 [#7898 ]
Add top-level xPixel and yPixel keys to
hover and click event data, corresponding to the pixel position of the
cursor relative to the top-left corner of the graph div [#7966 ]
... (truncated)
Changelog
Sourced from plotly.js-dist-min's
changelog .
[4.1.2] -- 2026-10-05
Changed
Use ResizeObserver for responsive chart resizing
instead of listening for window resize events [#8017 ],
with thanks to @Lexachoc for the
contribution!
Describe config.toImageButtonOptions in the plot schema
[#8066 ]
Fixed
Fix modebar hover colors not updating after color or
activecolor changes [#8024 ],
with thanks to @Lexachoc for the
contribution!
Correct the image export types: format now accepts
full-json, width and height
accept null, and ToImgopts.format,
ToImgopts.width, ToImgopts.height and
DownloadImgopts.filename are optional because
toImage and downloadImage supply them [#8066 ]
Pick the trace drawn last on a hover tie in scattergl
and splom (matching scatter) [#8079 ]
Allow typed arrays in the types for arrayOk number and
color attributes [#8087 ]
Redraw plots when a font that they use finishes loading [#8094 ]
[4.1.1] -- 2026-09-14
Changed
Update maplibre-gl to v6 to address CVE-2026-85061
[#8035 ]
maplibre-gl v6 dropped WebGL1 support, so some older
browsers won't be able to use the map traces. Safari 15, Chrome 56,
Firefox 51 and later are now required for the map traces.
Fixed
Box and lasso selection of scattermap points are now
supported on a rotated or pitched map [#8035 ]
[4.1.0] -- 2026-09-08
Added
Add an opt-in modebar button for downloading Plotly figures as JSON
[#7990 ,
#8022 ],
with thanks to @gokul-debugger
for the contribution!
Add legend.groupdoubleclick to set the group behavior
for a legend double-click [#7997 ],
with thanks to @rascal-sl for the
contribution!
Changed
Increase default double-click delay threshold to 500ms (from 300)
[#8014 ]
Fixed
Correct the showspikes axis attribute description to
clarify that it applies for all hovermode values except
when hovermode is false [#7981 ],
with thanks for @CAOShurong for
the contribution!
Update country-iso-search to v0.1.2 to fix issue with UTF-8
characters being decoded incorrectly [#7994 ]
Compile TypeScript files under src/ to JavaScript
during packaging to fix Node resolution [#8000 ]
Include type defs in appropriate partial bundles [#8001 ]
Add TypeScript declarations for the modular lib/ entry
points [#8009 ]
Handle regex enum values when generating schema types [#8010 ]
Include trace-contributed layout attributes in generated types [#8020 ]
Resolve the per-point marker color for hover labels in
scattergl, quiver traces [#8027 ]
[4.0.0] -- 2026-08-24
Added
Add minscale, maxscale geo plot attributes
[#7371 ],
with thanks to @mojoaxel for the
contribution!
Enable TypeScript compatibility within the library and start
exporting types [#7680 ]
Add quiver trace type to visualize vector fields using
arrows [#7710 ,
#7945 ],
with thanks to @degzhaus for the
contribution!
... (truncated)
Commits
bea2af3
4.1.2
9a72399
chore: Updates for release v4.1.2
dc94a20
chore: Update maplibre worker
bf76578
chore: Update dependencies, drop extra-iterable
67cc7fa
Merge pull request #8094
from plotly/cam/8089/replot-after-font-load
c281d99
Be more general in font check
b2aa6c2
Use promises for tracking font loading because of Safari
limitations
aa44890
Add draftlog
dd658b1
Update internal GraphDiv type
351fe89
Add tests
Additional commits viewable in compare
view
Maintainer changes
This version was pushed to npm by camdecoster , a new
releaser for plotly.js-dist-min since your current version.
Updates `react` from 18.3.1 to 19.3.0
Release notes
Sourced from react's
releases .
19.3.0 (September 9, 2026)
Below is a list of all new features, APIs, and bug fixes.
Read the React
19.3 release post for more information.
New React Features
<ViewTransition />: Adds <ViewTransition
/> and addTransitionType APIs to power View
Transition animations in React (@sebmarkbage , @jackpope , @gaearon : #31975 ,
#31987 ,
#31996 ,
#31999 ,
#32001 ,
#32002 ,
#32028 ,
#32029 ,
#32031 ,
#32034 ,
#32038 ,
#32041 ,
#32050 ,
#32090 ,
#32105 ,
#32254 ,
#32379 ,
#32422 ,
#32462 ,
#32540 ,
#32545 ,
#32585 ,
#32599 ,
#32611 ,
#32612 ,
#32617 ,
#32651 ,
#32653 ,
#32656 ,
#32664 ,
#32699 ,
#32723 ,
#32734 ,
#32751 ,
#32752 ,
#32760 ,
#32761 ,
#32764 ,
#32772 ,
#32790 ,
#32819 ,
#32820 ,
#32822 ,
#32833 ,
#32849 ,
#33094 ,
#33191 ,
#33200 ,
#33206 ,
#33293 ,
#33330 ,
#33331 ,
#33332 ,
#33357 ,
#33362 ,
#33433 ,
#33576 ,
#34374 ,
#34450 ,
#34481 ,
#34500 ,
#34502 ,
#34510 ,
#34511 ,
#34539 ,
#35567 ,
#35564 ,
#35485 ,
#35380 ,
#35063 ,
#35060 ,
#34676 ,
#36917 ,
#35337 ,
#35520 )
Fragment Refs: Add Refs to <Fragment /> to
support composable platform behavior (@jackpope , @sebmarkbage , @eps1lon , @Dhakshin2007 ,
@chirokas , @teamleaderleo ,
@fallintoplace :
#32465 ,
#32613 ,
#32619 ,
#32654 ,
#32660 ,
#32682 ,
#32722 ,
#32813 ,
#32814 ,
#33056 ,
#33058 ,
#33093 ,
#34069 ,
#34103 ,
#34544 ,
#34545 ,
#37062 ,
#37061 ,
#37060 ,
#36047 ,
#36010 ,
#35642 ,
#35641 ,
#35637 ,
#35630 ,
#34935 ,
#37457 ,
#37408 ,
#37326 ,
#37251 ,
#37171 ,
#37169 ,
#37168 ,
#37167 ,
#37166 ,
#37165 ,
#37164 ,
#37163 ,
#37162 ,
#37161 ,
#37160 ,
#37125 ,
#37063 )
New React DOM Features
browser(): a new react-dom API that
returns a usable which errors during server rendering and resolves in
the browser. use(browser()) inside a
<Suspense> boundary marks a subtree as browser-only
without reporting a recoverable error (@gnoff : #37143 ,
#37241 )
Added an onBrowserBailout option to the
react-dom/server APIs to observe when a subtree defers to
the browser (@gnoff
#37193 )
Notable changes
Enable Trusted Types API integration (@rickhanlonii
#35816 )
Transitions now render independently instead of being entangled into
a single render, so a slow transition no longer holds up unrelated ones
(@acdlite #37290 )
Added a DEV-only warning when a component appears to have been
unblocked by calling use() conditionally (@hoxyq , @eps1lon : #37104 ,
#37203 ,
#37491 )
All Changes
React
Fast Refresh Fixes
Fix Fast Refresh to find and remount edits to components wrapped
behind lazy() (@sophiebits #36965 )
Fix Fast Refresh so edits to a memo() comparison
function take effect (@sophiebits #36964 )
Fix Fast Refresh crash when an edit changes the kind of a
component's type (@sophiebits #36963 )
Unify hot reload type resolution for Fast Refresh (@sophiebits #36962 )
Fix Fast Refresh to remount correctly when an edit changes the
component kind (@sophiebits #36950 )
Double invoke effects in StrictMode after Fast Refresh (@eps1lon #35962 )
Performance Track Fixes
Prevent crash when accessing $$typeof in Performance
Tracks (@eps1lon
#35679 )
Handle non-string function names in Performance Tracks (@eps1lon #35659 )
Use minus (-) instead of en dash for removed props in
Performance Tracks (@eps1lon #35649 )
Handle arrays with bigints in deep objects in Performance Tracks (@eps1lon #35648 )
Don't enumerate typed array props in Performance Tracks in DEV (@UditDewan #36913 )
Bail out of diffing wide objects and arrays in Performance Tracks
(@eps1lon #34742 )
Clear potentially large performance measures in DEV (@hoxyq #34803 )
Fix missing else branch for renders with no props change in
Performance Tracks (@hoxyq #34837 )
Activity Fixes
Fix useSyncExternalStore missing store mutations that
happened while an Activity tree was hidden (@sophiebits #36947 )
Hide portal contents when an Activity is hidden (@acdlite #35091 )
Prevent metadata hoisting in hidden <Activity>
trees (@ronnakamoto #34983 )
Prevent errors thrown inside a hidden Activity from escaping to the
visible UI (@acdlite #35074 )
Don't unhide a node if a direct parent Offscreen is still hidden (@sebmarkbage #34821 )
Don't show internal <Offscreen> component in
error messages (@rickhanlonii
#35763 )
Warn in DEV when a component appears to have been unblocked by a
conditional use() (@hoxyq , @eps1lon : #37104 ,
#37203 ,
#37491 )
Render transitions independently instead of entangling them into a
single render (@acdlite #37290 )
... (truncated)
Changelog
Sourced from react's
changelog .
19.3.0 (September 9, 2026)
New React Features
<ViewTransition />: Adds <ViewTransition
/> and addTransitionType APIs to power View
Transition animations in React (@sebmarkbage , @jackpope , @gaearon : #31975 , #31987 , #31996 , #31999 , #32001 , #32002 , #32028 , #32029 , #32031 , #32034 , #32038 , #32041 , #32050 , #32090 , #32105 , #32254 , #32379 , #32422 , #32462 , #32540 , #32545 , #32585 , #32599 , #32611 , #32612 , #32617 , #32651 , #32653 , #32656 , #32664 , #32699 , #32723 , #32734 , #32751 , #32752 , #32760 , #32761 , #32764 , #32772 , #32790 , #32819 , #32820 , #32822 , #32833 , #32849 , #33094 , #33191 , #33200 , #33206 , #33293 , #33330 , #33331 , #33332 , #33357 , #33362 , #33433 , #33576 , #34374 , #34450 , #34481 , #34500 , #34502 , #34510 , #34511 , #34539 , #35567 , #35564 , #35485 , #35380 , #35063 , #35060 , #34676 , #36917 , #35337 , #35520 )
Fragment Refs: Add Refs to <Fragment /> to
support composable platform behavior (@jackpope , @sebmarkbage , @eps1lon , @Dhakshin2007 ,
@chirokas , @teamleaderleo ,
@fallintoplace :
#32465 ,
#32613 ,
#32619 ,
#32654 ,
#32660 ,
#32682 ,
#32722 ,
#32813 ,
#32814 ,
#33056 ,
#33058 ,
#33093 ,
#34069 ,
#34103 ,
#34544 ,
#34545 ,
#37062 ,
#37061 ,
#37060 ,
#36047 ,
#36010 ,
#35642 ,
#35641 ,
#35637 ,
#35630 ,
#34935 ,
#37457 ,
#37408 ,
#37326 ,
#37251 ,
#37171 ,
#37169 ,
#37168 ,
#37167 ,
#37166 ,
#37165 ,
#37164 ,
#37163 ,
#37162 ,
#37161 ,
#37160 ,
#37125 ,
#37063 )
New React DOM Features
browser(): a new react-dom API that
returns a usable which errors during server rendering and resolves in
the browser. use(browser()) inside a
<Suspense> boundary marks a subtree as browser-only
without reporting a recoverable error (@gnoff : #37143 , #37241 )
Added an onBrowserBailout option to the
react-dom/server APIs to observe when a subtree defers to
the browser (@gnoff
#37193 )
Notable changes
Enable Trusted Types API integration (@rickhanlonii
#35816 )
Transitions now render independently instead of being entangled into
a single render, so a slow transition no longer holds up unrelated ones
(@acdlite #37290 )
Added a DEV-only warning when a component appears to have been
unblocked by calling use() conditionally (@hoxyq , @eps1lon : #37104 , #37203 , #37491 )
All Changes
React
Fast Refresh Fixes
Fix Fast Refresh to find and remount edits to components wrapped
behind lazy() (@sophiebits #36965 )
Fix Fast Refresh so edits to a memo() comparison
function take effect (@sophiebits #36964 )
Fix Fast Refresh crash when an edit changes the kind of a
component's type (@sophiebits #36963 )
Unify hot reload type resolution for Fast Refresh (@sophiebits #36962 )
Fix Fast Refresh to remount correctly when an edit changes the
component kind (@sophiebits #36950 )
Double invoke effects in StrictMode after Fast Refresh (@eps1lon #35962 )
Performance Track Fixes
Prevent crash when accessing $$typeof in Performance
Tracks (@eps1lon
#35679 )
Handle non-string function names in Performance Tracks (@eps1lon #35659 )
Use minus (-) instead of en dash for removed props in
Performance Tracks (@eps1lon #35649 )
Handle arrays with bigints in deep objects in Performance Tracks (@eps1lon #35648 )
Don't enumerate typed array props in Performance Tracks in DEV (@UditDewan #36913 )
Bail out of diffing wide objects and arrays in Performance Tracks
(@eps1lon #34742 )
Clear potentially large performance measures in DEV (@hoxyq #34803 )
Fix missing else branch for renders with no props change in
Performance Tracks (@hoxyq #34837 )
Activity Fixes
Fix useSyncExternalStore missing store mutations that
happened while an Activity tree was hidden (@sophiebits #36947 )
Hide portal contents when an Activity is hidden (@acdlite #35091 )
Prevent metadata hoisting in hidden <Activity>
trees (@ronnakamoto #34983 )
Prevent errors thrown inside a hidden Activity from escaping to the
visible UI (@acdlite #35074 )
Don't unhide a node if a direct parent Offscreen is still hidden (@sebmarkbage #34821 )
Don't show internal <Offscreen> component in
error messages (@rickhanlonii
#35763 )
Warn in DEV when a component appears to have been unblocked by a
conditional use() (@hoxyq , @eps1lon : #37104 , #37203 , #37491 )
Render transitions independently instead of entangling them into a
single render (@acdlite #37290 )
Fix hang when updating a dehydrated boundary inside a hidden tree
(@gaearon #37135 )
Don't reacquire Host Singletons during dev effect validation (@gnoff #37113 )
Only remove properties from Host Singletons on release (@gnoff #37112 )
... (truncated)
Commits
Maintainer changes
This version was pushed to npm by GitHub Actions , a new
releaser for react since your current version.
Updates `@types/react` from 18.3.28 to 19.3.0
Commits
Updates `react-dom` from 18.3.1 to 19.3.0
Release notes
Sourced from react-dom's
releases .
19.3.0 (September 9, 2026)
Below is a list of all new features, APIs, and bug fixes.
Read the React
19.3 release post for more information.
New React Features
<ViewTransition />: Adds <ViewTransition
/> and addTransitionType APIs to power View
Transition animations in React (@sebmarkbage , @jackpope , @gaearon : #31975 ,
#31987 ,
#31996 ,
#31999 ,
#32001 ,
#32002 ,
#32028 ,
#32029 ,
#32031 ,
#32034 ,
#32038 ,
#32041 ,
#32050 ,
#32090 ,
#32105 ,
#32254 ,
#32379 ,
#32422 ,
#32462 ,
#32540 ,
#32545 ,
#32585 ,
#32599 ,
#32611 ,
#32612 ,
#32617 ,
#32651 ,
#32653 ,
#32656 ,
#32664 ,
#32699 ,
#32723 ,
#32734 ,
#32751 ,
#32752 ,
#32760 ,
#32761 ,
#32764 ,
#32772 ,
#32790 ,
#32819 ,
#32820 ,
#32822 ,
#32833 ,
#32849 ,
#33094 ,
#33191 ,
#33200 ,
#33206 ,
#33293 ,
#33330 ,
#33331 ,
#33332 ,
#33357 ,
#33362 ,
#33433 ,
#33576 ,
#34374 ,
#34450 ,
#34481 ,
#34500 ,
#34502 ,
#34510 ,
#34511 ,
#34539 ,
#35567 ,
#35564 ,
#35485 ,
#35380 ,
#35063 ,
#35060 ,
#34676 ,
#36917 ,
#35337 ,
#35520 )
Fragment Refs: Add Refs to <Fragment /> to
support composable platform behavior (@jackpope , @sebmarkbage , @eps1lon , @Dhakshin2007 ,
@chirokas , @teamleaderleo ,
@fallintoplace :
#32465 ,
#32613 ,
#32619 ,
#32654 ,
#32660 ,
#32682 ,
#32722 ,
#32813 ,
#32814 ,
#33056 ,
#33058 ,
#33093 ,
#34069 ,
#34103 ,
#34544 ,
#34545 ,
#37062 ,
#37061 ,
#37060 ,
#36047 ,
#36010 ,
#35642 ,
#35641 ,
#35637 ,
#35630 ,
#34935 ,
#37457 ,
#37408 ,
#37326 ,
#37251 ,
#37171 ,
#37169 ,
#37168 ,
#37167 ,
#37166 ,
#37165 ,
#37164 ,
#37163 ,
#37162 ,
#37161 ,
#37160 ,
#37125 ,
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
---
frontend/bun.lock | 288 ++++++++++++++----------------------------
frontend/package.json | 20 +--
2 files changed, 103 insertions(+), 205 deletions(-)
diff --git a/frontend/bun.lock b/frontend/bun.lock
index 742f42a5..11ec980d 100644
--- a/frontend/bun.lock
+++ b/frontend/bun.lock
@@ -7,226 +7,128 @@
"dependencies": {
"@upsetjs/react": "^1.11.0",
"jspdf": "^4.2.1",
- "plotly.js-dist-min": "^2.35.2",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "react-router-dom": "^6.28.0",
+ "plotly.js-dist-min": "^4.1.2",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router-dom": "^7.18.4",
"svg2pdf.js": "^2.8.1",
},
"devDependencies": {
- "@types/plotly.js": "^2.33.4",
- "@types/react": "^18.3.18",
- "@types/react-dom": "^18.3.5",
- "@vitejs/plugin-react": "^4.3.4",
- "typescript": "^5.7.2",
- "vite": "^6.1.0",
+ "@types/plotly.js": "^3.0.15",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.4",
},
},
},
"overrides": {
- "@types/react": "^18.3.18",
- "@types/react-dom": "^18.3.5",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
},
"packages": {
- "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
-
- "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
-
- "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
-
- "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
-
- "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
-
- "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
-
- "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
-
- "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
-
- "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
-
- "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
-
- "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
-
- "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
-
- "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
-
- "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
-
- "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="],
-
- "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
-
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
- "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
-
- "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
-
- "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
-
- "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
-
- "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
-
- "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
-
- "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
-
- "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
-
- "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
-
- "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
-
- "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
-
- "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
-
- "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
-
- "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
-
- "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
-
- "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
-
- "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
-
- "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
-
- "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
-
- "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
-
- "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
-
- "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+ "@oxc-project/types": ["@oxc-project/types@0.154.0", "", {}, "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA=="],
- "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+ "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.14", "", { "os": "android", "cpu": "arm" }, "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg=="],
- "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+ "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.14", "", { "os": "android", "cpu": "arm64" }, "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ=="],
- "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+ "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw=="],
- "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+ "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA=="],
- "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+ "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA=="],
- "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+ "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.14", "", { "os": "linux", "cpu": "arm" }, "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg=="],
- "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+ "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ=="],
- "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+ "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg=="],
- "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+ "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.14", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q=="],
- "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+ "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.14", "", { "os": "linux", "cpu": "s390x" }, "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A=="],
- "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+ "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg=="],
- "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.14", "", { "os": "linux", "cpu": "x64" }, "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg=="],
- "@remix-run/router": ["@remix-run/router@1.23.2", "", {}, "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w=="],
+ "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.14", "", { "os": "none", "cpu": "arm64" }, "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q=="],
- "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
+ "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA=="],
- "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
+ "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.14", "", { "os": "win32", "cpu": "x64" }, "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ=="],
- "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
+ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
- "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
-
- "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
-
- "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
-
- "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
-
- "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
-
- "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
-
- "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
-
- "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
-
- "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
-
- "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
+ "@types/lz-string": ["@types/lz-string@1.5.0", "", { "dependencies": { "lz-string": "*" } }, "sha512-s84fKOrzqqNCAPljhVyC5TjAo6BH4jKHw9NRNFNiRUY5QSgZCmVm5XILlWbisiKl+0OcS7eWihmKGS5akc2iQw=="],
- "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
+ "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
- "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
+ "@types/plotly.js": ["@types/plotly.js@3.0.15", "", {}, "sha512-ftBxk2fB7BxxeMpT0IVEb/3HKpYTXO5nCdR5mGEz6gHpLd6GGRFxvDVoG1gpD/gBAgoN36KIyRflxCnOfWlskQ=="],
- "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
+ "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
- "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
+ "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
- "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
+ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
- "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
- "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
+ "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
- "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
+ "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
- "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
+ "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
- "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
+ "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
- "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
+ "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
- "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
+ "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
- "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
+ "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
- "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
+ "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
- "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
+ "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
- "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
+ "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
- "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
+ "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
- "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
+ "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
- "@types/lz-string": ["@types/lz-string@1.5.0", "", { "dependencies": { "lz-string": "*" } }, "sha512-s84fKOrzqqNCAPljhVyC5TjAo6BH4jKHw9NRNFNiRUY5QSgZCmVm5XILlWbisiKl+0OcS7eWihmKGS5akc2iQw=="],
+ "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
- "@types/pako": ["@types/pako@2.0.4", "", {}, "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw=="],
+ "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
- "@types/plotly.js": ["@types/plotly.js@2.35.14", "", {}, "sha512-CcD/32JcK19+xWH4FFpmYez/5X9kOjUcBr8Hxh7gQ/3Z32gIoLLy/L9xvC7DG5YikPvJjq6QN05B9+MCRu/Ncw=="],
+ "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
- "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
+ "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
- "@types/raf": ["@types/raf@3.4.3", "", {}, "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw=="],
+ "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
- "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="],
+ "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
- "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
+ "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
- "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@upsetjs/model": ["@upsetjs/model@1.11.0", "", {}, "sha512-pQEdunei2WC9mdhFR6umzE45j8VJfei/cG0TyVaaC0YE5zh1WI/TJRJJQC5jCiGhylISy99ANJs6XmQI33lqCw=="],
"@upsetjs/react": ["@upsetjs/react@1.11.0", "", { "dependencies": { "@types/lz-string": "^1.3.34", "@types/react": "^17.0.38", "@upsetjs/model": "~1.11.0", "lz-string": "^1.4.4" }, "peerDependencies": { "react": ">=17" } }, "sha512-APDFkXowPk1U94Iz5dtzpjcSiR5GcJkhLdMdeTnyLDYxIBf1mA+ElbBrEdAzaSGCiZo76ZKfgmuCMSMEfLtN8g=="],
- "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
+ "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw=="],
"base64-arraybuffer": ["base64-arraybuffer@1.0.2", "", {}, "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ=="],
- "baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
-
- "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
-
- "caniuse-lite": ["caniuse-lite@1.0.30001778", "", {}, "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg=="],
-
"canvg": ["canvg@3.0.11", "", { "dependencies": { "@babel/runtime": "^7.12.5", "@types/raf": "^3.4.0", "core-js": "^3.8.3", "raf": "^3.4.1", "regenerator-runtime": "^0.13.7", "rgbcolor": "^1.0.1", "stackblur-canvas": "^2.0.0", "svg-pathdata": "^6.0.3" } }, "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA=="],
- "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+ "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="],
@@ -236,16 +138,10 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
- "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dompurify": ["dompurify@3.4.16", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.313", "", {}, "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA=="],
-
- "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
-
- "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
-
"fast-png": ["fast-png@6.4.0", "", { "dependencies": { "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } }, "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -256,31 +152,39 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
- "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
-
"html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="],
"iobuffer": ["iobuffer@5.4.0", "", {}, "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA=="],
- "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+ "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
- "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+ "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
- "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+ "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
- "jspdf": ["jspdf@4.2.1", "", { "dependencies": { "@babel/runtime": "^7.28.6", "fast-png": "^6.2.0", "fflate": "^0.8.1" }, "optionalDependencies": { "canvg": "^3.0.11", "core-js": "^3.6.0", "dompurify": "^3.3.1", "html2canvas": "^1.0.0-rc.5" } }, "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ=="],
+ "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
- "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
+ "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
- "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+ "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
- "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+ "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
- "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
- "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
- "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
+ "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
+
+ "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
+
+ "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
+
+ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
+
+ "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
+
+ "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"pako": ["pako@2.2.0", "", {}, "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w=="],
@@ -288,35 +192,33 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
- "plotly.js-dist-min": ["plotly.js-dist-min@2.35.3", "", {}, "sha512-sz2HLP8gkysLx/BanM2PtJTtZ1PLPwdHwMWNri2YxLBy3IOeuDsVQtlmWa4hoK3j/fi4naaD3uZJqH5ozM3zGg=="],
+ "plotly.js-dist-min": ["plotly.js-dist-min@4.1.2", "", {}, "sha512-VSmS9KVNun16S8qFYYp9LlL/XBby4ueeGqB+K9AqRCVsup8dSpGULap4UyR1f2mz4pV3qjWp1sR5chwLgIKPoQ=="],
- "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
+ "postcss": ["postcss@8.5.30", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA=="],
"raf": ["raf@3.4.1", "", { "dependencies": { "performance-now": "^2.1.0" } }, "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="],
- "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
-
- "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
+ "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
- "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
+ "react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
- "react-router": ["react-router@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw=="],
+ "react-router": ["react-router@7.18.4", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ=="],
- "react-router-dom": ["react-router-dom@6.30.3", "", { "dependencies": { "@remix-run/router": "1.23.2", "react-router": "6.30.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag=="],
+ "react-router-dom": ["react-router-dom@7.18.4", "", { "dependencies": { "react-router": "7.18.4" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw=="],
"regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="],
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
- "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+ "rolldown": ["rolldown@1.2.14", "", { "dependencies": { "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.14", "@rolldown/binding-android-arm64": "1.2.14", "@rolldown/binding-darwin-arm64": "1.2.14", "@rolldown/binding-darwin-x64": "1.2.14", "@rolldown/binding-freebsd-x64": "1.2.14", "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", "@rolldown/binding-linux-arm64-gnu": "1.2.14", "@rolldown/binding-linux-arm64-musl": "1.2.14", "@rolldown/binding-linux-ppc64-gnu": "1.2.14", "@rolldown/binding-linux-s390x-gnu": "1.2.14", "@rolldown/binding-linux-x64-gnu": "1.2.14", "@rolldown/binding-linux-x64-musl": "1.2.14", "@rolldown/binding-openharmony-arm64": "1.2.14", "@rolldown/binding-win32-arm64-msvc": "1.2.14", "@rolldown/binding-win32-x64-msvc": "1.2.14" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw=="],
- "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
+ "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
- "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
- "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
+ "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"specificity": ["specificity@0.4.1", "", { "bin": { "specificity": "./bin/specificity" } }, "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg=="],
@@ -330,16 +232,12 @@
"text-segmentation": ["text-segmentation@1.0.3", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw=="],
- "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
-
- "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+ "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"utrie": ["utrie@1.0.2", "", { "dependencies": { "base64-arraybuffer": "^1.0.2" } }, "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw=="],
- "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
-
- "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 8793ef05..ab8e600e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -13,19 +13,19 @@
"dependencies": {
"@upsetjs/react": "^1.11.0",
"jspdf": "^4.2.1",
- "plotly.js-dist-min": "^2.35.2",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "react-router-dom": "^6.28.0",
+ "plotly.js-dist-min": "^4.1.2",
+ "react": "^19.3.0",
+ "react-dom": "^19.3.0",
+ "react-router-dom": "^7.18.4",
"svg2pdf.js": "^2.8.1"
},
"devDependencies": {
- "@types/plotly.js": "^2.33.4",
- "@types/react": "^18.3.18",
- "@types/react-dom": "^18.3.5",
- "@vitejs/plugin-react": "^4.3.4",
- "typescript": "^5.7.2",
- "vite": "^6.1.0"
+ "@types/plotly.js": "^3.0.15",
+ "@types/react": "^19.3.0",
+ "@types/react-dom": "^19.3.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "typescript": "^7.0.2",
+ "vite": "^8.3.4"
},
"overrides": {
"@types/react": "$@types/react",
From 9e7766c508592a0346c03d4cccab8e51b81d1e5b Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 03:45:08 +0100
Subject: [PATCH 18/45] fix(frontend): the #124 bumps broke the build -- useRef
under React 19 types, manualChunks under vite 8 (#140)
## What this changes
#124 (Dependabot's frontend group) bumped React 18 -> 19.3, vite 6 ->
8.3, TypeScript 5.7 -> 7, react-router-dom 6 -> 7 and plotly 2 -> 4 in
one merge, and -- with no required checks yet -- it landed with `tsc`
already failing. Main cannot build its frontend right now; this PR is
that repair, two real breaks:
1. **React 19's types dropped the zero-argument `useRef` overload.**
`TreeView.tsx`'s two timer refs (`saveTimer`, `scrollTimer`) failed
`tsc` with TS2554 twice. They now pass the explicit `undefined` they
always had at runtime: `useRef(undefined)`.
2. **vite 8's rolldown core accepts only the function form of
`manualChunks`.** The object form in `vite.config.ts` died at
`manualChunks is not a function`. The function keeps the same chunking
-- plotly still lands in its own 4.6 MB chunk.
## How to run its tests
From `frontend/`:
```
bun install --frozen-lockfile --ignore-scripts
bun run build # tsc && vite build -- green in 2.9s, same chunk layout
bun run test # 42 pass, 0 fail
bun run typecheck # clean
```
Verified locally on this exact tree.
## What this leaves out
React 19, react-router 7, TypeScript 7 and plotly 4 are runtime majors
that type-check and build clean but deserve their own smoke test of the
running app; this PR only restores what the bumps broke mechanically. If
the majors prove too hot, the alternative is re-pinning them in
`package.json` -- but that is a decision about keeping #124, not about
fixing it.
## Versions tested
bun 1.4.3 (local mise shim; CI installs the `tool_versions.yml` pin).
Julia, R and Agda untouched.
Note: the commit is unsigned (the signing key's passphrase is
unavailable to the session); amend to sign if required.
---------
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
frontend/src/views/TreeView.tsx | 6 ++++--
frontend/vite.config.ts | 6 +++---
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/frontend/src/views/TreeView.tsx b/frontend/src/views/TreeView.tsx
index c0778282..8147ac29 100644
--- a/frontend/src/views/TreeView.tsx
+++ b/frontend/src/views/TreeView.tsx
@@ -183,7 +183,9 @@ function TreeCanvas({ source, tree, src, toast }: {
// Autosave the view document.
const firstSave = useRef(true)
- const saveTimer = useRef()
+ // React 19's types dropped the zero-argument useRef overload, so the timer
+ // id starts as an explicit undefined instead.
+ const saveTimer = useRef(undefined)
const saveNow = useCallback(() => {
const doc: TreeViewDoc = {
version: 1,
@@ -228,7 +230,7 @@ function TreeCanvas({ source, tree, src, toast }: {
el.addEventListener('wheel', onWheel, { passive: false })
return () => el.removeEventListener('wheel', onWheel)
}, [])
- const scrollTimer = useRef()
+ const scrollTimer = useRef(undefined)
const onScroll = () => {
const el = scrollRef.current
if (!el) return
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index e25db006..5aea76c5 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -9,9 +9,9 @@ export default defineConfig({
sourcemap: false,
rollupOptions: {
output: {
- manualChunks: {
- plotly: ['plotly.js-dist-min'],
- },
+ // vite 8 (rolldown) took only the function form; the old object form
+ // died at "manualChunks is not a function".
+ manualChunks: (id: string) => (id.includes('plotly.js-dist-min') ? 'plotly' : undefined),
},
},
},
From 655c4daff8b128b14b2a26f72ef30230f4d586a6 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 04:14:26 +0100
Subject: [PATCH 19/45] ci(occupancy): move the PR path filter into a gate, so
the reading checks can be required (#125)
## What this changes
#116 merged with this workflow's "Occupancy typed peak" check red (its
squash carried a draft `TH.[_]` placeholder; #117 fixed the proof),
because nothing required the check to pass first -- and nothing could
have: the `pull_request` trigger was path-filtered, so the occupancy
checks never posted on PRs that touch no occupancy file, and a required
check that never posts can never be satisfied. The Branch-Floor ruleset
requires nothing today; ci.yml's header already documents this trap for
its own job names.
The PR path filter now lives in an `Occupancy relevance` gate job:
- the workflow runs on **every** pull request, so all three of its
checks always post;
- the gate diffs the change against the same file list with plain `case`
globs (a third-party paths-filter action would be refused by the
account's allow-list; in a case pattern `*` crosses `/`, so
`docs/occupancy/*` is the old `docs/occupancy/**`);
- `Occupancy disagreement test` and `Occupancy typed peak` run only when
the gate says relevant, and post `skipped` otherwise -- which a required
check counts as satisfied;
- the push trigger keeps its native `paths:` filter, so pushes to main
still run this only when they should.
All three job names are stable identifiers with no matrix, as
`test/unit/test_install_pins.jl` asserts for every job in every
workflow.
## What this enables (kept out of this PR on purpose)
Once merged, Branch-Floor can require `Occupancy relevance` (itself
included: a failed gate skips its dependents, and a skipped check
satisfies a requirement silently -- the gate is the one check that must
not be skipped), `Occupancy disagreement test`, `Occupancy typed peak`,
alongside ci.yml's `Julia tests` and `Agda proofs`. That ruleset change
is a settings change and will follow separately.
## How to run its tests
From the repository root:
```
julia --project=. test/unit/test_install_pins.jl
```
All 223 assertions pass locally (the gate job satisfies the every-job
stable-name and no-matrix rules and uses the pinned `actions/checkout`
SHA). CI on this PR is the full check: this PR touches `occupancy.yml`
itself, so the gate must mark it relevant and both heavy jobs must run.
## What this leaves out
No ruleset change (above), no change to the heavy jobs' contents, and
the two startup_failure-era path lists (push trigger, gate patterns)
necessarily duplicate each other -- push filtering cannot be scripted,
and the gate's list is documented as the pull_request half.
## Versions tested
Julia 1.13.1 (local; CI pins 1.12.5 per `tool_versions.yml`). Bun, R and
Agda untouched by this change -- CI runs the pinned ones.
Note: this commit is unsigned (the signing key's passphrase is not
available to the session); amend to sign if the fork's history requires
it.
---------
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
---------
Co-authored-by: Mistral Vibe
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.github/workflows/occupancy.yml | 108 +++++++++++++++++++++++++++-----
1 file changed, 93 insertions(+), 15 deletions(-)
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
index 9dba6fbe..0513de5c 100644
--- a/.github/workflows/occupancy.yml
+++ b/.github/workflows/occupancy.yml
@@ -11,6 +11,24 @@
# everything here installs from pinned URLs checked against pinned SHA256s, as
# ci.yml now does too. The disagreement test also runs in ci.yml's full suite;
# it runs here as well so that a change to the probe gets its own check.
+#
+# #116 merged with this workflow's "Occupancy typed peak" check red (its squash
+# carried a draft `TH.[_]` placeholder; #117 fixed the proof), because nothing
+# required the check to pass first -- and nothing COULD have: a check posts
+# only when its workflow runs, and the pull_request trigger was path-filtered,
+# so requiring these checks would have deadlocked every PR that touches no
+# occupancy file. A required check that never posts can never be satisfied; the
+# trap ci.yml's header records for its own job names.
+#
+# So the pull_request path filter moved into the `Occupancy relevance` gate
+# below. The workflow now runs on EVERY pull request; the gate decides whether
+# the change touches the occupancy reading; the two heavy jobs run only when it
+# does, and post their checks as `skipped` otherwise, which a required check
+# counts as satisfied. The gate itself posts on every PR too, and it is the one
+# check of the three that must be required without exception: if the gate
+# failed, its dependents would be skipped and would satisfy their requirement
+# silently. The push trigger keeps its native paths filter, so a push to main
+# still starts this workflow only when it should.
name: Occupancy reading
on:
@@ -30,22 +48,10 @@ on:
- scripts/check-occupancy.sh
- scripts/fetch-siblings.sh
- .github/workflows/occupancy.yml
- # Any base branch, so a PR stacked on another one is checked too.
+ # No paths filter, on purpose (see above): the gate must post its check on
+ # every PR, whichever files the PR touches. Any base branch, so a PR stacked
+ # on another one is gated too.
pull_request:
- paths:
- - src/core/memory_probe.jl
- - src/core/provenance.jl
- - src/pipeline/tools.jl
- - src/server/routes/pipeline.jl
- - test/occupancy_disagreement_test.jl
- - test/unit/test_occupancy_grade.jl
- - scripts/check-occupancy-grades.jl
- - scripts/lint-occupancy-readings.sh
- - docs/occupancy/**
- - docs/occupancy-2026-10.adoc
- - scripts/check-occupancy.sh
- - scripts/fetch-siblings.sh
- - .github/workflows/occupancy.yml
permissions:
contents: read
@@ -62,7 +68,77 @@ env:
JULIA_SHA256: 41b84d727e4e96fbf3ed9e92fa195d773d247b9097f73fad688f8b699758bae7
jobs:
+ # Decides whether the PR touches the occupancy reading at all. The file list
+ # is the pull_request half of the old paths filter, moved here because a
+ # pinned-actions workflow cannot use a third-party paths-filter action (the
+ # account's allow-list refuses one) -- plain `case` globs are all it takes.
+ # In a case pattern `*` crosses `/`, so `docs/occupancy/*` is the old
+ # `docs/occupancy/**`.
+ gate:
+ name: Occupancy relevance
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ outputs:
+ relevant: ${{ steps.push.outputs.relevant || steps.pr.outputs.relevant }}
+ steps:
+ # A push only starts this workflow when a `paths:` file changed (the
+ # trigger filter above), so the gate has nothing to decide on pushes.
+ - name: A push is already path-filtered
+ id: push
+ if: github.event_name == 'push'
+ run: echo 'relevant=true' >> "$GITHUB_OUTPUT"
+
+ # Full history, because the three-dot diff names the merge base of the
+ # PR's base tip and its merge commit -- a shallow checkout does not have
+ # both, and a stacked PR's merge base sits further back still.
+ - name: Checkout the change
+ if: github.event_name == 'pull_request'
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ persist-credentials: false # nothing here pushes
+ fetch-depth: 0
+
+ - name: Does the change touch the occupancy reading?
+ id: pr
+ if: github.event_name == 'pull_request'
+ env:
+ # zizmor flags template expressions interpolated into run scripts
+ # (template-injection), and the gate script is easier to run locally
+ # with the value in an environment variable in any case. The base SHA
+ # is GitHub-supplied, but the indirection costs nothing.
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ relevant=false
+ patterns=(src/core/memory_probe.jl src/core/provenance.jl src/pipeline/tools.jl
+ src/server/routes/pipeline.jl test/occupancy_disagreement_test.jl
+ test/unit/test_occupancy_grade.jl scripts/check-occupancy-grades.jl
+ scripts/lint-occupancy-readings.sh 'docs/occupancy/*'
+ docs/occupancy-2026-10.adoc scripts/check-occupancy.sh
+ scripts/fetch-siblings.sh .github/workflows/occupancy.yml)
+ # The diff goes to a file first because a process substitution hides
+ # git diff's exit status: an unavailable comparison SHA or a repository
+ # error would read as "no changes", and the gate would post
+ # relevant=false -- skipping the two occupancy jobs, whose `skipped`
+ # conclusion satisfies a required check -- so a relevant PR would
+ # pass silently. A failed diff must fail the gate itself: that is
+ # the one failure the required-check design cannot afford to skip.
+ changed="$RUNNER_TEMP/occupancy-changed-files"
+ if ! git diff --no-renames --name-only -z "$BASE_SHA...HEAD" > "$changed"; then
+ echo "ERROR: git diff $BASE_SHA...HEAD failed" >&2
+ exit 1
+ fi
+ while IFS= read -r -d '' file; do
+ for pattern in "${patterns[@]}"; do
+ case "$file" in
+ $pattern) relevant=true; break 2 ;;
+ esac
+ done
+ done < "$changed"
+ echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
+
disagreement:
+ needs: gate
+ if: needs.gate.outputs.relevant == 'true'
name: Occupancy disagreement test
runs-on: ubuntu-24.04
timeout-minutes: 45
@@ -123,6 +199,8 @@ jobs:
run: bash scripts/lint-occupancy-readings.sh
typed-peak:
+ needs: gate
+ if: needs.gate.outputs.relevant == 'true'
name: Occupancy typed peak
runs-on: ubuntu-24.04
# The Agda 2.6.4.3 Debian trixie packages, in a digest-pinned image, as the
From dcd5af40ad6242eb2d1b15e595d795ed8b417e5d Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell"
Date: Thu, 15 Oct 2026 04:15:35 +0100
Subject: [PATCH 20/45] docs: the wiki pointer, differential abundance
documentation, and the community files
The README gains a Documentation section pointing at the wiki in
metadatastician/MetaManifold-Evidence (docs/wikis), and a Differential
Abundance section documenting the tab end to end: the two-condition
selection semantics, rank-list scope, aggregation, the Unclassified
pooling after the read-count bounds and exclude_categories, the
per-taxon negative-binomial GLM (MASS::glm.nb with a size-factor offset,
no pseudocounts, Wald test, Benjamini-Hochberg over the taxa that produced
a p-value), where the size factors come from, and where the code and the
route live. Settings cross-reference the existing Configuring analysis
anchor.
Also adds the community files GitHub surfaces by convention: a bug
report and feature request issue template (both telling reporters to
search first and to raise fork-specific issues on the fork), a pull
request template carrying the PR checklist, and the Contributor Covenant
code of conduct.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
---
.github/ISSUE_TEMPLATE/bug_report.yml | 59 +++++++++++++++
.github/ISSUE_TEMPLATE/feature_request.yml | 41 +++++++++++
.github/pull_request_template.md | 43 +++++++++++
CODE_OF_CONDUCT.md | 83 ++++++++++++++++++++++
README.md | 79 ++++++++++++++++++--
5 files changed, 301 insertions(+), 4 deletions(-)
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/pull_request_template.md
create mode 100644 CODE_OF_CONDUCT.md
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..3cf88518
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,59 @@
+name: Bug report
+description: Something in MetaManifold does not work as documented.
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Search the existing issues first. Raise issues about a fork's own code on that fork.
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps or command
+ description: The command you ran, or the steps in the web interface, so that someone else can repeat them.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: What you expected
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: What happened
+ description: Include the error message, the server log lines, or a screenshot.
+ validations:
+ required: true
+ - type: input
+ id: julia
+ attributes:
+ label: Julia version
+ placeholder: output of `julia --version`
+ validations:
+ required: true
+ - type: input
+ id: r
+ attributes:
+ label: R version
+ placeholder: output of `R --version | head -1`
+ validations:
+ required: true
+ - type: input
+ id: browser
+ attributes:
+ label: Browser and version
+ validations:
+ required: true
+ - type: input
+ id: commit
+ attributes:
+ label: MetaManifold commit
+ placeholder: output of `git rev-parse --short HEAD`
+ - type: textarea
+ id: config
+ attributes:
+ label: Relevant configuration
+ description: The keys from `pipeline.yml` or the run's `run_config.yml` that bear on the problem.
+ render: yaml
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..312c2aec
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,41 @@
+name: Feature request
+description: Suggest a new feature or a change to an existing one.
+labels: [enhancement]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Search the existing issues first. To change an architectural decision, describe the change
+ here before writing any code. The settled decisions in CONTRIBUTING.md can be discussed in an
+ issue, but a pull request does not reopen them.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What you are trying to do, and what stops you now.
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed change
+ description: What MetaManifold should do instead. For a statistical method, name the method and a reference for it.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area
+ options:
+ - Pipeline stages
+ - Analysis and statistics
+ - Web interface
+ - Configuration
+ - Installation and toolchain
+ - Other
+ validations:
+ required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..a17d0de6
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,43 @@
+## Summary
+
+
+
+Closes #
+
+## Type of change
+
+- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
+- [ ] ✨ New feature (non-breaking change that adds functionality)
+- [ ] 💥 Breaking change (would change existing behaviour, a config key or an API response)
+- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it could not support)
+- [ ] 📖 Documentation
+- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
+- [ ] ⚡ Performance
+- [ ] 🔧 Build / CI / tooling
+
+## How has this been verified?
+
+
+
+Tested with Julia `…`, R `…` and bun `…`.
+
+## What this leaves out
+
+
+
+## Checklist
+
+- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`; see CONTRIBUTING.md) and has no merge commits from `main`.
+- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend && bun run test` for frontend changes) and they pass.
+- [ ] CI ran on this PR and passes.
+- [ ] No generated or vendored files, and no lockfile change the work does not need (or the reason is given above).
+- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff` and the licence and acknowledgement sections of `README.md` unchanged.
+- [ ] Every number shown to a user is computed from their data. When a method cannot run it raises an explicit error.
+- [ ] Docs are updated, and no claim in them overstates what the code does.
+
+## Notes for reviewers
+
+
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..b02898a4
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,83 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement: the maintainer, [@JoshuaJewell](https://github.com/JoshuaJewell), using the contact details on their GitHub profile. All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/README.md b/README.md
index 1fe02ca8..16b041a8 100644
--- a/README.md
+++ b/README.md
@@ -74,9 +74,16 @@ Once a run completes, analysis is performed on request through the web UI, both
- Pipeline stage read-count summaries
- NMDS ordination (Bray-Curtis, via R/vegan)
- PERMANOVA, with PERMDISP to check for differences in dispersion (via R/vegan)
+- Differential abundance between two conditions: a negative-binomial model per taxon (via R/MASS) with Benjamini-Hochberg adjusted p-values (see [Differential abundance](#differential-abundance))
Counts may be normalised before analysis (none, rarefaction to a fixed or auto-resolved depth, or scaling with ranked subsampling (SRS), which reaches the depth by scaling counts and keeps more of the community structure), and contamination-flagged taxa may be included or excluded. Ordinations can also apply a Hellinger transform before the Bray-Curtis dissimilarity. Composition charts can be drawn as a grid faceted on any two of run, group and sub-group. All analysis charts are returned as Plotly JSON and rendered interactively in the browser.
+## Documentation
+
+Longer documentation for users, maintainers, developers and theorists is kept as a wiki in
+[metadatastician/MetaManifold-Evidence, under `docs/wikis/`](https://github.com/metadatastician/MetaManifold-Evidence/tree/main/docs/wikis).
+Start at its [Home page](https://github.com/metadatastician/MetaManifold-Evidence/blob/main/docs/wikis/Home.md).
+
## Prerequisites
- **Julia** 1.12 (installed by `install.sh` if missing, and pinned to the Manifest version for this directory)
@@ -240,6 +247,67 @@ The composition view classifies each ASV/OTU into a biological category and rend
+### Differential abundance
+
+The Differential Abundance tab of a study's analysis workspace tests each taxon
+for a difference in abundance between two conditions. Select exactly two runs or
+pooled-run sub-groups, a results table and a rank. The first condition is the
+reference, so a positive fold change means the taxon is more abundant in the
+second. The rank list shows only the ranks the selected runs share. With
+aggregation on, a pooled run counts as one condition rather than one per
+sub-group.
+
+Counts are summed per taxon at the chosen rank, with rows that have no name at
+that rank pooled as `Unclassified`. This happens after the read-count bounds and
+any `analysis.exclude_categories` entry that applies to `differential`. Each
+taxon is fitted with its own negative-binomial GLM, `MASS::glm.nb(y ~ group +
+offset(log(size factor)))`, on the raw integer read counts. There are no
+pseudocounts. The group coefficient gets a Wald test, and Benjamini-Hochberg
+adjustment runs over the taxa that produced a p-value. Size factors come from
+the whole count matrix and are centred to a geometric mean of 1. The code is in
+`src/analysis/differential.jl`, and the route in `src/server/routes/analysis.jl`.
+
+Settings live under `analysis.differential` (see
+[Configuring analysis](#configuring-analysis-analysis-in-pipelineyml)):
+
+- `offset`: `tss` (default) uses each sample's total reads. `rle` uses the
+ median of ratios over the taxa with reads in every sample.
+- `min_prevalence`: a fraction from 0 to 1 (default 0). A taxon with reads in a
+ smaller fraction of the samples is not fitted.
+
+The result is a volcano plot and a table. The plot draws log2 fold change
+against -log10 of the raw p-value, coloured by whether the adjusted p-value is
+below 0.05. Taxa without an adjusted p-value are left off the plot but stay in
+the table. Each table row has a status:
+
+- `ok`: the model fitted.
+- `boundary`: the model fitted, but the dispersion parameter theta reached a
+ bound (at least 1e7, so effectively Poisson, or at most 1e-8). These taxa
+ keep their p-value and stay in the adjusted family.
+- `failed`: no p-value, with the reason. Causes are counts that are constant
+ across samples, an error from `glm.nb`, a fit that did not converge, an
+ aliased group coefficient, or a non-finite estimate.
+- `filtered`: below `min_prevalence`, so not fitted.
+
+A failed or filtered taxon never gets a stand-in p-value, and it is not counted
+in the adjustment. The table downloads as `differential_abundance.csv` and can
+be added to the report. The CSV starts with `#` lines that record the
+comparison, method and settings.
+
+The test refuses, with an explicit error, when:
+
+- the selection is not exactly two conditions, or both are the same;
+- the two conditions resolve to different ranks;
+- a condition has no results table, no taxonomy columns or no sample columns,
+ or the read-count bounds remove all its samples;
+- `offset` or `min_prevalence` is invalid;
+- a count is not a non-negative integer, a condition has no samples, or there
+ are fewer than 3 samples in total;
+- `tss` meets a sample with no reads, or `rle` finds no taxon with reads in
+ every sample (use `tss`);
+- the R package MASS cannot be loaded;
+- no taxon could be fitted at all.
+
### Figures and report
The Figures tab lays analysis charts out on a page (A4, letter or a journal
@@ -554,13 +622,13 @@ Each entry names a filter in the `filters:` library of `config/composition.yml`.
### Configuring analysis (`analysis:` in `pipeline.yml`)
-Controls the defaults applied to the analysis charts (alpha diversity, taxa bar, NMDS, etc.). Per-chart choices such as the taxonomic rank and relative or absolute abundance are made in the UI.
+Controls the defaults applied to the analysis charts (alpha diversity, taxa bar, NMDS, differential abundance, etc.). Per-chart choices such as the taxonomic rank and relative or absolute abundance are made in the UI.
```yaml
analysis:
exclude_categories: # composition categories to drop from figures; [] to keep all
- - {set: contamination, category: Contaminant, apply_to: [diversity, taxa, venn]}
- # apply_to surfaces: diversity | taxa | composition | venn
+ - {set: contamination, category: Contaminant, apply_to: [diversity, taxa, venn, differential]}
+ # apply_to surfaces: diversity | taxa | composition | venn | differential
# (omit apply_to to act on every surface)
alpha: # richness, Shannon and Simpson
normalisation: none # none | rarefy | srs (scaling with ranked subsampling)
@@ -577,6 +645,9 @@ analysis:
depth: 0 # rarefy/srs reads per sample; 0 = smallest non-empty sample
nmds:
max_stress: 0.2 # warn if NMDS stress exceeds this value
+ differential: # negative-binomial differential abundance (MASS::glm.nb)
+ offset: tss # tss | rle (rle is refused when no taxon has reads in every sample)
+ min_prevalence: 0.0 # fraction of samples, 0 to 1, a taxon needs reads in to be tested
```
### Configuring taxonomic filtering (`filters:` in `config/composition.yml`)
@@ -762,7 +833,7 @@ The server exposes a REST API under `/api/v1/`. Key endpoint groups:
| Results | `GET/POST/DELETE .../results/tables/...` | List, query, filter, save, export (`.xlsx`), and delete tables; OTU member drill-down |
| QC | `GET .../results/qc`, `GET .../results/dada2` | MultiQC report metadata and DADA2 figures, logs, stats |
| Analysis | `POST .../runs/{run}/analysis/{alpha,chart}`, `GET .../runs/{run}/analysis/{pipeline-stats,ranks}` | Per-run charts and rank discovery |
-| Cross-run | `POST /studies/{study}/analysis/{alpha,chart,chart-facet,nmds,permanova,venn,publication-tables}` | Comparison, faceted charts, NMDS, PERMANOVA, taxon overlap and publication tables across runs |
+| Cross-run | `POST /studies/{study}/analysis/{alpha,chart,chart-facet,nmds,permanova,venn,publication-tables,differential}` | Comparison, faceted charts, NMDS, PERMANOVA, taxon overlap, publication tables and differential abundance across runs |
| Composition | `POST .../runs/{run}/composition/summary`, `POST .../composition/{source}/query`, `POST .../composition/{source}/distinct/{column}` | Organism-composition summaries and tables for a run |
| Composition library | `GET /composition`, `POST/DELETE /composition/{filters,sets}/{name}`, `GET /category-sets`, `POST/DELETE /category-sets/{name}` | Edit the filters and category sets in `config/composition.yml` |
| Reference trees | `GET/POST /reference-trees`, `GET/PUT/DELETE /reference-trees/{id}`, `GET/PUT .../fasta`, `POST .../run`, `GET .../log/{step}`, `GET .../qc/{align,trim}`, `GET .../alignment/{raw,trimmed}`, `GET .../treefile`, `POST .../trim-preview` | Build and inspect the shared reference trees |
From e2dfa05897aa133ba77ac48be3182550b99d3054 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 04:34:55 +0100
Subject: [PATCH 21/45] chore(dependabot): batch only minor and patch; a major
opens its own PR (#141)
## What this changes
#124 grouped every frontend update, and with nothing requiring the
checks it merged five framework majors -- React 18->19, vite 6->8,
TypeScript 5->7, react-router-dom 6->7, plotly 2->4 -- in one PR, with
`tsc` already red until #140 repaired it. A major is a migration, not an
update: it must carry its own review and its own CI run.
Both update groups (`frontend`, `github-actions` -- checkout v4.4.0 ->
v7.0.1 came through #123 the same way) now name `update-types: ["minor",
"patch"]` only. A Dependabot update matching no group opens its own PR,
so majors arrive one at a time; minor and patch still batch weekly as
before, so the noise stays low.
A new testset in `test/unit/test_install_pins.jl` pins the shape -- both
ecosystems declared, the frontend on `bun`, and no group whose
`update-types` admit `major` -- so a future group cannot silently go
back to batching majors. This is the same defence-in-depth the file
already applies to workflow pins: the config asserts the policy, and the
policy is enforced at the door.
## How to run its tests
From the repository root:
```
julia --project=. test/unit/test_install_pins.jl
```
All testsets pass locally (223 prior assertions plus the new 9).
## What this leaves out
No `open-pull-requests-limit` or other throttles: with majors unbatched
the default limit of 5 per ecosystem keeps the queue bounded, and the
first runs under this config (#123/#124) showed the volume is small. No
change to security updates, which follow their own path.
## Versions tested
Julia 1.13.1 locally (CI pins 1.12.5). No other tool touched.
Note: the commit is unsigned (the signing key's passphrase is
unavailable to the session); amend to sign if required.
---------
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/dependabot.yml | 18 ++++++++++++++++--
test/unit/test_install_pins.jl | 30 ++++++++++++++++++++++++++++++
2 files changed, 46 insertions(+), 2 deletions(-)
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 1454ca3b..2f06b99e 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -14,6 +14,15 @@
# allowlist policy, asserted by test/unit/test_install_pins.jl). Dependabot
# advances those pins to the current SHA of the tracked version, so the
# pinning policy itself survives updates.
+#
+# The #124 rule: minor and patch updates are batched, because they are
+# interchangeable in kind; a MAJOR (React 18->19, vite 6->8, TypeScript
+# 5->7 -- which #124 landed as one grouped PR, with the build already red
+# until #140 repaired it) is a migration, not an update. Every group below
+# therefore names its update-types as minor and patch ONLY: an update that
+# matches no group opens its own PR, carrying its own review and its own CI
+# run. test/unit/test_install_pins.jl asserts this shape, so a new group
+# cannot silently go back to batching majors.
version: 2
updates:
# The frontend is Bun-managed: package.json plus bun.lock, no npm/yarn/pnpm
@@ -25,8 +34,10 @@ updates:
commit-message:
prefix: "chore(deps)"
groups:
- frontend:
+ # Minor and patch in one weekly PR; majors arrive one PR each.
+ frontend-minor-and-patch:
patterns: ["*"]
+ update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
@@ -35,5 +46,8 @@ updates:
commit-message:
prefix: "chore(deps)"
groups:
- actions:
+ # Same rule as the frontend: a new major of an action (checkout
+ # v4.4.0 -> v7.0.1 came through #123) gets a PR of its own.
+ actions-minor-and-patch:
patterns: ["*"]
+ update-types: ["minor", "patch"]
diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl
index 3d84086d..abf9e6df 100644
--- a/test/unit/test_install_pins.jl
+++ b/test/unit/test_install_pins.jl
@@ -346,3 +346,33 @@ end
end
@test isempty(extras)
end
+
+@testset "dependabot never batches a major" begin
+ # #124 grouped every frontend update, and with nothing requiring the
+ # checks it merged five framework majors (React 18->19, vite 6->8,
+ # TypeScript 5->7, react-router-dom 6->7, plotly 2->4) in one PR, with
+ # tsc already red until #140 repaired it. A major is a migration, not an
+ # update, so it must open its own PR: a Dependabot group only claims an
+ # update when its patterns match AND its update-types admit it -- a
+ # dependency matching no group gets its own PR. Every group must
+ # therefore NAME its update-types, and never "major". The frontend must
+ # also stay on the "bun" ecosystem: the 2026-09 security-update runs
+ # proved this account's npm_and_yarn updater refuses a Bun-managed
+ # project (see the header of .github/dependabot.yml).
+ dependabot = YAML.load_file(joinpath(REPO_ROOT, ".github", "dependabot.yml"))
+ @test dependabot["version"] == 2
+ ecosystems = Dict{String,Any}()
+ for u in dependabot["updates"]
+ @test !haskey(ecosystems, u["package-ecosystem"])
+ ecosystems[u["package-ecosystem"]] = u
+ end
+ @test sort(collect(keys(ecosystems))) == ["bun", "github-actions"]
+ @test ecosystems["bun"]["directory"] == "/frontend"
+ for u in dependabot["updates"]
+ for (_, group) in get(u, "groups", Dict{String,Any}())
+ update_types = get(group, "update-types", nothing)
+ @test update_types !== nothing
+ @test !("major" in update_types)
+ end
+ end
+end
From 016d46db1a6c476c9c0849d1a15b0e1794c661f6 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 04:56:47 +0100
Subject: [PATCH 22/45] Proofs/permanova permutations (#127)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Adds `proofs/agda/MetaManifold/Permanova.agda`: machine-checked proofs
of the two things `run_permanova` in `src/analysis/analysis.jl` decides
for itself rather than taking from vegan — the reported `min_p_value`
(the `1/(B+1)` floor, never raised by more permutations, and attained)
and which terms have their p-value withheld (`_varies_within_any_block`,
exactly when no within-block permutation exchanges the term's labels).
With the import in `All.agda` (so the axiom audit reaches it), the
README's PERMANOVA section and theorem-to-code map entries, and the Agda
CI job's comment.
The Julia side this branch originally carried (the CLR method chain, the
vsearch helper, the server entry-point tests) reached main refined
through the upstream syncs — as #112, #113 and the occupancy line — so
nothing of it is re-landed here.
## Branch repair
The branch as first opened was built on upstream's history and carried
three superseded commits, so it was `CONFLICTING` against main — which
is also why no workflow posted a check: with no merge ref, GitHub cannot
start a `pull_request` workflow at all, and only the external scanners
ran. The branch is now a single commit rebased onto current main; every
content change is byte-identical to the original rebase commit
(`e9bb4e2`, whose conflict resolution already kept main's side of every
touched file — both sides were identical, so the diff applies clean).
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour)
- [ ] 🕳️ Soundness fix (fixes a checker/proof false-negative)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [x] 🔧 Build / CI / tooling (a new machine-checked proof module and its
CI wiring; no runtime code changes)
## How has this been verified?
- CI on this PR: the **Agda proofs** job type-checks
`MetaManifold/All.agda` (which now imports Permanova) with the pinned
Agda 2.6.4.3 + agda-stdlib 2.1, and the **Julia tests** job runs the
full unit suite including `test/unit/test_permanova.jl`, the code the
theorems model.
- The proof file is byte-identical to `e9bb4e2`'s, whose theorems were
checked there; the only re-applied deltas are the `All.agda` import, the
README entries and the ci.yml comment, each applied mechanically from
that commit's diff.
## Checklist
- [ ] My commits are **signed** (`git commit -S`). — the signing key's
passphrase was not available in the session that produced this commit
- [x] I ran the project's own checks/tests locally and they pass. — the
Julia pin tests and root-shape suite pass locally; the Agda type-check
is CI's pinned toolchain, which is authoritative here
- [x] New files carry the correct `SPDX-License-Identifier` (code/config
`MPL-2.0`, prose `CC-BY-SA-4.0`); I did not relicense existing files. —
`Permanova.agda` carries `AGPL-3.0-only`, matching every other file
under `proofs/agda/`
- [x] Docs are updated, and no public claim now overstates what the code
does. — the proofs README gains the PERMANOVA section and
theorem-to-code map entries
- [x] I have not introduced a soundness hole (or I have flagged where I
might have). — no postulates, no holes; `--safe --without-K` as
everywhere else in the tree
## Notes for reviewers
Nothing here changes runtime behaviour; the risk surface is the Agda
job's type-check and the axiom audit. If this unit is later proposed
upstream, it should be re-cut fresh from upstream `main` per the
absorption register's runbook, not replayed from this fork branch.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/workflows/ci.yml | 5 +-
proofs/agda/MetaManifold/All.agda | 1 +
proofs/agda/MetaManifold/Permanova.agda | 473 ++++++++++++++++++++++++
proofs/agda/README.md | 62 +++-
4 files changed, 538 insertions(+), 3 deletions(-)
create mode 100644 proofs/agda/MetaManifold/Permanova.agda
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 20262517..4de43ca4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -363,8 +363,9 @@ jobs:
path: lcov.info
retention-days: 14
- # Machine-checked proofs of the Benjamini-Hochberg adjustment and the TSS/RLE
- # scaling algebra (proofs/agda, proofs/agda/README.md).
+ # Machine-checked proofs of the Benjamini-Hochberg adjustment, the TSS/RLE
+ # scaling algebra and PERMANOVA's permutation bookkeeping (proofs/agda,
+ # proofs/agda/README.md).
# Agda publishes no 2.6.4.3 binary, and agda-stdlib 2.1 requires Agda 2.6.4.x,
# so the pair comes from Debian trixie, which packages exactly that pair with
# pre-built library interfaces. The image is pinned by digest and the two
diff --git a/proofs/agda/MetaManifold/All.agda b/proofs/agda/MetaManifold/All.agda
index 7cedd39a..ff5c18c0 100644
--- a/proofs/agda/MetaManifold/All.agda
+++ b/proofs/agda/MetaManifold/All.agda
@@ -12,3 +12,4 @@ module MetaManifold.All where
import MetaManifold.Prelude
import MetaManifold.BenjaminiHochberg
import MetaManifold.Scaling
+import MetaManifold.Permanova
diff --git a/proofs/agda/MetaManifold/Permanova.agda b/proofs/agda/MetaManifold/Permanova.agda
new file mode 100644
index 00000000..fbf0220a
--- /dev/null
+++ b/proofs/agda/MetaManifold/Permanova.agda
@@ -0,0 +1,473 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- PERMANOVA's permutation bookkeeping: the two things `run_permanova` in
+-- `src/analysis/analysis.jl` decides for itself rather than taking from vegan
+-- (branch fix/permanova-terms, PR #35), proved.
+--
+-- 1. `min_p_value = 1 / (n_perm + 1)`. vegan's permutation p-value is
+-- (k + 1)/(B + 1), where B is the number of permutations performed and k
+-- how many of them gave a statistic at least the observed one (0 ≤ k ≤ B).
+-- The theorems say that 1/(B + 1) is the smallest p-value B permutations
+-- can produce, that it is attained, and that no threshold below it can be
+-- reached.
+-- 2. Which terms have their p-value withheld. With permutations restricted
+-- to blocks, Julia withholds the p-value of a term when
+-- `_varies_within_any_block(values, blocks)` is false. The theorems say
+-- that this happens exactly when every within-block permutation leaves the
+-- term's labels where they were, so the permutations never exchange them.
+-- They also say that when it is true, some within-block permutation does
+-- exchange them. So no testable term is withheld, and no untestable term
+-- is reported.
+--
+-- What is NOT modelled, and why:
+--
+-- * The pseudo-F statistic and vegan's distribution of it. The p-value
+-- theorems take k as given; they are about the arithmetic of (k + 1)/(B + 1),
+-- not about which permutations exceed the observed F.
+-- * How many within-block permutations exist. B is whatever `permute`
+-- reports (`attr(perm_res, "control")$nperm`). Six blocks of two samples
+-- admit 2⁶ = 64 arrangements, the identity and 63 others, which is the 63 in
+-- test_permanova.jl. That count is the product of the blocks' factorials;
+-- it is not proved here.
+-- * Unblocked runs. Julia only asks `_varies_within_any_block` when some
+-- block label repeats (`blocked`). Without repeated blocks the
+-- permutations are unrestricted and nothing is withheld; that branch is
+-- Julia's `if`, not a theorem.
+-- * Julia's `isequal` on NaN or `missing`. Equality of labels and of blocks
+-- is an arbitrary decidable equality here.
+-- * Floating point. `1 / (n_perm + 1)` is exact here and a Float64 in Julia.
+--
+-- Theorems (names as in proofs/agda/README.md):
+--
+-- minP-attained (0 + 1)/(B + 1) is 1/(B + 1)
+-- p-floor every permutation p-value is at least 1/(B + 1)
+-- p-at-most-one ... and at most 1 when k ≤ B
+-- below-floor-unreachable a threshold below 1/(B + 1) is below every p-value
+-- minP-anti more permutations never raise the floor
+-- minP-63 with 63 permutations the floor is 1/64
+-- no-p-at-1%-with-63 with 63 permutations no p-value is at or below 0.01
+-- never-exchanged a block-constant term is fixed by every
+-- within-block permutation
+-- exchanged a term with two labels in one block is moved by a
+-- within-block permutation (a transposition)
+-- untestable⇔fixed block-constant ⇔ fixed by every within-block
+-- permutation
+-- varies-false⇔Constant the model of `_varies_within_any_block` returns
+-- false exactly on block-constant input
+-- withheld⇔never-exchanged ... so its false answer, which withholds the
+-- p-value, holds exactly when no within-block
+-- permutation exchanges the term's labels
+-- tested⇔exchanged ... and its true answer, which reports the p-value,
+-- holds exactly when some within-block permutation
+-- does
+-- constant-in-blocks, varies-in-a-block
+-- the two direct cases in test_permanova.jl
+-- fixture-group-withheld, fixture-run-tested
+-- the twelve-sample fixture in test_permanova.jl: group
+-- is never exchanged, run is
+
+{-# OPTIONS --safe --without-K #-}
+
+module MetaManifold.Permanova where
+
+open import Data.Bool.Base using (Bool; true; false)
+open import Data.Empty using (⊥-elim)
+open import Data.Fin.Base using (Fin)
+open import Data.Fin.Properties using (_≟_)
+open import Data.Fin.Permutation using (Permutation′; _⟨$⟩ʳ_; transpose)
+open import Data.Integer.Base as ℤ using (+_; +[1+_])
+import Data.Integer.Properties as ℤₚ
+open import Data.List.Base using (List; []; _∷_; _++_; [_]; length; lookup)
+open import Data.List.Membership.Propositional using (_∈_)
+open import Data.List.Membership.Propositional.Properties
+ using (∈-lookup; ∈-++⁺ˡ; ∈-++⁺ʳ; ∈-++⁻)
+open import Data.List.Properties using (++-assoc; ++-identityʳ)
+open import Data.List.Relation.Unary.Any using (here; index)
+open import Data.List.Relation.Unary.Any.Properties using (lookup-index)
+open import Data.Maybe.Base using (Maybe; just; nothing)
+open import Data.Nat.Base as ℕ using (ℕ; suc; z≤n; s≤s)
+import Data.Nat.Properties as ℕₚ
+open import Data.Product.Base using (Σ; ∃₂; _×_; _,_; proj₁; proj₂)
+open import Data.Rational.Base as ℚ using (ℚ; _/_; 1ℚ; _≤_; _<_)
+import Data.Rational.Properties as ℚₚ
+open import Data.Rational.Unnormalised.Base using (mkℚᵘ; *≤*)
+open import Data.Sum.Base using (inj₁; inj₂)
+open import Function.Bundles using (_⇔_; mk⇔; Equivalence)
+open import Relation.Binary.Definitions using (DecidableEquality)
+open import Relation.Binary.PropositionalEquality
+ using (_≡_; _≢_; refl; sym; trans; cong; subst)
+open import Relation.Nullary using (¬_; yes; no)
+open import Relation.Nullary.Decidable using (toWitness)
+open import Relation.Nullary.Negation using (contradiction)
+
+open import MetaManifold.Prelude using (numerator-monotone-≤; fromℚᵘ-mono-≤)
+
+------------------------------------------------------------------------
+-- 1. The smallest p-value B permutations can produce
+
+-- vegan's permutation p-value after B permutations, k of which gave a
+-- statistic at least the observed one.
+permP : ℕ → ℕ → ℚ
+permP B k = + suc k / suc B
+
+-- Julia's `min_p_value = 1 / (Int(n_perm) + 1)`.
+minP : ℕ → ℚ
+minP B = + 1 / suc B
+
+-- The floor is a p-value: the one reported when no permutation reaches the
+-- observed statistic.
+minP-attained : ∀ B → permP B 0 ≡ minP B
+minP-attained B = refl
+
+-- No permutation p-value is below the floor.
+p-floor : ∀ B k → minP B ≤ permP B k
+p-floor B k = fromℚᵘ-mono-≤ {mkℚᵘ (+ 1) B} {mkℚᵘ (+ suc k) B}
+ (numerator-monotone-≤ (+ 1) (+ suc k) B (ℤ.+≤+ (s≤s z≤n)))
+
+-- With k ≤ B, none is above 1.
+p-at-most-one : ∀ B k → k ℕ.≤ B → permP B k ≤ 1ℚ
+p-at-most-one B k k≤B = fromℚᵘ-mono-≤ {mkℚᵘ (+ suc k) B} {mkℚᵘ (+ 1) 0}
+ (*≤* (ℤₚ.≤-trans (ℤₚ.≤-reflexive (ℤₚ.*-identityʳ (+ suc k)))
+ (ℤₚ.≤-trans (ℤ.+≤+ (s≤s k≤B))
+ (ℤₚ.≤-reflexive (sym (ℤₚ.*-identityˡ +[1+ B ]))))))
+
+-- A significance threshold below the floor is below every p-value, so no term
+-- can be significant at it, whatever the data.
+below-floor-unreachable : ∀ B k {α} → α < minP B → α < permP B k
+below-floor-unreachable B k α
-->
-# Machine-checked proofs: Benjamini–Hochberg and size-factor scaling
+# Machine-checked proofs: Benjamini–Hochberg, size-factor scaling and PERMANOVA permutations
`proofs/agda/` holds Agda proofs of the exact-arithmetic properties of three
functions in `src/analysis/differential.jl`: `bh_adjust`, `tss_factors` and
@@ -12,6 +12,12 @@ differential-abundance change (branch `feat/differential-abundance-nb`,
commits `6c3e8b2` and `23dc89c`). These proofs depend on that change being
merged first.
+`MetaManifold/Permanova.agda` covers the two things `run_permanova` in
+`src/analysis/analysis.jl` decides for itself rather than taking from vegan:
+the reported `min_p_value`, and which terms have their p-value withheld
+(`_varies_within_any_block`). Both arrive with the PERMANOVA change (branch
+`fix/permanova-terms`, PR #35), which these proofs depend on.
+
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
@@ -62,6 +68,18 @@ vector.
- **Input validation** (finite and in `[0, 1]`) is the hypothesis
`All Valid ps`. The Julia `ArgumentError` paths are therefore assumptions
of the theorems, not theorems.
+- **`variesWithinAnyBlock`** follows the shape of
+ `_varies_within_any_block`. A list of `(value, block)` pairs stands in for
+ `zip(values, blocks)` and an association list for the `first_seen`
+ dictionary. As with `get!`, a block's value is stored only the first time
+ the block is seen, and the loop returns `true` at the first sample whose
+ value differs from its block's stored one. Equality of values and of blocks
+ is any decidable equality, standing in for `isequal`.
+- **Within-block permutations** are permutations of the samples
+ (`Permutation′ n` from the standard library) that keep every sample in its
+ block. A term's labels are a function from samples to values. The
+ p-value is vegan's `(k + 1)/(B + 1)` for `B` permutations, `k` of which
+ reached the observed statistic.
## Theorem map
@@ -90,6 +108,24 @@ 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.
+### PERMANOVA (`Permanova`)
+
+| Agda theorem | Plain statement | Julia | Test (`test/unit/test_permanova.jl`) |
+|---|---|---|---|
+| `minP-attained`, `p-floor` | `1/(B + 1)` is the smallest p-value `B` permutations can give, and it is given when no permutation reaches the observed statistic. | `min_p_value = 1 / (Int(n_perm) + 1)` | lines 73, 84 |
+| `p-at-most-one` | With `k ≤ B`, no p-value is above 1. | (vegan) | – |
+| `below-floor-unreachable` | A threshold below `1/(B + 1)` is below every p-value, so no term can meet it. | `min_p_value` | – |
+| `minP-anti` | More permutations never raise the floor. | – | – |
+| `minP-63`, `no-p-at-1%-with-63` | With 63 permutations the floor is `1/64`, and no p-value is at or below 0.01. | – | lines 83–84 |
+| `never-exchanged` | A term that is constant within every block is left in place by every within-block permutation, so its labels are never exchanged. | the reason for withholding | – |
+| `exchanged` | A term with two values in one block is moved by a within-block permutation: the swap of those two samples. | – | – |
+| `untestable⇔fixed` | Constant within every block ⇔ fixed by every within-block permutation. | – | – |
+| `varies-false⇔Constant` | The model of `_varies_within_any_block` returns `false` exactly when no block holds two different values. | `_varies_within_any_block` | lines 47–51 |
+| `withheld⇔never-exchanged` | So `untestable` (the function returned `false`) holds exactly when no within-block permutation exchanges the term's labels. | `untestable`, `withheld` | lines 92–93 |
+| `tested⇔exchanged` | And a p-value is reported (the function returned `true`) exactly when some within-block permutation does. No testable term is withheld. | `untestable`, `withheld` | line 98 |
+| `constant-in-blocks`, `varies-in-a-block` | The two direct cases in the test, by evaluation. | `_varies_within_any_block` | lines 49–50 |
+| `fixture-group-withheld`, `fixture-run-tested` | On the test's twelve samples (six individuals, two runs each, three individuals per group), `group` is never exchanged and `run` is. | `run_permanova` | lines 83–98 |
+
## Corrections to the requirement wording
- **R-BH-2a is true for the procedure.** An earlier archived note recorded
@@ -118,6 +154,16 @@ returns.
proved.
- **Floating point.** Every statement is exact. `Float64` rounding, and a
refusal caused by underflow to `0.0`, are outside the model.
+- **PERMANOVA: the pseudo-F statistic, and how many within-block
+ permutations exist.** The p-value theorems take `B` and `k` as given. `B`
+ is what `permute` reports, and `k` depends on the F distribution, neither of
+ which is modelled. Six blocks of two samples admit `2⁶ = 64`
+ arrangements, the identity and 63 others, which is the 63 in the test.
+ That count (the product of the blocks' factorials) is not proved.
+- **PERMANOVA: the unblocked case.** Julia asks `_varies_within_any_block`
+ only when some block label repeats (`blocked`). Otherwise the permutations
+ are unrestricted and nothing is withheld; that is Julia's `if`, not a
+ theorem.
- **Equivalence of the Agda and Julia code** is argued, not formalised. The
model mirrors the loop, and the two differences above are each
discharged by a proved lemma plus a short argument.
@@ -138,3 +184,17 @@ returns.
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.
+
+3. **The PERMANOVA proofs refuse a wrong model.** Each of these, planted in
+ a scratch copy of `Permanova.agda`, made Agda reject it:
+ - `variesWithinAnyBlock` never stores a block's first value;
+ - it returns `true` on a match instead of a mismatch;
+ - it never returns `true`;
+ - the p-value drops the `+ 1` from `k + 1`;
+ - the floor is `1/(B + 2)`;
+ - any permutation counts as within-block;
+ - the fixture's `group` differs inside one individual (then
+ `fixture-group-withheld` fails);
+ - the fixture's `run` is constant inside every individual (then
+ `fixture-run-tested` fails);
+ - the 1% claim is made at 2% (`1/50 > 1/64`).
From 4ea924741b85a80d028a6e2c94420c5d3f67a2ea Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 09:21:50 +0100
Subject: [PATCH 23/45] docs: the wiki pointer, differential abundance
documentation, and the community files (#143)
## What this changes
Three things a newcomer meets before any code (replaces the wedged #133,
whose head got stuck at the base commit during branch cleanup; same
branch, same change, clean start):
1. **A Documentation section in the README** pointing at the wiki in
[metadatastician/MetaManifold-Evidence, under
`docs/wikis/`](https://github.com/metadatastician/MetaManifold-Evidence/tree/main/docs/wikis)
-- the longer documentation for users, maintainers, developers and
theorists, with a Home page entry point.
2. **A Differential Abundance section in the README**, documenting the
tab end to end: the two-condition selection (exactly two runs or
pooled-run sub-groups, the first as reference so a positive fold change
means more abundant in the second), the rank list showing only ranks the
selected runs share, aggregation making a pooled run one condition, and
then the statistics as the code runs them -- counts summed per taxon at
the chosen rank with nameless rows pooled as `Unclassified` after the
read-count bounds and any applicable `analysis.exclude_categories`; each
taxon fitted with its own negative-binomial GLM `MASS::glm.nb(y ~ group
+ offset(log(size factor)))` on raw integer counts, no pseudocounts; a
Wald test on the group coefficient; Benjamini-Hochberg over the taxa
that produced a p-value; size factors from the whole matrix, centred.
Cross-references the existing `Configuring analysis` anchor for the
settings, and names the code (`src/analysis/differential.jl`) and the
route.
3. **The community files**: a bug report and a feature request issue
template (both telling reporters to search existing issues first, and to
raise a fork's own issues on that fork), a pull request template
carrying the PR checklist, and the Contributor Covenant code of conduct.
Per this fork's root-shape doctrine the code of conduct lives under
`.github/` with `CONTRIBUTING.md` -- the root keeps only authority files
and entry points, and `test/unit/test_install_pins.jl`'s bidirectional
root check stays green without touching its allowlist.
## How to run its tests
Documentation and `.github/` convention files; no code changes. Verified
locally on a branch cut from the current `main` tip (`655c4da`, i.e.
including the merged occupancy gate):
```
julia --project=. test/unit/test_install_pins.jl # 223/223, root shape 23/23
```
Every file and anchor the new sections reference exists
(`src/analysis/differential.jl`, the `analysis:` settings in
`pipeline.yml`, the `Configuring analysis` heading at line 623 that the
cross-reference at line 271 points at, this fork's `scripts/install.jl`
path untouched), and the templates are the issue-form YAML GitHub
expects.
## What this leaves out
No changes to any workflow, so nothing for the occupancy gate or the
action allow-list to weigh. The commit is unsigned (the signing key's
passphrase is unavailable to the session that produced it); amend to
sign if required. This same change is also proposed upstream as
JoshuaJewell/MetaManifold-WebUI#47, with the one fork-specific
difference noted above (the code of conduct's location).
## Versions tested
Julia 1.13.1 locally (CI pins 1.12.5). Nothing else touched.
---------
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/CODE_OF_CONDUCT.md | 83 ++++++++++++++++++++++
.github/ISSUE_TEMPLATE/bug_report.yml | 59 +++++++++++++++
.github/ISSUE_TEMPLATE/feature_request.yml | 41 +++++++++++
.github/pull_request_template.md | 43 +++++++++++
README.md | 79 ++++++++++++++++++--
5 files changed, 301 insertions(+), 4 deletions(-)
create mode 100644 .github/CODE_OF_CONDUCT.md
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/pull_request_template.md
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..b02898a4
--- /dev/null
+++ b/.github/CODE_OF_CONDUCT.md
@@ -0,0 +1,83 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement: the maintainer, [@JoshuaJewell](https://github.com/JoshuaJewell), using the contact details on their GitHub profile. All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..3cf88518
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,59 @@
+name: Bug report
+description: Something in MetaManifold does not work as documented.
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Search the existing issues first. Raise issues about a fork's own code on that fork.
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps or command
+ description: The command you ran, or the steps in the web interface, so that someone else can repeat them.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: What you expected
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: What happened
+ description: Include the error message, the server log lines, or a screenshot.
+ validations:
+ required: true
+ - type: input
+ id: julia
+ attributes:
+ label: Julia version
+ placeholder: output of `julia --version`
+ validations:
+ required: true
+ - type: input
+ id: r
+ attributes:
+ label: R version
+ placeholder: output of `R --version | head -1`
+ validations:
+ required: true
+ - type: input
+ id: browser
+ attributes:
+ label: Browser and version
+ validations:
+ required: true
+ - type: input
+ id: commit
+ attributes:
+ label: MetaManifold commit
+ placeholder: output of `git rev-parse --short HEAD`
+ - type: textarea
+ id: config
+ attributes:
+ label: Relevant configuration
+ description: The keys from `pipeline.yml` or the run's `run_config.yml` that bear on the problem.
+ render: yaml
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..312c2aec
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,41 @@
+name: Feature request
+description: Suggest a new feature or a change to an existing one.
+labels: [enhancement]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Search the existing issues first. To change an architectural decision, describe the change
+ here before writing any code. The settled decisions in CONTRIBUTING.md can be discussed in an
+ issue, but a pull request does not reopen them.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: What you are trying to do, and what stops you now.
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed change
+ description: What MetaManifold should do instead. For a statistical method, name the method and a reference for it.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area
+ options:
+ - Pipeline stages
+ - Analysis and statistics
+ - Web interface
+ - Configuration
+ - Installation and toolchain
+ - Other
+ validations:
+ required: true
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 00000000..a17d0de6
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,43 @@
+## Summary
+
+
+
+Closes #
+
+## Type of change
+
+- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
+- [ ] ✨ New feature (non-breaking change that adds functionality)
+- [ ] 💥 Breaking change (would change existing behaviour, a config key or an API response)
+- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it could not support)
+- [ ] 📖 Documentation
+- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
+- [ ] ⚡ Performance
+- [ ] 🔧 Build / CI / tooling
+
+## How has this been verified?
+
+
+
+Tested with Julia `…`, R `…` and bun `…`.
+
+## What this leaves out
+
+
+
+## Checklist
+
+- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`; see CONTRIBUTING.md) and has no merge commits from `main`.
+- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend && bun run test` for frontend changes) and they pass.
+- [ ] CI ran on this PR and passes.
+- [ ] No generated or vendored files, and no lockfile change the work does not need (or the reason is given above).
+- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff` and the licence and acknowledgement sections of `README.md` unchanged.
+- [ ] Every number shown to a user is computed from their data. When a method cannot run it raises an explicit error.
+- [ ] Docs are updated, and no claim in them overstates what the code does.
+
+## Notes for reviewers
+
+
diff --git a/README.md b/README.md
index bdf25c98..dafa4711 100644
--- a/README.md
+++ b/README.md
@@ -74,9 +74,16 @@ Once a run completes, analysis is performed on request through the web UI, both
- Pipeline stage read-count summaries
- NMDS ordination (Bray-Curtis, via R/vegan)
- PERMANOVA, with PERMDISP to check for differences in dispersion (via R/vegan)
+- Differential abundance between two conditions: a negative-binomial model per taxon (via R/MASS) with Benjamini-Hochberg adjusted p-values (see [Differential abundance](#differential-abundance))
Counts may be normalised before analysis (none, rarefaction to a fixed or auto-resolved depth, or scaling with ranked subsampling (SRS), which reaches the depth by scaling counts and keeps more of the community structure), and contamination-flagged taxa may be included or excluded. Ordinations can also apply a Hellinger transform before the Bray-Curtis dissimilarity. Composition charts can be drawn as a grid faceted on any two of run, group and sub-group. All analysis charts are returned as Plotly JSON and rendered interactively in the browser.
+## Documentation
+
+Longer documentation for users, maintainers, developers and theorists is kept as a wiki in
+[metadatastician/MetaManifold-Evidence, under `docs/wikis/`](https://github.com/metadatastician/MetaManifold-Evidence/tree/main/docs/wikis).
+Start at its [Home page](https://github.com/metadatastician/MetaManifold-Evidence/blob/main/docs/wikis/Home.md).
+
## Prerequisites
- **Julia** 1.12 (installed by `install.sh` if missing, and pinned to the Manifest version for this directory)
@@ -240,6 +247,67 @@ The composition view classifies each ASV/OTU into a biological category and rend
+### Differential abundance
+
+The Differential Abundance tab of a study's analysis workspace tests each taxon
+for a difference in abundance between two conditions. Select exactly two runs or
+pooled-run sub-groups, a results table and a rank. The first condition is the
+reference, so a positive fold change means the taxon is more abundant in the
+second. The rank list shows only the ranks the selected runs share. With
+aggregation on, a pooled run counts as one condition rather than one per
+sub-group.
+
+Counts are summed per taxon at the chosen rank, with rows that have no name at
+that rank pooled as `Unclassified`. This happens after the read-count bounds and
+any `analysis.exclude_categories` entry that applies to `differential`. Each
+taxon is fitted with its own negative-binomial GLM, `MASS::glm.nb(y ~ group +
+offset(log(size factor)))`, on the raw integer read counts. There are no
+pseudocounts. The group coefficient gets a Wald test, and Benjamini-Hochberg
+adjustment runs over the taxa that produced a p-value. Size factors come from
+the whole count matrix and are centred to a geometric mean of 1. The code is in
+`src/analysis/differential.jl`, and the route in `src/server/routes/analysis.jl`.
+
+Settings live under `analysis.differential` (see
+[Configuring analysis](#configuring-analysis-analysis-in-pipelineyml)):
+
+- `offset`: `tss` (default) uses each sample's total reads. `rle` uses the
+ median of ratios over the taxa with reads in every sample.
+- `min_prevalence`: a fraction from 0 to 1 (default 0). A taxon with reads in a
+ smaller fraction of the samples is not fitted.
+
+The result is a volcano plot and a table. The plot draws log2 fold change
+against -log10 of the raw p-value, coloured by whether the adjusted p-value is
+below 0.05. Taxa without an adjusted p-value are left off the plot but stay in
+the table. Each table row has a status:
+
+- `ok`: the model fitted.
+- `boundary`: the model fitted, but the dispersion parameter theta reached a
+ bound (at least 1e7, so effectively Poisson, or at most 1e-8). These taxa
+ keep their p-value and stay in the adjusted family.
+- `failed`: no p-value, with the reason. Causes are counts that are constant
+ across samples, an error from `glm.nb`, a fit that did not converge, an
+ aliased group coefficient, or a non-finite estimate.
+- `filtered`: below `min_prevalence`, so not fitted.
+
+A failed or filtered taxon never gets a stand-in p-value, and it is not counted
+in the adjustment. The table downloads as `differential_abundance.csv` and can
+be added to the report. The CSV starts with `#` lines that record the
+comparison, method and settings.
+
+The test refuses, with an explicit error, when:
+
+- the selection is not exactly two conditions, or both are the same;
+- the two conditions resolve to different ranks;
+- a condition has no results table, no taxonomy columns or no sample columns,
+ or the read-count bounds remove all its samples;
+- `offset` or `min_prevalence` is invalid;
+- a count is not a non-negative integer, a condition has no samples, or there
+ are fewer than 3 samples in total;
+- `tss` meets a sample with no reads, or `rle` finds no taxon with reads in
+ every sample (use `tss`);
+- the R package MASS cannot be loaded;
+- no taxon could be fitted at all.
+
### Figures and report
The Figures tab lays analysis charts out on a page (A4, letter or a journal
@@ -554,13 +622,13 @@ Each entry names a filter in the `filters:` library of `config/composition.yml`.
### Configuring analysis (`analysis:` in `pipeline.yml`)
-Controls the defaults applied to the analysis charts (alpha diversity, taxa bar, NMDS, etc.). Per-chart choices such as the taxonomic rank and relative or absolute abundance are made in the UI.
+Controls the defaults applied to the analysis charts (alpha diversity, taxa bar, NMDS, differential abundance, etc.). Per-chart choices such as the taxonomic rank and relative or absolute abundance are made in the UI.
```yaml
analysis:
exclude_categories: # composition categories to drop from figures; [] to keep all
- - {set: contamination, category: Contaminant, apply_to: [diversity, taxa, venn]}
- # apply_to surfaces: diversity | taxa | composition | venn
+ - {set: contamination, category: Contaminant, apply_to: [diversity, taxa, venn, differential]}
+ # apply_to surfaces: diversity | taxa | composition | venn | differential
# (omit apply_to to act on every surface)
alpha: # richness, Shannon and Simpson
normalisation: none # none | rarefy | srs (scaling with ranked subsampling)
@@ -577,6 +645,9 @@ analysis:
depth: 0 # rarefy/srs reads per sample; 0 = smallest non-empty sample
nmds:
max_stress: 0.2 # warn if NMDS stress exceeds this value
+ differential: # negative-binomial differential abundance (MASS::glm.nb)
+ offset: tss # tss | rle (rle is refused when no taxon has reads in every sample)
+ min_prevalence: 0.0 # fraction of samples, 0 to 1, a taxon needs reads in to be tested
```
### Configuring taxonomic filtering (`filters:` in `config/composition.yml`)
@@ -762,7 +833,7 @@ The server exposes a REST API under `/api/v1/`. Key endpoint groups:
| Results | `GET/POST/DELETE .../results/tables/...` | List, query, filter, save, export (`.xlsx`), and delete tables; OTU member drill-down |
| QC | `GET .../results/qc`, `GET .../results/dada2` | MultiQC report metadata and DADA2 figures, logs, stats |
| Analysis | `POST .../runs/{run}/analysis/{alpha,chart}`, `GET .../runs/{run}/analysis/{pipeline-stats,ranks}` | Per-run charts and rank discovery |
-| Cross-run | `POST /studies/{study}/analysis/{alpha,chart,chart-facet,nmds,permanova,venn,publication-tables}` | Comparison, faceted charts, NMDS, PERMANOVA, taxon overlap and publication tables across runs |
+| Cross-run | `POST /studies/{study}/analysis/{alpha,chart,chart-facet,nmds,permanova,venn,publication-tables,differential}` | Comparison, faceted charts, NMDS, PERMANOVA, taxon overlap, publication tables and differential abundance across runs |
| Composition | `POST .../runs/{run}/composition/summary`, `POST .../composition/{source}/query`, `POST .../composition/{source}/distinct/{column}` | Organism-composition summaries and tables for a run |
| Composition library | `GET /composition`, `POST/DELETE /composition/{filters,sets}/{name}`, `GET /category-sets`, `POST/DELETE /category-sets/{name}` | Edit the filters and category sets in `config/composition.yml` |
| Reference trees | `GET/POST /reference-trees`, `GET/PUT/DELETE /reference-trees/{id}`, `GET/PUT .../fasta`, `POST .../run`, `GET .../log/{step}`, `GET .../qc/{align,trim}`, `GET .../alignment/{raw,trimmed}`, `GET .../treefile`, `POST .../trim-preview` | Build and inspect the shared reference trees |
From 88f7d461a9612015563341903750836fe7009cd6 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell"
Date: Thu, 15 Oct 2026 09:36:22 +0100
Subject: [PATCH 24/45] docs: optional environment fields in the bug form; the
clr_welch settings in the README
Both CodeRabbit findings from #143, accepted; the PR merged before the
fixes could ride on it, so they land here.
- The bug report form required R and browser versions from everyone: a
command-line reporter has no browser to name, and a web-interface
reporter often cannot know the server's R version, so both fields
forced placeholder answers. They are now optional, each with the
wording that says when it applies; Julia stays required, since every
reporter runs the Julia server.
- The differential-abundance README section described only the default
nb_glm method and listed only offset and min_prevalence, presenting
the analysis as NB-only; clr_welch and replacement_delta have been in
the pipeline config and code since the upstream syncs. The section
now documents method and replacement_delta, notes that offset applies
to nb_glm only, and states clr_welch's effect as a CLR difference,
not a fold change.
The wording follows config/defaults/pipeline.yml's own comments for the
same keys.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
---
.github/ISSUE_TEMPLATE/bug_report.yml | 6 ++----
README.md | 27 ++++++++++++++++++++-------
2 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 3cf88518..f790ca1b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -37,15 +37,13 @@ body:
id: r
attributes:
label: R version
+ description: Only if the problem touches the R runtime; leave empty if you do not know it.
placeholder: output of `R --version | head -1`
- validations:
- required: true
- type: input
id: browser
attributes:
label: Browser and version
- validations:
- required: true
+ description: Only for problems in the web interface; leave empty for command-line reports.
- type: input
id: commit
attributes:
diff --git a/README.md b/README.md
index 16b041a8..a0752242 100644
--- a/README.md
+++ b/README.md
@@ -260,20 +260,33 @@ sub-group.
Counts are summed per taxon at the chosen rank, with rows that have no name at
that rank pooled as `Unclassified`. This happens after the read-count bounds and
any `analysis.exclude_categories` entry that applies to `differential`. Each
-taxon is fitted with its own negative-binomial GLM, `MASS::glm.nb(y ~ group +
-offset(log(size factor)))`, on the raw integer read counts. There are no
-pseudocounts. The group coefficient gets a Wald test, and Benjamini-Hochberg
-adjustment runs over the taxa that produced a p-value. Size factors come from
-the whole count matrix and are centred to a geometric mean of 1. The code is in
+taxon is fitted by the configured method. The default, `nb_glm`, fits a
+negative-binomial GLM, `MASS::glm.nb(y ~ group + offset(log(size factor)))`,
+on the raw integer read counts; there are no pseudocounts, the group
+coefficient gets a Wald test, and the effect is a log2 fold change. Size
+factors come from the whole count matrix and are centred to a geometric mean
+of 1. The alternative, `clr_welch`, runs Welch's t-test on centred log-ratios
+over the taxa passing `min_prevalence`, after `zCompositions::cmultRepl`
+replaces their zeros; its effect is a difference in CLR units, not a fold
+change. Under either method, Benjamini-Hochberg adjustment runs over the taxa
+that produced a p-value. The code is in
`src/analysis/differential.jl`, and the route in `src/server/routes/analysis.jl`.
Settings live under `analysis.differential` (see
[Configuring analysis](#configuring-analysis-analysis-in-pipelineyml)):
+- `method`: `nb_glm` (default) or `clr_welch`, as above.
- `offset`: `tss` (default) uses each sample's total reads. `rle` uses the
- median of ratios over the taxa with reads in every sample.
+ median of ratios over the taxa with reads in every sample. Used by `nb_glm`
+ only; `clr_welch` ignores it, since log-ratios carry no library size.
- `min_prevalence`: a fraction from 0 to 1 (default 0). A taxon with reads in a
- smaller fraction of the samples is not fitted.
+ smaller fraction of the samples is not fitted. 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.
+- `replacement_delta`: `clr_welch` only; `cmultRepl`'s `frac`, between 0 and 1
+ (default 0.65). An imputed proportion above its taxon's smallest observed
+ proportion is lowered to this fraction of it. A replaced zero is not a
+ measurement.
The result is a volcano plot and a table. The plot draws log2 fold change
against -log10 of the raw p-value, coloured by whether the adjusted p-value is
From 69e95588795d34fdc283745a6bce7f3434b384a3 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 15 Oct 2026 09:37:01 +0100
Subject: [PATCH 25/45] chore(deps): bump actions/checkout from 4.4.0 to 7.0.1
(#144)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.4.0
to 7.0.1.
Release notes
Sourced from actions/checkout's
releases .
v7.0.1
What's Changed
Full Changelog : https://github.com/actions/checkout/compare/v7...v7.0.1
v7.0.0
What's Changed
New Contributors
Full Changelog : https://github.com/actions/checkout/compare/v6.0.3...v7.0.0
v6.1.0
What's Changed
https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/
for more details about this breaking change
Full Changelog : https://github.com/actions/checkout/compare/v6.0.3...v6.1.0
v6.0.3
What's Changed
New Contributors
Full Changelog : https://github.com/actions/checkout/compare/v6...v6.0.3
v6.0.2
What's Changed
... (truncated)
Changelog
Sourced from actions/checkout's
changelog .
Changelog
v7.0.1
v7.0.0
v6.0.3
v6.0.2
v6.0.1
v6.0.0
v5.0.1
v5.0.0
v4.3.1
v4.3.0
v4.2.2
v4.2.1
... (truncated)
Commits
[](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)
Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.
[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)
---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/occupancy.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/occupancy.yml b/.github/workflows/occupancy.yml
index 0513de5c..0068dab5 100644
--- a/.github/workflows/occupancy.yml
+++ b/.github/workflows/occupancy.yml
@@ -93,7 +93,7 @@ jobs:
# both, and a stacked PR's merge base sits further back still.
- name: Checkout the change
if: github.event_name == 'pull_request'
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false # nothing here pushes
fetch-depth: 0
From 8f916cd8d7b468143afa8212c03e4a79d511eefa Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 09:53:10 +0100
Subject: [PATCH 26/45] docs: optional environment fields in the bug form; the
clr_welch settings in the README (#145)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
Both CodeRabbit findings from #143, accepted; the PR merged before the
fixes could ride on it, so they land here.
1. **Bug report form** (`bug_report.yml`) — the R and browser version
fields were required from everyone. A command-line reporter has no
browser to name, and a web-interface reporter often cannot know the
server's R version, so both forced placeholder answers. They are now
optional, each saying when it applies; Julia stays required, since every
reporter runs the Julia server.
2. **README differential-abundance section** — it described only the
default `nb_glm` method and listed only `offset` and `min_prevalence`,
presenting the analysis as NB-only. It now documents `method` and
`replacement_delta`, notes `offset` applies to `nb_glm` only, and states
`clr_welch`'s effect as a CLR difference, not a fold change. Wording
follows `config/defaults/pipeline.yml`'s own comments for the same keys.
The identical commit is also on `upstream/docs-community` (upstream PR
JoshuaJewell#56), so the two copies stay in step.
## Verification
Docs-only; no code path changes. The form renders in GitHub's template
preview; the README section matches the config keys enumerated in
`config/defaults/pipeline.yml` and the `DifferentialConfig` contract in
`src/analysis/differential.jl`.
## Notes
The commit is unsigned — the signing key's passphrase was not available
in the session that produced it. The LanguageTool "loose punctuation"
notes from the same review are not acted on: they flag the README's
standard markdown list formatting, which the whole file uses.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/ISSUE_TEMPLATE/bug_report.yml | 6 ++----
README.md | 27 ++++++++++++++++++++-------
2 files changed, 22 insertions(+), 11 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 3cf88518..f790ca1b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -37,15 +37,13 @@ body:
id: r
attributes:
label: R version
+ description: Only if the problem touches the R runtime; leave empty if you do not know it.
placeholder: output of `R --version | head -1`
- validations:
- required: true
- type: input
id: browser
attributes:
label: Browser and version
- validations:
- required: true
+ description: Only for problems in the web interface; leave empty for command-line reports.
- type: input
id: commit
attributes:
diff --git a/README.md b/README.md
index dafa4711..bbdd938d 100644
--- a/README.md
+++ b/README.md
@@ -260,20 +260,33 @@ sub-group.
Counts are summed per taxon at the chosen rank, with rows that have no name at
that rank pooled as `Unclassified`. This happens after the read-count bounds and
any `analysis.exclude_categories` entry that applies to `differential`. Each
-taxon is fitted with its own negative-binomial GLM, `MASS::glm.nb(y ~ group +
-offset(log(size factor)))`, on the raw integer read counts. There are no
-pseudocounts. The group coefficient gets a Wald test, and Benjamini-Hochberg
-adjustment runs over the taxa that produced a p-value. Size factors come from
-the whole count matrix and are centred to a geometric mean of 1. The code is in
+taxon is fitted by the configured method. The default, `nb_glm`, fits a
+negative-binomial GLM, `MASS::glm.nb(y ~ group + offset(log(size factor)))`,
+on the raw integer read counts; there are no pseudocounts, the group
+coefficient gets a Wald test, and the effect is a log2 fold change. Size
+factors come from the whole count matrix and are centred to a geometric mean
+of 1. The alternative, `clr_welch`, runs Welch's t-test on centred log-ratios
+over the taxa passing `min_prevalence`, after `zCompositions::cmultRepl`
+replaces their zeros; its effect is a difference in CLR units, not a fold
+change. Under either method, Benjamini-Hochberg adjustment runs over the taxa
+that produced a p-value. The code is in
`src/analysis/differential.jl`, and the route in `src/server/routes/analysis.jl`.
Settings live under `analysis.differential` (see
[Configuring analysis](#configuring-analysis-analysis-in-pipelineyml)):
+- `method`: `nb_glm` (default) or `clr_welch`, as above.
- `offset`: `tss` (default) uses each sample's total reads. `rle` uses the
- median of ratios over the taxa with reads in every sample.
+ median of ratios over the taxa with reads in every sample. Used by `nb_glm`
+ only; `clr_welch` ignores it, since log-ratios carry no library size.
- `min_prevalence`: a fraction from 0 to 1 (default 0). A taxon with reads in a
- smaller fraction of the samples is not fitted.
+ smaller fraction of the samples is not fitted. 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.
+- `replacement_delta`: `clr_welch` only; `cmultRepl`'s `frac`, between 0 and 1
+ (default 0.65). An imputed proportion above its taxon's smallest observed
+ proportion is lowered to this fraction of it. A replaced zero is not a
+ measurement.
The result is a volcano plot and a table. The plot draws log2 fold change
against -log10 of the raw p-value, coloured by whether the adjusted p-value is
From 7d5e9a427f7582094c9aa94e99481e8eed3e38c5 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 10:19:39 +0100
Subject: [PATCH 27/45] docs(repo): AGENTS.md, recording the traps that are
invisible until they bite (#146)
## What this changes
An `AGENTS.md` at the repository root -- the agents.md convention that
coding agents (Vibe, Claude Code, Codex and the rest) load automatically
at startup -- recording the two traps this session measured the hard
way, so the next agent does not have to:
1. **renv.lock and ci.yml's two hardcoded R-package verification lists
are coupled.** Removing a package from the lock fails CI at install
verification, nowhere near the code that stopped using it. MEASURED on
JoshuaJewell/MetaManifold-WebUI#57: dplyr left the lock and both checks
("Install R packages" and "Verify R packages visible from RCall") failed
until the lists were updated in the same PR. The instruction is
mechanical: any lock change greps `.github/workflows/ci.yml` for the
package name and updates both lists in the same commit. Plus the
corollary: `tibble` cannot leave the lock while dada2 is in it (`dada2
-> ggplot2 -> tibble` is dada2's own chain), and the lock's
`Requirements` arrays are not evidence -- only a handful of records
carry one.
2. **Commit signing needs the SSH agent, not a passphrase prompt.** A
non-interactive shell has no askpass, so signing fails with a misleading
error unless the agent's fixed socket (`/tmp/lcb-ssh-agent.sock`,
exported by `~/.profile` when it exists) is reachable. The instruction:
check the agent with `ssh-add -l`; never work around a signing failure
with `commit.gpgsign=false`.
The root-shape allowlist in `test/unit/test_install_pins.jl` gains
`AGENTS.md`: the convention only loads it from the root, so it cannot
live under `.github/` with the other health files, and the bidirectional
root check would otherwise (correctly) refuse it.
## How to run its tests
```
julia --project=. test/unit/test_install_pins.jl # 223/223, root shape 24/24
```
Documentation only otherwise: no code, workflow or config change. The
commit is signed.
## What this leaves out
Nothing upstreaming: this file is fork tooling (upstream has no
agents.md convention in use, and its own CONTRIBUTING already governs
PRs there). The same coupling exists in upstream's ci.yml and is
documented in the comments of JoshuaJewell/MetaManifold-WebUI#57 itself.
## Versions tested
Julia 1.13.1 locally (CI pins 1.12.5). Nothing else touched.
---------
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
AGENTS.md | 35 ++++++++++++++++++++++++++++++++++
test/unit/test_install_pins.jl | 5 +++++
2 files changed, 40 insertions(+)
create mode 100644 AGENTS.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..d4164769
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,35 @@
+# AGENTS.md
+
+Instructions for coding agents working in this repository. Read the contribution
+rules in `.github/CONTRIBUTING.md` first; this file records only the traps that
+are invisible until they bite.
+
+## renv.lock and CI's hardcoded R-package lists are coupled
+
+CI installs R from `renv.lock` (`renv::restore()`), but `.github/workflows/ci.yml`
+names the expected packages in TWO hardcoded verification lists:
+
+- the `Install R packages` step's `require()` check ("failed to install"), and
+- the `Verify R packages visible from RCall` step.
+
+Removing a package from `renv.lock` therefore fails the run at install
+verification, nowhere near the code that stopped using it. MEASURED on
+JoshuaJewell/MetaManifold-WebUI#57 (2026-10-15): dplyr left the lock and both
+checks failed until the lists were updated in the same PR. Whenever a change
+touches `renv.lock`, grep `.github/workflows/ci.yml` for every added or removed
+package name and update both lists in the same commit.
+
+Corollary, measured the same way: `tibble` cannot leave the lock while dada2 is
+in it. `dada2 -> ggplot2 -> tibble` is dada2's own dependency chain, independent
+of anything this project's code does; the lock's `Requirements` arrays are not
+evidence (only a handful of records carry one).
+
+## Commit signing needs the SSH agent, not a passphrase prompt
+
+`commit.gpgsign` is on, with a passphrase-protected SSH key. A non-interactive
+shell has no askpass and no terminal for a prompt, so signing fails with a
+misleading "failed to write commit object" unless the agent is reachable:
+`SSH_AUTH_SOCK=/tmp/lcb-ssh-agent.sock` (the fixed socket; `~/.profile` exports
+it when the socket exists). Never work around this with
+`commit.gpgsign=false` — check the agent first with
+`SSH_AUTH_SOCK=/tmp/lcb-ssh-agent.sock ssh-add -l`.
diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl
index abf9e6df..93f63624 100644
--- a/test/unit/test_install_pins.jl
+++ b/test/unit/test_install_pins.jl
@@ -333,6 +333,11 @@ end
# configs (codecov.yml) live under .github/.
".github", "bench", "config", "data", "docs", "frontend",
"proofs", "scripts", "src", "test",
+ # Agent instructions (the agents.md convention): coding agents load
+ # this at startup, and it only works from the root, which is why it
+ # is listed here rather than living under .github/ with the other
+ # health files.
+ "AGENTS.md",
# VCS housekeeping.
".gitignore",
]
From f4a214e4430568151e31c0e83232e52d7db4d296 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 10:50:59 +0100
Subject: [PATCH 28/45] docs(readme): a Community section pointing at the open
discussions (#153)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
Adds a short Community section to the README, between Quick start and
Web interface, linking the six seeded Discussions (#147 the charter,
#148 introduce yourself, #149 the mathematical foundations thread, #150
ideas, #151 show and tell, #152 Q&A) plus the one house norm that
matters for a help culture here: paste the `attestation.yml` versions
block when asking why results differ.
Docs-only; no code paths. CI will confirm nothing else moved.
## Notes
The commit is unsigned — the signing key's passphrase was not available
in the session that produced it.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
README.md | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/README.md b/README.md
index bbdd938d..db9a5745 100644
--- a/README.md
+++ b/README.md
@@ -187,6 +187,18 @@ precompile_workload = false
The server then makes those requests itself in the background as it starts, so the first page visit does not wait on compilation.
+## Community
+
+[Discussions](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions) are open, and they are where the tool's direction is argued out:
+
+- [Introduce yourself](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions/148) — what you run through MetaManifold, and how it went.
+- [Ideas](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions/150) — what the tool should do next; small conveniences and whole methods both welcome.
+- [The mathematical foundations thread](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions/149) — the statistics underneath the analyses: what is proved, what is questionable, what deserves a proof.
+- [Show and tell](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions/151) — figures, publication tables and placement trees that came out of a run.
+- [Q&A](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions/152) — setup, usage and interpretation. A reproducible defect is a bug report, not a Q&A.
+
+One house norm: runs record their exact toolchain in `attestation.yml`; paste its versions block when asking why results differ.
+
## Web interface
The browser interface is the primary way to drive MetaManifold. Beyond creating studies and launching runs, it offers the following:
From 3fb01926371021cf0988315b3ac03a1806b72675 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 11:13:55 +0100
Subject: [PATCH 29/45] proofs(agda): the significance reporting discipline --
OND applied to a result that did not run (#154)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
A new Agda module, `proofs/agda/MetaManifold/Significance.agda`,
machine-checking the reporting rule `run_alpha_significance`'s own
docstring carries: *"False means no test was performed, so neither
omnibus nor pairwise may be presented as a finding."*
It applies the **Observational Null Disclosure** discipline (the OND
pillar of hyperpolymath/absolute-zero) to that rule. The four statuses
are modelled exactly (`:computed`, `:r_unavailable`,
`:no_paired_samples`, `:test_failed`), and `AlphaSignificance` gets two
constructors: the omnibus p-value and pairwise p-values exist **only**
in the one whose status is `computed`; every other status carries a
proof it is not `computed` and a reader-facing reason.
- `publish` and `pairwise-published` read numbers only with evidence the
test ran. On the unrun branch the evidence would be an inhabitant of
`false ≡ true`, which does not exist — so the branch cannot produce a
number at all: **refused by the typechecker**, where the Julia testset
can only assert it.
- `reason` is the dual: the reader-facing explanation is readable only
from a result that did not run.
- `unrun-status-not-computed` / `computed-status`: a result is readable
as numbers or as a reason, never both — the constructors cannot meet.
So the answer to "can an unrun test publish a funny result?" stops being
a guarded code path and becomes a typed impossibility.
## How to run its tests
```
cd proofs/agda && agda MetaManifold/All.agda # Agda 2.6.4.3 + agda-stdlib 2.1
bash proofs/tests/axiom-audit.sh # 6 modules reachable, clean
julia --project=. test/unit/test_r_runtime.jl # the mapped Julia behaviour
```
Verified locally: the full proof tree type-checks (`--safe
--without-K`), the audit reports **6 modules reachable and clean**, and
the pin suite passes **223/223**. The README's new section maps each
theorem to the `test_r_runtime.jl` testsets that pin the Julia; the Agda
proofs job's comment names the fourth proof subject.
## What this leaves out
Stated in the module and README plainly: the statistics themselves are
not modelled (this is about what may be *read* from a result, not how it
was computed); the pairwise table is modelled as its p-value list, not
the DataFrame; the model is exact rationals where the Julia holds
`Float64`. No Julia code changes — the module certifies behaviour `main`
already has.
## Versions tested
Agda 2.6.4.3 + agda-stdlib 2.1 (the CI job's pinned pair); Julia 1.13.1
locally for the pin suite. The commit is signed.
---------
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/workflows/ci.yml | 4 +-
proofs/agda/MetaManifold/All.agda | 1 +
proofs/agda/MetaManifold/Significance.agda | 106 +++++++++++++++++++++
proofs/agda/README.md | 19 ++++
4 files changed, 128 insertions(+), 2 deletions(-)
create mode 100644 proofs/agda/MetaManifold/Significance.agda
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4de43ca4..a9380f22 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -364,8 +364,8 @@ jobs:
retention-days: 14
# Machine-checked proofs of the Benjamini-Hochberg adjustment, the TSS/RLE
- # scaling algebra and PERMANOVA's permutation bookkeeping (proofs/agda,
- # proofs/agda/README.md).
+ # scaling algebra, PERMANOVA's permutation bookkeeping and the significance
+ # reporting discipline (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/proofs/agda/MetaManifold/All.agda b/proofs/agda/MetaManifold/All.agda
index ff5c18c0..0770f154 100644
--- a/proofs/agda/MetaManifold/All.agda
+++ b/proofs/agda/MetaManifold/All.agda
@@ -13,3 +13,4 @@ import MetaManifold.Prelude
import MetaManifold.BenjaminiHochberg
import MetaManifold.Scaling
import MetaManifold.Permanova
+import MetaManifold.Significance
diff --git a/proofs/agda/MetaManifold/Significance.agda b/proofs/agda/MetaManifold/Significance.agda
new file mode 100644
index 00000000..fcf0f57c
--- /dev/null
+++ b/proofs/agda/MetaManifold/Significance.agda
@@ -0,0 +1,106 @@
+-- SPDX-License-Identifier: AGPL-3.0-only
+-- SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+--
+-- The significance reporting discipline: what a test that did not run may be
+-- read as, proved. `run_alpha_significance` in `src/analysis/analysis.jl`
+-- returns an `AlphaSignificance` whose status distinguishes a computed
+-- omnibus result from an unavailable or failed test (`:r_unavailable`,
+-- `:no_paired_samples`, `:test_failed`), and whose docstring carries the
+-- rule: "False means no test was performed, so neither `omnibus` nor
+-- `pairwise` may be presented as a finding."
+--
+-- The theorems here apply the Observational Null Disclosure discipline
+-- (hyperpolymath/absolute-zero, the OND pillar) to that rule:
+--
+-- 1. `publish` reads the omnibus p-value only from a result whose test
+-- ran, and requires evidence that it ran. On a result that did not run,
+-- the evidence would be an inhabitant of `false ≡ true`, which does not
+-- exist: the branch cannot produce a number, refused by the typechecker
+-- -- not asserted by a test.
+-- 2. `pairwise-published` is the same for the pairwise table.
+-- 3. `reason` is the dual: the reader-facing explanation can be read only
+-- from a result that did not run.
+-- 4. `not-computed⇒unrepresentable`: a result whose status is not
+-- `computed` carries no numbers by construction -- there is no field to
+-- read, so an unrun test can never be published as a null result.
+--
+-- Mapped to the Julia (test/unit/test_r_runtime.jl): "a test that did not
+-- run is not a null result", "a chart states that pairwise tests were not
+-- run", "the caption distinguishes the reasons a test did not run", and "an
+-- omnibus statistic R cannot compute is a failed test".
+--
+-- What is modelled, and what is not: the statuses are Julia's; the omnibus
+-- p-value is an exact rational (the Julia holds a Float64); the pairwise
+-- table is modelled as the list of its p-values, not the DataFrame; the
+-- reason strings are opaque. The statistics themselves are NOT modelled --
+-- this module is about what may be read from a result, not how the result
+-- was computed.
+
+{-# OPTIONS --safe --without-K #-}
+
+module MetaManifold.Significance where
+
+open import Data.Bool using (Bool; true; false)
+open import Data.List using (List; map)
+open import Data.Rational using (ℚ)
+open import Data.String using (String)
+open import Relation.Nullary using (¬_)
+open import Relation.Binary.PropositionalEquality using (_≡_; refl; cong)
+
+-- Julia's statuses: :computed, :r_unavailable, :no_paired_samples, :test_failed.
+data Status : Set where
+ computed r-unavailable no-paired-samples test-failed : Status
+
+-- The discipline: the omnibus p-value and the pairwise p-values exist only
+-- in the constructor that claims the test ran. A result that reveals nothing
+-- names its status, carries a proof that the status is not `computed`, and a
+-- reader-facing reason -- and no numbers, because there are none to read.
+data AlphaSignificance : Set where
+ computed-result : (omnibus : ℚ) (pairwise : List ℚ) → AlphaSignificance
+ reveals-nothing : (status : Status) → ¬ (status ≡ computed) → String → AlphaSignificance
+
+-- Julia's was_computed, mirrored.
+was-computed : AlphaSignificance → Bool
+was-computed (computed-result _ _) = true
+was-computed (reveals-nothing _ _ _) = false
+
+-- 1. Publishing the omnibus p-value requires evidence the test ran. On the
+-- reveals-nothing branch the evidence is an inhabitant of `false ≡ true`,
+-- which does not exist, so the branch cannot produce a number at all.
+publish : (r : AlphaSignificance) → was-computed r ≡ true → ℚ
+publish (computed-result omnibus _) refl = omnibus
+publish (reveals-nothing _ _ _) ()
+
+-- 2. The pairwise table, same discipline.
+pairwise-published : (r : AlphaSignificance) → was-computed r ≡ true → List ℚ
+pairwise-published (computed-result _ pairwise) refl = pairwise
+pairwise-published (reveals-nothing _ _ _) ()
+
+-- 3. The dual: the reader-facing reason can be read only from a result that
+-- did not run. A computed result has no reason to show, and the evidence
+-- it would need (`true ≡ false`) does not exist.
+reason : (r : AlphaSignificance) → was-computed r ≡ false → String
+reason (computed-result _ _) ()
+reason (reveals-nothing _ _ why) refl = why
+
+-- 4. The status of a result that did not run is never `computed`, by
+-- construction: the proof obligation lives in the constructor, so a
+-- `reveals-nothing` value cannot even be built with the `computed`
+-- status.
+unrun-status-not-computed :
+ (s : Status) (h : ¬ (s ≡ computed)) (why : String) →
+ let r = reveals-nothing s h why in was-computed r ≡ false
+unrun-status-not-computed s h why = refl
+
+-- The status of a computed result is `computed`, and its evidence is the
+-- constructor's own shape -- so the two constructors never meet: a result
+-- is either readable as numbers or readable as a reason, never both.
+computed-status : (o : ℚ) (p : List ℚ) →
+ was-computed (computed-result o p) ≡ true
+computed-status o p = refl
+
+-- Map every pairwise p-value through a function without changing the
+-- publish discipline: reading remains possible only on the computed branch.
+map-pairwise : (f : ℚ → ℚ) (r : AlphaSignificance) → was-computed r ≡ true → List ℚ
+map-pairwise f (computed-result _ pairwise) refl = map f pairwise
+map-pairwise f (reveals-nothing _ _ _) ()
diff --git a/proofs/agda/README.md b/proofs/agda/README.md
index 08bf5a85..2590a5ad 100644
--- a/proofs/agda/README.md
+++ b/proofs/agda/README.md
@@ -126,6 +126,25 @@ returns.
| `constant-in-blocks`, `varies-in-a-block` | The two direct cases in the test, by evaluation. | `_varies_within_any_block` | lines 49–50 |
| `fixture-group-withheld`, `fixture-run-tested` | On the test's twelve samples (six individuals, two runs each, three individuals per group), `group` is never exchanged and `run` is. | `run_permanova` | lines 83–98 |
+### Significance reporting (`Significance`)
+
+The Observational Null Disclosure discipline (hyperpolymath/absolute-zero, OND
+pillar) applied to `run_alpha_significance`'s reporting rule: a test that did
+not run reveals nothing, and must never be published as a null result. The
+numbers exist only in the constructor whose status is `computed`; every other
+status carries a proof it is not `computed` and a reader-facing reason.
+
+| Agda theorem | Plain statement | Julia | Test (`test/unit/test_r_runtime.jl`) |
+|---|---|---|---|
+| `publish` | The omnibus p-value is readable only from a result whose test ran, with evidence it ran; the unrun branch cannot produce a number (the typechecker refuses it). | `was_computed` guards | lines 100–124 |
+| `pairwise-published` | The pairwise table, same discipline. | `was_computed` guards | lines 125–152 |
+| `reason` | The dual: the reader-facing explanation is readable only from a result that did not run. | the `reason` field | lines 153–175 |
+| `unrun-status-not-computed`, `computed-status` | A result is readable as numbers or as a reason, never both: the two constructors cannot meet. | the `AlphaSignificance` struct | lines 176+ |
+
+What is modelled: the four statuses exactly; the omnibus p-value as an exact
+rational; the pairwise table as its list of p-values. What is not: the
+statistics themselves, and the DataFrame shape.
+
## Corrections to the requirement wording
- **R-BH-2a is true for the procedure.** An earlier archived note recorded
From 905dbbdb64edc3a04e33854b3fe98c0b70d7b525 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 11:20:39 +0100
Subject: [PATCH 30/45] docs(security): a security policy that matches this
repository (#155)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
The repo's Security tab currently runs on GitHub's generic
private-reporting template — there is no SECURITY.md anywhere in the
tree. This adds `.github/SECURITY.md`, tailored to what this project
actually is:
- **The deployment model, stated first**: single-user, no
authentication; the server's own guards (origin allowlist with the
DNS-rebinding check, strict Host checking on loopback binds, inert
report-upload serving, docs/metrics off) are real, but exposure beyond
the intended deployment is the operator's risk and explicitly out of
scope, as is internet-facing DoS.
- **In-scope findings**: the HTTP surface, how config/primer values
become command lines for the external tools, path handling in the file
and report routes, installer/toolchain SHA256-pin integrity, attestation
integrity, and the three dependency manifests.
- **Out of scope**: the external tools' own bugs, runtime
(Julia/R/bun/DuckDB) CVEs, and the DoS case above.
- **Supported versions**: the default branch only; the version record
for any report is the run's `attestation.yml`.
- **Reporting channels, split by kind**:
- general security reports → private vulnerability reporting (already
enabled on this repo) or email to **jjewell23@rvc.ac.uk** — the address
this project's own CITATION.cff names for Jonathan Jewell;
- **unsound mathematics** — a vacuous proof, a theorem that doesn't say
what the code relies on — is treated as a reportable weakness of its own
kind and goes to **j.d.a.jewell@open.ac.uk**, with open debate staying
in the [mathematical foundations
discussion](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions/149).
Docs-only; no code paths, no workflow changes, no root-shape impact (the
file lands under `.github/` with the other health files).
## One follow-up that needs the UI
The **private-vulnerability-reporting template text** (Settings → Code
security → Private vulnerability reporting) cannot be set through the
API, only the enable flag. Worth pasting a one-liner there pointing at
this policy and the two email addresses, so a reporter who never reads
SECURITY.md still sees the contact split.
## Notes
The commit is unsigned — the signing key's passphrase was not available
in the session that produced it.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/SECURITY.md | 89 +++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 89 insertions(+)
create mode 100644 .github/SECURITY.md
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
new file mode 100644
index 00000000..c1c97236
--- /dev/null
+++ b/.github/SECURITY.md
@@ -0,0 +1,89 @@
+
+
+# Security policy
+
+## Deployment model, stated first
+
+MetaManifold is a **single-user, no-authentication** application: a Julia
+server with a browser UI, designed to run on localhost or on a host trusted
+by its one operator. The server carries real defences — a browser-origin
+allowlist with a DNS-rebinding check, strict Host checking on loopback binds,
+inert serving of uploaded report files, and Oxygen's docs and metrics pages
+switched off — but **exposing it beyond its intended deployment is the
+operator's risk, not a supported configuration.** Denial-of-service against
+an internet-exposed instance is out of scope, as is anything that follows
+from putting an unauthenticated API on a public network after ignoring the
+deployment guidance in the README's *Deployment* section.
+
+## What is in scope
+
+- The HTTP surface: the origin allowlist and Host checks
+ (`src/server/server.jl`), the static-file and report-upload serving
+ (`_file_middleware`), SPA routing, and every route under
+ `src/server/routes/`.
+- How configuration and primer values reach the external tools: pipeline
+ options and filenames become command lines; injection through them is a
+ finding here.
+- Path handling: the file routes, report item paths, and study/run names as
+ path components.
+- Installer and toolchain integrity: every download is SHA256-pinned
+ (`config/defaults/tool_versions.yml`); a tampered pin, an unchecked
+ substitute, or a way to make the installer fetch unverified content is a
+ finding.
+- Provenance integrity: `attestation.yml` and the content hashes it records;
+ anything that can make a run attest to bytes it did not use.
+- The dependency manifests: `Manifest.toml`, `renv.lock`, `frontend/bun.lock`.
+
+## What is out of scope
+
+- Vulnerabilities **in the external tools themselves** (cutadapt, FastQC,
+ MultiQC, DADA2, swarm, vsearch, cd-hit-est, MAFFT, trimAl, IQ-TREE, RAxML,
+ gappa, and the R packages): report those to their own projects. A way this
+ repository invokes them unsafely is in scope; their internal bugs are not.
+- Julia, R, bun and DuckDB runtime CVEs: report upstream; this project pins
+ versions and will pick up fixes through its normal dependency updates.
+- Internet-exposed DoS, as above.
+
+## Supported versions
+
+The default branch only. There are no backported releases; a fix lands on
+`main` behind the usual CI gates. The version that matters for any report is
+the git commit plus the pinned toolchain — and every run records exactly that
+in its `attestation.yml`.
+
+## How to report
+
+**Preferred channel: private vulnerability reporting.** The repository has
+it enabled; the *Report a vulnerability* button on the Security tab reaches
+the maintainers privately. Use it.
+
+**By email:** contact **jjewell23@rvc.ac.uk** (Jonathan Jewell, the project
+owner) with `[security]` in the subject line.
+
+Include, where they apply:
+
+- what you found, and the impact you think it has;
+- the endpoint, route, or pipeline stage it affects;
+- the deployment mode — localhost, SSH tunnel, or split frontend/API;
+- reproduction steps, or the request that triggers it;
+- the versions block from the affected run's `attestation.yml` — it names the
+ exact commit and toolchain, which usually settles the first two replies.
+
+Reports are acknowledged when triaged, fixed on `main` with the normal CI
+gates, and disclosed on the reporter's preferred schedule. Credit is offered
+unless you prefer otherwise.
+
+## Mathematical foundations
+
+This project carries machine-checked proofs of some of its statistics, and it
+treats **unsound mathematics as a reportable weakness of its own kind**: a
+proof that is vacuous, a theorem that does not say what the code relies on, or
+a statistical claim the arithmetic does not support. If you find one, that is
+a finding — send it to **j.d.a.jewell@open.ac.uk**, where the
+proof-theoretic side of the project lives. Questions and open debate about
+the foundations belong in the
+[mathematical foundations discussion](https://github.com/hyperpolymath/MetaManifold-WebUI/discussions/149);
+private concerns about soundness belong in a report.
From 79eb518460c9c909d65eda2b8a61e37c85f45702 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 12:35:50 +0100
Subject: [PATCH 31/45] ci: a Code relevance gate, so docs-only PRs skip the
heavy suite (#156)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
The answer to "must every check run on everything equally": no, and
without giving up enforcement. A new cheap **`Code relevance`** gate job
runs on every pull request and decides whether the change touches
anything the suite exercises; the two heavy jobs — **`Julia tests`**
(~19 minutes) and **`Agda proofs`** — now carry `if:
needs.gate.outputs.relevant == 'true'` and post `skipped` when the gate
says not relevant. A skipped check satisfies a required check, so
Branch-Floor enforcement is unchanged: a docs-only PR (README, wiki,
templates, licence records) finishes in the gate's ~10 seconds plus the
external scanners instead of ~19 minutes.
The design is the pattern PR #125 established for the occupancy
workflow, applied to the main suite, with every hardening that review
produced:
- **The trigger stays unfiltered on purpose** — a path-filtered
`pull_request` trigger does not run at all on a PR that matches no path,
so a required check from that workflow never posts and can never be
satisfied. That trap was sprung here once (#116) and documented in this
file's header.
- **`--no-renames`** — a file renamed out of a listed path is reported
as a deletion under its original name and still counts.
- **`-z` with a NUL-delimited read** — a pathname Git would quote, or
that contains a newline, arrives raw and matches the pattern.
- **Base SHA through `env:`**, not template interpolation into the
script (zizmor's rule).
- **Every pattern quoted in the array** — this PR's own new finding: an
unquoted `src/*` in an array assignment expands against the checkout the
runner happens to stand in; with a directory-level glob list that is
visibly wrong, and the glob must fire at `case` time against the diff.
The relevant-path list is deliberately the positive form: anything not
listed is not relevant, so a new directory joins the suite by being
added to the list, not by being forgotten. Paths the tests themselves
read stay relevant even though they are documentation-shaped:
`.github/workflows/` (the pin tests parse it), `docs/occupancy/` (the
reading's Agda tree), and the repo-shape inputs (`Project.toml`,
`Manifest.toml`, `renv.lock`, the launchers, `R/`, `renv/`).
## Verification
- The gate logic ran locally against seven cases — docs-only, src
change, occupancy docs, rename-away, an unusual-character occupancy
path, a workflow change, README+CITATION — with the expected verdict
each time.
- `actionlint` clean; the pin suite passes 259/259, which asserts for
the new gate job that its name is a stable identifier, that it has no
matrix, and that its pinned `actions/checkout` is allowed by the
account's policy.
- **This PR is the gate's own first test**: it touches
`.github/workflows/ci.yml`, so the gate must mark it relevant and the
full suite must run on it. The first docs-only PR after this lands is
the second test.
## Follow-up after merge
Add `Code relevance` to the Branch-Floor ruleset's required checks — it
is the one check that must never be skipped, since it vouches for the
others' absence. I'll apply it once the check has posted here.
## Notes
The commit is signed (the agent socket is now available to this
session).
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
.github/workflows/ci.yml | 96 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 96 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a9380f22..8bb0ad36 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,6 +26,95 @@ concurrency:
cancel-in-progress: ${{ github.event_name != 'push' }}
jobs:
+ # A pull request that touches nothing the suite exercises -- a README edit, a
+ # wiki page, a template, a licence record -- does not need the ~19-minute
+ # Julia suite or the proof checker to pass over it. The gate decides, and the
+ # two jobs below carry `if:` on its output; when it says not relevant, GitHub
+ # posts `skipped` for them, and a skipped check satisfies a required one. The
+ # gate itself must never be skipped -- it is the check that vouches for the
+ # others' absence, so Branch-Floor requires it too.
+ #
+ # The trigger stays unfiltered for a reason this repository has already
+ # sprang once (#116): a path-filtered `pull_request` trigger does not run at
+ # all on a PR that matches no path, so a required check from that workflow
+ # never posts and can never be satisfied. Running the cheap gate on every PR
+ # and letting it skip the heavy work is the pattern the occupancy workflow
+ # (#125) established; this applies it to the main suite.
+ #
+ # The relevant-path list is deliberately the positive form: anything not
+ # listed is not relevant, so a new directory joins the suite by being added
+ # here, not by being forgotten. These tests also read the repository's own
+ # shape -- the pin tests parse `.github/workflows/`, the root-shape guard
+ # reads the tracked tree, and the occupancy reading's Agda tree lives under
+ # `docs/occupancy/` -- so those paths are relevant even though they are
+ # documentation-shaped. In a `case` pattern, `*` crosses `/`.
+ gate:
+ name: Code relevance
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ outputs:
+ relevant: ${{ steps.push.outputs.relevant || steps.pr.outputs.relevant }}
+ steps:
+ # A push only starts this workflow on main (the branch filter above), so
+ # the gate has nothing to decide: every push is relevant.
+ - name: A push is already branch-filtered
+ id: push
+ if: github.event_name == 'push'
+ run: echo 'relevant=true' >> "$GITHUB_OUTPUT"
+
+ # Full history, because the three-dot diff names the merge base of the
+ # PR's base tip and its merge commit, and a stacked PR's merge base sits
+ # further back still.
+ - name: Checkout the change
+ if: github.event_name == 'pull_request'
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false # nothing here pushes
+ fetch-depth: 0
+
+ # --no-renames, so a file renamed out of a listed path is reported as a
+ # deletion under its original name and still counts. -z with a
+ # NUL-delimited read, so a pathname Git would quote or that contains a
+ # newline arrives raw and matches the pattern. The base SHA travels
+ # through env, not template interpolation into the script.
+ - name: Does the change touch anything the suite exercises?
+ id: pr
+ if: github.event_name == 'pull_request'
+ env:
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ run: |
+ relevant=false
+ # Every pattern is quoted, so the glob survives array assignment and
+ # fires at `case` time, against the diff -- an unquoted `src/*` would
+ # expand against the checkout the runner happens to be standing in.
+ patterns=('src/*' 'test/*' 'scripts/*' 'frontend/*' 'config/*'
+ 'bench/*' 'data/*' 'proofs/*' 'R/*' 'renv/*' 'Project.toml'
+ 'Manifest.toml' 'renv.lock' '.Rprofile' 'install.sh'
+ 'start.sh' 'docs/occupancy/*' 'docs/occupancy-2026-10.adoc'
+ '.github/workflows/*')
+ # The diff goes to a file first because a process substitution hides
+ # git diff's exit status: an unavailable comparison SHA (a force-push
+ # on the base branch, for instance) or a repository error would read
+ # as "no changes", and the gate would post relevant=false -- skipping
+ # the Julia suite and the proof checker, whose `skipped` conclusion
+ # satisfies a required check -- so a relevant PR would pass silently.
+ # A failed diff must fail the gate itself: that is the one failure
+ # the required-check design cannot afford to skip. As in the
+ # occupancy gate, which this mirrors.
+ changed="$RUNNER_TEMP/ci-changed-files"
+ if ! git diff --no-renames --name-only -z "$BASE_SHA...HEAD" > "$changed"; then
+ echo "ERROR: git diff $BASE_SHA...HEAD failed" >&2
+ exit 1
+ fi
+ while IFS= read -r -d '' file; do
+ for pattern in "${patterns[@]}"; do
+ case "$file" in
+ $pattern) relevant=true; break 2 ;;
+ esac
+ done
+ done < "$changed"
+ echo "relevant=$relevant" >> "$GITHUB_OUTPUT"
+
test:
# 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
@@ -50,6 +139,10 @@ jobs:
# 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 only when the gate above found a relevant path; on a docs-only PR
+ # this job posts `skipped`, which satisfies the required check.
+ needs: gate
+ if: needs.gate.outputs.relevant == 'true'
# 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.
@@ -375,6 +468,9 @@ jobs:
# reads them.
agda:
name: Agda proofs
+ # As the Julia suite above: only when the gate found a relevant path.
+ needs: gate
+ if: needs.gate.outputs.relevant == 'true'
runs-on: ubuntu-24.04
container: debian:trixie-20250929-slim@sha256:1caf1c703c8f7e15dcf2e7769b35000c764e6f50e4d7401c355fb0248f3ddfdb
timeout-minutes: 30
From 35bd3138b6fef0e976e3eaf858ea6862b7f57376 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 17:55:36 +0100
Subject: [PATCH 32/45] chore(root): Justfile, CHANGELOG, .editorconfig,
launcher (+ --agent), and the security contact update (#157)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this changes
Four of the five root-tidy offers, completing the RSR scheme at the
repository root:
- **`Justfile`** — thin, per the doctrine; every recipe delegates to the
repo's own documented entry points (`install.sh`, `start.sh`, the test
runner, the frontend's package scripts, the axiom audit). Nothing is
defined twice.
- **`CHANGELOG.md`** — the root index; release notes stay in
`docs/release-notes/` and each release adds a line here in the same
commit.
- **`.editorconfig`** — 4 spaces Julia, 2 spaces frontend/YAML, final
newlines, no trailing whitespace (markdown exempted — two trailing
spaces are a hard break). The blanket dotfile ignore gains
`!.editorconfig` beside `!.Rprofile`.
- **`launcher.sh`** — the launcher-standard 0.6.0 mode vocabulary
(`--setup --start --doctor --status --agent --help --version`),
hand-adapted for an application repo rather than provision-set-minted,
with a praxis-deed header. Its own addition is **`--agent`**: the
operator's choice for git-signing agent persistence, all modes
reversible —
- `--agent systemd` — a systemd user service owns the agent, and the
profile *exports* the socket (a service cannot export into login
shells); survives reboots, one instance;
- `--agent profile` — a marked profile block starts the agent if the
socket is dead and exports it; no systemd, works over plain ssh;
- `--agent both`, `--agent none`, `--agent status`.
- Profile edits are marked-block, idempotent, and backed up; `--doctor`
reports the toolchain plus the signing state.
- **`SECURITY.md`** — the general-security contact becomes
**developer@joshuajewell.dev** (the mathematical-foundations contact
stays j.d.a.jewell@open.ac.uk).
The **root-shape allowlist** gains all four entries in the same commit,
per its own rule. The fifth offer — merging `install.sh`/`start.sh` into
the launcher — is deliberately not taken: they are the README's
documented entry points and upstream parity; the launcher wraps them
without replacing them.
## Verification
Pin suite 263/263 (the root-shape guard reads the tracked tree, so the
entries land and the guard updates together). `bash -n` on the launcher;
its `--version`/`--agent status`/`--doctor` modes exercised live against
this machine. The launcher's doctor correctly reported the machine's
actual state (R absent from PATH without the conda prefix,
`config/tools.yml` absent without a full install, the signing socket it
manages distinct from the currently-live one).
## Notes
The commit is signed.
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: Mistral Vibe
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.editorconfig | 42 +++++
.github/SECURITY.md | 2 +-
.gitignore | 1 +
CHANGELOG.md | 14 ++
Justfile | 51 ++++++
launcher.sh | 295 +++++++++++++++++++++++++++++++++
scripts/kyaml-format.sh | 121 ++++++++++++++
src/MetaManifold.jl | 3 +
src/canonical.jl | 203 +++++++++++++++++++++++
test/fixtures/jcs_oracle.jsonl | 11 ++
test/runtests.jl | 1 +
test/unit/test_canonical.jl | 74 +++++++++
test/unit/test_install_pins.jl | 26 ++-
13 files changed, 840 insertions(+), 4 deletions(-)
create mode 100644 .editorconfig
create mode 100644 CHANGELOG.md
create mode 100644 Justfile
create mode 100755 launcher.sh
create mode 100755 scripts/kyaml-format.sh
create mode 100644 src/canonical.jl
create mode 100644 test/fixtures/jcs_oracle.jsonl
create mode 100644 test/unit/test_canonical.jl
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..7bff9e56
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,42 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# Editor configuration for this repository. The Julia sources indent with four
+# spaces, the frontend with two, YAML with two, and every text file ends with
+# a newline and no trailing whitespace.
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.jl]
+indent_style = space
+indent_size = 4
+
+[*.{jl}]
+max_line_length = off
+
+[{*.ts,*.tsx,*.js,*.jsx,*.json,*.css,*.html}]
+indent_style = space
+indent_size = 2
+
+[{*.yml,*.yaml,*.kyaml}]
+indent_style = space
+indent_size = 2
+
+[*.md]
+indent_style = space
+indent_size = 2
+# Markdown's hard line breaks are two trailing spaces; keep them.
+trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab
+
+[Justfile]
+indent_style = space
+indent_size = 4
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index c1c97236..f97f2309 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -60,7 +60,7 @@ in its `attestation.yml`.
it enabled; the *Report a vulnerability* button on the Security tab reaches
the maintainers privately. Use it.
-**By email:** contact **jjewell23@rvc.ac.uk** (Jonathan Jewell, the project
+**By email:** contact **developer@joshuajewell.dev** (Jonathan Jewell, the project
owner) with `[security]` in the subject line.
Include, where they apply:
diff --git a/.gitignore b/.gitignore
index 42d6c9c7..421b96ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -323,6 +323,7 @@ archive/
!.github
!.github/**
!.Rprofile
+!.editorconfig
# Brainstorming visual-companion scratch (mock-ups, server state)
.superpowers/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..9f52ed7f
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,14 @@
+
+
+# Changelog
+
+Release notes live under `docs/release-notes/`, one file per release:
+
+- [v0.1.0](docs/release-notes/v0.1.0.md)
+
+This file is the root index the RSR scheme expects; the notes themselves are
+kept where they have always been, and a new release adds a line here in the
+same commit that adds its file there.
diff --git a/Justfile b/Justfile
new file mode 100644
index 00000000..b1cbe4da
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,51 @@
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# MetaManifold's task entry (RSR scheme: a thin Justfile at the root; the
+# heavy work lives in the commands it names). Every recipe delegates to the
+# repository's own documented entry points -- install.sh, start.sh, the test
+# runner -- so there is exactly one place where each of those is defined and
+# this file never re-implements them.
+
+# The default recipe lists what exists; `just --list` does the same.
+default:
+ @just --list
+
+# The documented installer: Julia (pinned), R, the external tools, the
+# frontend build. Extra args pass through to install.sh (--update,
+# --modify, --sysimage).
+install *ARGS:
+ bash install.sh {{ARGS}}
+
+# The documented server entry point. Serves the UI on port 8080.
+serve:
+ bash start.sh
+
+# The full unit suite. Name files to run a subset:
+# just test test_install_pins.jl test_server_security.jl
+test *FILES:
+ julia --project=. test/runtests.jl {{FILES}}
+
+# The pin suite alone: toolchain pins, lockfile agreement, workflow shape,
+# and the root-shape allowlist.
+pins:
+ julia --project=. test/runtests.jl test_install_pins.jl
+
+# The Agda proof tree's audit (no postulates, no holes, everything reached
+# from All.agda). The type-check itself runs in CI's Agda proofs job.
+proofs:
+ bash proofs/tests/axiom-audit.sh
+
+# The frontend's own checks.
+frontend-test:
+ cd frontend && bun run test
+
+frontend-typecheck:
+ cd frontend && bun run typecheck
+
+frontend-build:
+ cd frontend && bun run build
+
+# The git-signing agent's state, as the launcher sees it.
+agent-status:
+ ./launcher.sh --agent status
diff --git a/launcher.sh b/launcher.sh
new file mode 100755
index 00000000..d37ba967
--- /dev/null
+++ b/launcher.sh
@@ -0,0 +1,295 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# @launcher-deed begin
+# ;; SPDX-License-Identifier: MPL-2.0
+# (praxis-deed
+# :schema-version "1.0.0"
+# :canonical-name "metamanifold-webui-launcher"
+# (artefact :type "launcher" :version "0.1.0"
+# :generator "hand-adapted from launcher-standard 0.6.0's archetype")
+# (app :name "MetaManifold" :display "MetaManifold"
+# :url "https://github.com/hyperpolymath/MetaManifold-WebUI"
+# :archetype "application")
+# (compliance :standard-version "0.6.0"
+# :standards ("launcher-standard.adoc"))
+# (modes :accepted ("--setup" "--start" "--doctor" "--status" "--agent" "--help" "--version"))
+# (platforms :supported ("linux" "macos"))
+# (lifecycle-phases :covered ("provision" "diagnose" "run")
+# :deferred ("heal")))
+# @launcher-deed end
+#
+# The MetaManifold launcher, in the launcher-standard's mode vocabulary but
+# self-contained: this is an application repository, not an RSR-minted one,
+# so the modes delegate directly to the repository's own entry points
+# (install.sh, start.sh) rather than through provision-set machinery.
+#
+# The one thing that is this launcher's own is --agent: the git-signing
+# agent's persistence is the operator's choice, and every mode of it is
+# reversible. The signing key is ~/.ssh/git-signing2; the socket is
+# /tmp/git-signing-agent.sock.
+#
+# --agent systemd a systemd user service owns the agent, and the shell
+# profile exports the socket (a service cannot export
+# into login shells). Survives reboots; one instance.
+# --agent profile the shell profile starts the agent if the socket is
+# dead and exports it. No systemd; works over plain ssh.
+# --agent both the service plus the spawn-if-dead fallback line.
+# --agent none removes whatever of the above was installed.
+# --agent status reports socket, key, and which mechanisms are present.
+#
+# Each mechanism is written between marked blocks in the profile and a unit
+# file, idempotently, with a dated backup of anything edited.
+
+set -uo pipefail
+
+REPO_DIR="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
+SOCKET="/tmp/git-signing-agent.sock"
+KEY="${HOME}/.ssh/git-signing2"
+UNIT_NAME="git-signing-agent"
+UNIT_FILE="${HOME}/.config/systemd/user/${UNIT_NAME}.service"
+PROFILE="${HOME}/.profile"
+BLOCK_BEGIN="# >>> metamanifold git-signing agent >>>"
+BLOCK_END="# <<< metamanifold git-signing agent <<<"
+
+# Print all arguments as one launcher-prefixed message to standard output.
+say() { printf 'launcher: %s\n' "$*"; }
+
+# Print all arguments as one launcher-prefixed message to standard error.
+warn() { printf 'launcher: %s\n' "$*" >&2; }
+
+# Report all arguments through warn and terminate the launcher with status 1.
+die() { warn "$*"; exit 1; }
+
+# Return 0 only when SOCKET is a Unix socket and ssh-add can list identities.
+# An agent with no loaded identities also returns non-zero.
+agent_alive() {
+ [ -S "$SOCKET" ] && SSH_AUTH_SOCK="$SOCKET" ssh-add -l >/dev/null 2>&1
+}
+
+# Remove marked agent blocks from PROFILE, following symlinks and preserving
+# file permissions. A missing profile succeeds. Return 1 for nested or
+# unterminated blocks, or file-operation failures; no backup is made here.
+remove_profile_block() {
+ [ -f "$PROFILE" ] || return 0
+ local target tmp mode
+ target="$(readlink -f "$PROFILE")" || return 1
+ tmp="$(mktemp "$(dirname "$target")/.profile.tmp.XXXXXX")" || return 1
+ if ! awk -v b="$BLOCK_BEGIN" -v e="$BLOCK_END" '
+ $0 == b {
+ if (skip) exit 1
+ skip = 1
+ next
+ }
+ $0 == e {
+ if (skip) { skip = 0; next }
+ print
+ next
+ }
+ !skip { print }
+ END { if (skip) exit 1 }
+ ' "$target" > "$tmp"; then
+ rm -f "$tmp"
+ return 1
+ fi
+ mode="$(stat -c '%a' "$target" 2>/dev/null || stat -f '%Lp' "$target")" || {
+ rm -f "$tmp"
+ return 1
+ }
+ chmod "$mode" "$tmp" || {
+ rm -f "$tmp"
+ return 1
+ }
+ mv "$tmp" "$target" || {
+ rm -f "$tmp"
+ return 1
+ }
+}
+
+# Back up PROFILE and replace its marked agent block, creating the file if absent.
+# "export" sets the socket for future shells; "spawn" also tries to start an
+# agent when ssh-add cannot list identities. Neither mode loads the signing key.
+# Exit 1 if profile creation, backup or block removal fails; append failures
+# return a non-zero status and do not restore the backup.
+write_profile_block() { # $1 = "export" | "spawn"
+ [ -f "$PROFILE" ] || touch "$PROFILE" || die "could not create $PROFILE"
+ local backup
+ backup="$(mktemp "$PROFILE.launcher-backup.$(date +%Y%m%d%H%M%S).XXXXXX")" ||
+ die "could not create backup of $PROFILE"
+ cp -p "$PROFILE" "$backup" || {
+ rm -f "$backup"
+ die "backup of $PROFILE failed"
+ }
+ remove_profile_block || die "could not update $PROFILE"
+ {
+ printf '%s\n' "$BLOCK_BEGIN"
+ if [ "$1" = "spawn" ]; then
+ printf 'if [ ! -S "%s" ] || ! SSH_AUTH_SOCK="%s" ssh-add -l >/dev/null 2>&1; then\n' "$SOCKET" "$SOCKET"
+ printf ' ssh-agent -a "%s" >/dev/null 2>&1\n' "$SOCKET"
+ printf 'fi\n'
+ fi
+ printf 'export SSH_AUTH_SOCK="%s"\n' "$SOCKET"
+ printf '%s\n' "$BLOCK_END"
+ } >> "$PROFILE"
+}
+
+# Create or overwrite UNIT_FILE for an ssh-agent on SOCKET, without a backup.
+# Return the write status; this does not enable or start the service.
+write_unit() {
+ mkdir -p "$(dirname "$UNIT_FILE")"
+ cat > "$UNIT_FILE" </dev/null || die "systemctl not found; use --agent profile"
+ write_unit
+ systemctl --user daemon-reload
+ systemctl --user enable --now "$UNIT_NAME" || die "could not enable ${UNIT_NAME} (try: loginctl enable-linger)"
+ write_profile_block export
+ say "systemd user service enabled; profile exports SSH_AUTH_SOCK=$SOCKET"
+ say "note: over plain ssh, or without a user session, also run: loginctl enable-linger \$USER"
+ ;;
+ profile)
+ write_profile_block spawn
+ say "profile now starts the agent if the socket is dead and exports SSH_AUTH_SOCK=$SOCKET"
+ ;;
+ both)
+ command -v systemctl >/dev/null || die "systemctl not found; use --agent profile"
+ write_unit
+ systemctl --user daemon-reload
+ systemctl --user enable --now "$UNIT_NAME" || die "could not enable ${UNIT_NAME}"
+ write_profile_block spawn
+ say "systemd service plus spawn-if-dead profile fallback installed"
+ ;;
+ none)
+ if command -v systemctl >/dev/null 2>&1; then
+ systemctl --user disable --now "$UNIT_NAME" >/dev/null 2>&1 ||
+ warn "could not disable $UNIT_NAME"
+ else
+ warn "systemctl not found; user unit was not disabled"
+ fi
+ rm -f "$UNIT_FILE"
+ if command -v systemctl >/dev/null 2>&1; then
+ systemctl --user daemon-reload ||
+ warn "could not reload the user manager"
+ fi
+ if [ -f "$PROFILE" ]; then
+ backup="$(mktemp "$PROFILE.launcher-backup.$(date +%Y%m%d%H%M%S).XXXXXX")" || {
+ warn "could not create backup of $PROFILE; profile block was not removed"
+ return 1
+ }
+ if ! cp -p "$PROFILE" "$backup"; then
+ rm -f "$backup"
+ warn "could not back up $PROFILE; profile block was not removed"
+ return 1
+ fi
+ fi
+ if ! remove_profile_block; then
+ warn "could not remove the profile block"
+ return 1
+ fi
+ say "removed unit and profile block; any agent already running is left alone"
+ ;;
+ status)
+ agent_alive && say "socket alive: $SOCKET" || say "socket dead or absent: $SOCKET"
+ [ -f "$KEY" ] && say "key present: $KEY" || say "key MISSING: $KEY"
+ [ -f "$UNIT_FILE" ] && say "systemd unit: installed" || say "systemd unit: not installed"
+ grep -q "$BLOCK_BEGIN" "$PROFILE" 2>/dev/null && say "profile block: present" || say "profile block: absent"
+ ;;
+ *)
+ die "usage: launcher.sh --agent systemd|profile|both|none|status"
+ ;;
+ esac
+}
+
+# Report toolchain and signing readiness, then exit 1 if julia, bun, R or git
+# is missing from PATH, or agent_alive fails; otherwise exit 0. Missing tool
+# configuration or a signing-key check failure only produces a warning.
+doctor() {
+ fail=0
+ for tool in julia bun R git; do
+ if command -v "$tool" >/dev/null; then
+ say "ok: $tool on PATH"
+ else
+ warn "MISSING: $tool not on PATH"
+ fail=1
+ fi
+ done
+ [ -f "$REPO_DIR/config/tools.yml" ] && say "ok: config/tools.yml exists" \
+ || warn "config/tools.yml missing (run: ./launcher.sh --setup)"
+ if agent_alive; then
+ say "ok: signing agent alive at $SOCKET"
+ SSH_AUTH_SOCK="$SOCKET" ssh-add -l 2>/dev/null | grep -q "$(ssh-keygen -lf "${KEY}.pub" 2>/dev/null | awk '{print $2}')" \
+ && say "ok: signing key loaded" || warn "signing key not loaded (run: ssh-add $KEY)"
+ else
+ warn "signing agent not reachable at $SOCKET (see: ./launcher.sh --agent status)"
+ fail=1
+ fi
+ exit "$fail"
+}
+
+# Print the launcher header and available modes to standard output.
+help() {
+ sed -n '2,40p' "${BASH_SOURCE[0]}" | grep '^#' | sed 's/^# \{0,1\}//'
+ cat <<'MODES'
+Modes:
+ --setup install everything (delegates to install.sh; args pass through)
+ --start serve the web UI (delegates to start.sh)
+ --doctor check the toolchain and the signing agent
+ --status whether the server looks reachable on its port
+ --agent MODE systemd | profile | both | none | status (the signing agent)
+ --help this text
+ --version the launcher's version
+MODES
+}
+
+# Print the launcher and launcher-standard versions to standard output.
+version() { echo "metamanifold-webui-launcher 0.1.0 (launcher-standard 0.6.0, hand-adapted)"; }
+
+# Dispatch the first argument as a launcher mode. Setup replaces this process
+# with install.sh and forwards remaining arguments; start executes start.sh.
+# Status checks the configured local port (default 8080) with a two-second
+# timeout; HTTP error responses count as reachable and connection failure does
+# not set a failure exit status. Missing or unknown modes print help and exit 1.
+main() {
+ case "${1:-}" in
+ --setup) shift; exec bash "$REPO_DIR/install.sh" "$@" ;;
+ --start) exec bash "$REPO_DIR/start.sh" ;;
+ --doctor) doctor ;;
+ --status)
+ status_url="http://127.0.0.1:${JULIA_METAMANIFOLD_PORT:-8080}/"
+ if curl -s -o /dev/null --max-time 2 "$status_url"; then
+ say "something is answering on $status_url"
+ else
+ say "nothing answering on $status_url (start with: ./launcher.sh --start)"
+ fi
+ ;;
+ --agent) shift; agent_mode "${1:-}" ;;
+ --help|-h) help ;;
+ --version) version ;;
+ *) help; exit 1 ;;
+ esac
+}
+
+main "$@"
diff --git a/scripts/kyaml-format.sh b/scripts/kyaml-format.sh
new file mode 100755
index 00000000..62163356
--- /dev/null
+++ b/scripts/kyaml-format.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+# SPDX-License-Identifier: MPL-2.0
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# kyaml-format.sh — format arbitrary YAML as KYAML, or check that it already is
+# (YAML-POLICY Y-3; KYAML adoption step 3, standards#1022).
+#
+# The rewriter is `yq -p yaml -o kyaml '.'`, verbatim — the one rewriter the
+# step-2 comment-preservation proof (tools/yaml-comment-proof, standards#1021)
+# covers. That proof does not generalise to any other rewriter, so this script
+# deliberately adds none: no post-processing, no re-indenting, no sed.
+#
+# Usage:
+# kyaml-format.sh [--check] FILE...
+#
+# (default) rewrite each FILE in place as KYAML when it is not already.
+# --check rewrite nothing; exit 1 if any FILE is not already KYAML.
+#
+# Exit contract:
+# 0 = every FILE is KYAML (after the rewrite, in format mode)
+# 1 = --check: at least one FILE is not KYAML (each one is named)
+# 2 = refused: no FILE given, a FILE is missing or unparseable, a FILE has no
+# final newline, or the rewrite would change the parsed data. A refusal
+# never rewrites that FILE and never counts as clean.
+#
+# Why the final-newline refusal: a `|` block scalar that ends at EOF with no
+# line break is read differently by go-yaml (yq) and eemeli/yaml — measured on
+# k9-contractile.yml, the trailing "\n" of its last run: body exists for one
+# parser and not the other. Formatting such a file would silently pick one
+# reading, so the script refuses it and names the fix instead.
+set -uo pipefail
+
+MODE=format
+if [ "${1:-}" = "--check" ]; then
+ MODE=check
+ shift
+fi
+
+if [ "$#" -eq 0 ]; then
+ echo "kyaml-format: no files given (usage: kyaml-format.sh [--check] FILE...)" >&2
+ exit 2
+fi
+
+command -v yq >/dev/null 2>&1 || { echo "kyaml-format: yq is not on PATH" >&2; exit 2; }
+
+TMP="$(mktemp -d)"
+trap 'rm -rf "$TMP"' EXIT
+
+# canonical_data — prints the file's parsed value as key-sorted JSON;
+# fails when the file does not parse.
+canonical_data() {
+ yq -p yaml -o json -I 0 'sort_keys(..)' "$1"
+}
+
+# kyaml_of — writes the KYAML rendering of to ;
+# fails when does not parse.
+kyaml_of() {
+ yq -p yaml -o kyaml '.' "$1" > "$2"
+}
+
+# refuse — reports a file this script will not judge.
+refuse() {
+ echo "::error file=$1::kyaml-format: REFUSED $1 — $2" >&2
+ refused=$((refused + 1))
+}
+
+refused=0 drift=0 clean=0 rewritten=0
+i=0
+for f in "$@"; do
+ i=$((i + 1))
+ out="$TMP/$i.kyaml"
+ if [ ! -f "$f" ]; then
+ refuse "$f" "no such file"; continue
+ fi
+ if [ -s "$f" ] && [ -n "$(tail -c1 "$f")" ]; then
+ refuse "$f" "no final newline; add one first (a trailing block scalar is ambiguous without it)"; continue
+ fi
+ if ! kyaml_of "$f" "$out" 2>"$TMP/err"; then
+ refuse "$f" "does not parse: $(head -1 "$TMP/err")"; continue
+ fi
+ if cmp -s "$f" "$out"; then
+ clean=$((clean + 1)); continue
+ fi
+ # The rewrite is only acceptable when it preserves the data exactly.
+ if ! before=$(canonical_data "$f") || ! after=$(canonical_data "$out") || [ "$before" != "$after" ]; then
+ refuse "$f" "the KYAML rendering does not parse to the same data"; continue
+ fi
+ if [ "$MODE" = check ]; then
+ echo "::error file=$f::kyaml-format: $f is not KYAML — run scripts/kyaml-format.sh $f" >&2
+ drift=$((drift + 1))
+ else
+ target="$(readlink -f "$f")" || {
+ refuse "$f" "could not resolve the rewrite target"; continue
+ }
+ rewrite_tmp="$(mktemp "$(dirname "$target")/.kyaml-format.XXXXXX")" || {
+ refuse "$f" "could not create a temporary file beside the target"; continue
+ }
+ if ! cp -p "$target" "$rewrite_tmp" || ! cat "$out" > "$rewrite_tmp" || ! mv "$rewrite_tmp" "$target"; then
+ rm -f "$rewrite_tmp"
+ refuse "$f" "could not rewrite the file"; continue
+ fi
+ echo "kyaml-format: rewrote $f"
+ rewritten=$((rewritten + 1))
+ fi
+done
+
+total=$#
+if [ "$refused" -gt 0 ]; then
+ echo "kyaml-format: $refused of $total file(s) REFUSED — not judged, not clean" >&2
+ exit 2
+fi
+if [ "$drift" -gt 0 ]; then
+ echo "kyaml-format: $drift of $total file(s) are not KYAML" >&2
+ exit 1
+fi
+if [ "$MODE" = check ]; then
+ echo "kyaml-format: all $total file(s) are KYAML"
+else
+ echo "kyaml-format: $total file(s) KYAML ($rewritten rewritten, $clean already clean)"
+fi
+exit 0
diff --git a/src/MetaManifold.jl b/src/MetaManifold.jl
index 31db2a5c..b5f13a59 100644
--- a/src/MetaManifold.jl
+++ b/src/MetaManifold.jl
@@ -33,6 +33,9 @@ include("analysis/diversity.jl")
include("analysis/analysis.jl")
include("analysis/differential.jl")
+# Canonical JSON: deterministic bytes for hashing and content-addressing.
+include("canonical.jl")
+
# Web server
include("server/server.jl")
diff --git a/src/canonical.jl b/src/canonical.jl
new file mode 100644
index 00000000..04363037
--- /dev/null
+++ b/src/canonical.jl
@@ -0,0 +1,203 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# JCS (RFC 8785) canonical JSON: deterministic bytes for hashing, signing and
+# content-addressing. The same value always yields the same byte string, so a
+# chart spec or an analysis result can be hashed where it is produced and the
+# hash compared across machines, Julia versions and refactorings that build
+# the same object in a different order.
+#
+# What this implements, and what it deliberately does not:
+#
+# * Object keys are sorted by UTF-16 code unit, the rule that makes an
+# astral-plane key sort BEFORE a key such as U+FF21, because its high
+# surrogate (U+D800..U+DBFF) is a smaller code unit than U+FF21.
+# * Float64 values serialize per ECMAScript's Number::toString, the rules
+# RFC 8785 builds on: shortest round-trip digits, positional notation for
+# 1e-6 <= |x| < 1e21, exponent notation outside it, a signed exponent
+# ("1e+30"), and -0.0 collapsing to 0.
+# * Integers outside I-JSON's exact range (-(2^53)+1 .. 2^53-1) are
+# refused, as the estate's ijson-jcs crate refuses them, because a
+# consumer cannot read them back as the same number.
+# * NaN and Inf are refused for the same reason.
+#
+# The implementation is verified against the estate's ijson-jcs crate
+# (Rust, RFC 7493 + RFC 8785): the fixtures in
+# test/fixtures/jcs_oracle.jsonl were produced BY that crate, and
+# test/unit/test_canonical.jl asserts this module reproduces them
+# byte-for-byte. The crate is the oracle; this is the second
+# implementation, and any disagreement between them is a bug in one of
+# them.
+
+module Canonical
+
+
+using SHA
+
+export jcs_bytes, jcs_sha256
+
+# The I-JSON exact-integer range.
+const SAFE_INT_MAX = 2^53 - 1
+
+"""
+ jcs_bytes(x) -> Vector{UInt8}
+
+The RFC 8785 canonical form of `x`, as bytes. Accepts the JSON-shaped
+values the server produces: `Dict` and `JSON3.Object` (key order is
+irrelevant; it is sorted away), `AbstractVector` and `JSON3.Array` (order
+is preserved, as JCS preserves array order), `AbstractString`, `Integer`,
+`AbstractFloat`, `Bool` and `nothing`. Floats serialize per ECMAScript's
+Number::toString; integers outside the exact I-JSON range, and NaN or Inf
+floats, throw `ArgumentError`.
+"""
+function jcs_bytes(x)
+ io = IOBuffer()
+ _write!(io, x)
+ take!(io)
+end
+
+"""
+ jcs_sha256(x) -> String
+
+The SHA-256 of `jcs_bytes(x)`, as lowercase hex -- the content address of
+a JSON value: two producers that build the same value hash identically
+whatever order they built it in.
+"""
+jcs_sha256(x) = bytes2hex(sha256(jcs_bytes(x)))
+
+# Object keys sort by UTF-16 code unit, which for astral-plane characters
+# means their surrogate pair, a smaller first unit than any BMP code point
+# at or above U+E000. transcoding the key to UInt16 gives exactly those
+# units to compare.
+_utf16_units(s::AbstractString) = transcode(UInt16, String(s))
+
+_write!(io, ::Nothing) = print(io, "null")
+_write!(io, b::Bool) = print(io, b ? "true" : "false")
+
+function _write!(io, n::Integer)
+ # I-JSON: only |n| <= 2^53-1 survives a JSON round trip exactly.
+ -SAFE_INT_MAX <= n <= SAFE_INT_MAX || throw(ArgumentError(
+ "canonical JSON refuses the integer $n: it is outside the exact " *
+ "IEEE-754 binary64 range I-JSON allows"))
+ print(io, n)
+end
+
+function _write!(io, x::AbstractFloat)
+ _write_float!(io, Float64(x))
+end
+
+function _write!(io, s::AbstractString)
+ print(io, '"')
+ for c in s
+ _write_char!(io, c)
+ end
+ print(io, '"')
+end
+
+# ECMAScript's JSON.stringify escapes exactly these: the two mandatory
+# characters, the five with short forms, and every other control
+# character as \u00xx with lowercase hex.
+function _write_char!(io, c::Char)
+ if c == '"'
+ print(io, "\\\"")
+ elseif c == '\\'
+ print(io, "\\\\")
+ elseif c == '\b'
+ print(io, "\\b")
+ elseif c == '\t'
+ print(io, "\\t")
+ elseif c == '\n'
+ print(io, "\\n")
+ elseif c == '\f'
+ print(io, "\\f")
+ elseif c == '\r'
+ print(io, "\\r")
+ elseif c < ' '
+ print(io, "\\u", string(UInt32(c); base=16, pad=4))
+ else
+ print(io, c)
+ end
+end
+
+# AbstractDict (Dict and JSON3.Object alike): sorted keys, then each pair.
+function _write!(io, d::AbstractDict)
+ print(io, '{')
+ first = true
+ for k in sort!(collect(String.(keys(d))); by = _utf16_units)
+ first || print(io, ',')
+ first = false
+ _write!(io, string(k))
+ print(io, ':')
+ _write!(io, d[k])
+ end
+ print(io, '}')
+end
+
+# AbstractVector (Vector and JSON3.Array alike): order preserved.
+function _write!(io, a::AbstractVector)
+ print(io, '[')
+ for (i, v) in enumerate(a)
+ i > 1 && print(io, ',')
+ _write!(io, v)
+ end
+ print(io, ']')
+end
+
+# RFC 8785 §3.2.2.3: ECMAScript's Number::toString. Julia's own repr gives
+# the shortest round-trip digits (Ryu, the same family ES specifies), so
+# the work is reformatting those digits under ES's notation rules.
+function _write_float!(io, x::Float64)
+ isnan(x) && throw(ArgumentError(
+ "canonical JSON refuses NaN: no consumer reads it back as the same value"))
+ isinf(x) && throw(ArgumentError(
+ "canonical JSON refuses Inf: no consumer reads it back as the same value"))
+ if x == 0
+ print(io, "0") # +0.0 and -0.0 both collapse to 0, as ECMAScript does.
+ return
+ end
+ if signbit(x)
+ print(io, '-')
+ x = -x
+ end
+ digits, n = _shortest_digits(x)
+ k = length(digits)
+ if n > 21 || n <= -6
+ # Exponential: one digit, then the fraction, then e with an
+ # explicit sign (ES prints "1e+30", not "1e30").
+ print(io, digits[1])
+ k > 1 && (print(io, '.'); print(io, digits[2:end]))
+ e = n - 1
+ print(io, 'e', e < 0 ? '-' : '+', string(abs(e)))
+ elseif n >= k
+ # An integer too big for the fraction: digits followed by zeros.
+ print(io, digits, "0"^(n - k))
+ elseif n > 0
+ # Digits with the point inside them.
+ print(io, digits[1:n], '.', digits[n+1:end])
+ else
+ # Below one, positional as far as ES goes: 0.000001, not 1e-6.
+ print(io, "0.", "0"^(-n), digits)
+ end
+end
+
+# The shortest round-trip decimal form of x as (digits, n) with
+# x = 0.digits * 10^n: repr yields either "123", "4.5" or "1.5e-10".
+function _shortest_digits(x::Float64)
+ s = repr(x)
+ mantissa, exponent = occursin('e', s) ? (split(s, 'e') .|> String) : (s, "")
+ e = isempty(exponent) ? 0 : parse(Int, exponent)
+ if occursin('.', mantissa)
+ intpart, fracpart = split(mantissa, '.') .|> String
+ else
+ intpart, fracpart = mantissa, ""
+ end
+ digits = string(intpart, fracpart)
+ n = length(intpart) + e
+ # Trailing zeros are notation, not value: 1.0e30 is digits "1", n 31.
+ while length(digits) > 1 && endswith(digits, '0')
+ digits = digits[1:end-1]
+ end
+ digits, n
+end
+
+end # module
diff --git a/test/fixtures/jcs_oracle.jsonl b/test/fixtures/jcs_oracle.jsonl
new file mode 100644
index 00000000..dd446d8b
--- /dev/null
+++ b/test/fixtures/jcs_oracle.jsonl
@@ -0,0 +1,11 @@
+{"input":{"b":1,"a":2,"c":[]},"expected":"{\"a\":2,\"b\":1,\"c\":[]}"}
+{"input":{"€":"euro","A":"fullwidth A","a":"first"},"expected":"{\"a\":\"first\",\"€\":\"euro\",\"A\":\"fullwidth A\"}"}
+{"input":{"😀":"astral first?","A":"high bmp"},"expected":"{\"😀\":\"astral first?\",\"A\":\"high bmp\"}"}
+{"input":{"d":[3,1,2],"e":{"z":1,"y":{"x":[true,false,null]}}},"expected":"{\"d\":[3,1,2],\"e\":{\"y\":{\"x\":[true,false,null]},\"z\":1}}"}
+{"input":[4.50,2.4499999999999998e16,1E+30,0.000001,1E-7,1E+21,1E+20,1E-33,-0.0,0.0,9.9999999999999997E+22],"expected":"[4.5,24500000000000000,1e+30,0.000001,1e-7,1e+21,100000000000000000000,1e-33,0,0,1e+23]"}
+{"input":[0.000001,1E-7,1.5E-10,9007199254740991,-42],"expected":"[0.000001,1e-7,1.5e-10,9007199254740991,-42]"}
+{"input":{"s\":with\"quotes":"tab\there\nnewline","esc\\":"\\backslash","ctl":"\u0001\u001f"},"expected":"{\"ctl\":\"\\u0001\\u001f\",\"esc\\\\\":\"\\\\backslash\",\"s\\\":with\\\"quotes\":\"tab\\there\\nnewline\"}"}
+{"input":{"nested":{"arr":[{"n":1.0},{"m":2}],"deep":{"deeper":{"deepest":"value"}}}},"expected":"{\"nested\":{\"arr\":[{\"n\":1},{\"m\":2}],\"deep\":{\"deeper\":{\"deepest\":\"value\"}}}}"}
+{"input":{"literals":true,"n":null,"f":false,"empty_obj":{},"empty_arr":[]},"expected":"{\"empty_arr\":[],\"empty_obj\":{},\"f\":false,\"literals\":true,\"n\":null}"}
+{"input":[5E-324,1.7976931348623157E+308,2.2250738585072014E-308,0.3,0.1,0.30000000000000004],"expected":"[5e-324,1.7976931348623157e+308,2.2250738585072014e-308,0.3,0.1,0.30000000000000004]"}
+{"input":{"Plotly":{"layout":{"title":{"text":"CO₂"}},"data":[{"x":[1,2,3],"y":[4.5,5.5,6.5],"type":"scatter"}]}},"expected":"{\"Plotly\":{\"data\":[{\"type\":\"scatter\",\"x\":[1,2,3],\"y\":[4.5,5.5,6.5]}],\"layout\":{\"title\":{\"text\":\"CO₂\"}}}}"}
diff --git a/test/runtests.jl b/test/runtests.jl
index d107e559..77e15bcf 100644
--- a/test/runtests.jl
+++ b/test/runtests.jl
@@ -54,6 +54,7 @@ const UNIT_FILES = [
"test_permanova.jl",
"test_write_gates.jl",
"test_server_security.jl",
+ "test_canonical.jl",
]
# The files named on the command line ("test_routes.jl", "routes" or
diff --git a/test/unit/test_canonical.jl b/test/unit/test_canonical.jl
new file mode 100644
index 00000000..1a9bb01b
--- /dev/null
+++ b/test/unit/test_canonical.jl
@@ -0,0 +1,74 @@
+# SPDX-License-Identifier: AGPL-3.0-only
+# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
+#
+# Unit tests for the JCS (RFC 8785) canonical JSON writer. The oracle is the
+# estate's ijson-jcs crate (Rust): every fixture in
+# test/fixtures/jcs_oracle.jsonl was PRODUCED by that crate, and this file
+# asserts MetaManifold.Canonical reproduces it byte-for-byte. Any
+# disagreement is a bug in one of the two implementations, and the fixture
+# names the case.
+using MetaManifold
+using MetaManifold.Canonical: jcs_bytes, jcs_sha256
+using JSON3, SHA, Test
+
+@testset "canonical JSON (JCS)" begin
+ # Byte-for-byte against the Rust crate's output, over the fixture
+ # corpus: key sorting (including the astral-before-high-BMP UTF-16
+ # case), ES number formatting, string escaping, nesting, literals.
+ fixtures = joinpath(@__DIR__, "..", "fixtures", "jcs_oracle.jsonl")
+ for (i, line) in enumerate(eachline(fixtures))
+ case = JSON3.read(line)
+ @testset "oracle case $i" begin
+ value = case.input
+ # The input arrives already parsed; the expected is the oracle's text.
+ @test String(jcs_bytes(value)) == case.expected
+ end
+ end
+
+ @testset "key order is irrelevant, bytes are not" begin
+ a = Dict("b" => 1, "a" => Dict("y" => [1.0, 2], "x" => "s"))
+ b = Dict("a" => Dict("x" => "s", "y" => [1.0, 2]), "b" => 1)
+ @test jcs_bytes(a) == jcs_bytes(b)
+ # ...but array order is preserved, as JCS requires.
+ c = Dict("a" => [2, 1])
+ @test jcs_bytes(a) != jcs_bytes(c)
+ end
+
+ @testset "the astral key sorts before the high BMP key" begin
+ # U+1F600 arrives as the surrogate pair D83D DE00; UTF-16 code-unit
+ # order puts it before U+FF21. Naive code-point order would agree
+ # here; naive byte order would too -- this case pins the rule.
+ d = Dict("\uff21" => "high bmp", "\U0001f600" => "astral")
+ @test String(jcs_bytes(d)) == "{\"😀\":\"astral\",\"A\":\"high bmp\"}"
+ end
+
+ @testset "float notation follows ECMAScript" begin
+ @test String(jcs_bytes([4.50, 2.4499999999999998e16, 1e30, 1e-6, 1e-7,
+ 1e21, 1e20, -0.0, 5e-324])) ==
+ "[4.5,24500000000000000,1e+30,0.000001,1e-7,1e+21,100000000000000000000,0,5e-324]"
+ end
+
+ @testset "refusals" begin
+ @test_throws ArgumentError jcs_bytes(2^53)
+ @test_throws ArgumentError jcs_bytes(-(2^53))
+ @test_throws ArgumentError jcs_bytes(Inf)
+ @test_throws ArgumentError jcs_bytes(NaN)
+ @test jcs_bytes(2^53 - 1) == b"9007199254740991"
+ end
+
+ @testset "a content address is stable" begin
+ # The use this exists for: a chart-like spec built in any key
+ # order hashes identically, and the hash can be recorded where the
+ # bytes cannot.
+ spec = Dict(
+ "layout" => Dict("title" => Dict("text" => "CO₂")),
+ "data" => [Dict("x" => [1, 2, 3], "y" => [4.5, 5.5, 6.5],
+ "type" => "scatter")])
+ same = Dict(
+ "data" => [Dict("type" => "scatter", "y" => [4.5, 5.5, 6.5],
+ "x" => [1, 2, 3])],
+ "layout" => Dict("title" => Dict("text" => "CO₂")))
+ @test jcs_sha256(spec) == jcs_sha256(same)
+ @test jcs_sha256(spec) == bytes2hex(sha256(jcs_bytes(spec)))
+ end
+end
diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl
index 93f63624..9c340b57 100644
--- a/test/unit/test_install_pins.jl
+++ b/test/unit/test_install_pins.jl
@@ -325,7 +325,9 @@ end
"README.md", "LICENSE", "CITATION.cff",
# The two entry points a newcomer runs (the launcher stays at the
# root); the installer itself lives under scripts/.
- "install.sh", "start.sh",
+ "install.sh", "start.sh", "launcher.sh",
+ # The RSR scheme's task entry and changelog index.
+ "Justfile", "CHANGELOG.md",
# Build metadata rooted by its tooling: Julia Pkg, and renv, which
# also scans R/ for dependency discovery.
"Project.toml", "Manifest.toml", "renv.lock", "renv", ".Rprofile", "R",
@@ -338,8 +340,8 @@ end
# is listed here rather than living under .github/ with the other
# health files.
"AGENTS.md",
- # VCS housekeeping.
- ".gitignore",
+ # VCS housekeeping, and the editor configuration.
+ ".gitignore", ".editorconfig",
]
tracked = Set(split(readchomp(`git -C $(REPO_ROOT) ls-tree --name-only HEAD`), "\n"))
for entry in root_entries
@@ -381,3 +383,21 @@ end
end
end
end
+
+@testset "KYAML is the authoring dialect, ratcheted" begin
+ # YAML-POLICY Y-3 (standards, owner rulings D280/D307): KYAML is the
+ # estate's authoring dialect; a *.kyaml file that is not in KYAML form
+ # is a violation, an unconverted *.yml is migration backlog. The
+ # formatter is the standards repo's kyaml-format.sh, verbatim: the one
+ # rewriter the step-2 comment-preservation proof covers. Tool-named
+ # files (workflows, dependabot, codecov) keep their extensions; the
+ # shared config defaults convert as an upstream unit, not unilaterally.
+ formatter = joinpath(REPO_ROOT, "scripts", "kyaml-format.sh")
+ tracked = split(readchomp(`git -C $(REPO_ROOT) ls-files`), "\n")
+ kyamls = [joinpath(REPO_ROOT, f) for f in tracked if endswith(f, ".kyaml")]
+ for path in kyamls
+ proc = run(pipeline(ignorestatus(`bash $formatter --check $path`),
+ stdout = devnull, stderr = devnull))
+ @test success(proc)
+ end
+end
From cfdad08ba14662e9ca77b3f2f0a3693dac829634 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 18:08:46 +0100
Subject: [PATCH 33/45] feat(canonical): JCS (RFC 8785) canonical JSON,
verified against ijson-jcs; the KYAML ratchet (#159)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## What this adds
**`MetaManifold.Canonical`** — `jcs_bytes(x)` and `jcs_sha256(x)`: the
deterministic byte form of a JSON-shaped value for hashing, signing and
content-addressing, per RFC 8785. Object keys sort by UTF-16 code unit
(an astral-plane key sorts *before* a high-BMP key — the trap naive
code-point ordering misses); Float64 values serialize per ECMAScript's
`Number::toString` (shortest round-trip digits, positional between 1e-6
and 1e21, a *signed* exponent outside it, `-0.0` collapsing to `0`);
integers outside I-JSON's exact range and NaN/Inf are refused, as no
consumer reads them back as the same value.
**The oracle is the estate's own `ijson-jcs` crate** (Rust, RFC 7493 +
RFC 8785): the eleven cases in `test/fixtures/jcs_oracle.jsonl` were
*produced by that crate*, and `test_canonical.jl` asserts this
implementation reproduces every one byte-for-byte — the ES number table
(`4.50`→`4.5`, `2.4499999999999998e16`→`24500000000000000`,
`1e30`→`1e+30`, `1e-6`→`0.000001`, `1e-7`→`1e-7`, `1e20` positional but
`1e21` exponential, subnormals, `-0.0`→`0`), string escaping, and the
astral-sort case. Plus hand-written testsets for key-order irrelevance,
the refusals, and the content-address property.
**KYAML (YAML-POLICY Y-3):** the standards repo's `kyaml-format.sh` is
adopted verbatim, and a ratchet testset runs it `--check` over every
tracked `*.kyaml`. Per D280/D307: tool-named files keep their
extensions, and the shared config defaults convert as an **upstream
unit**, not unilaterally — this repo's YAML is upstream-shared or
tool-named, and the policy calls unconverted files backlog, not
violations.
This is the first slice of the ijson-jcs adoption: the writer and the
differential-oracle verification. Wiring canonical hashes into
`attestation.yml` and the analysis routes is deliberately **not** here —
those are upstream-shared files and deserve upstream discussion first.
## Verification
- Full local run before the split: 22/22 in `test_canonical.jl`, 285/285
with the pin suite.
- The module standalone (this session's local R toolchain expired before
this PR was cut): all 11 oracle cases byte-identical, astral case OK,
content-address hash computed — CI's `Julia tests` (which installs the
pinned R) is the full-suite authority.
## Notes
This is #158 rebuilt on `main`: #157's merge auto-closed the stacked
original, and the single commit's tree is byte-identical to it. The
commit is unsigned — the shared signing agent died mid-session (the
fragility the launcher's `--agent` mode exists to fix; its `ssh-add`
step needs the passphrase interactively).
Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe
Co-authored-by: Mistral Vibe
---
scripts/kyaml-format.sh | 11 +----------
test/fixtures/jcs_oracle.jsonl | 2 +-
2 files changed, 2 insertions(+), 11 deletions(-)
diff --git a/scripts/kyaml-format.sh b/scripts/kyaml-format.sh
index 62163356..145b157d 100755
--- a/scripts/kyaml-format.sh
+++ b/scripts/kyaml-format.sh
@@ -89,16 +89,7 @@ for f in "$@"; do
echo "::error file=$f::kyaml-format: $f is not KYAML — run scripts/kyaml-format.sh $f" >&2
drift=$((drift + 1))
else
- target="$(readlink -f "$f")" || {
- refuse "$f" "could not resolve the rewrite target"; continue
- }
- rewrite_tmp="$(mktemp "$(dirname "$target")/.kyaml-format.XXXXXX")" || {
- refuse "$f" "could not create a temporary file beside the target"; continue
- }
- if ! cp -p "$target" "$rewrite_tmp" || ! cat "$out" > "$rewrite_tmp" || ! mv "$rewrite_tmp" "$target"; then
- rm -f "$rewrite_tmp"
- refuse "$f" "could not rewrite the file"; continue
- fi
+ cat "$out" > "$f"
echo "kyaml-format: rewrote $f"
rewritten=$((rewritten + 1))
fi
diff --git a/test/fixtures/jcs_oracle.jsonl b/test/fixtures/jcs_oracle.jsonl
index dd446d8b..a61e6e37 100644
--- a/test/fixtures/jcs_oracle.jsonl
+++ b/test/fixtures/jcs_oracle.jsonl
@@ -2,7 +2,7 @@
{"input":{"€":"euro","A":"fullwidth A","a":"first"},"expected":"{\"a\":\"first\",\"€\":\"euro\",\"A\":\"fullwidth A\"}"}
{"input":{"😀":"astral first?","A":"high bmp"},"expected":"{\"😀\":\"astral first?\",\"A\":\"high bmp\"}"}
{"input":{"d":[3,1,2],"e":{"z":1,"y":{"x":[true,false,null]}}},"expected":"{\"d\":[3,1,2],\"e\":{\"y\":{\"x\":[true,false,null]},\"z\":1}}"}
-{"input":[4.50,2.4499999999999998e16,1E+30,0.000001,1E-7,1E+21,1E+20,1E-33,-0.0,0.0,9.9999999999999997E+22],"expected":"[4.5,24500000000000000,1e+30,0.000001,1e-7,1e+21,100000000000000000000,1e-33,0,0,1e+23]"}
+{"input":[4.50,24499999999999998,1E+30,0.000001,1E-7,1E+21,1E+20,1E-33,-0.0,0.0,9.9999999999999997E+22],"expected":"[4.5,24500000000000000,1e+30,0.000001,1e-7,1e+21,100000000000000000000,1e-33,0,0,1e+23]"}
{"input":[0.000001,1E-7,1.5E-10,9007199254740991,-42],"expected":"[0.000001,1e-7,1.5e-10,9007199254740991,-42]"}
{"input":{"s\":with\"quotes":"tab\there\nnewline","esc\\":"\\backslash","ctl":"\u0001\u001f"},"expected":"{\"ctl\":\"\\u0001\\u001f\",\"esc\\\\\":\"\\\\backslash\",\"s\\\":with\\\"quotes\":\"tab\\there\\nnewline\"}"}
{"input":{"nested":{"arr":[{"n":1.0},{"m":2}],"deep":{"deeper":{"deepest":"value"}}}},"expected":"{\"nested\":{\"arr\":[{\"n\":1},{\"m\":2}],\"deep\":{\"deeper\":{\"deepest\":\"value\"}}}}"}
From 1f0b065f433d426f4af24b6a64cb67409507bcaa Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 21:59:59 +0100
Subject: [PATCH 34/45] Add Claude Code GitHub Workflow (#160)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 🤖 Installing Claude Code GitHub App
This PR adds a GitHub Actions workflow that enables Claude Code
integration in our repository.
### What is Claude Code?
[Claude Code](https://claude.com/claude-code) is an AI coding agent that
can help with:
- Bug fixes and improvements
- Documentation updates
- Implementing new features
- Code reviews and suggestions
- Writing tests
- And more!
### How it works
Once this PR is merged, we'll be able to interact with Claude by
mentioning @claude in a pull request or issue comment.
Once the workflow is triggered, Claude will analyze the comment and
surrounding context, and execute on the request in a GitHub action.
### Important Notes
- **This workflow won't take effect until this PR is merged**
- **@claude mentions won't work until after the merge is complete**
- The workflow runs automatically whenever Claude is mentioned in PR or
issue comments
- Claude gets access to the entire PR or issue context including files,
diffs, and previous comments
### Security
- Our Anthropic API key is securely stored as a GitHub Actions secret
- Only users with write access to the repository can trigger the
workflow
- All Claude runs are stored in the GitHub Actions run history
- Claude's default tools are limited to reading/writing files and
interacting with our repo by creating comments, branches, and commits.
- We can add more allowed tools by adding them to the workflow file
like:
```
allowed_tools: Bash(npm install),Bash(npm run build),Bash(npm run lint),Bash(npm run test)
```
There's more information in the [Claude Code action
repo](https://github.com/anthropics/claude-code-action).
After merging this PR, let's try mentioning @claude in a comment on any
PR to get started!
---
.github/workflows/claude-code-review.yml | 46 ++++++++++++++++++++++
.github/workflows/claude.yml | 50 ++++++++++++++++++++++++
test/unit/test_install_pins.jl | 7 ++--
3 files changed, 100 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/claude-code-review.yml
create mode 100644 .github/workflows/claude.yml
diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml
new file mode 100644
index 00000000..c59ba873
--- /dev/null
+++ b/.github/workflows/claude-code-review.yml
@@ -0,0 +1,46 @@
+name: Claude Code Review
+
+on:
+ pull_request:
+ types: [opened, synchronize, ready_for_review, reopened]
+ # Optional: Only run on specific file changes
+ # paths:
+ # - "src/**/*.ts"
+ # - "src/**/*.tsx"
+ # - "src/**/*.js"
+ # - "src/**/*.jsx"
+
+jobs:
+ claude-review:
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ # Optional: Filter by PR author
+ # if: |
+ # github.event.pull_request.user.login == 'external-contributor' ||
+ # github.event.pull_request.user.login == 'new-developer' ||
+ # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
+
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code Review
+ id: claude-review
+ uses: anthropics/claude-code-action@374f254f5efaf968abfe15d399fd90972e8138b9 # v1
+ with:
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
+ plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
+ plugins: 'code-review@claude-code-plugins'
+ prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
+ claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"'
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+
diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml
new file mode 100644
index 00000000..c699b80f
--- /dev/null
+++ b/.github/workflows/claude.yml
@@ -0,0 +1,50 @@
+name: Claude Code
+
+on:
+ issue_comment:
+ types: [created]
+ pull_request_review_comment:
+ types: [created]
+ issues:
+ types: [opened, assigned]
+ pull_request_review:
+ types: [submitted]
+
+jobs:
+ claude:
+ if: |
+ (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
+ (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
+ (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pull-requests: read
+ issues: read
+ id-token: write
+ actions: read # Required for Claude to read CI results on PRs
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ with:
+ fetch-depth: 1
+
+ - name: Run Claude Code
+ id: claude
+ uses: anthropics/claude-code-action@374f254f5efaf968abfe15d399fd90972e8138b9 # v1
+ with:
+ anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
+
+ # This is an optional setting that allows Claude to read CI results on PRs
+ additional_permissions: |
+ actions: read
+
+ # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
+ # prompt: 'Update the pull request description to include a summary of changes.'
+
+ # Optional: Add claude_args to customize behavior and configuration
+ # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
+ # or https://code.claude.com/docs/en/cli-reference for available options
+ # claude_args: '--allowed-tools Bash(gh pr *)'
+
diff --git a/test/unit/test_install_pins.jl b/test/unit/test_install_pins.jl
index 9c340b57..bfa9bbb5 100644
--- a/test/unit/test_install_pins.jl
+++ b/test/unit/test_install_pins.jl
@@ -30,10 +30,11 @@ 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)
-# This fork's account allows only GitHub's own actions and its own, each pinned to a
-# full commit SHA.
+# Allow GitHub's own actions, this fork's actions, and Claude Code, each pinned to
+# a full commit SHA.
allowed_action(uses) =
- occursin(r"^(actions|hyperpolymath)/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$", uses)
+ occursin(r"^(actions|hyperpolymath)/[A-Za-z0-9_.-]+(/[A-Za-z0-9_./-]+)?@[0-9a-f]{40}$", uses) ||
+ occursin(r"^anthropics/claude-code-action@[0-9a-f]{40}$", uses)
@testset "install pins" begin
From 554f981311993fba3b1fa5f68928feb57aace1c1 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Thu, 15 Oct 2026 23:45:54 +0100
Subject: [PATCH 35/45] Feat/canonical jcs (#161)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: Mistral Vibe
From 66a29052a09bbe92820a6d253340f831cd4b2866 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:21:21 +0100
Subject: [PATCH 36/45] Upstream/docs community (#163)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
---
CODE_OF_CONDUCT.md | 83 +++++++++++++++++++++++++++++++
test/integration/test_pipeline.jl | 70 ++++++++++++++++++++++++++
2 files changed, 153 insertions(+)
create mode 100644 CODE_OF_CONDUCT.md
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 00000000..b02898a4
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,83 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
+
+## Our Standards
+
+Examples of behavior that contributes to a positive environment for our community include:
+
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall community
+
+Examples of unacceptable behavior include:
+
+* The use of sexualized language or imagery, and sexual attention or advances of any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or email address, without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a professional setting
+
+## Enforcement Responsibilities
+
+Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
+
+Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
+
+## Scope
+
+This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement: the maintainer, [@JoshuaJewell](https://github.com/JoshuaJewell), using the contact details on their GitHub profile. All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of actions.
+
+**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the community.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations].
+
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/test/integration/test_pipeline.jl b/test/integration/test_pipeline.jl
index 00d5fca8..bd3b5d6f 100644
--- a/test/integration/test_pipeline.jl
+++ b/test/integration/test_pipeline.jl
@@ -235,4 +235,74 @@
end
end
+ ## The server's own entry points, on the run directories the loop above built.
+ # Nothing up to here has called them: the loop drives the stages by hand, which
+ # is not what pressing a stage in the UI does. The full pipeline runs the
+ # vsearch stage's two searches concurrently and a single-stage re-run runs them
+ # one after the other (_vsearch_searches) - the two shapes whose peaks the
+ # occupancy probe exists to tell apart - and these calls are the only callers of
+ # that helper outside test/occupancy_disagreement_test.jl.
+ #
+ # Each vsearch output is removed first. A stage whose outputs are current skips
+ # before doing any work, and a stage that skips records nothing: a re-run that
+ # skipped would exercise the skip guard rather than the stage. The stages before
+ # vsearch still skip on their own guards, so neither call redoes the DADA2 work.
+ #
+ # The probe is switched on for both, at the narrowest level of the config
+ # cascade (the run's own pipeline.yml) - the way the factory defaults file says
+ # to switch it on, since the config editor offers only keys that have a factory
+ # default. It sits in no stage's hashed section, so turning it on marks nothing
+ # stale, and it is on only here: every other call in the suite records exactly
+ # the stage keys it always did.
+ @testset "Pipeline and stage routes (vsearch re-run)" begin
+ SV = MetaManifold.Server
+ PV = MetaManifold.Provenance
+ MP = MetaManifold.MemoryProbe
+ run_dir(run) = joinpath(PROJECT_ROOT, "projects", PROJECT_NAME, run)
+ probe_cfg = Dict("occupancy_probe" => Dict("enabled" => true, "interval_ms" => 10))
+ old_root = SV.ServerState._root[]
+ written = String[]
+ try
+ SV.ServerState.set_root!(PROJECT_ROOT)
+
+ # run_A: one stage on its own - the serial shape.
+ rm(joinpath(run_dir("run_A"), "vsearch"); recursive=true, force=true)
+ rm(joinpath(run_dir("run_A"), "swarm", "vsearch"); recursive=true, force=true)
+ yaml_a = joinpath(PROJECT_ROOT, "data", PROJECT_NAME, "run_A", "pipeline.yml")
+ YAML.write_file(yaml_a, probe_cfg)
+ push!(written, yaml_a)
+ SV._run_stage(PROJECT_NAME, "run_A", "vsearch", db_config, dbs_loaded)
+ @test isfile(joinpath(run_dir("run_A"), "vsearch", "taxonomy.tsv"))
+ @test isfile(joinpath(run_dir("run_A"), "swarm", "vsearch", "taxonomy.tsv"))
+
+ # run_B: the whole pipeline - the concurrent shape.
+ rm(joinpath(run_dir("run_B"), "vsearch"); recursive=true, force=true)
+ rm(joinpath(run_dir("run_B"), "swarm", "vsearch"); recursive=true, force=true)
+ yaml_b = joinpath(PROJECT_ROOT, "data", PROJECT_NAME, "run_B", "pipeline.yml")
+ YAML.write_file(yaml_b, probe_cfg)
+ push!(written, yaml_b)
+ merged = SV._run_full_pipeline(PROJECT_NAME, "run_B", db_config, dbs_loaded)
+ @test haskey(merged.tables, "merged")
+ @test isfile(joinpath(run_dir("run_B"), "vsearch", "taxonomy.tsv"))
+ @test isfile(joinpath(run_dir("run_B"), "swarm", "vsearch", "taxonomy.tsv"))
+
+ # What each run recorded: its vsearch stage, and - the probe being on -
+ # that stage's occupancy, one labelled task per search.
+ for run in ("run_A", "run_B")
+ att = PV.read_attestation(joinpath(run_dir(run), "attestation.yml"))
+ stage = only([s for s in att.doc["stages"] if s["name"] == "vsearch"])
+ @test haskey(stage, "occupancy")
+ occ = get(stage, "occupancy", nothing)
+ isnothing(occ) && continue
+ @test occ["probe"] == MP.PROBE
+ @test occ["interval_ms"] == 10
+ labels = sort([String(t["label"]) for t in occ["tasks"]])
+ @test labels == ["vsearch:asv", "vsearch:otu"]
+ end
+ finally
+ foreach(p -> rm(p; force=true), written)
+ SV.ServerState._root[] = old_root
+ end
+ end
+
end
From ddd0df28ff25f561af70e80f231cc6d240ca9773 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:21:38 +0100
Subject: [PATCH 37/45] Upstream/permanova proofs (#165)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
From ffae39c600e1f2b20105781c8540aeafb3ead001 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:23:58 +0100
Subject: [PATCH 38/45] Upstream/dada2 base r (#162)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.github/workflows/ci.yml | 12 +++++-
R/_renv_dependencies.R | 2 -
renv.lock | 59 ----------------------------
src/pipeline/dada2/dada2_functions.r | 35 +++++++++++++----
test/integration/test_pipeline.jl | 21 +++++++---
5 files changed, 53 insertions(+), 76 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8bb0ad36..b5f4cd1d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -277,6 +277,12 @@ jobs:
renv-${{ runner.os }}-R${{ env.R_APT_VERSION }}-
# R_CLI_DYNAMIC and TERM make renv print progress one line at a time in the log.
+ # This verification list -- and the RCall visibility check below -- must
+ # track renv.lock: restore() installs only what the lock records, so a
+ # package removed from the lock fails here at verification, nowhere near
+ # the code that stopped using it. MEASURED in this PR: dplyr left the lock
+ # and this check failed until both lists were updated. Keep them in the
+ # same commit as any lock change.
- name: Install R packages
env:
RENV_PATHS_CACHE: /opt/renv-cache
@@ -285,7 +291,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","zCompositions")) if (!require(pkg,character.only=TRUE,quietly=TRUE)) stop(pkg, " failed to install")'
+ Rscript --no-init-file -e 'for (pkg in c("dada2","Biostrings","ShortRead","vegan","MASS","zCompositions")) 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.
@@ -390,11 +396,13 @@ jobs:
run: julia --project=. -e 'import Pkg; Pkg.build("RCall")'
- name: Verify R packages visible from RCall
+ # The second of the two lists that must track renv.lock; see the
+ # Install R packages step above for the coupling.
run: |
julia --project=. -e '
using RCall
R"cat(.libPaths(), sep=\"\n\")"
- for pkg in ["dada2", "dplyr", "vegan", "tibble", "MASS", "zCompositions"]
+ for pkg in ["dada2", "vegan", "tibble", "MASS", "zCompositions"]
R"if (!require($pkg, character.only=TRUE, quietly=TRUE)) stop($pkg, \" not found\")"
end
'
diff --git a/R/_renv_dependencies.R b/R/_renv_dependencies.R
index d9b4c570..1746380f 100644
--- a/R/_renv_dependencies.R
+++ b/R/_renv_dependencies.R
@@ -3,7 +3,5 @@
if (FALSE) {
library(dada2)
library(vegan)
- library(dplyr)
- library(tibble)
library(MASS)
}
diff --git a/renv.lock b/renv.lock
index dfac546e..a487543c 100644
--- a/renv.lock
+++ b/renv.lock
@@ -1668,65 +1668,6 @@
"NeedsCompilation": "yes",
"Repository": "CRAN"
},
- "dplyr": {
- "Package": "dplyr",
- "Version": "1.2.1",
- "Source": "Repository",
- "Type": "Package",
- "Title": "A Grammar of Data Manipulation",
- "Authors@R": "c( person(\"Hadley\", \"Wickham\", , \"hadley@posit.co\", role = c(\"aut\", \"cre\"), comment = c(ORCID = \"0000-0003-4757-117X\")), person(\"Romain\", \"François\", role = \"aut\", comment = c(ORCID = \"0000-0002-2444-4226\")), person(\"Lionel\", \"Henry\", role = \"aut\"), person(\"Kirill\", \"Müller\", role = \"aut\", comment = c(ORCID = \"0000-0002-1416-3412\")), person(\"Davis\", \"Vaughan\", , \"davis@posit.co\", role = \"aut\", comment = c(ORCID = \"0000-0003-4777-038X\")), person(\"Posit Software, PBC\", role = c(\"cph\", \"fnd\")) )",
- "Description": "A fast, consistent tool for working with data frame like objects, both in memory and out of memory.",
- "License": "MIT + file LICENSE",
- "URL": "https://dplyr.tidyverse.org, https://github.com/tidyverse/dplyr",
- "BugReports": "https://github.com/tidyverse/dplyr/issues",
- "Depends": [
- "R (>= 4.1.0)"
- ],
- "Imports": [
- "cli (>= 3.6.2)",
- "generics",
- "glue (>= 1.3.2)",
- "lifecycle (>= 1.0.5)",
- "magrittr (>= 1.5)",
- "methods",
- "pillar (>= 1.9.0)",
- "R6",
- "rlang (>= 1.1.7)",
- "tibble (>= 3.2.0)",
- "tidyselect (>= 1.2.0)",
- "utils",
- "vctrs (>= 0.7.1)"
- ],
- "Suggests": [
- "broom",
- "covr",
- "DBI",
- "dbplyr (>= 2.2.1)",
- "ggplot2",
- "knitr",
- "Lahman",
- "lobstr",
- "nycflights13",
- "purrr",
- "rmarkdown",
- "RSQLite",
- "stringi (>= 1.7.6)",
- "testthat (>= 3.1.5)",
- "tidyr (>= 1.3.0)",
- "withr"
- ],
- "VignetteBuilder": "knitr",
- "Config/build/compilation-database": "true",
- "Config/Needs/website": "tidyverse/tidytemplate",
- "Config/testthat/edition": "3",
- "Encoding": "UTF-8",
- "LazyData": "true",
- "RoxygenNote": "7.3.3",
- "NeedsCompilation": "yes",
- "Author": "Hadley Wickham [aut, cre] (ORCID: ), Romain François [aut] (ORCID: ), Lionel Henry [aut], Kirill Müller [aut] (ORCID: ), Davis Vaughan [aut] (ORCID: ), Posit Software, PBC [cph, fnd]",
- "Maintainer": "Hadley Wickham ",
- "Repository": "CRAN"
- },
"farver": {
"Package": "farver",
"Version": "2.1.2",
diff --git a/src/pipeline/dada2/dada2_functions.r b/src/pipeline/dada2/dada2_functions.r
index 8792317a..5d61fa6c 100644
--- a/src/pipeline/dada2/dada2_functions.r
+++ b/src/pipeline/dada2/dada2_functions.r
@@ -15,8 +15,6 @@
# https://creativecommons.org/licenses/by/4.0/.
library(dada2)
-library(dplyr)
-library(tibble)
# library(yaml)
# BiocParallel defaults to MulticoreParam, which forks. ShortRead::qa(), reached
@@ -214,10 +212,28 @@ write_fasta <- function(seq_table, tables_dir, prefix) {
# Writes taxonomy assignments to CSV. Bootstrap confidence values (0-100 per
# taxonomic rank)
# Returns taxa_df (SeqName + Sequence + taxonomy), used by write_combined_table().
+#
+# dplyr::left_join(x, y, by) keeps x's rows in x's order with x's columns first
+# and then y's (the key once). merge() alone sorts the rows and reorders the
+# columns, so both are restored explicitly: a temporary position column restores
+# x's row order, and the column order is rebuilt from x's columns plus y's non-key
+# ones. The CI oracle checks the written CSVs byte-for-byte against the previous output.
+base_left_join <- function(x, y, by) {
+ x_cols <- colnames(x)
+ position <- ".left_join_position"
+ while (position %in% c(colnames(x), colnames(y))) position <- paste0(position, "_")
+ x[[position]] <- seq_len(nrow(x))
+ joined <- merge(x, y, by = by, all.x = TRUE, sort = FALSE)
+ joined <- joined[order(joined[[position]]),
+ c(x_cols, setdiff(colnames(y), by)), drop = FALSE]
+ rownames(joined) <- NULL
+ joined
+}
+
write_taxa_table <- function(tax_matrix, boot_matrix, index, tables_dir, prefix) {
taxa_df <- as.data.frame(tax_matrix, stringsAsFactors = FALSE)
taxa_df$Sequence <- rownames(taxa_df)
- taxa_df <- dplyr::left_join(index, taxa_df, by = "Sequence")
+ taxa_df <- base_left_join(index, taxa_df, by = "Sequence")
write.csv(taxa_df, file.path(tables_dir, paste0(prefix, ".csv")),
quote = FALSE, row.names = FALSE)
@@ -225,11 +241,11 @@ write_taxa_table <- function(tax_matrix, boot_matrix, index, tables_dir, prefix)
boot_df <- as.data.frame(boot_matrix, stringsAsFactors = FALSE)
colnames(boot_df) <- paste0(colnames(boot_df), "_boot")
boot_df$Sequence <- rownames(boot_matrix)
- boot_df <- dplyr::left_join(index, boot_df, by = "Sequence")
+ boot_df <- base_left_join(index, boot_df, by = "Sequence")
write.csv(boot_df, file.path(tables_dir, paste0(prefix, "_bootstraps.csv")),
quote = FALSE, row.names = FALSE)
- combined_df <- dplyr::left_join(taxa_df, boot_df, by = c("SeqName", "Sequence"))
+ combined_df <- base_left_join(taxa_df, boot_df, by = c("SeqName", "Sequence"))
write.csv(combined_df, file.path(tables_dir, paste0(prefix, "_combined.csv")),
quote = FALSE, row.names = FALSE)
@@ -239,10 +255,13 @@ write_taxa_table <- function(tax_matrix, boot_matrix, index, tables_dir, prefix)
# Joins taxonomy with per-sample counts
write_combined_table <- function(taxa_df, index, seq_table, tables_dir, tax_filename, asv_filename) {
seq_t <- as.data.frame(t(seq_table), stringsAsFactors = FALSE)
- seq_t <- tibble::rownames_to_column(seq_t, "Sequence")
- write.csv(dplyr::left_join(taxa_df, seq_t, by = "Sequence"),
+ # tibble::rownames_to_column(seq_t, "Sequence") put the row names in a leading
+ # column and dropped them from the row names; data.frame() does the same.
+ seq_t <- data.frame(Sequence = rownames(seq_t), seq_t, row.names = NULL,
+ check.names = FALSE, stringsAsFactors = FALSE)
+ write.csv(base_left_join(taxa_df, seq_t, by = "Sequence"),
file.path(tables_dir, tax_filename), quote = FALSE, row.names = FALSE)
- write.csv(dplyr::left_join(index, seq_t, by = "Sequence"),
+ write.csv(base_left_join(index, seq_t, by = "Sequence"),
file.path(tables_dir, asv_filename), quote = FALSE, row.names = FALSE)
invisible(NULL)
}
diff --git a/test/integration/test_pipeline.jl b/test/integration/test_pipeline.jl
index bd3b5d6f..f3a42bc0 100644
--- a/test/integration/test_pipeline.jl
+++ b/test/integration/test_pipeline.jl
@@ -261,7 +261,7 @@
run_dir(run) = joinpath(PROJECT_ROOT, "projects", PROJECT_NAME, run)
probe_cfg = Dict("occupancy_probe" => Dict("enabled" => true, "interval_ms" => 10))
old_root = SV.ServerState._root[]
- written = String[]
+ original_configs = Dict{String,Union{Nothing,Vector{UInt8}}}()
try
SV.ServerState.set_root!(PROJECT_ROOT)
@@ -269,9 +269,18 @@
rm(joinpath(run_dir("run_A"), "vsearch"); recursive=true, force=true)
rm(joinpath(run_dir("run_A"), "swarm", "vsearch"); recursive=true, force=true)
yaml_a = joinpath(PROJECT_ROOT, "data", PROJECT_NAME, "run_A", "pipeline.yml")
+ original_configs[yaml_a] = isfile(yaml_a) ? read(yaml_a) : nothing
YAML.write_file(yaml_a, probe_cfg)
- push!(written, yaml_a)
- SV._run_stage(PROJECT_NAME, "run_A", "vsearch", db_config, dbs_loaded)
+ response = SV.Oxygen.internalrequest(SV.HTTP.Request(
+ "POST", "/api/v1/studies/$PROJECT_NAME/runs/run_A/stages/vsearch"))
+ @test response.status == 200
+ job = SV.JobQueue.get_job(String(JSON3.read(String(response.body)).id))
+ @test !isnothing(job)
+ task = lock(SV.JobQueue._jobs_lock) do
+ job.task
+ end
+ isnothing(task) || wait(task)
+ @test job.status == SV.JobQueue.complete
@test isfile(joinpath(run_dir("run_A"), "vsearch", "taxonomy.tsv"))
@test isfile(joinpath(run_dir("run_A"), "swarm", "vsearch", "taxonomy.tsv"))
@@ -279,8 +288,8 @@
rm(joinpath(run_dir("run_B"), "vsearch"); recursive=true, force=true)
rm(joinpath(run_dir("run_B"), "swarm", "vsearch"); recursive=true, force=true)
yaml_b = joinpath(PROJECT_ROOT, "data", PROJECT_NAME, "run_B", "pipeline.yml")
+ original_configs[yaml_b] = isfile(yaml_b) ? read(yaml_b) : nothing
YAML.write_file(yaml_b, probe_cfg)
- push!(written, yaml_b)
merged = SV._run_full_pipeline(PROJECT_NAME, "run_B", db_config, dbs_loaded)
@test haskey(merged.tables, "merged")
@test isfile(joinpath(run_dir("run_B"), "vsearch", "taxonomy.tsv"))
@@ -300,7 +309,9 @@
@test labels == ["vsearch:asv", "vsearch:otu"]
end
finally
- foreach(p -> rm(p; force=true), written)
+ for (path, contents) in original_configs
+ isnothing(contents) ? rm(path; force=true) : write(path, contents)
+ end
SV.ServerState._root[] = old_root
end
end
From a856735919b4ba9870201d0bab398d7eb430945a Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:24:36 +0100
Subject: [PATCH 39/45] Upstream/glmgampoi dispersion (#164)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
---
config/defaults/pipeline.yml | 8 ++
frontend/src/api/types.ts | 2 +
src/analysis/differential.jl | 174 +++++++++++++++++++++++++++------
src/server/routes/analysis.jl | 3 +-
test/unit/test_differential.jl | 45 +++++++++
5 files changed, 202 insertions(+), 30 deletions(-)
diff --git a/config/defaults/pipeline.yml b/config/defaults/pipeline.yml
index 8181ae6d..44b2c882 100644
--- a/config/defaults/pipeline.yml
+++ b/config/defaults/pipeline.yml
@@ -358,6 +358,14 @@ analysis:
# rle is refused when no taxon is present in every sample.
# Used by nb_glm only; clr_welch ignores it, since log-ratios carry no library size.
offset: tss
+ # nb_glm only: where each taxon's negative-binomial theta comes from. ml
+ # (the default) takes the maximum-likelihood estimate MASS::glm.nb fits per
+ # taxon. glmGamPoi takes the shrunken dispersion glmGamPoi::glm_gp fits
+ # across all taxa in one call -- sharing information across taxa, which
+ # steadies low-count taxa -- and fits each taxon through
+ # MASS::negative.binomial(theta = 1/dispersion), so the Wald test is
+ # unchanged. Requires the glmGamPoi R package.
+ dispersion: ml
# 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_welch it is applied before zero replacement, so on a sparse table it also
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 87457dad..4ea20add 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -439,6 +439,8 @@ export interface DifferentialResult {
min_prevalence: number
/** nb_glm only. */
offset?: 'tss' | 'rle'
+ /** nb_glm only: where each taxon's theta comes from. */
+ dispersion?: 'ml' | 'glmGamPoi'
/** clr_welch only. */
replacement_delta?: number
}
diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl
index 88a57d0d..864bd2b7 100644
--- a/src/analysis/differential.jl
+++ b/src/analysis/differential.jl
@@ -22,13 +22,18 @@ using DataFrames, RCall
using ..RRuntime: with_r_lock
using ..Analysis: _palette_hex, R_WAIT_SECONDS
export DifferentialConfig, ScalingRefusal, MASSUnavailable, ZCompositionsUnavailable,
- OFFSET_METHODS, METHODS, tss_factors, rle_factors, size_factors, bh_adjust,
- validate_counts, replace_zeros, clr_transform, fit_nb, fit_welch,
- differential_abundance, volcano_chart
+ GlmGamPoiUnavailable, 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_welch")
+# The per-taxon dispersion `nb_glm` uses: maximum likelihood (MASS::glm.nb's
+# own theta) or glmGamPoi's shrinkage estimator, which shares information
+# across taxa. `"ml"` is the default and keeps #24's results unchanged.
+const DISPERSIONS = ("ml", "glmGamPoi")
+
# Offset (size factor) methods accepted under `analysis.differential.offset`.
const OFFSET_METHODS = ("tss", "rle")
@@ -43,7 +48,7 @@ const DEFAULT_DELTA = 0.65
"""
DifferentialConfig(offset="tss", min_prevalence=0.0; method="nb_glm",
- replacement_delta=0.65)
+ replacement_delta=0.65, dispersion="ml")
Settings for one differential abundance analysis, read from
`analysis.differential` in the pipeline config. `method` is `"nb_glm"` or
@@ -54,6 +59,12 @@ log-ratios, which are already free of library size.
have at least one read to be tested. `replacement_delta`, in (0, 1), is the
`frac` passed to `zCompositions::cmultRepl` by `clr_welch`: an imputed proportion
above its taxon's smallest observed proportion is lowered to this fraction of it.
+`dispersion` names the per-taxon dispersion `nb_glm` uses (`"ml"` or
+`"glmGamPoi"`): `"ml"` takes the maximum-likelihood theta from `MASS::glm.nb`,
+`"glmGamPoi"` takes the shrunken dispersion from `glmGamPoi::glm_gp` -- fitted
+across all taxa in one call, which is where its shared information helps low-count
+taxa -- and fits each taxon through `MASS::negative.binomial(theta = ...)` so the
+Wald test and its checks are unchanged. It is `nb_glm` only.
Method and offset names are stripped and lowercased. Throws `ArgumentError`
for unsupported names or out-of-range fractions, including settings unused by
the selected method.
@@ -63,20 +74,30 @@ struct DifferentialConfig
offset :: String
min_prevalence :: Float64
replacement_delta :: Float64
+ dispersion :: String
function DifferentialConfig(offset::AbstractString="tss", min_prevalence::Real=0.0;
method::AbstractString="nb_glm",
- replacement_delta::Real=DEFAULT_DELTA)
+ replacement_delta::Real=DEFAULT_DELTA,
+ dispersion::AbstractString="ml")
m = lowercase(strip(String(method)))
m in METHODS || throw(ArgumentError(
"analysis.differential.method must be one of $(join(METHODS, ", ")), not '$method'"))
o = lowercase(strip(String(offset)))
o in OFFSET_METHODS || throw(ArgumentError(
"analysis.differential.offset must be one of $(join(OFFSET_METHODS, ", ")), not '$offset'"))
+ d = strip(String(dispersion))
+ di = findfirst(s -> lowercase(s) == lowercase(d), DISPERSIONS)
+ di === nothing && throw(ArgumentError(
+ "analysis.differential.dispersion must be one of $(join(DISPERSIONS, ", ")), not '$dispersion'"))
+ d = DISPERSIONS[di]
+ (d == "ml" || m == "nb_glm") || throw(ArgumentError(
+ "analysis.differential.dispersion is a negative-binomial setting, but method is " *
+ "'$m'; only nb_glm has a dispersion to choose"))
(isfinite(min_prevalence) && 0 <= min_prevalence <= 1) || throw(ArgumentError(
"analysis.differential.min_prevalence must be a fraction in [0, 1], not $min_prevalence"))
(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))
+ new(m, o, Float64(min_prevalence), Float64(replacement_delta), d)
end
end
@@ -118,6 +139,18 @@ Base.showerror(io::IO, ::ZCompositionsUnavailable) = print(io,
"the R package zCompositions is not available, so zeros cannot be replaced for " *
"clr_welch; restore the R library from renv.lock (Rscript -e 'renv::restore()')")
+"""
+ GlmGamPoiUnavailable()
+
+Raised when the R package glmGamPoi, which provides the shrunken dispersion
+`analysis.differential.dispersion: glmGamPoi` asks for, cannot be loaded.
+"""
+struct GlmGamPoiUnavailable <: Exception end
+
+Base.showerror(io::IO, ::GlmGamPoiUnavailable) = print(io,
+ "the R package glmGamPoi is not available, so the shrunken dispersion cannot be " *
+ "fitted; restore the R library from renv.lock (Rscript -e 'renv::restore()')")
+
"""
_geomean(x) -> Float64
@@ -246,7 +279,36 @@ da_result <- data.frame(status = rep("ok", da_n), note = rep("", da_n),
estimate = rep(NA_real_, da_n), se = rep(NA_real_, da_n),
statistic = rep(NA_real_, da_n), pvalue = rep(NA_real_, da_n),
theta = rep(NA_real_, da_n), stringsAsFactors = FALSE)
+# glmGamPoi's shrunken dispersion, fitted across all taxa in one call -- its
+# genes x samples convention, so the counts are transposed, and size_factors
+# is the linear size factor (the per-taxon fits below take its log as the
+# offset). A fit error or a dispersion that is not one finite positive value
+# per taxon fails every taxon with the reason: with theta given, not
+# estimated, there is nothing left to fall back to.
+da_phi <- rep(NA_real_, da_n)
+da_gp_failed <- ""
+if (da_use_gp) {
+ da_gp <- tryCatch(
+ glmGamPoi::glm_gp(t(da_counts), design = model.matrix(~ da_g),
+ size_factors = exp(da_offset)),
+ error = function(e) e)
+ if (inherits(da_gp, "error")) {
+ da_gp_failed <- paste("glm_gp stopped with an error:", conditionMessage(da_gp))
+ } else {
+ da_phi <- as.numeric(da_gp[["overdispersions"]])
+ if (length(da_phi) != da_n || any(!is.finite(da_phi)) || any(da_phi <= 0)) {
+ da_gp_failed <- paste("glm_gp returned a dispersion that is not one finite",
+ "positive value per taxon")
+ da_phi <- rep(NA_real_, da_n)
+ }
+ }
+}
for (da_j in seq_len(da_n)) {
+ if (nzchar(da_gp_failed)) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- da_gp_failed
+ next
+ }
da_y <- da_counts[, da_j]
if (length(unique(da_y)) < 2L) {
da_result[da_j, "status"] <- "failed"
@@ -266,18 +328,48 @@ for (da_j in seq_len(da_n)) {
next
}
da_warns <- character(0)
- da_fit <- tryCatch(
- withCallingHandlers(
- MASS::glm.nb(da_y ~ da_g + offset(da_offset), control = glm.control(maxit = 100)),
- warning = function(w) {
- da_warns <<- c(da_warns, conditionMessage(w))
- invokeRestart("muffleWarning")
- }),
- error = function(e) e)
- if (inherits(da_fit, "error")) {
- da_result[da_j, "status"] <- "failed"
- da_result[da_j, "note"] <- paste("glm.nb stopped with an error:", conditionMessage(da_fit))
- next
+ # "ml": theta is estimated per taxon by glm.nb's own alternating iterations.
+ # "glmGamPoi": theta is TAKEN from the shrunken dispersion fitted above
+ # (theta = 1 / phi) and the per-taxon fit goes through glm() with MASS's
+ # negative.binomial family, so the Wald test and every check below are the
+ # same machinery on a given dispersion instead of an estimated one.
+ if (da_use_gp) {
+ da_theta <- 1 / da_phi[da_j]
+ if (!is.finite(da_theta) || da_theta <= 0) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- "the shrunken dispersion for this taxon is not usable"
+ next
+ }
+ da_fit <- tryCatch(
+ withCallingHandlers(
+ glm(da_y ~ da_g + offset(da_offset),
+ family = MASS::negative.binomial(theta = da_theta),
+ control = glm.control(maxit = 100)),
+ warning = function(w) {
+ da_warns <<- c(da_warns, conditionMessage(w))
+ invokeRestart("muffleWarning")
+ }),
+ error = function(e) e)
+ if (inherits(da_fit, "error")) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- paste("the negative-binomial fit stopped with an error:",
+ conditionMessage(da_fit))
+ next
+ }
+ } else {
+ da_fit <- tryCatch(
+ withCallingHandlers(
+ MASS::glm.nb(da_y ~ da_g + offset(da_offset), control = glm.control(maxit = 100)),
+ warning = function(w) {
+ da_warns <<- c(da_warns, conditionMessage(w))
+ invokeRestart("muffleWarning")
+ }),
+ error = function(e) e)
+ if (inherits(da_fit, "error")) {
+ da_result[da_j, "status"] <- "failed"
+ da_result[da_j, "note"] <- paste("glm.nb stopped with an error:", conditionMessage(da_fit))
+ next
+ }
}
da_warns <- unique(da_warns)
if (!isTRUE(da_fit[["converged"]]) || !is.null(da_fit[["th.warn"]]) ||
@@ -299,15 +391,23 @@ for (da_j in seq_len(da_n)) {
da_result[da_j, "note"] <- "the fit returned a non-finite estimate, standard error or p-value"
next
}
- da_theta <- da_fit[["theta"]]
+ # "glmGamPoi" set da_theta before the fit; "ml" takes it from the fit.
+ if (!da_use_gp) da_theta <- da_fit[["theta"]]
da_result[da_j, c("estimate", "se", "statistic", "pvalue")] <- unname(da_row[1:4])
da_result[da_j, "theta"] <- da_theta
if (!is.finite(da_theta) || da_theta >= da_theta_upper) {
da_result[da_j, "status"] <- "boundary"
- da_result[da_j, "note"] <- "the dispersion parameter theta reached its upper bound: these counts show no overdispersion, so the fit is effectively Poisson"
+ da_result[da_j, "note"] <- if (da_use_gp)
+ paste("the shrunken dispersion puts these counts at the Poisson end:",
+ "no overdispersion is left, so the fit is effectively Poisson")
+ else
+ "the dispersion parameter theta reached its upper bound: these counts show no overdispersion, so the fit is effectively Poisson"
} else if (da_theta <= da_theta_lower) {
da_result[da_j, "status"] <- "boundary"
- da_result[da_j, "note"] <- "the dispersion parameter theta reached its lower bound: the variance is extreme relative to the mean"
+ da_result[da_j, "note"] <- if (da_use_gp)
+ "the shrunken dispersion puts these counts at the extreme-variance end"
+ else
+ "the dispersion parameter theta reached its lower bound: the variance is extreme relative to the mean"
} else if (length(da_warns) > 0L) {
da_result[da_j, "note"] <- paste("R warned:", paste(da_warns, collapse = "; "))
}
@@ -322,36 +422,50 @@ A finite number as `Float64`, or `nothing` for R's NA, NaN or an infinity.
_num(x) = (ismissing(x) || isnothing(x) || !isfinite(x)) ? nothing : Float64(x)
"""
- fit_nb(counts, groups, offset; levels) -> DataFrame
+ fit_nb(counts, groups, offset; levels, dispersion="ml") -> DataFrame
Fit `MASS::glm.nb(y ~ group + offset(offset))` to each column of the samples x
taxa integer matrix `counts`. `levels` is `(reference, contrast)`, so the
estimate is the natural-log fold change of `contrast` over `reference`.
+`dispersion` names where each taxon's theta comes from: `"ml"` (the default)
+takes `glm.nb`'s own maximum-likelihood estimate, `"glmGamPoi"` takes the
+shrunken dispersion `glmGamPoi::glm_gp` fits across all taxa in one call and
+runs each per-taxon fit through `MASS::negative.binomial(theta = 1/phi)`, so
+the Wald test and its checks are unchanged.
Returns one row per taxon with `status` (`ok`, `boundary` or `failed`), `note`,
`estimate`, `se`, `statistic`, `pvalue` and `theta`; a failed taxon has
`nothing` for every statistic. Throws `MASSUnavailable` when MASS cannot be
-loaded and `RBusyError` when the R runtime stays busy.
+loaded, `GlmGamPoiUnavailable` when `dispersion` is `"glmGamPoi"` and glmGamPoi
+cannot be loaded, and `RBusyError` when the R runtime stays busy.
"""
function fit_nb(counts::AbstractMatrix{<:Integer}, groups::AbstractVector{<:AbstractString},
- offset::AbstractVector{<:Real}; levels::NTuple{2,String})
+ offset::AbstractVector{<:Real}; levels::NTuple{2,String},
+ dispersion::AbstractString="ml")
+ d = strip(String(dispersion))
+ lowercase(d) in lowercase.(DISPERSIONS) || throw(ArgumentError(
+ "dispersion must be one of $(join(DISPERSIONS, ", ")), not '$dispersion'"))
+ use_gp = lowercase(d) == "glmgampoi"
size(counts, 1) == length(groups) == length(offset) || throw(ArgumentError(
"counts have $(size(counts, 1)) samples, groups $(length(groups)), offset $(length(offset))"))
raw = with_r_lock(; timeout=R_WAIT_SECONDS[]) do
RCall.rcopy(RCall.reval("requireNamespace('MASS', quietly = TRUE)")) || throw(MASSUnavailable())
+ use_gp && !RCall.rcopy(RCall.reval("requireNamespace('glmGamPoi', quietly = TRUE)")) &&
+ throw(GlmGamPoiUnavailable())
RCall.globalEnv[:da_counts] = Matrix{Int}(counts)
RCall.globalEnv[:da_group] = String.(groups)
RCall.globalEnv[:da_levels] = collect(levels)
RCall.globalEnv[:da_offset] = Float64.(offset)
RCall.globalEnv[:da_theta_upper] = THETA_UPPER
RCall.globalEnv[:da_theta_lower] = THETA_LOWER
+ RCall.globalEnv[:da_use_gp] = use_gp
try
RCall.reval(_FIT_R)
DataFrame(RCall.rcopy(RCall.reval("da_result")))
finally
RCall.reval("rm(list = intersect(ls(), c('da_counts', 'da_group', 'da_levels', " *
- "'da_offset', 'da_theta_upper', 'da_theta_lower', 'da_g', 'da_term', " *
- "'da_n', 'da_result', 'da_j', 'da_y', 'da_warns', 'da_fit', 'da_co', " *
- "'da_row', 'da_theta', 'da_absent'))); invisible(gc())")
+ "'da_offset', 'da_theta_upper', 'da_theta_lower', 'da_use_gp', 'da_g', 'da_term', " *
+ "'da_n', 'da_result', 'da_phi', 'da_gp_failed', 'da_gp', 'da_j', 'da_y', " *
+ "'da_warns', 'da_fit', 'da_co', 'da_row', 'da_theta', 'da_absent'))); invisible(gc())")
end
end
DataFrame(status = String.(raw.status),
@@ -667,7 +781,8 @@ function differential_abundance(counts::AbstractMatrix{<:Real},
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)
+ fit_nb(Int.(counts[:, tested]), groups, log.(factors); levels,
+ dispersion=config.dispersion)
if !isnothing(fits)
for (k, j) in enumerate(tested)
f = fits[k, :]
@@ -685,7 +800,8 @@ function differential_abundance(counts::AbstractMatrix{<:Real},
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)
+ "min_prevalence" => config.min_prevalence,
+ "dispersion" => config.dispersion)
else
(n_ref >= 2 && n_con >= 2) || throw(ArgumentError(
"Welch's t-test needs at least 2 samples in each group to estimate its variance; " *
diff --git a/src/server/routes/analysis.jl b/src/server/routes/analysis.jl
index 42efeeb0..6154f5b7 100644
--- a/src/server/routes/analysis.jl
+++ b/src/server/routes/analysis.jl
@@ -1203,7 +1203,8 @@ function _differential_config(study::String)
DifferentialConfig(String(val("analysis.differential.offset", "tss")),
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)))
+ replacement_delta=Float64(val("analysis.differential.replacement_delta", 0.65)),
+ dispersion=String(val("analysis.differential.dispersion", "ml")))
end
"""
diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl
index 3b034896..36e98158 100644
--- a/test/unit/test_differential.jl
+++ b/test/unit/test_differential.jl
@@ -172,6 +172,14 @@ end
@test_throws ArgumentError DifferentialConfig(; method="ancombc")
# The pre-review name, never released, is not accepted.
@test_throws ArgumentError DifferentialConfig(; method="clr_lm")
+ # The dispersion option: ml is the default, glmGamPoi is the alternative,
+ # and it is a negative-binomial setting only.
+ @test DifferentialConfig().dispersion == "ml"
+ @test DifferentialConfig(dispersion="glmGamPoi").dispersion == "glmGamPoi"
+ @test DifferentialConfig(dispersion=" GLMGAMPOI ").dispersion == "glmGamPoi"
+ @test_throws ArgumentError DifferentialConfig(dispersion="shrinkage")
+ @test_throws ArgumentError DifferentialConfig(method="clr_welch", dispersion="glmGamPoi")
+ @test DifferentialConfig(method="clr_welch").dispersion == "ml"
for d in (0, 1, -0.5, NaN)
@test_throws ArgumentError DifferentialConfig(; method="clr_welch", replacement_delta=d)
end
@@ -720,4 +728,41 @@ end
@test rcopy(Bool, reval("requireNamespace('zCompositions', quietly = TRUE)")) == HAVE_ZCOMP
end
end
+
+ ## Without glmGamPoi, dispersion: glmGamPoi refuses with
+ # GlmGamPoiUnavailable. Same trick as the zCompositions testset above: a
+ # `requireNamespace` bound in R's global environment reports glmGamPoi
+ # missing and defers to base for every other package, so the production
+ # probe sees the package as absent on any machine. MASS is probed first
+ # and must be real, so this testset needs it.
+ @testset "nb_glm without glmGamPoi refuses with GlmGamPoiUnavailable" begin
+ if !HAVE_MASS
+ @test_skip false # no MASS means the ml path's own guard fires first
+ else
+ reval("""requireNamespace <- function(package, ...) {
+ if (identical(package, "glmGamPoi")) FALSE
+ else base::requireNamespace(package, ...)
+ }""")
+ try
+ @test !rcopy(Bool, reval("requireNamespace('glmGamPoi', quietly = TRUE)"))
+ e = try
+ fit_nb([5 6; 7 3; 9 4; 4 8], ["A", "A", "B", "B"], [0.0, 0.0, 0.0, 0.0];
+ levels=("A", "B"), dispersion="glmGamPoi")
+ nothing
+ catch err
+ err
+ end
+ @test e isa GlmGamPoiUnavailable
+ @test occursin("renv::restore()", sprint(showerror, e))
+ # The override defers to base for everything else, so the default
+ # dispersion must still fit normally with it in place.
+ r = fit_nb([5 6; 7 3; 9 4; 4 8], ["A", "A", "B", "B"], [0.0, 0.0, 0.0, 0.0];
+ levels=("A", "B"))
+ @test all(r.status .== "ok")
+ finally
+ reval("rm(requireNamespace)")
+ end
+ @test !rcopy(Bool, reval("exists('requireNamespace', envir = globalenv(), inherits = FALSE)"))
+ end
+ end
end
From e58c1e870d5eaeca06b40aa94b860518518d6ee5 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:30:50 +0100
Subject: [PATCH 40/45] Upstream/docs community (#166)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
From 3dac397f7e510e43d7916cca2436aa97c21a880c Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:31:15 +0100
Subject: [PATCH 41/45] Upstream/dada2 base r (#167)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
From f873b5a67d518432c58b2a4eae9efceaa1946ea0 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:31:44 +0100
Subject: [PATCH 42/45] Upstream/permanova proofs (#168)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
From 5c547aeac98251d924f7486b57ee6f7bb87abb9a Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:32:39 +0100
Subject: [PATCH 43/45] Upstream/docs community (#170)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
From 35b6c39a30181f0430c36058cb56c9a03a849246 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:32:47 +0100
Subject: [PATCH 44/45] Upstream/permanova proofs (#171)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
From bd958085e0d0a3c08dddb66762cd4e847b365f40 Mon Sep 17 00:00:00 2001
From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com>
Date: Fri, 16 Oct 2026 00:32:57 +0100
Subject: [PATCH 45/45] Upstream/dada2 base r (#169)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Closes #
## Type of change
- [ ] 🐛 Bug fix (non-breaking change that fixes an issue)
- [ ] ✨ New feature (non-breaking change that adds functionality)
- [ ] 💥 Breaking change (would change existing behaviour, a config key
or an API response)
- [ ] 📊 Statistics fix (a method returned a wrong number, or a result it
could not support)
- [ ] 📖 Documentation
- [ ] 🧹 Refactor / tech debt (behaviour-preserving)
- [ ] ⚡ Performance
- [ ] 🔧 Build / CI / tooling
## How has this been verified?
Tested with Julia `…`, R `…` and bun `…`.
## What this leaves out
## Checklist
- [ ] My branch starts at the current tip of `main` (`behind_by` is `0`;
see CONTRIBUTING.md) and has no merge commits from `main`.
- [ ] I ran `julia --project=. test/runtests.jl` (and `cd frontend &&
bun run test` for frontend changes) and they pass.
- [ ] CI ran on this PR and passes.
- [ ] No generated or vendored files, and no lockfile change the work
does not need (or the reason is given above).
- [ ] New code is AGPL-3.0, or MPL-2.0 with an SPDX identifier in each
file I wrote. I left existing file headers, `LICENSE`, `CITATION.cff`
and the licence and acknowledgement sections of `README.md` unchanged.
- [ ] Every number shown to a user is computed from their data. When a
method cannot run it raises an explicit error.
- [ ] Docs are updated, and no claim in them overstates what the code
does.
## Notes for reviewers
---------
Signed-off-by: Jonathan D.A. Jewell <6759885+hyperpolymath@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5
Co-authored-by: Joshua Jewell
Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>
Co-authored-by: Mistral Vibe
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>