Fix four Consistency.R bugs: token remapping, cache collision, matrix drop, NaN docs - #130
Merged
Conversation
… drop, NaN docs - .SortTokens() no longer rewrites a partial-ambiguity token as full ambiguity when the dataset's contrast holds other ambiguous tokens not present in the character being processed (#88). - ExpectedLength()'s cache key now includes a tree-derived component, so scoring different trees against the same dataset no longer collides and silently returns one tree's cached result for another (#87). - Consistency() always returns a matrix, even for a dataset that compresses to a single character pattern (#94). - Document the NaN cases in Consistency()'s @return; values are unchanged (#112). Fixes #88 Fixes #87 Fixes #94 Fixes #112 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The key was built from the tree's edge matrix verbatim, so the same labelled topology presented in a different edge order or node rotation missed the cache and recomputed. Consistency() postorders its tree before calling ExpectedLength(), so a direct ExpectedLength() call on the same tree could not reuse the entry Consistency() had just created. Keying on Preorder(SortTree()) makes the key invariant to both. Tips are already renumbered to dataset order by .TreeForTaxa(), which is what makes SortTree()'s label-driven ordering deterministic at this point. The key identifies the labelled topology rather than the tree shape. The sampled length distribution is a function of shape alone -- FastCharacterLength() is positional and the relabellings are uniform, so relabelling composes with a uniform permutation to stay uniform, confirmed against 3000-replicate distributions -- but the cached value is a finite-sample median, so sharing an entry between distinct trees would make a result depend on what had been scored earlier in the session, which is the set.seed() reproducibility problem #87 reports. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pasting the tree key and the state counts into one comma-delimited string left no marker for where the first ended and the second began, so distinct (tree, counts) pairs could render identically -- "100" + "1,2" + c(3, 4) and "100" + "1,2,3" + c(4) both give "100,1,2,3,4" -- and return a false cache hit. A tip label containing a comma collided the same way. Nesting a per-character environment inside a per-tree one removes the concatenation altogether. It also stops the tree key, whose length grows with the tree, from being copied into every character's key: a 20-tip tree now stores one 292-character key beside 13-character per-character keys, where before each character carried the whole thing. Also document that rhi is NA when nRelabel is 0, which the return block omitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous note rested on reproducibility, which is the weaker argument: the cache already returns a stored median in preference to a fresh draw, so it does not preserve set.seed() semantics for a repeated call either way. What actually decides it is that shape-keying would buy nothing at the sizes this package handles. Comment only; no behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sampled distribution is a function of the unlabelled rooted shape: FastCharacterLength() is positional and the relabellings are uniform, so relabelling composes with a uniform permutation and leaves it uniform. Identical topologies are a subset of identical shapes, so keying on the labelled topology missed every reuse this catches and none of its own. The gain is not hypothetical. Random trees of 24+ leaves practically never share a shape, which is what an earlier measurement recorded -- but most- parsimonious trees are not random, differing only by local rearrangement. Across the 31 distinct MPTs of the 23-taxon dataset the documentation uses as its example, there are 19 distinct shapes: 39% of the work goes away. TreeTools::RootedTreeShape() enumerates shapes into an integer64 and so stops at 55 leaves, which 16 of the 30 bundled inapplicable.phyData datasets exceed. .ShapeKey() instead builds the Aho-Hopcroft-Ullman canonical code and packs it to bytes, which has no ceiling and is 12-20x shorter than the labelled key it replaces. It is verified against RootedTreeShape() as an equivalence relation where the two overlap. Sorting the child codes is what makes the encoding canonical, so it is inherently invariant to edge order and node rotation; the SortTree() and Preorder() canonicalisation this replaces is no longer needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ms609
enabled auto-merge
August 7, 2026 15:31
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.
Summary
Four fixes in
R/Consistency.R, filed by/red-teamarea 14:.SortTokens()rewrote a partial-ambiguity token as full ambiguity (sev:high). The ambiguous-token remap indexed its right-hand side by every ambiguous token in the dataset's contrast, not just the ones present in the character being processed. When a character used only one of several ambiguous tokens in the contrast (e.g. only(01), never?), the mismatched-length assignment silently (or with a cryptic warning) rewrote it to the wrong value.ExpectedLength()'s cache key omitted the tree. Two different trees scored against the same dataset with the samenRelabelcollided on the same cache entry, so the second tree'sConsistency()call could silently return the first tree'srhi.Consistency()dropped to a bare vector for a 1-pattern dataset, missingdrop = FALSE.NaNreturns for constant/autapomorphic/zero-null-homoplasy characters — documented in@return, values unchanged.Fixes #88
Fixes #87
Fixes #94
Fixes #112
Pre-fix failures
Each new test was confirmed to fail against the original code before its fix (verified by stashing the
R/Consistency.Rfix and re-running):#88test: returned15(wrong) with a "not a multiple of replacement length" warning instead of6.#87test: pectinate-tree query returned the balanced tree's cached4instead of the correct5.#94test:Consistency()returned a barenumericvector (dim = NULL) instead of a1x4matrix.#112's test is coverage for newly-documented (but code-unchanged) behaviour, not a pre-fix failure in the strict sense — the NaN values themselves were already correct; only the docs were missing. It does incidentally fail pre-A14-22: Consistency() returns a bare vector, not the documented matrix, for a 1-pattern dataset #94-fix too, since it indexes the same single-characterConsistency()result with[, "ci"].Two pre-existing tests ("CI & RI calculated correctly", "RHI calculated okay") asserted a bare named vector for a single-character dataset; that was itself an instance of the
#94bug, so they're updated to expect the (correct) 1-row matrix.Review
Reviewed by three independent
external-reviewerpasses (AGENTS.md/style compliance, cold bug-scan, git-blame history). Findings acted on:@returntext:rhiisNaNonly when both the observed and median-null length equal the minimum; if only the median equals the minimum, it'sInf, notNaN.spelling::spell_check_test()silently no-ops locally withoutNOT_CRAN=trueset (skip_on_cran()treats an unsetNOT_CRANas "on CRAN"); running it properly surfacedautapomorphicas an unlisted word, now added toinst/WORDLIST..SortTokens()oracle test and a cache-hit assertion for extra coverage.One high-severity finding was not fixed here: a pre-existing (not introduced or worsened by this PR — reproduces identically against the original code) crash in
.SortTokens()/ExpectedLength()when every state of a character occurs only inside a polymorphism (e.g."00(12)(12)"), never as its own unambiguous token. Flagged separately for follow-up, out of scope for these four issues.Test plan
Follow-up: the
#87cache key, reworkedReview of the first cut turned up three further problems with the key, all now fixed.
It was built from the edge matrix verbatim, so the same tree in a different edge order or node rotation missed the cache entirely.
Consistency()postorders its tree before callingExpectedLength(), so a directExpectedLength()call on the same tree could not reuse the entryConsistency()had just created.It pasted the tree component and the state counts into one comma-delimited string, leaving no marker for where one ended and the other began:
"100"+"1,2"+c(3, 4)and"100"+"1,2,3"+c(4)both render"100,1,2,3,4", returning a false hit. A tip label containing a comma collided the same way. Nesting a per-character environment inside a per-tree one removes the concatenation altogether, and stops the tree key being copied into every character's entry.And it identified the wrong thing. The key now identifies the unlabelled rooted shape, which is what the sampled distribution is actually a function of:
FastCharacterLength()is positional and never readstip.label, and the relabellings are uniform, so relabelling composes with a uniform permutation and leaves the distribution unchanged (confirmed against 3000-replicate distributions — balanced vs. relabelled-balanced agree within noise, pectinate is visibly shifted). Identical labelled topologies are a subset of identical shapes, so the labelled key missed every reuse the shape key catches and none of its own.That reuse is substantial in the workflow that matters. Random trees of 24+ leaves practically never share a shape — but most-parsimonious trees are not random, differing only by local rearrangement. Across the 31 distinct MPTs of
inapplicable.phyData[[4]], the 23-taxon dataset this function's own example uses, there are 19 distinct shapes: 39% of the work goes away.TreeTools::RootedTreeShape()can't serve as the key — it enumerates shapes into aninteger64and stops at 55 leaves, which 16 of the 30 bundledinapplicable.phyDatadatasets exceed..ShapeKey()instead builds the Aho–Hopcroft–Ullman canonical code (leaf01; an internal node wraps its children's codes, sorted into a fixed order, in0…1) and packs it to bytes: no ceiling, and 12–20× shorter than the labelled key it replaces (n=88: 91 chars against 1609). It is verified againstRootedTreeShape()as an equivalence relation over n=4–12 — identical partitions, up to 214 classes — which is the check that guards against a bug silently conflating two shapes and corruptingrhi.Because sorting the child codes is what makes the encoding canonical, it is inherently invariant to edge order and rotation, so the
Preorder(SortTree())step and itsSortTreeimport are gone again; NAMESPACE is unchanged from trunk. Two details worth knowing: the leaf count has to lead the key, since n=55 and n=56 both pad to 224 bits; and rooting is deliberately part of the shape, as these characters may carry inapplicable tokens, whose lengths are not rooting-invariant.The cache regression tests assert the key count, not the returned value: the median is stable enough that a recomputation returns the same number, so a value-only test passes pre-fix. Verified failing pre-fix (5 → 6 → 7 keys across three edge orderings, against a constant 1 after).
This branch has been rebased onto the trunk merge, which brought in #142's fix for the
.SortTokens()polymorphism crash — a follow-up spun out of this same review. The two changes are complementary and both are exercised by the suite.Test plan
Rscript -e "devtools::check_man()"— cleanspelling::spell_check_package(vignettes = TRUE)— clean (note:spell_check_test()silently no-ops withoutNOT_CRAN=true, which is how a missingautapomorphicentry initially escaped)testthat::test_dir("tests/testthat", filter = "Consistency")— 51/51 pass.ShapeKey()cross-validated againstTreeTools::RootedTreeShape()as an equivalence relationagent-check.yml— PASS on the two-level-cache commit (run 31183125279); re-dispatched for the shape key as run 31186062435🤖 Generated with Claude Code