C++ search - #210
Draft
ms609 wants to merge 1030 commits into
Draft
Conversation
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
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>
This was referenced May 19, 2026
…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>
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>
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>
…+ min_steps invariant guard)
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Manual testing underway; shiny app in particular has some usability issues.