From 3ed1dfb51035340f61e319482222d19fdbc91f94 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 19:44:18 +0000 Subject: [PATCH] test(differential): run the native adapter against a stand-in backend Codecov reported 30 uncovered lines on this branch, all in `src/analysis/differential.jl`: the adapter that turns a CompositionalDA result into result rows. CompositionalDA is not in the General registry, so CI cannot install it, and only the refusal path ran. - `differential_abundance(...; native_backend=nothing)` calls a module. The default is the `CompositionalDA` this session loaded, or the by-name refusal when it loaded none. Production behaviour is unchanged. - `_is_composition_error` translates the `CompositionError` of the backend in use. - `diagnostics.native_method.package` reports the name of the module that produced the numbers, so a stand-in record cannot read as a package record. `test/support/native_da_stub.jl` has the two entry points, the column names and the verdict strings of the package. Its `qvalue` is not `bh_adjust(pvalue)`, so a second BH pass is visible, and its structural rows carry NaN as the package leaves them. `test/unit/test_differential_native.jl` gains four testsets that run on every checkout: the seam, the ALDEx2 wiring, the ANCOM-BC wiring and the refusal order. The comparisons against the real package stay, and still skip with a message when the package is absent. Measured with Julia 1.12.5, `--code-coverage=user --compiled-modules=no`, `test/runtests.jl differential differential_native`: 1159 pass, 13 skipped; every instrumented line this branch adds to `differential.jl` is covered (52 of 52). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_0136P7ZoTPXfxg1C85rVNx78 --- src/analysis/differential.jl | 58 +++++-- test/runtests.jl | 6 + test/support/native_da_stub.jl | 170 +++++++++++++++++++ test/unit/test_differential_native.jl | 224 ++++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 10 deletions(-) create mode 100644 test/support/native_da_stub.jl diff --git a/src/analysis/differential.jl b/src/analysis/differential.jl index ffdc5ece..447a30d4 100644 --- a/src/analysis/differential.jl +++ b/src/analysis/differential.jl @@ -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 @@ -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 @@ -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 @@ -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( @@ -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)) @@ -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) diff --git a/test/runtests.jl b/test/runtests.jl index ebd14c14..5413c803 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -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 diff --git a/test/support/native_da_stub.jl b/test/support/native_da_stub.jl new file mode 100644 index 00000000..7450c7c1 --- /dev/null +++ b/test/support/native_da_stub.jl @@ -0,0 +1,170 @@ +# SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath) +# 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 diff --git a/test/unit/test_differential_native.jl b/test/unit/test_differential_native.jl index 70294ebe..086b5de4 100644 --- a/test/unit/test_differential_native.jl +++ b/test/unit/test_differential_native.jl @@ -162,3 +162,227 @@ end @test actual["diagnostics"]["n_tested"] == 40 end end + +## The adapter, exercised against a stand-in backend +# +# The two testsets above run only where CompositionalDA is installed, and CI +# cannot install it: the package is not in the General registry. Without the +# testsets below, nothing in CI runs the adapter that turns a DAResult into +# result rows, or the branch that keeps BH from being applied twice. +# +# `differential_abundance(...; native_backend=…)` makes the adapter reachable +# without changing what production does (the default is still the CompositionalDA +# this session loaded, or the refusal when it did not). The stand-in in +# `test/support/native_da_stub.jl` offers the real package's entry points, +# columns and verdict strings, so what runs below is the code that runs in +# production; only the estimator behind the boundary is a stand-in, and the +# result names it under `diagnostics.native_method.package`. + +@testset "The native backend seam selects the module, and defaults to the package" begin + @test MetaManifold.Differential._native_backend(NativeDAStub) === NativeDAStub + @test isnothing(MetaManifold.Differential._native_backend(nothing)) == !HAVE_COMPOSITIONAL_DA + # An available backend satisfies the guard; an absent one refuses by name. + @test isnothing(MetaManifold.Differential._native_methods_available("aldex2", NativeDAStub)) + err = try + MetaManifold.Differential._native_methods_available("ancombc", nothing) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("CompositionalDA", sprint(showerror, err)) +end + +@testset "Native ALDEx2 rows are wired from the backend's own columns" begin + NativeDAStub.reset!() + config = DifferentialConfig(; method="aldex2", mc_samples=32, seed=7) + actual = differential_abundance(NATIVE_COUNTS, NATIVE_SAMPLES, NATIVE_TAXA, NATIVE_GROUPS; + reference="z", contrast="a", config, native_backend=NativeDAStub) + + call = NativeDAStub.last_call() + @test call.method === :aldex2 + @test call.settings[:mc_samples] == 32 + @test call.settings[:seed] == 7 + # The user's groups are relabelled "0"/"1" in the requested direction, so the + # package's sorted contrast is the contrast the user asked for even though + # the reference ("z") sorts after the contrast ("a"). + @test call.groups == ["0", "0", "0", "1", "1", "1"] + @test call.features == NATIVE_TAXA + @test size(call.counts) == size(NATIVE_COUNTS) + @test eltype(call.counts) <: Integer + + rows = Dict(String(r["taxon"]) => r for r in actual["rows"]) + @test length(rows) == length(NATIVE_TAXA) + for (j, taxon) in enumerate(NATIVE_TAXA) + row = rows[taxon] + @test row["status"] == "ok" + @test row["note"] == "" + @test row["estimate"] == 0.5 * j # diff_btw + @test row["aldex_effect"] == 0.25 * j # effect + @test row["aldex_diff_within"] == 0.125 * j # diff_win + @test row["aldex_abundance"] == 1.0 * j # rab_all + @test row["wilcoxon_pvalue"] == 0.0075 * j # wi_pvalue + @test row["wilcoxon_padj"] == 0.00375 * j # wi_qvalue + @test row["pvalue"] == 0.01 * j + # ALDEx2 averages BH within each posterior instance. Applying BH a second + # time to its expected p-values would implement a different method, so + # padj is the package's qvalue even where BH would disagree. + @test row["padj"] == 0.005 * j + @test row["log2_fold_change"] === nothing + @test row["standard_error"] === nothing + @test row["statistic"] === nothing + end + @test [rows[t]["padj"] for t in NATIVE_TAXA] != bh_adjust([0.01 * j for j in 1:4]) + @test actual["effect"]["key"] == "estimate" + @test occursin("ALDEx2", actual["method"]) + @test occursin("32 Dirichlet instances", actual["method"]) + @test isempty(actual["size_factors"]) + @test actual["config"]["method"] == "aldex2" + @test actual["config"]["mc_samples"] == 32 + @test actual["config"]["seed"] == 7 + @test actual["diagnostics"]["native_method"]["package"] == "NativeDAStub" + @test actual["diagnostics"]["native_method"]["version"] == "0.0.0-stub" + @test actual["diagnostics"]["native_method"]["parameters"]["mc_samples"] == 32 + @test actual["diagnostics"]["n_structural_zero"] == 0 + @test actual["diagnostics"]["n_tested"] == 4 + + # A feature the caller has no row for is skipped, not invented. + NativeDAStub.reset!() + NativeDAStub.EXTRA_FEATURE[] = true + extra = differential_abundance(NATIVE_COUNTS, NATIVE_SAMPLES, NATIVE_TAXA, NATIVE_GROUPS; + reference="z", contrast="a", config, native_backend=NativeDAStub) + @test length(extra["rows"]) == length(NATIVE_TAXA) + @test !any(r -> String(r["taxon"]) == "not_requested", extra["rows"]) + NativeDAStub.reset!() +end + +@testset "Native ANCOM-BC keeps units and reports structural decisions separately" begin + NativeDAStub.reset!() + samples = ["s1", "s2", "s3", "s4", "s5", "s6", "s7", "s8"] + taxa = ["spike", "flat", "structural", "half", "structural_both", "absent"] + groups = ["z", "z", "z", "z", "a", "a", "a", "a"] + counts = [10 50 0 8 3 0; + 10 50 0 8 3 0; + 10 50 0 9 4 0; + 13 50 0 7 4 0; + 80 50 9 9 3 0; + 80 50 9 8 3 0; + 83 50 8 9 4 0; + 77 50 8 8 3 0] + config = DifferentialConfig(; method="ancombc") + actual = differential_abundance(counts, samples, taxa, groups; + reference="z", contrast="a", config, native_backend=NativeDAStub) + + call = NativeDAStub.last_call() + @test call.method === :ancombc + @test call.settings[:prevalence] == 0.0 + @test call.settings[:structural_zeros] === true + @test call.settings[:conserve] === true + # "absent" has no reads at all: it is filtered before the backend is called. + @test call.features == ["spike", "flat", "structural", "half", "structural_both"] + @test call.groups == ["0", "0", "0", "0", "1", "1", "1", "1"] + + rows = Dict(String(r["taxon"]) => r for r in actual["rows"]) + @test rows["absent"]["status"] == "filtered" + @test isnothing(rows["absent"]["pvalue"]) + + for (j, taxon) in enumerate(call.features) + row = rows[taxon] + if row["status"] == "structural_zero" + @test isnothing(row["estimate"]) + @test isnothing(row["pvalue"]) + @test isnothing(row["padj"]) + continue + end + @test row["status"] == "ok" + @test row["estimate"] == log(2) * j / 2 + @test row["log2_fold_change"] ≈ j / 2 + @test row["standard_error"] == 0.1 * j + @test row["statistic"] == (log(2) * j / 2) / (0.1 * j) + @test row["pvalue"] == 0.02 * j + @test row["padj"] == 0.005 * j + end + + # Structural absences are decisions, not tests: named as such, and carrying + # no stand-in p-value. + @test rows["structural"]["status"] == "structural_zero" + @test occursin("one condition", rows["structural"]["note"]) + @test rows["structural_both"]["status"] == "structural_zero" + @test occursin("both conditions", rows["structural_both"]["note"]) + @test actual["diagnostics"]["n_structural_zero"] == 2 + @test actual["diagnostics"]["n_filtered"] == 1 + @test actual["diagnostics"]["n_tested"] == 3 # spike, flat, half + @test actual["effect"]["key"] == "log2_fold_change" + @test occursin("ANCOM-BC", actual["method"]) + @test actual["config"]["method"] == "ancombc" + # ALDEx2's sampling settings have no meaning here and must not be echoed. + @test !haskey(actual["config"], "mc_samples") + @test actual["diagnostics"]["native_method"]["package"] == "NativeDAStub" + @test actual["diagnostics"]["native_method"]["parameters"]["structural_zeros"] === true + NativeDAStub.reset!() +end + +@testset "Native refusals happen in the right order, whatever the backend" begin + # Input problems are refused before the backend is consulted, so a malformed + # matrix gets the same answer on a machine with the package and without it. + one_taxon = [10 0; 12 0; 9 0; 40 0; 42 0; 38 0] + for method in ("aldex2", "ancombc") + config = DifferentialConfig(; method) + err = try + differential_abundance(one_taxon, NATIVE_SAMPLES, ["a", "b"], NATIVE_GROUPS; + reference="z", contrast="a", config, native_backend=NativeDAStub) + nothing + catch e + e + end + @test err isa ArgumentError + @test occursin("at least two retained taxa", sprint(showerror, err)) + end + @test isempty(NativeDAStub.CALLS) + + config = DifferentialConfig(; method="aldex2") + + # The package's own refusal is translated into an ErrorException carrying + # its message, which the route reports as 422 no_taxon_fitted. + NativeDAStub.reset!() + NativeDAStub.FAILURE[] = :composition + err = try + differential_abundance(NATIVE_COUNTS, NATIVE_SAMPLES, NATIVE_TAXA, NATIVE_GROUPS; + reference="z", contrast="a", config, native_backend=NativeDAStub) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("CompositionError", err.msg) + + # Any other failure is the backend's own and is rethrown untouched: a broken + # install must not be dressed up as a refusal of the data. + NativeDAStub.reset!() + NativeDAStub.FAILURE[] = :other + err = try + differential_abundance(NATIVE_COUNTS, NATIVE_SAMPLES, NATIVE_TAXA, NATIVE_GROUPS; + reference="z", contrast="a", config, native_backend=NativeDAStub) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("a reason of its own", err.msg) + @test !occursin("CompositionError", err.msg) + + # A statistic that is not finite is refused by name rather than published. + NativeDAStub.reset!() + NativeDAStub.FAILURE[] = :nonfinite + err = try + differential_abundance(NATIVE_COUNTS, NATIVE_SAMPLES, NATIVE_TAXA, NATIVE_GROUPS; + reference="z", contrast="a", config, native_backend=NativeDAStub) + nothing + catch e + e + end + @test err isa ErrorException + @test occursin("non-finite statistics", err.msg) + @test occursin("aldex2", err.msg) + NativeDAStub.reset!() +end