Skip to content

Fix Hierarchical hanging on non-finite dissimilarities - #353

Merged
josevalim merged 2 commits into
elixir-nx:mainfrom
RicardoSantos-99:hierarchical-nonfinite-stall
Aug 17, 2026
Merged

Fix Hierarchical hanging on non-finite dissimilarities#353
josevalim merged 2 commits into
elixir-nx:mainfrom
RicardoSantos-99:hierarchical-nonfinite-stall

Conversation

@RicardoSantos-99

Copy link
Copy Markdown
Contributor

Bug

With NaN or infinity in the dissimilarity matrix, fit/2 can hang forever.

Root cause: once only two clades remain and their distance is infinite (or NaN), argmin's
tie-breaking points them at each other asymmetrically (one points to itself, the other to
it), so neither is ever picked as the other's mutual nearest neighbor. No merge happens and
the loop that drives the algorithm never terminates.

This is otherwise impossible for finite input: the globally closest pair of clades is
always mutually nearest to each other, guaranteeing at least one merge every round. Caught
while working on HDBSCAN, which can produce infinite mutual reachability values.

Fix

Track which clades are still alive. If a round makes zero progress, force a merge between
the two lowest-indexed ones still alive. This never triggers on finite input, so it's a
no-op there.

Testing

4 new regression tests, each wrapped in a Task with an explicit timeout so a future
regression fails fast instead of hanging the suite: infinite distance between the last two
clades, a NaN coordinate, an all-NaN input, and a check that results on finite data are
byte-for-byte unchanged across every linkage. Full suite passes, 0 failures.

@josevalim

Copy link
Copy Markdown
Contributor

Tracking which ones is alive requires additional work though, no? I wonder if instead of making it work we could abort? For example, if the thing argmin points to is non-finite, we abort the loop?

I also assume the :tie_break option in argmin won't help us...

@RicardoSantos-99

Copy link
Copy Markdown
Contributor Author

Tracking which ones is alive requires additional work though, no? I wonder if instead of making it work we could abort? For example, if the thing argmin points to is non-finite, we abort the loop?

I also assume the :tie_break option in argmin won't help us...

Might be overengineering this, so maybe simplify is better.

And switching argmin's tie_break to :high doesn't help, the same stuck pair just moves

I agree aborting is the better fix. Tracking alive only exists to force a merge when we're stuck, and that merge doesn't actually mean anything (the two clades are stuck precisely because they're both non-finite from each other or from everything else). Better to stop the loop and raise than hand back a dendrogram whose top merge silently carries a meaningless height.

@josevalim I'll rework the PR to abort and raise instead, and drop the alive tracking entirely.

A non-finite dissimilarity, from NaN or infinite values in the data or
in a precomputed matrix, can make argmin's tie-breaking point two
clades at each other without either being picked as the other's mutual
nearest neighbor. No merge happens that round, and since nothing about
the state changes, no merge ever happens again: fit/2 loops forever.
This cannot happen for finite dissimilarities, where the globally
closest pair of live clades is always mutually nearest, guaranteeing at
least one merge per round.

Stop the loop when a round makes no progress instead of running
forever. The merges that could not be made are reported as clades of
[-1, -1], sizes of 0, and NaN dissimilarities sorted to the end, rather
than guessing a pairing that has no finite distance to justify it.
Finite input is unaffected, since a round with no progress is
impossible there.
@RicardoSantos-99
RicardoSantos-99 force-pushed the hierarchical-nonfinite-stall branch from da0285f to 6bf7322 Compare August 16, 2026 14:13
@RicardoSantos-99

Copy link
Copy Markdown
Contributor Author

Pushed, with one change from what I said: no raise, because it can't work here.

fit/2 is a deftransform, so under Nx.Defn.jit_apply the whole thing is traced and "did the loop stall" is only known at runtime. Reading it with Nx.to_number to decide whether to raise blows up with "cannot invoke to_binary/2 on Nx.Defn.Expr", and breaks the two existing jit_apply tests. Hooks don't fit either since they're opt-in per call, so a plain fit/2 wouldn't fire it.

So the loop still aborts as agreed, but the merges it couldn't make are reported instead: clades of [-1, -1], sizes of 0, NaN dissimilarity, sorted to the end. alive is gone entirely and merge_clades is back to what it was.

Comment thread test/scholar/cluster/hierarchical_test.exs Outdated
Comment thread test/scholar/cluster/hierarchical_test.exs Outdated
Comment thread test/scholar/cluster/hierarchical_test.exs
Comment thread test/scholar/cluster/hierarchical_test.exs Outdated
Drop the Task timeout helper, since ExUnit already times out a hanging
test. Drop the all-NaN case, which exercises the same path as the NaN
coordinate one, and the jit_apply case, since jit is already covered
elsewhere in the file. Drop the finite data case, which compared fit/2
against itself once the fallback path was removed.
@josevalim
josevalim merged commit b4ed6f0 into elixir-nx:main Aug 17, 2026
2 checks passed
@josevalim

Copy link
Copy Markdown
Contributor

💚 💙 💜 💛 ❤️

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