Skip to content

feat(effort): scale the MPT-enumeration ceiling, not poolMaxSize - #285

Open
ms609 wants to merge 1 commit into
cpp-searchfrom
feature/enum-pool-ceiling
Open

feat(effort): scale the MPT-enumeration ceiling, not poolMaxSize#285
ms609 wants to merge 1 commit into
cpp-searchfrom
feature/enum-pool-ceiling

Conversation

@ms609

@ms609 ms609 commented Aug 6, 2026

Copy link
Copy Markdown
Owner

effort doubled the replicate budget and the hit target from rung 5, but not the
size of the tree set a search returns — so a run could be given 8× the budget and
still return the default 100 trees, the ceiling bounding the answer rather than
the MPT count doing so. Observed on four 182-tip analyses, every one of which
returned exactly poolMaxSize trees.

Why not just scale poolMaxSize?

Because during the replicate loop that cap is not a ceiling on what is returned
— it is the size of the working set the search reads:

  • tree_fuse takes its donors from the whole pool: uncapped, max_rounds = 10,
    and on the parallel path held under the pool mutex, where one slow fuse stalls
    every worker. thorough/large set fuseInterval = 2.
  • conflict-guided sectorial search calls compute_split_frequencies() over
    best-score entries once per replicate;
  • consensusConstrain calls extract_consensus_splits();
  • and the cap itself sets enumeration depth via
    tp.max_hits = max(10, (max_size - size) * 2).

So raising it changes which trees the search visits. The anytime-dominance
argument that licenses raising maxReplicates — "a higher cap only appends later
replicates and never delays 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.

The change

Split the knob rather than scaling the wrong half:

  • New SearchControl(enumMaxTrees = ): a retention ceiling applied to the
    post-search MPT-enumeration phase alone. 0 (the default) keeps poolMaxSize
    throughout, so the shipped default is unchanged.
  • .RungSpec() gains enumMultiplier, doubling from rung 5 in step with the
    other two budgets. An explicit enumMaxTrees outranks the ladder, exactly as
    an explicit targetHits already does, and no preset sets it.
  • TreePool::raise_max_size() only ever raises, so entries already held cannot
    be orphaned above the cap. Applied at all three enumeration sites: both in
    ts_driven.cpp (the second inherits the raise, which is unconditional) and the
    parallel one, which already operates on the extracted output pool, so the
    shared search pool's cap is untouched by construction.
  • ts_rcpp.cpp reads the field defensively, so a control list built by an older
    caller is treated as 0 rather than throwing on R_NilValue.

It needs no policy cap of its own: enumeration shares the
maxSeconds * enumTimeFraction reserve and its loop exits as soon as the pool
fills, so an over-generous value costs enumeration time, never a worse tree.

As with hitMultiplier, the doubling shape is an operating point, not a fitted
constant
. What is measured is only that the ceiling — not the MPT count — bound
the returned set in all four 182-tip analyses.

Tests

919 assertions pass, 0 failures, across test-MaximizeParsimony-features.R,
test-ts-pool.R, test-ts-strategy.R, test-ts-driven.R,
test-ts-parallel.R, test-ts_collapse_pool.R and test-SearchControl.R.

New cases: the ladder scales enumMultiplier at the same rate as the other
knobs and leaves rungs 1–4 alone; no preset carries a value and an explicit one
survives the merge as explicit; 0 is legal but a negative is rejected; and an
end-to-end case that fails if the field is dropped anywhere along
SearchControl → ctrl list → DrivenParamsTreePool::raise_max_size(),
which also asserts that raising the ceiling leaves the score unchanged.

Roxygen regenerated only man/SearchControl.Rd and man/MaximizeParsimony.Rd.


Note on authorship: this was written to be committed by ms609-agent (the commit
is attributed to it) so that it would be reviewable, but that account is
currently blocked, so the PR itself is opened by @ms609 at their request. It
therefore cannot be self-approved.

🤖 Generated with Claude Code

`effort` doubled the replicate budget and the hit target from rung 5, but not
the size of the tree set a search returns -- so a run could be given 8x the
budget and still return the default 100 trees, the ceiling bounding the answer
rather than the MPT count doing so (observed on four 182-tip analyses, all of
which returned exactly `poolMaxSize` trees).

Scaling `poolMaxSize` itself would have been wrong. During the replicate loop
that cap is not a ceiling on what is returned but the size of the working set
the search reads: `tree_fuse` takes its donors from the whole pool (uncapped,
`max_rounds = 10`, and held under the pool mutex on the parallel path, where a
slow fuse stalls every worker), conflict-guided sectorial search calls
compute_split_frequencies() over best-score entries once per replicate, and
consensusConstrain calls extract_consensus_splits(). Raising it changes which
trees the search VISITS, so the anytime-dominance argument that licenses raising
`maxReplicates` -- a higher cap only appends later replicates and never delays an
earlier improvement -- does not transfer. After the loop the pool is pure
output, and there the argument does hold.

So split the knob: new `SearchControl(enumMaxTrees =)` is a ceiling raised only
at the MPT-enumeration phase, and `.RungSpec()` gains `enumMultiplier`, doubling
from rung 5 in step with the other two budgets. `enumMaxTrees = 0` (the default)
keeps `poolMaxSize` throughout, so the shipped default is unchanged; an explicit
`enumMaxTrees` outranks the ladder, as an explicit `targetHits` already does.

The ceiling is only ever raised, never lowered, so entries already held cannot
be orphaned above the cap. It needs no policy cap of its own: enumeration shares
the `maxSeconds * enumTimeFraction` reserve and its loop exits as soon as the
pool fills, so an over-generous value costs enumeration time, never a worse tree.

Applied at all three enumeration sites (both in ts_driven.cpp -- the second
inherits the raise, which is unconditional -- and the parallel one, which already
operates on the extracted OUTPUT pool, leaving the shared search pool's cap
alone). `ts_rcpp.cpp` reads the field defensively so a control list built by an
older caller is treated as 0 rather than throwing on R_NilValue.

Tests: 919 assertions pass across MaximizeParsimony-features, ts-pool,
ts-strategy, ts-driven, ts-parallel, ts_collapse_pool and SearchControl,
including an end-to-end case that fails if the field is dropped anywhere along
SearchControl -> ctrl list -> DrivenParams -> TreePool::raise_max_size(), and a
check that raising the ceiling leaves the score unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant