Skip to content

Bremer (decay) support via converse-constraint + pool#270

Open
ms609 wants to merge 12 commits into
cpp-searchfrom
claude/bremer-support-calculation-857d5f
Open

Bremer (decay) support via converse-constraint + pool#270
ms609 wants to merge 12 commits into
cpp-searchfrom
claude/bremer-support-calculation-857d5f

Conversation

@ms609

@ms609 ms609 commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Bremer (decay) support

Adds Bremer support calculation to TreeSearch, plus the suboptimal-tree
infrastructure it builds on. Bremer support for a clade C is
L(¬C) − L* — the extra tree length required before C stops appearing on
optimal trees.

What's new

  • Bremer(tree, dataset, …) — a standalone post-search function (mirroring
    JackLabels()), returning a decay index per resolved clade, keyed by node
    number.

    • method = "constraint" (default, rigorous). One converse-constraint
      search per clade, forcing that clade absent and taking the shortest
      resulting tree. TNT/PAUP*-grade, bounded memory (best tree per clade).
    • method = "pool" (fast preview). Min length among retained suboptimal
      pool trees lacking each clade — an upper bound, right-censored at the
      sampling depth (attr(, "censored")).
    • Scoring arguments mirror TreeLength()/MaximizeParsimony(), so Bremer is
      computed under exactly the search's optimality criterion (equal weights,
      implied weights, profile, inapplicable "bgs" — fractional decay indices are
      preserved, never coerced to integer).
    • Per-clade searches are independent, so they distribute over a parallel
      cluster via cl = for near-linear speed-up on many-clade trees.
  • SuboptimalTrees(dataset, …) — 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 retained pool's per-tree scores via a scores attribute (and
    a score on each tree), so Suboptimality() works on the result directly.

  • Scoring provenance. MaximizeParsimony() now records the optimality
    criterion it searched under as attr(result, "scoring"). Bremer() checks it
    exactly: if a supplied optimalScore/reference was found under a different
    scoring mode, it warns and proceeds (trusting the user's declared mode) —
    a saved optimal score is only meaningful alongside the conditions it was
    optimal under.

Correctness

  • Brute-force oracle gate. On ≤7 taxa the converse search is effectively
    exhaustive, so method = "constraint" is asserted equal to the exact
    min(len[¬C]) − min(len) over AllTrees, including an adversarial case where
    the unconstrained optimum displays C and the ¬C optimum is several steps
    away.
  • Soundness backstop. The negative constraint is enforced at the
    per-candidate regraft filter (mirroring the positive constraint), with a pool
    set_forbidden insert-time backstop and reverts in the sector /
    prune-reinsert paths so a converse search can never rebuild the forbidden
    clade.
  • New C++ negative-constraint machinery is ASan-clean. gcc-ASAN
    (r-hub container, R-devel ASAN/UBSAN) run on the merge tip b26f948c:
    AddressSanitizer examples / vignettes / tests all pass
    (run 29083961613, ~2h23m).

Tests

test-Bremer.R, test-BremerParallel.R, test-SuboptimalTrees.R — oracle
equality (equal-weights, implied-weights, inapplicable), cross-engine bound
(constraint ≤ pool), star-tree / zero-clade early return, optimalScore
validation, scoring-provenance match/mismatch, pool eviction-score alignment,
and engine-failure paths via an injection seam. Local Bremer + SuboptimalTrees +
constraint suites: 126 pass, 0 fail.

Merge note

This branch is merged up to current cpp-search, including the native
inapplicable kernel replacing Morphy (#251). The one substantive conflict
(MaximizeParsimony.R) was hand-resolved; NAMESPACE / Collate / man /
RcppExports were regenerated with roxygen + compileAttributes (Morphy exports
dropped, negative-constraint plumbing retained).

Deferred (non-blocking)

Cross-process PSOCK fault-injection tests (worker value-correctness under
mis-serialized scoring args) and a Hamilton A/B on the converse-search reach
benefit remain flagged; neither gates correctness.

🤖 Generated with Claude Code

ms609 and others added 12 commits July 9, 2026 15:11
Add standalone Bremer(tree, dataset, method=c("constraint","pool")) computing the
per-clade decay index L(-C) - L*, plus SuboptimalTrees() and per-tree pool scores
on MaximizeParsimony(collapse=FALSE) for landscape analysis.

Rigorous default engine forces each clade absent via new C++ negative-constraint
machinery (ConstraintData.neg_*, add_negative_constraint, displays_forbidden_clade)
plumbed through consNegSplitMatrix + an internal .negativeConstraint arg. Soundness
+ optimality via three layers: a TBR regraft move-guard, a TreePool backstop
(set_forbidden rejects any clade-displaying tree), and a not-C start repair. Drift
and annealing accept worse moves through an unguarded path, so they are disabled in
converse searches; ratchet/sectorial/nni-perturb re-optimise through the guarded
TBR and stay on for search power. Oracle-validated against exhaustive enumeration
of all binary trees, including the adversarial case (optimum displays C, not-C 3
steps away) and implied weights.

Docs regenerated: adds Bremer/SuboptimalTrees to the split-support family and
syncs the previously-stale SearchControl.Rd in-sector-drift params.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review (two background red-team agents) of the Bremer implementation
surfaced ten findings; this lands the fixes. Parallel fan-out follows separately.

R (Bremer.R / SuboptimalTrees.R / MaximizeParsimony.R):
- BR-1 (P1): guard against a scoring-units mismatch. A multiPhylo whose stored
  optimal score was measured under different scoring args (e.g. trees found under
  implied weights, then Bremer() called with the default equal weights) silently
  subtracted an IW optimum from EW lengths. Re-score the reference under the
  active scoring args (resolving collapsed polytomies) and error on a mismatch,
  or on a supplied optimalScore longer than the reference tree.
- BR-2 (P2): drift / in-sector drift / annealing are unsound in a converse search;
  a user value no longer re-enables them (was modifyList, silently emptying the
  pool -> all NA). Ignore with a warning.
- BR-6 (P3): verify in R that each converse-search tree really lacks the clade,
  surfacing an engine regression as a visible NA rather than a deflated decay.
- BR-3 (P3): SuboptimalTrees() strips a colliding control=/collapse= from ...
  (was a hard "matched by multiple actual arguments" crash on the pool path).
- BR-5 (P3): carry the censored flag into format="character" output.
- BRM-2 (P2, latent): MaximizeParsimony() forces nThreads=1 when a negative
  constraint is set (the parallel pool has no soundness backstop).

C++ (ts_tbr.cpp / ts_driven.cpp), all gated on neg_active so the mainline path is
byte-identical (confirmed: 180 constraint/driven/parallel/sector/fuse/ratchet/
wagner test blocks unchanged):
- BRM-1 (P2): the outer-reroot sweep (try_root_edge_moves / exact_verify_sweep)
  was unguarded, so a root-edge TBR move could carry the search into forbidden
  space and inflate the reported decay (or spuriously NA). Snapshot the clade-free
  tree and restore+stop if the improved tree displays the clade.
- BRM-3 (P3): gate the periodic-fuse improvement bookkeeping on the actual pool
  insertion, so a backstop-rejected fused tree no longer resets the stopping rules.
- BRM-4 (perf): skip the positive post-hoc block when there are no positive splits
  (a negative-only constraint), removing a redundant compute_node_tips sweep.

Tests: NA/bgs enumeration oracle for the DEFAULT scoring mode (previously
unvalidated); BR-1 and BR-2 regressions; de-vacuumed the cross-engine test (it
had been comparing empty vectors). All oracle checks (EW / IW / bgs / adversarial)
pass; broad regression 180 blocks / 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`Bremer(method = "constraint")` runs one independent converse search per clade;
its dominant cost is that N-search sequential fan-out.  Add a `cl =` argument
distributing the per-clade searches over a `parallel` cluster (each worker runs
one serial search -- the negative-constraint pool guard is serial-only, so
parallelism must be across clades, never within a search).

The fan-out primitive `.BremerConverseScores()` seeds each clade independently
(seeds drawn once from the caller's stream), so a parallel run reproduces a
serial one under the same `set.seed()`, regardless of task scheduling.  The
default `cl = NULL` keeps the original serial path bit-for-bit; `method = "pool"`
ignores `cl` (with a warning).

Validated against the exhaustive enumeration oracle both serially and over a live
2-worker PSOCK cluster (identical results); the public `Bremer(cl =)` matches the
serial path exactly.  Adds `parallel` to Imports; completes the (previously
stale) Collate with Bremer.R / BremerParallel.R / SuboptimalTrees.R.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 3-agent adversarial pass over the red-team fix diff surfaced four issues (the
C++ changes came back clean):

- VF-1 (P2): the BR-1 scoring guard could raise a SPURIOUS fatal error on the
  canonical Bremer(trees, ds) call. It compared the stored L* to the reference's
  length under the current scoring, resolving collapsed MPTs with
  multi2di(random=FALSE); but an arbitrary resolution can be suboptimal, so
  min(refLen) > L* whenever poolMaxSize truncation leaves no cleanly-resolving
  member. Since TreeLength rejects non-binary trees, refLen can only be an UPPER
  bound on L* (exact L* is NP-hard). Reworked to a SOUND one-sided error (supplied
  L* exceeds an achievable length -> definitely wrong; multi2di inflation can
  never trigger it) plus a heuristic WARNING for the opposite direction (an
  implied-weights optimum scored under equal weights), which no sound score
  comparison can catch.

- VF-2/VF-3: documented that a parallel run is reproducible under set.seed() but,
  because its per-clade seeding differs from the serial stream, may differ from a
  serial run where a search does not saturate (both valid upper bounds); added a
  public Bremer(cl=) vs serial equality test on saturating data (the primitive
  tests only covered .BremerConverseScores).

- VF-4: the aggregate NA warning no longer misattributes a "returned tree still
  displays the clade" NA (whose per-clade warning is lost on cluster workers) to
  a search-budget shortfall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…the forbidden clade

The reduced-dataset sector solve (rss_search/xss_search) and the prune-reinsert
backbone TBR run constraint-blind, so a reinsertion could rebuild a forbidden
clade even though the pre-move tree lacked it. The pool backstop still guaranteed
soundness (a clade-displaying tree is rejected at pool insert), but the LIVE
search tree could strand on the clade with no improving escape move -- wasting
the converse replicate and inflating/NA-ing the reported decay on datasets with
>= 12 tips (where sectors engage). This is structurally invisible to the <= 7-tip
enumeration oracle, so the gap was never caught.

Mirror the positive post-hoc constraint check with a negative
displays_forbidden_clade revert in rss_search/xss_search and the prune-reinsert
accept path, gated on neg_active so mainline (positive/unconstrained) searches
are byte-identical. Also fail loudly on a consNegSplitMatrix column-count
mismatch (previously a silent unconstrained converse search -> decay reported as
0) and correct a misleading displays_forbidden_clade doc comment.

Red-team round-2 findings A-11/A-12/A-15/A-17. Validated: mainline
sector/prune-reinsert/positive-constraint/driven suites unchanged (613
assertions), <= 7-tip enumeration oracle exact, and a new >= 12-tip sector test
asserts the converse trees genuinely lack the clade. Reach-benefit magnitude is
a Hamilton follow-up (not measurable on the exhaustive oracle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rip pool-size overrides

Three red-team round-2 R findings:

- optimalScore = NA_real_ slipped past the is.null() "not supplied" sentinel and
  crashed the one-sided scoring guard with "missing value where TRUE/FALSE
  needed"; now rejected up front with a clear message (B-8).

- Bremer(format = "character") dropped the `censored` attribute entirely when no
  clade was censored, while the numeric format kept an all-FALSE vector -- so
  downstream code assuming its presence broke on the character path whenever
  nothing happened to be censored. The character format now always attaches a
  node.label-shaped censored vector (B-9).

- SuboptimalTrees() stripped colliding `control`/`collapse` but not the raw
  `poolSuboptimal`/`poolMaxSize` SearchControl fields, which would silently win
  over the values set from `maxSuboptimal`/`maxPool` via MaximizeParsimony()'s
  dots-override-control merge; now warned and ignored (B-10).

Tests added for each; Bremer / SuboptimalTrees / BremerParallel suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…a fuzzy error

The scoring-units guard now checks a supplied optimalScore against the
reference's length under the scoring arguments in effect and WARNS (in either
direction) on any material difference, then accepts the score and proceeds --
per the package author's decision to trust the user's scoring choice and only
alert them in case they forgot to pass the scoring arguments used to find the
trees.

This replaces the previous one-sided 5%-of-length heuristic, which had a P1 blind
spot: two criteria that place L* close together (a finder reproduced
inapplicable="bgs" vs "missing" giving an identical length) slipped under the
threshold and produced a silently mis-scored decay vector. The tight two-sided
tolerance now catches a small but genuine mismatch; taking the minimum over the
supplied MPTs absorbs multi2di resolution slop, so a consistent call does not
false-warn (verified across the EW / IW / bgs test corpus). The former hard error
on an "impossible" L* is downgraded to the same accept-and-proceed warning.

Red-team round-2 finding B-7 / TA-6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…check it exactly in Bremer

A saved optimal score is only meaningful alongside the conditions it was optimal
under. MaximizeParsimony() now stamps a `scoring` attribute -- a canonical
signature of the scoring arguments (concavity, extended_iw, xpiwe_r, xpiwe_max_f,
hierarchy presence, inapplicable, hsj_alpha) -- on its returned multiPhylo.

Bremer() compares that signature EXACTLY against the scoring arguments in effect:
a mismatch (e.g. implied-weights trees passed to a default equal-weights Bremer)
warns and proceeds (trusting the user's choice); a match is silent. This closes
the B-7/TA-6 blind spot at the root -- it needs no re-scoring, so it has neither
the resolution slop nor the near-equal-length hole of the length heuristic, which
is retained only as a fallback for a bare optimalScore or a reference built
outside MaximizeParsimony.

Additive attribute; validated across EW / IW / profile / hsj / xform / hierarchy
modes (537 assertions in MaximizeParsimony / Jackknife / scoring-mode suites
unchanged) and the Bremer suite (provenance match = no warning, mismatch =
warning, length fallback intact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lignment

Two of the deferred round-2 test-adequacy gaps, added with fast searches:

- TA-9: Bremer() on a fully-unresolved (star) reference warns and returns an
  empty numeric (the "nothing to calculate" early return, previously untested).

- TA-5: SuboptimalTrees() under a tiny maxPool exercises the diversity-eviction
  path and asserts the `scores` attribute (and each tree's `score`) stays aligned
  with the evicted-down set -- the exact truncation mechanism that hid VF-1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The aggregate-NA warning (VF-4) and the per-clade "returned tree still displays
the clade" check (BR-6) guard against engine states the C++ move-guard + pool
backstop are designed never to reach, so an ordinary search cannot provoke them.
Add a proper test seam instead of leaving them untested:

- Extract .BremerProcessResult() -- the per-clade result handler -- and unit-test
  its NA sentinels (non-finite / negative score) and its displays-clade warning
  path directly (TA-4 / BR-6).

- Add an internal .runConverse injection argument to .BremerConstraint() so a
  stub engine can drive every clade to NA, exercising the aggregate NA warning
  and the NA assembly end-to-end (TA-3 / VF-4).

Pure refactor of the real path (processConverse now delegates to the extracted
helper); the full Bremer + BremerParallel suites are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brings the Bremer branch up to current cpp-search, notably the native
inapplicable kernel replacing Morphy (#251) and the FixedTree change.

Conflict resolutions:
- R/MaximizeParsimony.R: keep the scoring-signature helpers
  (.ScoringSignature / .ScoringSignatureMatch / .DescribeScoring) added for
  Bremer provenance; honour cpp-search's removal of the deprecated
  MaximizeParsimony2 alias and its EasyTrees launcher.
- DESCRIPTION Collate / NAMESPACE / man: regenerated with roxygen; drop
  Sectorial.R (deleted upstream) and C_MorphyLength (Morphy removed), keep
  SuboptimalTrees + Bremer exports.
- RcppExports regenerated: Morphy exports dropped, negative-constraint
  plumbing retained.

Also files SuboptimalTrees() under @family tree scoring (in addition to
split support functions).

Bremer, SuboptimalTrees and constraint suites: 126 pass, 0 fail. Full
recompile of the merged C++ is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sense-check run on the PR surfaced 2 ERRORs + 2 WARNINGs; all four are
doc/hygiene, not code:

- examples ERROR: the Bremer example called LabelSplits() with no active
  device (edgelabels -> `last_plot.phylo` not found), and keyed decay onto
  trees[[1]] rather than the consensus it is computed against. Plot and
  label the consensus reference so node numbers align and a device exists.
- tests ERROR (spelling): add bgs, bipartitions, clade's, run's, search's
  to inst/WORDLIST (new terms in the Bremer/SuboptimalTrees docs).
- Rd WARNING: document the internal .negativeConstraint argument of
  MaximizeParsimony() (roxygen requires every \usage arg documented).
- tests WARNING: declare pkgload in Suggests (test-BremerParallel.R uses it
  for the PSOCK worker load_all()).

Co-Authored-By: Claude Opus 4.8 <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