Skip to content

C++ search - #210

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

C++ search#210
ms609 wants to merge 1002 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
ms609 and others added 23 commits June 18, 2026 19:41
All three scorers (EW, IW, NA) now reach guaranteed true unrooted-TBR
optima by default.  EW/IW via the in-pass root-edge check; NA via the
exact-verify sweep at convergence.  Gated out for sector/constrained/
tabu/pool sub-searches (state would be invalidated) so those are
unaffected.

Two pre-existing test failures (tbr<=spr, ratchet<=tbr) resolved as a
side-effect: the rooted kernel was getting stuck above the unrooted
optimum so TBR appeared no better than SPR.

Adjust test-ts-sector-resolve.R: raise targetHits to 99 so both
rasStarts configurations run the full two replicates; under the new
default the better initial tree caused rasStarts=3 to terminate after
one replicate (fewer total candidates) even though per-sector work was
correctly higher.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
exact_verify_sweep runs a full O(n^3) unrooted-TBR neighbourhood sweep at
every NA convergence to certify a true optimum. The same converged
topologies are re-verified many times across a search (ratchet restore
passes, per-cycle TBR polish), each repeating the entire sweep.

Memoize FALSE (genuine-optimum) verdicts in a thread_local cache. The
result is a pure function of (topology, dataset, weighting regime), so a
cached FALSE stays valid until one of those changes. Key =
hash(canonical child-pairs) XOR dataset-fingerprint XOR weight-fingerprint;
the dataset-fingerprint alone is the clear-trigger, so base-regime entries
survive perturbation excursions and are reused.

The weight-fingerprint (per-block active_mask, upweight_mask, pattern_freq
-- exactly the fields ts_ratchet.cpp's save_perturb_state snapshots) is
essential, not optional: the ratchet mutates the weighting in place and
runs NA TBR under both perturbed and base weights within one cycle (the
default strategy's ZERO_ONLY mode zeroes active_mask every cycle). Without
it, a base-regime "optimal" verdict would be reused during a perturbed
pass, silently skipping the improving moves the ratchet exists to find --
an NA-search-quality regression the NA oracle (which never ratchets)
cannot catch.

Validated on Zanol2014 (74 tips, NA), default strategy, seed 1, 3 reps:
cache-on score == cache-off (TS_EV_NOCACHE) score == 1315 with identical
tree count -- the cache is behaviourally transparent (a hit returns the
same FALSE the full sweep would) -- at 364.5s vs 416.1s wall (-12.4%).
TBR regression suite: 0 failures/errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rds)

The comment claimed the hash is "root-position-independent (any rooting of
the same unrooted topology produces the same hash)". That is false and, worse,
backwards: sorting each child pair canonicalizes only the left/right child
order WITHIN a node, not across rerootings. Different rootings renumber the
internal nodes and flip parent/child directions, so they hash differently
(verified: two rootings of one unrooted tree differ at 55/73 internal nodes).

That root-DEPENDENCE is exactly what makes the memoization cache correct:
exact_verify_sweep is itself root-dependent (it skips root-child clips, leaving
a residual completeness gap — task #19), so each rooting has a different
neighbourhood and must be cached separately. The previous wording invited a
future "canonicalize to root-independent" optimization that would cause
cross-rooting cache hits to suppress improvers one rooting finds and another
misses. Comment-only; no behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The exact_verify_sweep optimum cache memoizes "genuine NA optimum"
verdicts, which are valid only under the current weighting regime. The
ratchet mutates active_mask/upweight_mask/pattern_freq in place mid-
search, so a base-regime verdict leaking into a perturbed pass silently
skips the improvers the ratchet exists to find. That regression is
invisible to the NA oracle (never ratchets) and to final scores (always
recomputed), so guard it deterministically at the key level:

- extract the key into one shared helper, exact_verify_cache_key(),
  called by BOTH the cache and the probe, so dropping a term from the
  key line is caught -- not just a broken fingerprint function;
- ts_ev_cache_key_probe export returns the exact key; its flags
  reproduce the three ways the ratchet changes the regime (zero_active
  = ZERO_ONLY, the default NA strategy; set_upweight; bump_pattern_freq);
- test-ts-na-evcache.R asserts the composite key changes for each regime
  field AND for topology/dataset, and is deterministic;
- TS_EV_AUDIT (off by default): on a cache hit, re-run the full sweep
  and abort if it finds an improver -- a live tripwire for contamination.

Mutation-validated: dropping `^ weight_fingerprint` fails exactly the
three regime assertions (topology/dataset/determinism still pass).
Default path byte-identical; TBR regression suite 0 failures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The exact directional insertion edge-set computation (the EW per-clip
hotspot, ~27% of full-EW CPU per VTune) reallocated and zero-filled its
edge_set and up-message buffers on every clip. Both are fully written
before any read for every in-tree non-root node (the only slots any
reader touches), so the zero-fill was pure waste.

Hoist `up` and `pre` to caller-owned scratch passed by reference (NOT
thread_local — function-local per call, so per-thread safe) and replace
the per-call assign(N,0) with a non-zeroing size-ensure. A debug-only
(#ifndef NDEBUG) write-before-read guard records every written non-root
slot and asserts completeness against the in-tree node set, so a stale
slot can never be read. Threaded through all three call sites
(tbr_search, build_ras_sector, wagner_tree).

Verified:
- EW bit-identical: score AND candidates_evaluated exact over
  {Wortley2006,Zhu2013,Zanol2014} x seeds{1,2}, reps3.
- NA single-threaded bit-identical: Vinther2008 x seeds{1..4}, exact.
- 276 kernel search tests pass (tbr/wagner/sector/ratchet/drift/fitch).
- Wall: -16.4% on Zanol2014 (largest), -9.4% sum on heavy runs; the
  saving scales with O(n_node * total_words), as predicted.

NB an unrelated pre-existing crash exists in the parallel (nThreads>=2)
NA path (Vinther2008): it reproduces on the unmodified baseline, is a
timing-dependent race, and is independent of this bit-identical change.

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

exact_verify_sweep (the NA convergence certifier) clips every internal node
EXCEPT root-children (the nx==n_tip guard), so the one unrooted edge the display
root sits on (cL-cR) was never enumerated.  On poor NA starts that left a
root-edge improver undetected: the kernel declared convergence above the true
unrooted-TBR optimum (dev/benchmarks/tbr_oracle_na.R: 2/20 on Zanol2014, e.g.
start #14 stalled at 1323 with a 1320 neighbour).

Fix: enumerate that one edge exactly at the optimum exit, reusing the already-
tested try_root_edge_moves_rescore (the same apply+full_rescore path IW uses at
convergence).  The clip loop covers the 2n-4 non-root edges; the root-edge check
covers the 1 remaining edge; together they certify all 2n-3 unrooted edges, so a
FALSE return — and the memoized optimum — now means a true unrooted-TBR optimum.

The change strictly enlarges the certified neighbourhood and only ever applies
strict improvers, so it can only remove "converged-with-improver" failures and
cannot worsen any score.

Validation: Zanol2014 start #14 now reaches 1320 with no improver; small-tree NA
oracle 0/100; IW unaffected (already complete, 0/60); TBR regression suite 0
failures.  Adds dev/benchmarks/tbr_oracle_na_small.R (fast high-N oracle) and
tests/testthat/test-ts-na-complete.R (Tier-3 regression pinning start #14).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Records why the exact O(1) "EW additive" directional scan is provably unavailable
for NA (the per-node Pass-3 step is not 2-local — it reads the global applicable-
region resolution; code already gates NA off the additive path), why a richer
fixed-size-message DP is plausible but research-grade (the one directional NA
message that exists, fitch_na_indirect_length, is deliberately approximate), and
recommends the cheaper lever for #18: incremental EXACT rescore (pruning via the
approximate scan + a localised Pass-3 delta on top of fitch_na_dirty_*), validated
against the now-complete exact_verify_sweep oracle.

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

Rogue hard-imports Rfast, which compiles from source under the r-hub
gcc-asan container (~30 min).  All Rogue usage in TreeSearch is already
requireNamespace-guarded (two vignettes, Shiny consensus module), so
it skips cleanly when absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…253)

The parallel NA search (nThreads>=2) intermittently aborted with
STATUS_HEAP_CORRUPTION.  Root cause: the per-thread scratch in the TBR
kernel (ts_tbr.cpp, ts_fitch.cpp) and exact_verify_sweep's optimum cache
were function-local `static thread_local`.  On MinGW these resolve via
emutls, whose thread_local teardown across std::thread spawn/exit corrupts
the heap.  EW is unaffected (light TLS); the NA path trips it because
exact_verify adds a thread_local unordered_set plus more scratch.

Fix: convert all worker-reachable scratch to plain function-locals (each
worker owns its call frame -> per-thread-safe; per-clip realloc measured
<=1.6% on 88-tip data, ~0% typical).  Move exact_verify_sweep's optimum
memoization to mutable members on DataSet so it keeps the same per-worker,
cross-replicate persistence the thread_local had, without emutls.

Verified on clean builds (rm src/*.o; CCACHE_DISABLE=1; --preclean):
parallel NA survives 120/120 (was iter ~4-8), EW 200/200, serial scores
bit-identical, NA perf 4.15s (cache intact, vs 5.81s cache-disabled).

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ot loops

Bank the validated micro-lever sweep from branch claude/tbr-microlevers
(task #48). All changes are BYTE-IDENTICAL: score + candidates_evaluated
unchanged on Wortley2006/Zhu2013/Zanol2014 x seed{1,2} (verify_l1.R, 6/6).

THE WIN — a diagnostic std::getenv("TS_REVERT_CHECK") left in the per-clip
teardown (~100k+ calls/search) was costing 13-19% of EW MaximizeParsimony
wall on Windows/ucrt, where getenv is us-scale (locked env-block scan), not
sub-ns. Hoisted to a per-call bool. Quiet-machine same-seed paired A/B:
Zanol -13.2% (20/20, p=0), Zhu -19.1% (12/12, p=0); 3-way attribution proves
the getenv hoist alone is the entire win.

Also folded in (both byte-identical, both ~0 measured effect, kept as exact
cleanups):
  - cutoff hoist: maintain the EW/NA bail cutoff across the clip, recompute
    only on improvement (+0.00%, attribution-proven).
  - kept_ei: precompute sub_edge-invariant reroot skip predicates once per
    clip (marginal/wash even at Zanol-1261; droppable).

Caveat: getenv magnitude is env-size + platform dependent (Windows/ucrt
large; Linux cheaper) — Hamilton/Linux confirmation owed. Byte-identical and
strictly removes ~100k getenv/search regardless.

Detail: dev/profiling/findings.md T-P5n + dev/profiling/tbr-microlever-sweep.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lands the sectorial component-isolation round (gate 1) onto cpp-search.

ts_sector.cpp: 3 byte-identical micro-levers (search_sector ras_starts==1
fast path; compute_from_above new_from_above alloc->std::swap; TS_FREE_HTU_PROBE
/ TS_SECT_DEBUG getenv -> cached static — the per-sector getenvs T-P5n flagged
"for the sectorial agent"). ~2.8% isolated sectorial; verified score +
n_sectors byte-identical across {Zanol,Zhu,Wortley}x{wagner,tbr}xseed{1,2} and
the mission A/B; 8 search test files pass.

Harness: drivers/sector-rss.R (isolated rss), drivers/mission-getenv-ab.R
(full-search getenv A/B), run_sector_tests.R, microbench/bench_getenv.cpp
(std::getenv = 2398 ns/call on ucrt), sector-levers.patch, PRODUCTION-LEVERS.md.

focus-areas.md: RSS #3 / CSS-XSS #6 -> AT-LIMIT (throughput at-limit by
inheritance; ~96% is the at-limit inner+global tbr_search).

findings.md T-S6a-e left uncommitted to land with the in-flight T-P5n/o rows.
The mission-wide getenv finding (T-S6d) converges with T-P5n (commit 3a50537e).
Measurement-only instrumentation NOT included (env-gated, stayed on the
scratch worktree branch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes beb5213's getenv-hoist work. TS_PHYS_REROOT was the last
diagnostic getenv read inside tbr_search's hot/warm path -- the outer
reroot-control loop (>=1x/call) at ts_tbr.cpp:2133. Hoist it to a
per-call const bool at function entry (1277), matching revert_check /
iw_scanchk from beb5213. It selects the legacy physical-reroot
reference path and is constant for the process, so this is byte-identical.

std::getenv is ~2.4us/call on Windows/ucrt (linear env-block scan) and
its cost hides in VTune's ucrtbase self-time. The headline per-clip
TS_REVERT_CHECK win (~13-22% EW wall) was ALREADY merged in beb5213
(findings T-P5n) and confirmed gone by the post-getenv re-survey
(T-P5o); this only clears the small residual per-reroot read.

Verify: ts-tbr-search + ts-ratchet-search 45/45 pass, CustomSearch
clean (isolated install).

findings.md: add T-P5l/m cross-ref notes so future readers trusting
"TBR kernel at-limit / closed" don't miss that a profiler-invisible
getenv cost sat next to the at-limit kernel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…etenv out of tbr_search + findings T-P5l/m at-limit≠no-overhead correction
tree_fuse segfaulted on >64-tip data (wps>=2) with intraFuse=TRUE: reroot_at_tip0
ran once before the round loop, but the round-end TBR moves tip 0 off the root, so
round >=2 split-matching matched a clade against its complement and replace_subtree
corrupted the tree. Fix = re-root every round (early-returns when already rooted, so
round 1 is byte-identical) + a defensive replace_subtree size guard. Ported from
TreeSearch-nonclade; 22/22 fuse tests pass incl. an 80-tip regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
(cherry picked from commit da21f5dce652ee85d12e1e50d1cba349c74824b4)
…n plan

- dev/red-team/proofs/lever-c-bound-then-verify.md: 531-line durable proof
  settling lever-c (bound-then-verify) as dead-by-proof-plus-magnitude
  (no-forced-step lemma + net-overhead inequality + origin-recovery cap).
- dev/plans/2026-06-20-fuse-drift-isolation.md: fuse/drift component
  isolation plan + progress log (gate-1 AT-LIMIT complete across all
  components; fuse >64t crash fix; SCOREAPPROX finding T-F1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The union-of-finals Y = final(A)|final(D) is an APPROXIMATION that
UNDER-counts insertion cost (it is a superset of the true directional
edge set), not an exact non-additive method. The exact cost is the
directional edge_set[D] = combine(prelim[D], up[D]) via
compute_insertion_edge_sets + fitch_indirect_length_cached. Comment now
matches ts_fitch.h and the validated directional-fix finding. Doc-only;
no behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backwards comment fixed+landed (8671fda); Δ-probe showed ~48% greedy-regret
share in expand_and_reinsert on Zanol; exact-scorer port prepared+validated on
worktree (41b0d237); heavy A/B path-killed (no mission dataset >=120t, so
prune_reinsert never auto-enables) → land+A/B deferred to composition #40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ts_search.cpp spr_search uses the bounded scorer but is off-default
(sprFirst=FALSE everywhere), exact-verify-gated (never false-accepts), and a
warmup washed by the subsequent exact tbr_search → silent-miss mooted, no
action. All remaining union-of-finals sites accounted for and benign.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Read of ts_driven.cpp orchestration: per-phase score_tree prints are
verbosity>=2-gated (off at default v=1L); only un-gated full rescores are the
2 per-outer-cycle convergence checks (~µs each, ~0.001% wall, one redundant but
sub-floor) + 1 final/replicate. Step-switching minimal (each phase owns its
state). R/C marshalling already T-P5o'd as amortizable. Last undone non-gated
aspect of the isolation plan; addressable wall now lives in composition #40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ms609 and others added 30 commits July 28, 2026 07:04
Direct internal .Call with a hand-crafted xformConfig and a short
tip_states vector would read past its end in the per-tip loop.
Same internal-boundary hardening class as T-323/T-328.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… topology (T-369)

Tip placement always ranks candidates the same way regardless of the
numeric concavity value used; only the reported $score differs.
Verified empirically across concavity = Inf/10/3 on Longrich2010.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
FirstHit() reassigns r$trees via WhenFirstHit() without updating
r$treeHash, breaking the documented invariant that every r$trees
write must be paired with a hash update (app_state.R:34) that ~9
downstream caches key off. Currently latent (WhenFirstHit only sets
an attribute and only fires for legacy seed/start/ratchN/final tree
names nothing here produces), but a real invariant break waiting to
bite the next extension of WhenFirstHit().
T-335: name the five stale per-pattern arrays (n_patterns/min_steps/
pattern_freq/precomputed_steps/plane_state) at the EW-only column-reduce
gate so widening it toward IW/weighted scoring can't silently skip
rebuilding them.

T-338: mark every worker-thread-reachable getenv()/Rprintf/audit-static
site on the parallel resample path as inert-but-conditional, so a future
speed change on this path can't flip one live without noticing.

Both are dormant tripwires from dev/red-team/findings.md, comment-only.
…ental_rescore

Phase 2's early-termination check tested only "final_ unchanged" and
missed that the changed-prelim region from Phase 1 can lie below the
node where it stopped checking, leaving newly-created internals'
final_ stale at zero. Re-verified (not just trusted) that none of the
three callers read final_ before their next full uppass/rescore, so
Phase 2 was dead work; deleted it outright per the finding's
recommended fix, and corrected the now-stale comment in
ts_prune_reinsert.cpp that described the deleted uppass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Records the c1c6b30 fix (paired r$treeHash update in FirstHit) so
the log reflects closed status rather than open.
…_tree

expand_and_reinsert() scored the rebuilt backbone with score_tree(), which
on has_inapplicable datasets falls through to fitch_na_score and writes
NA-regime prelim. The insertion loop only patches inserted-path entries via
wagner_incremental_rescore (standard-Fitch only, no NA branch), leaving
tree.prelim in a mixed regime that compute_insertion_edge_sets() then reads
to choose reinsertion edges. Swap to fitch_score(), matching the sibling
call sites (ts_wagner.cpp:449, ts_sector.cpp:917).

No wrong final score can escape (full_rescore() is authoritative); this only
affects placement quality during prune-reinsert, which is opt-in and
default-off (pruneReinsertCycles = 0L in both R/SearchControl.R and
R/ts-driven-compat.R). A/B on Dikow2009 (NA data, cycles forced on) confirms
the swap changes search trajectory on some seeds without any crash or score
corruption in either arm.

Known follow-up, out of scope here: fitch_na_score's local_cost is only
written in the non-NA branch, which still corrupts wagner_incremental_rescore's
old_cost subtraction for NA blocks. Wants its own A/B (T-366 follow-up).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…atch)

Directed fix, not a rotation round: documents the finding fixed in
d94d76b (fitch_score vs score_tree in expand_and_reinsert), including the
A/B verification performed and the fitch_na_score local_cost follow-up
that's out of scope. last_focus left untouched per that convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tion

random_topology_tree() and random_constrained_tree() seeded edge_children
with both of the root's two children, but a degree-2 root means those are
the two halves of a single unrooted edge -- so it was sampled with roughly
double the probability of any other edge, violating the documented
uniform-random-tree guarantee (T-370).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test-ParsSim.R, test-MaddisonSlatkin.R and test-recode-hierarchy.R each
carried a Tier-2 skip_on_cran() guard the testing-strategy.md rules
never actually grant them (Tier 2 is test-ts-*.R only), so a dependency
regression in ParsSim/MaddisonSlatkin/RecodeHierarchy was invisible to
CRAN's own checks. Guard added in bulk commit 78b7414 without updating
the doc. Removed the file-level guards plus a stray per-test
skip_on_cran() on .MSSplitCount from the same commit; all three files
re-measured clean and fast (<5s, 0 failures) with NOT_CRAN unset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Moved from findings.md to findings-archive.md, citing 547f6ab (root-edge
double-counting fix) and the empirical uniformity check that confirmed it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ofile

`LengthAdded()` / `PolEscapa()` with `concavity = "profile"` derived `cont`,
`qm`, `qmApp`, `app` and `inapp` from the character the user supplied, then
scored the character `PrepareDataProfile()` returns.  That function replaces
the contrast wholesale with `rbind(diag(k), rep(1, k))` and renumbers every
token to `1:k` with `k + 1` denoting ambiguity, collapsing inapplicable,
partially ambiguous and singleton codings into it — so the stale row indices
named rows that no longer existed, and the `qm` / `qmApp` fallbacks
`rbind()`ed a row of raw width onto the prepared contrast.

Every character whose raw and prepared token spaces differed in shape errored
out; only a plain {0, 1, ?} character worked, by coincidence of index spaces.
Reproduced (pre-fix messages):

  0,0,0,1,1,1,2,2,3,?  `tip_data` values must be in [1, nrow(contrast)] (4); found 5
  0,0,0,1,1,1,-,-      `levels` length (2) must equal ncol(contrast) (3)
  0,0,0,1,1,1,2,2,3    `levels` length (3) must equal ncol(contrast) (4)
  0,0,0,1,1,1,2,3      `levels` length (2) must equal ncol(contrast) (4)

Note the last of these, not the third, is the case filed as (c): the filed
message needs two singletons, which empty both `qm` and `qmApp`.

Fix: move the whole block below the prepare step, keeping the pre-prepare
contrast only as `rawCont` for the user-input validation that must run on it.
`PrepareDataIW()` touches neither tokens nor contrast, so the equal-weights
and implied-weights paths are unaffected — verified `identical()` before and
after across ten character x concavity combinations.

Post-fix the profile path has no "-" column, so `qm == qmApp == k + 1` and
both fallbacks stay dormant; an inapplicable leaf is ambiguous either way and
still scores a zero-length change, as documented.

Tests: four regression cases with `concavity = "profile"` (the string appeared
nowhere in the file before), asserting exact zeros for ambiguity-collapsed
leaves, a strictly positive delta for informative ones, and agreement with an
independent recomputation in the prepared token space.  All four fail against
the pre-fix code.  covr on R/PolEscapa.R: 55/55 lines, 100%.

Reached users through the Shiny character-wise plot, which pipes the app's
concavity setting, which can be "profile", straight into LengthAdded().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…TreeLength)

T-365: records the fix, the ten-combination `identical()` EW/IW check, the
100% covr result, and two corrections for the next reader — the filed case (c)
message needs the two-singleton character (`0,0,0,1,1,1,2,3`), and the
`nr == 0` guard was deliberately not added because `TreeLength()` errors
before it, which is T-372.

T-372: new P3, found while fixing T-365 rather than by a finder sweep.
`PrepareDataProfile()`'s uninformative-character early return emits
`info.amounts` as `double(0)` rather than a matrix; the multiPhylo profile
path then raises "Not a matrix." where the single-tree path returns 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…per-edge loop

Constrained Wagner starts re-derived their constraint bookkeeping from
scratch for every candidate insertion edge.  Unconstrained starts pay
nothing either way: the whole block sits inside `if (constrained)`.

Three behaviour-preserving reductions, all in the constrained path:

1. `node_tips` was a fresh `n_node * nw` heap allocation, zero-filled once
   per inserted tip (~64 KB/step, ~32 MB per start at 500 tips).  It is now
   caller-owned scratch on the same non-zeroing-reuse contract as the
   insertion edge sets.  No per-call reset is needed at all: tip rows are
   the constant singleton {t} and are written once by ensure(), and internal
   rows are fully overwritten by the postorder union, whose write set is
   exactly the read set (tree.postorder).  The zero-fill only ever existed
   for words 1..nw-1 of a tip row, which exist once n_tip > 64.

   The larger allocation win is the one the finding did not name: `needed`
   was a `std::vector` constructed *per split per call* — ~25k malloc/free
   per start at 50 splits x 500 tips, against 500 memsets for `node_tips`.
   It is now scratch too.

2. `wagner_edge_violates_constraint` recomputed `tip_inside`,
   `has_prev_inside`, `has_prev_outside`, `cn` and the `cn == root` skip for
   each of the ~2i candidate edges, though all are invariant across the DFS
   for a given (tip, split).  They move to `wagner_collect_active_splits`,
   run once per inserted tip; the per-edge test drops from
   O(n_splits * nw) to O(#active splits) and now touches only `below`.
   Splits are collected in index order, so the per-edge loop still returns
   on the same first violation.

3. The per-split tip scan was an O(n_tip * nw) way of computing a popcount:
   a tip mask is the singleton {t}, so `needed subset of {t}` with `needed`
   non-empty forces `needed == {t}`.  At most one tip can match, only when
   exactly one inside tip has been added, and it is necessarily already in
   `added_tips`.  When it matches, the internal scan cannot fire either
   (it needs `sz < best_size == 1`, impossible for a superset of a non-empty
   set), so both loops collapse to a direct assignment.

Verified byte-identical, not merely same-tree.  A temporary probe logged
every insertion step's chosen edge plus an FNV hash over every
(candidate edge, violates?) verdict and over `cd.constraint_node`: all 1575
steps across 15 constrained AdditionTree() runs are identical before and
after.  Cases span nw = 1, 2 and 3 (60/100/130/150 tips, 5-10 splits), since
multi-word tip rows — the whole point of fix 1 — do not exist below 65 tips.
Returned trees `identical()` in 15/15.  test-ts-constraint-small.R 8/8 and
test-ts-constraint-multi.R 336/336 pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ad under T-324

T-368 -> fixed `37bca4b6`.  The row now records that the fix landed three
reductions rather than the two filed, and that the third is NOT the optional
postorder-mask rebuild the finding floated: that is ~4k word-ops/call against
~600k for the split loops, so it was deliberately left alone.  The real third
win was that the per-split tip scan was an O(n_tip*nw) popcount in disguise.
Also records a fourth, unfiled allocation win (`needed` was constructed per
split per call) that is larger than the one the finding named.

The verification method is worth the words: byte-identity over every
(candidate edge, violates?) verdict, not tree-equality — two different
decision sequences can reach the same tree.  Re-verified against each new
base while rebasing, since T-367 and T-370 both touch the same file.

Separately, files an ADJACENT EVIDENCE note under T-324, whose open question
is reachability of a satisfiable-constraint violation.  Constrained
AdditionTree() was seen returning violating trees for trivially satisfiable
constraints on the UNMODIFIED tip.  The note is explicit that this does not
settle T-324 — AdditionTree() calls ts::wagner_tree directly, so neither the
100-reshuffle retry loop nor violates_constraint_posthoc is on that path —
and that the mechanism is correlation, not a trace.  Recorded rather than
chased so the next round does not re-discover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ith a real differential

Commit d8c5999 justified TS_CHAR_ORDER with "the score is order-invariant
(covered by the existing 'Scores are correct after block reordering' test)".
That test never varied the ordering, and its loop assertion
`s >= score || s > 0` is satisfied by `s > 0` alone, so it could not fail on an
informative matrix. No test in the package referenced TS_CHAR_ORDER at all.

The invariance claim is TRUE -- red-team area 10 verified it empirically on a
non-identity permutation (180/180 positions moved) across EW/IW/XPIWE/profile
and per-pattern char_steps. This pins it, in the modes that can break it: IW,
XPIWE and profile index per-pattern arrays (ds.min_steps, ds.info_amounts,
populated at ts_data.cpp:447+, i.e. after the sort at :190), so a
block-slot-to-pattern mix-up would surface there and nowhere else.

Guards against this test going vacuous in turn, which was the live risk:
- multistate matrix (binary data -> MINORITY ties -> stable sort -> identity
  permutation -> green proves nothing), with expect_gt on the level count;
- is.finite() on every reference score, because waldo treats NaN as equal to
  NaN, so a NaN profile arm would pass every comparison silently;
- nTip 12 at 4 states keeps MaddisonSlatkin's exact cache within capacity, so
  info.amounts is exact and warning-free; nTip 14 spills to Monte Carlo.

Also two defensive fixes found in the same round:
- ts_sankoff.cpp: sankoff_score_char and sankoff_uppass both read
  postorder[n_internal - 1], i.e. postorder[-1], on a 0-internal-node tree.
- ts_data.h: total_words is the block sum rounded UP to even (SIMD padding),
  so block_word_offset[b] + blocks[b].n_states can be less than total_words
  for the last block; the comment claimed a plain sum.

Plus a red-team bookkeeping correction: escalation-backlog item 5(a) carried a
bug claim ("carry this first, it is the one item here with a hard numeric
repro") that had already been refuted on 2026-06-16 by the very area it was
routed to. Retired, with the arithmetic re-confirmed: the reported 0.16573 is
XPIWE phi/eff_k scaling, (2/11.3)(10.3/11) = 0.165728, not a fractional
min-steps. Two narrow sub-items survive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
covr's block-exclusion regex requires "nocov start", not "nocov begin";
"begin" only matches the single-line exclude_pattern, so the three
Rcpp::stop() guards between the markers were still counted as
uncovered. Verified via covr::tally_coverage that the guarded lines
are now excluded.
AdditionTree()'s C++ Wagner kernel roots arbitrarily (an artefact of
build_three_taxon_tree/insert_tip_at_edge), not on sequence[1] as the
docs claimed. Point users at TreeTools::RootTree() instead, and add a
regression test so the docs claim can't silently drift back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ur P1s in HSJ/XFORM

First-versioned measurement of area 10: its only dry rounds (2026-05-19,
2026-05-26) are pre-tier and version-unrecorded, so it had zero version-scoped
dry verdicts.

The result is a cautionary tale about tier state. The area looked mature, and
its Profile/IW half genuinely is -- every static residual (profile delta
capping, e/(k+e), precomputed_steps offset, info_amounts capping, concavity=1.0
sentinel, DAT-002 obs==0 reachability) was independently re-derived clean, as
was the char-ordering x per-pattern-indexing question on a proven non-identity
permutation. But ts_hsj.*/ts_sankoff.* were added to the scope row on
2026-07-03 and no finder had ever read them at any tier, so both dry verdicts
were dry about a different half of the area than the row described. Every P1
came from the unread half. Lesson recorded in focus-areas.md: a scope row that
grows does not inherit the dry verdicts earned before it grew.

Filed:
- T-373 P1  search silently no-ops when total_words==0; under HSJ/XFORM that
            does not mean "all trees score the same". Start tree returned
            bit-identical under maximal effort (10->10) vs a control moving
            13->8; ~1.075% of hierarchical bootstrap replicates feed an
            unsearched tree into user-visible support values.
- T-374 P1  HSJ/XFORM scores are rooting-dependent while the pipeline treats
            topologies as unrooted: reported best 15.5 vs TreeLength 16 15.5
            15.5 16 16 16 on the SAME six unrooted topologies (cross-RF 0).
            Trigger localised to ts_collapse_pool's tip-0 re-root, whose
            "rooting-invariant" comment is false for these modes.
- T-375 P1  HSJ bit-encodes contrast-row token indices as states, so "?" in a
            secondary scores as a concrete conflicting state.
- T-376 P1  primary_present compares a token index against a levels index. A
            confound-free 24-way token permutation (m==0, so fitch_label_char
            provably out of the loop) gives HSJ 2 or 3 for one identical
            dataset+tree -- the score is not a function of the data. On shipped
            Vinther2008.nex via ReadAsPhyDat, absent and inapplicable primaries
            read as present while "?" reads as absent.
- T-377 P2  TBR candidate scan hierarchy-blind (NNI is not; scope narrowed).
- T-378 P2  ratchet cannot reweight hierarchy-only patterns.
- T-379 P3  XFORM -2 sentinel discards known secondaries.
- T-380 P3  ts_sankoff_test/unpack_xform dimensions unvalidated.
- T-381 P3  min_steps clamp masks an invariant violation.
- T-382 P3  collapse reads stale local_cost/prelim (conservative).

Both haiku refutations were overturned, and re-verifying them was the
highest-value orchestrator work of the round:
- T-380: Rcpp::NumericMatrix::operator() is NOT bounds-checked. Matrix.h:174 is
  the only offset overload (i + nrows*j, no check), no size_t variant exists in
  Rcpp/vector/, and Vector.h:338 operator[] is cache.ref(i). Worse than filed:
  the wrong stride gives a silently-wrong in-bounds read before any OOB.
- T-378: hierarchy characters are in ds.blocks[] only when a pattern is SHARED
  with a free character; hierarchy-only patterns reach build_dataset already
  weight-zeroed by .NonHierarchyWeights and are erased at ts_data.cpp:236-238.

Also retired escalation-backlog item 5(a) pre-dispatch. It was flagged "carry
this first, it is the one item here with a hard numeric repro" and had already
been refuted on 2026-06-16 by the very area it was routed to; the 2026-07-27
re-queue read the entry that raised the signal but not the one that killed it.
Arithmetic re-confirmed: the "non-rational" 0.16573 is XPIWE phi/eff_k scaling,
(2/11.3)(10.3/11) = 0.165728, and at eff_k=10 it collapses to 0.166667 exactly
-- the correction is the identity when nothing is missing, which is why Lobo
agreed and Vinther2008 did not. Corollary now written into the backlog: before
re-queuing any residual, grep log.md for later rounds on the RECEIVING area,
not just the raising one.

Not reached, named so it does not become another orphan: the clipped-subtree
IW-screening follow-up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… is worse than filed

The user pointed out Hopkins & St John (2021) is in the Zotero library, which
closes the open design question the area-10 round left on T-374 and escalates
T-376 substantially.

DESIGN QUESTION SETTLED (HSJ half). The rooting-dependence is a BUG, not an
intended rooted objective. The paper defines the score as a MINIMUM over
internal-node labelings of a sum of SYMMETRIC dissimilarities over the branches
of an unrooted tree (p.3 "compute the minimal score"; p.6 keeps both
present/absent possibilities and "return[s] the minimal score"), so it is
rooting-invariant by construction. The fix is the paper's two-state DP;
fitch_label_char's single directional DELTRAN pick is neither the paper's
algorithm nor guaranteed minimal. XFORM remains open -- an asymmetric step
matrix is intrinsically rooted, so pinning a rooting may be correct there, at
the cost of a TBR rethink.

T-376 ESCALATED. The paper's p.6 identity "HSJ is equivalent to the Fitch
approaches when alpha = 0.0" fails 20/20 trees. The discriminator is
unambiguous: HSJ(alpha=0) equals Fitch over the NON-controlling primaries
exactly, with the shortfall equal to Fitch(char 1) on every tree -- so on an
ordinary MatrixToPhyDat layout the controlling primary contributes NOTHING,
which is the entire point of the HSJ method. Confirmed by calling
.HSJAbsentState() directly: levels = "- 0 1" gives absent_state=1, inapp_state=0,
while allLevels = "1 0 -" gives token("1")=0 -- so a PRESENT tip collides with
inapp_state and reads absent, and a "-" tip reads present.

This is the T-307 regression of 2026-06-15 in mirror image: that fix made "0"
primaries read as present (no gain/loss counted); this makes "1" primaries read
as absent (no gain/loss counted). It inverted the failure rather than removing
it, and its verification battery permuted `levels` rather than `allLevels`, so
it could not see this.

Adds dev/red-team/heavy-tests/hsj-paper-oracle.R -- four paper-derived checks
with citations inline, currently 2 pass / 3 fail, as the fix gate. Figure 1's
absolute numbers were re-derived independently first (left 6+alpha=7, right
3+2alpha=5, one consistent alpha=1, derived without choosing a root) to confirm
the reading of the formula before relying on it.

The oracle deliberately reports INCONCLUSIVE rather than green for rooting
invariance and the p.5 per-branch bound: both are inert while this bug holds the
alpha term at zero, so a green there would be vacuous -- the same failure mode as
the tautological char-ordering test replaced earlier in this round.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Promotes the experimental TS_STOP_PATIENCE knob to a real SearchControl
field, `stopPatience`, and uses it to ship a deeper implied-weights ratchet
for `sprint` and `default`.

`stopPatience` stops the search after N consecutive replicates fail to
improve the best score.  Unlike `perturbStopFactor` it is a FLAT count --
it refers neither to the tip count nor to the hit count -- so the replicate
at which it fires does not stretch as replicates become individually more
expensive.  That is what lets a deeper ratchet pay for itself: both other
no-improvement rules are indexed on replicates via hits, so under them a
change that makes a replicate better but slower delays the stop instead of
improving the answer.  The count resets on every improvement, so the search
stops at last_improved + stopPatience.  0 (the default) disables it.

The env var is gone: one knob, one path.  Nothing committed read it.

Preset values, implied weights ONLY, via .IwStopPackage() -- deliberately
disjoint by strategy from .IwRatchetDepth() so the two cannot both set
ratchetCycles, and scoped the same way (finite concavity, never overriding
a field the caller set):

  sprint   ratchetCycles 12, ratchetPerturbProb 0.25, stopPatience 20
  default  ratchetCycles 20,                          stopPatience 15

Measured over 68 training matrices x 6 seeds at k = 10 (two arrays, 4624
cells).  Each half fails alone: the ratchet is the quality lever (`default`
cyc20 alone scored better on 11 matrices and worse on 0, p = 0.001, reach
0.78 -> 0.84) but costs +25s of a 151s mean; patience alone degrades score
(`default` 1 better/15 worse, p = 5e-04).  Together, on the MEDIAN matrix:
sprint -26% wall (19 faster/5 slower) with score 4/0 and no MPT loss;
default -18% wall (33/11, p = 0.001) with score 9/3 -- favourable direction
only, NOT significant (p = 0.15).

A sweep over stopPatience {10,15,20,25,30} found score and wall both
MONOTONE in the value with no spike, so these are operating points on a
smooth trade-off rather than fitted constants.  Chosen on the median
per-matrix wall change and its sign count, not the mean: the mean is
dominated by the largest matrices and picks a value that makes the typical
matrix slower.  Residual cost, stated in NEWS rather than buried: 9 of 44
`default` matrices remain >10% slower, those where patience does not fire.

Equal weights, profile parsimony and `thorough`/`large` are untouched --
none was measured for this.

Tests: 33 assertions covering the mechanism, the fire-at-last_improved
identity, the plumbing round trip (the C++ read is presence-guarded, so a
dropped field would otherwise fail silently as "patience off"), validation,
and the scope -- every .IwStopPackage() branch, plus `strategy = "auto"`
under IW and EW, since auto resolves to exactly the two changed presets.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cters

PrepareDataProfile()'s early return set info.amounts to a bare double(0)
instead of a matrix, so scoring a multiPhylo under profile parsimony
against an uninformative character failed converting it to a C++
NumericMatrix ("Not a matrix."). Fixed to emit a zero-column matrix.

LengthAdded()/PolEscapa() crashed the same way for the same input
("argument is of length zero"), since it reassigns char to the
zero-character prepared dataset and then indexes it per leaf. Guarded
with an early all-zero return: ambiguating one already-uninformative
leaf cannot create information.

Adds regression tests for both TreeLength(multiPhylo, ...) and
LengthAdded(), and updates the PrepareDataProfile() empty-matrix
fixture. Marks T-372 resolved in dev/red-team/findings.md.
Follow-up to 323a0da.  The values shipped there were measured entirely
with nThreads = 1, and the parallel path is not equivalent -- so the
non-zero default landed on a path that had not been executed once.  Now
exercised, with two things the run falsified:

- The parallel rule counts a dry spell over replicates completed into the
  shared pool and is evaluated on the coordinating thread's 200 ms poll, so
  it fires later and less precisely than the serial rule: patience 5 on one
  40-tip matrix stopped at 6 replicates serial, 21 on two threads, 65 on
  four.  The `lastImprovement + patience` identity is serial-only, and
  `last_improved_rep` is 0 in parallel anyway, so the ?MaximizeParsimony
  sentence pointing readers at that arithmetic now says "in a serial
  search".  Documented in ?SearchControl and NEWS; noted at the call site
  that the wall saving will be smaller in parallel.

- If replicates are cheap enough that the whole search finishes between two
  polls, the rule never runs at all: the monitor wakes, sees
  `replicates_done >= max_replicates`, and breaks.  That granularity is
  shared by perturbStopFactor and consensusStableReps -- it is a property of
  the parallel monitor, not of this rule -- and it is why the new test uses
  a 40-tip matrix rather than the 8-tip one the serial tests share.

No behaviour change; the parallel implementation is as landed and does not
crash at 2 or 4 threads (worth checking given PR #258's MinGW heap
corruption on this path, and a restructured guard in the replicate loop).

Also pins `strategy = "none"` in that test, since 40 tips x 30 characters
makes `auto` resolve to `default` -- whose new implied-weights patience
stopped the control arm and made the comparison measure nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merged rather than rebased deliberately: 34 local commits from several parallel
sessions cite each other's SHAs inside dev/red-team/findings.md and log.md, so a
rebase would invalidate those citations and force the same two files to be
re-resolved up to 34 times. Precedent: 08a3820.

Conflict resolutions (all four were additive; nothing was dropped):

- dev/red-team/log.md -- kept the incoming T-366 directed-fix entry and set
  last_focus: 10. The merge base and remote both read 8; the remote correctly
  left it alone because its entry was a directed fix, not a rotation round.
  Local had advanced 8 -> 9 (area-9 round) -> 10 (area-10 round), so 10 stands.

- dev/red-team/findings.md -- BOTH SIDES HAD FILED A T-366 ROW for the same
  defect at the same file:line: the area-9 round filed the diagnosis, a directed
  fix session filed the fix. Collapsed into one row combining the area-9 row's
  mechanism trace (ts_fitch_na.h:43-68, the ts_sector.cpp sibling comment, the
  authority sites) with the fix row's applied fix, Dikow2009 A/B and
  "fixed d94d76b". Verification status is now stated precisely because the two
  rows disagreed: the FINDING was opus-verified; the FIX was not independently
  re-verified. Heed red-team's own advice to leave an ID gap when sessions run
  in parallel.

  Dropped the incoming T-362/T-363 rows as strict subsets -- the local versions
  add the "Resolved:" sections recording that both were fixed.

- R/AdditionTree.R + man/AdditionTree.Rd -- both sides rewrote the same @return
  block to correct the same rooting claim. Combined: local's fuller content
  (degree-two root, the rooting-invariance rationale, the <4-taxa clause) with
  the incoming link form (\href to the TreeTools reference page).

Also narrowed T-380 in light of what came in: its unpack_xform tip_states half
duplicates the already-filed T-344, now fixed by 70ae4d5. That fix guards only
the tip_states length in unpack_xform -- it does not validate cost-matrix
dimensions anywhere and does not touch ts_sankoff_test, which is what T-380 now
covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tree_fuse() had zero R_CheckUserInterrupt()/check_interrupt() calls in
its round/donor loops, unlike every other search module. For a large
pool-wide fuse (fuseInterval, sprint default 5) over a >100-tip pool,
the O(n_donors * n_splits^2) donor scan could run for many seconds
between other checked points, delaying Ctrl-C/Esc response well past
the ~1s target reported by the user.

Add ts::check_interrupt() at the top of the round loop and per-donor,
which correctly maps to R_CheckUserInterrupt() (serial) or the shared
atomic stop flag (parallel workers), matching the pattern used
elsewhere (e.g. ts_ratchet.cpp, ts_tbr.cpp).
A user watching a 182-tip, 420-character matrix with inapplicable tokens
throughout reported the search looked frozen. It was not. Measured, one sprint
replicate takes 1173 s, of which TBR is 582 s and a 3-cycle ratchet 549 s --
96% of the replicate in two phases that printed only on completion. Nothing
reported progress for ~10 minutes at a stretch, and the finest granularity
available was one line per replicate, i.e. one line per 20 minutes.

Heartbeat. Long phases now report their running best and time in phase,
overwriting a single line at a terminal and emitting discrete lines to a batch
log. Cadence defaults to 30 s / 120 s respectively, override or disable with
TS_HEARTBEAT_SECONDS (fractional values allowed; junk falls back to the default
rather than disabling).

Reporting is opt-in per call site via TBRParams::heartbeat_label, not opt-out.
A caller is the only thing that knows whether a search's running best is on the
user's objective: sectorial searches score a subtree, and the ratchet's
perturbation phase scores a reweighted matrix. Both legitimately run far below
the true optimum -- observed at 33 where the optimum was 79 -- so reporting them
would read as erratic progress. Only whole-tree, real-weight searches are
labelled, so any score printed is comparable with the final tree score.

Thread safety: every entry point no-ops unless ts::thread_stop_flag is null,
i.e. on the R main thread, so Rprintf is never reached from a worker. State is
plain globals rather than thread_local, given the MinGW emutls corruption fixed
in PR #253. The parallel path instead gains a wall-clock cadence on its existing
coordinator poll, which was already the only thread touching R there, and which
previously reported only when a replicate completed -- silent for hours on a
large matrix, and indistinguishable from a hung job.

Cost: the TBR hook piggybacks the existing per-clip interrupt poll at stride 64
rather than adding a clock read to the candidate loop, which would have reopened
a hot path the profiling campaign left at-limit. The ratchet hook uses stride 1,
since a cycle is coarse and a larger stride would silence it entirely on the deep
ratchets implied weights asks for.

Strategy partial matching. `strategy = "thoro"` previously warned "Unknown
strategy" and silently fell back to `default`. Any unambiguous abbreviation now
resolves; every preset's initial differs, so one letter suffices. Resolution
happens BEFORE the preset lookup and before .IwRatchetDepth()/.IwStopPackage(),
which test `strategy %in% c("thorough", "large")` by exact string -- resolving
later would apply thorough's preset while skipping its implied-weights ratchet
package, a worse failure than not matching because it looks like it worked.
pmatch() rather than match.arg() so a real typo still warns instead of erroring.

Also documents TREESEARCH_PROGRESS_FILE, which already existed in R/, the Shiny
app and dev notes but appeared nowhere in man/ -- the one mechanism that gives a
non-interactive run any progress at all, and it was undiscoverable.

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