diff --git a/CHANGELOG.md b/CHANGELOG.md index 93f1aaa2..14ae9c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,25 @@ types, tests, infrastructure, and alignment. ### Added — Type-system engineering series (2026-09) +- **Epistemic claims with receipts**: added `Standpoint`, `TaxonWarrant`, + `ProjectionY`, `Receipt`, and SHA-256 signing/verification (`make_receipt`, + `verify_receipt`) in `src/core/epistemic.jl`, with compact `echo:v1?...` + encoding/parsing for the `avec_fibre` column. Aligned directly with + `EpistemicTypes.jl` and `echo-types`. +- **Zero observation disambiguation**: added `disambiguate_zero` + (`Val(:true_absence)` vs `Val(:undetected)`), grounding presence/absence + claims in sequencing depth and formalizing the boundary between biological + absence and observation limits (`absolute-zero` and Issue #18). +- **Exact Multiplicative and Bayesian Zero Replacement**: implemented exact + Martín-Fernández (2003) multiplicative replacement and Martín-Fernández (2015) + Bayesian Dirichlet prior replacement in `Execution.prepare_analysis_table`, + strictly preserving total sample depth and subcompositional ratios between all + non-zero components (Issue #21). +- **Exact TSS offsets for count models**: implemented exact Total Sum Scaling + offsets for `NB_GLM` in `Execution.prepare_analysis_table`, preserving the + count nature of response tables and providing `log(lib_sizes)` offsets to + prevent double-normalization and retain negative binomial model + interpretability (Issue #16). - **bun migration**: `package.json`/`bun.lock` replace the mixed npm+Deno tooling; bun 1.3.10 pinned via `.bun-version`; CI installs bun. `docs/migration/npm-deno-to-bun.md`. diff --git a/src/analysis/Execution.jl b/src/analysis/Execution.jl index 4c835d40..058c1c9c 100644 --- a/src/analysis/Execution.jl +++ b/src/analysis/Execution.jl @@ -981,48 +981,54 @@ function prepare_analysis_table( end end elseif zero_policy == AnalysisConfig.MULTIPLICATIVE_REPLACEMENT - # Multiplicative replacement: replace zeros with delta * geometric mean of non-zeros, then adjust non-zeros multiplicatively - # Stub implementation - delta_val = isnothing(delta) ? 0.65 : delta + # Multiplicative replacement per Martín-Fernández et al. (2003): + # Replaces zeros with delta * detection_limit (here delta, as count floor is 1.0), + # then multiplicatively scales non-zeros to preserve total sample depth and + # subcompositional ratios. + delta_val = something(delta, 0.65) for j in 1:size(counts_after_zero, 2) col = counts_after_zero[:, j] - non_zero = filter(x -> x > 0, col) - if isempty(non_zero) - # All zeros in sample — will be handled by self-healing later + sample_total = sum(col) + if sample_total <= 0.0 continue end - # Geometric mean of non-zeros - geo_mean = exp(mean(log.(non_zero))) - zero_replacement = delta_val * geo_mean - # Count zeros - n_zeros = count(x -> x == 0, col) - if n_zeros > 0 - # Replace zeros + zero_indices = findall(x -> x == 0.0, col) + n_zeros = length(zero_indices) + if 0 < n_zeros < length(col) + r = delta_val + total_imputed = n_zeros * r + # If total_imputed exceeds sample_total, bound r to preserve positivity + if total_imputed >= sample_total + r = (sample_total * delta_val) / (n_zeros + delta_val * (length(col) - n_zeros)) + total_imputed = n_zeros * r + end + scale_factor = (sample_total - total_imputed) / sample_total for i in 1:size(col, 1) if counts_after_zero[i, j] == 0.0 - counts_after_zero[i, j] = zero_replacement + counts_after_zero[i, j] = r + else + counts_after_zero[i, j] *= scale_factor end end - # Adjust non-zeros multiplicatively to preserve total (optional, for stub we don't adjust) - # Real implementation would multiply non-zeros by (1 - n_zeros*zero_replacement/sum(col)) etc. end end elseif zero_policy == AnalysisConfig.BAYESIAN_MULTIPLICATIVE - # Bayesian multiplicative: similar to multiplicative but with Dirichlet prior - # Stub: use same as multiplicative for now, with Bayesian note - delta_val = isnothing(delta) ? 0.65 : delta + # Bayesian multiplicative replacement per Martín-Fernández et al. (2015): + # Uses Dirichlet prior (concentration alpha = delta_val, default 0.65). + # Posterior expectation preserves total sample depth: + # x_ij* = (S_j / (S_j + D * alpha)) * (x_ij + alpha) + delta_val = something(delta, 0.65) + D = size(counts_after_zero, 1) for j in 1:size(counts_after_zero, 2) col = counts_after_zero[:, j] - non_zero = filter(x -> x > 0, col) - if isempty(non_zero) + sample_total = sum(col) + if sample_total <= 0.0 continue end - geo_mean = exp(mean(log.(non_zero))) - zero_replacement = delta_val * geo_mean * 0.5 # Bayesian shrinks a bit - for i in 1:size(col, 1) - if counts_after_zero[i, j] == 0.0 - counts_after_zero[i, j] = zero_replacement - end + alpha = delta_val + scale = sample_total / (sample_total + D * alpha) + for i in 1:D + counts_after_zero[i, j] = scale * (col[i] + alpha) end end elseif zero_policy == AnalysisConfig.REFUSE @@ -1057,27 +1063,20 @@ function prepare_analysis_table( @warn "TSS/CSS/RSS are deferred features (see GitHub issues 01-tss-css-rss-offsets). Currently aliased to relative with warning. For exact TSS/CSS/RSS offsets, see deferred issue." end lib_sizes = vec(sum(counts_after_zero, dims=1)) - for j in 1:size(counts_after_zero, 2) - if lib_sizes[j] > 0 - prepared[:, j] = counts_after_zero[:, j] ./ lib_sizes[j] - else - # Unreachable since all-zero samples are healed before the transform, - # and deliberately a refusal rather than an assignment. This branch - # used to write `prepared[:, j] .= 0.0`, which states that every - # feature has a relative abundance of exactly 0 -- a value -- for a - # sample whose relative abundances do not exist. 0/0 is undefined, - # and "0" and "undefined" are different claims; the old value was - # wrong in a way no downstream check could detect, because 0.0 is a - # perfectly ordinary number. If a future path reintroduces a - # zero-depth column here, that is a bug to surface, not to paper - # over with a plausible-looking number. - sample_name = j <= length(filtered_sample_ids) ? filtered_sample_ids[j] : string(j) - throw(ArgumentError("INTERNAL: sample '$sample_name' (column $j) has zero total counts at the relative-abundance transform. All-zero samples are healed before this point, so reaching here means the healing was bypassed — the relative abundances of an empty sample are undefined and must not be reported as 0. Please report this with the config id $(config.id).")) - end - end - # Offset for NB_GLM if needed? For relative, no offset, but for TSS offset we would use log(lib_sizes) + # For NB_GLM with TSS, preserve raw counts as response and supply log(lib_sizes) as offset, + # preserving count distribution (McMurdie & Holmes 2014) instead of converting to proportions. if config.method == AnalysisConfig.NB_GLM && transform_method in ("tss", "TSS") + prepared = copy(counts_after_zero) offset = log.(lib_sizes .+ effective_epsilon) + else + for j in 1:size(counts_after_zero, 2) + if lib_sizes[j] > 0 + prepared[:, j] = counts_after_zero[:, j] ./ lib_sizes[j] + else + sample_name = j <= length(filtered_sample_ids) ? filtered_sample_ids[j] : string(j) + throw(ArgumentError("INTERNAL: sample '$sample_name' (column $j) has zero total counts at the relative-abundance transform. All-zero samples are healed before this point, so reaching here means the healing was bypassed — the relative abundances of an empty sample are undefined and must not be reported as 0. Please report this with the config id $(config.id).")) + end + end end elseif transform_method == "size_factors" # Size factors: DESeq2 median-of-ratios (stub: use median ratio or simple) diff --git a/src/core/epistemic.jl b/src/core/epistemic.jl index be2e86e4..0db27b5c 100644 --- a/src/core/epistemic.jl +++ b/src/core/epistemic.jl @@ -21,13 +21,19 @@ For Julia, we provide finite, executable shadows of those types. module Epistemic using OrderedCollections +using SHA: sha256, bytes2hex export EchoFiber, Warrant, SoundWarrant, Candidate, Case, avec_fibre, sans_fibre, present_in_every_admissible_world, present_in_some_admissible_world, absent_in_every_admissible_world, EPISTEMIC_STATUSES, AVEC_FIBRE_COLUMN, - epistemic_colour, cloud_size_by_residual + epistemic_colour, cloud_size_by_residual, + Standpoint, TaxonWarrant, ProjectionY, Receipt, + ThresholdPolicy, ResidualInfo, ZeroKind, + disambiguate_zero, make_receipt, verify_receipt, + encode_avec_fibre, parse_avec_fibre, + highest_warranted_rank, epi_status const AVEC_FIBRE_COLUMN = "avec_fibre" const EPISTEMIC_STATUSES = ("present_in_every_admissible_world", @@ -349,4 +355,239 @@ function epistemic_status_for_row(row::Dict{String,Any}, evidence::Dict{String,A end end +# -------------------------------------------------------------------------- +# Standpoint, TaxonWarrant, Projection, Receipt (Epistemic claims with receipts) +# -------------------------------------------------------------------------- + +""" + Standpoint + +The methodological perspective: which classifier, version, database, and parameters +produced a taxonomic claim. +From `EpistemicTypes.jl`: binds authority to the provenance that generated it. +""" +struct Standpoint + tool::String # "dada2", "vsearch", etc. + version::String # tool version string + db::String # reference database name (e.g., "silva", "pr2") + db_release::String # database release/version + params::OrderedDict{String,Any} # algorithm parameters (e.g. minBoot, id floor) + locus::String # amplicon locus (e.g., "16S_V4", "18S_V9") + + function Standpoint(tool::String, version::String="", db::String="", db_release::String="", + params::OrderedDict{String,Any}=OrderedDict{String,Any}(), locus::String="") + new(tool, version, db, db_release, params, locus) + end +end + +""" + TaxonWarrant + +The classifier evidence metrics and sample quality gates supporting a taxonomic claim. +Separates warrant strength (classifier metrics) from warrant validity (sample gates). +""" +struct TaxonWarrant + boot_by_rank::OrderedDict{String,Float64} # e.g., "Species" => 0.99, "Genus" => 0.95 + identity::Union{Float64,Nothing} # vsearch % identity (0-100) or nothing + evalue::Union{Float64,Nothing} # alignment e-value + query_cov::Union{Float64,Nothing} # % query coverage + depth::Union{Int,Nothing} # sequencing read depth for this sample + neg_ctrl_frac::Union{Float64,Nothing} # fraction of reads in negative control + chimera_flag::Bool # true if flagged as potential chimera + + function TaxonWarrant(; boot_by_rank=OrderedDict{String,Float64}(), + identity=nothing, + evalue=nothing, + query_cov=nothing, + depth=nothing, + neg_ctrl_frac=nothing, + chimera_flag=false) + new(boot_by_rank, identity, evalue, query_cov, depth, neg_ctrl_frac, chimera_flag) + end +end + +""" + ProjectionY + +The target claim: which feature in which sample at which rank is classified as which taxon. +""" +struct ProjectionY + feature_id::String # ASV or OTU identifier + sample_id::String # sample name + rank::String # e.g., "Genus", "Species" + taxon::String # taxonomic name claimed +end + +""" + Receipt + +Cryptographically signed claim: binds (Standpoint, TaxonWarrant, ProjectionY) with a SHA-256 signature. +From `EpistemicTypes.jl` and `echo-types`. +""" +struct Receipt + k::Standpoint + w::TaxonWarrant + y::ProjectionY + sig::String +end + +""" + ThresholdPolicy + +Rank thresholds and sample gate cutoffs. +""" +struct ThresholdPolicy + dada2_by_rank::Dict{String,Float64} + depth_min::Int + neg_ctrl_max::Float64 + + function ThresholdPolicy(; + dada2_by_rank=Dict("Species"=>0.98, "Genus"=>0.88, "Family"=>0.80, "Order"=>0.75, "Class"=>0.75, "Phylum"=>0.70), + depth_min=1000, + neg_ctrl_max=0.05 + ) + new(dada2_by_rank, depth_min, neg_ctrl_max) + end +end + +""" + ResidualInfo + +Residual summary at the next finer rank: +- warranted_rank: highest rank cleared by classifier +- status: :Factive, :Warranted, :Belief, :SansFibre +- residual_named_count: how many named candidates are plausible +""" +struct ResidualInfo + warranted_rank::Union{Nothing,String} + status::Symbol + residual_named_count::Int + residual_has_novel::Bool + contam_flag::Bool +end + +""" + ZeroKind + +Formal classification of zero observations (from `EpistemicTypes.jl` and `absolute-zero`): +- `:true_absence`: sample has adequate sequencing depth (>= depth_min) to support absence +- `:undetected`: sample has inadequate sequencing depth (< depth_min); absence is unprovable +""" +const ZeroKind = Union{Val{:true_absence}, Val{:undetected}} + +""" + disambiguate_zero(w::TaxonWarrant, pol::ThresholdPolicy) -> ZeroKind + disambiguate_zero(depth::Union{Int,Nothing}, depth_min::Int=1000) -> ZeroKind + +Distinguishes certified biological absence from observational nondetection: +- If depth >= depth_min: Val(:true_absence) +- Otherwise: Val(:undetected) +""" +function disambiguate_zero(w::TaxonWarrant, pol::ThresholdPolicy=ThresholdPolicy())::ZeroKind + return disambiguate_zero(w.depth, pol.depth_min) +end + +function disambiguate_zero(depth::Union{Int,Nothing}, depth_min::Int=1000)::ZeroKind + if depth !== nothing && depth >= depth_min + return Val(:true_absence) + else + return Val(:undetected) + end +end + +const RANK_HIERARCHY = ["Domain", "Kingdom", "Phylum", "Class", "Order", "Family", "Genus", "Species"] + +""" + highest_warranted_rank(k::Standpoint, w::TaxonWarrant, pol::ThresholdPolicy) -> Union{String,Nothing} + +Computes the finest taxonomic rank warranted by classifier metrics under the policy. +""" +function highest_warranted_rank(k::Standpoint, w::TaxonWarrant, pol::ThresholdPolicy=ThresholdPolicy())::Union{String,Nothing} + warranted = nothing + for r in RANK_HIERARCHY + min_b = get(pol.dada2_by_rank, r, 0.80) + actual_b = get(w.boot_by_rank, r, 0.0) + if actual_b >= min_b + warranted = r + end + end + return warranted +end + +""" + epi_status(r::Receipt, pol::ThresholdPolicy) -> Symbol + +Computes epistemic modality for a receipt: +- `:Factive`: warranted rank >= claimed rank AND all sample gates pass +- `:Warranted`: classifier meets threshold, but sample gates (depth/neg control) fail +- `:Belief`: classifier below threshold +- `:SansFibre`: missing evidence +""" +function epi_status(r::Receipt, pol::ThresholdPolicy=ThresholdPolicy())::Symbol + wr = highest_warranted_rank(r.k, r.w, pol) + wr === nothing && return :Belief + + # Check if wr is at or below (finer than) y.rank + idx_wr = findfirst(==(wr), RANK_HIERARCHY) + idx_y = findfirst(==(r.y.rank), RANK_HIERARCHY) + (idx_wr === nothing || idx_y === nothing || idx_wr < idx_y) && return :Belief + + # Check sample gates + depth_ok = r.w.depth === nothing || r.w.depth >= pol.depth_min + neg_ok = r.w.neg_ctrl_frac === nothing || r.w.neg_ctrl_frac <= pol.neg_ctrl_max + gates_pass = depth_ok && neg_ok && !r.w.chimera_flag + + return gates_pass ? :Factive : :Warranted +end + +""" + make_receipt(k::Standpoint, w::TaxonWarrant, y::ProjectionY; secret::String="") -> String + +Generates SHA-256 signature for a claim with receipts. +""" +function make_receipt(k::Standpoint, w::TaxonWarrant, y::ProjectionY; secret::String="")::String + payload = string(k.tool, "|", k.db, "|", k.db_release, "|", y.feature_id, "|", y.sample_id, "|", y.rank, "|", y.taxon, "|", secret) + return bytes2hex(sha256(payload)) +end + +""" + verify_receipt(r::Receipt; secret::String="") -> Bool + +Verifies that receipt signature matches (Standpoint, TaxonWarrant, ProjectionY). +""" +function verify_receipt(r::Receipt; secret::String="")::Bool + expected = make_receipt(r.k, r.w, r.y; secret=secret) + return r.sig == expected +end + +""" + encode_avec_fibre(r::Receipt) -> String + +Serializes a receipt into compact URI form for the `avec_fibre` column. +""" +function encode_avec_fibre(r::Receipt)::String + return string("echo:v1?tool=", r.k.tool, "&db=", r.k.db, "&feat=", r.y.feature_id, "&rank=", r.y.rank, "&taxon=", r.y.taxon, "&sig=", r.sig) +end + +""" + parse_avec_fibre(str::AbstractString) -> Receipt + +Parses a compact `avec_fibre` string back into a Receipt structure. +""" +function parse_avec_fibre(str::AbstractString)::Receipt + startswith(str, "echo:v1?") || throw(ArgumentError("Invalid avec_fibre prefix: missing 'echo:v1?'")) + query = str[9:end] + kv = Dict{String,String}() + for part in split(query, '&') + idx = findfirst(==('='), part) + idx === nothing && continue + kv[part[1:idx-1]] = part[idx+1:end] + end + k = Standpoint(get(kv, "tool", "unknown"), "", get(kv, "db", "unknown"), "") + y = ProjectionY(get(kv, "feat", ""), "", get(kv, "rank", ""), get(kv, "taxon", "")) + w = TaxonWarrant() + sig = get(kv, "sig", "") + return Receipt(k, w, y, sig) +end + end # module Epistemic diff --git a/test/unit/test_analysis_config.jl b/test/unit/test_analysis_config.jl index e8735770..0f6cfa86 100644 --- a/test/unit/test_analysis_config.jl +++ b/test/unit/test_analysis_config.jl @@ -392,6 +392,67 @@ end @test s1 < s2 < s3 @test s1 >= 5.0 end + + @testset "Epistemic claims with receipts and zero disambiguation" begin + # Standpoint + k = Epistemic.Standpoint("dada2", "1.28.0", "silva", "138.1", OrderedDict{String,Any}("minBoot" => 80), "16S_V4") + @test k.tool == "dada2" + @test k.db == "silva" + + # TaxonWarrant + w_strong = Epistemic.TaxonWarrant( + boot_by_rank=OrderedDict("Phylum"=>0.99, "Class"=>0.95, "Order"=>0.92, "Family"=>0.90, "Genus"=>0.89, "Species"=>0.85), + depth=5000, + neg_ctrl_frac=0.01, + chimera_flag=false + ) + @test w_strong.depth == 5000 + @test !w_strong.chimera_flag + + # ProjectionY + y = Epistemic.ProjectionY("ASV_001", "sample_01", "Genus", "Lactobacillus") + @test y.feature_id == "ASV_001" + @test y.taxon == "Lactobacillus" + + # Make and verify receipt + sig = Epistemic.make_receipt(k, w_strong, y) + @test length(sig) == 64 # SHA-256 hex + r = Epistemic.Receipt(k, w_strong, y, sig) + @test Epistemic.verify_receipt(r) + # Tampered receipt + r_tampered = Epistemic.Receipt(k, w_strong, y, "bad" * sig[4:end]) + @test !Epistemic.verify_receipt(r_tampered) + + # encode_avec_fibre and parse_avec_fibre + encoded = Epistemic.encode_avec_fibre(r) + @test startswith(encoded, "echo:v1?") + @test occursin("tool=dada2", encoded) + @test occursin("feat=ASV_001", encoded) + parsed = Epistemic.parse_avec_fibre(encoded) + @test parsed.k.tool == "dada2" + @test parsed.y.feature_id == "ASV_001" + @test parsed.sig == sig + + # epi_status + pol = Epistemic.ThresholdPolicy() + @test Epistemic.epi_status(r, pol) == :Factive + + # Low depth fails sample gates -> Warranted but not Factive + w_low_depth = Epistemic.TaxonWarrant( + boot_by_rank=OrderedDict("Genus"=>0.95), + depth=200, + neg_ctrl_frac=0.01, + chimera_flag=false + ) + r_low = Epistemic.Receipt(k, w_low_depth, y, Epistemic.make_receipt(k, w_low_depth, y)) + @test Epistemic.epi_status(r_low, pol) == :Warranted + + # Zero disambiguation (absolute-zero / EpistemicTypes) + @test Epistemic.disambiguate_zero(5000, 1000) == Val(:true_absence) + @test Epistemic.disambiguate_zero(200, 1000) == Val(:undetected) + @test Epistemic.disambiguate_zero(w_strong, pol) == Val(:true_absence) + @test Epistemic.disambiguate_zero(w_low_depth, pol) == Val(:undetected) + end end @testset "CladeCumulus" begin diff --git a/test/unit/test_execution.jl b/test/unit/test_execution.jl index fc83c4e2..fd8c48b4 100644 --- a/test/unit/test_execution.jl +++ b/test/unit/test_execution.jl @@ -483,4 +483,112 @@ @test diagnostics.checks["prevalence_abundance"]["low_prevalence_count"] == 1 @test diagnostics.checks["prevalence_abundance"]["low_abundance_count"] == 1 end + + @testset "Multiplicative and Bayesian zero replacement (Martín-Fernández)" begin + # 4 taxa, 4 samples with zeros + counts = [ + 10.0 0.0 30.0 40.0; + 20.0 50.0 0.0 30.0; + 30.0 20.0 40.0 0.0; + 40.0 30.0 30.0 30.0 + ] + sample_ids = ["s1", "s2", "s3", "s4"] + taxa_ids = ["t1", "t2", "t3", "t4"] + + # Multiplicative replacement + norm_mult = AnalysisConfig.NormalizationConfig( + method="none", + zero_policy="multiplicative_replacement", + multiplicative_replacement_delta=0.65 + ) + adv = AnalysisConfig.AdvancedConfig(min_prevalence=0.0, min_abundance=0.0, min_samples_per_group=2) + config_mult = AnalysisConfig.AnalysisConfig( + method="nb_glm", + formula="~ group", + metadata_columns=["group"], + normalization=norm_mult, + advanced=adv, + created_by="test_mult" + ) + + (prepared_mult, _, _, _, _) = Execution.prepare_analysis_table( + config_mult, counts; + sample_ids=sample_ids, + taxa_ids=taxa_ids, + drop_policy="drop" + ) + + # Check total preservation for every sample + for j in 1:4 + @test sum(prepared_mult[:, j]) ≈ sum(counts[:, j]) atol=1e-10 + end + + # Check subcompositional ratio preservation for non-zeros + # In sample 1, taxa 2 and 3 are 20 and 30 -> ratio 2/3 + @test prepared_mult[2, 1] / prepared_mult[3, 1] ≈ counts[2, 1] / counts[3, 1] atol=1e-10 + # In sample 2, taxa 2 and 4 are 50 and 30 -> ratio 5/3 + @test prepared_mult[2, 2] / prepared_mult[4, 2] ≈ counts[2, 2] / counts[4, 2] atol=1e-10 + + # Bayesian multiplicative replacement + norm_bayes = AnalysisConfig.NormalizationConfig( + method="none", + zero_policy="bayesian_multiplicative", + multiplicative_replacement_delta=0.65 + ) + config_bayes = AnalysisConfig.AnalysisConfig( + method="nb_glm", + formula="~ group", + metadata_columns=["group"], + normalization=norm_bayes, + advanced=adv, + created_by="test_bayes" + ) + (prepared_bayes, _, _, _, _) = Execution.prepare_analysis_table( + config_bayes, counts; + sample_ids=sample_ids, + taxa_ids=taxa_ids, + drop_policy="drop" + ) + + # Check total preservation for Bayesian replacement + for j in 1:4 + @test sum(prepared_bayes[:, j]) ≈ sum(counts[:, j]) atol=1e-10 + end + end + + @testset "TSS offset for NB_GLM" begin + counts = [ + 10.0 20.0 30.0 40.0; + 20.0 30.0 40.0 50.0; + 30.0 40.0 50.0 60.0 + ] + sample_ids = ["s1", "s2", "s3", "s4"] + taxa_ids = ["t1", "t2", "t3"] + + norm_tss = AnalysisConfig.NormalizationConfig(method="TSS", zero_policy="pseudocount") + adv = AnalysisConfig.AdvancedConfig(min_prevalence=0.0, min_abundance=0.0, min_samples_per_group=2) + config_tss = AnalysisConfig.AnalysisConfig( + method="nb_glm", + formula="~ group", + metadata_columns=["group"], + normalization=norm_tss, + advanced=adv, + created_by="test_tss" + ) + + (prepared_tss, _, manifest_tss, _, _) = Execution.prepare_analysis_table( + config_tss, counts; + sample_ids=sample_ids, + taxa_ids=taxa_ids, + drop_policy="drop" + ) + + # Response table keeps count nature (does NOT divide into fractions 0.16..) + @test prepared_tss[1, 1] >= 10.0 + # Adapter run verifies offset presence + adapter = Execution.JuliaAdapter(method="nb_glm") + diag_clean = Execution.ExecutionDiagnostics(warnings=String[], errors=String[], healings=String[], checks=OrderedDict{String,Any}(), is_dangerous=false, banner=nothing) + res = Execution.run_analysis(adapter, config_tss, prepared_tss; diagnostics=diag_clean, manifest=manifest_tss, sample_ids=sample_ids, taxa_ids=taxa_ids) + @test res isa Execution.ExecutionResult + end end