HDBSCAN for Preflight: reuse assessment and reference gate¶
Status: ABI 2.19.0 (unreleased), n4m_cluster_hdbscan and
n4m_preflight_adjusted_rand_index in n4m/metrics.h. Requested by
nirs4all-preflight lot PF-05 (clustering capability).
Reuse assessment (2026-10-13)¶
Methods is C++17 with zero mandatory dependencies, so a Rust crate cannot be linked into
it. Candidates were checked for semantic fidelity to the reference (scikit-learn
HDBSCAN / hdbscan), maintenance, dependencies, WASM fit and licence.
Candidate |
Licence |
Finding |
|---|---|---|
scikit-learn 1.9.1 |
BSD-3-Clause |
This is the reference itself. Prim on implicit mutual reachability, condensed tree, EOM/leaf, |
scikit-learn-contrib/hdbscan 0.8.44 |
BSD-3-Clause |
The same tree algorithms with GLOSH |
HDBSCAN-CPP (rohanmohapatra) |
MIT |
Inactive since 2019. It copies a dense |
|
Apache-2.0 |
Rust, so it cannot be linked. It needs ndarray, rayon and petal-neighbors, has EOM only, and has no leaf mode, membership or |
|
MIT/Apache-2.0 |
Rust, so it cannot be linked. It has no leaf mode, membership or outlier scores. |
R |
GPL-3.0 |
Tied to Rcpp |
ELKI |
AGPL-3.0 |
Java. |
cuML |
Apache-2.0 |
CUDA. |
Methods building blocks |
— |
Methods had no MST, union-find or ARI. Its PCA, neighbour and LOF code is task-specific. |
The decision is to port the BSD-3 reference code into
cpp/src/core/clustering/_vendored/ with attribution, following the convention of
cpp/src/core/filters/_vendored. See LICENSE.scikit_learn.txt there,
THIRD_PARTY_LICENSES.md and NOTICE.md. No change to the Methods licence is required.
The ARI is new native code, about 30 lines.
Frozen semantics¶
Metric: Euclidean, with
alpha = 1. Not supported:cluster_selection_epsilon,max_cluster_sizeand other metrics.min_cluster_size >= 2and1 <= min_samples <= n.Core distance: the distance to the
min_samples-th nearest row, counting the row itself. This is the scikit-learn convention; contribhdbscanusesmin_samples - 1. It is computed by brute force in O(n²p) time with O(n) memory.MST: Prim’s algorithm on the implicit mutual-reachability graph, exactly as in
mst_from_data_matrix. It starts at row 0, and on equal weights the first index wins.Tie rule: the MST edges are sorted with a stable sort, so equal weights keep Prim insertion order. This is the only intentional difference from scikit-learn, which uses the default
np.argsort. That order is unspecified and depends on NumPy’s CPU dispatch.Condensed tree: lambda = 1/distance (+inf at distance 0). Stability is the excess of mass,
sum (lambda_p - lambda_birth) * size. EOM replaces a node by its children only when their summed stability is strictly larger. In leaf mode, a tree without splits selects nothing.Outputs: labels (noise -1), numbered by increasing condensed-node index. Membership is scikit-learn
probabilities_, a relative membership strength and not a probability. Outlier scores are GLOSH. Persistence is contribcluster_persistence_, which is 1 when the maximum lambda is infinite. The condensed cluster tree reports parent, size, label, lambda birth/death and stability. The result is deterministic for a given row order; callers that need order invariance must fix a canonical row order.
Numerical domain and invariants (ABI 2.20)¶
Independent reviews found inputs at extreme magnitudes that broke the port: squared
distances overflowing to infinity made Prim emit self-edges and a condensed tree with
seven nodes for four rows, written past the caller’s buffers ([0, 1e155, 2e155, 3e155]); densities 1/d and their excess-of-mass sums overflowed near the subnormal
range (1e-308 × [0, 1, 3, 10, 11, 14] gave NaN persistence). Point fixes moved the
defects to ever more extreme inputs, so the kernel now follows the rules 13/15 domain
(macros N4M_DOMAIN_MAX_MAGNITUDE_LOG2, N4M_DOMAIN_MAX_SPREAD_LOG2 in n4m/metrics.h):
finite X with every nonzero
|x|in[2^-256, 2^256]and max/min nonzero|x|≤ 2^32; anything else returnsN4M_ERR_UNSUPPORTED(“input outside the supported numerical domain …”) after one O(n·p) scan, before any computation;inside the domain every nonzero coordinate difference lies in
[2^-308, 2^257], so squared distances are normal doubles (sums over at most 5e7 columns stay below2^540), densitiesλ = 1/d ≤ 2^308and stability sums≤ n · 2^308are finite, and the scikit-learn operation order is kept unchanged: results equal the reference and scaling X by2^kscales lambdas and stabilities by exactly2^−k;every MST edge must join two distinct rows and two distinct components, and result sizes and tree order are checked before any output is written; a working-memory budget of 256 MiB is checked before allocating. Errors are status codes, not C++ exceptions, because the WASM build does not catch exceptions;
an
inf − infexcess (a node born at zero distance whose points leave at zero distance) counts as 0; scikit-learn would produce NaN, and no reference fixture contains that case.
n4m_hdbscan_tests checks sentinel-guarded buffers at both domain edges (2^-256,
3 · 2^254, spread exactly 2^32) and just outside them (one ulp beyond, and spread
2^40), and the review inputs.
n4m_cluster_robustness_tests scales an irregular blob set by 2^k, k ∈ {−1014, −257, −256, −200, −100, 100, 200, 252, 253, 1000}: inside the domain all outputs are exact
power-of-two rescalings, outside it the call is refused without writing. Exact
translations by 2^54 and 2^200 and a constant 2^30 column leave every output
bit-identical; a constant 1e300 column is refused. parity/fixtures/ cluster_matching_null_v1.json adds five in-domain multi-magnitude cases ([0, 1, 3, 10, 11, 14, 2^30], blobs at 2^-200 and 2^180, an outlier at 2^28, columns at 2^-200
and 2^-225) checked against scikit-learn HDBSCAN(metric="precomputed") on exact
math.dist distances. The JS/WASM test checks the same boundaries and status codes.
Tie order matters, including in the reference¶
Mutual-reachability weights tie structurally, even on continuous data, because an edge
weight is often a core distance. The order in which equal edges are merged decides which
cluster a boundary point falls out of, and sometimes whether a cluster exists at all.
We ran 500 random cases (blobs, uniform, rounded, duplicated and continuum data, with
random parameters). Stock scikit-learn 1.9.1 on NumPy 2.4.6 (AVX2) gave the same
partition as the stable tie rule in 60–85 % of cases, depending on data kind. On heavily
tied data the ARI fell to 0. Stock scikit-learn also changes its result under a
permutation of the input rows: on the pure_noise fixture, one row order yields two
clusters and two other orders yield no cluster. A frozen, documented tie rule is
therefore necessary. Downstream reports must treat partitions as order- and
tie-sensitive, and subsample stability is the instrument for this.
Reference gate¶
parity/generators/hdbscan_v1.py (test-only; pins scikit-learn 1.9.1, hdbscan 0.8.44 and
numpy 2.4.6) writes parity/fixtures/hdbscan_v1.json. Its SHA-256 at generation is
294457336e820754b5047851760ea0d02391c1c552cd9420cc80223179297cd4. The reference is
scikit-learn with only the stable MST edge sort changed. Contrib hdbscan then runs on
the same single-linkage tree, and the generator refuses to write the fixture if the
two projects disagree on labels or membership. The eight cases are:
separated blobs
varying density (EOM and leaf)
pure noise (with and without
allow_single_cluster)a continuum
duplicates/ties
min_samples>min_cluster_sizewith leaf mode andallow_single_cluster
Each case records the stock scikit-learn ARI for information.
n4m_hdbscan_tests compares partitions up to label permutation, with an identical noise
set and a bijective label map. It also compares membership, GLOSH, persistence and
condensed-tree values within 1e-12 (relative), checks the exact tree structure, and checks
that row-major, column-major and strided views give identical results. Before freezing,
a fuzz run of 500 random cases matched exactly on labels, membership, GLOSH, persistence
and ARI.