# Cluster matching and Gaussian null kernels: reuse assessment and reference gate Status: ABI 2.20.0 (unreleased), `n4m_cluster_jaccard_matrix`, `n4m_linear_sum_assignment`, `n4m_random_permutations` and `n4m_random_multivariate_normal` in `n4m/metrics.h`. Requested by nirs4all-preflight lot PF-05 (cluster appearance frequency and the unimodal Gaussian null model). The kernels are generic; the matching threshold, the covariance regularization and the null protocol belong to the caller. ## Reuse assessment (2026-10-13) | Need | Candidates | Decision | | --- | --- | --- | | One-to-one assignment | SciPy 1.18.1 `scipy/optimize/rectangular_lsap/rectangular_lsap.cpp` (BSD-3-Clause, C++, about 250 lines, Crouse's shortest augmenting path) is the reference itself. `gatagat/lap` LAPJV (BSD-2-Clause) solves the same problem with another tie order. dlib `max_cost_assignment` (Boost) is integer-only. The Rust `pathfinding` Kuhn–Munkres (MIT/Apache-2.0) cannot be linked into C++. Methods had no assignment solver. | **Port SciPy** into `cpp/src/core/optimization/_vendored/`, keeping its control flow, tie rules and BSD-3 header (`LICENSE.scipy.txt`, `THIRD_PARTY_LICENSES.md`, `NOTICE.md`). | | Jaccard between clusters | scikit-learn `contingency_matrix` (Python); no C/C++ library worth a dependency. | New native code (about 30 lines); scikit-learn is the test reference. | | Subsampling plans | Methods PCG64 already reproduced NumPy `Generator.permutation` (`n4m_pcg64_engine_permutation`, used by the factor-association test). | **Expose** it as `n4m_random_permutations`: successive permutations from one seeded stream. | | Gaussian draws | Methods PCG64 with the NumPy Ziggurat (`n4m_pcg64_engine_standard_normal_fill`, bit-identical to `default_rng(seed).standard_normal`, frozen by `test_rng_pcg64.cpp`). The Cholesky routines in `gpr_pls.cpp`, `gp.cpp` and `emsc.c` are file-local to those methods. | **Reuse** the PCG64 Ziggurat stream; a 15-line lower Cholesky lives with the kernel. No new normal sampler. | ## Frozen semantics - Domains of validity are stated in each header. Real-valued inputs (assignment costs, Gaussian mean and covariance) follow the rule-13 domain; Gaussian draws factor a scaled covariance; Jaccard and ARI take integer labels (`n ≤ 2^53`, `n ≤ 2^23`). `n4m_cluster_robustness_tests` checks scaling, translation and constant-column invariance for these kernels, HDBSCAN and ARI. - `n4m_cluster_jaccard_matrix`: negative labels are unassigned and belong to no cluster; labels must be below the declared cluster counts. `J_ij = |A_i ∩ B_j| / (|A_i| + |B_j| - |A_i ∩ B_j|)`, NaN when both clusters are empty. Callers restrict both labelings to the elements they share before the call. - `n4m_linear_sum_assignment`: the algorithm and tie rules of `scipy.optimize.linear_sum_assignment`, evaluated in exact arithmetic. Inside the rules 13/15 domain (nonzero `|cost|` in `[2^-256, 2^256]`, max/min nonzero `|cost|` `≤ 2^32`, `+inf` forbidden pairs excluded) every cost is an integer multiple of `2^E`, `E` the last mantissa bit of the smallest nonzero `|cost|`, below `2^87` units; duals, reduced and path costs are kept as signed 192-bit integers in those units, with a guard far above any reachable value. The result is an exact optimum. On random 2–4 × 2–6 matrices with signed costs whose exponents span ±60 to ±600, SciPy's double version is suboptimal in 2–9 % of cases; `n4m_cluster_robustness_tests` and the JS/WASM test check the exact optimum by enumeration with independent exact sums (2240-bit integers, BigInt) on in-domain matrices with full 53-bit mantissas and exponents mixed over 32 binades, for minimisation, maximisation and transposed shapes. Costs are never centred; inputs outside the domain are refused before computing. Work is bounded by `rows·cols·min(rows, cols) ≤ 1e8` fixed-size operations and memory by `16·rows·cols + 128·(rows + cols)` bytes `≤ 256 MiB`, both checked before any allocation (a `1 × 32,000,000` matrix is refused). A tall matrix is solved on its transpose and the pairs are sorted by row; `maximize` negates the costs; `+inf` forbids a pair; NaN or `-inf` (after negation) is invalid; a missing feasible assignment is a numerical failure. Ties: in each augmenting-path search the columns are scanned from the last to the first, a strictly smaller reduced cost wins, and on equal reduced cost an unassigned column wins. A constant matrix therefore gives the identity assignment. - `n4m_random_permutations`: one `PCG64(seed)` stream; row `b` of the output is the `b`-th `rng.permutation(n)` (Fisher–Yates from the last position over NumPy `random_interval`). - `n4m_random_multivariate_normal`: `z` holds `n × d` standard normals from `PCG64(seed)` (NumPy `SeedSequence` expansion) in row-major order, `L` is the lower Cholesky factor of the lower triangle of `cov`, and `x_ij = sum_k z_ik L_jk + mean_j`. This is NumPy `Generator.multivariate_normal(mean, cov, size=n, method="cholesky")`; NumPy's default `method="svd"` uses another factor and gives other draws. A covariance that is not numerically positive definite fails instead of being repaired. Storage and cost are checked before any allocation, independently of `n`: `d·d` and `n·d` at most 5e7 values, `d^3/6` and `n·d·d` at most 2.5e9 operations. ## Reference gate `parity/generators/cluster_matching_null_v1.py` (test-only; pins NumPy 2.4.6, SciPy 1.18.1, scikit-learn 1.9.1 and hdbscan 0.8.44) writes `parity/fixtures/cluster_matching_null_v1.json`. Its SHA-256 at generation is `ff70ef7e34cdb15d1d018e1adf88cee4a04941e032d7123fd8a1d7d018de32a1`. The cases are: - twelve assignment matrices: square, wide, tall (transposed path), small-integer ties, constant (identity), tall constant maximized, forbidden `+inf` pairs, a maximized Jaccard matrix, a single cell, and the NaN, maximized `+inf` and infeasible errors; - four Jaccard pairs with noise, merged and new clusters, split clusters and an empty cluster slot, against scikit-learn `contingency_matrix`; - four permutation streams, including 50 permutations of 40 units, a seed above 2^63, a single unit and zero permutations; - four NumPy normal draws, including a full 3×3 covariance, seeds above 2^63 and `2^64 - 1`, a ridge-regularized diagonal covariance and a univariate case; - three Gaussian datasets (n = 80, d = 5, `min_cluster_size = min_samples = 5`) drawn by NumPy and clustered by the stable-tie scikit-learn reference of `hdbscan_v1.py`. One yields three clusters with 59 noise points and two yield none; the stock scikit-learn ARI is recorded for information (1, 1 and 0: stock scikit-learn finds clusters in the third dataset because of its unspecified MST tie order). Three large-n adjusted Rand indices (up to 3 million elements, values near 0) are checked within 1e-14 relative against exact-integer scikit-learn. Five in-domain multi-magnitude HDBSCAN cases (see `docs/dev/hdbscan.md`) use scikit-learn `HDBSCAN(metric="precomputed")` on exact `math.dist` distances. `n4m_cluster_matching_null_tests` checks the assignments, Jaccard values and permutations exactly, the draws within 1e-12 relative, the HDBSCAN partitions of the Methods draws up to label permutation, column-major cost storage, the lower-triangle-only covariance read, the argument errors, and the review's overlapping 65536 × 65536 covariance view with zero draws, which is refused before any allocation (natively and in WASM). The JS/WASM test replays the same fixture through `linearSumAssignment`, `clusterJaccardMatrix`, `randomPermutations`, `randomMultivariateNormal` and `clusterHdbscan`.