Skip to content

fix(cluster): exclude shed noise points from cluster membership#15

Draft
rdmmf wants to merge 4 commits into
devfrom
cluster-meta-fix-rebased
Draft

fix(cluster): exclude shed noise points from cluster membership#15
rdmmf wants to merge 4 commits into
devfrom
cluster-meta-fix-rebased

Conversation

@rdmmf

@rdmmf rdmmf commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto current dev (supersedes #14, which was branched off a stale dev).

Problem

Two complaints, one root cause:

  1. Cluster metadata wrongmember_count, cohesion_score inflated.
  2. Big low-cohesion clusters instead of small precise ones — e.g. 3 binaries, 2 of them 100% similar, land in one ~80%-cohesion cluster of 3 instead of a precise pair.

Root cause

HDBSCAN keeps a tight core under one condensed-tree node while loosely-attached members peel off one-at-a-time as singleton noise (each < min_cluster_size), so no split node is created. The code then built each node's membership by walking every descendant leaf, including those shed singletons — diluting the tight core.

Not a parameter problem: min_samples=1 (needed to keep identical binaries tight) is already the default.

Fix

A leaf belongs to an ancestor cluster C only if it survives to C's death lambda (fallout(leaf) >= death(C)). Shed points become noise.

  • Genuine sub-structure preserved: parent + tight child clusters still all emitted.
  • Dendrogram unchanged — tree/tree_links storage untouched; only membership/metadata changed.
  • Excludes the synthetic global root (was a giant ~0-cohesion cluster) and drops <min_cluster_size survivors.
  • direct_members derived from each leaf's deepest surviving cluster.

Shared logic in cluster_common.hierarchical_membership, applied to all hierarchical callers: run_clustering (function + binary) and run_pool_bin_clustering. Redis-free self-check: uv run python bsimvis/app/services/cluster_common.py.

Testing

  • Membership logic verified against real HDBSCAN condensed trees (self-check asserts tight-pair-vs-loose and two-family cases).
  • NOT run end-to-end against live kvrocks (no access to real sim data) — recommend a re-cluster on a throwaway collection before undrafting.

🤖 Generated with Claude Code

rdmmf and others added 3 commits July 16, 2026 15:22
Function and binary clustering stored a condensed-tree node's members as
*every* descendant leaf, including points that HDBSCAN sheds as singleton
noise before the cluster dissolves. That diluted tight cores: two 100%-similar
binaries with one loosely-attached neighbour showed up as a single ~80%
cohesion cluster of 3 instead of a precise pair, and member_count/cohesion were
inflated by the shed points.

Membership now follows HDBSCAN semantics: a leaf belongs to an ancestor cluster
C only if it survives to C's death lambda (fallout >= death(C)). Shed points
drop out and become noise. The full hierarchy is preserved when genuine
sub-structure exists (parent + tight children still emitted). Also excludes the
synthetic global root (which only stitches unrelated components) and drops
degenerate <min_cluster_size survivor nodes.

Shared logic extracted to cluster_common.hierarchical_membership with a
Redis-free self-check (python -m ... cluster_common).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ring

dev added run_pool_bin_clustering with its own inline membership walk that had
the same bug (all descendants counted as members). Route it through the shared
cluster_common.hierarchical_membership so pool-based binary clustering gets the
same precise membership as the main path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
HDBSCAN's single-linkage buries high-cohesion sub-groups inside low-density
clusters: a cluster of 14 near-identical stub functions (cohesion 0.54) had 14
leaf children and zero sub-clusters, so the 100% pairs/groups inside were
invisible. No HDBSCAN parameter surfaces them (the condensed tree genuinely has
no sub-nodes), because loose members peel off one-at-a-time as singleton noise
rather than splitting.

Add a selectable Leiden backend (clustering.algorithm="leiden") that runs
recursive community detection on the sparse similarity graph:
- keeps low-cohesion parents as dendrogram levels (insight the user wants),
- surfaces tight high-cohesion sub-groups as their children,
- stops recursing at stop_cohesion so already-tight groups aren't over-split,
- graph-native: missing pairs are absent, not treated as distance-1.

Verified read-only on real data: the 0.54 blob is preserved as a parent with
0.96-1.0 sub-groups surfaced beneath it; aggregate 221 clusters @ avg cohesion
0.982. Faster than HDBSCAN and re-clusters a single touched component in ~3ms
(incremental-friendly).

Implementation: build_leiden_tree emits the same condensed-tree shape (tree_df)
that flows through the existing persistence/metadata path unchanged. The
HDBSCAN tree-build is extracted verbatim into _hdbscan_tree; run_clustering now
dispatches on algorithm. HDBSCAN remains the default. Redis-free self-check in
cluster_common covers both the split and no-over-split cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@rdmmf

rdmmf commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator Author

Added: recursive-Leiden backend (scope A)

Beyond the shed-noise membership fix, this branch now adds a selectable Leiden clustering backend for the function path.

Why: HDBSCAN's single-linkage buries high-cohesion sub-groups inside low-density clusters — a 14-func cluster @ cohesion 0.54 had 14 leaf children and zero sub-clusters, so the 100% groups inside were invisible. No HDBSCAN param surfaces them (the tree genuinely has no sub-nodes). On the real null data, 6 of 52 clusters hid a high-cohesion sub-group.

What: clustering.algorithm = "leiden" runs recursive community detection on the sparse similarity graph — keeps low-cohesion parents as dendrogram levels, surfaces tight sub-groups as their children, stop_cohesion prevents over-splitting already-tight groups. Graph-native (missing pairs absent, not distance-1).

Verified read-only on real data: the 0.54 blob preserved as a parent with 0.96–1.0 sub-groups surfaced beneath it; aggregate 221 clusters @ avg cohesion 0.982; re-clusters one touched component in ~3ms.

Implementation: build_leiden_tree emits the same tree_df shape HDBSCAN produces → flows through the existing persistence/metadata path unchanged. HDBSCAN tree-build extracted verbatim into _hdbscan_tree; run_clustering dispatches on algorithm. HDBSCAN stays default. Deps: python-igraph/leidenalg.

Not yet: full write-path run on live data (would clear existing clusters — recommend a throwaway-collection re-cluster before undrafting); binary path still HDBSCAN-only (follow-up).

… phantom file-level algo

Method selection was unreachable: pool config stored `cluster_algo` but
run_pool_clustering never read it, and the direct build routes didn't forward
an algorithm. Now:
- run_pool_clustering reads cluster_algo/resolution/stop_cohesion from the
  pool's func_cluster_params and passes them through.
- build_cluster / rebuild_cluster / rebuild_all forward algorithm + leiden knobs.
- swagger ClusterBuild + PoolFuncClusterParams expose algorithm/resolution/
  stop_cohesion.
- pool-creation UI gets an Algorithm dropdown (HDBSCAN | Leiden) with
  resolution + stop_cohesion shown only for Leiden; pool detail view displays
  the chosen algorithm.

Also removes the phantom file-level similarity algorithm: the pool form offered
unweighted_cosine/weighted_cosine/jaccard for "file similarity", but file/binary
similarity is derived from shared function clusters and inherits the function
algorithm — `file_sim_params.algo` was stored and never consumed. Replaced the
selector with an explanatory note and dropped the dead field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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