fix: reject non-binary trees at the TreeState boundary - #50
Open
ms609 wants to merge 2 commits into
Open
Conversation
This was referenced Aug 4, 2026
TreeState::init_from_edge derives n_tip, n_internal and n_node from the edge count alone, which identifies a tree only when it is binary. A multifurcating edge list broke that derivation in both parities: an odd edge count left the topology arrays one element short, so parent[] and left[]/right[] were written out of bounds, while an even one kept the indices in range but rooted the tree on a real tip, leaving a one-element postorder whose downpass read prelim.data() - total_words. Either way the caller got a plausible number instead of an error, and the number varied between identical calls; a polytomous startEdge segfaulted. Refuse the edge list at that boundary rather than at each of the R entry points that reach it: edge_list_is_binary() checks the shape from the edge arrays alone, before anything is written, and init_from_edge throws for Rcpp to forward. build_topology_tree() clones the same derivation for the least-squares path and gets the same check. ts_driven_search() screens start trees with the predicate on the main thread, since an uncaught throw on a parallel worker would terminate the session. TreeLength.list(), .CheckTreeCharLen(), TreeScore() and EdgeListScore() gain the R-level check so the message matches the one TreeLength.phylo() has always given. FastCharacterLength() is left unchecked, as documented; the kernel now gives it the same message. The Shiny app scores every tree it displays, so it now searches with collapse = FALSE. Also extends the T-261 zeroing audit in reset_states() to name the collapse kernels, whose whole-row memcmps read words no pass writes, and to name every path that zero-fills the state arrays rather than only init_from_edge. The T-382 one-sidedness comment keeps its original reasoning for prelim -- a tip sibling always carries real states, so a ratchet-zeroed block makes equality harder -- and gains the down2 / subtree_actives case, where the words really are always zero. Fixes #16 Fixes #24 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ms609
force-pushed
the
feature/multifurc-oob
branch
from
August 4, 2026 17:02
6c375cd to
7c8c3ab
Compare
ms609
enabled auto-merge
August 5, 2026 02:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #16
Fixes #24
#16 (T-400) — the defect
TreeState::init_from_edgederivesn_tip = (n_edge / 2) + 1, which identifies a tree only when it is binary. Both parities broke, differently:n_edge—n_node_derived == n_edge, one short:parent[c] = pwrites oneintpast the end, andpi = p - n_tipreachesn_internal, makingleft[pi]/right[pi]out-of-bounds writes.n_edge— indices stay in range, butn_tip_derived < nfor every non-binary tree, soparent[n_tip] = n_tiproots the tree on a real tip.build_postorderthen yields a one-element postorder andts_fitch.cppformsprelim.data() - total_words— the score is read from memory before the buffer.The fix
Refused at the kernel boundary, not per caller.
ts::edge_list_is_binary()checks the shape from the edge arrays alone, before anything is written: even edge count >= 2, every parent an internal index, every child in range and not the root, each child claimed once, each internal claiming at most two children. Sincen_edge == 2 * n_internalby construction, "at most two" forces "exactly two".init_from_edgethrowsstd::invalid_argumentcarrying the message "treemust be binary", which Rcpp forwards as an R error at every export boundary — so all 24 kernel entry points, including the exports with no R caller, are fixed by one check.Two call sites need more than that:
build_topology_tree()(src/ts_rcpp.cpp:271) clones the same derivation for the least-squares path. The issue listed it as "do not touch" becauseR/LeastSquares.R:64guards withape::is.binary, but that guard is at the R level only and the C++ hole is reachable fromTreeSearch:::ts_ls_fit. Its own header documents the identical rooted-binary convention, so it now uses the same predicate.startEdgeingestion (src/ts_rcpp.cpp) is screened on the main thread, becauseinit_from_edgealso runs on astd::threadworker where an uncaught throw would terminate the session rather than raise an R error.R-level guards were added to
TreeLength.list(),.CheckTreeCharLen(),TreeScore()andEdgeListScore()so users get the same messageTreeLength.phylo()has always given.FastCharacterLength()is deliberately left unchecked — its documentation promises no checks — and the kernel now gives it the same message anyway.The bundled Shiny app scores every tree it displays and filters the pool on those scores, so it now searches with
collapse = FALSE. It was already failing today whenever the returned pool was heterogeneous ("Trees have different numbers of edges") and silently displaying wrong numbers when it was not.Not implemented: polytomy scoring. Upstream
ms609/TreeSearch#259asks for that as an enhancement and asserts as background that the API already refuses; this is a bug inside today's contract, and ms609#259 stays open.#24 (T-411) — comment only
reset_states' T-261 audit coveredscore_tree/fitch_na_scoreonly. The collapse kernels are a third consumer: they compare whole rows bymemcmp, spanning words no pass writes. The audit now names them, and names every path that zero-fills the state arrays —assignininit_from_edge,ts_sector.cppandts_constraint.cpp;resizeon a freshTreeState's empty vectors ints_fuse.cpp— rather than onlyinit_from_edge.The T-382 one-sidedness comment is corrected, but not in the direction the issue proposed. The issue holds that the stale words are zero and so make equality easier. That is false for condition 3:
load_tip_statesmemcpys every word for every tip regardless ofactive_mask, andperturb_zeronever touchesds.tip_states, so a tip sibling always carries its real states while its parent holds zeros or stale values. Equality is made harder, exactly as the original comment said — its reasoning is restored. The "always zero, always equal" case is real only for conditions 4-5, wheredown2/subtree_activesare never written for non-NA blocks; that is where it now lives.Regression tests
tests/testthat/test-ts-t400-multifurcating-guard.R(Tier 2). Pre-fix 16 failures, post-fix 41 pass, 0 skip — verified against temp-library installs oforigin/cpp-searchand of this branch, notload_all.Pre-fix output:
Line 68 is the determinism test — the symptom the issue reports as
12, 9, 12, 12, 12.The
startEdgetest does not merely fail pre-fix, it takes the session down:Post-fix the same call raises "
treemust be binary", on both the serial and thenThreads = 2path; a binarystartEdgeis still accepted on both.Review
Three independent
external-reviewerlenses (conventions, cold bug-scan, history/blame). Findings applied: the second derivation clone, the T-382 direction (above), the four zeroing sites, an over-claiming header (edge_list_is_binaryproves a degree spectrum, not tree validity — a cycle unreachable from the root still passes, andbuild_postorder's T-327 backstop catches that), the Shiny app, NEWS scope, and the untestedstartEdgepre-check.Refuted after measurement, so unchanged:
MaximizeParsimony(inapplicable = "xform")now errors on its own default output" (rated a blocker).compute_collapsed_flagsreturns all-zero flags forHSJ/XFORM(T-330), so those trees are never collapsed. Measured on Vinther2008:Nnode 22on 23 tips — fully binary — score 79. The same reasoning covers thevignettes/search-algorithm.Rmdand?MaximizeParsimonypromises thatTreeLength()reproduces the reported score, which are scoped to those two modes and remain true.profile.Rmdsearch chunks were run against both builds: identical output, trees binary.ape::checkValidPhylo()also treats a root other thannTip + 1as fatal, and pre-fix such a tree was mis-scored regardless.Checks
Rscript .claude/tools/compile-attrs.RandRscript check_init.R— arg counts match; no new Rcpp export (edge_list_is_binaryis plain C++), soTreeSearch-init.cis untouched.spelling::spell_check_package(vignettes = TRUE)— clean, using the invocationtests/spelling.Ruses.man/is untouched.src/Makevars.win,.oor.dllleft behind.vignettes/search-algorithm.Rmdis not triggered.CI status
ubuntu-arm64is green — fullR CMD check,Status: OK,FAIL 0 | WARN 4 | SKIP 10 | PASS 11754(run 30928649665). The 4 warnings are pre-existing.windowsfails in dependency setup —Could not solve package dependencies: MaxMin: Can't find package called MaxMin— before any code is compiled. This is not from this branch:cpp-searchitself failed identically (run 30909722952), as did every other branch that afternoon. #45 fixes it. This branch should be re-checked once that merges.Known gaps, deliberately not addressed here
?MaximizeParsimony'scollapseparameter does not mention that collapsed trees cannot be scored. Adding it means a roxygen change andman/regeneration; NEWS carries the guidance for now.ts_compute_splits(src/ts_rcpp.cpp:1131) hand-derives node counts from a caller-suppliedn_tipand is likewise unguarded — a smaller instance of the same family, with no polytomy-reachable R caller.Merge note
A sibling chip edits
src/ts_rcpp.cpp:1829-1949andsrc/ts_collapsed.cpp:24-25, 153-156. This branch touches different regions of both files; nothing outside its own hunks was reformatted or reordered.