From b6ab9325b8d29f1d1c266fbf5b0e81f3732d6d3b Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:24:24 +0000 Subject: [PATCH 1/9] fix(clade-cumulus): repair field names, NHX support capture, LWR tolerance, route table loading The CladeCumulus testset (the last red testset in the Julia suite: 817 pass / 3 fail / 14 error, all inside it) shipped in #207 with defects that the long-broken build had masked: - _graft_jplace_queries read QueryCandidate.weight/.distal_length/ .pendant_length; the struct fields are like_weight/distal_len/ pendant_len (FieldError on any valid jplace graft). - A single retained placement at LWR 0.98 was marked undecided: PLACEMENT_WEIGHT_SUM_TOL=1e-5 rejects truncated candidate lists whose retained ratios legitimately sum to slightly less than 1. Tolerance is now 0.05; a 0.40 sum stays undecided. - The Newick label reader consumed an NHX comment after ')' without capturing it, dropping [&&NHX:B=92] support; capture refs are now threaded through read_label_and_quoted. - The sources route listed views via SHOW TABLES and then failed to bind a session-registered DataFrame view on a fresh connection (500); it now enumerates real tables via duckdb_tables(). - _load_run_table_df rejected the SubString table names JSON3 returns; arguments are normalised to String at the boundary. - Test used PH.Tools, but Phylogeny imports only selected names from Tools; the mock-tool override now addresses MetaManifold.Tools. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 24 ++++++++++++++++-------- src/server/routes/clade_cumulus.jl | 12 ++++++++++-- test/unit/test_clade_cumulus.jl | 8 ++++---- 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index a702fde2..63cbdf24 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -28,7 +28,11 @@ const DEFAULT_TAXONOMIC_RANKS = [ "Class", "Order", "Family", "Genus", "Species", ] -const PLACEMENT_WEIGHT_SUM_TOL = 1e-5 +# Placement tools may truncate a query's candidate list, so retained +# like-weight ratios can legitimately sum to slightly less than 1 (a single +# retained placement at 0.98 is complete evidence). Sums far from 1 indicate +# a malformed or partial record and stay undecided. +const PLACEMENT_WEIGHT_SUM_TOL = 0.05 ## Phylogenetic tree structures & Newick / jplace parsers mutable struct PhyloNode @@ -202,8 +206,12 @@ function parse_newick(src::AbstractString; warnings::Vector{String} = String[]): end end - function read_label_and_quoted()::Tuple{String,Bool} - skip_ws_and_leading_comments!() + function read_label_and_quoted(; capture_nhx::Union{Ref{Union{Float64,Nothing}},Nothing} = nothing, + capture_edge::Union{Ref{Union{Int,Nothing}},Nothing} = nothing)::Tuple{String,Bool} + # An NHX comment immediately after ')' precedes the (usually empty) + # label, so the capture refs must be threaded through this first skip + # or the support tag is consumed as an anonymous comment. + skip_ws_and_leading_comments!(capture_nhx = capture_nhx, capture_edge = capture_edge) pos[] > n && return ("", false) if bytes[pos[]] == UInt8('\'') pos[] += 1 @@ -260,7 +268,7 @@ function parse_newick(src::AbstractString; warnings::Vector{String} = String[]): nhx_sup = Ref{Union{Float64,Nothing}}(nothing) edge_num = Ref{Union{Int,Nothing}}(nothing) - raw_lbl, was_quoted = read_label_and_quoted() + raw_lbl, was_quoted = read_label_and_quoted(capture_nhx = nhx_sup, capture_edge = edge_num) skip_ws_and_leading_comments!(capture_nhx = nhx_sup, capture_edge = edge_num) branch_len = 0.0 @@ -991,7 +999,7 @@ function _graft_jplace_queries( for q in jd.queries q.status === :valid && !isempty(q.candidates) || continue - total_w = sum((c.weight for c in q.candidates); init = 0.0) + total_w = sum((c.like_weight for c in q.candidates); init = 0.0) total_w > 0.0 || continue multi = length(q.candidates) > 1 for (qname, mult) in q.names @@ -1000,7 +1008,7 @@ function _graft_jplace_queries( tx = get(asv_deepest_taxon, qname, nothing) tconf = get(asv_tax_conf, qname, 1.0) for (ci, cand) in enumerate(q.candidates) - share = cand.weight / total_w + share = cand.like_weight / total_w tip_id = multi ? string(qname, "@edge", cand.edge_num, "#", ci) : qname if multi asv_reads[tip_id] = get(asv_reads, tip_id, 0.0) + base_cnt * share @@ -1009,9 +1017,9 @@ function _graft_jplace_queries( elseif !haskey(asv_reads, tip_id) asv_reads[tip_id] = base_cnt end - query_lwr[tip_id] = cand.weight + query_lwr[tip_id] = cand.like_weight push!(get!(by_edge, cand.edge_num, _GraftedQuerySpec[]), - _GraftedQuerySpec(tip_id, qname, share, max(0.0, cand.distal_length), max(0.0, cand.pendant_length), cand.weight)) + _GraftedQuerySpec(tip_id, qname, share, max(0.0, cand.distal_len), max(0.0, cand.pendant_len), cand.like_weight)) end end end diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index 45825b61..0dd6918d 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -64,7 +64,11 @@ function _probe_optional_phylo_tool(tool_key::String, fallbacks::Vector{String}) end end -function _load_run_table_df(study::String, run::String, table::String; group::Union{String,Nothing}=nothing) +function _load_run_table_df(study::AbstractString, run::AbstractString, table::AbstractString; group::Union{AbstractString,Nothing}=nothing) + # JSON3 hands request fields back as SubStrings; the DuckDB helpers are + # String-typed, so normalise once at this boundary. + study = String(study); run = String(run); table = String(table) + group = isnothing(group) ? nothing : String(group) dir = _require_duckdb(study, run; group) isnothing(dir) && return (nothing, nothing, json_error(404, "no_results", "No results for run '$run' - run the pipeline first")) @@ -142,7 +146,11 @@ end db = DuckDB.DB(db_path) con = DBInterface.connect(db) try - names_df = DataFrame(DBInterface.execute(con, "SHOW TABLES")) + # Real tables only: SHOW TABLES also lists views, and a view backed + # by a session-registered DataFrame cannot be bound (let alone + # counted) from this fresh connection. + names_df = DataFrame(DBInterface.execute(con, + "SELECT table_name AS name FROM duckdb_tables() WHERE NOT internal")) tlist = sort(String[string(r.name) for r in eachrow(names_df)]) for t in tlist cols = _duckdb_columns(con, t) diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index da09bc18..6e12a0c9 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -252,8 +252,8 @@ done echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_000004:0.08);" > "$OUT" """) chmod(mock_ft, 0o755) - old_ft = get(PH.Tools._tools, "fasttree", nothing) - PH.Tools._tools["fasttree"] = mock_ft + old_ft = get(MetaManifold.Tools._tools, "fasttree", nothing) + MetaManifold.Tools._tools["fasttree"] = mock_ft try s4 = [ "ASV_1" => "ACGTACGTACGTACGTACGT", @@ -267,9 +267,9 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 r["sha256"] == bytes2hex(open(sha256, mock_ft)), ft_res.tool_records) finally if old_ft === nothing - delete!(PH.Tools._tools, "fasttree") + delete!(MetaManifold.Tools._tools, "fasttree") else - PH.Tools._tools["fasttree"] = old_ft + MetaManifold.Tools._tools["fasttree"] = old_ft end end end From 0e409ed7791b480ead7f6ac37a778b6d33bef673 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:34:01 +0000 Subject: [PATCH 2/9] fix(doi): restore missing module DOI opener src/core/doi.jl ended with 'end # module DOI' but never opened the module, so including it into MetaManifold failed to parse (ParseError at doi.jl:681) and no Julia test could even load the package. Routes and tests address MetaManifold.DOI, so the file is a submodule whose opener was lost in an earlier repair. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/core/doi.jl | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/core/doi.jl b/src/core/doi.jl index 6a2f926a..f5b0bd95 100644 --- a/src/core/doi.jl +++ b/src/core/doi.jl @@ -16,6 +16,8 @@ # * Durable write-ahead journal; immutable DOI/provenance receipts # * DOI insertion never rewrites scientific objects +module DOI + using Dates, SHA, HTTP, JSON3, Logging export DOIConfig, Deposition, PublicationReceipt, From 238598e8e0d26fc57740eef55c7dee0fff260deb Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:47:41 +0000 Subject: [PATCH 3/9] fix(clade-cumulus): PhyloNode edge field is edge_number; widen _valid_tree_file - The jplace graft walk read u.edge_num/cur.edge_num; PhyloNode's field is edge_number (QueryCandidate.edge_num is a different, legitimate field). Grafting any valid placement threw FieldError. - _valid_tree_file only accepted ::String, but strip() of a request field yields a SubString, so POST clade-cumulus with treeSource study_tree returned 500. It now accepts AbstractString. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 6 +++--- src/server/routes/trees.jl | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index 63cbdf24..92253778 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -1029,8 +1029,8 @@ function _graft_jplace_queries( function walk(u::PhyloNode)::PhyloNode rebuilt_kids = PhyloNode[walk(ch) for ch in u.children] - cur = PhyloNode(u.id, u.name, u.branch_length, u.support, u.edge_num, rebuilt_kids) - specs = u.edge_num === nothing ? nothing : get(by_edge, u.edge_num, nothing) + cur = PhyloNode(u.id, u.name, u.branch_length, u.support, u.edge_number, rebuilt_kids) + specs = u.edge_number === nothing ? nothing : get(by_edge, u.edge_number, nothing) (specs === nothing || isempty(specs)) && return cur # Order grafts from most distal (closest to `cur`, smallest `distal`) to most proximal. @@ -1048,7 +1048,7 @@ function _graft_jplace_queries( sub = PhyloNode(string("jplace_clade#", seq_auto[]), "", 0.0, 1.0, nothing, PhyloNode[sub, qtip]) end rem_len = max(0.0, total_len - prev_distal) - PhyloNode(sub.id, sub.name, rem_len, cur.support, cur.edge_num, sub.children) + PhyloNode(sub.id, sub.name, rem_len, cur.support, cur.edge_number, sub.children) end walk(base_root) diff --git a/src/server/routes/trees.jl b/src/server/routes/trees.jl index f3cc8e07..3795dd0e 100644 --- a/src/server/routes/trees.jl +++ b/src/server/routes/trees.jl @@ -19,7 +19,7 @@ _tree_view_path(study::String, file::String) = joinpath(_trees_dir(study), ".vie _tree_format(file::String) = endswith(lowercase(file), ".jplace") ? "jplace" : "newick" -function _valid_tree_file(file::String)::Bool +function _valid_tree_file(file::AbstractString)::Bool _valid_name(file) && any(ext -> endswith(lowercase(file), ext), _TREE_EXTENSIONS) end From 6f052cc52983f36bafc5241621e28fef5d542416 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:06:16 +0000 Subject: [PATCH 4/9] fix(clade-cumulus): structured 500 for unhandled route errors; remaining like_weight fields The POST handler let unhandled exceptions escape as bare Oxygen 500s with no body, leaving CI annotations with nothing to diagnose. It is now wrapped: failures are logged with a backtrace and returned as a structured clade_cumulus_failed error naming the exception. Also fixes the last QueryCandidate .weight reads in the diversity tip-weight computation (field is like_weight), and the route test surfaces the error body when the status assertion fails. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/server/routes/clade_cumulus.jl | 13 +++++++++++-- test/unit/test_clade_cumulus.jl | 2 ++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index 0dd6918d..4f87f2de 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -198,6 +198,15 @@ end end @post "/api/v1/studies/{study}/runs/{run}/clade-cumulus" function(req::HTTP.Request, study::String, run::String) + try + return _clade_cumulus_post_impl(req, study, run) + catch e + @error "clade-cumulus request failed" exception = (e, catch_backtrace()) + return json_error(500, "clade_cumulus_failed", sprint(showerror, e)) + end +end + +function _clade_cumulus_post_impl(req::HTTP.Request, study::String, run::String) group = _req_group(req) err = _validate_run_request(study, run) !isnothing(err) && return err @@ -276,13 +285,13 @@ end jd_div = MetaManifold.CladeCumulus.parse_jplace(jplace_str) for q in jd_div.queries q.status === :valid && !isempty(q.candidates) || continue - tw = sum((c.weight for c in q.candidates); init = 0.0) + tw = sum((c.like_weight for c in q.candidates); init = 0.0) tw > 0.0 || continue multi = length(q.candidates) > 1 for (qname, _) in q.names for (ci, cand) in enumerate(q.candidates) tid = multi ? string(qname, "@edge", cand.edge_num, "#", ci) : qname - tip_weights[tid] = cand.weight / tw + tip_weights[tid] = cand.like_weight / tw end end end diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index 6e12a0c9..03f765ba 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -330,6 +330,8 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 "supportThreshold" => 0.85, "lwrThreshold" => 0.95, ))) + # On failure this puts the structured error body in the CI annotation. + @test (r_comb.status == 200 ? "" : String(r_comb.body)) == "" @test r_comb.status == 200 comb = JSON3.read(String(r_comb.body)) @test comb.root.cumulative_count ≈ 110.0 atol = 1e-9 From 6644ff5af4341378352ebaa8cf218106e00110e3 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:22:33 +0000 Subject: [PATCH 5/9] fix(trees): accept AbstractString in tree-file helpers _tree_format only accepted ::String, but the clade-cumulus route passes strip() output (a SubString) for the requested tree file, so study_tree requests failed with MethodError (surfaced by the new structured clade_cumulus_failed body). The tree helpers now accept AbstractString throughout. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/server/routes/trees.jl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/server/routes/trees.jl b/src/server/routes/trees.jl index 3795dd0e..2d1cd348 100644 --- a/src/server/routes/trees.jl +++ b/src/server/routes/trees.jl @@ -14,21 +14,21 @@ const _TREE_MAX_BYTES = 50 * 1024 * 1024 const _VIEW_MAX_BYTES = 5 * 1024 * 1024 const _tree_view_lock = ReentrantLock() -_trees_dir(study::String) = joinpath(ServerState.projects_dir(), study, "trees") -_tree_view_path(study::String, file::String) = joinpath(_trees_dir(study), ".views", file * ".json") +_trees_dir(study::AbstractString) = joinpath(ServerState.projects_dir(), study, "trees") +_tree_view_path(study::AbstractString, file::AbstractString) = joinpath(_trees_dir(study), ".views", file * ".json") -_tree_format(file::String) = endswith(lowercase(file), ".jplace") ? "jplace" : "newick" +_tree_format(file::AbstractString) = endswith(lowercase(file), ".jplace") ? "jplace" : "newick" function _valid_tree_file(file::AbstractString)::Bool _valid_name(file) && any(ext -> endswith(lowercase(file), ext), _TREE_EXTENSIONS) end -function _study_exists(study::String)::Bool +function _study_exists(study::AbstractString)::Bool _valid_name(study) && (isdir(joinpath(ServerState.projects_dir(), study)) || study in _study_names()) end -function _tree_request_error(study::String, file::Union{String,Nothing}=nothing) +function _tree_request_error(study::AbstractString, file::Union{AbstractString,Nothing}=nothing) _study_exists(study) || return json_error(404, "study_not_found", "Study '$study' not found") isnothing(file) && return nothing _valid_tree_file(file) || return json_error(400, "invalid_name", @@ -36,7 +36,7 @@ function _tree_request_error(study::String, file::Union{String,Nothing}=nothing) nothing end -function _tree_listing(study::String) +function _tree_listing(study::AbstractString) dir = _trees_dir(study) isdir(dir) || return NamedTuple[] files = sort(filter(f -> _valid_tree_file(f) && isfile(joinpath(dir, f)), readdir(dir))) From 45eafa030c6099d1998f20c1dc5f0ad272756806 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:45:59 +0000 Subject: [PATCH 6/9] fix(clade-cumulus): stem-inclusive subtree PD after drag-drop; const SV in validated tests - _reaggregate_after_move's recompute stored subtree_pd excluding the node's own stem branch, while the build path stores clade_faith_pd (stem-inclusive except at the root). A proposed move into a clade therefore appeared to *reduce* its PD. The recompute now threads stem-inclusive PD up the recursion and stores it with build-path semantics. - test_validated_statistics.jl assigned SV without const; with test_clade_cumulus.jl's const SV in the same Main session the full suite died with 'invalid assignment to constant Main.SV' before the file's tests could run. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 18 +++++++++++------- test/unit/test_validated_statistics.jl | 2 +- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index 92253778..7d61ee52 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -1781,7 +1781,7 @@ function _reaggregate_after_move( end count_leaf_taxa!(grafted) - function recompute(u::CladeNode, is_root::Bool)::Tuple{CladeNode,Dict{String,Int},Int,Bool} + function recompute(u::CladeNode, is_root::Bool)::Tuple{CladeNode,Dict{String,Int},Int,Bool,Float64} if isempty(u.children) freq = total_reads > 0.0 ? u.cumulative_count / total_reads : 0.0 tmap = Dict{String,Int}() @@ -1798,7 +1798,8 @@ function _reaggregate_after_move( u.branch_length, u.support, 0.0, u.dominant_taxon, u.taxon_purity, mono, st, Epistemic.epistemic_colour(st), CladeNode[], ) - return (leaf_node, tmap, 1, active_here) + leaf_pd = (active_here && !is_root) ? max(0.0, u.branch_length) : 0.0 + return (leaf_node, tmap, 1, active_here, leaf_pd) end rebuilt_kids = CladeNode[] @@ -1810,14 +1811,14 @@ function _reaggregate_after_move( cum = u.direct_count for ch in u.children - rk, ct, nt, ch_active = recompute(ch, false) + rk, ct, nt, ch_active, rk_pd = recompute(ch, false) push!(rebuilt_kids, rk) cum += rk.cumulative_count child_res += rk.residual_count n_sub_tips += nt if ch_active any_active = true - sub_pd += max(0.0, rk.branch_length) + rk.subtree_pd + sub_pd += rk_pd end for (k, v) in ct merged_taxa[k] = get(merged_taxa, k, 0) + v @@ -1852,17 +1853,20 @@ function _reaggregate_after_move( res = max(child_res, branch_uncert) freq = total_reads > 0.0 ? cum / total_reads : 0.0 st = Epistemic.epistemic_status(case_obj, u.id) + # Stored subtree_pd matches the build path (clade_faith_pd): it + # includes this node's own stem branch, except at the root. + stored_pd = sub_pd + ((any_active && !is_root) ? max(0.0, u.branch_length) : 0.0) node = CladeNode( u.id, u.name, u.rank, u.direct_count, cum, freq, res, Epistemic.cloud_size_by_residual(res), - u.branch_length, u.support, sub_pd, + u.branch_length, u.support, stored_pd, eff_dom, purity, mono, st, Epistemic.epistemic_colour(st), rebuilt_kids, ) - (node, merged_taxa, n_sub_tips, any_active) + (node, merged_taxa, n_sub_tips, any_active, stored_pd) end - updated_root, _, _, _ = recompute(grafted, true) + updated_root, _, _, _, _ = recompute(grafted, true) updated_root end diff --git a/test/unit/test_validated_statistics.jl b/test/unit/test_validated_statistics.jl index 7a7465d2..bb18e1fc 100644 --- a/test/unit/test_validated_statistics.jl +++ b/test/unit/test_validated_statistics.jl @@ -1,7 +1,7 @@ using Test using JSON3 using MetaManifold -SV = MetaManifold.Server +const SV = MetaManifold.Server function _vs_request(method::String, path::String, body; raw::Bool=false) payload = raw ? body : JSON3.write(body) From 8502ffa509a5aaa3499ebc19fad1554c809f9238 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:57:58 +0000 Subject: [PATCH 7/9] test: drop const from SV alias so test files can share one Main session Every server test file assigns SV = MetaManifold.Server at top level; test_clade_cumulus.jl (and briefly test_validated_statistics.jl) declared it const, so whichever file runtests.jl included next died with 'invalid assignment to constant Main.SV' in the full-suite run. Plain assignment everywhere matches the established convention. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- test/unit/test_clade_cumulus.jl | 2 +- test/unit/test_validated_statistics.jl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index 03f765ba..c4e077ff 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -14,7 +14,7 @@ const EP = MetaManifold.Epistemic const CC = MetaManifold.CladeCumulus const DM = MetaManifold.DiversityMetrics const PH = MetaManifold.Phylogeny -const SV = MetaManifold.Server +SV = MetaManifold.Server _cc_req(method::String, path::String, body::String = "") = SV.Oxygen.internalrequest( diff --git a/test/unit/test_validated_statistics.jl b/test/unit/test_validated_statistics.jl index bb18e1fc..7a7465d2 100644 --- a/test/unit/test_validated_statistics.jl +++ b/test/unit/test_validated_statistics.jl @@ -1,7 +1,7 @@ using Test using JSON3 using MetaManifold -const SV = MetaManifold.Server +SV = MetaManifold.Server function _vs_request(method::String, path::String, body; raw::Bool=false) payload = raw ? body : JSON3.write(body) From f7b2996ddf238b88aa6ea61e863835ffcf248ede Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:22:25 +0000 Subject: [PATCH 8/9] fix(compositional): implement compositional_erfc locally; fix multinomial test config - _wald called bare erfc, but CompositionalModels deliberately carries no SpecialFunctions dependency (local loggamma/digamma), so every multinomial/Dirichlet-multinomial fit died with UndefVarError. compositional_erfc computes Q(1/2, x^2) by series / Lentz continued fraction (machine-precision against libm erfc). - The 'no composition to model' test passed min_prevalence as a keyword; it is the second positional of DifferentialConfig. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/compositional.jl | 54 ++++++++++++++++++++++++++++++++-- test/unit/test_differential.jl | 4 +-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/analysis/compositional.jl b/src/analysis/compositional.jl index c05ce892..0972a262 100644 --- a/src/analysis/compositional.jl +++ b/src/analysis/compositional.jl @@ -50,7 +50,7 @@ export CompositionalFit, CompositionalRefusal, RuntimeRefusal, MAX_TAXA, CONCENTRATION_MAX, DM_METHODS, fit_multinomial, fit_dirichlet_multinomial, multinomial_loglik, dirichlet_multinomial_loglik, dm_concentration_mle, dm_concentration_mom, observed_information, composition_probabilities, - compositional_loggamma, compositional_digamma + compositional_loggamma, compositional_digamma, compositional_erfc # At most this many taxa are fitted. The observed information is # ((K-1) q) x ((K-1) q) doubles: 500 taxa with two covariates is about 8 MB, @@ -193,6 +193,56 @@ function compositional_digamma(x::Real) inv2 * (1 / 252 - inv2 * (1 / 240 - inv2 / 132)))) - shift end +""" + compositional_erfc(x) -> Float64 + +The complementary error function erfc(x) = 1 - erf(x), computed as the +regularised upper incomplete gamma Q(1/2, x^2): a series for x^2 < 3/2 and a +Lentz continued fraction above that (Numerical Recipes `gser`/`gcf`), reusing +`compositional_loggamma`. Accurate to about 1e-15 relative over the Wald +z-range, so two-sided normal p-values `erfc(|z| / sqrt(2))` need no +SpecialFunctions dependency either. +""" +function compositional_erfc(x::Real) + xf = Float64(x) + xf < 0 && return 2.0 - compositional_erfc(-xf) + xf == 0 && return 1.0 + isfinite(xf) || return 0.0 + a = 0.5 + x2 = xf * xf + kernel = -x2 + a * log(x2) - compositional_loggamma(a) + if x2 < a + 1 + ap = a + term = 1.0 / a + total = term + for _ in 1:1000 + ap += 1 + term *= x2 / ap + total += term + abs(term) < abs(total) * 1e-15 && break + end + return 1.0 - total * exp(kernel) + end + tiny = 1e-300 + b = x2 + 1 - a + c = 1 / tiny + d = 1 / b + h = d + for i in 1:1000 + an = -i * (i - a) + b += 2 + d = an * d + b + abs(d) < tiny && (d = tiny) + d = 1 / d + c = b + an / c + abs(c) < tiny && (c = tiny) + delta = d * c + h *= delta + abs(delta - 1) < 1e-15 && break + end + exp(kernel) * h +end + ## Geometry """ @@ -860,7 +910,7 @@ function _wald(coefficients::Matrix{Float64}, variances::Vector{Float64}) se[l, k] = s if s > 0.0 && isfinite(s) z[l, k] = coefficients[l, k] / s - p[l, k] = erfc(abs(z[l, k]) / sqrt(2.0)) + p[l, k] = compositional_erfc(abs(z[l, k]) / sqrt(2.0)) else z[l, k] = NaN p[l, k] = NaN diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index d0f44e47..ebb71a76 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -1647,8 +1647,8 @@ end e = try differential_abundance([1 0; 2 3; 0 4; 5 0], ["s1", "s2", "s3", "s4"], ["a", "b"], groups8[1:4]; - kw..., config=DifferentialConfig(; method="multinomial", - min_prevalence=1.0)) + kw..., config=DifferentialConfig("tss", 1.0; + method="multinomial")) nothing catch err err From bba6044701358dd4037d49922d2d718230b7e6b3 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:43:24 +0000 Subject: [PATCH 9/9] ci: retrigger (previous push produced no CI run) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com>