Skip to content

fix: validate presence and shape at the HSJ/XFORM bridge - #49

Merged
ms609 merged 1 commit into
cpp-searchfrom
feature/hierarchy-guards
Aug 4, 2026
Merged

fix: validate presence and shape at the HSJ/XFORM bridge#49
ms609 merged 1 commit into
cpp-searchfrom
feature/hierarchy-guards

Conversation

@ms609

@ms609 ms609 commented Aug 4, 2026

Copy link
Copy Markdown

Fixes #14
Fixes #13
Fixes #22

Three guard clauses at the HSJ/XFORM scoring bridge (src/ts_rcpp.cpp) and the collapse gate (src/ts_collapsed.cpp).

#14 (T-398) — unpack_hsj() segfault on present-but-NULL hsjTipLabels

hsjConfig$hsjTipLabels == NULL (element present, value NULL) passed containsElementNamed() but was then skipped by !Rf_isNull(), leaving scoring_mode = HSJ with an empty ds.tip_labelsscore_hierarchy_block() then reads it unconditionally and segfaults. Fixed with an explicit presence/non-NULL check that Rcpp::stop()s. Review also surfaced a second entrance to the same crash class — a non-NULL but too-narrow hsjTipLabels that doesn't cover every hierarchy block's primary/secondary character index — closed with the same guard.

Pre-fix standalone reproduction (segfault, cannot be asserted from inside testthat since it kills the process):

$ Rscript -e "library(TreeSearch, lib.loc='.agent-hierarchy-guards-prefix'); library(TreeTools); testthat::test_dir('tests/testthat', filter='ts-hsj-xform-guards', package='TreeSearch')"
Segmentation fault
$ echo $?
139

#13 (T-397) — unpack_xform() had no dimension validation

Ported ts_sankoff_test()'s cost-matrix dimension check (guarded since 0856748f) and added guards for holes that function doesn't have either: combo_grid row count against n_states - 1, tip_sec_known dimensions (found in review), and out-of-range tip_states. Previously a mis-shaped-but-same-length cost matrix read garbage with no warning at all — Rcpp's Matrix::operator() only bounds-checks the linear offset, not (row, col) — and an out-of-range tip_states value corrupted the score via the 1e18 pool sentinel, which passes is.finite().

#22 (T-408) — collapse guard keyed on scoring_mode alone

ts_collapsed.cpp's two collapse guards (compute_collapsed_flags, compute_collapsed_flags_aggressive) disabled branch collapse for any HSJ/XFORM config, even one carrying no actual hierarchy data (hierarchy_blocks empty and sankoff_n_chars == 0) — a case collapse is provably safe for. Fixed to gate on hierarchy-data presence, matching the predicate DataSet::topology_independent() already uses (src/ts_data.h:292).

Verified the loosening is strictly confined to the no-data case: every config with real hierarchy_blocks/sankoff_n_chars still gets all-zero collapse flags exactly as before — the sibling #17 chip's reliance on this guard's current behaviour for non-degenerate configs is untouched. Confirmed by an independent external-reviewer pass and by the existing T-330 regression suite (test-ts-t330-collapse-hsj-xform.R), which still passes unchanged.

Pre-fix failures (confirmed by reverting src/ changes and rebuilding via the tarball recipe)

FAILURE: 'test-ts-hsj-xform-guards-no14.R:81:3' — Expected ts_driven_search(...) to throw an error. [cost_matrix]
FAILURE: 'test-ts-hsj-xform-guards-no14.R:115:3' — Expected ts_driven_search(...) to throw an error. [combo_grid]
FAILURE: 'test-ts-hsj-xform-guards-no14.R:149:3' — Expected ts_driven_search(...) to throw an error. [tip_states]
FAILURE: 'test-ts-hsj-xform-guards-no14.R:213:3' — Expected nrow(cp_hsj$trees[[1]]) to equal nrow(cp_ew$trees[[1]])
  actual: 10, expected: 8
[ FAIL 4 | WARN 0 | SKIP 0 | PASS 2 ]

(The T-398 test is excluded from this run since its pre-fix behaviour is a segfault that kills the process — see above.)

Post-fix

Full guard suite plus related HSJ/XFORM/collapse/resample suites: 509/509 pass, verified via a temp-library tarball install (R CMD build + R CMD INSTALL --library=.agent-hierarchy-guards) per AGENTS.md, not devtools::load_all().

Reviewed by an independent external-reviewer agent, scoped to correctness; it confirmed the three primary guards and the two adjacent gaps noted above, both now closed in this branch.

CI status

  • ubuntu-arm64 (full R CMD check + tests) — passed.
  • windows — fails at the "Set up R dependencies" step, but this is confirmed pre-existing, unrelated infra breakage: it reproduces identically on cpp-search tip itself right now (two direct cpp-search dispatches failed with the same signature in the last 20 minutes), DESCRIPTION is untouched by this branch, and it is root-caused and already tracked/fixed at #43 (MaxMin pak-resolution asymmetry between Linux/Windows pandoc auto-detect), verified working on feature/ci-maxmin-windows. Not duplicated here to avoid conflicting with that in-flight fix.

Mandatory checks

  • Rscript .claude/tools/compile-attrs.R — arg counts match (no new exports).
  • Rscript check_init.R — arg counts match.
  • spelling::spell_check_test(vignettes = TRUE, ...) (exact tests/spelling.R invocation) — clean.
  • No search-behaviour change; vignettes/search-algorithm.Rmd not touched (collapse behaviour on a degenerate config only, not the search itself).
  • NEWS.md updated with the one user-visible surface (Resample() replicates that drop every hierarchy block from a unit now collapse like ordinary Fitch data).

Three guard clauses at the HSJ/XFORM scoring bridge:

- unpack_hsj() (T-398/#14): a present-but-NULL hsjTipLabels passed the
  containsElementNamed() check but was then skipped, leaving scoring_mode
  = HSJ with an empty ds.tip_labels -- segfaulting score_hierarchy_block()
  later. Now requires hsjTipLabels non-NULL whenever hsjConfig is
  supplied, and (found during review) that it covers every hierarchy
  block's primary/secondary character index.

- unpack_xform() (T-397/#13): ported ts_sankoff_test()'s cost-matrix
  dimension check and added three more guards for holes that function
  didn't have either: combo_grid row count, tip_sec_known dimensions
  (found during review), and out-of-range tip_states. Previously a
  mis-shaped-but-same-length matrix read garbage with no warning at all
  (Rcpp's Matrix::operator() bounds-checks only the linear offset).

- ts_collapsed.cpp collapse guards (T-408/#22): keyed on scoring_mode
  alone, disabling branch collapse for an HSJ/XFORM config with no actual
  hierarchy data -- a case collapse is provably safe for. Now gates on
  hierarchy-data presence, matching DataSet::topology_independent()'s
  predicate. Verified the loosening is strictly confined to the no-data
  case: every config with real hierarchy_blocks/sankoff_n_chars still
  gets all-zero flags exactly as before, so the sibling #17 chip's
  reliance on this guard's current behaviour for non-degenerate configs
  is untouched.

Reviewed by an independent external-reviewer pass, which confirmed the
three primary guards and surfaced two adjacent gaps in the same
functions (hsjTipLabels shape, tip_sec_known shape), now closed
alongside them.

Fixes #14
Fixes #13
Fixes #22

Pre-fix failures (confirmed by reverting the src/ changes and rebuilding):
- T-398 standalone segfault: exit code 139 (SIGSEGV) running the compat
  wrapper with hierarchyBlocks set and hsjTipLabels left at its default.
- test-ts-hsj-xform-guards.R (T-397/T-408 portion, T-398 test excluded
  since a segfault kills the testthat process): FAIL 4 | PASS 2 -- three
  xform guards did not error, and the empty-hierarchy HSJ config
  collapsed to 10 edges instead of 8 (matching the plain-EW baseline).

Post-fix: full guard suite plus related HSJ/XFORM/collapse/resample
suites all pass (509/509), verified via a temp-library tarball install
per AGENTS.md, not devtools::load_all().
@ms609
ms609 merged commit b19d460 into cpp-search Aug 4, 2026
8 of 12 checks passed
@ms609
ms609 deleted the feature/hierarchy-guards branch August 4, 2026 16:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment