@@ -295,10 +295,12 @@ defmodule Scholar.Cluster.Hierarchical do
295295 chain_length = Nx . select ( needs_start , 1 , chain_length )
296296
297297 # Extend the chain until its last two entries are mutual nearest neighbors.
298- # Bounded by n: a real chain can never revisit a clade before terminating,
299- # so exceeding n extensions only happens with a non-finite dissimilarity
300- # (from NaN or infinite input), where argmin's tie-breaking can land on an
301- # already-dead clade and loop without ever finding a genuine mutual pair.
298+ # Bounded by n: the distance along a chain strictly decreases, so a chain
299+ # can never revisit a clade before terminating, provided ties end it rather
300+ # than extend it (see `previous_is_nearest` below). Exceeding n extensions
301+ # then only happens with a non-finite dissimilarity (from NaN or infinite
302+ # input), where argmin's tie-breaking can land on an already-dead clade and
303+ # loop without ever finding a genuine mutual pair.
302304 { chain , chain_length , found , _steps , merge_diss , _pairwise , _alive } =
303305 while { chain , chain_length , found = Nx . u8 ( 0 ) , steps = 0 ,
304306 _chain_diss = Nx.Constants . infinity ( Nx . type ( pairwise ) ) , pairwise , alive } ,
@@ -327,17 +329,44 @@ defmodule Scholar.Cluster.Hierarchical do
327329 # comment above); treat it as stalled rather than let the chain merge a
328330 # clade with itself.
329331 self_match = nearest == tip
330- found = nearest == previous and not self_match
332+
333+ # The chain terminates whenever the previous entry is *a* nearest
334+ # neighbor of the tip, not only when it is the one argmin happens to
335+ # return. The two differ exactly when the tip's nearest distance is
336+ # tied, and preferring the previous entry there is what keeps the
337+ # chain from walking back onto a clade it already holds: a duplicated
338+ # entry survives the merge that pops its other occurrence, and is then
339+ # merged a second time, after it is already gone. The chain can only
340+ # cycle back on its immediate predecessor while distances strictly
341+ # decrease, which ties break.
342+ previous_is_nearest =
343+ chain_length > 1 and
344+ Nx . take ( row , Nx . max ( previous , 0 ) ) == chain_diss
345+
346+ # `nearest == previous` on its own would miss a tie, and comparing the
347+ # distances on its own would miss a NaN, which is never equal to
348+ # itself. Either one ending the chain is enough.
349+ found = ( previous_is_nearest or nearest == previous ) and not self_match
350+
351+ # The chain holds live clades and so cannot outgrow its buffer, but a
352+ # non-finite dissimilarity can stall it without ever finding a pair, and
353+ # a write past the end would otherwise be silently clamped onto the last
354+ # slot. Treat it as stalled, like a self match.
355+ stalled = self_match or chain_length >= n
331356
332357 chain =
333358 Nx . select (
334- found or self_match ,
359+ found or stalled ,
335360 chain ,
336- Nx . indexed_put ( chain , Nx . reshape ( chain_length , { 1 , 1 } ) , Nx . reshape ( nearest , { 1 } ) )
361+ Nx . indexed_put (
362+ chain ,
363+ Nx . reshape ( Nx . min ( chain_length , n - 1 ) , { 1 , 1 } ) ,
364+ Nx . reshape ( nearest , { 1 } )
365+ )
337366 )
338367
339- chain_length = Nx . select ( found or self_match , chain_length , chain_length + 1 )
340- steps = Nx . select ( self_match , n + 1 , steps + 1 )
368+ chain_length = Nx . select ( found or stalled , chain_length , chain_length + 1 )
369+ steps = Nx . select ( stalled , n + 1 , steps + 1 )
341370 { chain , chain_length , found , steps , chain_diss , pairwise , alive }
342371 end
343372
0 commit comments