Skip to content

Reject interval scaling that breaches a sampled-ancestor ceiling - #134

Merged
walterxie merged 1 commit into
masterfrom
fix/interval-scale-sa-trees
Aug 12, 2026
Merged

Reject interval scaling that breaches a sampled-ancestor ceiling#134
walterxie merged 1 commit into
masterfrom
fix/interval-scale-sa-trees

Conversation

@alexeid

@alexeid alexeid commented Aug 12, 2026

Copy link
Copy Markdown
Member

Summary

Interval scaling could lift a node through its own sampled ancestor, leaving a tree with a child above its parent. Downstream code does not survive that state: MRCAPrior.getCommonAncestor spins forever walking parent pointers, and TreeLikelihood.traverse blows the stack.

A fake node's height is pinned to its direct-ancestor leaf's sampling time, so it cannot move and acts as a fixed ceiling on the subtree below it. The room between that ceiling and the fixed leaves underneath is finite, so a large enough scale factor has nowhere valid to land. Node.intervalScale now throws IllegalArgumentException — the rejection signal the Scalable contract already specifies, and which every scale operator already catches.

The recursion existed in three copies and only Node.intervalScale was checked, which is why this survived. UpDownOperator and IntervalScaleOperator now delegate to it instead of keeping private copies. IntervalScaleOperator.proposal also gained the try/catch it was missing, so a rejection no longer kills the chain.

Closes #78.

Impact on analyses without sampled ancestors

None measurable. The check sits inside if (isFake()), a branch these trees never enter, and that call was already there.

Tree.scale microbenchmark, this branch vs master, best of 3 runs:

tips master this branch
128 1977.5 ns/call 1907.4 ns/call
1024 20558.2 ns/call 20399.9 ns/call
4096 82276.7 ns/call 81863.0 ns/call

Re-running with alternating order flipped the sign, so this is noise under 1%.

Results are bit-identical: reproducing the deleted resampleNodeHeight verbatim and comparing against Node.intervalScale over 500 random trees (ultrametric and heterochronous, 4–200 tips, scale factors 0.2–4.2) matches 102,188 node heights on doubleToLongBits, with identical dof counts.

One behavioural delta worth review: the deleted copies used node.setHeight(), which also marks both children dirty, whereas Node.intervalScale marks only the node itself. This is safe because all four likelihood implementations (legacy and spec, TreeLikelihood and BeagleTreeLikelihood) gate recomputation on update != Tree.IS_CLEAN || branchTime != m_branchLengths[nodeIndex], so a changed branch length is picked up regardless of the flag. Node.isDirty() has no other consumers in core, and ScaleOperator/ScaleTreeOperator already went through intervalScale, so this makes UpDownOperator consistent with the path already in production.

Hastings ratio

Rejecting is the correct acceptance probability rather than an approximation: the invalid tree is outside the model's support, so π = 0 and α = 0. Two properties make that sound, both covered by tests:

  • The reverse move always exists. Only upward moves can breach a ceiling, so scale(1/s) can never fail, and interval scaling is exactly invertible (leaf and fake-node heights are never touched).
  • The Jacobians match. dof counts non-fake internal nodes; scaling changes no topology, moves no fake node, and cannot create a new zero-length tip branch, so the fake-node set is invariant.

Rejections feed logAlpha = -Infinity into calcDelta, where Math.exp(Math.min(logAlpha, 0)) is 0 — finite, no NaN — giving Robbins-Monro the intended downward nudge on the scale factor.

Zero-length branches outside an FBD analysis

isFake()/isDirectAncestor() are decided purely by height equality and never consult the tree prior, so this applies to any analysis. Both cases are now covered by tests:

  • Zero-length tip branch gets the sampled-ancestor treatment regardless of the prior. Previously scaling through it silently produced an invalid tree; it is now rejected.
  • Zero-length internal branch is not fake, so no ceiling applies. It is still frozen, because interval scaling is multiplicative and a zero margin is absorbing — unchanged by this PR.

Both are transient in practice: a node-height operator is additive and steps off the state on the first proposal that touches the node, after which nothing returns to it (measure zero under a continuous prior). TreeParser binarizing a polytomy produces zero-length internal edges only, so consensus and constraint starting trees never reach the ceiling path.

Test plan

  • TreeScalableTest 5 → 17 tests; new SampledAncestorScalingTest adds 3, driving all three operators over 2000 proposals each and asserting the tree is never left invalid
  • Reverting the three main-source files fails 7 of the 15 new tests (4 in TreeScalableTest, all 3 in SampledAncestorScalingTest); the remaining 8 cover behaviour this PR preserves rather than changes
  • beast-base suite on a clean build: 477 tests, 0 failures, 3 skipped
  • sampled-ancestors bears.xml and bears_ranges.xml run 200k states to completion; both previously hung or crashed at sample 0
  • Not verified: downstream packages that call Tree.scale from initialisation code without a try/catch would abort rather than reject if handed a tree with sampled ancestors. BEASTLabs.TreeScaleOperator catches and is fine; starbeast3.StarBeastStartState has three uncaught call sites, though species trees are not SA trees. Both still use the pre-Scale/UpDown operators rely on StateNode.scale() -- replace by Scalable interface #20 int return signature and do not compile against current beast3 regardless.

Follow-ups, not in this PR

  • sampled-ancestors needs its example XMLs repaired and its beast.version bumped once this is released; its examples currently fail against beta5 and beta7 for exactly this bug.
  • Tree.allowSampledAncestors() has had no callers since the Constraints PR that used it was reverted in fc77f3c. BEAST infers "sampled ancestor" from exact height equality with no flag, which is what makes the zero-length-tip case above ambiguous.

A fake node's height is pinned to its direct-ancestor leaf's sampling
time, so it cannot move and acts as a fixed ceiling on the subtree
hanging below it. The interval-scaling recursion scaled that subtree
without checking the ceiling, so a large enough scale factor lifted a
node through its own sampled ancestor and left a child above its parent.

Downstream code does not survive that state: MRCAPrior.getCommonAncestor
spins forever walking parent pointers, and TreeLikelihood.traverse blows
the stack. On the SA package's bears.xml example this presented as a
hang, a StackOverflowError or an infinite posterior depending on which
operators were active.

The room between the ceiling and the fixed leaves below is finite, so
such a move has no valid state to land in and must be rejected rather
than fixed up. Node.intervalScale now throws IllegalArgumentException,
which is the rejection signal the Scalable contract already specifies
and which every scale operator already catches. The check is inline on
the existing traversal, so trees with no sampled ancestors pay nothing;
a leaf child is skipped, since a leaf cannot move and a violation there
would predate the move.

The recursion existed in three copies and only Node.intervalScale was
checked, which is why this survived. UpDownOperator and
IntervalScaleOperator now delegate to Node.intervalScale instead of
keeping private copies; IntervalScaleOperator.proposal also gained the
try/catch it was missing, so a rejection no longer kills the chain.

Tested:
  * TreeScalableTest goes from 5 to 17 tests and a new
    SampledAncestorScalingTest adds 3 more, driving all three operators
    over 2000 proposals each and asserting the tree is never left
    invalid. Reverting the three main-source files fails 7 of the 15 new
    tests (4 in TreeScalableTest, all 3 in SampledAncestorScalingTest);
    the rest cover behaviour the fix preserves rather than changes.
  * beast-base suite on a clean build: 477 tests, 0 failures, 3 skipped
  * sampled-ancestors bears.xml and bears_ranges.xml now run to
    completion; both previously hung or crashed

Addresses: #78
@walterxie
walterxie merged commit d58a051 into master Aug 12, 2026
1 check passed
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.

Tree.scale / Node.intervalScale unsafe on fake-rooted SA trees

2 participants