Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 48 additions & 10 deletions src/analysis/differential.jl
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,35 @@ and start a fresh session.
"""
compositional_da_available() = _COMPOSITIONAL_DA_AVAILABLE

function _native_methods_available(method::AbstractString)
_COMPOSITIONAL_DA_AVAILABLE || throw(ArgumentError(
# The module the native methods call when the caller names none: the package
# where the import above succeeded, `nothing` where it did not.
const _DEFAULT_NATIVE_BACKEND = _COMPOSITIONAL_DA_AVAILABLE ? CompositionalDA : nothing

"""
_native_backend(override)

The module the native methods call: `override` when one is given, otherwise the
`CompositionalDA` this session loaded, or `nothing` when it loaded none.

This is the seam that keeps the adapter testable. `CompositionalDA` is not in
the General registry, so CI never has it, and without a seam the adapter is
unreachable there. The tests pass a stand-in module with the same two entry
points and the same error type, so the adapter code they run is the code that
runs in production; only the estimator behind the boundary differs.
"""
_native_backend(override) = override === nothing ? _DEFAULT_NATIVE_BACKEND : override

"Whether `backend` defines the package's `CompositionError` and `err` is one."
_is_composition_error(backend::Module, err) =
isdefined(backend, :CompositionError) && err isa backend.CompositionError

function _native_methods_available(method::AbstractString, backend)
backend === nothing || return nothing
throw(ArgumentError(
"'$method' needs the CompositionalDA package, which is not installed in this " *
"environment: it is not in the General registry yet, so it is not a declared " *
"dependency and `Pkg.instantiate()` does not install it. Use 'nb_glm' or " *
"'clr_welch' in the meantime."))
nothing
end

using ..RRuntime: with_r_lock
Expand Down Expand Up @@ -802,7 +824,8 @@ _row(taxon, prevalence, note) = Dict{String,Any}(

"""
differential_abundance(counts, samples, taxa, groups; reference, contrast,
config=DifferentialConfig()) -> Dict{String,Any}
config=DifferentialConfig(), native_backend=nothing)
-> 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
Expand Down Expand Up @@ -838,6 +861,17 @@ family is the fitted taxa that produced a p-value; failed and filtered taxa
carry `padj = nothing`. The result's `effect` names the row key that holds the
effect size and its label.

`aldex2` and `ancombc` are the native methods. They need the `CompositionalDA`
package, which is not in the General registry yet, so `Pkg.instantiate()` does
not install it and they refuse by name on such a checkout;
`compositional_da_available()` answers the same question without raising.
`native_backend` is the module those two methods call, and defaults to the
`CompositionalDA` this session loaded. Passing a module with the same entry
points (`aldex2`, `ancombc`, `CompositionError`) is how the adapter is tested
where the package cannot be installed; nothing else about the call changes, and
the result records the module that produced it under
`diagnostics.native_method.package`.

Throws `ArgumentError` for unusable input (including a zero replacement that
cannot be made), `ScalingRefusal` when the offset cannot be computed,
`MASSUnavailable`, `GlmGamPoiUnavailable` or `ZCompositionsUnavailable` when a
Expand All @@ -851,7 +885,8 @@ function differential_abundance(counts::AbstractMatrix{<:Real},
taxa::AbstractVector{<:AbstractString},
groups::AbstractVector{<:AbstractString};
reference::AbstractString, contrast::AbstractString,
config::DifferentialConfig=DifferentialConfig())
config::DifferentialConfig=DifferentialConfig(),
native_backend=nothing)
reference == contrast && throw(ArgumentError(
"the two groups must differ, but both are '$reference'"))
length(groups) == length(samples) || throw(ArgumentError(
Expand Down Expand Up @@ -898,20 +933,21 @@ function differential_abundance(counts::AbstractMatrix{<:Real},
length(tested) >= 2 || throw(ArgumentError("native compositional methods need at least two retained taxa"))
all(x -> x <= typemax(Int), @view counts[:, tested]) ||
throw(ArgumentError("native compositional counts must fit the platform's Int range"))
_native_methods_available(config.method)
backend = _native_backend(native_backend)
_native_methods_available(config.method, backend)
# Stable labels make the package's sorted contrast agree with the
# user's requested direction, irrespective of run-name sorting.
native_groups = [g == reference ? "0" : "1" for g in groups]
native = try
if config.method == "aldex2"
CompositionalDA.aldex2(Int.(counts[:, tested]), taxa[tested], native_groups;
backend.aldex2(Int.(counts[:, tested]), taxa[tested], native_groups;
mc_samples=config.mc_samples, seed=config.seed)
else
CompositionalDA.ancombc(Int.(counts[:, tested]), taxa[tested], native_groups;
backend.ancombc(Int.(counts[:, tested]), taxa[tested], native_groups;
prevalence=0.0, structural_zeros=true, conserve=true)
end
catch e
e isa CompositionalDA.CompositionError && error(sprint(showerror, e))
_is_composition_error(backend, e) && error(sprint(showerror, e))
rethrow()
end
by_taxon = Dict(String(r.feature) => r for r in eachrow(native.table))
Expand Down Expand Up @@ -959,7 +995,9 @@ function differential_abundance(counts::AbstractMatrix{<:Real},
if config.method == "aldex2"
merge!(config_echo, Dict("mc_samples" => config.mc_samples, "seed" => config.seed))
end
diagnostics["native_method"] = Dict("package" => "CompositionalDA",
# The module that actually produced the numbers, so a record made with a
# stand-in cannot read as one made with the package.
diagnostics["native_method"] = Dict("package" => string(nameof(backend)),
"version" => string(native.provenance.package_version),
"parameters" => Dict(string(k) => v for (k, v) in native.provenance.parameters))
diagnostics["n_structural_zero"] = count(r -> r["status"] == "structural_zero", rows)
Expand Down
6 changes: 6 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,12 @@ using MetaManifold.Tools, MetaManifold.TaxonomyTableTools, MetaManifold.ProjectS
using MetaManifold.DiversityMetrics, MetaManifold.Analysis
using MetaManifold.Categories, MetaManifold.CompositionLibrary

## The stand-in backend for the native differential adapter. CompositionalDA is
## not in the General registry, so CI cannot install it, and without a stand-in
## the adapter in src/analysis/differential.jl is unreachable here. `module` is
## not valid inside the `@testset` below, so the stand-in is loaded at top level.
include(joinpath(@__DIR__, "support", "native_da_stub.jl"))

## Unit tests (always run)
@testset "MetaManifold" begin

Expand Down
170 changes: 170 additions & 0 deletions test/support/native_da_stub.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
# SPDX-License-Identifier: AGPL-3.0-or-later

## A stand-in for CompositionalDA
#
# `CompositionalDA` is not in the General registry, so it is not a declared
# dependency and `Pkg.instantiate()` cannot install it: in CI,
# `compositional_da_available()` is false, and the adapter in
# `src/analysis/differential.jl` that turns a `DAResult` into result rows is
# unreachable through the package.
#
# `differential_abundance(...; native_backend=…)` is the seam: the adapter calls
# a module, so a test can supply this one. The module below offers the two entry
# points the adapter uses (`aldex2`, `ancombc`) and the error type it translates
# (`CompositionError`), with the column names and the verdict strings of the
# real package, so the adapter code under test is the code that runs in
# production. Only the estimator behind the boundary differs, and the result
# records which module produced it under `diagnostics.native_method.package`,
# so a record made with this stand-in cannot be read as one made with the
# package.
#
# The values are deliberately simple arithmetic on the feature's position, so
# every assertion in `test/unit/test_differential_native.jl` can name the number
# it expects rather than compare two implementations of the same model.
#
# Loaded from `test/runtests.jl` at top level: `module` is not valid inside the
# `@testset` that includes the unit files.

module NativeDAStub

using DataFrames

struct CompositionError <: Exception
msg::String
end

Base.showerror(io::IO, e::CompositionError) = print(io, "CompositionError: ", e.msg)

# Every call the adapter made, in order, with the arguments it used. The tests
# read these to check the wiring (which taxa were sent, in what order, with
# which labels and settings) rather than inferring it from the result.
const CALLS = Any[]

# What the next call should do: `:none` answers normally; `:composition` raises
# the package's own error; `:other` raises something else; `:nonfinite` answers
# with a NaN statistic for the first feature.
const FAILURE = Ref{Symbol}(:none)

# Append a feature the adapter never asked about, to check that a row the caller
# has no place for is skipped instead of invented.
const EXTRA_FEATURE = Ref{Bool}(false)

function reset!()
empty!(CALLS)
FAILURE[] = :none
EXTRA_FEATURE[] = false
nothing
end

last_call() = last(CALLS)

function _record(method::Symbol, counts, features, groups, settings::Dict{Symbol,Any})
push!(CALLS, (method=method, counts=copy(counts),
features=collect(String.(features)), groups=collect(String.(groups)),
settings=settings))
nothing
end

function _dispatch_failure()
FAILURE[] === :composition && throw(CompositionError("stub refused the composition"))
FAILURE[] === :other && error("stub backend failed for a reason of its own")
nothing
end

"""
aldex2(counts, features, groups; mc_samples=128, seed=1)

The same signature and column names as `CompositionalDA.aldex2`. `diff_btw` is
the estimate the adapter reports, `effect` the within-instance effect it keeps
beside it, and `qvalue` is deliberately not `bh_adjust(pvalue)`: the adapter must
publish ALDEx2's expected within-instance BH values rather than adjust a second
time, and a stand-in whose two agreed could not tell the two apart.
"""
function aldex2(counts::AbstractMatrix{<:Integer}, features::AbstractVector,
groups::AbstractVector; mc_samples::Integer=128, seed::Integer=1)
_record(:aldex2, counts, features, groups,
Dict{Symbol,Any}(:mc_samples => mc_samples, :seed => seed))
_dispatch_failure()
n = length(features)
p = [0.01 * j for j in 1:n]
table = DataFrame(
feature = String.(features),
effect = [0.25 * j for j in 1:n],
pvalue = p,
qvalue = p .* 0.5,
wi_pvalue = p .* 0.75,
wi_qvalue = p .* 0.375,
diff_btw = [0.5 * j for j in 1:n],
diff_win = [0.125 * j for j in 1:n],
rab_all = [1.0 * j for j in 1:n])
FAILURE[] === :nonfinite && (table.pvalue[1] = NaN)
EXTRA_FEATURE[] && push!(table,
("not_requested", 0.0, 1.0, 1.0, 1.0, 1.0, 0.0, 0.0, 0.0))
(table = table,
provenance = (package_version = v"0.0.0-stub",
parameters = Dict{Symbol,Any}(:mc_samples => mc_samples, :seed => seed,
:prior => 0.5)))
end

"""
ancombc(counts, features, groups; prevalence=0.0, structural_zeros=true, conserve=true)

The same signature, columns and verdict strings as `CompositionalDA.ancombc`. A
feature that is zero in every sample of one condition is `"structural_zero"`,
one that is zero in both is `"structural_zero_both"`, and the rest are
`"tested"`.

A feature named `structural_both` is reported as structurally absent in both
conditions even when it has reads. Real data cannot produce that verdict through
`differential_abundance` (an all-zero column is filtered out before the backend
is called), but the adapter still has to map the verdict, and a mapping nobody
exercises is where a wrong message hides.
"""
function ancombc(counts::AbstractMatrix{<:Integer}, features::AbstractVector,
groups::AbstractVector; prevalence::Real=0.0,
structural_zeros::Bool=true, conserve::Bool=true)
_record(:ancombc, counts, features, groups,
Dict{Symbol,Any}(:prevalence => prevalence,
:structural_zeros => structural_zeros,
:conserve => conserve))
_dispatch_failure()
labels = String.(groups)
n = length(features)
status = map(1:n) do j
String(features[j]) == "structural_both" && return "structural_zero_both"
zero0 = sum(counts[labels .== "0", j]) == 0
zero1 = sum(counts[labels .== "1", j]) == 0
zero0 && zero1 && return "structural_zero_both"
(zero0 || zero1) && return "structural_zero"
"tested"
end
# `effect / log(2)` is the adapter's log2 fold change, so the effect is a
# half-integer multiple of log(2) and the expected value is exactly j/2.
effect = [log(2) * j / 2 for j in 1:n]
se = [0.1 * j for j in 1:n]
# Structural decisions carry no numerical test: NaN, exactly as the package
# leaves them. The adapter must branch on the verdict *before* it looks at
# finiteness, or these rows would raise instead of being reported.
p = [status[j] == "tested" ? 0.02 * j : NaN for j in 1:n]
table = DataFrame(
feature = String.(features),
effect = effect,
se = se,
statistic = effect ./ se,
pvalue = p,
qvalue = p .* 0.25,
status = status)
if FAILURE[] === :nonfinite
first_tested = findfirst(==("tested"), status)
first_tested === nothing || (table.pvalue[first_tested] = NaN)
end
EXTRA_FEATURE[] && push!(table, ("not_requested", 0.0, 1.0, 0.0, 1.0, 1.0, "tested"))
(table = table,
provenance = (package_version = v"0.0.0-stub",
parameters = Dict{Symbol,Any}(:prevalence => prevalence,
:structural_zeros => structural_zeros,
:conserve => conserve)))
end

end # module NativeDAStub
Loading