Skip to content

C++ search - #210

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

C++ search#210
ms609 wants to merge 1030 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 21, 2026 06:27
…kernels stand

Adversarial re-audit (27 agents, 8 lenses): 18 candidates -> 3 survived -> 15
killed. Core kernel/TBR throughput verdicts STAND; no second getenv-class hotspot.
Survivors: fuse value stale post-reroot-fix (#55, re-measuring), sectorial
column-axis reduction (#56), x4 reroot wasted-block (#57, weak).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lock counter (#57)

#55: capture FuseResult + verbosity>=2 'Fuse attempt' print (pool size + n_exchanges)
to distinguish fires-but-useless from never-fires (pool-collapse).
#57: TS_AUDIT_PROBE-gated counter in fitch_indirect_cached_flat_x4 measuring blocks
scanned past each member's individual bail (the x4 'deepest-bailing member' ceiling).
Default build unaffected (counter fully #ifdef'd; print is verbosity>=2 only).

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

Measures the realized informative-within-sector fraction on the actual sectors:
a char is droppable iff some state is shared by ALL sector tips (incl HTU) -> 0
Fitch steps -> ranking-preserving. fp/tot_blocks = the no-bail precompute saving
(compute_insertion_edge_sets scans all n_blocks/node). Inert in production.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…s (TS_AUDIT_PROBE)

RAII timer to measure the no-bail precompute's share of SECTOR wall (the
load-bearing multiplier in #56's saving estimate). Inert in production.
…e namespace ts

The precompute timer's mid-namespace #include <chrono> made std::ratio parse as
ts::std::ratio (compile error under -DTS_AUDIT_PROBE). Move includes to global
scope. Default build unaffected (all ifdef'd).
…OLREDUCE)

Drops characters constant-within-{sector tips + HTU} (0 Fitch steps -> scores
stay exact) and re-packs informative survivors into fewer blocks, shrinking the
per-node block scan in the inner-sector TBR (esp. the no-bail precompute
compute_insertion_edge_sets). EW only (weight 1, no upweight, no inapplicable).
Off by default.

Validated (Hamilton 17533059): dScore=0 on 9/9 full searches, valgrind clean,
adversarial review verified the 0-step invariance + bit arithmetic. The review
also caught (and this fixes) a stale rd.subtree stride that would OOB. rss-
isolated saving: Giles 17%, Zhu 9%, Zanol ~0% (uniform ns=9 = least reduction =
the load-bearing case). Changes the search trajectory on mixed-n_states data
(dCand!=0, equally-optimal path) => OPT-IN, NOT a default flip. Before any
default-on: run a sector-score oracle (reduced vs full, same topology, mixed
state); an accept-gated search cannot discriminate a masked packing bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#55 fuse=dead-weight (drop); #56 col-reduction shipped opt-in 830b8cc (Giles 17%
Zhu 9% Zanol ~0%, dCand!=0 mixed-state -> opt-in not default, oracle before any
default-on); #57 ~1.9% EW sub-floor deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every MaximizeParsimony/SearchControl switch + preset + the opt-in env levers
(TS_SECT_COLREDUCE), each with a when-relevant assessment grounded in this
session's findings (ratchet 6, fuse=dead-weight, col-reduce mixed-state-only,
rasStarts, prune-reinsert >=120t, clipOrder, the 3x trailing-TBR consolidation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…5-6% slower)

Force-scalar A/B (17533065): GATE dScore=0 & dCand=0 9/9; speedup x4/scalar
Giles 0.939 / Zhu 0.945 / Zanol 1.001. ~1.9% waste ceiling not realizable
(x4 ILP covers it). All 3 audit survivors now resolved.

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

Jobs 17533071 (20-rep) + 17541277 (40-rep), 3 seeds EW. clipOrder=2 (tips-first)
~1.25x faster / ~26% fewer candidates, but biases the trajectory: clean ~1.5x win
on Zanol (uniform ns=9, 3/3 optima); +1 quality tradeoff on Zhu that 2x budget does
NOT recover; wall-unstable on Giles. Complements TS_SECT_COLREDUCE. Default stays 0L.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ve-NA mission wall) (#254)

* perf(na): incremental dirty rescore for exact_verify candidates (default-off)

exact_verify_sweep is ~95% of native-NA TBR/mission wall: it scores every TBR
neighbour of the converged tree by apply_tbr_move + full three-pass full_rescore
(NA needs the exact sweep because its indirect scan is approximate). This makes
each candidate an INCREMENTAL rescore instead.

- 3-seed dirty passes: fitch_na_dirty_{down,up}pass gain an optional third seed
  (default -1 = no-op, so the SPR accept path is unchanged). exact_verify seeds
  {nz (sibling reconnect), nx (regraft node), clip_node (covers the reversed
  subtree path + its rootward chain)} -- covering BOTH SPR and reroot candidates.
- Per candidate: dirty Pass1/Pass2 (O(dirty) not O(n)) + full Pass3 + per-pattern
  extract + compute_weighted (IW/XPIWE/PROFILE) or +ew_offset (EW); on reject,
  restore_prealloc_undo/restore_saved_states undoes the dirty state before
  restore_topology, keeping the base state valid for the next candidate.

Behind TS_NA_INCR (fast path) and TS_NA_INCR_AUDIT (cross-check vs full_rescore);
both DEFAULT OFF, so production is byte-identical (519 testthat pass; 3-seed
default -1).

VALIDATION:
- TS_NA_INCR_AUDIT: every candidate's incremental score byte-matched full_rescore
  -- 5486..13674 candidates each on Vinther/Longrich/DeAssis x {EW, IW}.
- End-to-end byte-identical search outcome (TS_NA_INCR on vs off): 18/18
  (3 datasets x 2 regimes x 3 starts).
- Element wall (exact_verify-heavy direct climb): Zanol 1.12x, Dikow 1.21x,
  scores identical. (Below the ~2.5x ceiling: NA final_ uppass propagation makes
  the dirty region large; Pass3/extract stay full -- headroom remains.)

Mission A/B pending (should translate, unlike the washed extract-fusion: this
speeds exact_verify directly rather than relocating work). NOT default-on /
not for cpp-search until the mission A/B + broader byte-identity confirm.

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

* perf(na): fuse per-pattern extraction into fitch_na_pass3_score (incremental path)

The incremental exact_verify path ran fitch_na_pass3_score AND a separate
extract_char_steps over the full tree -- both recompute the identical per-node
step bits (standard from local_cost, NA from the Pass3 needs_step formula).
Add an optional char_steps_out to fitch_na_pass3_score that buckets per-pattern
during its existing walk; the IW incremental path uses it and skips extract.

Unlike the dirty Pass1/2 (capped by NA's ~0.4-0.48 dirty fraction), this is a
redundant-walk ELIMINATION, not dirty-fraction-limited. Default-nullptr param
=> all other callers byte-identical.

Element wall (exact_verify-heavy climb), incremental + this fold vs legacy:
Zanol 1.23x (was 1.12x), Dikow 1.32x (was 1.21x); scores byte-identical.
Audit byte-matches full_rescore on every candidate; fast-vs-legacy 18/18
byte-identical; 519 tests pass (production unchanged).

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

* perf(na): make incremental exact_verify rescore the production default

Flip the na_incr gate to default-ON (kill-switch TS_NA_NOINCR restores the
legacy full_rescore path). The incremental dirty rescore is byte-identical to
legacy (per-candidate audit + 180/180 full-roster climbs + 40/40 mission cells)
and cuts native-NA mission wall ~25-30% (1.30x mean, p=2e-06). Disabled in
TS_NA_INCR_AUDIT mode (which uses full_rescore for decisions). getenv read is
per-convergence (exact_verify), not per-candidate.

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

* test(na): guard incremental exact_verify rescore (default vs TS_NA_NOINCR + audit)

Two enduring tests: (1) the default incremental path is byte-identical to the
legacy full_rescore (TS_NA_NOINCR) across Vinther/DeAssis x {EW, IW} x starts;
(2) TS_NA_INCR_AUDIT runs clean (per-candidate incr == full_rescore, else stop).

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

* Fix SearchControl expectation and spelling terms (#255)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
…efault-OFF (#B2)

Adds compute_collapsed_flags_aggressive: collapse internal branches of minimum
possible length 0 (final[p] & final[c] != 0 per char), validated bit-for-bit
against a brute-force MPR oracle (0/206 internal, fp=0). Gated TS_COLLAPSE_AGGRESSIVE
(default OFF), scoped to tbr_search neighbourhood reduction; pool dedup keeps exact
flags; NA datasets fall back to conservative. Byte-identical OFF (156/156 tests).

Inert on the char-rich roster (0% density at optimum) but the mbank corpus has a
char-poor tail where density is high; b2_speed_cell.R + hamilton_b2_speed_array.sh
run the wall-clock-to-optimum ON/OFF A/B there. Diagnostic export
ts_collapsed_flags_debug + b2_*.R probes + #40 handoff briefing.

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

Hamilton job 17591484 (896 cells, 10 char-poor + 4 control × 32 seeds):
faster-frac 42%, median speedup ≈ 0, dScore ≈ 0; project2144 ≈1.6× SLOWER.
char/tip ratio does not predict winners → no selector → CLOSED.
Add b2_speed_analyze.R (paired wall-clock-to-floor analysis script).
Briefing updated with "TESTED, NOT RECOMMENDED" TL;DR for #40.

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

Prototype: ts_driven.cpp / ts_parallel.cpp wired with TS_FUSE_PAIRWISE env flag.
Functional on 88t (validated), no quality gain over matched-wall-clock multistart
on 4 hardest datasets; TS reaches TNT optimum everywhere → recombination NOT a
quality lever, residual = budget. B1 closed; flag retained for completeness.
Add pairwise-fuse strategy briefing and zero-exchanges analysis for #40.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…est-known README

Add basin_diversity.R (B1 harness), fuse_efficiency.R (FeRun/FeAnalyze helpers),
basin_cell.R / hamilton_basin_array.sh / hamilton_basin_build.sh / hamilton_fe_array.sh
(Hamilton dispatch scripts), b3b1_endstate_probe.R (endstate sampling probe),
README-best-known-targets.md (canonical target derivation guide),
.gitignore (exclude *_pull/ *_partials/ *.rds *.pdf binary results).

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

The Monte Carlo profile-parsimony information estimate (used when the exact
solver is infeasible) scores random trees via mc_fitch_scores() -> random_tree()
in build_postorder.h.  random_tree() drew from an unseeded global Marsaglia MWC
generator whose state was a fixed initial constant that advanced across calls and
ignored R's RNG entirely.  Two MaximizeParsimony(concavity = "profile") runs with
the same set.seed() therefore drew different random trees, producing different
info.amounts tables and -- amplified through the search's tie-breaking -- different
final scores.  EW and IW were unaffected (they never touch this path); the default
reproducer happened to stop reproducing only because the exact solver now handles
that small dataset (auto -> exact), but the MC path remained non-deterministic for
any larger / infeasible character.

Fix: add seed_random_tree() to reseed the MWC generator, and call it from
mc_fitch_scores() with two non-zero 32-bit seeds drawn from R's RNG (the Rcpp
wrapper already establishes an RNGScope).  Profile search is now reproducible
under set.seed(); the exact path is byte-identical (no regression).

Regression tests: same-seed reproducibility of mc_fitch_scores() and
StepInformation(approx = "mc") (test-pp-multistate.R), and of a
MaximizeParsimony(concavity = "profile", profile_approx = "mc") search
(test-ts-profile.R).

Note: RANDOM_TREE / RANDOM_TREE_SCORE consume the same unseeded MWC generator
(same set.seed-blindness, same bug class) but are out of scope for this fix.

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

RANDOM_TREE and RANDOM_TREE_SCORE are plain .Call() entry points that wrap
the Marsaglia MWC generator.  Without GetRNGstate/PutRNGstate they ignored
set.seed() entirely.  Now both functions draw two non-zero 32-bit seeds from
R's RNG (bracketed with GetRNGstate/PutRNGstate) and call seed_random_tree(),
matching the pattern already applied to mc_fitch_scores (fe9dd6f).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pt-in) (#256)

* perf(iw): x4 reroot batch + extract_char_steps dirty-region for IW TBR

Cherry-pick of 69febb4 onto cpp-search (post-#254). Resolved the
exact_verify candidate-scoring conflict by keeping #254's incremental
rescore path and dropping the directional-audit (na_dir_audit) block
that #254 deliberately removed. Stripped the settled-dead
ts_iw_gather_bench microbench (gather micro-opt proven a dead heat) and
trimmed superseded dev A/B harnesses, keeping only bench_iw_realized.R.

Opts remain kill-switched (TS_IW_NOX4 / TS_IW_NODIRTY); a follow-up
commit flips them default-off for the shared branch. Mission-null at
morphology scale; kept for the large-N / recipe-retune reopen.

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

* perf(tbr): fire IW dirty/x4 opts on XPIWE; fix nx_cs active_mask underflow

The extract_char_steps dirty-region + x4 reroot-batch opts were gated to
ScoringMode::IW, but production MaximizeParsimony defaults to extended IW
(ScoringMode::XPIWE, ts_data.cpp:297). So the opts were dead on the production
path for every dataset, and the mission-wall A/B (which toggles TS_IW_NOX4/
NODIRTY through MaximizeParsimony) was toggling code that never executed -> the
1.005x "null result". Widen the gate to iw_family = (IW || XPIWE): both modes
score via compute_iw/precompute_iw_delta from the same weighting-agnostic
char_steps, differing only in per-pattern eff_k/phi (which those functions
already consume). PROFILE stays excluded (compute_profile + info_amounts +
precomputed_steps is a genuinely different char_steps convention).

Perf-only / byte-identical: opts-on vs opts-off scores identical across
Dikow/Vinther/Zanol/Giles/Wortley x 3 seeds; SCANCHK clean on XPIWE.

Firing the opts on the production path exposed a pre-existing dirty-region bug:
the nx_cs accumulation did not skip active_mask==0 blocks, but extract_char_steps
(which builds the F cache) does. Under a ratchet ZERO_ONLY perturbation (the
default, ratchetCycles>=3) that fully deactivates a block, divided_steps =
F + cs_delta - nx_cs went negative (TS_IW_DIRTYCHK: dirty=-1 ref=0 on
Dikow2009/Vinther2008). Weighting-independent (plain IW mismatches identically;
reachable today via MaximizeParsimony(extended_iw=FALSE)). The candidate scan
masks needs_step by active_mask so scores were unaffected, but the invariant was
broken. Fix: skip active_mask==0 in the nx_cs loop only; EW nx_cost is left
counting all blocks (its best_score/delta length convention is self-consistent).
DIRTYCHK now clean across 5 datasets x ratchetCycles {0,3,6}.

Add a testthat guard pinning XPIWE opts-on == opts-off (tolerance=0) under
ratchet (the discriminating config; prior tests ran plain-IW or ratchetCycles=0
and were tautological for this path). 398 existing tests still pass.

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

* perf(tbr): NA-IW 4-wide reroot batch (indirect_na_iw_cached_flat_x4) [perf TBD]

Port the T-245 4-wide reroot ILP batch to the implied-weights + inapplicable
(IW/XPIWE + NA) TBR scan -- the one scoring path that never received it. The NA
reroot scan already had an EW-NA x4 (fitch_na_indirect_cached_flat_x4) but
IW-NA/XPIWE-NA fell to the one-at-a-time scalar indirect_na_iw_length_cached;
the IW x4 branch was !has_na-gated.

New kernel indirect_na_iw_cached_flat_x4 (src/ts_fitch.cpp) fuses the NA
active-mask candidate logic of the EW-NA x4 (from1 reduce, shared
clip_has_active, per-candidate below_actives AND) with the per-candidate
iw_delta ctz-gather of indirect_iw_cached_flat_x4. Each accumulator keeps the
scalar add order of indirect_na_iw_length_cached, so per-candidate results are
bit-identical; the shared all-4-exceed-cutoff bail only changes early-exit on
cutoff-losing candidates. Wired by widening the IW x4 branch gate
(ts_tbr.cpp:1985) to dispatch on has_na, mirroring its own per-candidate skip
re-check and main_edges[ei] indexing; the no-NA path stays byte-identical.

Gated on iw_family (IW||XPIWE), so it fires on the production XPIWE+NA path
(MaximizeParsimony default) -- the first banked IW kernel opt that lands on the
NATIVE inapplicable-bearing corpus rather than only recoded matrices.

The dirty-region scan shortcut is deliberately NOT ported (stays !has_na): the
top-down down2 pass breaks the path-bounded F+cs_delta-nx_cs decomposition, and
it is the smaller (once-per-clip) prize.

CORRECTNESS validated, PERF still unknown (Hamilton A/B pending):
- byte-identical x4-on vs TS_IW_NOX4-off: 24/24 (15 direct IW-NA ts_tbr_search +
  9 MaximizeParsimony XPIWE-NA under ratchet; datasets at 10-38% NA)
- existing independent full-rescore oracle passes; 519 testthat pass
- kernel confirmed to execute (throw-probe), so the byte-identity is not vacuous
- new regression guard in test-ts-tbr-dirty-rescore.R keeps inapplicables

Kill-switch TS_IW_NOX4 (shared with the no-NA x4). NOT for cpp-search merge
until the A/B reports and the getenv-consolidation merge-gate is addressed.

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

* perf(iw): land IW/NA x4 + dirty-region opts default-OFF (opt-in)

Flip the kill-switch semantics from default-on (TS_IW_NOX4 /
TS_IW_NODIRTY) to opt-in (TS_IW_X4 / TS_IW_DIRTY). These opts are
mission-null at morphology scale (validated byte-identical, 1.005x wall)
and are preserved on the shared branch only for the large-N /
recipe-retune reopen, so they should impose nothing by default.

Test guards updated in lockstep: the "on" arm now explicitly enables the
opt so the kernel still fires (data triggers the iw_family + has_na gate
as before); byte-identity vs the scalar baseline is unchanged.

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

* dev(benchmarks): NA-IW x4 A/B harnesses + large-N matrices for reopen

Preserve the dilution-free element-level and mission-wall A/B harnesses
for the NA-IW x4 reroot batch (updated to the TS_IW_X4 opt-in env var),
plus the two large real inapplicable-bearing matrices (Sun2018, lobo)
the large-N reopen will need to re-test whether the scan share rises.

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

* fix(tbr): drop directional-audit orphan bundled in cherry-picked ts_tbr.cpp

The worktree's 69febb4 bundled the exact directional (Regime-C) NA
scoring audit/scorer into ts_tbr.cpp alongside the x4/dirty opts; the
header it needs (ts_fitch_na_directional.h, added separately by b2e03a9)
is not on cpp-search and was deliberately excluded (the directional path
is dead -- 24-89x slower than SIMD full_rescore). Strip the include,
na_dir_audit / na_dir_scorer setup, whole-tree cross-check,
build_clip_folds, and the per-candidate directional fast-path so
exact_verify_sweep matches cpp-search's (#254 incremental) version. Kept
<chrono> (needed by the iw_timing diagnostic). Builds clean.

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

---------

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

XSS/RSS/CSS ignored the timeout callback, letting an in-flight sector pass
overrun the deadline by up to one full XSS pass per worker.  On Agnarsson2004
(62t) the parallel path previously took 2.4-9.3 s with maxSeconds=2;
it now returns in ~2.7 s.

Changes:
- ts_sector.h: add <functional> + check_timeout=nullptr to all three signatures
- ts_sector.cpp: poll check_timeout at every existing check_interrupt site;
  forward it to all tbr_search() calls inside xss/rss/css_search
- ts_driven.cpp: pass check_timeout at all 6 xss/rss/css_search call sites
- ts_parallel.cpp: give workers a real wall-clock deadline lambda (not
  stop_flag, which is also set by target_hits and would break byte-identity)

No-timeout path is byte-identical by construction: check_timeout=nullptr
short-circuits the new branch and nothing else changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ndows CI hang) (#258)

* fix(parallel): move weighted-rescore char_steps off thread_local (MinGW emutls)

`fitch_score_ew`'s IW/profile full-rescore path kept its per-pattern step
buffer in a function-local `static thread_local std::vector<int>`. On MinGW a
thread_local with a non-trivial destructor is torn down via emutls when each
std::thread worker exits, and that teardown corrupted the heap across the
parallel search's repeated worker spawn/exit cycles — a Windows-only failure
in test-ts-parallel.R (the suite reached "Parallel hits_to_best" and the
process hung on a corrupted worker whose replicate never completed, so the
driver's main poll loop spun forever; on CI it surfaced as a hard error).
Linux uses native TLS and was unaffected (gcc-ASan + valgrind both clean).

Fix mirrors the evs_false_cache change already in this file's history: the
scratch now lives on DataSet as a `mutable std::vector<int> char_steps_scratch`.
Each parallel worker owns a private `ds_local` copy for its whole lifetime, so
this preserves the same per-thread, cross-call capacity persistence the
thread_local provided — without any emutls teardown. Single-writer per copy
(workers touch only their own ds_local; the prototype is used only in the
post-join single-threaded MPT phase), so no synchronisation is required.

Byte-identical scores; this is the last remaining `static thread_local` object
with a destructor on the worker scoring path. Verified on Windows: the full
test-ts-parallel.R sequence, which hung 2/2 before, completed 3/3 after.

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

* test(parallel): assert timeout behaviour, not a fragile wall-clock ceiling

"Parallel search respects timeout" asserted `elapsed < 15.0` for a 2 s budget.
That is a wall-clock precision assertion a shared CI runner cannot guarantee:
the per-test wall time is observed to vary from ~5 s to ~30 s on the same
machine (one in-flight sectorial pass per worker, plus scheduling jitter).

Replace it with a working-vs-broken discriminator: `expect_lt(elapsed, 60)`
(a working timeout returns in seconds-to-low-tens; a broken one runs the full
1000-replicate budget, >1000 s — a ~100x gap) plus `expect_lt(replicates,
1000L)`, keeping the `timed_out` flag check. Supersedes #257.

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

* test(parallel): drop the wall-clock timeout ceiling entirely (covr-robust)

The relaxed `elapsed < 60` bound still failed the Windows covr step at 84.7 s:
covr rebuilds the C++ engine -O0 + gcov-instrumented, so the single in-flight
sectorial pass that overruns the 2 s deadline runs ~10-20x slower than the
optimised build — slow code, not a broken timeout. Any wall-clock ceiling is
fundamentally unworkable under instrumentation.

Drop the elapsed assertion: `result$timed_out` (set only on the deadline/cancel
path) plus `result$replicates < maxReplicates` (proves the search stopped before
exhausting its budget) are a robust working-vs-broken discriminator with zero
wall-clock dependency, holding on any hardware and under covr. A real unbounded
run would exhaust the budget (replicates == 1000) and fail the second check; a
true hang would never return (caught as a job timeout, not this assertion).

This surfaced only after the emutls fix let R CMD check pass on Windows, so the
covr step finally ran the parallel tests to completion.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ms609 and others added 30 commits July 28, 2026 13:15
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>
…y are swept

findings.md cited both P1 reproductions by session-temp paths
(...\scratchpad\V7.R, a1001d.R). That directory gets swept, and the skill's own
tidy doctrine says artifacts backing an OPEN finding are live and must never be
swept -- so the two load-bearing scripts are now in the repo, re-run, and
confirmed to still reproduce the verifiers' exact numbers:

- hsj-token-permutation.R  (T-376, from V7.R)
    sweep 1 (zero secondaries, so fitch_label_char is provably never entered)
    gives HSJ 2 or 3 for one identical dataset+tree; sweep 2 gives 1, 2 or 4;
    BGS control constant in both. Documents why permuting matrix ROWS is the
    wrong experiment (pick_state's tb_mintip tie-break makes row order change
    tip numbering), and asserts PhyDatToMatrix() byte-identical per iteration.

- hsj-totalwords-zero-noop.R  (T-373, from a1001d.R)
    zeroW returns the start tree bit-identical under maximal effort (10 -> 10)
    while the one-non-zero-weight control moves it (13 -> 8). Documents why the
    tempting "returned 8, sampled 6" comparison is confounded by rooting.
    Exits 2 INCONCLUSIVE if the CONTROL fails to move the tree, so a broken
    harness cannot masquerade as a confirmed bug.

Both take an optional library path as argv[1], matching hsj-paper-oracle.R, and
exit non-zero while their bug is live so they work as fix gates.

Scripts NOT ported are named explicitly in their finding rows (V2/V3/V4 layout
and 57%-invariant sweeps; a1001e/f/h bootstrap reachability and topology
enumeration) -- their results are recorded, but re-deriving them needs fresh
scripts rather than a rerun.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`R-CMD-check.yml` runs covr/codecov only in Wave 2, which is gated on
`sense-check`; sense-check has been failing on cpp-search, so Wave 2 was
skipped and coverage has not run on recent work. `tests/spelling.R` is one of
the two reasons.

Eight of the nine words are legitimate technical vocabulary and go into
inst/WORDLIST: pectinate, preset's, reweighting, run's, segfaulted,
trifurcation, unparseable, unrooting. Possessives match the existing
convention there (dataset's, replicate's, TNT's).

"erroring" is not a word, so it is reworded to "throwing an error" rather than
added -- once in the `strategy` roxygen (regenerating man/MaximizeParsimony.Rd)
and twice in comments, so the same non-word does not sit in the source waiting
to reappear in a future .Rd.

The pre-existing sort disorder at inst/WORDLIST:138 (RAS after RJ) is left
alone: it is not new, and reordering adds diff noise to a file that concurrent
sessions also touch.

Verified: tests/spelling.R, run from tests/ as R CMD check runs it, reports
"All Done!"; spell_check_package() finds no errors. This does NOT make
sense-check green on its own -- test-AdditionTree.R:93 still fails (open bug
T-364) and is untouched here.

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

T-380 (ts_sankoff_test dimension guard): Add checks to ensure cost_matrices and
tip_states_r have correct dimensions before accessing them. Missing dimension
validation could silently read wrong values due to unchecked matrix indexing.

T-381 (min_steps invariant violation): Replace silent clamp with error when
precomputed_steps > min_steps_r. This invariant violation indicates a bug in
simplify_patterns or invalid user input; raising an error prevents silent IW
score inflation.

Tests added:
- test-ts-sankoff.R: 5 new tests for cost matrix and tip_states dimension guards
- test-t381-min_steps-guard.R: 3 new tests for min_steps invariant validation
- test-ts-simplify.R: Fixed one test that was passing invalid min_steps (now
  uses default instead of explicit zeros)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Mark T-380 (ts_sankoff_test dimension guard) and T-381 (min_steps invariant
guard) as FIXED by commit 0856748.

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

compute_collapsed_flags's condition 1 read tree.local_cost for every
standard block unconditionally, but fitch_downpass (ts_fitch.cpp:85)
skips blocks with active_mask == 0, leaving their local_cost stale.
The stale value only ever made condition 1 fail (under-flagging
collapsible edges), never caused a spurious collapse, so this was a
lost optimisation rather than a correctness bug.

Mirror the active_mask == 0 skip that ts_fitch.cpp and the already-
correct compute_collapsed_flags_aggressive both use. Left the
full-row memcmp (condition 3) as-is with a comment recording that its
staleness is one-sided-safe, since masking it per-word wasn't judged
worth the complexity for a P3.

Verified the MPT set returned by MaximizeParsimony() on a real,
ratchet-heavy run (congreveLamsdellMatrices[[1]]) is unchanged before
and after the fix (same score, same split-set).
…apse kernel)

# Conflicts:
#	dev/red-team/findings.md
…t the NA wall is

Two changes to the exact_verify_sweep subsystem: a latent cache-key defect, and the
instrumentation that found out where native-inapplicable search actually spends its time.

weight_fingerprint() keyed the exact_verify_sweep optimum cache on active_mask,
upweight_mask and pattern_freq -- the only scoring state that varied for a given
DataSet when it was written. Concavity and scoring_mode were omitted, so before this
change k=10 and k=40 hashed identically (2132fc2543dc37a9). Nothing on the current
default path scores one DataSet under two regimes, so this was latent rather than live;
anything that does -- a concavity sweep, or a phase swapping in a cheaper surrogate
scorer -- would have had a "genuine optimum" verdict recorded under one regime served
to the other, suppressing a real improving move with no symptom but a worse tree.
Both fields are now mixed in, pinned by a new test-ts-na-evcache.R case that fails
without them.

TS_NA_TIMING (default off) decomposes tbr_search into disjoint brackets --
exact_verify_sweep, the below_actives_cache build, compute_from_above + vroot, the
accept-path rescore, and the remainder -- accumulated onto the DataSet so one call
yields whole-search totals, and exposed on ts_tbr_search and ts_ratchet_search. The
clock is sampled only when the flag is armed: three of these sites are per-clip or
per-accept, where an unconditional steady_clock::now() would tax the default path.
A positive remainder is the check that the brackets do not overlap.

What it found, over four matrices spanning 7-64% inapplicables and 23-88 tips:
exact_verify_sweep is 97.7% of native-NA tbr_search wall (89.4-99.6% individually),
while the per-clip scaffolding is 0.08% and the accept path 0.4%. That reverses the
standing diagnosis, which named below_actives_cache as the first suspect -- it is
0.001 ms per build. The sweep never touches n_candidates_evaluated, which is why
candidate-based metrics measured the ~2% and were blind to the rest. Its memo cache
hits 2.1%: it stores only true-optimum verdicts, and a still-improving search does not
revisit the same (topology, regime) pair. Details and both candidate levers in
dev/profiling/na-exact-verify-dominates.md.

No default behaviour change beyond the cache-key fix. 866 assertions green across the
NA, TBR, drift, sector, ratchet, IW and parallel suites.

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

Investigation only; no src/ change.

Decision: do NOT pin a rooting for XFORM. Three results, all in
dev/plans/2026-07-29-t374b-xform-rooting-policy.md and backed by the new
pure-R oracle dev/red-team/heavy-tests/xform-rooting-oracle.R (no build
needed; exits 0 while its stated predictions hold):

1. The cheap fix is REFUTED. forced_root_state = 0 ("absent at the root")
   makes rooting-dependence far worse — 84-117/120 random 9-tip topologies
   vs 0-34/120, spread up to 5 vs <= nSec — and makes even the *symmetric*
   nSec = 0 control rooting-dependent (0/120 -> 84/120). Pinning the root
   state does nothing about the root position and removes the
   min-over-states slack that was absorbing it.

2. The dependence is bounded and one-directional. The asymmetric part of
   the x-transformation cost matrix is a gradient, so the root is charged
   f twice; tested spread bound is nSec per block (an earlier nSec/2 guess
   was falsified — rooting subdivides an edge). An arbitrary rooting always
   OVERstates the min-over-rootings objective; 87-98% of rootings attain
   it. So the search optimises a sound but loose upper bound.

3. TBR is not the obstacle. TBR's fragment reroot is an unrooted-topology
   change, not a rooting change, and the default path never physically
   reroots the whole tree. Whole-tree rerooting is four enumerable sites.

Recommended fix is a reporting fix: make MaximizeParsimony's reported
score and TreeLength() agree on one rooting, and document XFORM as
rooting-sensitive. A/B verdict: do not run the rooting-pinned A/B.

Also files T-383 as a candidate (unverified, code-read only): the Sankoff
term is absent from XFORM's TBR candidate screen, so move selection is
Fitch-guided, and whether the convergence sweep sees the term depends on
whether non-hierarchy characters happen to carry gaps. First-order, and it
must be settled before any rooting measurement is interpretable.
Review caught an over-claim in the previous commit. The Q-C measurement
compared spread-across-rootings of a root-FREE criterion against that of a
root-PINNED one. Those are different criteria, so the comparison cannot
support "pinning the root state is strictly worse". The nSec = 0 pinned
cell (84/120) was cited as decisive and is in fact a tautology:
constraining the root's label on a symmetric matrix must make the root
position matter.

The recommendation is unchanged and unaffected — it rests on Q-E (an
arbitrary rooting always OVERstates the min-over-rootings objective;
87-98% of rootings attain it) and on the T-383 blocker, both sound. Only
the argument changes:

- Option 1 is rejected as INCOHERENT ON ITS OWN, not as harmful: pinning
  the root state makes the root meaningful while leaving its position
  unchosen and, at four sites, moving, so the score comes to depend on a
  root the user never selected. It is only meaningful together with
  pinning the position, i.e. it is Option 2, not a one-liner.
- Q-E promoted to lead evidence in the recommendation.
- The nSec = 0 row is now marked as an asymmetry control for the FREE arm
  only, and the two arms as non-commensurable.

Oracle gains the discriminating control: the pinned-state score is
verified reproducible at a fixed canonical rooting, making explicit that
the pinned criterion is well-defined and its spread is purely the
unchosen-root cost. Still exits 0.

Also notes that T-383's number was claimed as max+1 and may collide with a
concurrent session, so a tidy pass can renumber freely.
…new finding

Caught before landing. The hierarchy-blind TBR candidate screen I derived
from code and filed as T-383 is T-377, "TBR's candidate scan is
hierarchy-blind, so moves that improve the HSJ DP or Sankoff term at a
cost in Fitch steps are never proposed" (P2, verified, ts_tbr.cpp:2206) —
and it is being worked on concurrently in claude/t377-hierarchy-screen.

- Delete the duplicate T-383 row.
- Fold the three genuine increments into T-377's row instead:
  (a) "seen solely by the accept-time full_rescore" is incomplete and the
      gap is has_na-CONDITIONAL (ew_directional at :1739); under XFORM
      has_na is data-dependent, since non-hierarchy characters keep their
      gaps, so search behaviour changes with an incidental property of the
      data;
  (b) the `dominated` test is systematically over-permissive — Fitch-only
      candidate vs Fitch+Sankoff incumbent — a wall-clock cost T-377 did
      not record, and it makes the per-clip argmin the Fitch argmin;
  (c) T-377 gates T-374's XFORM A/B.
- Repoint every T-383 reference in the T-374 row and the decision document
  to T-377, and note the concurrent worktree so this is coordinated rather
  than duplicated.

No change to the recommendation: it rests on Q-E and on T-377, both intact.
XFORM's `-2` tip-state sentinel ("present, some secondary unknown")
previously freed every present state regardless of any secondary that
WAS observed, discarding real information and biasing the search
toward zero-cost placements.

RecodeHierarchy() no longer breaks out of its per-secondary loop on
the first unknown token; it now records, per tip, which secondaries
were resolved (`tip_sec_known`) alongside the block's existing
`combo_grid`, and exports both on the block list. Both C++ consumers
of the `-2` sentinel -- unpack_xform (the live ts_driven_search /
ts_collapse_pool path) and ts_sankoff_test (the TreeLength()/test-only
kernel, via two new optional trailing params defaulting to R_NilValue
for backward compatibility) -- now intersect against these to build
the admissible state set instead of freeing everything.

Chose to pass per-secondary tokens through and let C++ build the
admissible set (rather than enriching the sentinel itself), since the
combinatorics don't fit in one spare integer once more than ~5
secondaries are in play. Left ts_data.cpp's token_states (the T-375
plumbing) untouched -- XFORM's secondaries live in the block's own
R-side level space, not the Fitch contrast-token bitmask T-375 stores,
so there is nothing to share yet.

Adds a regression test showing a 2-known/1-unknown tip whose known
secondaries conflict with a fully-resolved comparison tip now scores
strictly more than the old free-ride of 0, on an isolated 2-tip tree
(a 3rd tip needed only to register a secondary's second informative
level is excluded from the scored tree, since a 3-tip topology's
Hamming geometry can mask the effect entirely).

Gotcha: ts_sankoff_test's signature is manually mirrored in
src/TreeSearch-init.c; Rcpp::compileAttributes() does not touch that
file, so a param-count change needs a matching manual edit there too.

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

`AdditionTree(constraint = )` silently returned constraint-violating trees for
about one addition order in eleven.  A constraint split is an *unrooted*
bipartition, but `wagner_collect_active_splits()` could only express "inside" as
a rooted subtree, so when the added inside tips straddled the construction root
their LCA was the root and the split was skipped.  An LCA never moves back down,
so the skip latched: one unlucky base triple left the constraint unenforced for
every remaining insertion, and no warning was emitted because
`constraint_fallback` never fired.

Enforce through the complement instead.  At most one side of a split can straddle
the root, and making the outside set monophyletic displays the same bipartition,
so `wagner_map_constraint_nodes()` now also maps the complement -- but only for
the splits that need it, and `use_complement` latches so a mapped split costs one
postorder scan per step, not two (the latch is sound for the same monotonicity
reason the bug was permanent).  If both sides straddle, the partial tree already
fails to display the split and no insertion can repair it; that case is left to
the post-hoc check.

T-364 and T-370 are the same defect filed twice, and each had an unmerged fix
(`bdc32fb2`, `355c4196`) written against the pre-T-368 layout, which still
contained the per-tip scan that HEAD replaced with the `n_needed == 1` shortcut.
This ports their shared technique onto the current layout rather than reverting
that, and takes `bdc32fb2`'s tests.

Tests are sweeps, not single calls, because the failure was order-dependent.  On
the code before this commit they report 35/400 seeds, 16/120 base triples (a
two-taxon group) and 42/120 base triples (a three-taxon group); after it, zero of
each.  The pinned `set.seed(1)` in the existing test is no longer load-bearing --
which matters, because that pin passed on Windows/x86 and failed on the ARM64 CI
runner, red-lighting `sense-check` and skipping the coverage wave with it.

KNOWN COST, constrained *search* only (`AdditionTree()` itself is unaffected --
it has `has_posthoc = FALSE`, so it never retried and never paid for retries).
On a 22-tip probe the constrained search is ~4.7x slower at an identical score
and identical constraint compliance.  The cause is not this fix: 19 of 200 start
trees now display the constraint with the *complement* as the rooted clade, and
`map_constraint_nodes()` demands an exact rooted-clade match, so it returns -1 and
`regraft_violates_constraint()` (ts_constraint.cpp:355) then rejects *every* move
-- the same rooting-blindness this commit fixes in Wagner, still present
downstream.  `reroot_at_tip0()` in ts_fuse.cpp already establishes exactly the
invariant that would fix it.  Filed rather than fixed here: it touches a shared
header and the fuse path, and wants its own measurement.

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

Completes the previous commit.  Enforcing a split through its complement is
correct but can leave the complement as the rooted clade, and the rest of the
constraint machinery is not rooting-agnostic: map_constraint_nodes() looks for a
node whose subtree mask *equals* the split (canonicalised by build_constraint()
with tip 0 outside), and when that search fails regraft_violates_constraint()
reads the -1 as "tree already violates" and rejects every move
(ts_constraint.cpp:355, whose own comment says this "shouldn't happen if the
starting tree is valid" -- it now could, because the tree was valid and merely
rooted elsewhere).

Measured on a 22-tip constrained search: 19 of 200 start trees displayed the
constraint with the complement as the rooted clade -- 9.5%, matching the rate at
which the original bug fired -- and the search took 4.7x longer to reach an
identical score with identical constraint compliance (0.33s vs 0.07s median,
three interleaved rounds, reproducible to 2 dp).  Every phase was slower on a
score-identical trajectory, which is the signature of moves being rejected rather
than of a different search.

Rooting on tip 0 makes the non-tip-0 side of every displayed split a clade, so
the mapping always succeeds when the tree really does display the split.
reroot_at_tip0() already existed in ts_fuse.cpp for the same reason (fuse
compares differently-rooted trees); it is now declared in ts_fuse.h rather than
duplicated.  After it: 200/200 rooted-clade, and the wall gap is gone -- 0.07s
vs 0.07s over three interleaved rounds.

So there was no correctness-versus-speed trade-off here to weigh: the cost was an
artefact of handing a downstream check a rooting it could not read.  This does
NOT resolve that rooting-blindness in general -- map_constraint_nodes() is still
exact-match, so any other producer of a validly-but-differently-rooted tree will
trip the same branch.

test-ts-wagner.R:394 asserted sisterhood of tips 1 and 2, which held only in the
rooting the constrained build used to return; it now tests for the unrooted split
it meant all along.  Verified with a from-clean rebuild (CCACHE_DISABLE=1, force),
since ts_fuse.h gained a declaration and R's build does not track header deps.

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

T-364 is marked FIXED against `7685bf07` + `796a29d3`, with the note that T-370
is the same defect and that the two branch fixes are superseded rather than
pending.  Without that the next finder re-hunts a closed bug, and the next
maintainer merges a stale fix.

T-384 is the defect found while fixing it, and is the more interesting one:
`map_constraint_nodes()` tests whether the canonicalised split is a *rooted
clade*, but a split is an unrooted bipartition displayed whenever either side is
a clade -- so a valid tree rooted the other way maps to -1, and
`regraft_violates_constraint()` answers that by rejecting every move.  Filed P2,
not P1: nothing wrong is returned, the search just cannot move.

The row records the measurement (19/200 start trees, 4.7x wall at an identical
score), the diagnostic signature that identified it (every phase slower on a
score-identical trajectory = rejected moves, not a different search), the three
fix options with the reason (a) is not free, and the honest scope of the current
workaround: re-rooting fixes the one producer, while fuse/sector/parallel still
call the mapping on trees they did not root.

It also flags that T-370's "~1.43x wall to match the old score" -- the figure that
kept that fix on a branch -- was measured in exactly the configuration this
finding describes, and that the gap vanished here once the tree was re-rooted.
Not shown to be the same artefact; flagged for re-measurement rather than
retracted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All six search-kernel entry points (nni_search, spr_search, tbr_search,
ratchet_search, drift_search, stochastic_tbr_phase) bailed out whenever
`ds.total_words == 0`, on the assumption that no Fitch words means every
topology scores the same. That's true for EW/IW/profile but false for
HSJ/XFORM: .NonHierarchyWeights() zero-weights hierarchy-only patterns,
build_dataset() drops weight-0 patterns, and the resulting total_words == 0
dataset's hierarchy DP / Sankoff term is still fully topology-dependent --
so the start tree was silently returned unsearched. Reachable via an
all-hierarchy matrix or, more commonly, an ordinary hierarchical bootstrap
replicate that (~1% of draws, by the binomial math) samples zero copies of
every free-character unit.

Add DataSet::topology_independent() (total_words == 0 AND hierarchy_blocks
empty AND sankoff_n_chars == 0) and use it at all six bail sites in place
of the bare total_words == 0 check. For EW/IW/profile this predicate is
identical to the old check (hierarchy_blocks/sankoff_n_chars are only
populated under HSJ/XFORM), so behaviour there is unchanged.

Removing the bail exposed a second, distinct hazard: several kernels'
candidate-scan machinery unconditionally constructs pointers into
total_words-sized buffers (tree.prelim, edge_set_buf, from_above, ...) via
`&buf[idx * total_words]`, which is UB on an empty vector regardless of
whether the result is dereferenced (confirmed empirically: aborts under
-D_GLIBCXX_ASSERTIONS on a full rebuild). tbr_search/spr_search/drift_phase/
compute_insertion_edge_sets/patch_insertion_edge_sets/try_root_edge_moves
are patched to either null-guard the pointer (harmless: every consumer
loops over ds.n_blocks, == 0 here, so the value is never read) or skip a
Fitch-only sub-phase entirely when total_words == 0 -- tbr_search's exact
accept-time full_rescore() remains the source of truth throughout, so
search correctness is preserved even where candidate screening degrades
(the same "screening degraded, accept exact" shape as T-377).

stochastic_tbr_phase (annealing) has no HSJ/XFORM-aware fallback at all
and stays a guarded no-op for that case; ts_driven_search now warns once,
on the R thread before any worker spawns, when annealCycles > 0 would
otherwise silently do nothing.

Verified against dev/red-team/heavy-tests/hsj-totalwords-zero-noop.R
(fails pre-fix, passes post-fix, both in a normal build and a full clean
-D_GLIBCXX_ASSERTIONS rebuild) and the full existing test suite (one
pre-existing, documented, unrelated flake in test-AdditionTree.R / T-364).
Adds an R-level regression test exercising the same ts_driven_search path
Resample()'s hierarchical-bootstrap code calls.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Update the T-373 row with the fix commit, what changed, and residual
scope (stochastic_tbr_phase stays a guarded no-op for HSJ/XFORM, now
warned at the R entry point). Row kept per convention -- tidy pass
archives it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Correct "all six bail sites" to five (ts_temper.cpp keeps the bare
  total_words == 0 check by design -- it has no HSJ/XFORM fallback at all).
- Confirmed the new R-level regression test genuinely fails against a
  pre-fix build (not vacuous) before confirming it passes post-fix.
- Replace "full suite passes" with what was actually run: test_dir on the
  installed tests, plus the three TREESEARCH_EXTENDED_TESTS-gated files
  that touch the changed kernels (ts-ratchet-stress, ts-resample-stress,
  ts-tbr-bench), all green.

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

`exact_verify_sweep` is 97.7% of `tbr_search` wall on inapplicable data
(dev/profiling/na-exact-verify-dominates.md), and nothing in `tbr_search` knew
whether its caller needed a certified optimum.  `certify_unrooted` says so;
internal sub-searches whose tree is never reported (ratchet, drift, sector,
fuse, nni-perturb, prune-reinsert, the anneal reconverge) clear it.

The gate is OPT-IN via TS_NA_NOCERTIFY, so this commit is byte-identical to its
parent: with the variable unset every caller certifies as before.  The default
flips only if the floor-attainment panel earns it -- skipping certification
returns worse trees, so this is a quality-for-speed trade, not a free win.

Two things the measurement note could not have known, both from the new
counters:

* `do_reroot` requires `tabu_size == 0`, and the shipped presets set
  tabuSize = 100 (default) / 200 (thorough).  The 97.7% was measured through
  ts_tbr_search / ts_ratchet_search, which leave it at 0.  Under the default
  preset the certifier is reached ONLY by the sector sub-searches (and the fuse
  cleanup), which leave tabu_size at 0 -- never by the ratchet, drift, or the
  driven polishes.  So the tree a replicate reports has never been certified,
  while trees nobody reports are certified repeatedly.
* Certification is not merely a proof: on Zanol2014 (74t) under the default
  preset it is worth 5 steps (1321 certified vs 1326 not), because under
  Brazeau's three-pass it is the only EXACT step, so it finds NA improvers the
  approximate scan cannot see.  Gating it is a real quality loss to be measured,
  not a formality.

Hence TS_NA_FINAL_CERTIFY (also default off): one certification of the tree each
replicate actually contributes, which is where the spend is worth something.
That is the third arm of the panel, and the answer to "should a cleared flag
still certify a new global best" -- certify the reported result once, rather than
threading the caller's incumbent through TBRParams.

`na_n_evs` / `na_n_evs_skipped` are counted unconditionally and surfaced on the
production entry point as `attr(, "naDiag")`.  Without the skipped count, "the
flag changed nothing" is indistinguishable from "the flag never fired" -- and
given the tabu_size finding, that is the likelier reading of a null result.

On the skip path `best_score` is re-synced explicitly: `exact_verify_sweep` did
that on both its exits, and a score that drifts from its returned topology
(ts_tbr.cpp:660 bug class) would read as a quality regression in the panel when
it is really a reporting bug.  Pinned by test-ts-na-certify.R.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five arms per cell, all on one node: A_certify (today) / B_gate / C_final at a
fixed replicate budget, then B_gate_mw / C_final_mw at arm A's OWN measured wall.
The matched-wall arms are not optional -- a wall win is spendable on replicates,
and a fixed-replicate comparison cannot see that.  Local pre-read: Griswold1999
seed 1 goes 407 -> 408 at fixed replicates and back to 407 at matched wall, on
64 replicates instead of 4.

Panel is all 30 bundled inapplicable matrices, native, 5 seeds, x tabuSize
{100, 0} = 300 tasks.  tabuSize is an arm dimension because do_reroot gates the
certifier on tabu_size == 0 and the shipped presets set 100/200: at 100 only the
sector sub-searches reach it (4 sweeps on Vinther2008), at 0 every whole-tree
search does (40).  Thirty matrices rather than the profile's four because a sign
test at n = 4 bottoms out at p = 0.125.

The analyser enforces the rules that have previously been got wrong here: floor
attainment not any improvements-per-second rate; one number per MATRIX before any
paired test; wall as median + sign count + count >10% slower, never the mean; and
n_evs_skipped printed per arm so a null result cannot be read as "the lever is
worthless" when it means "the flag never fired".

na_n_evs_improved is now counted unconditionally rather than only under
TS_NA_TIMING.  It is how often certification found a real improver rather than
merely proving optimality -- the mechanistic explanation of any reach the gate
costs.  Without it a panel can report that reach dropped but not why.

dev/profiling/na-certify-gate.md records two corrections to the premise of
na-exact-verify-dominates.md: the 97.7% was measured at tabu_size == 0, which is
not the shipped recipe; and certification finds improvers rather than only
certifying, so it is worth 5 steps on Zanol2014.

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

The TS_NA_FINAL_CERTIFY pass runs after the outer-cycle loop's own timeout
checks, and tbr_search polls check_timeout only every n_tip clips.  A replicate
reaching it with the clock already expired would still pay a whole O(n^3)
certification -- seconds to minutes on an 88-tip matrix.  That breaks the
maxSeconds contract for the user, and in the matched-wall A/B it silently hands
that arm more wall than the arm it is compared against.  Skip outright when the
budget is spent.

The analyser now audits this rather than trusting it: per-matrix
wall / mwBudget for every matched-wall arm, with a warning above 1.25x.  An arm
handed more wall than arm A is not comparable to it, and the overshoot grows with
matrix size because certification is O(n^3) -- so it grows exactly where the
lever matters most.  The panel array was built before this guard, so its
C_final_mw rows are the unguarded behaviour; the audit is how that gets caught
instead of assumed away.

Also records three ways to misread the panel: the pre-read is 43-55 tips and its
matched-wall recovery is least likely to hold on the 74-88 tip matrices where the
fixed-replicate gap was largest (5 steps on Zanol2014 vs 1-2 there), so a
size-conditioned default is the likelier honest outcome than a global flip;
tabuSize = 0 also disables the tabu list, so those rows bound the lever's size
rather than describing `default`-preset behaviour; and nEvsImproved is NOT
"improvers the gate cost you" -- what certification would have found in a run
that never ran it is unobservable by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read order matters: a matched-wall budget warning invalidates the *_mw rows, and
nEvsSkipped == 0 means an arm never fired, so both must be checked before the
floor-attainment table is interpreted at all.

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

7685bf0 closed the T-364/T-370 leak; this adds the check that would have made
it loud rather than silent, for whatever causes it next.

`constraint_fallback` was never that check. It only fires when the placement
filter rejected *every* edge, which the leak never did -- it returned violating
trees mutely, so the documented warning never once fired in 736 measured calls.
Nor does the caller's post-hoc retry cover it: `build_constraint_posthoc()` is
called only from the search entry (ts_rcpp.cpp), so `AdditionTree()` runs with
`has_posthoc = FALSE` and has no reshuffle to fall back on -- including for the
both-sides-straddle case that wagner_collect_active_splits() explicitly defers
to "the post-hoc check".

So verify the finished tree directly: a bipartition is displayed iff some
non-root node's subtree tip set equals one side of it. Orientation-agnostic, so
it stays correct however the tree ends up rooted -- including after 796a29d's
reroot, and for any future producer that roots differently.

Report it through WagnerResult and warn from the R thread. Rf_warning() was
being called inside the Wagner kernel, which also runs on search worker threads
where that is not safe; this removes the hazard rather than widening it.
It also surfaces random_wagner_tree()'s 100-attempt retry exhaustion, silent
until now.

Cost is one postorder scan per finished tree, O(n_node x n_words x n_splits),
against a build that is already O(n_tips) postorder scans.

Validated on the merged fix: 1334 randomised constrained cases (6-45 tips,
1-4 groups, nested constraints included) -- 0 violations, 0 warnings, 0
spurious. With complement enforcement disabled, 425 of those 1334 violate and
the check caught 425/425, still with no false positive on the other 909. The
added test sweeps for silence, since spurious warnings are the realistic
failure mode of adding a verifier.

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

Array 18080528, 300/300 COMPLETED.  30 native inapplicable matrices x 5 seeds x
tabuSize {100, 0}, unit of replication = MATRIX (n = 30).  Raw cells in
dev/profiling/na-certify-gate-hamilton.csv.

At EQUAL REPLICATES the gate regresses floor attainment at both tabu levels
(tabu 100: -0.100, 3 better / 12 worse, p = 0.035; tabu 0: -0.140, 0/12,
p = 0.00049).  By the stated rule it does not ship as a default, and the default
stays opt-in.

At EQUAL WALL the same gate wins decisively under the production preset:
B_gate_mw +0.167, 10 better / 1 worse, p = 0.012 -- while spending 10% LESS wall
than the arm it beats.  Every matched-wall arm came in at 0.90-0.97x arm A's wall
(maxSeconds is polled at replicate boundaries, so a replicate that would overrun
never starts), so those wins are floors, not artefacts.  Gains land where the
engine is weakest: Aguado2009 0.0 -> 1.0, Geisler2001 0.2 -> 1.0, Zhu2013
0.2 -> 1.0, Dikow2009 0.4 -> 1.0, Liljeblad2008 0.4 -> 1.0.

So the finding is NOT that certification is wasteful -- 16% of sweeps found a
real improver (1087 of 6957).  It is that certification is OVER-SEARCH: its wall
buys more reach spent on replicates.  Which regime production is in therefore
decides the default, and MaximizeParsimony defaults to maxSeconds = 0 with
maxReplicates/targetHits -- the replicate-bounded regime, where the gate loses.
Capturing the matched-wall win by default means gating AND raising the NA
replicate budget together: a recipe change, gated on its own panel, not a flag
flip here.

Firing is confirmed, so no arm's result is vacuous: at tabuSize = 100 B_gate
executed 0 sweeps against 5526 skipped -- under the shipped preset the gate
removes ALL certification.

Zanol2014, the hardest matrix in the corpus, is the honest counter-example:
B_gate_mw is -0.2 there at both tabu levels, so certification pays even at
matched wall.  C_final_mw recovers it and is the only arm in the panel with zero
regressions anywhere (5 better / 0 worse at tabu 0) -- the arm for the hard tail
if a difficulty-conditioned default is ever built.

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

Three corrections to the verdict as first written:

* "Set maxSeconds and gate" is NOT the arm that won.  B_gate_mw ran with
  maxReplicates = 1000 and completed a median 101-226 replicates on 23 of the 30
  matrices at arm A's wall (Zhu2013 226, Zanol2014 173).  The shipped cap of 96
  binds on all 23, so that recommendation would deliver a replicate-capped run,
  not the measured arm.  The recommendation is gating TOGETHER WITH a raised
  maxReplicates.  C_final_mw is the exception -- it never exceeded 33 replicates,
  so the cap never binds for it; it buys less but works with today's budget.

* The equal-replicate regression is uniform only at tabuSize = 0 (0 better / 12
  worse).  At tabuSize = 100 three matrices IMPROVED with certification removed,
  so the sign test reports a net, not a one-way mechanism.  The verdict is
  unchanged (12 > 3, p = 0.035) but the prose no longer claims a direction the
  data do not show.

* Zanol2014's per-block floor differs (1311 at tabu 0, 1312 at tabu 100), so its
  0.2 in the two blocks is not the same achievement -- flagged, since it is the
  matrix singled out as the counter-example.

C_final_mw's "zero regressions" softened to "no regression observed": 5/0 is
p = 0.063 on five discordant matrices.

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

Panel 1 neutralised targetHits/stopPatience so the budget alone ended a run, and
measured two budgets: 8 replicates (gate loses, p = 0.035) and matched wall (gate
wins, +0.167, p = 0.012).  Production is NEITHER.  MaximizeParsimony defaults to
maxSeconds = 0, maxReplicates = 96, targetHits = max(10, ntax/5), so a real run
stops on hits-to-best.  Nothing measured so far says where that regime falls, and
it is the only regime that can justify changing a preset default.

The mechanism at issue: targetHits counts hits against the CURRENT best, not the
true optimum.  A weaker gated search accumulates hits more slowly, so it runs MORE
replicates and gets more chances -- but if it reliably plateaus one step above the
optimum it accumulates targetHits hits on THAT score and stops early, confidently
wrong.  A local smoke on Longrich2010 shows the first effect live: B_gate needed
13 replicates to reach 10 hits where A_default needed 10, at under half the wall.

Six arms: A_default / B_gate / C_final untouched; B_gate_reps pairs the gate with
maxReplicates = 480, because panel 1's WINNING arm spent 101-226 replicates on 23
of 30 matrices and the shipped 96 cap would have bound on all 23 -- gating without
raising the cap is not the arm that won.  A_hits3 / B_gate_hits3 run at 3x the
default hit target, which answers "does anything need adjusting as targetHits
rises?" directly.

Records reps and hitsToBest alongside score and wall: "where did it stop, and
why" is the question, so a null on score with a large reps difference is a
finding, not a wash.

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

Presets unchanged today: neither panel-1 budget is the shipped one.  thorough
keeps certification permanently (its objective is reach, and the hard tail is
where certification still pays at matched wall).  For default/auto the candidate
is a PAIR -- TS_NA_NOCERTIFY plus a raised maxReplicates -- because gating alone
is the arm that lost and the 96 cap is what stops the freed wall being spendable.

targetHits needs no compensating adjustment and raising it helps, since more
replicates is the regime the gate won in; it is already the shipped escalation
signal (.IwRatchetDepth reads targetHits/defaultHits).  But it counts hits against
the CURRENT best, so it cannot rescue a search that plateaus one step high -- it
just buys more confirmations of the same wrong score.  maxReplicates is the knob
that pays for gating; targetHits is not.

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