From 08bdb398dd97fd66364c550e9d64dbbfaee06f2d Mon Sep 17 00:00:00 2001 From: R script <1695515+ms609@users.noreply.github.com> Date: Tue, 19 May 2026 12:41:56 +0100 Subject: [PATCH] test(nni): pin IW score returned by nni_search to independent recompute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the coverage gap that let the IW NNI rescore bug (3df90882) ship. The existing IW NNI test (test-ts-spr-nni-opt.R) only asserted result$score >= 0 and topology shape — it never cross-checked that the returned score matched the IW score of the returned topology. Pre-fix, nni_search did new_score = best_score + integer_delta unconditionally, so under IW the returned score was the accumulated wrong float and the existing assertions still passed. New file mirrors the EW pattern from "NNI: 15-tip random dataset" (result$score == ts_score(rt, ds)) but under concavity=10, sweeps a few concavities, and exercises multistate + NA cases. Profile parsimony isn't reached because ts_nni_search's Rcpp bridge doesn't forward infoAmounts; the IW path through compute_weighted_score is the same codepath the fix unified, so this catches both modes at the C++ level. Co-Authored-By: Claude Opus 4.7 --- tests/testthat/test-ts-nni-iw-rescore.R | 126 ++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/testthat/test-ts-nni-iw-rescore.R diff --git a/tests/testthat/test-ts-nni-iw-rescore.R b/tests/testthat/test-ts-nni-iw-rescore.R new file mode 100644 index 000000000..bd8d2c25e --- /dev/null +++ b/tests/testthat/test-ts-nni-iw-rescore.R @@ -0,0 +1,126 @@ +# Tier 2: skipped on CRAN; see tests/testing-strategy.md +skip_on_cran() + +# Regression test for the IW NNI incremental-rescore bug fixed in 3df90882 +# ("fix(nni): correct IW score computation in incremental rescore"). +# +# Before the fix, nni_search() in src/ts_search.cpp computed +# new_score = best_score + delta +# unconditionally, where `delta` is an integer EW step count from +# fitch_incremental_downpass. Under IW / profile parsimony, `best_score` +# is a float weighted score, so the addition mixed units and produced +# garbage; accept/reject comparisons were essentially random and the +# `score` returned by ts_nni_search did not match the IW score of the +# returned tree. +# +# These tests pin the contract that ts_nni_search's reported score equals +# the IW score of the returned topology, recomputed independently. + +# Helpers from helper-ts.R: make_ts_data, ts_score, validate_result + +ts_nni <- function(tree, ds, maxHits = 20L, concavity = Inf, + min_steps = integer(0)) { + TreeSearch:::ts_nni_search(tree$edge, ds$contrast, ds$tip_data, + ds$weight, ds$levels, + maxHits = maxHits, + min_steps = min_steps, + concavity = concavity) +} + +test_that("NNI: IW score matches independent recompute (binary)", { + set.seed(4815) + mat <- matrix(sample(0:1, 12 * 20, replace = TRUE), + nrow = 12, dimnames = list(paste0("t", 1:12), NULL)) + dataset <- MatrixToPhyDat(mat) + ds <- make_ts_data(dataset) + min_steps <- as.integer(MinimumLength(dataset, compress = TRUE)) + + tree <- as.phylo(7, 12) + + result <- ts_nni(tree, ds, concavity = 10.0, min_steps = min_steps, + maxHits = 5L) + validate_result(result, 12L) + + rt <- tree + rt$edge <- result$edge + + # C++ recompute under same IW configuration + c_score <- ts_score(rt, ds, concavity = 10.0, min_steps = min_steps) + expect_equal(result$score, c_score, tolerance = 1e-8) + + # R-level TreeLength as second independent oracle + r_score <- TreeLength(rt, dataset, concavity = 10) + expect_equal(result$score, r_score, tolerance = 1e-8) +}) + +test_that("NNI: IW score matches independent recompute (multistate)", { + set.seed(2306) + mat <- matrix(sample(0:3, 12 * 18, replace = TRUE), + nrow = 12, dimnames = list(paste0("t", 1:12), NULL)) + dataset <- MatrixToPhyDat(mat) + ds <- make_ts_data(dataset) + min_steps <- as.integer(MinimumLength(dataset, compress = TRUE)) + + tree <- as.phylo(123, 12) + + result <- ts_nni(tree, ds, concavity = 10.0, min_steps = min_steps, + maxHits = 5L) + validate_result(result, 12L) + + rt <- tree + rt$edge <- result$edge + + c_score <- ts_score(rt, ds, concavity = 10.0, min_steps = min_steps) + expect_equal(result$score, c_score, tolerance = 1e-8) + + r_score <- TreeLength(rt, dataset, concavity = 10) + expect_equal(result$score, r_score, tolerance = 1e-8) +}) + +test_that("NNI: IW score matches independent recompute across concavities", { + # The bug returned garbage regardless of concavity value; sweep a few to + # make sure the fix holds for both tight and loose weighting. + set.seed(9182) + mat <- matrix(sample(0:1, 12 * 20, replace = TRUE), + nrow = 12, dimnames = list(paste0("t", 1:12), NULL)) + dataset <- MatrixToPhyDat(mat) + ds <- make_ts_data(dataset) + min_steps <- as.integer(MinimumLength(dataset, compress = TRUE)) + tree <- as.phylo(31, 12) + + for (k in c(3.0, 10.0, 100.0)) { + result <- ts_nni(tree, ds, concavity = k, min_steps = min_steps, + maxHits = 5L) + validate_result(result, 12L) + + rt <- tree + rt$edge <- result$edge + c_score <- ts_score(rt, ds, concavity = k, min_steps = min_steps) + expect_equal(result$score, c_score, tolerance = 1e-8, + label = paste0("concavity=", k)) + } +}) + +test_that("NNI: IW score matches independent recompute with NA tokens", { + # Combine IW with inapplicable tokens — exercises the IW accept-path on + # the NA-aware scoring branch. + set.seed(7401) + mat <- matrix(sample(c("0", "1", "-"), 12 * 20, replace = TRUE, + prob = c(0.45, 0.45, 0.10)), + nrow = 12, dimnames = list(paste0("t", 1:12), NULL)) + dataset <- MatrixToPhyDat(mat) + ds <- make_ts_data(dataset) + min_steps <- as.integer(MinimumLength(dataset, compress = TRUE)) + + tree <- as.phylo(55, 12) + + result <- ts_nni(tree, ds, concavity = 10.0, min_steps = min_steps, + maxHits = 5L) + validate_result(result, 12L) + + rt <- tree + rt$edge <- result$edge + + c_score <- ts_score(rt, ds, concavity = 10.0, min_steps = min_steps) + expect_equal(result$score, c_score, tolerance = 1e-8) +})