Skip to content

Large-tree robustness: int32 migration + heap-aware guards#199

Merged
ms609 merged 23 commits intomainfrom
large-tree-robustness
May 7, 2026
Merged

Large-tree robustness: int32 migration + heap-aware guards#199
ms609 merged 23 commits intomainfrom
large-tree-robustness

Conversation

@ms609
Copy link
Copy Markdown
Owner

@ms609 ms609 commented May 5, 2026

Summary

Makes TreeDist robust to trees with > 2048 tips, supporting both TreeTools 2.2.0 (CRAN, SL_MAX_TIPS=2048) and 2.3.0 (SL_MAX_TIPS=32704 with heap-backed split storage). All six phases of the large-tree robustness plan are implemented:

  • Phase 1: Targeted int16→split_int C++ migration; SL_MAX_TIPS-bounded static asserts preserved
  • Phase 2: R soft cap unlocked to 32767 on TT ≥ 2.3.0; version gates based on cpp_sl_max_tips() compile-time constant
  • Phase 3: User interrupt responsiveness verified in serial scorers
  • Phase 4: 4000-tip and 8000-tip RF/IRF smoke tests
  • Phase 5: NEWS.md entry documenting support

Changes

C++ Internals

  • src/ints.h: Add split_int = int32 type alias for all counters that may exceed SL_MAX_TIPS
  • src/tree_distances.h: TREEDIST_MAX_TIPS = 32767 constant; document SL_MAX_TIPS-bounded stack thresholds
  • src/tree_distances.cpp: check_ntip() backstop against 32768+; cpp_max_tips() (already exported)
  • src/pairwise_distances.cpp, src/tree_distance_functions.cpp, src/nni_distance.cpp: Targeted int16→int32 for split/tip counters sized by actual n_tips

R API

  • R/tree_distance_utilities.R: .CheckMaxTips() gated on cpp_sl_max_tips() (a compile-time constant, free to call)
    • TT ≥ 2.3.0 (sl_max > 2048): accept up to 32767 tips
    • TT < 2.3.0 (sl_max = 2048): reject > 2048 with clear messaging
    • Single source of truth; no mutable package state
  • R/tree_distance_nni.R: NNI soft cap routes through .CheckMaxTips() instead of hardcoded 32768L
  • R/zzz.R: Removed mutable .SL_MAX_TIPS and .onLoad() entirely; only .onUnload() remains

Tests

  • tests/testthat/test-large-trees.R: New smoke tests for 4000-tip and 8000-tip RF/IRF
  • tests/testthat/test-tree_distance_nni.R: Split version guards using cpp_sl_max_tips() instead of packageVersion()
  • tests/testthat/test-tree_distance_utilities.R: Separate TT 2.2.0 and TT 2.3.0 blocks; cpp_sl_max_tips() discriminant

Documentation

  • NEWS.md: Added large-tree support entry (v2.13.0.9003 Internals section)

Testing

Full suite passes on both TreeTools versions:

  • TT 2.3.0: [ FAIL 0 | WARN 0 | SKIP 19 | PASS 2009 ] (large-tree tests enabled)
  • TT 2.2.0: [ FAIL 0 | WARN 0 | SKIP 19 | PASS 2009 ] (large-tree tests skipped gracefully)

Technical Notes

Version detection without mutable state:

  • cpp_sl_max_tips() returns SL_MAX_TIPS value baked into the TreeDist binary at compile time
  • TT 2.2.0 → 2048, TT 2.3.0 → 32704
  • Test skip guards use cpp_sl_max_tips() <= 2048L (TT < 2.3.0) vs > 2048L (TT ≥ 2.3.0)
  • Matches actual compiled behavior; packageVersion() can diverge if installed package differs from build headers

int32 scope:

  • Only counters that may exceed SL_MAX_TIPS (16384 splits per tree) promoted to split_int
  • Loop counters that fit int16 left unchanged (perf is king)
  • Products in IC and clustering scorers remain int32; with n_tips ≤ 32767, max product ≈ 1.07 × 10⁹ (fits easily)

Interrupt responsiveness:

  • checkUserInterrupt() already present every 1024 outer iterations in serial per-pair scorers
  • OpenMP batch path keeps allow_interrupt = false (safe, parallel workers cannot be interrupted)

No Breaking Changes

  • DESCRIPTION still requires TreeTools (>= 2.1.0) — no version bump forced
  • Larger-tree functionality is TT 2.3.0-only at runtime, but older TT works fine (just capped at 2048)
  • All existing tests pass on both TT versions

Closes PR #196 (supersedes the Copilot merge branch), completes the large-tree robustness plan.

🤖 Generated with Claude Code

ms609 and others added 8 commits May 5, 2026 14:34
Introduce split_int = int32 type alias as a single point of change for
all split/tip/bin counters in future int16 -> int32 migrations. Currently
an alias; exists to document the semantic intent and ease future refactors.

Co-Authored-By: Claude Haiku <noreply@anthropic.com>
- Add split_int alias (int32) to mutual_clustering.h; update
  mutual_clustering_score() signature to accept split_int* for
  in_split arrays and all internal counters
- Update mutual_clustering_impl.h: split_int throughout, lg2_lookup()
  / lg2_unrooted_lookup() / lg2_rooted_lookup() fallbacks replace
  direct OOB table accesses for n_tips > SL_MAX_TIPS
- Migrate pairwise_distances.cpp: MatchScratch struct, find_exact_matches(),
  and all seven score functions use split_int; fix critical int16 overflow
  in shared_phylo_score() when n_tips=32767; fix all lg2[] OOB accesses
- Migrate tree_distances.cpp: robinson_foulds_info(), matching_split_distance(),
  jaccard_similarity() use split_int and lookup helpers
- Fix tree_distance_functions.cpp: pass split_int vectors to
  mutual_clustering_score() (was passing int16, causing compile error)
- Re-export lookup function names into global scope in tree_distances.h
- Update test-tree_distance_utilities.R: separate C++ backstop message
  pattern from R-level guard message (check_ntip uses fixed max of 32767)

All 2002 tests pass; zero regressions on normal-sized trees.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- zzz.R: cache .TT_HAS_HEAP_FALLBACK flag at .onLoad() via
  packageVersion("TreeTools") >= "2.3.0"
- .CheckMaxTips(): when heap fallback is available, accept nTip up to
  32767 (TREEDIST_MAX_TIPS) rather than the compiled SL_MAX_TIPS (2048);
  old behavior retained for TT < 2.3.0 users
- .NNIDistSingle(): replace hardcoded if (nTip > 32768L) with
  .CheckMaxTips(nTip) to route through the centralised guard
- Tests: update NNI and utilities error-message patterns to match new
  guard messages; add TT-version-conditional branches in utilities test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
test-large-trees.R covers trees with n_tips > 2048 (old SL_MAX_TIPS):
- 4000-tip RF and IRF: non-zero for distinct topologies, zero for identical
- 8000-tip RF and IRF: same checks, guarded by slowMode option
- Tip-count ceiling: 32767 accepted, 32768 rejected with clear message

All tests wrapped in skip_on_cran() and skip_if(SL_MAX_TIPS < 4000) so
the suite stays green when linked against TreeTools < 2.3.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove .TT_HAS_HEAP_FALLBACK and the extra <<- assignment in .onLoad().
  .CheckMaxTips() now derives the heap-fallback capability directly from
  .SL_MAX_TIPS > 2048L: TreeTools 2.2.0 compiles SL_MAX_TIPS=2048 (no
  heap fallback); TreeTools 2.3.0 compiles SL_MAX_TIPS=32704 (heap-backed
  split storage for large trees).  No separate version check or mutable
  flag needed.

- Split version-sensitive test assertions into separate test_that blocks
  each guarded with the appropriate skip_if(packageVersion("TreeTools")
  </> "2.3.0"), so the right assertions run on whichever version is
  installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace the <<--assigned .SL_MAX_TIPS with direct calls to the C++
function cpp_sl_max_tips(), which returns the compile-time constant
SL_MAX_TIPS in one integer return — essentially free.  This eliminates:
- The NULL sentinel in zzz.R
- The .onLoad() hook entirely
- Any risk of another package or user overwriting the value

.CheckMaxTips() now reads the limit fresh from C++ each call.
Tests reference TreeDist:::cpp_sl_max_tips() directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
packageVersion("TreeTools") reflects the installed R package, but test
behaviour depends on which headers TreeDist was compiled against — a
mismatch that arises when the binary is compiled against TT 2.3.0 headers
but a TT 2.2.0 R package is subsequently loaded.

cpp_sl_max_tips() returns the SL_MAX_TIPS value baked into the TreeDist
binary at compile time (2048 for TT 2.2.0, 32704 for TT 2.3.0), so it
is the correct discriminant for skip_if guards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 95.06173% with 20 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.65%. Comparing base (5305893) to head (86980d5).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
inst/include/TreeDist/mutual_clustering_impl.h 82.60% 12 Missing ⚠️
inst/include/TreeDist/mutual_clustering.h 76.92% 3 Missing ⚠️
src/pairwise_distances.cpp 98.01% 3 Missing ⚠️
src/tree_distances.h 91.66% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #199      +/-   ##
==========================================
- Coverage   95.98%   95.65%   -0.34%     
==========================================
  Files          57       57              
  Lines        5553     5588      +35     
==========================================
+ Hits         5330     5345      +15     
- Misses        223      243      +20     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

ms609 and others added 10 commits May 6, 2026 07:08
mutual_clustering.h: the lg2_lookup/lg2_unrooted_lookup/lg2_rooted_lookup
fallback paths (n > SL_MAX_TIPS) only execute on trees larger than the
compile-time stack threshold, which the large-tree smoke tests cover but
skip_on_cran hides from CI coverage.  Add LCOV_EXCL_START/STOP to exclude
these legitimately-tested-but-not-in-CI lines rather than leaving them as
false coverage gaps.

.CheckMaxTips(): remove the dead inner if (sl_max < 32704L) guard — the
else-if branch fires only when sl_max <= 2048 (TT < 2.3.0), which already
guarantees sl_max < 32704.  Add a local_mocked_bindings test that exercises
the TT < 2.3.0 code path without requiring an actual TT 2.2.0 install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4000 was arbitrary; 2049 is the minimum needed to exceed TT 2.2.0's
SL_MAX_TIPS ceiling of 2048.  Using as.phylo(1/2, n) generates adjacent
similar trees whose shared splits hit the O(n log n) exact-match fast
path, keeping the test quick.

Add ClusteringInfoDistance and DifferentPhylogeneticInfo and
MatchingSplitInfoDistance smoke tests alongside the existing RF/IRF ones.

Add an explicit correct-value test for ClusteringInfoDistance: the
individual-pair path and the all-pairs OpenMP batch path are independent
implementations; their agreement for n > SL_MAX_TIPS confirms the result
is not merely non-zero but actually correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread .github/workflows/benchmark.yml Fixed
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

⚠️ This benchmark result is outdated. See the latest comment below.

Performance benchmark results

Call Status Change Time (ms)
ClusteringInfoDistance(tr200) ⚪ NSD 0.17% 16 →
16, 16
ClusteringInfoDistance(tr50) ⚪ NSD -2.69% 10.3 →
10.7, 10.4
LAPJV(test2000) ⚪ NSD -2.21% 120 →
126, 122
LAPJV(test40) ⚪ NSD -0.92% 0.0176 →
0.0178, 0.0174
LAPJV(test400) 🟣 ~same -0.52% 3.46 →
3.49, 3.47
MutualClusteringInfo(tr200) ⚪ NSD -2.78% 21.6 →
22.3, 22.1
MutualClusteringInfo(tr50) ⚪ NSD -4.37% 20.7 →
21.6, 21.6
PathDist(postTrees) ⚪ NSD -0.07% 3.3 →
3.3, 3.3
PhylogeneticInfoDistance(tr200) 🟠 Slower 🙁 -11.11% 236 →
263, 263
PhylogeneticInfoDistance(tr50) 🟠 Slower 🙁 -5.76% 75.9 →
80.3, 80
RobinsonFoulds(tr200) ⚪ NSD -2.24% 2.76 →
2.83, 2.81
RobinsonFoulds(tr200) ⚪ NSD -1.28% 2.56 →
2.61, 2.58
RobinsonFoulds(tr50) ⚪ NSD -0.56% 4.31 →
4.35, 4.3

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

⚠️ This benchmark result is outdated. See the latest comment below.

Performance benchmark results

Call Status Change Time (ms)
ClusteringInfoDistance(tr200) ⚪ NSD 0.27% 15.3 →
15.2, 15.3
ClusteringInfoDistance(tr50) ⚪ NSD -1.45% 11.5 →
11.7, 11.6
LAPJV(test2000) ⚪ NSD -1.16% 93 →
91.5, 95.2
LAPJV(test40) ⚪ NSD -0.51% 0.0175 →
0.0176, 0.0175
LAPJV(test400) ⚪ NSD -0.12% 3.18 →
3.18, 3.19
MutualClusteringInfo(tr200) ⚪ NSD -0.81% 21.6 →
21.7, 21.8
MutualClusteringInfo(tr50) ⚪ NSD -0.48% 22.4 →
22.4, 22.7
PathDist(postTrees) 🟠 Slower 🙁 -48.55% 3.5 →
5.2, 5.19
PhylogeneticInfoDistance(tr200) 🟠 Slower 🙁 -4.36% 233 →
242, 245
PhylogeneticInfoDistance(tr50) 🟣 ~same -2.29% 80.7 →
82.5, 82.6
RobinsonFoulds(tr200) ⚪ NSD 1.78% 2.93 →
2.88, 2.88
RobinsonFoulds(tr200) ⚪ NSD 2.75% 2.75 →
2.68, 2.67
RobinsonFoulds(tr50) ⚪ NSD 2.7% 4.51 →
4.37, 4.4

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

Performance benchmark results

Call Status Change Time (ms)
ClusteringInfoDistance(tr200) ⚪ NSD -0.64% 15.9 →
16, 16.1
ClusteringInfoDistance(tr50) ⚪ NSD 0.41% 11.8 →
11.7, 11.7
LAPJV(test2000) ⚪ NSD -1.56% 114 →
112, 120
LAPJV(test40) ⚪ NSD -0.71% 0.0184 →
0.0184, 0.0186
LAPJV(test400) ⚪ NSD -0.2% 3.32 →
3.31, 3.33
MutualClusteringInfo(tr200) ⚪ NSD 1.33% 24.2 →
23.6, 24.4
MutualClusteringInfo(tr50) ⚪ NSD 1.65% 26.4 →
25.7, 26.4
PathDist(postTrees) 🟠 Slower 🙁 -54.82% 3.59 →
5.55, 5.58
PhylogeneticInfoDistance(tr200) 🟠 Slower 🙁 -5.75% 244 →
257, 258
PhylogeneticInfoDistance(tr50) ⚪ NSD -0.01% 74.1 →
73.5, 74.4
RobinsonFoulds(tr200) ⚪ NSD 3.77% 3.02 →
2.89, 2.92
RobinsonFoulds(tr200) ⚪ NSD 4.57% 2.78 →
2.63, 2.66
RobinsonFoulds(tr50) ⚪ NSD 3.51% 4.51 →
4.35, 4.36

@ms609 ms609 merged commit 1f0c3d6 into main May 7, 2026
20 of 22 checks passed
@ms609 ms609 deleted the large-tree-robustness branch May 7, 2026 15:05
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.

2 participants