Skip to content

C++ search - #210

Draft
ms609 wants to merge 1082 commits into
mainfrom
cpp-search
Draft

C++ search#210
ms609 wants to merge 1082 commits into
mainfrom
cpp-search

Conversation

@ms609

@ms609 ms609 commented Mar 19, 2026

Copy link
Copy Markdown
Owner
  • other optimizations + features

Manual testing underway; shiny app in particular has some usability issues.

@ms609
ms609 marked this pull request as draft March 25, 2026 14:21
ms609 added a commit that referenced this pull request Mar 28, 2026
ms609 added a commit that referenced this pull request May 18, 2026
In R CMD check, R runs as a non-interactive subprocess with captured
stdout. R_FlushConsole() calls fflush() on that pipe; when the buffer
fills the call blocks indefinitely, causing the 6 h GHA timeout seen
on every ubuntu runner for PR #210.

Gate the \r-overwrite progress line and the flush behind R_Interactive
(FALSE in batch/check contexts). Interactive sessions are unchanged.
At verbosity >= 2 in batch mode, emit plain \n-terminated lines so
diagnostic logs still carry progress detail without the flush risk.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ms609 added a commit that referenced this pull request May 18, 2026
In R CMD check, R runs as a non-interactive subprocess with captured
stdout. R_FlushConsole() calls fflush() on that pipe; when the buffer
fills the call blocks indefinitely, causing the 6 h GHA timeout seen
on every ubuntu runner for PR #210.

Gate the \r-overwrite progress line and the flush behind R_Interactive
(FALSE in batch/check contexts). Interactive sessions are unchanged.
At verbosity >= 2 in batch mode, emit plain \n-terminated lines so
diagnostic logs still carry progress detail without the flush risk.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
ms609 and others added 23 commits July 3, 2026 08:26
…31/T-332 work

# Conflicts:
#	src/ts_collapsed.cpp
…ecord

Keeps the reusable rssPicks SearchControl knob (exposes existing
SectorParams::rss_picks_per_round; default 0 = byte-identical) and the
mission-gate harness+FINDINGS documenting the sectorial-escape thread's
closure (mechanism resolved, wall-lever ruled out). See
dev/benchmarks/missiongate/FINDINGS.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
test-ViewChars.R and test-Distribution.R were silently broken since the app
was modularised: they drove consensus inputs by pre-modularisation unqualified
ids (keepNTips, consP, outgroup, concordance ...), so every consensus
interaction no-oped and the committed baselines had drifted stale (still citing
the long-removed MorphyLib refs). Nothing caught this because these tests are
not run in CI. Also fixed mapLines, which is a top-level input (ui.R) that had
been wrongly namespaced treespace-.

- Namespace every input to its real id (consensus-*, mapLines top-level);
  regenerate and certify the ViewChars/Distribution value baselines (verified
  the app now reaches each intended state).
- Port the legacy tests/shinytest/*.R download-content coverage: add
  savePlotZip script snapshots via expect_download() at the same states, with a
  shared normalize_download() transform (setup.R) that is CRLF-safe and scrubs
  dates/versions/system lines. Teeth verified: a meaningful script change is
  detected; a volatile version change is scrubbed.
- SearchLog: the saveZip session log embeds non-portable search results
  (trees[[N]], allTrees[1:M], nThreads); assert deterministic log-generation
  markers instead of a brittle full-content snapshot, and check saveNwk/saveNex
  structurally. This ports v1 intent in a CI-portable form.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The three legacy shinytest scripts (Distribution.R, SearchLog.R, ViewChars.R)
plus their -expected snapshots and the shinytest.R runner are now fully
superseded by their shinytest2 AppDriver counterparts under tests/testthat/
(interaction coverage, plus the download-content coverage ported in the
previous commit). The v1 runner could no longer execute anyway: shinytest (v1)
was dropped from DESCRIPTION Suggests some time ago (only shinytest2 remains),
so `library(shinytest)` errored on the first line.

codemeta.json still lists shinytest; that file is generated (see
.github/workflows/codemeta.yml) and will drop the entry on its next regen from
DESCRIPTION, so it is intentionally left untouched here.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Prepares the three integration tests to run under GitHub Actions.

- setup.R gains a shared new_app_driver() factory and a wait_stable() retry
  wrapper. wait_stable() re-attempts app$wait_for_idle(), which can transiently
  error ("An error occurred while waiting for Shiny to be stable") right after a
  heavy dataset load; a first-try success incurs no delay.
- Disable expect_values() browser screenshots everywhere
  (expect_values_screenshot_args = FALSE) and drop the committed .png baselines.
  The screenshot is flaky even run-to-run on one machine (anti-aliasing / render
  timing) and carried no reliable signal; the value (json) + download-content
  snapshots carry the coverage.
- Route value snapshots through a thin expect_vals() wrapper (a seam for a
  future normalising transform) and add the .md download-filename baselines that
  the previous commit missed (they live at the _snaps root, not the per-test
  subdir).

Note on portability: expect_values() hashes the app's rendered plot output, so
the value snapshots are only portable on the OS that generated them. The shiny
CI job therefore runs on Windows (the development platform) rather than Linux,
which keeps CI output consistent with the committed baselines.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Restructure R-CMD-check.yml from a single 6-config matrix into fail-fast waves,
mirroring ../MaxMin:

  sense-check (ubuntu-24.04-arm, release) -- cheapest leg, NO concurrency group
    so a rapid follow-up push never cancels it; every commit gets a build signal.
  core (needs sense-check) -- Windows (+codecov), R 4.1 (highs pin), R devel;
    per-leg concurrency cancels superseded in-flight legs.
  full (needs core) -- macOS x2 backstop; the ~10x-runner-minute legs only run
    once core is green (in practice, about to merge).

All six original check legs are preserved, just reorganised into waves. There is
deliberately no workflow-level concurrency (see header comment).

New: a `shiny` job running the EasyTrees shinytest2 suite, gated on BOTH the
sense-check passing AND the app having changed (a `changes` job with
dorny/paths-filter feeds its `if:`, since workflow-level `paths` cannot combine
with `needs:`). It runs on windows-latest -- the development platform -- because
expect_values() hashes the app's rendered plot output, so the value snapshots
are only portable on the OS that generated the baselines. It is
`continue-on-error: true` (NON-BLOCKING) until its stability on CI is
established, and uploads any *.new.* snapshot diffs as an artifact for triage.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
EasyTrees shinytest2 repair/port/CI (local) + sectorial mission-gate (origin); disjoint file sets, automatic merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… + Δ-probe

expand_and_reinsert (T-266, 2026-03-27) used the union-of-finals
fitch_indirect_length_bounded approximation that the June directional fix
(#26/#27) replaced everywhere else — it was MISSED. Port it to the exact
edge_set scorer, mirroring ts_wagner.cpp:487:
  - per-tip compute_insertion_edge_sets (prelim is current via
    wagner_incremental_rescore) + fitch_indirect_length_cached per edge.

Also a gated, non-perturbing -DTS_SCOREAPPROX_PROBE oracle that tallied
Δ = exact_cost(E_bounded) − min_E exact_cost(E) per placement. On Zanol
(forced pruneReinsertCycles): bounded chose strictly-worse edges in ~62%
of placements, mean ~6 steps, ~48% greedy-regret SHARE. After the port the
probe reports Δ=0 at every placement (production == exact argmin).

NOTE: this is greedy-regret SHARE, not realizable wall-clock — prune_reinsert
auto-enables ONLY at nTip>=120 (`large` preset); NO mission dataset reaches
120t (max=88). So this path runs on zero default mission searches. Land +
time-matched A/B (NNI-polish `large` preset, ≥120t) is COMPOSITION-gated (#40).
Worktree-only; NOT for cpp-search until composition.

Tests: prune-reinsert 44/0, drift 22/0, ratchet 17/0, tbr 28/0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…-search data race

Post-review hardening of the exact-directional scorer port. The
-DTS_SCOREAPPROX_PROBE diagnostic block's function-local `static` counters
(sa_placements/sa_delta_*/sa_*_exact_sum) are mutated with no synchronization,
but expand_and_reinsert runs concurrently on parallel-search workers — an
unsynchronised data race (adversarial review, CONFIRMED low). thread_local gives
each worker its own tally (per-thread partials under multithreading; exact for
the single-threaded diagnostic runs this probe is intended for). Production
(probe undefined) is byte-identical and unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…w coverage gap)

Adversarial review of the exact-directional scorer port found no prior test
drove expand_and_reinsert on NA (inapplicable) or IW (implied-weights) data, nor
asserted score self-consistency in any regime — yet prune_reinsert_search runs on
both (its guard only early-returns for PROFILE/HSJ/XFORM). The port is a
ranking-only construction heuristic; the accepted tree is gated by a
regime-correct score_tree() (strict-improve + revert), so the REPORTED best_score
must equal an independent length recompute of the RETURNED tree in every regime.

Adds two tests (Vinther2008, 23t, forced pruneReinsertCycles=3): NA-EW and
IW (concavity=3) — each asserts result$best_score == ts_fitch_score(returned tree).
Both green. Empirically confirms the port cannot corrupt the accepted tree on
NA/IW (mirrors the ts_wagner.cpp all-regime convention, not the ts_tbr.cpp guard).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…sert (T-266)

Lands 41b0d2373's exact edge-set insertion scorer in expand_and_reinsert (replacing the union-of-finals fitch_indirect_length_bounded approximation; mirrors the June directional fix at ts_wagner.cpp), plus branch post-review hardening: 4007f8e port (byte-identical to 41b0d2373); 0fb6de9 thread_local probe accumulators (parallel-search data-race fix, production byte-identical); 98122e5 NA + IW score-soundness regression tests (Vinther2008).

The 'NOT for cpp-search until composition (#40)' note in 4007f8e's body is superseded: landing the correctness fix is decoupled from the #40 time-matched A/B. Dormant path (prune-reinsert auto-enables only at nTip>=120 / large preset; no default mission search reaches 120t) so nil current wall-clock risk. Verified: clean consistent rebuild + targeted tests 123 pass / 0 fail (incl new NA/IW soundness + wagner-quality).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Drop the non-portable value snapshots from the ViewChars/Distribution
AppDriver tests. app$expect_values() embeds rendered-plot output (a
data-URI image hash plus float plot geometry) that differs machine to
machine even on the same OS, so it could never green on GHA. Replace
each expect_values() with wait_stable() (retrying wait_for_idle wrapper)
and keep the portable coverage: the savePlotZip download-script
snapshots (which encode app state), the interaction path itself, and
SearchLog's log markers. Regenerate all download baselines; delete every
_snaps/*/*.json.

Rewrite test-app-smoke to load a bundled dataset (Sun2018) before
asserting "N trees in memory" -- a fresh boot has data-dataSource="file"
with nothing loaded, so the old assertion was checking a state the app
never reaches.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
The testServer module tests drifted from the app code while the suite was
CI-unwired:

* test-mod-search.R carried a hand-copied SearchConfidenceText frozen at
  an older signature; global.R's real one grew stopReason/replicateScores
  (consensus/timeout stop notes + Chao1 coverage note). The module now
  passes those args, so every observeEvent(input$implied.weights) that
  calls DisplayTreeScores() errored with "unused arguments" and aborted
  before completing the T-165 run-stats reset -- cascading into the
  reset/cancel-file assertions (656-658, 519). Re-sync the stub verbatim
  to global.R and flag it KEEP-IN-SYNC.

* TreeLength()/ScoreSpectrum() are TreeSearch exports but the file only
  attached shiny, so they were "could not find function". Attach
  TreeSearch.

* The consensus stop-note assertion pinned "consensus stable"; the shipped
  text is "consensus tree unchanged across recent replicates". Align the
  assertion to the real output. (See note to maintainer: confirm this
  wording is intended, not an accidental UX regression.)

* test-mod-data.R expected tipLabels() to be NULL with no trees, but
  mod_data.R deliberately guards `return(character(0L))`. Align the
  expectation.

Verified: test-mod-search PASS=66 FAIL=0, test-mod-data PASS=13 FAIL=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ARNs

- global.R: consensus stop-note now reads "Search stopped: consensus stable
  across recent replicates." (terse, descriptive); test-mod-search stub kept
  in sync + assertion pinned to "consensus stable".
- test-mod-clustering.R: library(shinyjs) so the clThresh observer resolves
  runjs() (was 3 "could not find function" WARNs).
- test-mod-consensus.R: suppressWarnings around two isolated-harness artefacts
  (transient non-numeric signif / length-zero if) irrelevant to the assertions.

CI fails on WARN; whole EasyTrees suite now FAIL 0 | WARN 0 | PASS 192.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* fix(constraint): reject impossible constraints, guard collapsed-split OOB (T-329)

An impossible (non-laminar) constraint reached random_constrained_tree()
unfiltered: .PrepareConstraint() only filtered splits by size, never
checked pairwise compatibility. In random_constrained_tree(), a split that
loses all its tips to tighter non-laminar splits collapses (split_root =
-1); if it still has a strict-superset parent, the parent's child-split
loop pushed that -1 as a phantom item, causing tree.parent[-1] and
tree.left/right[n_tip-1] out-of-bounds writes.

Primary fix: .PrepareConstraint() now validates pairwise split
compatibility (disjoint or nested) and stops with a clear error before
reaching the C++ builder. Defensive fix: guard split_root[j] >= 0 in
random_constrained_tree()'s child-split collection, mirroring the
existing root-level guard.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* ci(ASan): switch mem-check runner to ubuntu-latest (x86)

The r-hub gcc-asan container image has no arm64 variant; on
ubuntu-24.04-arm the container dies at init before any R work runs
(every run since 2026-06-19 failed in ~30-40s). Cherry-picked from
TreeSearch main's fix (PR #262) so the T-329 fix can be verified
under a working ASan CI.

* ci(asan): install phangorn for tests leg, exclude MaxMin from vignettes leg

The tests leg only installs hard deps + Config/Needs/memcheck (Suggests are
skipped for tests/examples), but tests/testthat/*.R make heavy unguarded use
of phangorn::phyDat() etc. -> "there is no package called 'phangorn'".  Add
it to Config/Needs/memcheck so it installs regardless of leg.

The vignettes leg installs all immediate Suggests via a single
pak::pkg_install() batch, which resolves plain package names against
CRAN/Bioconductor only - it doesn't see the local DESCRIPTION's Remotes
mapping.  MaxMin is GitHub-only (Remotes: ms609/MaxMin), so that batched
solve fails outright with "Can't find package called MaxMin", taking every
other Suggest down with it.  Exclude MaxMin, mirroring the existing Rogue
exclusion: its only use (WideSample()) is requireNamespace-guarded and no
vignette/example exercises it.

* fix: guard zero-length memcpy in TreeState::load_tip_states

Datasets whose characters are all uninformative (e.g. every observed
state is a singleton) collapse to zero blocks and a zero-length
tip_states vector in build_dataset(). std::vector::data() on an empty
vector may legally return nullptr, which memcpy's nonnull-parameter
attribute forbids — flagged by UBSan (gcc-ASAN run 28662381835) as a
null-pointer violation reached via ts_char_steps() -> init_from_edge()
-> load_tip_states(). Skip the copy when there is nothing to copy.

Covered by the existing "ConcordantInformation() works" regression
test in test-Concordance.R, which already exercises an all-singleton
dataset via ConcordantInformation() -> CharacterLength() ->
ts_char_steps().

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(constraint): use four-gamete split compatibility, not laminar-subset (T-329 follow-up)

T-329 (19f4f97) added a laminarity gate to .PrepareConstraint() that tested
each constraint character's "1"-side as a full bipartition vs everything-else
and accepted only disjoint-or-nested pairs. This rejected SATISFIABLE
constraints: it (1) folded wildcard "?" and unconstrained tips into the "0"
side though they are free to plot either side, and (2) tested only three of
the four split-compatibility cases, missing the case where the two "0" sides
are disjoint (the "1" sides jointly cover the constrained tips).

The gcc-ASAN CI (run 28662381835) surfaced this on the pre-existing Morphy
test and the tree-search vignette "complex-constraints" chunk (ab|cef & abcd|ef,
g free) -- both displayable on ((a,b),(d,(c,(e,f))))+g. T-329's own "impossible"
example {t1,t2,t3}&{t3,t4,t5} was likewise satisfiable (coexist on
((t1,t2),t3,(t4,t5))).

Fix: build a companion 0-group matrix and apply the four-gamete test on both
groups (wildcards excluded) -- incompatible iff all four group intersections
are non-empty. Valid constraints return to their working pre-T-329 path;
genuinely-impossible ones still error. The C++ OOB guard (ts_wagner.cpp
split_root[j] >= 0) is the real crash fix and is retained unchanged.

Tests: corrected the first T-329 case to a genuinely four-gamete-incompatible
constraint ({t1,t2,t3}&{t2,t3,t4}); added a positive regression asserting the
non-laminar-but-compatible case is accepted. Verified locally: Morphy suite,
vignette chunk, and 446 constraint tests all green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

* fix(wagner): guard zero-Fitch-words start-tree build (all-hierarchy HSJ/xform)

When every character belongs to a hierarchy block, the recoded equal-weights
dataset has zero blocks, so DataSet::total_words == 0 and the per-word Fitch
state vectors (ds.tip_states, edge_set) are empty. wagner_tree() unconditionally
took the address of element 0 of those empty vectors
(&ds.tip_states[tip * total_words] == &ds.tip_states[0], and likewise
&edge_set[node * tw]) to feed the incremental insertion-cost machinery. That is
undefined behaviour (address of a past-the-end element of an empty vector) and
aborted under the hardened libstdc++ assertions in the gcc-ASAN CI
(run 28662381835, ts-xform group; _GLIBCXX_ASSERTIONS operator[] __n < size()).

With no Fitch characters every insertion cost is identically zero, so guard the
per-word work behind have_words = ds.total_words > 0: skip the edge-set
precompute and evaluate the indirect length as 0, while still running the DFS so
constraints are honoured and the first legal edge is chosen. The search then
optimises the tree from the Sankoff (xform) / hierarchy-DP (HSJ) term alone.
The have_words == true path is behaviourally identical to before.

Regression tests (test-ts-xform.R, test-ts-hsj.R) run an all-hierarchy dataset
through MaximizeParsimony() so the hardened/ASAN CI covers the zero-words path.
Verified locally with a -D_GLIBCXX_ASSERTIONS -O0 -g build: the whole ts-xform
group (incl. SK-01, which previously aborted) and ts-hsj group now pass with
no abort; a normal equal-weights search is unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
…harness

Investigates whether the T-327 guard `build_postorder().size()==n_internal`
(impose_one_pass, src/ts_constraint.cpp:735) is equivalent to structural
validity, and whether a net-zero corruption can slip it to reach the
advertised std::bad_alloc from MaximizeParsimony(constraint=).

Adds dev/red-team/heavy-tests/impose_validity/ — a standalone C++ harness
(no R/Rcpp/Morphy/SIMD) that enumerates all (2n-3)!! rooted binary trees for
n_tip=4..8, runs the verbatim kernel functions (topology_spr, collect_edges_*,
find_maximal_subtrees, compute_node_tips extracted from HEAD at build time) +
the real build_postorder, and models the whole impose_constraint loop.

Findings (T-333, P3): the guard is NOT a full validator — it admits reachable
net-zero TYPE-1 left/right corruption (double-ref + orphan) that DOES reach the
DFS helpers, so its comment is inaccurate. But the hypothesised P1 is REFUTED:
0 root-reachable left/right cycles AND 0 parent[] cycles across ~1.33M
accepted-invalid trees (no std::bad_alloc on either vector, incl. the pre-verify
tbr_search->reroot_at_tip path), and 0 corrupt final trees survive the callers'
map_constraint_nodes verify-and-discard (no wrong answer). Fix = replace the
size-check with a full left/right arborescence check (O(n)).

Files: findings.md + to-do.md (T-333), log.md (area-13 round entry).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
…tor (T-333)

The T-327 backstop in impose_one_pass's try_move — postorder.size() ==
n_internal (commit 6b60f23) — is not equivalent to structural validity.
topology_spr's root-child degenerate case can emit a net-zero corruption
(one internal node double-referenced +1, one orphaned -1) that lands on
exactly n_internal and slips a size check, then feeds a malformed tree to
the next reanchor DFS and to reroot_at_tip's parent walk. (The hypothesised
std::bad_alloc P1 was refuted — no reachable cycle on either vector — and no
malformed tree was ever returned; correctness rested entirely on the callers'
verify-and-discard. This makes the guard self-sufficient.)

Add structurally_valid() (O(n): child slots in range + left!=right; in-degree
1 for every non-root node / 0 for root; a root DFS visiting every node exactly
once; parent[] the exact inverse of left/right). try_move now validates before
rebuilding the postorder and reverts on any corruption.

Performance: the check is confined to impose_one_pass/try_move, which runs
ONLY when constraint= is passed to MaximizeParsimony. TreeState::build_postorder
(97 hot-path call sites) is untouched and keeps its > n_internal cap as the
bad_alloc backstop, so unconstrained search pays nothing; within a constrained
run it is the same O(n) complexity class as the build_postorder already there.

Verification:
- Exhaustive harness (dev/red-team/heavy-tests/impose_validity/) re-pointed at
  the real, verbatim-extracted structurally_valid: probe() now asserts it equals
  the independent full_validity() oracle on every tree, with a g_guard_mismatch
  gate (exit 3 if they ever diverge — the durable CI signal against reverting to
  a size-only check). Result: 0 disagreements across all (2n-3)!! rooted binary
  trees n=4..8 (every net-zero witness the old guard admitted is now rejected).
- R integration test added (test-ts-impose-constraint.R "T-333: structural guard
  survives root-child repair, honours constraint") — the stricter guard neither
  crashes nor over-rejects valid repairs through the linked engine.
- Package builds clean (-Wall -pedantic); full test file green.

findings.md row removed (resolved); to-do.md T-333 -> FIXED (pending CI);
log.md area-13 round annotated. GHA CI (ASan + full check) pending on push.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
fitch_na_score()'s first-uppass tip loop iterates every tip 0..n_tip-1 and
dereferences tree.final_[tree.parent[tip] * total_words + ...]. That assumes
every tip is attached, but a partial tree can legitimately be scored while some
tips are detached: expand_and_reinsert() (prune-reinsert perturbation, T-266)
maps the optimised reduced backbone into the full node space and calls
score_tree() BEFORE the dropped tips are Wagner-reinserted. Those dropped tips
still carry the parent == -1 sentinel from init_wagner_state(), so anc == -1 and
the index becomes (-1) * total_words == -total_words, a wild negative subscript
(size_t-wrapped). That is out-of-bounds on tree.final_ / tree.prelim.

Only the NA path trips this: the EW path (fitch_uppass) and every other pass in
fitch_na_score (Pass 1, Pass 2 internals, Pass 3) walk tree.postorder, which
contains only attached nodes, so they already ignore detached tips. The NA
first-uppass tip loop was the lone place reading tree.parent[] for all tips
unconditionally. It aborted under the hardened libstdc++ assertions in the
gcc-ASAN CI (run 28681669404, ts-prune-reinsert group; _GLIBCXX_ASSERTIONS
operator[] __n < size(), long unsigned int == uint64_t on x86-64). Under a
normal build the same read silently touches out-of-bounds heap.

Fix: guard the tip loop with `if (anc < 0) continue;`, making the NA uppass
honour the same attached-nodes-only contract the EW path already follows. The
guard is inert for a complete tree (every tip's parent is >= n_tip), so a
normal search is behaviourally identical. Detached tips get their prelim/final
recomputed by wagner_incremental_rescore() when they are reinserted, and the
accept-gate score_tree() runs on the completed tree, so scoring is unaffected.

Regression coverage: the NA and IW score-self-consistency tests in
test-ts-prune-reinsert.R (both drive prune-reinsert on the inapplicable
Vinther2008 dataset; IW reuses it via fitch_score_ew -> fitch_na_score) exercise
this exact path and previously aborted before their assertions ran. Verified
locally with a -D_GLIBCXX_ASSERTIONS -O0 -g build: test-ts-prune-reinsert.R now
completes 52/52 with no abort (was exit 134), and the ts-na-* / ts-driven groups
still pass with no assertion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
(cherry picked from commit 638a6ce)
ms609 and others added 30 commits July 31, 2026 09:23
…hine's speed

The coverage wave ran for the first time and immediately failed on my own parallel
stopPatience test: FAIL 1 / PASS 11627 under `covr::codecov()`, against FAIL 0 /
PASS 11628 in the same commit's ordinary check.  So the test was not wrong about
the code -- it was wrong about the environment.

Mechanism, and it is the one I documented in the very commit that added the test
(998891e): on the parallel path the dry spell is counted over replicates completed
into the shared pool and evaluated only when the coordinating thread polls, every
200 ms.  covr instrumentation makes each replicate far slower, so far fewer land
per poll interval and `stopPatience = 3` fires genuinely earlier in the search --
13.063 against 12.988.

`expect_equal(short score, full score)` was therefore asserting a coincidence of
timing, not a property of the rule.  Stopping sooner is *allowed* to cost score;
that is the entire trade-off `stopPatience` exists to offer, and it is what the
shipped preset measurements quantify.  Asserting equality contradicted the
feature's own documentation.

What the rule does guarantee is already asserted and untouched: the search stops
before `maxReplicates`, and `perturb_stop` is set.  The score check is now only
that it is finite and not grossly worse, which holds at any speed.

Third time this session a test of mine encoded an environment assumption -- after
`as.Splits`/`%in%` dispatch and the ARM64 ordering chase.  The pattern: an
assertion that passes because of how fast, how attached, or how ordered the local
environment happens to be, rather than because of what the code promises.

Co-Authored-By: Claude Opus 5 <[email protected]>
…-374

Coordination-only (to-do.md + findings.md). No code.

Three P1s (T-374, T-375, T-376) were sitting in findings.md with NO to-do.md row,
and T-385 existed only in agent memory -- in neither file. The queue therefore
read as "one open P3" (T-324) while four P1 correctness defects were live, which
is also why the standing tasks were computing as P1 under to-do.md's own
<3-open-tasks rule.

T-385 is filed as the implementation ticket for the ALREADY-DECIDED T-374b
policy, not as a new finding: T-374's row already records both of its symptoms,
so a new number would have duplicated it -- the T-383 mistake recorded in the
HSJ hub. Its detail is folded into T-374 instead.

Also appended to T-374 a refinement that matters to whoever implements it: Q5 of
the decision document treats BOTH symptoms as reporting defects fixed by making
one rooting authoritative at the boundary. Measured against tip a8fbba8, that
holds for the reported-vs-TreeLength gap but NOT for the MPT-set symptom -- pool
membership is selected at MaximizeParsimony.R:1618 on search-time scores taken at
differing rootings, so canonicalising the report path leaves 2 distinct scores
among 32 "equally parsimonious" trees. Recorded as the open residue of T-374,
explicitly out of scope for the reporting fix.
…as invalid

Withdrawn same day, before any fix was written, so nothing was built on it.

The claim (pushed in 7d4d255) was that the MPT-set symptom survives a
canonicalised report path, evidenced by "2 distinct scores (183, 182) among 32
MPTs at a common tip-1 rooting". The measurement rooted each tree at its OWN
tip.label[1], which after Renumber() is a DIFFERENT TAXON for different trees --
so it was a different rooting per tree, not a common one.

Re-measured with the rooting taxon named explicitly
(dev/red-team/heavy-tests/t385-diagnose-rooting.R): all 32 returned trees are
already rooted at the kernel's tip 0 (32/32, root degree 2), and at a genuinely
common rooting they all score 183 -- 32 distinct topologies, 0 disagreement.
The pool IS self-consistent, so pool re-filtering is NOT required and
n_topologies/collapse semantics do not need to move. That materially simplifies
T-385.

What survives is the P1 itself: reported 178 is not the score of the tree at the
rooting it is returned at (183). And the objective is genuinely rooting-sensitive
-- the same topology spans 178-183 over 8 rootings, inside the Sum nSec = 12
bound, with 178 attained at 2 of them.

Fourth environment/measurement artefact this session (after %in%-on-Splits
dispatch, ARM64 tip ordering, and covr timing). Same root cause each time:
asserting on something incidental -- here "tip.label[1]" as a stand-in for a
fixed taxon -- instead of naming the thing I meant. See
[[loadall-is-not-rcmdcheck]] lesson 3.
Pre-fix reproduction against cpp-search tip a8fbba8, written before any fix so
the failure is on record independently of the change that addresses it.  The
finding was recorded today but the tip has moved since (T-391 landed), and
[[redteam-verify-against-current-tip]] says to re-verify rather than assume.

All three symptoms reproduce on a 36-tip / 6-block / nSec=2 synthetic dataset
(seed 1, search seed 11, maxReplicates 4):

  reported attr(res, "score")           178
  TreeLength(res[[i]], ...) as returned 183  for all 32 trees   -> gap 5
  same topology, 8 rootings             178..183, spread 5      (bound Sum nSec = 12)
  32 MPTs at a common tip-1 rooting     183 and 182             -> 2 distinct

Note the reported 178 IS attained, by 2 of the 8 sampled rootings: the engine is
not computing a wrong number, it is recording a score at one rooting and
returning a topology rooted elsewhere (ts_collapse_pool's tip-0
canonicalisation being the trigger site named in T-374).

The script reports rather than asserts, and exits 1 while the gap is open, so it
doubles as the acceptance check for the fix.
Implements the ALREADY-DECIDED Option 3 of
dev/plans/2026-07-29-t374b-xform-rooting-policy.md, whose three commits on
cpp-search (7a18a4b et al.) turned out to be docs only -- no code implemented
"make MaximizeParsimony's reported score and TreeLength() agree on one rooting".

## The defect

XFORM's step matrix is asymmetric (gain = nSec + 1 against loss = 1), so a
tree's length depends on where it is rooted -- unlike the symmetric criteria.
MaximizeParsimony reported `result$best_score`, recorded mid-search at whatever
rooting the replicate held, while ts_collapse_pool hands every tree back
re-rooted on tip 0.  So the reported number was not the length of the tree
returned, and re-rooting a returned tree changed it again.

Reproduced on tip a8fbba8 (36 tips / 6 blocks / nSec 2), pre-fix:

  reported 178  |  TreeLength(returned) 183 on all 32 trees  |  gap 5
  same topology over 8 rootings: 178..183, spread 5 (bound Sum nSec = 12)

The reported number was not WRONG -- 178 is attained by 2 of those 8 rootings --
it was a value at a rooting the user never receives.

## The fix

Canonicalise at both boundaries, on the dataset's first taxon (the tip-0 rooting
ts_collapse_pool already imposes):

- TreeLength(): root before scoring, in BOTH the single-tree and multiPhylo
  methods.  The multiPhylo path previously rooted only trees that arrived
  unrooted, so an already-rooted tree kept its own rooting.  Rooted by NAME with
  tips re-aligned afterwards, never by an R reroot on the edge matrix
  (na-validation-alignment-gotcha).
- MaximizeParsimony(): rescore the returned pool through that same path and
  report the result -- |pool| evaluations.

Post-fix the acceptance script is clean: gap 0, spread 0 across 8 rootings.
Scores may therefore differ from previous versions and will not decrease; the
value is now the length of the tree in hand, an upper bound on the rooting-free
minimum exceeding it by at most Sum nSec (attained by 87-98% of rootings).

Deliberately NOT done: min-over-rootings reporting.  It is what the plan calls
the better variant, but it reports a quantity the search never compared and costs
(2n-3)x on the Sankoff term, so it stays Option 4.  HSJ reporting is also
untouched -- there rooting-invariance is REQUIRED by the method (two-state DP,
T-374's open half), so canonicalising would convert a wrong objective into a
stably-wrong one.  Gated on XFORM alone, with that reason in the code.

## Scope boundary, and a warning where it bites

Pool membership is still decided on search-time scores taken at differing
rootings, so the returned trees need not share the canonical length.  That is
T-374's open residue, out of scope here -- but no longer silent:
MaximizeParsimony now warns and reports the smallest.  The warning immediately
found a live instance in the existing suite (all-hierarchy data spans 7 to 9),
which independently corroborates the earlier session's "4 of 6 trees do not share
a score" observation.

## Tests

Two regression tests, both verified to FAIL against the pre-fix tip built in a
throwaway worktree (spread 2 not 0; reported 46 against min 48) and pass after:

- The deterministic one carries its own anti-vacuity guard: it first ASSERTS via
  ts_sankoff_test -- untouched by this fix -- that the raw kernel really is
  rooting-sensitive on its 8-tip input, so it cannot pass by scoring
  rooting-insensitive data.
- The end-to-end one asserts the actual contract (reported == min of the returned
  trees' canonical lengths), not the stronger "every tree shares it", which holds
  for its data but is not what the code promises.

Also fixes the three false root-invariance comments the plan lists
(ts_tbr.cpp:123 and :3152, ts_rcpp.cpp's collapse reroot, MaximizeParsimony.R's
collapse note), and documents the behaviour in ?MaximizeParsimony,
?RecodeHierarchy, NEWS.md and vignettes/search-algorithm.Rmd.

Suite green: xform, tree_length, hsj, t330, recode-hierarchy,
resample-hierarchy, t306, prune-reinsert -- 0 failures.
… rename

Not part of T-385 -- surfaced by running check_man() for it.

419168d changed the roxygen example from "one notch less / effort = -1" to
"one notch more / effort = 1L" but man/MaximizeParsimony.Rd was never
regenerated, so the shipped example contradicted its own source. Regenerated
from the roxygen block, which is authoritative.
Coordination-only. The reporting half of T-374 is implemented on
claude/t385-xform-report-agreement (stacked on PR #277): TreeLength()
canonicalises the rooting in both its single-tree and multiPhylo methods, and
MaximizeParsimony() rescores the returned pool through that path before
reporting. Acceptance script: gap 5 -> 0, rooting spread 5 -> 0.

Worth the row update on its own: the new "returned trees do not share a length"
warning fired immediately on an EXISTING test -- all-hierarchy xform data returns
a pool spanning 7 to 9. That independently corroborates T-374's original "4 of 6
trees do not share a score", which my own 36-tip attempt to reproduce had
measured wrongly (retracted in 9296e87). The residue is real, reachable, and now
visible at runtime instead of silent.
The acceptance script carried its own copy of MakeXformData() identical to the
one the rooting diagnostic sources, so the two could silently drift.  Sourced
from the shared file instead.  Acceptance script re-verified: exit 0, gap 0,
spread 0 (checked WITHOUT a pipe -- the earlier 'EXIT: 0' was tail's status).
Three follow-ups, no behaviour change.

- test-ts-xform.R's all-hierarchy test now expect_warning()s the "do not share a
  length" warning instead of emitting it into an otherwise-green suite.  That
  matrix provably triggers T-374's residue (pool spans 7 to 9), so the warning is
  an expectation, not noise -- and if it ever stops firing, the residue has been
  fixed or masked and this test says so.  Assign INSIDE expect_warning(): it
  returns the condition, not the expression's value, so `res <- expect_warning(...)`
  silently bound the warning object and broke the two assertions below it.

- Both diagnostic scripts source() a path relative to the package root, so they
  only work when invoked from there.  They now stop with that message instead of
  failing obscurely -- the same relative-path trap that made five tree_length
  tests "fail" earlier today when test_dir() changed the working directory out
  from under a relative lib.loc.

- Recorded the real reason min-over-rootings reporting is declined.  "Reports a
  quantity the search never compared" is the weaker half; the deciding objection
  is cost -- naively (2n-3)x on the Sankoff term, so a 4000-tip pool of 100 trees
  needs ~800k boundary evaluations.  Affordably it needs an all-rootings up-down
  DP, which is Option 4's own piece of work.
All 8 CI jobs green on run 30662279563. The one ubuntu-24.04 (4.1) failure was
transient -- it died in 'Set up R dependencies' with the Bioconductor 3.14 repos
unreachable, so 'Check package' was skipped and no code was ever tested; green on
re-run of that job alone.
T-385: report the x-transformation score of the tree actually returned
…f 30)

Array 18126933, 150/150 cells at pin 644b5b1, every stopping rule left as
shipped (maxSeconds = 0, maxReplicates = 96, targetHits = max(10, ntax/5)).
Raw cells in dev/profiling/na-certify-stop-hamilton.csv.

THE HEADLINE, and it corrects documentation written two commits ago: tripling
`targetHits` changed floor attainment on NOT ONE of 30 matrices, while costing
2.58x the wall (26 of 30 slower, 22 by >10%, p = 6e-05).  Only 33 of 150 cells
were identical in score AND replicate count, so the runs genuinely went longer
-- they just never found anything better.  The mechanism is the disjoint-
population argument measured at corpus scale rather than on one cell: on hard
matrices the replicate cap binds before the hit target is reached (hitCapBound
rises 22% -> 47%, i.e. the extra demand only pushes more runs into the cap), and
on easy matrices the optimum is already in hand.  The extra work lands exactly
where it cannot help.

The `effort` ladder doubles `targetHits` at rungs 5+, so this is evidence
against a choice made in b2dfc3a6/2d871b41.  It is kept -- without it a notch
would be INERT on every dataset that stops early, and under implied weights it
deepens the ratchet, which an equal-weights panel cannot see -- but the docs no
longer imply it buys reach.  Rungs 5+ now read as buying confidence and distinct
trees on easy data, and reach on hard data.

Gating still costs reach under the real stopping rules, so the default still
does not flip: B_gate is 0 better / 4 worse (p = 0.125, uniformly negative),
losses concentrated on the hard tail (Zanol2014 -0.6, Wortley2006 -0.4,
Aria2015 -0.2, Zhu2013 -0.2).

C_final is the arm worth pursuing: statistically indistinguishable from baseline
(-0.013, 1 better / 2 worse, p = 1.000) at a THIRD of the wall, and its
certifications are the productive ones -- 4349 of 11000 sweeps found a real
improver (40%) against A_default's 9555 of 44889 (21%).  Certifying the tree a
replicate actually reports is four times cheaper and twice as likely to pay.
It still regresses on Zanol2014 (0.6 -> 0.0), so it is a candidate, not a change
to make now.  Settling it wants the hard tail at more seeds, not another
corpus-wide sweep: 24 of the 30 matrices sit at 1.0 in every arm and can only
dilute the signal.

Co-Authored-By: Claude Opus 5 <[email protected]>
…epos

R reads EITHER ./.Rprofile OR ~/.Rprofile, never both (?Startup). GHA steps
run with cwd = the checkout, so the root .Rprofile added on cpp-search (for
the Rcpp CRLF patch) silently suppressed the user profile -- which is exactly
where r-lib/actions/setup-r writes

  options(repos = c(RSPM = <Posit PM>, CRAN = ...), Ncpus, HTTPUserAgent)

(see setup-r/src/installer.ts:659-757). Consequence on every cpp-search job:
Posit Package Manager never entered getOption("repos"), the User-Agent that
makes it serve binaries was never set, and pak fell back to its default CRAN
mirror -- resolving 100% of dependencies from source. ~10 min of compiling per
leg, and it also explains why the earlier Ncpus and RSPM/env experiments
measured nothing: those values are written into the same shadowed file.

Evidence (cache-immune -- pak prints this before installing anything):

  main   arm64 leg: repo_status() row 1 = RSPM <Posit PM>; 110 aarch64
                    binary resolution targets, 1 source
  cpp-search leg:   repo_status() row 1 = CRAN https://cran.rstudio.com,
                    Posit PM absent entirely; 0 binary targets, 101 source

Fix: chain to the user profile before installing the Rcpp hook, with a guard
for the cwd == home case. inst/Parsimony/.Rprofile is deliberately left alone
-- it ships in the tarball and has no bearing on dependency setup.

cache-version bumped to 2 on every setup-r-dependencies step: the v1 caches
were populated while the shadowing was in effect, so every package in them
was built from source. Retire them once.

Co-Authored-By: Claude Opus 5 <[email protected]>
Two cuts to the preliminaries on the fast legs, now that dependencies actually
resolve as binaries again.

1. Pin Posit PM to a dated snapshot (2026-07-30, the date proven to serve
   aarch64/noble binaries) on `sense-check` and on agent-check's ubuntu leg.
   `setup-r-dependencies` keys its cache on the RESOLVED dependency versions,
   so `latest` misses the cache whenever any of ~110 dependencies gets a new
   CRAN release -- most days -- and re-downloads the lot. A fixed snapshot
   keeps the cache valid for weeks, so dependency setup becomes a restore.

   `core`'s ubuntu legs deliberately keep `latest`: catching breakage from
   bleeding-edge dependencies is the R-devel recipe's job, not the fast
   "did this commit build?" signal's.

2. Drop the texlive apt step from `sense-check` (~45 s/run). It was never
   needed: `check-r-package@v2` defaults to args = c("--no-manual",
   "--as-cran") and build_args = "--no-manual", and all eight vignettes are
   `rmarkdown::html_vignette`, i.e. pandoc-only. `core` and `full` still
   install texlive, so a real LaTeX dependency would still be caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
…s ~70 s/run

Profiling the (now-binary) dependency step showed the download of 108 binaries
takes 6 s and installing them ~10 s. The time is elsewhere:

  pak install + resolve + metadata DB    ~17 s
  download 108 binaries                    6 s
  pak system-requirements apt pass       ~75 s   <-- ppa:xtradeb/apps + Chromium
  install 108 binaries                   ~10 s
  MaxMin (GitHub source): Packaging 37 s + Building 29 s = 67 s

shinytest2 -> chromote declares `chromium` as a system requirement, so on Linux
pak runs `add-apt-repository -y ppa:xtradeb/apps`, `apt-get update`, then
installs Chromium and its GTK/X11 tree. That happens on EVERY run: the sysreqs
pass is not covered by the package cache (confirmed on a warm-cache run whose
dependency step still took 83 s with 112/117 packages kept).

No Linux leg needs it. shinytest2 appears only under inst/Parsimony/tests,
which `.Rbuildignore` excludes, so it is not even in the tarball R CMD check
examines; the app suite runs in the dedicated `shiny` job on Windows, which is
left untouched.

MaxMin's 67 s is deliberately NOT addressed here: it is a genuine trade
(tests/testthat/test-WideSample.R would skip on the affected leg) and is the
maintainer's call.

Co-Authored-By: Claude Opus 5 <[email protected]>
…after)

Answers whether a container with R (or R + all deps) preinstalled beats
setup-r + setup-r-dependencies. Bar to beat: ~40-70 s for R alone, ~3 min for
R + dependencies. Measures raw pull time/size for extrapolation, plus the
runner's own "Initialize containers" step, which is the cost that actually
lands on a container-based leg.

Co-Authored-By: Claude Opus 5 <[email protected]>
…hypothesis

RETRACTION.  Panel 2's headline -- "targetHits escalation is pure cost, 0 of 30
matrices improved at 2.6x the wall" -- is true and useless.  24 of the 30
matrices are saturated at 1.0 in every arm, so "no improvement" there is the
expected result, not a finding; and floor attainment is binary, so it cannot see
an arm getting closer without arriving.  I flagged the dilution in the same
document and then led with the diluted number anyway.

Re-analysed on score, restricted to the six matrices with headroom, and counting
what the arm actually bought:

  extra replicates from targetHits x3, all 30 matrices : 4409 (4.2 CPU-hours)
  ... on the six matrices with headroom                :  243
  ... on Zanol2014 / Zhu2013 / Geisler2001             :    0

On the three hardest the 96-replicate cap bound BOTH arms on every seed (21 of
30 headroom cells), so A_hits3 did byte-identical work to A_default -- identical
scores, identical replicate counts.  94% of the extra work landed on matrices
that were already solved.  Only 9 cells anywhere gained a replicate.

So the correct claim is not "raising targetHits does not help" but "targetHits
cannot act once maxReplicates binds", which is a stronger statement about
mechanism and a much weaker one about effort.  The hypothesis -- low effort
misses the optimum, high effort takes longer and recovers a better score -- was
never tested, because the arm could not act where it mattered.

PANEL 3 tests it.  Hard matrices only (the six with headroom), scored on SCORE
not binary attainment, arms = `effort` 0/+1/+2 crossed with certification.

Arms are `effort` itself, not a hand-rolled budget, because a first draft that
raised only maxReplicates was INERT on Aria2015: targetHits stopped every run at
39 replicates, far below even the 96 cap, so all three budgets returned
identical scores.  That is the exact mirror of panel 2's mistake.  Only moving
both knobs escalates every dataset -- which is what the ladder does -- so
testing the shipped argument is both more honest and more informative.

Two things the smoke test exposed, recorded rather than silently patched:
* Panel 2 pinned `strategy = "default"` for every matrix, so on 65-119-tip
  matrices it did NOT measure what a user gets (`auto` selects `thorough`).
* Notch 3->4 is DEAD on hits-bound datasets: rungs 1-4 never touch targetHits
  and rung 4 raises only the cap, so `effort = +1` and `+2` gave literally the
  same run on Aria2015.  Rung 4 is an auto pick calibrated for >=120 tips, so
  this is not restructured on no evidence -- panel 3 will quantify it.

Co-Authored-By: Claude Opus 5 <[email protected]>
arm64, ubuntu-24.04-arm runner:
  rocker/r-ver:latest        pull 10s, 865MB unpacked
  rocker/tidyverse:latest    pull 25s, 3073MB unpacked
  container: rocker/r-ver -> "Initialize containers" = 12s (pull+create+start)
  => ~10s fixed + ~7ms/MB; image ships R 4.6.1 + 31 packages

Conclusion: an R-ONLY image is roughly a wash -- setup-r spends only ~10s on R
itself, its other ~52s being the hard-coded devscripts/ghostscript apt install.
An image with R + all ~110 dependencies baked in would land near 1.3-1.6GB,
predicting ~15-20s of container init against the 170-250s the two setup steps
cost today. That is the variant worth building, if we build one.

Incidental: rocker sets repos in Rprofile.site, to
p3m.dev/cran/__linux__/noble/latest -- the rolling "latest", NOT a dated
snapshot. Rprofile.site is read unconditionally, so unlike the ~/.Rprofile
route it is immune to the project-.Rprofile shadowing this branch fixes.
Whether it also sets HTTPUserAgent is unverified and would need checking
before relying on it.

Co-Authored-By: Claude Opus 5 <[email protected]>
MaxMin isn't on CRAN (Remotes: ms609/MaxMin), so pak packages+builds it
from source on every cache miss. Costed hosting prebuilt binaries via
ms609/packages (a new GHA workflow there, a repository_dispatch sender
+ cross-repo PAT in ms609/MaxMin, per-arch/per-R-version Linux tarballs
since GitHub Pages can't do the UA-negotiation drat needs for Linux) --
MaxMin is pure Rcpp numerics with no OS-conditional code path, so that
infrastructure would buy no coverage the Windows/macOS legs don't
already give. ?ignore on the fast Linux legs (sense-check, core's two
Linux configs, agent-check ubuntu) is the same 67s saving with a
one-line change; test-WideSample.R already skip_if_not_installed
and every WideSample.R call site is requireNamespace()-guarded, so
those legs degrade gracefully. Windows and macOS legs keep MaxMin
installed for real.
… check

THROWAWAY verification step -- the previous cache-2 entry predates
MaxMin=?ignore and still has MaxMin installed, masking whether the
ignore directive actually works. Revert to 2 once confirmed.
…ignore

Confirmed against a genuinely cold cache (run 30699889580): zero
Packaging/Building MaxMin activity, R CMD check reports MaxMin
correctly absent, test-WideSample.R skips via skip_if_not_installed,
and dependency setup dropped from 174s to 95s -- consistent with
removing the ~67s MaxMin build while ~99 other deps still resolve as
binaries. Back to cache-version 2 now that the verification is done.
Both fixed together on feature/hsj-token-index-fix. T-374 (HSJ rooting)
remains open and unaffected; noted that hsj-paper-oracle.R now reads
8 pass/0 fail (not 2/3) so a future T-374 session doesn't mistake that
for a T-374 fix.
…ctors

Tested whether Vine's approach (bioRxiv 10.64898/2025.12.24.696405) -- embed
taxa, decode by neighbour-joining, backpropagate through the decoder -- ports
to TreeSearch. It does not, in either candidate slot.

LeastSquaresTree(): the refit discards the decoder's branch lengths, so
RSS-after-refit is piecewise CONSTANT in the input distance matrix (6 distinct
values over 241 grid points, median adjacent step 0). The only differentiable
surrogate misranks topologies (Spearman +0.10 to +0.36 among RF==2 neighbours),
and scale/affine correction moves that by <0.02, so it is not a branch-length
artifact. An oracle hill-climb on the TRUE objective tied NNI+SPR 3/5 and lost
2/5 -- it never won, so no gradient could help.

Sector starts (build_ras_sector): perturbed-NJ makes better individual starts
than RAS Wagner (2.5 vs 4.0 steps above optimum) but LOSES on best-of-5
(1 win / 7 ties / 7 losses) because its restarts are half as diverse
(CID 0.162 vs 0.328). Multi-start sector solving is diversity-limited, not
start-quality-limited. Corroborated internally: the more diverse pNJ arm beats
the less diverse one on every measure.

Two follow-up leads, unrelated to Vine, recorded in the write-up:
- LeastSquaresTree(method = "ols") returns topologies worse than the untouched
  NJ start when rescored under NNLS, on 5/5 targets (20-48% worse). Expected
  mathematically, but the documented purpose is Lapointe & Cucumel average
  consensus, which is NNLS. Worth a sentence in ?LeastSquaresTree.
- src/ts_ls.{cpp,h} has no certify_unrooted equivalent -- it is the one search
  path without the lever that dev/profiling/na-certify-gate.md documents.

Docs and benchmark scripts only; no package code touched.

Co-Authored-By: Claude Opus 5 <[email protected]>
…ignore

Supersedes the MaxMin=?ignore approach: ms609/packages now publishes
prebuilt MaxMin binaries for every platform/R-version this repo's CI
matrix uses (github.com/ms609/packages/pull/1), so there's no longer a
tradeoff to make -- every leg gets real MaxMin coverage without paying
to compile it from source.

- DESCRIPTION: drop `Remotes: ms609/MaxMin` (Remotes always wins over a
  repo binary in pak/remotes resolution, so it would otherwise force a
  from-source GitHub build regardless of what's in the repo) and add
  `Additional_repositories: https://ms609.github.io/packages` so pak
  knows to look there for a Suggests package absent from CRAN.
- Windows/macOS legs need no workflow changes: they resolve MaxMin
  automatically via the standard drat bin/<os>/contrib/4.6/ layout now
  published there.
- The 3 Linux legs (sense-check and agent-check's ubuntu-24.04-arm
  release, core's ubuntu-24.04 r4.1 and ubuntu-24.04-arm devel) each get
  a pinned `url::` ref to a stable, unversioned
  bin/linux/<tag>/MaxMin_latest.tar.gz -- GitHub Pages can't do the
  User-Agent negotiation Posit Package Manager uses to serve Linux
  binaries from src/contrib, so this is the only route there. Because
  the URL has no version/commit in it, it never needs updating here
  when MaxMin changes -- ms609/packages' build-maxmin workflow keeps
  the URL's content current on a schedule.
…naries

Root cause: a project-root .Rprofile (added for the Rcpp CRLF patch) shadowed
~/.Rprofile, where r-lib/actions/setup-r writes options(repos = c(RSPM =
<Posit PM>, ...)). Every job on this branch was therefore resolving 100% of
its dependencies from source against plain CRAN instead of Posit PM binaries
-- confirmed via pak::repo_status() and resolution-target counts (cache-
immune; wall-clock and Building counts are not, and cost a full session to
that confusion). Fixed by chaining to the user profile.

On top of that, three further cuts to the CI preliminaries, each verified by
mechanism rather than timing alone:
  - Pin Posit PM to a dated snapshot instead of `latest` on the fast legs, so
    the dependency cache doesn't miss every time a dependency gets a new
    CRAN release.
  - Drop the texlive install from sense-check: no leg needs it (--no-manual,
    html_vignette everywhere).
  - shinytest2=?ignore on Linux legs: its chromote/chromium sysreq forces an
    apt PPA + Chromium install every run, uncached, for a suite that only
    runs (correctly) on the Windows `shiny` job.
  - MaxMin (Suggests, not on CRAN) now resolves from prebuilt binaries at
    ms609.github.io/packages (new build-maxmin workflow there, PR ms609/
    packages#1, merged) instead of building from GitHub source on every
    cache miss. Remains a live, tested dependency on every leg -- not
    ignored.

Dependency setup: ~10 min -> ~35 s warm cache / ~2 min cold, measured
end-to-end on ms609/TreeSearch CI, not locally.

Co-Authored-By: Claude Opus 5 <[email protected]>
Co-Authored-By: Claude Sonnet 5 <[email protected]>
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