Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# To integrate into 2.0.0 notes

- New `SearchControl()` parameter `enumMaxTrees`: a retention ceiling applied to
the post-search MPT-enumeration phase alone. `0` (the default)
keeps `poolMaxSize` throughout, so behaviour is unchanged unless you set it.
From `effort` rung 5 the ladder now doubles it each notch alongside the
replicate budget and the hit target, so asking for more effort also asks for a
more complete tree set — previously a search could be given eight times the
budget and still return only the default 100 trees.

`poolMaxSize` is deliberately **not** scaled, and the split is the point.
During the replicate loop the pool cap is not a ceiling on what is returned but
the size of the working set the search reads: fusing draws its donors from the
whole pool, conflict-guided sectorial search reads the pool's split
frequencies once per replicate, and `consensusConstrain` reads its consensus
splits. Raising it therefore changes which trees the search *visits*, so the
anytime-dominance argument that licenses raising `maxReplicates` — a higher cap
only appends later replicates and can never delay an earlier improvement — does
not transfer to it. Once the loop is over the pool is pure output, and there
the same argument does hold, which is why the ceiling is raised at that point
instead. Raising `poolMaxSize` yourself still works and still governs both
phases; `enumMaxTrees` is the side-effect-free way to keep more trees.
- `constraint` now binds the trees `MaximizeParsimony()` returns, at three
boundaries where it did not. A starting tree supplied through `tree` was
never checked against the constraint; because a constrained search rejects
Expand Down
59 changes: 52 additions & 7 deletions R/MaximizeParsimony.R
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,32 @@
# through .IwRatchetDepth()'s targetHits/defaultHits escalation (capped at
# .iwRatchetMaxCycles), which is a genuine reach lever the equal-weights
# measurement above cannot see.
hitMultiplier = if (rung <= 4L) 1L else as.integer(2^(rung - 4L))
hitMultiplier = if (rung <= 4L) 1L else as.integer(2^(rung - 4L)),
# `enumMaxTrees` multiplier: the size of the returned MPT set, relative to
# `poolMaxSize`. Doubling in step with the other two knobs, from rung 5, so
# that one notch keeps meaning "roughly twice the work" on this axis too.
#
# This scales the ENUMERATION ceiling only, never `poolMaxSize` itself, and
# the distinction is the whole point. During the replicate loop the pool cap
# is the size of the working set the search reads -- fuse donors are the
# entire pool (uncapped, and taken under the pool mutex on the parallel
# path), conflict-guided sector selection reads the pool's split frequencies
# once per replicate, and `consensusConstrain` reads its consensus splits --
# so scaling it would change which trees the search VISITS. The
# anytime-dominance argument that licenses raising `maxReplicates` above
# therefore does NOT transfer to `poolMaxSize`: a bigger pool can delay
# every later improvement rather than merely appending to the result.
# After the loop, the pool is pure output and a bigger ceiling can only
# append equal-score topologies, so the same argument DOES hold there.
#
# It is bounded in practice without needing a cap: enumeration shares the
# `maxSeconds * enumTimeFraction` reserve, and its loop exits as soon as the
# pool fills, so an over-generous ceiling costs enumeration time, never a
# worse tree. As with `hitMultiplier` the doubling shape is an OPERATING
# POINT rather than a measurement -- what is measured is that the July 2026
# 182-tip runs returned exactly `poolMaxSize` trees in all four analyses,
# i.e. the ceiling bound the answer rather than the MPT count doing so.
enumMultiplier = if (rung <= 4L) 1L else as.integer(2^(rung - 4L))
)
}

Expand Down Expand Up @@ -632,9 +657,13 @@
#' most-parsimonious tree (\acronym{MPT}) is recovered.
#' The size of the returned set is bounded by, in order:
#' \enumerate{
#' \item **`poolMaxSize`** (default `100`) — a hard ceiling on the number of
#' trees retained. Raise it (via [`SearchControl()`]) to keep more MPTs;
#' with the default you will never see more than 100.
#' \item **`enumMaxTrees`**, falling back to **`poolMaxSize`** (default `100`)
#' when `enumMaxTrees` is `0` — a hard ceiling on the number of trees
#' retained; with the default you will never see more than 100. Prefer
#' raising `enumMaxTrees` (via [`SearchControl()`]): it applies only once the
#' search is over, so it cannot alter which trees are visited, whereas
#' `poolMaxSize` also sizes the working set that fusing and sectorial search
#' read. From `effort` rung 5 the ladder raises `enumMaxTrees` for you.
#' \item **MPT-enumeration time.** After the main search, a TBR plateau walk
#' enumerates equal-score neighbours of each pool tree, within a time
#' reserve of `maxSeconds * enumTimeFraction`. If this phase times out it
Expand Down Expand Up @@ -798,9 +827,13 @@
#' TBR-disconnected islands that random restarts alone miss.}
#' \item{4, `large`}{`thorough`'s provisioning with `maxReplicates` raised
#' to 500, to suit the higher per-replicate cost of big trees.}
#' \item{5 and up}{`thorough`'s provisioning, with both the replicate budget
#' and the hit target doubling each notch (1000, 2000, 4000 ...
#' replicates), so that one notch always means roughly twice the work.
#' \item{5 and up}{`thorough`'s provisioning, with the replicate budget, the
#' hit target and the \acronym{MPT}-enumeration ceiling (`enumMaxTrees`)
#' all doubling each notch (1000, 2000, 4000 ... replicates), so that one
#' notch always means roughly twice the work. `poolMaxSize` is
#' deliberately *not* scaled: it sizes the working set that fusing and
#' sectorial search read during the run, so raising it would change which
#' trees are visited rather than only how many are returned.
#' There is no policy ceiling: extra replicates cannot cost reach, only
#' wall, which is what you asked to spend. The ladder stops only at rung
#' 26, where the replicate budget outgrows R's integer type.}
Expand Down Expand Up @@ -1230,6 +1263,18 @@ MaximizeParsimony <- function(
targetHits <- as.integer(targetHits * spec[["hitMultiplier"]])
}

# Rung-scaled MPT-enumeration ceiling (rung 5 and up). Keyed off the
# POST-merge `poolMaxSize`, so a user who raised the pool gets a
# proportionally larger returned set rather than having their value
# ignored. Skipped when the user named `enumMaxTrees` themselves.
# `poolMaxSize` is deliberately not touched -- see .RungSpec().
if (!("enumMaxTrees" %in% union(names(controlDots),
attr(control, "explicit"))) &&
spec[["enumMultiplier"]] > 1L) {
control[["enumMaxTrees"]] <-
as.integer(control[["poolMaxSize"]] * spec[["enumMultiplier"]])
}

# Implied-weights ratchet depth. Under implied weights the optimum often
# sits in a small basin at fine score resolution, separated from an
# easy-to-find near-optimum by a fraction of a step; character reweighting
Expand Down
30 changes: 27 additions & 3 deletions R/SearchControl.R
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,22 @@
#' within each replicate, after TBR polish. This approximates TNT's
#' within-replicate fusing pattern. Default: `FALSE`.
#' @param poolMaxSize Integer; maximum trees retained in the pool.
#' This governs the pool throughout the search, so it is not only a ceiling on
#' the trees returned: fuse draws its donors from the pool, and conflict-guided
#' sector selection and `consensusConstrain` both read it. Raising it
#' therefore changes the trajectory as well as the output; to keep more
#' most-parsimonious trees without that side effect, use `enumMaxTrees`.
#' @param poolSuboptimal Numeric; retain trees that are this many steps
#' worse than the best tree. 0 (default) keeps only optimal trees.
#' @param enumMaxTrees Integer; retention ceiling applied to the
#' \acronym{MPT}-enumeration phase alone, which runs after the last replicate.
#' `0` (default) keeps `poolMaxSize` throughout, reproducing the behaviour
#' before this argument existed. Because enumeration happens once the search
#' is over, a larger ceiling here only appends further equal-score topologies:
#' it cannot change which trees the search visits. This is the knob
#' [`MaximizeParsimony()`]'s `effort` scales; `poolMaxSize` is deliberately
#' left alone. Values below `poolMaxSize` are ignored (the ceiling is only
#' ever raised).
#' @param consensusStableReps Integer; stop when the strict consensus of
#' best-score pool trees has been unchanged for this many consecutive
#' replicates.
Expand Down Expand Up @@ -377,7 +391,8 @@ SearchControl <- function(
# sampling from {Wagner-random, Wagner-Goloboff, Wagner-entropy,
# random-tree, pool-ratchet, pool-NNI-perturb}. Overrides wagnerBias.
adaptiveStart = FALSE,
enumTimeFraction = 0.1
enumTimeFraction = 0.1,
enumMaxTrees = 0L
) {
# Record which fields the caller set explicitly (by name or position;
# `match.call()` normalises positional args to their names). This lets
Expand All @@ -398,6 +413,14 @@ SearchControl <- function(
stop("`", .p, "` must be a single positive integer")
}
}
# `enumMaxTrees` takes 0 ("follow poolMaxSize") but never a negative: the
# kernel only ever RAISES the ceiling, so a negative would be silently inert
# rather than reported, hiding a sign typo.
.emt <- as.integer(enumMaxTrees)
if (length(.emt) != 1L || is.na(.emt) || .emt < 0L) {
stop("`enumMaxTrees` must be a single non-negative integer ",
"(0 follows `poolMaxSize`)")
}
# `stopPatience` is a replicate count, so a negative value is meaningless; the
# kernel treats anything <= 0 as "off", which would silently ignore a typo
# such as -20 rather than honouring the obvious intent.
Expand Down Expand Up @@ -498,7 +521,8 @@ SearchControl <- function(
annealTEnd = as.double(annealTEnd),
annealMovesPerPhase = as.integer(annealMovesPerPhase),
adaptiveStart = as.logical(adaptiveStart),
enumTimeFraction = as.double(enumTimeFraction)
enumTimeFraction = as.double(enumTimeFraction),
enumMaxTrees = .emt
),
class = "SearchControl",
explicit = .explicit
Expand Down Expand Up @@ -530,7 +554,7 @@ print.SearchControl <- function(x, ...) {
"sectorCombStarts", "sectorFuseRounds",
"postRatchetSectorial"),
"Fuse/Pool" = c("fuseInterval", "fuseAcceptEqual", "intraFuse",
"poolMaxSize", "poolSuboptimal"),
"poolMaxSize", "poolSuboptimal", "enumMaxTrees"),
"Stopping" = c("consensusStableReps", "perturbStopFactor", "stopPatience",
"adaptiveLevel",
"consensusConstrain", "adaptiveStart",
Expand Down
4 changes: 3 additions & 1 deletion R/ts-driven-compat.R
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ ts_driven_search <- function(
fuseAcceptEqual = FALSE,
poolMaxSize = 100L,
poolSuboptimal = 0.0,
enumMaxTrees = 0L,
maxSeconds = 0.0,
verbosity = 0L,
min_steps = integer(0),
Expand Down Expand Up @@ -140,7 +141,8 @@ ts_driven_search <- function(
pruneReinsertDrop = as.double(pruneReinsertDrop),
pruneReinsertSelection = as.integer(pruneReinsertSelection),
adaptiveStart = as.logical(adaptiveStart),
enumTimeFraction = as.double(enumTimeFraction)
enumTimeFraction = as.double(enumTimeFraction),
enumMaxTrees = as.integer(enumMaxTrees)
)

# Anneal config: fold into SearchControl if provided
Expand Down
20 changes: 14 additions & 6 deletions man/MaximizeParsimony.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 18 additions & 2 deletions man/SearchControl.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/ts_driven.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1436,6 +1436,10 @@ DrivenResult driven_search(TreePool& pool, DataSet& ds,
// one tree, so different TBR-connected islands are only discovered if
// different replicates landed on them. We enumerate from each seed
// tree to explore its island, stopping when the pool is full.
//
// The retention ceiling is raised HERE, not before the loop: past this
// point the pool is pure output, so a larger cap only appends topologies.
pool.raise_max_size(params.enum_pool_max_size);
if (pool.size() > 0 && pool.size() < pool.max_size) {
TBRParams tp;
tp.accept_equal = true;
Expand Down
11 changes: 11 additions & 0 deletions src/ts_driven.h
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,17 @@ struct DrivenParams {
int pool_max_size = 100;
double pool_suboptimal = 0.0; // 0 = keep only optimal

// Retention ceiling for the MPT-enumeration phase alone; 0 means "no separate
// ceiling", i.e. keep `pool_max_size` throughout, which is the default and is
// byte-identical to the behaviour before this field existed.
//
// Split from `pool_max_size` because only this half is safe to scale with
// search effort: during the replicate loop the cap is the size of the working
// set that fuse, sector selection and consensusConstrain all read, so raising
// it changes the search trajectory; after the loop it is purely how many
// equal-score topologies get returned. See TreePool::raise_max_size().
int enum_pool_max_size = 0;

// Timeout (seconds). 0 or negative = no timeout.
double max_seconds = 0.0;

Expand Down
5 changes: 5 additions & 0 deletions src/ts_parallel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -621,6 +621,11 @@ DrivenResult parallel_driven_search(
return false;
};

// Raise the retention ceiling for enumeration only. `pool_out` is already
// the OUTPUT pool -- the shared search pool keeps its own `pool_max_size`, so
// fuse donors and sector selection are untouched by this.
pool_out.raise_max_size(params.enum_pool_max_size);

if (pool_out.size() > 0 && pool_out.size() < pool_out.max_size) {
TBRParams tp;
tp.accept_equal = true;
Expand Down
15 changes: 15 additions & 0 deletions src/ts_pool.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ class TreePool {
// Get all entries.
const std::vector<PoolEntry>& all() const { return entries_; }

// Raise the retention ceiling; never lowers it, so entries already held can
// not be orphaned above the cap.
//
// Call this ONLY at the MPT-enumeration phase, where the pool is pure OUTPUT.
// During the replicate loop `max_size` is not a ceiling on what is *returned*
// but the size of the working set the search reads: fuse donors are
// `all().size()` (uncapped, and held under the pool mutex on the parallel
// path), conflict-guided sector selection calls compute_split_frequencies()
// over best-score entries once per replicate, and consensusConstrain calls
// extract_consensus_splits(). Raising it mid-loop would therefore change what
// the search DOES, not merely how much it keeps -- which is why the effort
// ladder raises it here, after the loop has finished, rather than scaling
// `poolMaxSize` itself.
void raise_max_size(int n) { if (n > max_size) max_size = n; }

// Evict entries worse than best_score + suboptimal.
void evict();

Expand Down
4 changes: 4 additions & 0 deletions src/ts_rcpp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1655,6 +1655,10 @@ static void unpack_search_control(List ctrl, ts::DrivenParams& params) {
params.intra_fuse = as<bool>(ctrl["intraFuse"]);
params.pool_max_size = as<int>(ctrl["poolMaxSize"]);
params.pool_suboptimal = as<double>(ctrl["poolSuboptimal"]);
// Absent in a control list built by an older caller: treat as 0 ("no separate
// enumeration ceiling") rather than letting as<int>() throw on R_NilValue.
params.enum_pool_max_size = ctrl.containsElementNamed("enumMaxTrees")
? as<int>(ctrl["enumMaxTrees"]) : 0;

// Stopping / adaptive
params.consensus_stable_reps = as<int>(ctrl["consensusStableReps"]);
Expand Down
Loading
Loading