Skip to content

fix(agglomeration): tolerate float rounding in stale-edge rescore#5

Merged
dodamih merged 1 commit into
masterfrom
dodam/numerical_stability
Jun 16, 2026
Merged

fix(agglomeration): tolerate float rounding in stale-edge rescore#5
dodamih merged 1 commit into
masterfrom
dodam/numerical_stability

Conversation

@dodamih

@dodamih dodamih commented Jun 16, 2026

Copy link
Copy Markdown

Problem

Agglomeration aborts with:

IterativeRegionMerging.hpp:99: ... mergeUntil(...): Assertion `newScore >= score' failed.
Fatal Python error: Aborted

It happens with the default OneMinus<MeanAffinity> scoring, and is data/parameter dependent ("fails a lot on 16nm seg, some succeed").

Root cause — 1-ULP floating-point rounding

When a stale edge is popped, its score is recomputed (scoreEdge) from the running/area-weighted mean-affinity formulas, which are float32 and order-dependent. The recomputed score can land one ULP below the score the edge was queued at, and the strict assert(newScore >= score) aborts the whole process.

Instrumenting a live reproduction (replacing the assert with a logger) showed the truth: every violation has gap = 5.96046e-08 exactly (= 2^-24, one float32 ULP), on high-affinity (mean ≈ 0.99), tiny-contact (count = 3-6) edges:

score=0.00109613 newScore=0.00109607 gap=5.96046e-08 mean=0.998904 count=3
score=0.00297725 newScore=0.00297719 gap=5.96046e-08 mean=0.997023 count=3
...

Only 23 such edges out of ~48M — but one abort() kills the run. It is not an algorithmic non-monotonicity; MeanAffinityProvider and this assert are unchanged since the original upstream. Heavy over-fragmentation (watershed size_threshold=0 → millions of tiny, high-affinity supervoxels) is what makes the running-mean rounding land on the wrong side of the last bit, right where 1 - mean is tiny.

Fix

Tolerate sub-epsilon rounding noise:

assert(newScore >= score - 1e-5);

1e-5 is far above the observed 6e-8 wiggle yet far below the discretize_queue bucket width (~1/256 ≈ 3.9e-3), so any real non-monotonicity still trips it. In release (NDEBUG) builds the assert is compiled out anyway; the edge is simply re-queued at its recomputed score.

Verification

A deterministic reproduction (cutout1 affs, size_threshold=0 watershed → 11.7M SVs, agglomerate @ 0.35) goes from abort()clean completion, 0 violations with this change.

Note: fully faithful reproduction at the production scale (size_threshold=50, full bbox, ~1.21M SVs) needs ≥64GB RAM; validated locally at size_threshold=0, which exercises the same float-rounding path.

🤖 Generated with Claude Code

MeanAffinity edge scores are recomputed from running/area-weighted means
in float32, so a stale edge's rescore can dip ~1 ULP (5.96e-08) below its
queued score and abort on `assert(newScore >= score)`. This surfaces under
heavy over-fragmentation (watershed size_threshold=0): high-affinity
(mean~0.99), tiny-contact edges where the running-mean rounding is most
jittery right where 1-mean is tiny. Observed 23 such 1-ULP violations out
of ~48M edges -- one is enough to abort the process.

Tolerate sub-epsilon rounding noise; a real non-monotonicity exceeds 1e-5
by orders of magnitude (and the discretize_queue bucket width ~1/256).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dodamih dodamih merged commit 77eccde into master Jun 16, 2026
6 checks 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.

1 participant