Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
773eec1
feat(bremer): Bremer (decay) support via converse-constraint + pool
ms609 Jul 9, 2026
02a7f7e
fix(bremer): address red-team findings (scoring guard, reach, soundness)
ms609 Jul 9, 2026
952120e
feat(bremer): parallelise per-clade converse searches over a cluster
ms609 Jul 9, 2026
2845b22
fix(bremer): address self-review findings on the red-team fixes
ms609 Jul 9, 2026
c5f700b
fix(bremer): guard converse sector/prune-reinsert against rebuilding …
ms609 Jul 10, 2026
8a0f56b
fix(bremer): validate optimalScore, make censored attr consistent, st…
ms609 Jul 10, 2026
22e6646
fix(bremer): make the scoring-mismatch guard a reliable warning, not …
ms609 Jul 10, 2026
9ee1d3c
feat(bremer): record scoring provenance on MaximizeParsimony output; …
ms609 Jul 10, 2026
2cc6311
test(bremer): cover zero-clade early return and pool-eviction score a…
ms609 Jul 10, 2026
41556cd
test(bremer): close TA-3/TA-4 via a search-injection seam
ms609 Jul 10, 2026
b26f948
Merge cpp-search into bremer-support-calculation
ms609 Jul 10, 2026
855c199
fix(bremer): green the R CMD check (examples, spelling, docs, deps)
ms609 Jul 10, 2026
9a67c04
fix(bremer): make the runnable example cheap, seeded, and hang-proof
ms609 Jul 10, 2026
5387ec2
fix(bremer): run the example serially (nThreads = 1)
ms609 Jul 10, 2026
88cb280
fix(bremer): serialize the SuboptimalTrees example too (nThreads = 1)
ms609 Jul 11, 2026
ba61fea
fix(bremer): bound the pool example's search so --run-donttest can't …
ms609 Jul 11, 2026
808c9aa
ci(probe): open a null graphics device before running plot-bearing ex…
ms609 Jul 11, 2026
b6d09ae
ci(probe): test the bgs-kernel hypothesis; portable gtimeout; add win…
ms609 Jul 11, 2026
3d4d660
ci(probe): faithful one-process concatenated donttest repro with mark…
ms609 Jul 11, 2026
a6f61f6
ci(probe): localize the hang to a TreeLength sub-call; RNG vs C++-sta…
ms609 Jul 11, 2026
5a8e75c
ci(probe): per-pattern profile timing to localize the macOS profile hang
ms609 Jul 11, 2026
f255220
ci: remove the donttest-hang debugging probe (investigation resolved …
ms609 Jul 11, 2026
8104429
Merge branch 'cpp-search' into claude/bremer-support-calculation-857d5f
ms609 Jul 12, 2026
837d95c
Merge branch 'cpp-search' into claude/bremer-support-calculation-857d5f
ms609 Jul 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Imports:
fastmatch (>= 1.1.3),
graphics,
grDevices,
parallel,
Rcpp,
Rdpack (>= 0.7),
stats,
Expand All @@ -60,6 +61,7 @@ Suggests:
knitr,
MaxMin,
phangorn (>= 2.2.1),
pkgload,
PlotTools,
promises,
protoclust,
Expand Down Expand Up @@ -95,6 +97,8 @@ Config/testthat/edition: 3
Collate:
'AdditionTree.R'
'Bootstrap.R'
'Bremer.R'
'BremerParallel.R'
'CharacterHierarchy.R'
'ClusterStrings.R'
'Concordance.R'
Expand Down Expand Up @@ -122,6 +126,7 @@ Collate:
'recode_hierarchy.R'
'SPR.R'
'ScoreSpectrum.R'
'SuboptimalTrees.R'
'SuccessiveApproximations.R'
'TBR.R'
'TaxonInfluence.R'
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export(.NonDuplicateRoot)
export(.UniqueExceptHits)
export(AdditionTree)
export(BootstrapTree)
export(Bremer)
export(Carter1)
export(CharacterHierarchy)
export(CharacterLength)
Expand Down Expand Up @@ -111,6 +112,7 @@ export(SingleCharData)
export(SingleCharMorphy)
export(StepInformation)
export(StopUnlessBifurcating)
export(SuboptimalTrees)
export(Suboptimality)
export(SuccessiveApproximations)
export(SuccessiveWeights)
Expand Down
17 changes: 17 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# To integrate into 2.0.0 notes

- New `Bremer()` calculates Bremer (decay) support for each clade of a reference
tree. The default `method = "constraint"` runs a rigorous converse-constraint
search per clade -- the shortest tree forced to *lack* the clade -- giving
trustworthy decay indices with bounded memory. A fast, approximate
`method = "pool"` instead reads a pool of suboptimal trees (an upper bound,
right-censored at the sampling depth). Both engines match an exhaustive
brute-force oracle on small datasets, including under implied weights and
inapplicable ("bgs") data. Because each clade's constraint search is
independent, they can be distributed over a `parallel` cluster with `cl =`
for a near-linear speed-up on trees with many clades.

- New `SuboptimalTrees()` returns every tree the search retained within a given
number of steps of the optimum, each annotated with its parsimony score, for
landscape analysis. `MaximizeParsimony(collapse = FALSE)` now surfaces the
per-tree scores of the retained pool via a `scores` attribute (and a `score`
attribute on each tree), so `Suboptimality()` works on the result directly.

- `MaximizeParsimony()` now contracts zero-length (unsupported) branches into
polytomies by default (`collapse = TRUE`), deduplicating the returned trees on
the resulting collapsed topologies, à la TNT's "collapse zero-length
Expand Down
505 changes: 505 additions & 0 deletions R/Bremer.R

Large diffs are not rendered by default.

59 changes: 59 additions & 0 deletions R/BremerParallel.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Parallel fan-out for the converse-constraint Bremer engine.
#
# Each clade's converse-constraint search is fully independent of the others, so
# the dominant cost of `Bremer(method = "constraint")` -- N sequential
# `MaximizeParsimony()` searches -- parallelises cleanly ACROSS clades. It must
# parallelise across clades and NOT within a single search: every converse
# search runs serially (`nThreads = 1`) because the negative-constraint pool
# guard (`TreePool::set_forbidden`) lives on the serial search path only.
#
# `.BremerConverseScores()` runs the N per-clade tasks -- optionally over a
# user-supplied `parallel` cluster -- and collects their results. The per-clade
# task (`processFn`, i.e. `.BremerConstraint()`'s `processConverse`) runs one
# converse search, maps the engine's "no tree found" sentinel to NA and verifies
# the returned tree lacks the clade, so this helper stays agnostic to that logic
# and can be validated in isolation against the enumeration oracle.
#
# Determinism: each clade is seeded independently (seeds drawn once, in order,
# from the caller's RNG stream). Seeding per CLADE rather than per WORKER makes
# the result independent of how tasks are scheduled onto workers, so a parallel
# run reproduces a serial one under the same `set.seed()` -- serial
# `MaximizeParsimony()` is itself reproducible under `set.seed()`.
#
# @param n Number of clades (tasks).
# @param processFn `function(i)` returning the (scalar) Bremer contribution of
# clade `i` -- a length, or `NA_real_`. The closure carries the reference,
# dataset and scoring/search arguments.
# @param cl A `parallel` cluster whose workers have TreeSearch loaded, or `NULL`
# for a seeded serial fan-out.
# @param seeds Optional integer per-clade RNG seeds; drawn reproducibly from the
# caller's stream when `NULL`.
# @return A numeric vector of length `n`, one entry per clade.
.BremerConverseScores <- function(n, processFn, cl = NULL, seeds = NULL) {
if (n == 0L) {
return(numeric(0))
}
if (is.null(seeds)) {
seeds <- sample.int(.Machine$integer.max, n)
}

# One task per clade. Seed inside the task so the outcome depends only on the
# clade index, never on which worker (or in what order) runs it.
worker <- function(i) {
set.seed(seeds[[i]])
processFn(i)
}

scores <- if (is.null(cl)) {
lapply(seq_len(n), worker)
} else {
if (!inherits(cl, "cluster")) {
stop("`cl` must be a `parallel` cluster or NULL.")
}
parallel::parLapply(cl, seq_len(n), worker)
}

vapply(scores, function(s) {
if (length(s) == 0L) NA_real_ else as.numeric(s)[[1L]]
}, double(1))
}
152 changes: 152 additions & 0 deletions R/MaximizeParsimony.R
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,38 @@
}

# Internal helper: prepare constraint data for C++ engine.
# Build the forbidden-clade (negative / converse constraint) matrix for the
# C++ engine. A tree that displays any of these bipartitions will be rejected.
# @param negConstraint A `Splits` object, or anything `as.Splits()` accepts
# (e.g. a `phylo`), giving the clade(s) to forbid.
# @param dataset A phyDat whose names define the tip ordering.
# @return An integer n_neg x n_tips membership matrix (columns in dataset tip
# order), or NULL if there is nothing to forbid.
# @keywords internal
#' @importFrom TreeTools as.Splits
.PrepareNegativeConstraint <- function(negConstraint, dataset) {
if (is.null(negConstraint)) return(NULL)
tipLabels <- names(dataset)
if (!inherits(negConstraint, "Splits")) {
negConstraint <- as.Splits(negConstraint, tipLabels = tipLabels)
}
if (length(negConstraint) == 0L) return(NULL)

# as.logical(<Splits>) yields an n_splits x n_tips logical membership matrix.
membership <- as.logical(negConstraint)
if (is.null(dim(membership))) {
membership <- matrix(membership, nrow = 1L)
}
splitLabels <- attr(negConstraint, "tip.label")
colOrder <- match(tipLabels, splitLabels)
if (anyNA(colOrder)) {
stop("Forbidden-clade taxa do not match the dataset taxa.")
}
membership <- membership[, colOrder, drop = FALSE]
storage.mode(membership) <- "integer"
membership
}

# Returns a named list of constraint arguments (empty list if no constraint).
# @param constraint A phyDat, phylo, or NULL.
# @param dataset A phyDat whose names define the tip ordering.
Expand Down Expand Up @@ -568,12 +600,25 @@
#' collapse, so an enforced-but-unsupported clade (a zero-length branch) stays
#' visible (a constraint encodes external evidence the matrix does not
#' capture); unsupported non-constraint branches still collapse.
#' @param .negativeConstraint Internal. A splits object (or `NULL`) naming
#' clades that returned trees must *not* display; used by [`Bremer()`] to run
#' converse-constraint searches. Not intended for direct use.
#' @param ... Backward compatibility.
#'
#' @return A `multiPhylo` object containing the best tree(s) found, with
#' attributes:
#' \describe{
#' \item{`score`}{Best parsimony score.}
#' \item{`scoring`}{A list recording the scoring conditions the score is
#' optimal under (`concavity`, `inapplicable`, ...), so a saved score
#' remains interpretable. [`Bremer()`][Bremer] checks it against its own
#' scoring arguments and warns on a mismatch.}
#' \item{`scores`}{Present only when `collapse = FALSE`: a numeric vector of
#' the parsimony score of each returned tree, aligned with the returned
#' `multiPhylo` (the same values are attached as a `score` attribute on
#' each individual tree). With `poolSuboptimal > 0` this exposes the
#' retained suboptimal pool for landscape analysis (see
#' [`SuboptimalTrees()`], [`Suboptimality()`]).}
#' \item{`replicates`}{Number of replicates completed.}
#' \item{`hits_to_best`}{Number of independent discoveries of the best
#' score.}
Expand Down Expand Up @@ -641,6 +686,7 @@ MaximizeParsimony <- function(
inapplicable = "bgs",
hsj_alpha = 1.0,
constraint,
.negativeConstraint = NULL,
strategy = "auto",
maxReplicates = 96L,
targetHits = NULL,
Expand All @@ -664,6 +710,16 @@ MaximizeParsimony <- function(
# the only reliable read.
userSetReps <- !missing(maxReplicates)

# Capture the scoring conditions the result is optimal under, BEFORE any
# normalization (e.g. inapplicable aliasing, profile -> Inf). Attached to the
# returned trees so a saved optimal score is interpretable -- a bare score is
# meaningless without knowing the criterion it was found under, and Bremer()
# checks this signature against its own scoring arguments.
scoringSignature <- .ScoringSignature(
concavity = concavity, extended_iw = extended_iw, xpiwe_r = xpiwe_r,
xpiwe_max_f = xpiwe_max_f, hierarchy = hierarchy,
inapplicable = inapplicable, hsj_alpha = hsj_alpha)

# --- Set targetHits default if not provided ---
if (is.null(targetHits)) {
targetHits <- max(10L, as.integer(NTip(dataset) / 5))
Expand Down Expand Up @@ -950,6 +1006,27 @@ MaximizeParsimony <- function(
cli_alert_info("Constraint: {nrow(consArgs$consSplitMatrix)} split{?s}")
}

# Negative (converse) constraints: forbidden clades. Internal argument used
# by Bremer() -- the returned trees must NOT display any supplied split.
if (!is.null(.negativeConstraint)) {
# The negative-constraint soundness backstop (TreePool::set_forbidden) is
# wired only into the serial search path; the parallel pool has no such
# guard, so a forbidden clade could slip into the result under nThreads > 1.
# Force serial (Bremer() already does; this protects any other caller).
if (!identical(as.integer(nThreads), 1L)) {
warning("Negative (converse) constraints are supported only in serial ",
"search; forcing `nThreads = 1`.")
nThreads <- 1L
}
negMatrix <- .PrepareNegativeConstraint(.negativeConstraint, dataset)
if (!is.null(negMatrix)) {
consArgs[["consNegSplitMatrix"]] <- negMatrix
if (verbosity > 0L) {
cli_alert_info("Forbidding {nrow(negMatrix)} clade{?s}")
}
}
}

# --- Profile parsimony: extract info_amounts ---
profileArgs <- list()
if (useProfile) {
Expand Down Expand Up @@ -1041,6 +1118,11 @@ MaximizeParsimony <- function(
resultTrees <- list()
}
nTopologies <- result$n_topologies
# Per-tree scores aligned with result$trees; surfaced only in the collapse =
# FALSE path (the collapse = TRUE path keeps only best-score trees, so their
# scores are all result$best_score and the collapsed/deduped list no longer
# aligns with result$scores). NULL here -> no "scores" attribute is attached.
perTreeScores <- NULL
if (isTRUE(collapse) && length(resultTrees) > 0L) {
# Contract zero-length (unsupported) branches into polytomies, à la TNT's
# "collapse zero-length branches" -- done entirely in C++ (ts_collapse_pool)
Expand Down Expand Up @@ -1089,9 +1171,26 @@ MaximizeParsimony <- function(
# C++ edge order may differ from template; renumber to valid preorder
Renumber(tr)
})
# Surface the per-tree parsimony scores the engine returns aligned with
# result$trees. Attaching a "score" attribute to each tree lets
# Suboptimality(), SuboptimalTrees() and Bremer(method = "pool") read the
# suboptimal pool's landscape directly without re-scoring.
perTreeScores <- result$scores
if (length(perTreeScores) == length(outTrees)) {
outTrees <- Map(function(tr, sc) {
attr(tr, "score") <- sc
tr
}, outTrees, perTreeScores)
} else {
perTreeScores <- NULL
}
}
if (length(outTrees) == 0L) {
outTrees <- list(treeTpl)
if (!isTRUE(collapse)) {
attr(outTrees[[1]], "score") <- result$best_score
perTreeScores <- result$best_score
}
}

# --- Output ---
Expand All @@ -1114,6 +1213,8 @@ MaximizeParsimony <- function(
structure(
outTrees,
score = result$best_score,
scoring = scoringSignature,
scores = perTreeScores,
replicates = result$replicates,
hits_to_best = result$hits_to_best,
n_topologies = nTopologies,
Expand All @@ -1129,6 +1230,57 @@ MaximizeParsimony <- function(
)
}

# Canonical scoring identity: the arguments that define the optimality criterion,
# normalized so aliases compare equal. Recorded on a MaximizeParsimony() result
# (attr "scoring") and checked by Bremer() -- a saved optimal score is only
# meaningful alongside the conditions it was optimal under.
.ScoringSignature <- function(concavity = Inf, extended_iw = TRUE, xpiwe_r = 0.5,
xpiwe_max_f = 5, hierarchy = NULL,
inapplicable = "bgs", hsj_alpha = 1.0) {
inap <- tolower(as.character(inapplicable)[[1]])
if (inap == "brazeau") inap <- "bgs" # documented alias for the bgs kernel
list(
concavity = if (is.numeric(concavity)) concavity else
tolower(as.character(concavity)[[1]]),
extended_iw = isTRUE(extended_iw),
xpiwe_r = as.double(xpiwe_r),
xpiwe_max_f = as.double(xpiwe_max_f),
# Presence only: comparing full hierarchy objects is heavy and brittle, and a
# present-vs-absent difference is the practically important mismatch.
hierarchy = !is.null(hierarchy),
inapplicable = inap,
hsj_alpha = as.double(hsj_alpha)
)
}

# TRUE if two scoring signatures denote the same optimality criterion.
.ScoringSignatureMatch <- function(a, b, tol = 1e-8) {
numMatch <- function(x, y) {
if (is.numeric(x) && is.numeric(y)) {
(is.infinite(x) && is.infinite(y) && sign(x) == sign(y)) ||
(is.finite(x) && is.finite(y) && abs(x - y) <= tol)
} else {
identical(as.character(x), as.character(y))
}
}
numMatch(a[["concavity"]], b[["concavity"]]) &&
identical(a[["extended_iw"]], b[["extended_iw"]]) &&
isTRUE(abs(a[["xpiwe_r"]] - b[["xpiwe_r"]]) <= tol) &&
isTRUE(abs(a[["xpiwe_max_f"]] - b[["xpiwe_max_f"]]) <= tol) &&
identical(a[["hierarchy"]], b[["hierarchy"]]) &&
identical(a[["inapplicable"]], b[["inapplicable"]]) &&
isTRUE(abs(a[["hsj_alpha"]] - b[["hsj_alpha"]]) <= tol)
}

# Compact human-readable rendering of a scoring signature for diagnostics.
.DescribeScoring <- function(sig) {
cn <- sig[["concavity"]]
weight <- if (is.numeric(cn) && is.infinite(cn)) "equal weights" else
paste0("concavity = ", if (is.numeric(cn)) signif(cn, 4) else cn)
paste0(weight, ", inapplicable = \"", sig[["inapplicable"]], "\"")
}


#' Launch tree search graphical user interface
#'
#' Opens a "shiny" app for interactive parsimony tree search and results
Expand Down
Loading
Loading