Skip to content

fix(hsj): translate token indices to state indices in HSJ scoring (T-375, T-376) - #280

Open
ms609 wants to merge 11 commits into
cpp-searchfrom
feature/hsj-token-index-fix
Open

fix(hsj): translate token indices to state indices in HSJ scoring (T-375, T-376)#280
ms609 wants to merge 11 commits into
cpp-searchfrom
feature/hsj-token-index-fix

Conversation

@ms609

@ms609 ms609 commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes red-team findings T-375 and T-376 in the HSJ (Hopkins & St John 2021) scorer — one defect family, fixed together as required.

.HSJAbsentState()/ds.inapp_state are STATE (levels) indices, but tip_labels (built by .BuildTipLabels()) holds TOKEN (allLevels/contrast-row) indices. primary_present and fitch_label_char()'s state-set construction both compared/used these directly, conflating the two index spaces. This is invisible whenever levels and allLevels happen to share the same order (common, but not guaranteed) — which is exactly why earlier fixes and their test batteries stayed green.

  • T-375: fitch_label_char() built Fitch state-sets as 1u << label (label = token index), so "?" scored as one arbitrary concrete state instead of the wildcard it denotes.
  • T-376: primary_present = (label != absent_state) && (label != inapp_state) compared a token index against two state indices, so the HSJ score was not a function of the data (varied across contrast-row permutations of an otherwise-identical dataset), and on the package's own shipped Vinther2008.nex (levels = "- 0 1 2 3", allLevels = "1 ? 0 - 2 3") absent/inapplicable primaries read as present while "?" read as absent.

Fix

build_dataset() already computes a token→state-set bitmask transiently (contrast_rtoken_states); it's now stored on DataSet (token_states + n_levels) instead of discarded. The HSJ kernel translates a tip's token label through this bitmask before comparing it to absent_state/inapp_state, replacing:

  • the token-index-as-bit-position hack in fitch_label_char(), and
  • the token-vs-state scalar == in primary_present, now genuine set membership (a[t]=0 iff the token's state set meets the absent-coding states, p[t]=0 iff it meets the present-coding states — so "?" correctly gives a=p=0, not a forced classification).

.HSJAbsentState() is unchanged (it already returned the correct thing — a state index — despite a stale docstring calling it a "token index", now fixed). No R-visible or Rcpp-exported signature changed.

Out of scope, deliberately: T-374 (HSJ rooting-dependence) is untouched. HSJ's rooting-invariance requirement needs the paper's two-state DP, which this PR does not implement — canonicalising rooting here would trade a wrong objective for a stably-wrong one. Confirmed T-374 still reproduces post-fix on other data; added a caveat to hsj-paper-oracle.R so a future session doesn't mistake its now-passing rooting check (on that script's own small matrix) for a T-374 fix.

Test plan

  • dev/red-team/heavy-tests/hsj-token-permutation.R: exit 1 (bug reproduces) pre-fix → exit 0 post-fix, both sweeps.
  • dev/red-team/heavy-tests/hsj-paper-oracle.R: 2 pass/3 fail pre-fix → 8 pass/0 fail post-fix.
  • Two new permanent regression tests in test-ts-hsj.R (contrast-row permutation invariance; a MatrixToPhyDat-emergent misalignment with a hand-derived absolute score) — verified to fail against a throwaway pre-fix build of cpp-search and pass post-fix.
  • Full existing test-ts-hsj.R suite (21 tests) plus test-tree_length.R, test-ts-t330-collapse-hsj-xform.R, test-ts-resample-hierarchy.R, test-ts-prune-reinsert.R, test-ts-t306-accept-guard.R, test-ts-xform.R pass unchanged.
  • Confirmed via a code audit that token_states/n_levels being empty on a build_reduced_dataset() result is safe: every HSJ/XFORM scoring path already bails before reaching a reduced dataset (same guards as T-303).
  • spelling::spell_check_package() clean; devtools::check_man() clean (regenerated one stale, unrelated .Rd example left behind by an earlier commit already on cpp-search).
  • GHA R-CMD-check green on all platforms (Linux/Windows/macOS, release/devel/ARM, incl. coverage jobs): https://github.com/ms609/TreeSearch/actions/runs/30698271349

🤖 Generated with Claude Code

ms609 added 4 commits August 1, 2026 12:24
…(T-375, T-376)

HSJ's primary_present test and fitch_label_char() both compared tip_labels'
token (allLevels/contrast-row) indices directly against state (levels)
indices -- absent_state, inapp_state, and bit positions in state_sets are
all in state space, but tip_labels is in token space. The two spaces
coincide often enough that earlier fixes and their test batteries (which
permuted levels, not allLevels) stayed green, but on real reductively-coded
data (e.g. Vinther2008.nex) absent/inapplicable primaries could read as
present while "?" read as absent, and the score was provably not a
function of the data under contrast-row permutation alone.

DataSet now stores token_states (the token -> state-set bitmask already
computed transiently in build_dataset()) and n_levels, so the HSJ kernel
can translate a token label into its state set before testing set
membership against absent_state/inapp_state, and before building Fitch
state_sets for secondary characters (fixing "?" scoring as a concrete,
conflicting state instead of a wildcard). Also corrects the stale "token
index" docstrings on .HSJAbsentState() and its caller, which described a
levels index.
…evels (T-375, T-376)

make_hsj_dat()'s construction keeps allLevels in the same relative order as
levels, so none of the existing absolute-value tests could have caught the
token/state index confusion -- confirmed by running this file's original
tests unmodified against a pre-fix build (all still pass, unchanged).

Adds: (1) contrast-row permutation invariance, lifted from
dev/red-team/heavy-tests/hsj-token-permutation.R, for zero and one
secondaries; (2) a MatrixToPhyDat()-emergent token/state misalignment
(levels = "- 0 1", allLevels = "1 0 -", the same shape as the shipped
Vinther2008.nex dataset) with a hand-derived absolute score, plus the
paper's alpha=0-equals-Fitch identity on the same data. Verified both new
tests fail against a throwaway pre-fix build of cpp-search and pass against
the fix.
Unrelated to the HSJ fix: R/MaximizeParsimony.R's @examples block was
updated by an earlier commit (18397db, "regenerate a MaximizeParsimony
example left stale by the effort rename") but man/MaximizeParsimony.Rd
wasn't regenerated to match. Caught by running devtools::check_man()
before dispatch, as this branch's fix required no doc changes of its own.
…374 fix

Verified while validating the T-375/T-376 fix: check [3] now runs live
(the alpha term is no longer inert) and passes on this small, Fig.1-derived
matrix -- but a fresh sweep over larger/messier matrices shows T-374's
rooting-dependence still reproduces readily post-fix, exactly as expected
since T-374 needs the paper's two-state DP, which this fix does not touch.
Add a comment so a future session doesn't read an all-green run here as
T-374 being closed.
ms609 added a commit that referenced this pull request Aug 1, 2026
Both fixed together on feature/hsj-token-index-fix. T-374 (HSJ rooting)
remains open and unaffected; noted that hsj-paper-oracle.R now reads
8 pass/0 fail (not 2/3) so a future T-374 session doesn't mistake that
for a T-374 fix.
ms609 added 7 commits August 1, 2026 14:35
…olution invariant)

The permutation and misalignment tests added earlier both use hsj_alpha=0,
which never calls count_mismatches() -- they exercise T-376's
primary_present term exclusively and can't see whether fitch_label_char()
resolves an ambiguous secondary correctly. This isolates T-375 with all
primaries "1" (so the primary DP is loss-free and the whole score reduces
to the secondary's Fitch step count) and checks T-375's own stated
acceptance criterion: score("?") <= min over concrete resolutions.
Verified to fail against a throwaway pre-fix build (scored 1, violating
the invariant against min(0,1,1)=0) and pass post-fix.
Sharpened by the T-375/T-376 fix: a bad value used to be a harmless wrong
scalar comparison, but now indexes DataSet::token_states directly
(out-of-bounds read) or shifts by absent_state (undefined for >= 32). Only
the internal ts_hsj_score() test bridge is reachable with hand-crafted
values -- public callers always derive tip_labels/absent_state from a
validated phyDat -- so this mirrors the existing validate_tip_data_values
guard rather than changing any public behaviour.
…in NEWS

hsj-paper-oracle.R's earlier caveat was a source comment on the branch that
never executes when check [3] passes -- a future session reading stdout
would miss it. Moved into a cat() so it prints on every passing run.

NEWS.md: HSJ scores under inapplicable = "hsj" can change from previous
versions now that the controlling primary's presence/absence and an
ambiguous secondary are read correctly -- log it next to the existing
x-transformation rooting entry, same "To integrate into 2.0.0" convention.
…ath too

/code-review (xhigh) flagged this independently across 6 of 10 review
angles: validate_hsj_tip_labels() only guarded the ts_hsj_score() test
bridge, leaving unpack_hsj() -- which populates the same fields for the
real ts_driven_search()/ts_collapse_pool() search path -- completely
unvalidated. This is concretely reachable, not just hypothetical:
R/ts-driven-compat.R's legacy flat-argument wrapper exposes hsjTipLabels/
hsjAbsentState as independent, hand-settable arguments feeding straight
into unpack_hsj(). Since this PR removed fitch_label_char()'s old
defensive clamp in favour of direct token_states[label] indexing, a bad
value here is now an out-of-bounds heap read instead of a harmless wrong
comparison.

Also tightens the absent_state bound from a hardcoded [0,31] (shift
safety only) to [0, n_levels) -- the dataset's actual state count -- since
a value in between passed silently but reproduced the exact T-375/T-376
symptom class (the primary is never classified absent). Adds a default
initializer to HierarchyBlock::absent_state for defensive symmetry with
DataSet::inapp_state's existing default.

Verified: a hand-crafted out-of-range hsjTipLabels/hsjAbsentState now
raises a clean Rcpp::stop() from all three entry points (ts_hsj_score,
and now ts_driven_search via the compat wrapper), where it previously
either mis-scored silently or would have read out of bounds.
/code-review flagged this independently via two angles: sizing
fitch_label_char()'s tb_cnt/tb_mintip arrays by the dataset-global
n_levels (instead of the old per-character state count) meant a dataset
with a high global state count but binary hierarchy secondaries paid up
to ~16x more allocation/scan cost per secondary character per HSJ
full-rescore call than before this PR.

The downpass only ever intersects/unions existing tip bits, so no bit
outside the union of tip state-sets can appear at any node -- accumulate
that union while initializing state_sets (one extra OR per tip, already
being read) and size K from its highest set bit instead of the global
n_levels. Restores the old tightness with no extra tree traversal.

Also replaces the tie-break credit loop's manual "scan for the single set
bit" with ctz64() (already declared in ts_data.h, already used
elsewhere in this codebase for the identical operation), removing a
7-line loop-with-break in favour of one call.

No test changes needed: this is a pure sizing/lookup optimisation, not a
behaviour change -- full test-ts-hsj.R (85 assertions) and the
hsj-token-permutation.R/hsj-paper-oracle.R heavy tests still pass
unchanged.
…ology, vignette

/code-review (xhigh) findings addressed:
- NEWS.md's new HSJ entry called T-374's rooting-dependence "intentional"
  (contradicts this repo's own settled finding that it's a tracked bug per
  the Hopkins & St John paper) and claimed scores are "generally
  increasing" (contradicted by this PR's own T-375 test, which shows a
  decrease case). Corrected both.
- .BuildTipLabels()'s own docstring (R/CharacterHierarchy.R) still called
  its output "state labels" -- the exact token/state conflation this PR
  fixed everywhere else except at the one place that actually produces the
  token-space data.
- A pre-existing test helper comment (test-ts-hsj.R) called absent_state a
  "token index"; it is a state index, per this PR's own terminology fix
  elsewhere.
- A new test's comment + expect_equal info= string cited a hardcoded
  ts_hsj.cpp line number that had already drifted from the code it
  described (and would surface the wrong line in CI failure output).
- ts_sector.cpp's two T-303 guard-rationale comments enumerated the
  DataSet fields build_reduced_dataset() doesn't copy, but didn't mention
  the two fields (token_states, n_levels) this PR added.
- vignettes/search-algorithm.Rmd wasn't updated despite AGENTS.md's
  mandatory-checks rule for any scoring-behaviour change (this PR's own
  NEWS.md entry documents exactly that); added a short note that HSJ
  scoring is correct regardless of a dataset's internal token ordering.

Also refactors the new contrast-row-permutation test's two near-identical
permutation-generation blocks into one shared .AllTokenOrderingScores()
helper, called twice -- flagged as duplicated logic that would otherwise
need fixing in two places.
GHA's R-CMD-check caught what my local spell-check missed: "HSJ's" isn't
in inst/WORDLIST (only bare "HSJ" is -- spelling::spell_check_test()
doesn't stem across the apostrophe, same reason "TNT's" needed its own
separate WORDLIST entry elsewhere in this file). Reworded rather than
added a wordlist entry, per AGENTS.md's stated preference.

My own verification gap: I'd been running
spelling::spell_check_package(vignettes = FALSE) all session, which
apparently doesn't scan NEWS.md the same way tests/spelling.R's actual
spell_check_test(vignettes = TRUE) does. Verified against that exact
invocation this time; both spell_check_package() (default args) and
spell_check_test(vignettes = TRUE) now pass clean.
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