Skip to content

fix: enforce user constraints at the start-tree, pool-capture and collapse boundaries - #59

Merged
ms609 merged 4 commits into
cpp-searchfrom
feature/constraint-holes
Aug 5, 2026
Merged

fix: enforce user constraints at the start-tree, pool-capture and collapse boundaries#59
ms609 merged 4 commits into
cpp-searchfrom
feature/constraint-holes

Conversation

@ms609

@ms609 ms609 commented Aug 5, 2026

Copy link
Copy Markdown

Fixes #18
Fixes #1
Fixes #19

Three independent holes let a constraint stop binding the trees the caller is handed. They are one PR because #18 and #1 share the ungated pool capture verbatim, and #19 edits the same .PrepareConstraint() return and ts_collapse_pool signature that #18 touches.

What each fix does

#18 (T-402) — the start tree. run_single_replicate now repairs any constraint-violating start before anything takes its score as a baseline, falling back to a constrained Wagner build (itself repaired) when the heuristic repair does not take. Both documented mis-patch traps are structural consequences of one fact: a violating tree is drawn from a wider set of topologies than a legal one, so it scores better and is an unbeatable baseline for any later accept test. Acting at the boundary is the only placement that works.

#1 (T-324) — the pool capture. All three sites — interrupted and normal in the serial driver, plus the parallel worker — go through a new capture_satisfies_constraint(), which repairs then verifies, mirroring the fuse capture beside it. Discards are counted per thread and warned from the main thread, since Rf_warning() from a worker would corrupt the heap. With the pool gated, an empty pool under a constraint no longer falls back to the unvalidated start tree.

#19 (T-403) — the collapse. consZero is plumbed through .PrepareConstraint() to the kernel, which now also protects the node realising a split when no realising edge survives the contraction on its own. Protecting unconditionally would force resolution of branches the constraint does not ask for — the "unsupported non-constraint branches still collapse" half of the promise.

Repair vs reject on #18

I repaired in C++ and warned in R, rather than rejecting the start.

constraint is a guarantee about output; tree is a hint about where to start. When they conflict the guarantee has to win, but discarding the hint throws away the user's basin — and the motivating case in ?MaximizeParsimony is warm-starting from previously found optima, exactly where that basin is the point. A few SPR moves preserve it. The conflict is still the caller's to know about, so MaximizeParsimony() warns: either the tree or the constraint is not what they meant.

The warning uses the phyDat reading @param constraint documents (? taxa free on either side), which is a true statement about the user's contract. The repair triggers on the same reading.

Pre-fix failure output

Against a clean git archive build of 99b79d8f, tests/testthat/test-ts-constraint-holes.R fails 14 assertions:

FAILURE 'test-ts-constraint-holes.R:91' Expected `as.numeric(attr(one, "score"))` to equal 10.
  [1] 6 - 10 == -4
FAILURE 'test-ts-constraint-holes.R:92' Expected `AllShown(one, ...)` to equal `length(one)`.
  [1] 0 - 1 == -1
FAILURE 'test-ts-constraint-holes.R:105' Expected `as.numeric(attr(many, "score"))` to equal 10.
  [1] 6 - 10 == -4
FAILURE 'test-ts-constraint-holes.R:106' Expected `AllShown(many, ...)` to equal `length(many)`.
  [1] 0 - 1 == -1
FAILURE 'test-ts-constraint-holes.R:128' Expected `AllShown(result, ...)` to equal `length(result)`.
  [1] 84 - 85 == -1
FAILURE 'test-ts-constraint-holes.R:133' Expected `as.numeric(attr(result, "score"))` >= 10.
  Actual comparison: 6.0 < 10.0
FAILURE 'test-ts-constraint-holes.R:202' Expected `AllShown(collapsed, ...)` to equal `length(collapsed)`.
  [1] 0 - 1 == -1
[ FAIL 14 | PASS 4 ]

The constrained optimum is 10; 6 is the unconstrained optimum, which is what the frozen search reported. Line 128 is the pool test: 84 of 85 retained trees complied, one did not. Line 202 is the collapse test, which returned (a,(e,(c,d,g,h,(b,f)))) — no split separating {a,b} from {c,d}.

After the fix: 35 pass, 0 fail. Full suite 11748 pass, 0 fail.

A limitation now stated rather than silent

A constraint character is enforced as a single split throughout — the locked-node filter screens rearrangements on it, and impose_constraint() repairs to it and nothing else. A character with three or more states therefore has taxa nothing constrains.

An intermediate version judged captures by the stricter full-Fitch reading instead. A probe on a three-state constraint then discarded all four replicates and errored with an empty pool, where a partial answer existed — the search cannot reach a standard its own repair cannot produce. The gate follows the mapping, and .PrepareConstraint() now warns at input that an intermediate state is unconstrained.

Scope

Deliberately not fixed, filed as #54: a start tree that satisfies the constraint as documented but not the stricter exact-clade form the locked-node machinery enforces still freezes the replicate. It is not a regression (identical on 99b79d8f) and returns no wrong answer — strict implies loose, so every accepted tree satisfies the documented constraint — but the replicate does no work. Closing it means picking which reading is canonical and making it true at the Wagner and sector entry points too. vignettes/search-algorithm.Rmd states the distinction rather than papering over it.

The reachability question behind #1 (whether a satisfiable constraint's violation survives all 100 Wagner reshuffles) is left open per the issue: it needs a Hamilton probe. The proven inconsistency — missing gate, missing warning — is what this fixes.

Performance

The added checks sit on the constrained path. Interleaved A/B on Vinther2008, 20 replicates, paired seeds, against cpp-search:

median ratio slower >10% slower scores
constrained 0.95 7/20 4 identical
unconstrained 0.93 6/20 1 identical

An earlier revision measured 1.12 median with 11/20 seeds >10% slower. The check now short-circuits on the locked-node mapping, which is cheaper than the post-hoc Fitch check, so only an unmapped split pays for the latter.

Review

Three external-reviewer passes (AGENTS.md compliance, cold bug scan, git blame history) plus a self-review. They found: a fourth do.call splat site that broke Resample(constraint=, nReplicates > 1) outright with no test covering it; over-protection in the collapse rule; discarded replicates feeding the strategy bandit and coverage estimate; repair running after the deadline; and the multi-state gap above. All fixed.

CI

ubuntu-arm64 passes. The windows job fails at Set up R dependenciesCan't find package called MaxMin — before any code runs. This is pre-existing and branch-independent: cpp-search itself fails identically (runs 30929069059, 30927635697), as does every open feature branch, and feature/ci-maxmin-windows is addressing it separately.

🤖 Generated with Claude Code

ms609 and others added 3 commits August 4, 2026 17:24
…lapse boundaries

Three independent holes let a `constraint` stop binding the trees the caller
is handed.

A start tree supplied through `tree =` was never checked against the
constraint (T-402).  Constrained rearrangement cannot climb out of a violating
tree -- an unmapped split makes every candidate regraft illegal -- so the
replicate froze on it and reported its unconstrained score, which then evicted
the compliant trees the other replicates found.  The repair has to happen
before anything takes the start's score as a baseline: a violating tree is
drawn from a wider set of topologies and so scores better, making the legal
repair look like a regression to any later accept test.  `run_single_replicate`
now repairs any violating start, whatever its source, and falls back to a
constrained Wagner build where the heuristic repair does not take.
`MaximizeParsimony()` warns when it was the caller's `tree` that conflicted.

The per-replicate pool capture had no constraint gate, asymmetrically to the
fuse capture beside it (T-324).  All three capture sites -- interrupted and
normal in the serial driver, and the parallel worker's -- now go through
`capture_satisfies_constraint()`, which tests with `violates_constraint_posthoc`
rather than the fuse's `constraint_node < 0`: a tree can map every constraint
node and still fail the full Fitch check.  Discards are counted and reported
from the main thread.  With the pool gated, an empty pool under a constraint is
an error rather than a fall back to the unvalidated start tree.

The collapse pass protected only a node whose tip set was the 1 group exactly
(T-403).  Tips coded `?` for a constraint character are free to sit on either
side, so the split is often realised by a wider node -- left collapsible, and
contracted away under the default `collapse = TRUE`.  `consZero` is now plumbed
through `.PrepareConstraint()` to the kernel, which additionally protects the
MRCA of either group when it excludes the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Resample(constraint =, nReplicates > 1)` errored: `R/Resample.R:372` splats
`.PrepareConstraint()`'s whole list into `ts_parallel_resample()`, which has no
`consZero` formal.  It was the one splat site of four not filtered, and no test
covered a constrained `Resample()` at all; a test now asserts the filter against
every flat kernel's formals.

The collapse protection over-reached.  It protected the MRCA of a group
unconditionally, so an unsupported branch that merely happened to be the
narrowest node containing the 0 group was returned resolved -- the "unsupported
non-constraint branches still collapse" half of the promise, and a shift in
`n_topologies`.  It now protects only when no realising edge survives the
contraction on its own.

A discarded replicate no longer feeds the strategy bandit, the Chao1 coverage
scores or the replicate report: a violating tree scores better than any legal
one, so those would be credited to whatever produced it.  Its stopping rules
still run -- skipping them would outlive the deadline and swallow an interrupt.
An interrupted replicate is now tested but not repaired, since
`impose_constraint()` has no interrupt check of its own.

The constraint check short-circuits on the locked-node mapping, which is cheaper
than the post-hoc Fitch check and strictly stronger, so only an unmapped split
pays for the latter.  Constrained wall-clock on Vinther2008 (30 paired seeds)
goes from a 1.12 median ratio, 11 seeds >10% slower, to 1.003 with none >10%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-review of the two commits before it.

A constraint is enforced as a split throughout -- the locked-node filter screens
rearrangements on it, and impose_constraint() repairs to it and nothing else --
so a character with a third state has taxa nothing constrains.  The capture gate
was judging that same tree by the stricter full-Fitch reading, which is not a
standard the search can reach: a probe on a three-state constraint discarded all
four replicates and errored with an empty pool, where a partial answer existed.
The gate now follows the mapping, and `.PrepareConstraint()` warns at input that
an intermediate state is unconstrained, rather than leaving the caller to infer
from `@param constraint` that it is not.

`constraint_satisfied()` refreshes the DFS timestamps alongside the node ids.
map_constraint_nodes() alone left the two out of step, and spr_search() reads
both without re-mapping, so the `sprFirst = TRUE` warmup could classify a
regraft against this tree's nodes and another tree's timestamps.

A start whose repair fails falls back to a Wagner build, which is exactly the
constructor that can exhaust its reshuffles and return a violating tree; it is
now repaired rather than trusted.

Also: the collapse protection's group sizes are counted once instead of per
tree; the kernel-formals test now asserts that no constraint field is dropped,
not just that none is unknown; `.ConstraintViolated()` indexes nodes by column
so its accumulation runs down a column-major matrix rather than across it.

Constrained wall-clock on Vinther2008 (20 paired seeds) is 0.95 median against
`cpp-search`, scores identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ms609
ms609 enabled auto-merge August 5, 2026 08:04
@ms609
ms609 merged commit f9c4e63 into cpp-search Aug 5, 2026
11 of 12 checks passed
@ms609
ms609 deleted the feature/constraint-holes branch August 5, 2026 09:42
ms609 added a commit that referenced this pull request Aug 5, 2026
#59 landed on the trunk meanwhile, enforcing the constraint at the start
tree, the pool capture and the collapse.  It reads the contract the same way
this branch does, so the two agree on intent -- but it routed the apart-group
to C++ as a second matrix (`consZero`) beside `consSplitMatrix`, where this
branch folds both groups into `consSplitMatrix` as 1/0/NA.  Two channels for
one fact is the shape of the bug this branch is fixing, so the merge keeps
one: the folded matrix, which every kernel already takes.

- `ts_collapse_pool()` derives both groups from the folded matrix and drops
  the now-dead `consZero` argument (9 args; init.c updated to match).  Its
  protection rule is #59's, which is the better one: protect a realising node
  only when no other realising node already survives the contraction, rather
  than pinning the tightest one unconditionally.  Both blocks #59 and this
  branch had -- exact-match and loose -- collapse to the single loose test,
  now spelled with the shared `ts::node_displays_split()`.
- `.ConstraintViolated()` is handed the folded matrix, so it reads "1" rather
  than truthiness; NA_INTEGER is truthy and would otherwise have propagated
  through the accumulation and made every comparison NA.
- `constraint_satisfied()` no longer claims the locked-node mapping is the
  stronger test.  Since this branch it asks exactly the documented question,
  which is why the Fitch fallback now fires only on a genuinely unmapped
  split.
- NEWS and the vignette carry both changes; the vignette's "the filter reads
  the constraint more strictly" paragraph goes, having become false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ms609 added a commit that referenced this pull request Aug 5, 2026
Three documentation conflicts, no code ones -- #84 and #59 both landed on
cpp-search while this branch was open.

NEWS.md and .AGENTS/memory/architecture.md: this branch carried its own copy of
#84's constraint entry, written before #59's merge reconciled the two.  Dropped
in favour of the reconciled version now on cpp-search -- which is also the
correct one on the inert-character rule, where this branch's copy still said "no
`0` taxa" rather than "either group under two taxa".  Only the random-start entry
is this branch's own, and it is appended after them.

vignettes/search-algorithm.Rmd: both sides added prose after the same paragraph.
#59's is about repairing a violating start, this branch's about which legal
starts can be drawn; they are complementary, so the new "Starting trees under a
constraint" heading now covers both.

Verified after resolving: build clean, compile-attrs clean, 15 constraint /
Wagner / driven / sector / pool / fuse / strategy suites pass (1101 assertions,
0 failures, 0 warnings), spell check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment