From 30e4c649f46565ebe8495696c17013332e8fa040 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 07:39:05 +0000 Subject: [PATCH 1/3] fix(julia): resolve DOI module wrapper, CladeCumulus jplace/PD/DuckDB bugs, and Main.SV binding conflict Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/clade_cumulus.jl | 30 ++++++++++------ src/core/doi.jl | 2 ++ src/pipeline/phylogeny.jl | 1 + src/server/routes/clade_cumulus.jl | 58 ++++++++++++++++-------------- src/server/routes/trees.jl | 4 +-- src/server/server.jl | 1 - test/unit/test_clade_cumulus.jl | 35 ++++++++++++++---- 7 files changed, 83 insertions(+), 48 deletions(-) diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index a702fde2..2321fa15 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -28,7 +28,10 @@ const DEFAULT_TAXONOMIC_RANKS = [ "Class", "Order", "Family", "Genus", "Species", ] -const PLACEMENT_WEIGHT_SUM_TOL = 1e-5 +# Placement writers truncate tail candidates below their own LWR cutoff, so a +# retained candidate list summing to >= 0.95 (e.g. a single placement at 0.98) +# is treated as complete evidence while larger shortfalls remain `:undecided`. +const PLACEMENT_WEIGHT_SUM_TOL = 0.05 ## Phylogenetic tree structures & Newick / jplace parsers mutable struct PhyloNode @@ -203,7 +206,9 @@ function parse_newick(src::AbstractString; warnings::Vector{String} = String[]): end function read_label_and_quoted()::Tuple{String,Bool} - skip_ws_and_leading_comments!() + while pos[] <= n && bytes[pos[]] in (UInt8(' '), UInt8('\t'), UInt8('\r'), UInt8('\n')) + pos[] += 1 + end pos[] > n && return ("", false) if bytes[pos[]] == UInt8('\'') pos[] += 1 @@ -991,7 +996,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 +1005,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 +1014,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 @@ -1021,8 +1026,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. @@ -1040,7 +1045,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) @@ -1783,11 +1788,12 @@ function _reaggregate_after_move( mono = get(total_tips_per_taxon, u.dominant_taxon, 0) == 1 end active_here = u.cumulative_count > 0.0 + sub_pd = active_here && !is_root ? max(0.0, u.branch_length) : 0.0 st = Epistemic.epistemic_status(case_obj, u.id) leaf_node = CladeNode( u.id, u.name, u.rank, u.direct_count, u.cumulative_count, freq, u.residual_count, Epistemic.cloud_size_by_residual(u.residual_count), - u.branch_length, u.support, 0.0, u.dominant_taxon, u.taxon_purity, mono, + u.branch_length, u.support, sub_pd, u.dominant_taxon, u.taxon_purity, mono, st, Epistemic.epistemic_colour(st), CladeNode[], ) return (leaf_node, tmap, 1, active_here) @@ -1809,13 +1815,15 @@ function _reaggregate_after_move( n_sub_tips += nt if ch_active any_active = true - sub_pd += max(0.0, rk.branch_length) + rk.subtree_pd + sub_pd += rk.subtree_pd end for (k, v) in ct merged_taxa[k] = get(merged_taxa, k, 0) + v end end + !is_root && any_active && (sub_pd += max(0.0, u.branch_length)) + dom_tx = nothing dom_n = 0 for (tx, c) in merged_taxa 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, diff --git a/src/pipeline/phylogeny.jl b/src/pipeline/phylogeny.jl index b394d9dc..b978ef02 100644 --- a/src/pipeline/phylogeny.jl +++ b/src/pipeline/phylogeny.jl @@ -34,6 +34,7 @@ export REFERENCE_STEPS, PLACEMENT_STEPS, Step, placement_settings, run_workflow, using ..PipelineLog using ..Validation using ..Tools: tool_bin, _sq, _run_logged, _safe_optional_args, _num + import ..Tools using ..RemoteExec: run_remote, checked_target import ..Provenance diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index 45825b61..cc737666 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -64,26 +64,30 @@ 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) - dir = _require_duckdb(study, run; group) +function _load_run_table_df( + study::AbstractString, + run::AbstractString, + table::AbstractString; + group::Union{AbstractString,Nothing} = nothing, +) + study_s = String(study) + run_s = String(run) + table_s = String(table) + group_s = isnothing(group) ? nothing : String(group) + dir = _require_duckdb(study_s, run_s; group = group_s) isnothing(dir) && return (nothing, nothing, json_error(404, "no_results", - "No results for run '$run' - run the pipeline first")) + "No results for run '$run_s' - run the pipeline first")) db_path = joinpath(dir, "results.duckdb") isfile(db_path) || return (nothing, nothing, json_error(404, "no_results", - "No results database for run '$run'")) - db = DuckDB.DB(db_path) - con = DBInterface.connect(db) - try - cols = _duckdb_columns(con, table) + "No results database for run '$run_s'")) + with_results_db(dir) do con + cols = _duckdb_columns(con, table_s) isempty(cols) && return (nothing, nothing, json_error(404, "table_not_found", - "Table '$table' not found in run '$run'")) - scols = _sample_count_columns(con, table) + "Table '$table_s' not found in run '$run_s'")) + scols = _sample_count_columns(con, table_s) order = _stable_order_clause(cols) - df = DataFrame(DBInterface.execute(con, "SELECT * FROM \"$table\" $order")) + df = DataFrame(DBInterface.execute(con, "SELECT * FROM \"$table_s\" $order")) return (df, (db_path = db_path, sample_cols = scols, columns = cols), nothing) - finally - DBInterface.close!(con) - close(db) end end @@ -93,8 +97,8 @@ function _resolve_tree_inputs_from_request( source_digests::OrderedDict{String,String}, ) tree_source = lowercase(strip(string(get(body, :treeSource, "auto")))) - tree_file = strip(string(get(body, :treeFile, ""))) - inline_newick = strip(string(get(body, :newick, ""))) + tree_file = String(strip(string(get(body, :treeFile, "")))) + inline_newick = String(strip(string(get(body, :newick, "")))) if tree_source == "study_tree" || (!isempty(tree_file) && tree_source == "auto") _valid_tree_file(tree_file) || return (nothing, nothing, :auto, @@ -138,11 +142,13 @@ end dir = _require_duckdb(study, run; group) db_path = isnothing(dir) ? nothing : joinpath(dir, "results.duckdb") tables_info = OrderedDict{String,Any}[] - if !isnothing(db_path) && isfile(db_path) - db = DuckDB.DB(db_path) - con = DBInterface.connect(db) - try - names_df = DataFrame(DBInterface.execute(con, "SHOW TABLES")) + if !isnothing(dir) && !isnothing(db_path) && isfile(db_path) + tables_info = with_results_db(dir) do con + available = OrderedDict{String,Any}[] + 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) @@ -151,7 +157,7 @@ end nrows = Int(only(DataFrame(DBInterface.execute(con, "SELECT COUNT(*) AS n FROM \"$t\""))).n) has_seq = any(c -> c in ("Sequence", "sequence", "seq", "DNA"), cols) ranks = [r for r in MetaManifold.CladeCumulus.DEFAULT_TAXONOMIC_RANKS if r in cols] - push!(tables_info, OrderedDict{String,Any}( + push!(available, OrderedDict{String,Any}( "table" => t, "row_count" => nrows, "sample_count" => length(scols), @@ -159,9 +165,7 @@ end "ranks" => ranks, )) end - finally - DBInterface.close!(con) - close(db) + available end end @@ -268,13 +272,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/src/server/routes/trees.jl b/src/server/routes/trees.jl index f3cc8e07..beec713a 100644 --- a/src/server/routes/trees.jl +++ b/src/server/routes/trees.jl @@ -17,9 +17,9 @@ 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") -_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::String)::Bool +function _valid_tree_file(file::AbstractString)::Bool _valid_name(file) && any(ext -> endswith(lowercase(file), ext), _TREE_EXTENSIONS) end diff --git a/src/server/server.jl b/src/server/server.jl index ebf5abb6..eca368a0 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -118,7 +118,6 @@ module Server include(joinpath(@__DIR__, "routes", "phylogeny.jl")) include(joinpath(@__DIR__, "routes", "cladistics.jl")) include(joinpath(@__DIR__, "routes", "clade_cumulus.jl")) - include(joinpath(@__DIR__, "routes", "cladistics.jl")) ## Bad request middleware # Helpers deep in a route (e.g. `_req_group`) throw this to reject a request. diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index da09bc18..3f046d7a 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -14,12 +14,12 @@ const EP = MetaManifold.Epistemic const CC = MetaManifold.CladeCumulus const DM = MetaManifold.DiversityMetrics const PH = MetaManifold.Phylogeny -const SV = MetaManifold.Server +const _CLADE_SV = MetaManifold.Server _cc_req(method::String, path::String, body::String = "") = - SV.Oxygen.internalrequest( - SV.HTTP.Request(method, path, ["Host" => "127.0.0.1:8080", "Content-Type" => "application/json"], body); - middleware = SV._MIDDLEWARE, + _CLADE_SV.Oxygen.internalrequest( + _CLADE_SV.HTTP.Request(method, path, ["Host" => "127.0.0.1:8080", "Content-Type" => "application/json"], body); + middleware = _CLADE_SV._MIDDLEWARE, ) @testset "CladeCumulus & Phylogenetic Integration" begin @@ -277,8 +277,8 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 @testset "Dual-hierarchy CladeCumulus, polyphyly detection, drag-and-drop, and HTTP routes" begin tmp = mktempdir() - old_root = SV.ServerState._root[] - SV.ServerState.set_root!(tmp) + old_root = _CLADE_SV.ServerState._root[] + _CLADE_SV.ServerState.set_root!(tmp) try data_dir = joinpath(tmp, "data", "StudyCC", "run1") mkpath(data_dir) @@ -310,6 +310,7 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 con = DBInterface.connect(db) DuckDB.register_data_frame(con, df, "src_df") DBInterface.execute(con, "CREATE TABLE merged AS SELECT * FROM src_df") + DuckDB.unregister_data_frame(con, "src_df") DBInterface.close!(con) close(db) before_db_sha = bytes2hex(open(sha256, db_path)) @@ -414,6 +415,26 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 # Canonical DuckDB database is strictly untouched byte-for-byte! @test bytes2hex(open(sha256, db_path)) == before_db_sha + # 6. POST combined mode against a study `.jplace` artifact + jplace_payload = JSON3.write(OrderedDict( + "version" => 3, + "tree" => "((ASV_1:0.05{0},ASV_2:0.05{1})0.96:0.20{2},ASV_3:0.08{3}){4};", + "fields" => ["edge_num", "likelihood", "like_weight_ratio", "distal_length", "pendant_length"], + "placements" => [ + OrderedDict("n" => ["ASV_4"], "p" => [[3, -10.0, 0.99, 0.02, 0.03]]), + ], + )) + write(joinpath(trees_dir, "placed.jplace"), jplace_payload) + r_jp = _cc_req("POST", "/api/v1/studies/StudyCC/runs/run1/clade-cumulus", JSON3.write(Dict( + "table" => "merged", + "mode" => "combined", + "treeSource" => "study_tree", + "treeFile" => "placed.jplace", + ))) + @test r_jp.status == 200 + jp_body = JSON3.read(String(r_jp.body)) + @test jp_body.diversity !== nothing + # Path traversal attempts are rejected with 400! @test _cc_req("GET", "/api/v1/studies/..%2Fetc/runs/run1/clade-cumulus/sources").status == 400 @test _cc_req("POST", "/api/v1/studies/StudyCC/runs/run1/clade-cumulus", JSON3.write(Dict( @@ -421,7 +442,7 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 "treeFile" => "../secret.nwk", ))).status == 400 finally - SV.ServerState._root[] = old_root + _CLADE_SV.ServerState._root[] = old_root rm(tmp; recursive = true, force = true) end end From c61c40233ef0f120968173caf788e6c5100a6650 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:42:09 +0000 Subject: [PATCH 2/3] fix(stats,da): implement pure-Julia _erfc for Wald tests, fix byte-backed JSON token check, and align DifferentialConfig call Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/analysis/compositional.jl | 41 ++++++++++++++++++++++- src/server/routes/validated_statistics.jl | 9 ++--- test/unit/test_differential.jl | 24 ++++++++++--- test/unit/test_validated_statistics.jl | 5 +++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/src/analysis/compositional.jl b/src/analysis/compositional.jl index c05ce892..48771c0c 100644 --- a/src/analysis/compositional.jl +++ b/src/analysis/compositional.jl @@ -193,6 +193,45 @@ function compositional_digamma(x::Real) inv2 * (1 / 252 - inv2 * (1 / 240 - inv2 / 132)))) - shift end +const _LOG_SQRT_PI = 0.5723649429247000870717136756765293558 + +function _erfc(t::Float64)::Float64 + t < 0.0 && return 2.0 - _erfc(-t) + iszero(t) && return 1.0 + x = t * t + log_prefactor = -x + 0.5 * log(x) - _LOG_SQRT_PI + if x < 1.5 + ap = 0.5 + term = 1.0 / ap + acc = term + for _ in 1:200 + ap += 1.0 + term *= x / ap + acc += term + abs(term) <= abs(acc) * 1e-16 && break + end + return 1.0 - acc * exp(log_prefactor) + end + tiny = 1e-300 + b = x + 0.5 + c = 1.0 / tiny + d = 1.0 / b + h = d + for i in 1:200 + an = -float(i) * (float(i) - 0.5) + b += 2.0 + d = an * d + b + abs(d) < tiny && (d = tiny) + c = b + an / c + abs(c) < tiny && (c = tiny) + d = 1.0 / d + delta = d * c + h *= delta + abs(delta - 1.0) <= 1e-16 && break + end + exp(log_prefactor) * h +end + ## Geometry """ @@ -860,7 +899,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] = _erfc(abs(z[l, k]) / sqrt(2.0)) else z[l, k] = NaN p[l, k] = NaN diff --git a/src/server/routes/validated_statistics.jl b/src/server/routes/validated_statistics.jl index fb14d584..12f452f5 100644 --- a/src/server/routes/validated_statistics.jl +++ b/src/server/routes/validated_statistics.jl @@ -38,15 +38,17 @@ function _statistics_guard(f::Function) end end -function _statistics_body(req) +function _statistics_body(req; reject_noninteger_tokens::Bool=false) length(req.body) <= _STATISTICS_REQUEST_LIMIT || throw(ResourceLimitError( "request bytes", _STATISTICS_REQUEST_LIMIT, length(req.body))) + raw = req.body isa AbstractVector{UInt8} ? String(copy(req.body)) : String(req.body) parsed = try - JSON3.read(String(req.body)) + JSON3.read(raw) catch throw(ArgumentError("request body is not valid JSON")) end parsed isa JSON3.Object || throw(ArgumentError("request body must be a JSON object")) + reject_noninteger_tokens && _reject_float_count_tokens(raw) parsed end @@ -424,8 +426,7 @@ end @post "/api/v1/statistics/summaries" function(req) _statistics_guard() do - body = _statistics_body(req) - _reject_float_count_tokens(String(req.body)) + body = _statistics_body(req; reject_noninteger_tokens=true) policy = _statistics_policy(body) counts, labels, features, groups = _direct_count_input(body) result = _run_statistics(counts, labels, features, groups, policy) diff --git a/test/unit/test_differential.jl b/test/unit/test_differential.jl index d0f44e47..d7048188 100644 --- a/test/unit/test_differential.jl +++ b/test/unit/test_differential.jl @@ -1338,6 +1338,21 @@ end Differential.DM_METHODS end + @testset "Multinomial: the Wald p-value uses the normal tail" begin + for (t, expected) in ((0.0, 1.0), (0.25, 0.7236736098317631), + (0.5, 0.47950012218695348), (0.75, 0.28884436634648486), + (1.0, 0.15729920705028513), (1.25, 0.07709987174354177), + (1.5, 0.033894853524689274), (2.0, 0.0046777349810472654), + (2.5, 0.00040695201744495892), (3.0, 2.2090496998585438e-05), + (4.0, 1.541725790028002e-08), (5.0, 1.5374597944280351e-12), + (7.0, 4.1838256077794142e-23), (10.0, 2.0884875837625449e-45)) + @test CM._erfc(t) ≈ expected rtol = 1e-12 + end + @test CM._erfc(-0.5) ≈ 2.0 - CM._erfc(0.5) atol = 1e-15 + @test 0.0 < CM._erfc(20.0) < 1e-170 + @test CM._erfc(0.0) == 1.0 + end + @testset "Multinomial: coefficients, standard errors and likelihood" begin fit = CM.fit_multinomial(mn_counts, mn_design; reference=1) @test fit.converged @@ -1644,11 +1659,12 @@ end @test e isa ArgumentError @test occursin("is not fitted", sprint(showerror, e)) # Every taxon filtered out is an explicit error, not an empty table. + all_thin = [1 0; 2 3; 0 4; 5 0; + 1 2; 0 3; 4 0; 2 1] 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)) + differential_abundance(all_thin, samples8, ["a", "b"], groups8; + kw..., config=DifferentialConfig("tss", 1.0; + method="multinomial")) nothing catch err err diff --git a/test/unit/test_validated_statistics.jl b/test/unit/test_validated_statistics.jl index 7a7465d2..074370b4 100644 --- a/test/unit/test_validated_statistics.jl +++ b/test/unit/test_validated_statistics.jl @@ -105,6 +105,11 @@ end for token in ("1.0", "1e0") raw = """{"numeric_policy":{"mode":"exact_counts"},"sample_labels":["A"],"feature_labels":["f"],"counts":[[$token]]}""" @test _vs_post("/api/v1/statistics/summaries", raw; raw=true).status == 422 + @test _vs_post("/api/v1/statistics/summaries", + Vector{UInt8}(codeunits(raw)); raw=true).status == 422 + integer_raw = replace(raw, token => "1") + @test _vs_post("/api/v1/statistics/summaries", + Vector{UInt8}(codeunits(integer_raw)); raw=true).status == 200 end negative_string = merge(_VS_EXACT_BODY, Dict("persist" => false, "counts" => [["-1", 1], [1, 0], [1, 0]])) From 08d26b4fa25363c5762c8ffa4e4ecc813fec8168 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:17:13 +0000 Subject: [PATCH 3/3] fix(routes): normalise table name to String on POST /clade-cumulus Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- src/server/routes/clade_cumulus.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index daf1a31a..f7f81706 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -200,7 +200,7 @@ end return json_error(400, "invalid_json", "Request body must be valid JSON") end - table = strip(string(get(body, :table, "merged"))) + table = String(strip(string(get(body, :table, "merged")))) _valid_name(table) || return json_error(400, "invalid_table", "Invalid table name '$table'") mode_str = lowercase(strip(string(get(body, :mode, "combined"))))