Changelog¶
All notable changes to this project are documented here. The format follows Keep a Changelog and the project adheres to Semantic Versioning.
Unreleased¶
Added¶
- Leiden passes:
n_iterationsonmethod="leiden"/"leiden-cpm", andclustering::leiden_iteratedin Rust. Every pass after the first starts its first local move from the previous partition, as igraph'sn_iterationsdoes, and runs on its own seed derived fromseed; the passes stop early once one returns its input, and a negativen_iterationsrepeats until that happens, capped at 100. The default is1, which is the single pass of 1.0 and keeps its labels bit for bit: pinned digests of the core on the planted-partition graphs are unchanged. One pass stops at a local optimum of its last aggregate graph, and that is most of the detection gap reported below ats = 4, though not ats = 3. On the three-community SBM ats = 4(medians of graph seeds 0/1/2) two passes move ARI from 0.657 to 0.897 at n = 3000 and from 0.563 to 0.870 at n = 30 000; until stable they reach 0.933 and 0.901, where python-igraph'sn_iterations=-1reads 0.910–0.923 on the same graphs. Modularity never falls from one pass to the next, and below the threshold the passes recover nothing. On the benchmark's real leaf graphs the gain is small: median ARI over one pass / until stable reads 0.728 / 0.731 on digits, 0.281 / 0.311 on MNIST and 0.046 / 0.046 on covtype. A new argument onclustering::leidenwould have broken a module frozen under semver, andMethodis frozen too, so a Rust caller who wants passes callsleiden_iterated; the numbers are indocs/MATH.md. CFTree::refit_leaves_beam—refit_leaveswith the pass's own route widened, so the leaves are fitted to the partition abeam-wide route reproduces.refit_leavesis unchanged and is itsbeam = 1case; a width argument onrefit_leavesitself would have been a breaking change to a module frozen under semver, which is the promise working rather than an obstacle to route around.vmfis measured on real text, and the embedding row of the head table no longer leads with it. On 20-newsgroups (the bench's TF-IDF, reduced to 50 dimensions and L2-normalised, every row its own leaf, medians of three seeds)vmfreads ARI 0.073 againstspherical-kmeans' 0.175, with 38 % of the rows in one cluster where the largest topic holds 5 %. An independent movMF EM collapses the same way and tying the concentration across clusters removes it, so the cause is the per-clusterκ— neither the implementation nor the summary. The measurement and when to prefer which head are indocs/USAGE.md, A concentration per cluster, and where it costs.- Fuzz targets for the three entry points that take untrusted input —
csr(validate_csr, thensummarize_sparseon whatever it accepted),insert(CFTree::try_inserton arbitrary floats under every feature model, thenrefit_leaves_beam) anddecode(a model's CBOR throughCFTree::validate, then routed through and inserted into). They live infuzz/, a crate of its own that is in neither the crates.io package nor the sdist, and run withcargo +nightly fuzz run -a <target>. What the first runs found is under Fixed. - A closed-form target for
kmeans, end to end through the tree. The population k-means cost per point ofN(0, I_d)isd − 27/(8π)atk = 3(three 120° sectors) andd − 2/πatk = 2(two half-planes). Both partitions are Lloyd fixed points by symmetry; that the three sectors are the global optimum is supported numerically, not proven. A Rust test intests/integration_api.rsand a numpy-only case intests/test_python.pyholdSSE/Nwithin 0.01 of it atd = 2, 2·10⁵ rows,max_leaves100 and 500, seeds 0/1/2: no labels and no reference implementation needed. The largest miss measured is 0.0035 (Rust) and 0.0037 (Python), against one standard error of 0.0024 / 0.0034 atk = 3/2. A head that seedsk − 1centres (1.361 atk = 3) and one that never moves its centres (0.976) both fail it. - A planted-partition check of the Leiden core. On the symmetric sparse three-community
stochastic block model, weak recovery is possible exactly when
s = (a − b)²/(3(a + 2b)) > 1(OpenAI, The exact reconstruction threshold for the three-state symmetric channel, 2026 preprint, Corollary 1.2). With the communities in contiguous node-id blocks, mean degree 6 and n = 3000, the in-module test ofcommunity.rsholds two controls over seeds 0/1/2: ats = 0.6an ARI above 0.005 could only come from the node ids (measured 0.0015–0.0021; at most 0.0037 over 40 graphs), and ats = 5the blocks must come back above 0.75 (measured 0.847–0.886). A local move seeded from id blocks of 100 nodes fails the first. Measured alongsat n = 30 000 (medians of three graphs), the modularity core reads ARI 0.017 ats = 3, 0.563 ats = 4and 0.798 ats = 5: it detects from about four times the threshold signal, where the information-theoretic limit iss = 1. The test reaches the core directly; theleidenhead builds its own kNN graph, so Python cannot feed it an SBM. - What the heads can and cannot guarantee, a section of
docs/MATH.mdthat names each objective exactly. The 2-way RatioCut isNtimes uniform sparsest cut, which is NP-hard to approximate within any constant, so the cut unnormalised spectral clustering relaxes has no polynomial constant-factor algorithm unless P = NP (thespectralhead relaxes NCut, which the result does not cover). Metric k-median has the exact threshold1 + 2/e, which neitherdc-median(exact in its ultrametric) norkmedoids(squared Euclidean, not a metric) is subject to. The kernel clustering hardness below(8π/9)(1 − 1/k)is about an objective no head optimises. Maximum modularity cannot be approximated within any positive factor unless P = NP, and the Leiden core detects from about four times the three-community threshold. The OpenAI sources are cited by title, date and URL as 2026 preprints, and each cited result says whether its source machine-checks it in Lean: the propeller bound, the k-median threshold and the Unique Games theorem are checked; the sparsest-cut and kernel-clustering hardness and the three-state impossibility direction are not.
Changed¶
leaf_refitnow routes atroute_beaminstead of always greedily. A Lloyd step is only a Lloyd step against the rule it is read back with. Setting both knobs used to hand back leaves fitted to a greedy partition while every query the estimator performs —predict,predict_proba,assign_microclusters,outlier_scores— used the wide one; that is not a cheaper answer, it is a mismatched one. Two consequences for callers who set both: the tree itself moves, so every head sees it includingkmeansand the mixtures, which are otherwise width-blind; and the pass costs a linear factor in the width on its route, on top of the 1.8–2.5× a plain fit one narrow pass already costs. At the defaultroute_beam=1nothing changes —beam <= 1is the plain descent, and the leaf statistics are bit-identical to 1.0. The freefit_predict/fit_predict_sparseengine functions take no width and keep refitting greedily. Measured against a build with the change reverted, over six cells (digits\@90 andmnist-10k\@200 ×kmeans/ward/gmm, ARI the median of seeds 0/1/2): three cells up, three down, mean +0.013, and the split follows the argument rather than cutting across it — the twowardcells, the only head that assigns by microcluster, gain +0.061 and +0.052, while the two heads that label from their own centres move −0.030/+0.034 and −0.003/−0.038. Worst case first: onmnist-10k wardthe greedy pass under a width of 8 was destroying 0.065 ARI, more than the width had bought. The table is inbench/RESULTS.md.
Fixed¶
memory_budget_mbno longer under-sizes the divisor it uses, so the budget is met instead of overrun. The per-leaf cost is now measured rather than derived from the struct definitions: the slope of process RSS against the realised leaf count, at four leaf counts per cell, for eachfeature×dimin {2, 20, 54, 784} (r²0.978–1.0000). The old formula under-predicted every cell, from 1.15× to 19.5× — so a 512 MB budget bought a tree of 590 MB at best and 10 GB at worst, and it never erred the safe way. Two causes. A resident leaf is more than its declared arrays: its cluster feature is also folded into every ancestor node's feature, a rebuild allocates a second tree whose small chunks glibc keeps in its arena rather than returning to the kernel, and everyVeccosts a header plus malloc rounding. Andfeature="fd"was costed as if it were"diagonal", ignoring the Frequent-Directions sketch entirely — anFdSketchholdsmin(32, dim)sketch rows ofdimbesides the mean, which is the whole of the 19.5× atdim = 784. The new constants sit 1.02–1.37× above every measured cell, deliberately: a divisor that under-predicts blows the budget silently, one that over-predicts hands back a slightly smaller tree than the caller could have afforded. A givenmemory_budget_mbtherefore resolves to fewer leaves than in 1.0, which changes labels for callers who set it —max_leavesis unaffected.linalg::orthonormalize_rows(Rust) documented the opposite of what it does with a dependent row. It said such a row is left at zero. Its residual is rounding noise rather than zero, so it comes back a unit vector orthogonal to the rows above it — which is what every caller in the crate needs, since each is a subspace iteration in which a zero row is a fixed point. The behaviour is unchanged and now asserted; only a residual below1e-150is zeroed.- A rebuild could silently drop rows. Six finite rows — ±1e200, ±3e200, 0 and 5 — fitted at
max_leaves=2kept three. When every merge candidate in a leaf cost+inf(a centroid gap that squares pastf64::MAX) or NaN, the entry was paired with itself, merged into itself and deleted. A candidate is now always two entries and a NaN cost ranks last. A gap that overflowed no longer grows the threshold either: it carries no scale, growing to it would absorb every later row, and+infis the one thresholdloadrefuses. fit_predict_sparsepanicked on a finite value past ~1.3e154 once the leader budget was spent, withindex out of bounds: … the index is 18446744073709551615. The value squares to infinity, every later row sits at+inffrom its leader, and the nearest-leader scan —<against an infinite start — found none, so the forced branch indexedusize::MAX. The scan now returns an existing leader whenever one exists, and the capped rescan finds an open leader at any distance, as its documentation always said.Betula(feature="fd")panicked on one float32 value past ~1.8e19, under every head: the square overflowed the sketch's Gram matrix, and the NaN eigenvalues reached apartial_cmp().unwrap()sort. The shrink already read every eigenvalue asmax(λ, 0); the median it shrinks by now does too.loadaccepted four tree shapes that the next call hung or panicked on — a child that does not name its parent (an insert climbed a parent loop forever), a root that names one, a reachable node with no children, and an entry listed twice (the next rebuild merged it into itself and emptied a leaf). Each is now refused with the index at fault. Every tree this library builds satisfies all four, so no file it wrote stops loading.
The first three change nothing for data whose squares stay finite. Each fix has a unit test that
fails without it, and every one came out of the fuzz targets or out of reading the code a finding
pointed at.
- leaf_cap=1 never held a tree to max_leaves. A rebuild merges two entries that share a
leaf node, and a leaf holding one entry has no partner, so every rebuild merged nothing and every
insert past the budget rebuilt again: Betula(leaf_cap=1, max_leaves=50) kept all 2400 rows of
four blobs as leaves. When no leaf node holds two entries, the rebuild now pairs across the leaves
of one internal node and then rebuilds the node structure, since those merges empty leaves. At
leaf_cap >= 2 a rebuild always finds a pair inside a leaf, so no such fit changes.
- branching below 2 and leaf_cap=0 are refused by Betula, fit_predict, BregmanBetula
and WindowStream, with a ValueError naming both values. They used to fit — leaf_cap=0
without honouring max_leaves — and a Betula fitted with them saved a model that load then
refused, as it always has.
- A finite row whose squares a fit would overflow is refused by every method that takes rows,
dense or sparse, with a ValueError naming the row. The bound is dtype.max / 2⁵⁶ on the squared
norm — about 4.7e21 for float32, 2.5e291 for float64 — which keeps every sum of squared
distances a fit takes inside the type for any row count up to 2⁵³. Past it a row went through the
finiteness check and then: on a float64 row at 1e200, kmeans indexed out of bounds (a
PanicException, through Betula.fit on sparse input as well) and normalize=True scaled the row
to zero; 3000 float32 rows spread over ±4.5e18 left the radii of every non-directional head
non-finite and made kmedoids panic. A float64 chunk that a float32 model casts down is
checked after the cast, where 1e39 is inf. Needing no row count has a price: a small float32
fit past the bound that happened to stay in range is refused too — rescale, or pass float64. A
bound on rows × largest square was the alternative, and it would have made the domain of
partial_fit depend on the rows that came before it. The check is at the Python boundary;
CFTree::try_insert in Rust takes any finite row, as before.
How large a row can be.
- The HDBSCAN blobs-contest numbers are re-measured on the tree 1.0.0 shipped, and the automatic
min_samples reads 0.822 at 8 000 leaves, not the published 0.896. The rule was measured on the
tree before 1.0.0's cost-ranked rebuild, and the rebuild's own re-measurement covered the quality
tables but not the contest section of bench/RESULTS.md, docs/USAGE.md or the
AUTO_MIN_SAMPLES_LEAVES doc, so all three quoted a tree that was never released. The rebuild
(7e5471f) is the whole cause: built as a wheel beside its parent, the parent reproduces the grid
the numbers came from to the fourth decimal and the commit reproduces today's build on every cell.
On 1.0.0
the rule reads 0.821 at 2 000 leaves and 0.822 at 8 000 (medians of seeds 0/1/2; the fixed
ten 0.612 and 0.717), against fast_hdbscan's 0.910. The drop is all seed 0, which loses one
cluster at each budget (0.790 → 0.561 at 2 000 leaves, 0.896 → 0.799 at 8 000; the 2 000-leaf
median is another seed's and did not move). On the six quality fixtures its gain on blobs is
gone — the fixed ten no longer fails there, 0.423 against 0.429 — and on varied it grew,
0.568 → 0.839. Over the wider grid it still wins or ties in 16 of 18 cells, but the two
losses are no longer between two failures: on two overlapping blobs at N = 100 000 the fixed ten
reads 0.484 at 200 leaves and 0.452 at 2 000 against the rule's 0.000 and 0.337. Below the leaf
mass the head is single linkage on either tree, and it now fragments where it chained: the
contest's min_samples=10 call finds 24 clusters at 100 000 points and 88 at 500 000 instead of
3, at ARI 0.480 and 0.490 against the published 0.478. The constant is unchanged — 5 to 40 leaves
are still inside the seed spread at both budgets — and no code changed. The contest table's time
and RSS columns are the 2026-08-25 run, and now say so.
bench/RESULTS.md.
1.0.0 — 2026-09-10¶
1.0 is a promise about compatibility, not a claim about features. Three things are frozen from this release on, and the rest of the crate is explicitly not:
- The Python package is the supported surface. Every keyword of
Betula, every free function and every fitted attribute is covered by semantic versioning.python/betula_cluster/__init__.pyipins the signatures andmypy.stubtestchecks the compiled module against it on every run of the gate, so a wrapper signature cannot drift without failing. - The Rust crate's contract is thirteen modules —
tree,feature,distance,bregman,model,clustering,types,sparse,order,coreset,stream,window,validity. Everything else is#[doc(hidden)]: still reachable, because this repository's own benchmarks call into it, and free to change shape in any release.cargo semver-checksnow runs against the last version published on crates.io on every pull request. - A model file written by 1.x loads in every later 1.y.
savewrites a gzip-framed, version-tagged CBOR document;loadaccepts one schema version, refuses every other by name, and validates the tree it decodes instead of trusting it.
What the promise costs is stated rather than hidden. cargo semver-checks reports six breaking
classes against 0.8.0: three from curating the surface (enum_now_doc_hidden,
function_now_doc_hidden, struct_now_doc_hidden) and three from signatures that gained a
parameter or moved to a try_ form (function_parameter_count_changed,
method_parameter_count_changed, inherent_method_missing). On top of those, an f32 tree past
2²⁴ rows now computes a different — correct — weight and variance, where it used to stop counting.
All of it is below, and this is the release to take it in.
Explicitly not part of 1.0: being fastest, having every head, or matching a specialist library
on its own contest. README.md names the three cases where a different tool is the right answer.
Added¶
- A "choosing a head" table in the README, and the three cases where the answer is a different
library.
docs/USAGE.mdhas documented every head for several releases and its twenty-row table answers "which one" — but only once you are already reading it. The README said which datasets betula-cluster is for and never which of the twenty-sevenmethodvalues to type, so the first question a new reader has was answered two clicks away. It now carries a five-family table (centroid / probabilistic / density / graph / directional) naming a head to start from and what to reach for next, above a link to the full one.
The exclusions are written in the same place rather than left implicit, each with the measurement
behind it. N in the low thousands: the tree stops compressing — on digits (1 797 points) it
holds 1 797 leaves and rebuilds zero times, at max_leaves of both 2 000 and 4 000, one leaf
per point — which is why that row is a tie (0.467 against scikit-learn's 0.468) rather than a win.
Density structure that overlaps: fast-hdbscan recovers the 100 k blob fixture at ARI 0.910
against our 0.478, and being 9× faster on half the memory is the trade, not a rebuttal. And a
mixture whose likelihood has to be a raw-point likelihood: responsibilities are tied within a leaf
and every ln p is computed from (n, μ, S), so a per-point density, a BIC against a model
fitted to raw points, or a likelihood-ratio test are not what comes out.
n_init— the k-means restart count is a parameter. Lloyd converges to a local optimum of the inertia, so the k-means heads have always kept the best of four k-means++ draws; the count was a constant. On MNIST-20k it is worth choosing: 25 restarts read ARI 0.3303 against the four-restart default's 0.3069 andsklearn.cluster.KMeans's 0.3244, in half scikit-learn's wall clock and 3.0× the default fit's (median of seeds 0/1/2, the published harness). It is not a free win and the docs say so — ondigitsthe same 25 restarts score below 10, and on covtype-20k the column has no direction, because the draw is selected by inertia and inertia is not ARI. The default is unchanged, so no existing fit moves.
Only the heads whose labels come from an inertia-selected k-means take it (kmeans,
spherical-kmeans, spectral, and the COP-KMeans behind fit_constrained). The EM heads select
their restarts by likelihood, and the same sweep over the GMM restart count was measured
non-monotone on all three datasets, so n_init on a head that would ignore it raises a
ValueError instead of passing silently. Rust: Model::fit and the kmeans_auto / spectral
entry points take the count (0 = the default), and KMEANS_N_INIT is public.
-
The ELKI tree gap has a mechanism: how the final leaf set is chosen. Q5 left our default D0/D0 geometry 7.6–8.5 % behind
BetulaLeafPreClusteringin WCSS at a matched leaf count, with three candidates on file (split rule, rebuild threshold, absorption tie-break). It is none of those: it is howCFTree::rebuildchooses the final leaf set — it merges the closest pairs, and only pairs inside one leaf node. Priced exactly in cluster-feature arithmetic — a merge-only compaction is a closed form, so an offline globally-cheapest-pair merge is a bound — a summary of exactly the same size built from a 2× finer tree reads 0.72× our WCSS ondigitsand 0.68× on covtype-50k, i.e. 0.78× and 0.73× of ELKI's. The 8 % is the small end of what the lever is worth. Not a free fix: the finer build holds 2× the leaves and the global merge isO(m²), which is the trade the current policy was chosen under. Written up inresearch/RESULTS-estep.mdwith the numbers and the shape of a fix that keeps both bounds. E13 then separated the two halves of that policy: the ranking is 21 of the 28 points and the locality is 7, so the fix in Changed above needs neither the finer build nor the global merge. -
absorb="chi2"is documented as an operating point, with the measurement that says it is not a general win. T22 turned up the gate reading 0.1199 with 46 leaves on covtype where the six radius criteria read 0.086 with ~3 600, which looked like a lever nobody had written down. Swept over three datasets × three heads × three budgets (medians of seeds 0/1/2) it is not: the gate wins on covtype-ward (+0.034) and on tight budgets (MNIST-10k ward at 500 leaves, 0.1601 → 0.2834), loses ondigits-ward (0.6428 → 0.4456) and on MNIST at a generous budget (0.4275 → 0.3209), and is mixed on the k-means and GMM heads. What generalises is that it chooses its own resolution — 46 leaves on covtype and 920 on MNIST whether you ask for 500 or 4 000 — which makes it a cheap operating point rather than a better criterion: the MNIST cell above is 17× the wall clock (7.38 s → 0.42 s) for −0.107 ARI.docs/USAGE.mdnow carries the table. -
The
covtype/wardbenchmark loss has a mechanism on record, and it is not the one that was filed. The cell (betula-ward0.086 againstsklearn-birch0.131) was attributed to mass imbalance; Q1 measured that absent, and this separates the rest. Not the leaf budget — 500 to 16 000 leaves reads 0.0857–0.0900, flat over a 32× range. Not the leaf feature —sphericalanddiagonalagree to four decimals, as the ward linkage's inputs say they must. Not the absorption criterion within the radius family — six of them read 0.0859–0.0864 — though the mass-invariantchi2gate reads 0.1199 with 46 leaves, the rival's score at a 78× smaller summary. It is the fixture:bench/comprehensive.pystandardises the 20 000-row subsample,bench/_worker.pystandardises all 581 012 rows and then subsamples, and the two draws differ as well. Crossed, ward reads 0.0861 / 0.1176 / 0.1059 / 0.1416 whilesklearn-birchsits at 0.1265–0.1306 throughout — the sensitivity is ours, and the 44 binary one-hot columns carry it. The scoreboard cell stands (the rival is measured on the same rows), with the new mechanism written next to it inbench/RESULTS.md. -
The spectral head's restart count is measured, and the default stays 4.
n_initbecame reachable onmethod="spectral"in 0.8.0's successor without ever being swept there. It is the one head where a restart is nearly free — its k-means runs on ak-dimensional embedding of at mostmax_leavesrows — and the measurement says so: a hundred restarts cost 0.20 s → 0.28 s ondigitsand 0.39 s → 0.44 s on covtype-20k, putting the default four at 0.5–2 % of the fit. It also says the restarts buy nothing, which is why the default does not move:moons0.9999 andcircles1.0000 are identical to four decimals at 4, 10, 25 and 100,digitsmoves +0.0009, and covtype-20k's −0.0103 sits well inside its own 0.0657–0.0992 seed spread. Documented indocs/USAGE.mdnext to the k-means table, where the curve is not flat. -
betula --n-init N(CLI). The binary passed the0sentinel, so--method kmeanswas stuck on four restarts while both Python entry points could choose. It carries the same head rule as the library:--n-initongmm,gmm-fullorwardis an error rather than a silently ignored flag, and--n-init 0is refused because zero is the engine's "take the default" sentinel and not a fit anyone means to ask for. -
simplified_silhouette— the silhouette a summary can actually carry.validity()now reports a fourth index: Hruschka's simplified silhouette, mass-weighted over leaves, measuring to the cluster centroid rather than to the members. The classical silhouette needsΣ‖x − y‖, which is degree 1 in the norm and provably not a function of a cluster feature (a Z3 witness:{−1, −7/12, 0, 1}and{−4/3, 0, 0, 3/4}sharen,Σx,Σx²and differ inΣ|xᵢ − xⱼ|by more than 0.1), so this is a declared surrogate, not an approximation that tightens.
The two losses are measured separately and are not the same size. The summary costs 0.0004 on
covtype-20k at 5.6 points per leaf, at most 0.013 over the k-grid on 784-dimensional MNIST, and
exactly nothing on a tree with one leaf per point, where a unit test pins it to the point-level
definition. The surrogate class shifts the level a long way — 0.4393 against scikit-learn's
0.1420 on digits — so the number ranks configurations of one dataset and must not be compared to
a published silhouette_score. Rank agreement with a 10 000-point sampled classical silhouette
is τ = +0.71 on covtype, +0.62 on digits and −0.24 on MNIST, where the point-level surrogate
disagrees harder still (−0.43): the disagreement is the surrogate, not the summary.
tune() can now select on it — objective="simplified_silhouette" or "medoid_silhouette" —
scoring off the leaves at 39 ms per trial on MNIST-20k against 1456 ms for the sampled point
silhouette, with no sampling seed in the answer. Rust: validity::simplified_silhouette is
public.
balance="auto"— the mass cap as a decision the data makes.balancebounds how much of the total mass one leaf may hold, and it has a known domain: over 27 cells (digits / covtype-20k / mnist-10k ×kmeans/ward/gmm× three budgets) a fixed cap gains +0.08 to +0.25 ARI on all six cells where the heaviest leaf holds more than half the mass, and stays inside seed noise on the twelve below 0.1. That statistic is now readable by the estimator itself:"auto"summarises once, reads the share off the finished tree and, only if it passes 0.5, summarises the same rows again with the cap on from the first point. Re-measured over the same 27 cells, it is bit-identical tobalance=Noneon all 21 non-firing cells and lands on thebalance=4.0answer on all six that fire (+0.0785 to +0.2514). The price is one extra pass, paid only where it fires.
partial_fit cannot summarise the same rows twice, so a stream gets a weaker mechanism instead:
the tree watches its own mass distribution as it builds and arms the cap when a leaf passes half
the mass. A cluster feature does not split back into points, so a leaf that has already absorbed
the core keeps it — measured on mnist-10k at 250 leaves the streaming arming recovers +0.006 to
+0.037 where the two-pass recovers +0.167 to +0.251. Both are documented in docs/USAGE.md; the
default is still None.
Not everything the parameter was hoped to fix is a mass problem: the published covtype/ward
cell (ARI 0.086 against sklearn-birch's 0.131) has a heaviest-leaf share of 0.041, and a fixed
cap moves it by −0.0001. "auto" correctly does nothing there.
-
CFTree::top1_mass(Rust) — the heaviest leaf's share of the mass, the diagnostic the docs already told readers to compute by hand. -
A
float32tree now warns before its weights stop counting. Anf32leaf saturates at 2²⁴ = 16 777 216 points: binary32 spaces its values 2 apart from there upward, so a unit-weight row leaves the weight exactly where it was. Measured, 16 781 312 identicalfloat32rows into one leaf report a weight of 16 777 216 against an exactfloat64control. The mean and the scatter do not stop with it — the mean becomes an exponential moving average of span 2²⁴ and the variance inflates in proportion to the rows the weight dropped (1.0105 against 1.0000 after 200 000 rows past the ceiling) — so the summary degrades silently rather than erroring. The estimator now raises aUserWarningnaming the heaviest leaf's mass when it passes 2²³, one doubling short of the ceiling and while every summary is still exact, anddocs/USAGE.mddocuments the limit and the three ways out. This is a warning, not a fix: the accumulators are stillf32, and widening them is a persisted-format change. -
assign::AssignPlan— pruned exact nearest-centroid assignment (Rust). A squared Euclidean distance is a sum of non-negative terms, so any prefix of it is a lower bound on the whole, and a candidate whose prefix already exceeds the best distance so far cannot win.AssignPlanwalks the dimensions in stages and drops candidates as soon as that happens. It returns the argmin a full scan returns, ties broken by the lower index; there is no error budget and no parameter to tune.
Two levers, both measured. A hint (the row's label from a previous iteration) starts the
threshold at that candidate's distance instead of infinity, which takes the dimensions read from
45–60 % down to about 20 %. A dimension order by descending between-centroid variance prunes
soonest — on mnist at k=10, 42.7 % of the dimensions read against 66.6 % in the natural order.
Fewer reads is not automatically less time and the module documentation says where it is not.
cargo bench --bench assign on a Ryzen 7 5800HS, single-threaded, hinted, against a full scan:
0.3–0.8× below d = 128 (the branchless AVX2 row kernel wins outright), 1.8–2.1× at
d = 784–1024 for k ≥ 100, 8.2× at d = 1024, k = 1000, and 3.0–3.9× when the clusters
are well separated. The dimension order is a k decision rather than a d one: below about
k = 100 the per-point gather costs more than the extra pruning buys, and identity_order is
faster. Nothing in the library calls it yet — this is the kernel, and each call site gets its own
measurement before it is wired in.
The statistical alternative was measured and rejected: SuperKMeans (arXiv 2603.20009) rotates the
data and prunes on a Beta tail, reading 34.1 % of the dimensions on mnist at k=10, but the
rotation costs d² per point, which at a CF summary's k is more than the assignment it
accelerates. Its own code disables the path below d = 128 or k ≤ 256.
route_beam— a wider routing descent, off by default. The tree descent commits to one child per level and never backtracks, which is why a quarter to a half of all rows do not reach their nearest microcluster (see Fixed, below).route_beam=bkeeps thebnearest nodes at each level instead of one and scans the entries of every leaf the frontier ends on. It reaches every routing entry point —predict,predict_proba,assign_microclusters,outlier_scores, and the sparse CSR path — and nothing else: insertion, the leaf clustering andleaf_refitare untouched, so the tree and the head are identical at every width and only the row → microcluster map moves.
The default is 1, which is the descent this library has always done, so no label changes
unless the parameter is set. Measured on ward at max_leaves=4000, median of seeds 0/1/2: the
share of re-routed rows converges to the independently measured exact-scan misroute rate on all
five datasets (within 0.1–0.8 points), which is what shows the beam finds the exact nearest entry
rather than merely a different one. ARI moves on the two datasets whose classes interleave —
mnist +0.035 (0.3452 → 0.3801 at b=8) and digits +0.025 at b=2 — and does not move at
all on blobs, highdim or covtype, where re-routing 25–44 % of rows lands them in a different
microcluster of the same cluster.
The cost inverts the usual trade: blobs/highdim/digits/covtype pay 11–15× on routing at
b=16, mnist pays 3.0×, because at d=784 the node features are 24 MB against a 16 MB L3
and the narrow descent is already stalled on memory. End to end that is small: routing is 0.53 s of
a 5.82 s mnist fit, so b=8 adds about 0.6 s.
The default stays at 1 on the measurement, not on compatibility: b=8 costs 6–10×
the routing for zero ARI on three of five datasets, so no single width is right for the table and
the choice belongs to the caller. Only heads that assign by microcluster (ward, spectral,
leiden, hdbscan) can see the parameter at all, and it is an estimator parameter — the free
fit_predict / fit_predict_sparse route with the plain descent. Full sweep in
bench/RESULTS.md;
CFTree::nearest_entry_beam is public on the Rust side.
- A wheel for free-threaded CPython (3.14t), and a CI gate on what it claims. abi3 cannot express
a
Py_GIL_DISABLEDbuild — such an interpreter exposes SOABIcpython-314tand no abi3 tag at all — so the singlecp311-abi3wheel matched nothing on 3.14t andpip install betula-clusterthere fell back to compiling from source, which needs a Rust toolchain the user did not ask for. maturin drops abi3 for these on its own ("abi3 does not yet support CPython 3.14t … artifacts will be version-specific"), so all that was missing was the build arm: Linux x86-64 + aarch64, macOS arm64 and Windows x64. macOS x86-64 is deliberately absent — the abi3 arm cross-builds it on an arm64 runner precisely because abi3 needs no interpreter at build time, and a version-specific build does.
Measured on cpython-3.14.7+freethreaded before adding any of it: the module imports without
re-enabling the GIL, labels are correct, and four Python threads each running a full fit_predict
over 6000×8 finish in 0.26 s against 0.22 s for one — 4× the work for 1.2× the time,
alternated A-B-A-B. The GIL check is now a CI assertion rather than a note, because a #[pymodule]
declaring Py_MOD_GIL_USED turns free threading back off for the whole process on import and
does it with a warning rather than an error; a wheel that did that would be worse than no wheel.
The Free Threading :: 2 - Beta classifier is deliberately not 3 - Stable: this is one
interpreter on one platform, and no test yet drives the extension from several threads at once.
Changed¶
- The Rust crate's public surface is curated, and the crate now says which modules the contract
covers.
src/lib.rsdeclared 23pub mod, sokernels,linalgandstats— a SIMD reduction, a Cholesky, algamma— carried the same semantic-versioning promise astreeandmodel, and a snapshot of that surface would have been a snapshot of the implementation. Ten are now#[doc(hidden)]:adwin,assign,fidelity,kernels,linalg,mixture,sketch,stats,topology,wasserstein. Nothing is deleted and nothing stops compiling — they staypub, because this repository's own benchmarks call intokernelsandassign— but they are off docs.rs and off the contract, which a crate-level doc section and a new Compatibility section inREADME.mdnow state outright. The thirteen that remain documented are the library:tree,feature,distance,bregman,model,clustering,types,sparse,order,coreset,stream,window,validity. What the hidden modules implement stays reachable through the Python package, which is the supported way to use it —mapper()istopology,KllSketch/DdSketcharesketch, the drift distance iswasserstein,tree_report()'s fidelity number isfidelity. Two rustdoc warnings went with the change (40 → 38), and the crate docs gained a compiled minimal-fit example.
The contract is checked by a tool rather than by a hand-kept list:
cargo semver-checks --baseline-version 0.8.0 classifies every difference against the published
crate, and CONTRIBUTING.md now names it as the gate for a PR that touches a documented module.
Run against this tree it reports six breaking changes since 0.8.0, all of them deliberate and
all of them already in this section: insert / predict renamed to their try_ forms on
CFTree, Model, DenStream, DbStream and WindowStream; the arity of Model::fit,
CFTree::build_sharded, clustering::spectral and clustering::kmeans_auto; and this hiding.
That is what makes this release a major one.
savegzip-frames the file, and whatloadpromises across versions is now written down. A model was bare CBOR, and the bulk of one is a leaf centroid matrix that repeats heavily down the leaves — dead dimensions, shared zeros, near-duplicate centroids. Framing it in gzip takes 2.0–4.8× off a real model: digits 0.18 → 0.09 MB, covtype (581 012 × 54) 1.06 → 0.22 MB, mnist (70 000 × 784) 14.35 → 5.29 MB spherical and 22.28 → 9.44 MB diagonal, the largest of them in 0.6 s. Pickle goes through the same bytes, sopickle.dumpsandjoblib.dumpshrink with it, and the container is a plain gzip member rather than a private framing:gzip -dc model.betulastill yields the CBOR.
The codec was picked on the artefact rather than on a fragment. zstd -19, brotli -q9 and
xz -6 all beat gzip on the same four files — by 12–22 %, for 4–13 s per save against
gzip's 0.6 s, which is the wrong trade for a call a user makes interactively — and flate2's
default backend is pure Rust, so every wheel target still cross-compiles without a C toolchain.
The ratio is a property of the data and is published as one: a standardized Gaussian fixture with
no dead dimension compresses 1.1–1.4×, and saying "2–5×" without that row would be quoting the
fixtures that flatter the change.
SCHEMA_VERSION stays 2. The CBOR inside is unchanged, so a bump would have refused every
existing file to gain nothing; load reads both framings and still rejects any other schema
version. That rejection is now a contract rather than an implementation detail — stated in the
save/load docstrings, in a new Compatibility section of
README.md and in a new Saving
and loading a model section of
docs/USAGE.md. Models are
not portable across major versions, and the alternative — a reader for every past layout — would
pin the internals of the CF-tree for the whole 1.x line.
The column-major leaf layout planned alongside this is rejected, and the measurement that
motivated it no longer reproduces. On a raw centroid array, column-major moved gzip -6 from
3.97× to 6.15× (mnist at 2000 leaves, 2026-09-08). On the file save actually writes it is worth
1.06–1.17×, because CBOR tags every float with its width and that byte between values dilutes
the column similarity the layout exists to exploit. Re-measuring the array itself now reads
2.88× row-major and 3.35× column-major, not 3.97 and 6.15 — the rebuild policy changed
which siblings merge, so different centroids end up adjacent. Encoding each per-dimension vector
as one CBOR byte string instead of a float array was measured on the same files and is rejected
too: 8 %. Neither buys a permanent format complication.
- A rebuild now merges the cheapest sibling pairs, not the closest ones — the leaf summary is
21 % tighter and every published label may move.
CFTree::rebuildpicked each entry's partner under the routing measure and ranked the pairs by the absorption gap. Under the default D0/D0 geometry both are the unweighted squared centroid distance, so compaction merged the geometrically closest pair whatever it weighed — and the exact price of fusing two cluster features is the Ward costw_a·w_b/(w_a+w_b)·‖μ_a−μ_b‖², which prices a pair of thousand-point leaves a thousand times above a pair of singletons the same distance apart. Ranking by distance therefore spent the budget on the dense core and left the rest as ones: ondigitsat 200 leaves 63 % of the leaves held a single point, leaf-mass Gini 0.82. The rank is now the merge cost, whateverdistanceandabsorptionare set to; the threshold still grows in the absorption measure, which is the one the gate reads.
Measured against ELKI 0.8.0's BetulaLeafPreClustering at a matched leaf count, the geometry this
library defaults to goes from 8 % behind to 15 % ahead: leaf WCSS on digits 7.087e5 → 5.601e5
and on covtype-50k 8.642e4 → 6.794e4, both −21 %, with the singleton share 0.626 → 0.139 and
0.309 → 0.148 and the rebuild count 23 → 8 and 27 → 5. ELKI's own D4/R geometry moves 1.8 % and
5.6 %, which is the control: under D4 the gap already was the Ward cost, and D4/R is the geometry
where the two implementations were already level.
Downstream, the ward head gains the most (moons ARI 0.148 → 0.599, varied 0.433 →
0.705, mnist 0.367 → 0.394), gmm gains a little on the real sets, and kmeans loses two
real-set cells and gains none (mnist 0.315 → 0.285, covtype 0.064 → 0.049 — both inside the
seed spread of the cell). Synthetic k-means and gmm cells move by ≤ 0.001. absorb="chi2" is
bit-identical, because its gate binds long before the leaf budget and no rebuild ever runs. Every
quality table in bench/RESULTS.md was re-measured for this. (The HDBSCAN contest section was
not, and went on quoting the earlier tree; the re-measurement is in [Unreleased].)
canonical_order=True is unaffected as a guarantee: the order study rewrote 50 of its 81
cells and all 27 canonical cells still read spread 0.0000 and pairwise ARI 1.0000, which
bench/insertion_order.py asserts rather than reports.
Three tuning knobs lose most of what they were worth, because the tree now does their job.
balance="auto" fires in none of the 27 cells it was shipped on (the heaviest-leaf share it
arms on reads 0.005–0.046 there against up to 0.594 before) and only reaches its trigger two
decades of budget lower. leaf_refit=1 now pays in 6 of 12 published column-pairs against 11 of
12, mean effect +0.005 on the arrival order and −0.001 under canonical_order against +0.032 and
+0.082 — every leaf_refit=0 cell rose instead. tree_report's size-imbalance diagnosis no longer
fires on the fixture it was cut on. All three are documented with the new numbers rather than left
quoting the old ones, and none of them changed: the tree they measure did. Full write-up, including the five
non-default geometries and the ten-seed spectral control, in
research/RESULTS-estep.md.
- The speed half of the benchmark is re-timed on the same tree, and the headline number moves from
8.7× to 10.8×. The rebuild change removes work — rebuilds fall 27 → 5 on covtype-50k and 23 → 8
on digits — and the previous edition published that as an argument for keeping stale timings. It is
now a measurement:
results_scaling,results_memory,results_real_scaleandresults_sparsere-run under the page's own contention gate with zero contention sightings, and betula's fit time at a million points is 0.763–0.816× the 2026-09-07 edition across five heads while the scikit-learn column moves 0.952–0.984×, which is machine drift and the control. The effect grows withN: at 10 000 points three of the five heads are slower. 1 M k-means reads 0.216 s, soREADME.md,docs/MATH.mdandpaper/paper.mdnow say 10.8× againstKMeansand 37× againstBirch; full covtype is 1.006 → 0.746 s at ARI 0.070 → 0.087.
Resident memory did not move at four significant figures, which retires the +7.9 MB covtype
regression the 0.8.0 edition recorded: 905.3 → 904.9 MB against an unmoved 931 MB, agreeing with
the eight paired runs that already refuted it. The control on the whole re-run is exact — over the
117 rows of results_quality.csv + results_real.csv every metric column reproduced bit for
bit and only time_s moved. The scoreboard moves speed 32/1/5 → 30/1/7 and memory 28/6/0 →
30/4/0; both demotions are the same 10 M row counted twice, where betula got faster (8.26 →
7.66 s) and scikit-learn's one-shot path returned to the allocator artefact this page has withdrawn
twice (8.96 → 6.43 s, faster at 10 M than at 5 M). They are accepted as measured and named as
unreliable in
bench/RESULTS.md.
min_samplesnow defaults to the leaf mass, not to a point count. HDBSCAN*'s core distance is the radius enclosingmin_samplespoints, and over a leaf summary a single leaf already holdsN / max_leavesof them at one coordinate — so any count below that mass is enclosed at radius zero, every core distance collapses and mutual reachability degenerates to plain distance, i.e. single linkage chaining through every overlap. The docs have stated that trap since 0.6.0 and left the caller to avoid it.min_samples=None(the new default, spelled"auto"if preferred) asks instead for the mass of ten average leaves — the conventional ten-point default translated into the currency the head counts in.
On the 100 000-point blobs contest that moves the untuned answer from ARI 0.478 to 0.820 at
2 000 leaves and from 0.678 to 0.896 at 8 000, against fast_hdbscan's 0.910 over raw points.
On the six published quality fixtures it costs nothing where the fixed ten already worked (moons
0.9999 → 0.9995, circles 1.0000 → 0.9999, highdim unchanged, aniso 0.568 → 0.565) and gains
where it did not (blobs 0.142 → 0.444, varied 0.479 → 0.548). Over a wider grid it wins or ties
in 16 of 18 cells, the two losses being a two-blob fixture at a 200-leaf budget where both answers
are ARI < 0.08. (Measured before the cost-ranked rebuild above, which shipped in the same release;
on 1.0.0 as tagged the rule reads 0.821 and 0.822. The re-measurement, and what else it moved, is
in [Unreleased].)
Labels change for method="hdbscan" / "dc-center" / "dc-median" fits that did not name a
min_samples; an explicit integer is used exactly as before. get_params() reports None for the
automatic state, and auto_min_samples / AUTO_MIN_SAMPLES_LEAVES are public on the Rust side.
-
The Hamerly/brute-Lloyd equivalence claim is now true at a cutoff, and tested there. The accelerated k-means is documented as producing output identical to brute Lloyd, and the test that checked it ran only at
max_iter = 100, where both have converged. They pair the steps differently — brute is[E, M] × itersand returns the labels of the previous centres, Hamerly isE + [M, E] × itersand returns the current ones — so atmax_iter = 1they disagreed. The reference implementation (test-only) now ends with an assignment step, which is also scikit-learn's contract and the onepredictassumes, and the differential test runs atmax_iter ∈ {1, 2, 3, 100}comparing labels, centres and inertia, plus asserting that the labels returned are nearest to the centres returned. No shipped path changes:lloyd_hamerlyalready had the consistent pairing. -
consensusmeasures one nuisance at a time, and its vote is a bijection — scores move. Two defects, both label- and score-changing. It permuted the insertion order and moved the head's seed on every run while documenting itself as measuring the insertion order;varynow names the choice —"order"(new default: permute, hold the seed),"seed"(one order, move the seed),"both"(the old behaviour). And runs were aligned to the reference by sending each cluster to whichever reference cluster it overlapped most, which is not a bijection: a run that splits a reference cluster maps both halves back onto it, so the split scored as agreement while the merge it forces at fixedkwas counted once. The bias has one sign — measured at +0.125 on a split/merge fixture (0.750 against the bijection's 0.625) and +0.03…+0.13 over random disagreements. Alignment is now a maximum-weight bijection (a ~40-line Jonker–Volgenant in NumPy, since the wrapper's only dependency is NumPy; tested against SciPy and against brute-force enumeration atk ≤ 6), and a run with more clusters than the reference keeps its surplus as fresh ids instead of folding them onto a matched cluster. Confidence scores from 0.8.0 and earlier were too high and are not comparable with these. -
Rust API (breaking): the point entry points are checked.
CFTree::insert,Model::predict,DenStream/DbStream'sinsert/predictandWindowStream::inserttook a&[R]and trusted its length. The SIMD kernels comparea.len().min(b.len())coordinates, so a row one column short was clustered on its prefix and a row one column long dropped its tail — both silently, and adebug_assertis not a release check. Each now has atry_*counterpart returningResult<_, ShapeError>(new, intypes, withexpected/gotand aDisplay), and the unchecked bodies arepub(crate): the Python boundary validates a whole array once and then calls them per row, which is the one caller that should not pay per point. The tree is untouched when a row is rejected, which an integration test pins in both profiles.
For a Rust caller the fix is tree.insert(p) → tree.try_insert(p)?. CFTree::dim() is now
public, since a caller that has to match the dimension should be able to read it. The truncation
in kernels.rs is no longer load-bearing — every internal caller slices exactly dim — and its
comment says so rather than claiming an insert relies on it.
-
The streaming constructors bound every parameter they carry, not the product of two of them.
DenStream::newtestedeps,lambdaandbeta·muwithis_nan, which an infinity passes, and never testedbetaormuon their own. Soeps=infwas accepted and merged the whole stream into one micro-cluster;beta=2.0set the promotion weight above the macro floor it is defined as a fraction of; andbeta=-0.5, mu=-4.0passed the product test withbeta·mu = 2while leavingmunegative, andmuis the macro-cluster weight floor — every connected component, down to a single stray point, was then reported as a cluster instead of as noise. Each bound is nowis_finiteand each parameter is checked on its own (eps > 0,lambda > 0,0 < beta <= 1,mu > 0, thenbeta·mu > 1);DbStream::newlikewise forr,lambda,alphaandmin_weight. The rejection reaches Python at the firstpartial_fit, where the model is built. -
max_iter=0is refused, and one iteration is the floor everywhere it was not. A zero-iteration EM returns its own initialisation: for the GMM heads a responsibility matrix of zeros and a labelling of all-zero, reported as a successful fit of one cluster. Nine head loops ran0..max_iterand five of them clamped to one iteration first, so the same request meant different things depending onmethod. The floor now lives once inmodel::fit_head, where every parametric head passes, and the three heads dispatched outside it (scale-space, COP-KMeans and the Bregman pair) carry it at their own entry with a comment saying why. On top of that,max_iter=0raises at every boundary a caller can reach:Betula,fit_predict,fit_predict_sparse,BregmanBetula,KPrototypesand the CLI's--max-iter. -
A head-specific keyword on a head that ignores it now raises.
rank=8withmethod="ward",min_samples=25withmethod="kmeans",resolution=1.5with anything but Leiden — each configured nothing and said nothing. Nine keywords are now checked against the head that would read them:min_samples/graph_degree(hdbscan,dc-center,dc-median),min_cluster_size(hdbscan),resolution/covariance_weight/tangent_weight/tangent_rank(leiden,leiden-cpm),rank(mppca,mfa) andfuzzifier(fuzzy-cmeans), with the error naming the heads that do read the keyword.
A keyword left at its default is not a request — rank=2 is what every caller who never thought
about rank passes — so only a changed value is an error, and the three defaults now live in one
place (DEFAULT_RANK and its siblings) that the PyO3 signatures, the serde defaults and the
check all read. max_iter and auto_k_max are deliberately not checked: they are general to
the parametric heads and their applicability depends on n_clusters as well as on the head.
- A covariance head with
feature="spherical"is now refused, not warned about.Sphericalkeeps one scalar of within-leaf scatter, andcov_densehands it back asssd/(w·dim) · I— so a head reading a per-component covariance gets the same isotropic term added to every component,ln|Σ_c|moves with it, and the maximum-posterior argmax follows. Ondigitsat ×2.0 compressionmethod="gmm"reads ARI 0.0097 against 0.4343 onfeature="diagonal"; nearest-centre labelling of the same fitted model reads 0.5288, which is how the head rather than the fit was identified as the loser. 0.8.0 warned on thegmmpair only, on the grounds that the other heads were unmeasured. They have now been measured, over five leaf budgets ondigits(medians of seeds 0/1/2):gmm-fullcollapses at two of the five (0.0096 and 0.0115 against 0.6220 and 0.5131) andmfaat ×2.0 (0.0086 against 0.4949), whilemppca— whose model is a low-rank subspace plus its own isotropic noise — and the threegmm-toeplitzrungs, which read covariance off the between-leaf structure, are unaffected to four decimals.
So method ∈ {gmm, gmm-full, mfa} with feature="spherical" now raises a ValueError
naming the measured cost and the fix, and tune()'s default space stops proposing the pair. An
answer that is right at three budgets and near zero at two is worse than no answer, and the caller
cannot tell which one they got. bench/leaf_budget.py, bench/size_imbalance.py and
bench/insertion_order.py measured their gmm column on that pair; they now pass "diagonal",
and those published tables will move when next re-run.
-
Rust API (breaking):
Model::fit,kmeans_autoandspectraltake the restart count. It sits aftermax_iter, and0asks forKMEANS_N_INIT— the value they used before — so the fix at every call site is to pass0.spectral's privateN_INITand the Python layer'sCOP_N_INITare gone, both being the same constant under two names. No fit changes. -
The streaming heads now have a reference, and it says the drift row is a win and the split row is a loss.
bench/stream_reference.pyruns DenStream / DBSTREAM / CluStream against River's implementations of the same three papers on a 4000-point stream (River is pulled per invocation and is not a dependency). At a shared radius the comparison is a units mismatch — every one of the four density heads reaches ARI 1.000 on the stationary fixture at its own radius, and they simply disagree about what a radius of 1 means. At each head's own best radius, chosen on the stationary prefix: a +50 translation is followed exactly here (1.000) while River'sDenStreamandCluStreamboth fall to 0.000, and a cluster split is resolved by River (0.54–0.57) and not by us (0.120 / 0.288), because the radius the settled stream recommends is wider than the halves it has to separate. Learning costs 0.1 µs/point (DenStream) and 1.0–1.7 µs/point (DbStream) against River's 4.7 and 21–23, and CluStream's 150–181. CluStream's pyramidal time frame — retrospective queries over an arbitrary past window — has no counterpart here at all, andbench/RESULTS.mdrecords that as a missing capability rather than omitting the row. -
The ELKI cross-check now covers the tree, and at equal leaf count the default geometry is 8 % behind.
cross_check.pyalways ran a tree-only layer and it was never written up. Read at an equalmaxleavesbudget it flatters this library — both implementations undershoot the budget and ELKI undershoots harder (926 leaves against 1583 at a budget of 2000 on covtype/D4/R), so its lower WCSS is bought with leaves rather than with a better summary. Binary-searching ELKI's budget until the realised counts match to within 1 % splits the answer by geometry: on D4/R the two trees are level (ratios 0.977 and 0.993), and on D0/D0 — this library's own default — ELKI's leaf partition reaches 7.6–8.5 % lower within-cluster sum of squares. The partitions agree with each other at only ARI 0.39–0.53 even where their WCSS matches, so a matching objective is not a matching tree.research/RESULTS-estep.mdrecords both tables and the timing column that must not be read as a speed comparison (ELKI's seconds are JVM start plus CSV parsing). -
The ELKI cross-check of the GMM head is re-run at five seeds, and it costs the E-step page one cell.
research/RESULTS-estep.mdclaimed the shipped head "leads at the median in all four cells" of an ELKI 0.8.0 comparison; three seeds could not separate a 0.02 ARI gap from its spread. At five, the shipped head's own medians are unchanged (0.5239, 0.5210, 0.0852 — its spread was already resolved) but ELKI's moved, and covtype/D0/D0 is now 0.0632 againstBetulaGMMWeighted's 0.0681. That is a tie inside a −0.0168…0.1039 seed range, not a loss, but it is not a lead and the sentence is corrected rather than left standing. The within-cluster sum of squares still favours the shipped head in all four cells, which is the claim worth keeping: WCSS is the objective and ARI is a proxy for it.
The same run makes the two come apart visibly on covtype, where ELKI's k-means scores higher ARI
(0.0951 vs 0.0820) while reaching a higher within-cluster sum of squares in every cell — a
better-optimised k-means that predicts forest-cover classes worse, because those classes are not
k-means-shaped. And one number for the open n_init question: ELKI's 4 restarts buy +0.137 ARI
on digits (0.6856 vs 0.5482) and nothing on covtype.
- The uniform 1–12 % slowdown across the scaling and streaming suites is measured, and it is not
the insert path.
bench/RESULTS.mdrecorded the shift honestly but could not attribute it, because the compiler moved with the code (rustc 1.98.0 → 1.98.1, kernel 7.1.9 → 7.1.13). Two A/Bs, each varying one thing: the toolchain costs 8.9 % on the kmeans probe, 4.1 % on ward and 7.7 % on streaming, with gmm a wash; the source (0.7.0 → 0.8.0, compiler pinned at 1.98.0, 15 clean samples per cell and 0 dirty of 120) costs 4.1 % on ward and ~2.5 % on streaming, while kmeans and gmm flip sign between cycles and are a wash.
Both probes run bench/_worker.py directly, so the cell measured is the cell published rather than
a proxy, and each arm's compiler is read back out of the built .so instead of trusted from the
environment variable meant to select it. The second arm nearly measured something else entirely:
a fresh uv venv in the baseline worktree resolves the repo's 3.14 pin to uv's managed
free-threaded build, not the system CPython the repo's own venv uses, and would have compared two
pyo3 configurations rather than two revisions. Both arms now assert the interpreter as well.
What the source change did buy: this edition is 4–12 % lighter in peak RSS on all four probes (88.1 vs 91.4 MB, 88.2 vs 95.5, 52.0 vs 54.8, 52.9 vs 60.1). The kernel could not be varied and so is not excluded, only unnecessary; the reading that is excluded is the one the old note pointed at, since kmeans — the purest insert-bound probe of the four — is 1.5 % faster on the new tree.
Fixed¶
- A corrupt model file loaded, and the panic arrived three calls later.
loadchecked the schema version and nothing else, so any CBOR document that deserialized became aBetula— including documents describing a tree no insert could have produced. That is the cause: a schema version says which fields exist, not that their values address each other, and a file is input. Of seven structural corruptions built by hand, four panicked: a root index past the arena, a child index past it, a parent index past it and an empty arena all reachedindex out of boundsinside the nextpartial_fit, and an entry whose mean was one coordinate wide reached it insidecluster_centers_. A panic crossing the FFI boundary surfaces aspyo3_runtime.PanicException, which is not aValueErrorand is not something a caller can be asked to catch.
load now validates every tree it deserializes and raises ValueError: corrupt model: … naming
what disagrees. CFTree::validate checks that the arena is non-empty and root addresses it, that
every parent and child index is in range and points at the right arena, that no node is reachable
twice — which is what rules out a cycle — that every feature is self-consistent and carries the
tree's dimension, and that branching, leaf_cap, max_leaves, threshold, huber_k and
balance cannot leave an insert unable to terminate. All seven corruptions now fail at the
boundary that produced them. The check is a no-op for a tree this process built, which a test pins
across fresh, rebuilt, sharded and empty trees and all four feature models — a validator that
rejects a real tree would be worse than none.
Two things it deliberately does not do. Unreachable nodes and unreferenced entries are not an
error: compaction leaves both behind and they cost only memory. And the decompression bomb the
gzip framing invites was checked rather than assumed — decode streams out of the gzip member
instead of inflating to a Vec first, and a CBOR length header claiming 2^40 elements is
refused without reserving for it. Over 4 000 randomly corrupted files, every failure is a
ValueError and none is a panic.
- An
f32tree stopped counting rows at2^24, and its reported variance drifted for every row after that. A cluster feature accumulatesw ← w + wᵢ, and binary32 spaces its values 2 apart from 16 777 216 upward, so a unit-weight row left the weight exactly where it was. Nothing else in the summary stopped with it: the mean went on moving at2^-24per row and the scatter went on accumulating against a weight that no longer grew, so the variance drifted with the fraction of rows the weight had dropped — 1.0058 on unit-variance data 200 000 rows past the ceiling, against 1.0000 inf64.microcluster_weights_,cluster_sizes_and every mass-weighted head read the frozen total.
The weight is now an f64 running total whatever the tree's element type, in all four cluster
features, and spherical's scalar scatter with it. That is the split the arithmetic asks for
rather than a concession to f32: a weight's precision is set by how many rows a leaf absorbs,
which the caller does not choose, while a mean and a per-coordinate scatter are bounded by the
data, which the caller picked the dtype for. Both widened fields are O(1) per leaf, so an f32
tree still costs half the memory of an f64 one. What a caller reads back is rounded to the tree's
own type on the way out — one part in 16 777 216 of a total that is itself exact. The UserWarning
that used to name the approaching ceiling is gone with the defect it warned about.
Nothing already measured moved, and that was checked rather than argued. Across 48 cells
(2 fixtures × 2 dtypes × 6 feature/head pairs × 2 leaf budgets) no f64 cell changed at all
and no labels changed anywhere, f32 included; the only movement is heaviest_leaf_width in
18 of the 24 f32 cells, by at most 6.5e-7 relative, since the width is derived from the
totals that widened. Every published table in
bench/RESULTS.md — all
f64 — therefore stands unchanged. The on-disk format needed no bump either: CBOR tags a float
with its width and serde's f64 visitor accepts an f32 value, so SCHEMA_VERSION stays 2
and a model written by the released 0.6.0 wheel still loads, which a test asserts. Insert-path
cost, three sessions per arm alternating builds on 200 000 × 32 at max_leaves = 2000:
f32/spherical +1.5 %, f32/full +1.8 %, f32/diagonal and all three f64 cells
unchanged.
One residual is left in deliberately. The per-axis and full-matrix scatters of diagonal,
full and fd stay in the tree's element type, so past 2^24 rows in a single leaf the scatter
is now the term that saturates and the variance comes back 0.89 % low where the defect had it
0.58 % high. Widening those was implemented and measured before being rejected: +11 % on the
insert path and an f32 tree three quarters the size of the f64 one instead of half, charged to
every f32 user to correct a leaf that has to be past 16.8 M rows before it is wrong at all.
an_f32_diagonal_leaf_past_the_ceiling_still_loses_scatter pins the figure and
docs/USAGE.md states it.
leaf_refithanded back leaf statistics for a partition the tree it returned could not produce, and at a large budget that misrouted 15 % of the rows. The pass routes every row through the finished tree and rebuilds each leaf CF from exactly the rows it won — then, if any entry won none, it dropped those and rebuilt the node structure from the survivors. That rebalance replaces the tree that did the routing, so the CFs describe a partitionpredictno longer returns: a row descends the new structure and is handed an entry accumulated from different rows. Measured on 4 000 8-D points atmax_leaves = 160, the routed codebook error read 26.54 against an exact 4.72, 14.8 % of rows on the wrong entry, p99 244 — while the entries themselves were fine, a beam-4 route reading the exact answer with no misroutes at all.
A rebalance is now always followed by another route, so the statistics and the structure describe
the same partition. Over 150 cells (5 budgets × 10 blob fixtures × leaf_refit 1/2/3) the routed
error rises by more than 5 % in 2 of them against 8, worst 1.33× against 5.25×, and the
worst exact nearest-centre ratio falls 1.32 → 1.13; on the cell that blew up every one of the
107 surviving prototypes now wins a row, where the un-refit tree leaves 19 of 144 winning none.
bench/results_refit.csv is re-run: the pass pays in 6 of 12 column-pairs with mean +0.006
(arrival) and +0.009 (canonical_order), against +0.005 / −0.001. Labels move wherever
leaf_refit > 0, and a pass that drops an entry now costs two routes rather than one — measured at
1.8–2.5× a plain fit for leaf_refit=1 against 1.4–1.8×.
How many discretionary rebalances to take is a cap on work with both extremes measured, recorded on
REFIT_REBALANCE_ROUNDS in src/tree.rs: never rebalancing lets a shallow tree drift until its own
descent cannot reach its prototypes (39 → 12 entries, exact error tripled, on 1 of 50 fixtures),
and rebalancing on every drop cascades to 59 routing passes and prunes 314 entries to 120. One is
the best of the five policies measured on every column.
-
projection="weighted-nmf"no longer depends on how the threads interleaved. The transpose-productWᵀXis the projection's hot loop and was summed with a rayonfold/reduce, which splits by work-stealing and merges in completion order. Floating-point addition does not associate, so the result was a function of the run, not of the input: measured on a 19 998-row fixture, 20 runs atRAYON_NUM_THREADS=8produced 20 differentcomponents_bit patterns, while the single-threaded run was stable. The sum is now taken over fixed 64-row chunks and merged in index order, which is one order for one input at any pool size, and a subprocess test pins it. The documented promise that the thread count does not enter the answer was true everywhere else and is now true here;components_values shift in their last few bits against 0.8.0. -
Every
bench/harness now loads its real-data fixture from one module, and the Zador control stops describing data no cell in its own table had seen. A "20 000-row draw from covtype" was spelled out inline in five separate files, and one-off scripts had written further variants: the quality tables draw withrng(seed).choiceand standardize that subsample, while the order and budget studies standardize all 581 012 rows and then take a fixedrng(0)permutation. The samewardhead reads 0.0861 under the first rule and 0.1416 under the second, andbench/RESULTS.mdprinted both under the labelcovtype-20k.bench/_fixtures.pyis now the only definition of any of them and every harness loads through it; the rules are named (sub / pop / raw), tabulated inbench/RESULTS.md, and the three sections that publish a sub fixture under a 20 k label say so in place. All five rewired loaders return bit-identical arrays to the ones they replace across 16 (dataset × seed) cells, so nothing published moved for the refactor.
One number did move, and it was a defect: bench/zador_fit.py's TWO-NN cross-check built its own
subsample — unstandardized, rng(0).choice against the sweep's rng(0).permutation, and MNIST
taken off the front of the file rather than drawn at all — so it estimated the intrinsic dimension
of data the budget sweep never summarised. It now loads through the loader bench/leaf_budget.py
itself fits: 4.61 → 5.28 on covtype, 12.91 → 12.94 on
digits, 18.65 → 18.50 on MNIST. Re-fitting that table against the results_budget.csv the
rebuild change rewrote also retires a published reading: digits ward was the shallowest slope
of the four (d_eff 3.67 ± 0.48 against euclidean's 2.85 ± 0.42) and is now the steeper of the
pair (3.47 ± 0.39 against 3.73 ± 0.43) — under one standard error either way, so the fit never
resolved a routing effect on digits.
- The three studies that swept
gmmon the refused feature are re-run, and one published conclusion changes sign.bench/results_budget.csv,bench/results_imbalance.csvandbench/results_order.csvhad theirgmmcolumns measured withfeature="spherical", which the library now rejects; all three were re-run on 2026-09-09 with the harnesses' currentfeature="diagonal". Onlygmmcells moved — 76 of 92 in the budget study against 0 of the 160 others, which is the control that says the rest of the record is reproducible to the last digit.
The conclusion that changes is canonical_order's cost. It was published as mean +0.0136 over
27 cells, "positive only because of two cells where it rescues a head the arrival order was
collapsing" (digits, 360, gmm 0.1738 → 0.5146; mnist-10k, 1000, gmm 0.0551 → 0.2457). Both
were the isotropic collapse, so the flag was being credited for rescuing a fit that is no longer
offered. On the diagonal feature those cells read 0.4721 → 0.4195 and 0.2223 → 0.2055 — the
arrival order ahead in both — and the grid-wide mean is −0.0064, median −0.0052, non-negative
in 9 of 27 (was 10). The invariance itself is untouched: 27 of 27 canonical cells still read
spread 0.0000 and pairwise ARI 1.0000. docs/USAGE.md and bench/RESULTS.md carry the corrected
numbers and say what they replaced.
The budget study's other half is the refusal's confirmation: on the diagonal feature gmm does
not collapse anywhere (digits at ×2.0 0.0088 → 0.4403, MNIST at ×5.5 0.0618 → 0.2850),
and it becomes the strongest head on digits at ×4.2 and on MNIST below ×20. The spherical
numbers are kept, dated, as the evidence for the refusal rather than as current results.
canonical_orderrefuses more rows than au32rank can address, rather than wrapping. The permutation is aVec<u32>— four bytes per row instead of eight, which is 17 GB of index at the ceiling — and(0..n as u32)truncates silently past2³² − 1rows: the result is a valid-looking order over the wrong rows, with the tail of the matrix never visited. Both entry points now return an error there,canonical_permutationalongside its CSR twin, and it reaches Python as aValueError. Rust API (breaking):order::canonical_permutationreturnsResult<Vec<u32>, &'static str>, matchingcanonical_permutation_csr, andorder::MAX_ROWSis public. The arrival-order build is unaffected and has no ceiling.
The row order is now computed once at the Python boundary and handed down, rather than inside the
build: balance="auto" builds the tree twice to decide, and was paying for the O(n · dim)
projection and the O(n log n) sort on both passes. No label moves — the order is a function of
the data, so computing it once and computing it twice give the same ranks.
-
Three documentation claims that were no longer true, and a check so the counts cannot rot again. The README and the JOSS paper quoted a 457-case Python suite and 728 Rust tests against an actual 547 and 807, and the README's two sentences disagreed with each other; the counts are now derived by
scripts/check_test_counts.py, which collects both suites without running them (cargo test -- --list,pytest --collect-only) and fails on any published number that has drifted. The README's "always faster — the unconditional win … this holds for every method at every size" is now scoped to what was measured — faster and lighter than scikit-learn at every published size and budget — with the two rows we lose named next to it: scikit-learn is faster on raw-TF-IDF 20-newsgroups at a better ARI, andfast-hdbscanreads 0.910 against our 0.478 on 100 k blobs. The paper's "every benchmark figure is the median of three seeds" is true of the quality tables only, and now says so; its mutation-baseline sentence no longer claims an argument for every surviving mutant when what exists is an argument for every recorded one.Betula.save's docstring said bincode; the format has been CBOR sinceciboriumreplaced it. -
The symmetric eigensolver's convergence test is relative to the matrix, not to
1e-15.jacobi_eigen's fixed absolute tolerance answered two questions wrongly at once. Below a‖A‖_Fof about1e-15it was satisfied before the first rotation, so the untouched diagonal came back as the spectrum: on a 4×4 fixture scaled by1e-18,octave-cli'seigreads[-0.4755, 3.3895, 4.0718, 7.2641]after rescaling and the old code read[-0.890, -0.812, -0.440, -0.114]— four wrong values, no error, no warning. At unit scale it was unreachable instead:off(A)sumsn(n−1)/2entries, so every one of them has to fall below1e-15/nfirst, and the loop always spent its whole 100-sweep budget inf64and inf32, whereepsis1.2e-7and the threshold is meaningless. The test is nowoff(A) ≤ eps_R · ‖A‖_F(Golub & Van Loan, Alg. 8.4.3), which makes the answer invariant under scaling the input by a positive constant, and a golden test pins five scales from1e18to1e-30againstoctave-cliat 13 significant digits. A 32×32f32decomposition drops from ~520 µs to ~348 µs on the measurement machine (three alternating repetitions);f64is unchanged within noise. No label moves:spectralandleidenondigitsat seeds 0/1/2 in both precisions return byte-identical labels before and after. -
cholesky_lowerrejects a non-finite partial sum.NaN <= 0.0is false, so a non-finite entry passed the positive-definiteness test,sqrtpropagated it, and everylogdetand Mahalanobis distance downstream came backNaN— a full-covariance GMM component with aNaNscatter looked like a valid factorisation rather than a rejected one. -
threshold="auto"no longer breaks thecanonical_orderguarantee.canonical_order=Truepromises a summary that is a function of the row multiset; the automatic threshold is piloted on a bounded subsample, and that subsample was drawn by row position, so a permutation handed the pilot different rows and it converged to a different threshold — the order dependence was back before the tree was built. Measured on a 12-dimensional 4-blob probe, one permutation moved the pilot from 17.184 to 17.256 and the realised tree from 284 leaves to 279, with the labels no longer identical. The pilot rows are now taken evenly spaced along the canonical order, which is a function of the content, so the threshold, the leaf count and the labels are all invariant again. Onlycanonical_order=Truewiththreshold="auto"is affected, and its labels change; the arrival-order path keeps the uniform draw and is untouched. -
A loaded model reported
projection="none"however it was fitted.Betula.loadrebuilds the wrapper withcls(**core.get_params()), and the engine'sget_paramsnever reportedprojection,projection_dimorprojection_max_iter— so a model fitted withprojection="svd", projection_dim=4, saved and loaded, came back describing itself as("none", 64). The fitted state survived (components_andpredictwere unaffected), butget_paramslied andsklearn.cloneof a loaded model returned an estimator that does not project at all.
The cause is that the engine stores the projection decomposed — rank, an SVD flag, a KL flag,
solver sweeps — because the persisted wire format predates the ProjectionSpec sum type, so
reporting it means inverting projection_spec() and nobody did. More generally the engine's
get_params is a hand-maintained mirror of the struct with nothing tying the two together, which
is how these three and the new route_beam each went missing. A test now asserts the engine
reports every parameter the wrapper carries, with memory_budget_mb — resolved into max_leaves
before the engine exists — as the one documented exception.
- The greedy descent misroutes a quarter to a half of all rows, measured — and the blast radius is
a quarter of what it was thought to be. The premise on record was that every label this library
returns goes through
CFTree::nearest_entry. It does not:fit_predictends inroute_data, which consults the tree only when noPointRuleis set, andfinalizesets one for every head with a centroid assignment rule. Ondigits,kmeanslabels agree with an exact argmin over the k cluster centres 100.00 % of the time andgmmuses the mixture posterior — neither touches the tree at label time. Only ward, spectral, leiden and hdbscan assign by microcluster.
For those, against an exact BLAS scan at max_leaves=4000: 24.6 % (blobs) to 44.4 % (covtype)
of rows do not get their nearest microcluster, and a microcluster's own centre fed back through the
tree returns that microcluster only 48–71 % of the time. It costs almost nothing on four of the
five datasets — 0.00–0.23 % of labels change, ARI between the two routings ≥ 0.994, and against
ground truth greedy is a wash or marginally ahead — because the median misroute lands 0.05 to 1.15
centre spacings away, i.e. in a neighbour that belongs to the same cluster.
mnist is the exception: 9.5 % of labels change and exact routing is worth +0.037 ARI (0.3533 →
0.3902, a 10 % relative gain). At d = 784 the excess is the smallest in the table (0.06
spacings) and still crosses cluster boundaries — concentration of measure makes the wrong answer
cheap in distance and expensive in labels at once. No unconditional fix is taken: an exact scan
costs 0.7× the fit on mnist but 8.9× on covtype (and covtype's scan is the cheaper of the two in
FLOPs, so the cost is n × m memory traffic, worst exactly where betula's advantage lives). An
exact scan below an m₀, or a beam at descent, both change mnist labels and so are type-1 doors —
the beam is the one taken, as the opt-in route_beam above. Full table in bench/RESULTS.md.
-
A published claim retracted: the +7.9 MB peak-RSS move on full covtype is not reproducible.
bench/RESULTS.mdcalled it "a real, reproducible move in the wrong direction" on the strength of one sample against one sample — two paragraphs after warning that a single sample cannot settle this cell. Eight paired runs ofbench/_worker.py real_fit betula-kmeans covtype, alternating this tree against28d4e9f, put the medians at 884.7 MB against 882.6 MB — 2.1 MB apart, inside within-arm spreads of 9.6 and 15.3 MB. The same eight pairs make the time gain larger than published, 1.005 s against 1.158 s (13 %) where the table reads 1.06 → 1.01 s. The scoreboard cell is a tie either way; what changes is that there is no unattributed memory regression to chase. -
The weekly mutation run was sharded 96 ways on a mutant count that has since grown 2.1×, and more than half of its shards are being cancelled at the timeout. Found by
scripts/check_mutants_baseline.py, which prints the live count as a side effect:cargo mutants --listreads 8 927 against the 4 312 the matrix was sized on. Run 34108190109 was in flight while this was being fixed and settled it — 28 of its first 53 completed shards were cancelled at exactly the 150-minute cap. A cancelled shard still uploads its partialmutants.out, so the collation job counts it as present and the week's survivor total simply comes out short, with nothing red to say so.
The 23 shards of that run that did finish give a cost model, taken from the per-mutant phase
timings in their outcomes.json rather than from a per-mutant average — averaging is what hid
this, because the distribution spans two orders of magnitude: 15 s unviable, 70 s caught
(p90 447, max 1082), 660 s missed, 988 s timed out, plus a 236 s baseline per shard. -j4
measures 0.94 parallel efficiency, so job_minutes ≈ worker_seconds / 4 / 0.94 / 60. Shard cost
is set by the mix, not the count: those 23 shards span 7 024 to 34 971 worker-seconds at an
identical 93 mutants each, because --shard k/n slices the deterministic list contiguously and so
effectively by file.
That rules out raising the cap alone: 93 mutants that all time out is 467 minutes, past
GitHub's 360-minute ceiling for a hosted job, so no cap makes a 96-shard matrix safe. The matrix is
192 shards with a 300-minute cap, which puts that same worst case at 236 minutes and the
worst observed mix at ~78. Wall clock does not pay for the split — the total work is unchanged
and only the per-shard baseline and job setup are duplicated, ~25 minutes against a measured total
of ~610 worker-hours. The shard count now lives in one env: SHARDS that both --shard k/n and
the collation tally read, so the two can no longer drift apart silently; a mismatch with the
literal matrix surfaces as shards collated: 96 / 192 and fails.
The v0.8.0 changelog reached the "cannot finish as configured" conclusion by wrong arithmetic (a
stale 101 worker-seconds per mutant, and -j4 forgotten); that sentence is corrected in place
rather than deleted.
0.8.0 — 2026-09-07¶
Fixed¶
- A column-major (Fortran-order) input array was read as its transpose — silently, with no error,
producing a clustering of scrambled data. Every array-taking entry point was affected:
fit,fit_predict,predict,partial_fit, and themixture_w2parameter readers. It is not an exotic input —sklearn.datasets.fetch_openmlreturns one, as donp.asfortranarray, several pandas.valuespaths, and interop with R or Julia.
The cause is one word in the zero-copy ingest (06ad6b5): PyReadonlyArray::as_slice succeeds on
any array that is contiguous, and a column-major array is contiguous. Its buffer is then the
transpose of what a row-major read expects, so the borrow handed the tree the right numbers in the
wrong shape. Nothing downstream can notice: the values are finite, the moments are well-formed,
the tree builds, and the labels come back. On MNIST (fetch_openml, 10 000 × 784) it read as one
leaf holding 2381 "identical" rows, a summary no data row was within 2273 of, and an ARI against
the digit labels of −0.0001 where scikit-learn's k-means reads 0.34. After the fix the same
call reads 0.344 at max_leaves = n, with a lower k-means objective than scikit-learn's
(2.5238e10 against 2.5284e10).
The borrow is now gated on ndarray's standard layout — the row-major condition the module
actually indexes by — rather than on contiguity, and a column-major array falls through to the
existing copy path, which iterates in logical order and was always correct. mixture_means and
mixture_covs had the same defect and now read logically too. The regression test asserts label
equality between the two layouts rather than similarity, and fails on revert.
No published number moves: every dataset in bench/ arrives C-contiguous (fancy indexing and
StandardScaler both produce one), which is also why this survived so long — the benchmark record
could not see it.
- Two reference values in the Watson head's
GOLDENtable were computed in double precision and printed as if they had 40 digits. No shipped behaviour changes; the table is test input, and the test's tolerance (1e-11relative) is four orders of magnitude looser than the error, so it passed before and passes now. The cause:local/scratch/watson_kummer.macpassed the float literals±0.25and1e4where exact±1/4and10000belong. A float argument makes Maxima'shypergeometricevaluate in float mode and return a double, whichbfloatthen pads out tofpprec: 40— 15 real digits followed by 25 of noise. Rounded tof64thed = 3, κ = +1/4row was 5 ulp wrong (8.615403391270196e-2against8.615403391270206e-2) andκ = −1/41.6 ulp; both are corrected. The same float literal made section 5 — the section that verifies the boundsg(0) = 1/d,g(−∞) = 0,g(+∞) = 1that let the concentration solver pick its branch — abort on a floating-point overflow, so that check had never actually run; with exact arguments it runs and confirms all three to 40 digits. - Six reference values in
stats.rs'sCDF_TABLEwere evaluated at the decimalxrather than at thef64the test passes toreg_lower_gamma. Also test input only, and also below the tolerance that reads it (5e-13against the implementation's own worst residual of1.1e-13), so nothing was ever red.P(a, x)has logarithmic sensitivity≈ ain the left tail, so the ~1e-17 gap between the decimal literal and the binary64 nearest it is amplified bya: the six affected rows are exactly the two smallest-xentries ata = 12, 50, 200, and the error runs from 2.3 ulp up to 61 ulp ata = 200, x = 10.05. The predicted amplification (60.5 ulp) and the measured one (61.3) agree, and all six committed values reproduce exactly when the reference is recomputed at the decimal — which is what identified the cause. -
A generator that silently drops to double precision leaves the provenance comment true and the numbers wrong, so provenance is no longer accepted as evidence. All three reference tables in the crate are now recomputed from an engine other than the one that produced them (
local/scratch/recheck_goldens.py, mpmath at 60 dps against Maxima and against mpmath's own earlier output):watson.rsagrees to 1.19 ulp over 48 values,vmf.rsto 0.48 over 11, andstats.rs's 234-row quantile table to 0.50. Relatedly,maxima -bexits 0 even when the batch aborts mid-file, which is why the Watson overflow went unseen;local/scratch/run_maxima.shnow runs every.macunder-Q, scans for the abort signature and requires a completion sentinel, and all 13 scripts pass it. -
The release workflow would have failed after publishing.
release.ymlpipes this file's section for the tag straight into the GitHub Release body, and GitHub caps that body at 125 000 characters. 0.6.0's section is 16 649 bytes and published fine; 0.8.0's is 172 863, so thegithub-releasejob would have returned 422 — after thepublishandcrates-iojobs had already put the wheels on PyPI and the crate on crates.io, leaving a published version with no Release and therefore no Zenodo archive, and no way to re-run it without a new version number. The step now truncates on a line boundary at 110 000 bytes and appends a link to the full file; under the cap it is unchanged. Verified by running the step's script against this tree: it emits 108 517 characters out of 170 426.
Changed¶
CFTree::build_parallelis nowCFTree::build_sharded, is compiled without theparallelfeature, and takes a trailingorder: Option<&[u32]>— a breaking change for Rust crate users. Python callers are unaffected; the migration is a rename plus one argument, andNonereproduces the previous behaviour exactly.
The order parameter exists because sharding had to split ranks of the canonical order rather
than rows: sharding rows would have kept the insertion-order dependence while looking like it
removed it, which is a failure mode no test would have caught by accident, so there is one that
fails on exactly that mistake.
The rename and the un-gating go together, and both come from the same rule — the thread count
and the build configuration are not allowed into the answer, only the shard count is. The old
name said "parallel" for something whose observable effect is a partition, and the old #[cfg]
meant a crate built without parallel silently produced a different summary for the same
n_jobs. rayon now supplies the schedule and nothing else: the partition, the per-shard budget and
the merge order are identical either way, and the tests that cover them run in both
configurations rather than only under --features parallel.
fit_predict'sn_jobsis renamedn_shards, nowint | Nonedefaulting toNone— a breaking change for Python callers who passed it. There is no deprecated alias: the migration is a rename, andn_shards=1or the default reproduces the previous default exactly.consensus(n_jobs=…)is untouched — there it really is a worker count over independent runs, and the result is invariant to it.
It was never a worker count here. It was the shard count, and shards are the partition: two counts
hold different point sets and build different sub-summaries, which no merge order repairs.
Measured over the 27 published cells at 1 / 2 / 4 / 8 shards, labels at 8 agreed with 1 at pairwise
ARI 0.46 on average, 0.098 at worst wherever compression was real — as wide as the row-order
gap canonical_order exists to close. scikit-learn documents n_jobs as a worker count and no
more, so the old name broke a convention rather than a written contract; but the convention is
real — no scikit-learn estimator moves with it — and it is what the name buys. Parallelism comes
from RAYON_NUM_THREADS, as it already does for the kernels and the Phase-3 heads, and does not
enter the answer.
n_shardstogether withcanonical_order=TrueraisesValueErrorrather than being ignored. With the flag on the shard count isclamp(n / 25000, 1, 64)— a function of the data — because a guarantee that survives a reshuffle but not a re-tuned shard count is not a guarantee. Silently discarding an argument it will not honour is the shape that let the old name go unexamined for two releases, so the two are rejected as a pair instead. The constant is the measured knee rather than a round number: atn = 200k, max_leaves = 2000akmeansfit reads 1.00 / 1.90 / 2.66 / 3.42 / 3.71 / 3.91× for 1 / 2 / 4 / 8 / 16 / 32 shards, so 25 000 rows per shard buys 87 % of the available speed-up and leaves the partition four times coarser than the alternative. Sharding's quality cost was measured before the constant was picked and is a wash (8 shards minus 1, mean +0.004 ARI). Inputs under 25 000 rows return one shard and keep the plain sequential build, so every published cell is unchanged. With the flag off nothing changes at all.- The canonical order's "3 → 30 rebuilds, insert 1.56×" was a tail draw published as the cost of
high dimension — one claim corrected, no code change, and a magic constant deliberately left
alone.
order::PROJECTION_SEEDseeds the eight projections the insertion key is built from, and the worst cell on the cost page made it look badly chosen: a seven-candidate screen over six shapes found four constants reading 3 → 3..5 at50k × 784, max_leaves = 8000against the shipped constant's 3 → 30, and 138–152 total rebuilds against 172.
It is not badly chosen, and the screen could not have shown that either way. Quality does not move
with the constant (18 cells × 7 constants × 3 head seeds; Friedman p = 0.53, largest gap between
any two constants' mean delta 0.028 against the arrival order's own median spread of 0.077), so
the choice was a cost choice — and the cost margin vanished on shapes the candidates had not been
screened on (107 / 109 / 99 total rebuilds for shipped / winner / runner-up, with the roles
inverting outright on blobs 60k × 384: shipped 3, winner 15). Twenty-four draws per shape give the
reason: the rebuild count is a right-tailed lottery over the projection draw, reading min 3,
median 4, max 31 at the very shape that started this, with the shipped constant at 4. One
draw in roughly twenty-four is pathological at a given shape, and which one changes with the shape.
So the constant stays, and stays untuned: a screen of a few shapes will always produce a winner
that does not generalise. Selecting the draw from the data at fit time would have preserved the
order-invariance guarantee and is rejected on measurement instead — no cheap statistic of the codes
predicts the rebuild count (Spearman |ρ| ≤ 0.48 over those draws, mostly under 0.27, sign-flipping
between cases), so there is nothing to select on short of building a tree per candidate. Recorded in
docs/adr/005-canonical-order-projection-seed.md.
- bench/RESULTS.md's reproduce recipe pinned OpenMP, which hands betula a free 2.2× — recipe
corrected, no published number moves. The page told the reader to export
OMP/OPENBLAS/MKL/NUMEXPR_NUM_THREADS=1. Following it does not reproduce the page, because the two
libraries do not share a thread knob: betula's parallelism is rayon's, which OMP_NUM_THREADS does
not reach, while scikit-learn's k-means Lloyd loop is OpenMP. Pinning OpenMP gags one side and
leaves the other on every core.
Every table was in fact taken at OMP_NUM_THREADS=8, inherited from the shell — which makes
comprehensive.py's os.environ.setdefault a no-op for that variable and leaves it pinning only
the harness's own BLAS. Instruction and numbers were never the same run. Re-running the whole sweep
with that single variable changed, scikit-learn alone slows 2.2–2.7× wherever d is large
enough for the Lloyd loop to matter — 1.03 → 2.80 s on the memory suite at n = 500 000, d = 20,
4.92 → 10.86 s on full covtype, 1.03 → 1.80 s on 20-newsgroups — and does not move at all on
results_scaling.csv, whose blobs are two-dimensional. That dimension dependence is what identifies
the cause: a dependency bump or machine drift would not spare d = 2. Two alternative explanations
were tested and rejected first — scikit-learn 1.7.2 and 1.8.0 are slower than the installed 1.9.0
at equal threads (22 s against 13 s on covtype), and every affected cell's ARI is unchanged to three
decimals, so it is not a different computation.
- Every table in bench/RESULTS.md re-measured 2026-09-07, and it is the first edition taken
under a contention gate. This machine is shared with other long-running jobs, and the two
libraries do not lose the same fraction to a competing process — scikit-learn's OpenMP loops lose
more than betula's rayon does, so contention moves a ratio while leaving every label and every
structural count bit-identical. Repetition cannot detect that. Each of the three measured steps
waited for four consecutive clean 15-second samples, was sampled every 10 seconds while running,
and was discarded and re-run if any foreign process crossed 80 % of a core; the first took three
attempts.
The quality column reproduced and the speed column did not, which is what makes the speed
changes attributable. 108 of the 117 rows in results_quality.csv + results_real.csv are
byte-identical in every metric column to the 2026-08-24/25 edition across 180 commits — every
scikit-learn row, and the whole of results_real_hires.csv,
results_real_normalize.csv and results_refit.csv. The nine that moved are all
betula-spectral, from the rewrite already described above.
What moved on speed, and why: betula-hdbscan at 1 M 0.801 → 0.517 s and, where those heads
dominate, far more — on mnist 20 000 × 784 hdbscan 15.61 → 1.75 s and ward 17.67 → 3.62 s,
which is 06ad6b5 putting the O(m²) heads on rayon. Streaming peak RSS fell 60.2 → 52.9 MB,
uniformly at all five sizes, because the suite streams 50 000 × 20 f64 chunks — 8.0 MB — and the
same commit stopped the ingest duplicating each one; the saving is one chunk and not a function of
N. Headline figures move with it: 1 M k-means 0.26 → 0.28 s, 9× → 8.7× against
scikit-learn's KMeans, 30× → 29× against Birch, 82× → 94× on 10 M streaming memory.
README.md, docs/index.md, docs/MATH.md and paper/paper.md are updated to match.
Three things are recorded as losses rather than smoothed. (1) Every betula row other than
hdbscan on the d = 2 scaling fixture got 1–12 % slower, and the streaming time column by the
same 6–9 % at every size — a uniform shift across two independent suites is not a timing sample,
it is either the insert path or the toolchain (rustc 1.98.0 → 1.98.1, kernel 7.1.9 → 7.1.13), and
this run cannot tell which. (2) betula's peak RSS on full covtype rose 905.3 → 913.3 MB against an
unmoved scikit-learn, which demotes two scoreboard cells to ties. (3) Every time in the 20-newsgroups
table fell, some by a factor of two, with every ARI byte-identical. Three further gated
repetitions with the six arms interleaved put five of the six published values above the whole
four-sample gated range, which is what a slower machine looks like — but they also put the
betula-sparse / sklearn-kmeans ratio at 2.18–3.22 with the published 2.23 inside it, so that
ratio is not resolved by four samples and the page now says so rather than quoting it. Within a
gated session every arm slows 4–21 % from the first repetition to the third, which is most of that
spread. The one pair the samples do resolve is betula-nmf against sklearn-nmf: a tie inside
6 % in all four, against a published 0.79. The board therefore moves 33/0/5 → 32/1/5 on speed
and 30/4/0 → 28/6/0 on memory, accepted with scoreboard.py --update in the same commit.
One published cell was a transcription error, not a measurement. betula-hdbscan on MNIST was
printed in the real-datasets table as 0.000; its own results_real.csv has read 0.117 in
both editions. The table is corrected; nothing was re-run to correct it.
- The scoreboard ratchet identified a cell by who won it, so a champion changing read as a result
vanishing. bench/scoreboard.py --check is now a CI job, and it could not have been one before:
on a clean tree it failed, reporting two results_sparse cells as VANISHED. Neither had moved —
betula-svd had overtaken betula-sparse as our fastest sparse head, which renamed the key
…/betula-sparse-vs-sklearn-svd out of existence while the verdict stayed loss. The cause is in
the producer, not the check: vs-best and vs-external pick a champion per side, and folding the
winners' names into the cell's identity made an improvement indistinguishable from a missing
comparison. A cell is now identified by what it compares — axis, table, pairing, dataset slice —
and the champions are printed on the rendered line. scoreboard.json is migrated in place; only
the 44 vs-best / vs-external keys change, and no verdict does.
- BIRCH's size-imbalance bug is D0's, not the CF-tree's — two published claims corrected, no code
changed. bench/size_imbalance.py reproduced scikit-learn #22854
at absorb="euclidean" only, and concluded the mis-allocation was "a property of the shared
design — one global absorption radius". The sweep now runs all eight absorption criteria, and
they differ sharply: on structured (one core of 20 000 against six minorities of 30, medians of
seeds 0/1/2) only ward (D4) and chi2 hold ARI 1.0000 at every leaf budget;
manhattan, diameter and radius recover at 4 000 leaves and not below; euclidean and
average never recover — sixteen times the budget moves neither off 0.4174. ward is immune at
250 leaves with the same realised leaf count as the default (247 against 241), so it is not a
larger budget, it is the same budget spent by a criterion that does not measure a radius; chi2
gets there with half the leaves. That makes absorb= a second independent fix alongside
balance=, and the cheaper of the two — a bigger budget is not a third, since it rescues D1/D3/R
and leaves the default exactly where it was. docs/USAGE.md separately claimed
the variance-minimising criteria "inherit BIRCH's size-imbalance bug" — D4 is the
variance-minimising criterion and it is the best cell in the table; that paragraph now names which
criteria are actually affected. bench/results_imbalance.csv grows the absorb rows so both
corrections are reproducible from the shipped harness.
- The spectral head clusters every leaf instead of 256 k-means landmarks, and it is 2–12× faster
for it. Above 256 microclusters
method="spectral"used to reduce them to 256 weighted landmarks, cluster those, and let each leaf inherit its landmark's label — a second lossy summarisation stacked on the one the tree had already done, forced by theO(M³)cyclic-Jacobi eigensolver. The top-keigenvectors ofP = D^{-1/2}AD^{-1/2}now come from Chebyshev-filtered subspace iteration: the iteration only ever needs the productP x, which isO(nnz)on the sparse graph; a degree-12 Chebyshev polynomial evaluated by the three-term recurrence multiplies the wanted end of the spectrum bycosh(12 · arccosh σ)while staying bounded by 1 on the damped interval, and the Rayleigh–Ritz step runs on a(k+4)×(k+4)matrix. NothingM×Mis formed. Past 2048 nodes the affinity graph comes from the bounded-degree beam-search index as well (the same graph the HDBSCAN head runs on), since theO(M²)distance matrix is the next thing to become unaffordable — 134 MB at 4096 nodes, 3.2 GB at 20 000. A/B on identical trees, both sides rebuilt, median of seeds 0/1/2:two-moonsandtwo-circlesat N = 20 000 hold ARI 1.000 at every budget from 500 to 5000 leaves while the fit drops from 0.34 s to 0.02 s at 500;digits-PCA20 goes 0.660 → 0.779 at 500 leaves and 0.786 → 0.801 at 1000, and loses one budget, 0.766 → 0.735 at one leaf per point;covtypestays within ±0.01 of zero on both paths, which is its documented failure case. At or below 256 microclusters the code path is unchanged and the labels are identical. Labels change above 256 microclusters. The eigenvectors are checked against the exact solver by subspace angle under Davis–Kahan'ssin θ_max ≤ ‖R‖_F / gap, not by ARI — on three fixtures whose eigengaps span an order of magnitude the measured angle tracks the gap from1.5·10⁻⁸to1.5·10⁻⁵, and a fixed tolerance would pass a broken solver on one and fail a correct one on another. The head's remaining ceiling is the graph's, and the control says so: at 20 000 nodes it scores ≈ 0.60, and forcing the exactO(M²)affinity there returns the same answer for 17.2 s instead of 1.7 — what breaks isKNN = 10falling to1.0 · log n, the García Trillos–Slepčev connectivity threshold. Tables inbench/RESULTS.md.
On MNIST the change is a budget shift, and the A/B above missed it — this is the loss. That A/B
covered two-moons, two-circles, digits-PCA20 and covtype, none of them above 64 dimensions
after reduction. At 784 the benchmark's fixed max_leaves = 4000 reads ARI 0.203 → 0.101, with
the seed ranges disjoint (published [0.183, 0.203, 0.248] against [0.079, 0.101, 0.122] over
seeds 0/1/2) — and out of 117 quality cells, the nine that moved at all are all this head's.
It is neither the new solver nor the approximate graph. Sweeping the budget through both boundaries
at a fixed subsample, quality rises monotonically — 256 → 0.0005, 512 → 0.007, 1024 → 0.025,
2048 → 0.050, 4000 → 0.082, 8000 → 0.203 — and the ≤ 256 row, whose code path is unchanged
from 0.7.0, is the worst of the six. What the landmark path bought was smoothing: k-means down
to 256 weighted landmarks conditions the affinity graph better than 4 000 raw 784-dimensional
centroids do, so the rewrite needs roughly twice the microclusters to build the same embedding.
It pays for them many times over, and then passes the old head. At equal quality the new head
reads 0.2029 in 6.21 s against the landmark path's 0.2033 in 43.87 s on the same machine and
harness one commit earlier — a 7.1× speed-up, measured on a contended machine and therefore a
lower bound. Given the resolution outright it does better than the landmark path ever did: the curve
continues 12 000 → 0.235 and peaks at 16 000 → 0.328, 61 % above the published 0.203, before
turning down at one leaf per point (20 000 → 0.299) exactly as the documented graph ceiling
predicts. Sixteen thousand is what bench/RESULTS.md's hires table already gives every head.
The fixed-budget row is published as the loss it is. The correction that matters to users is in
docs/USAGE.md, which advised
spending "a few hundred to a few thousand leaves" on this head — advice derived from two-dimensional
non-convex fixtures and digits-PCA20, and actively wrong at 784 dimensions, where it costs a
factor of four in ARI.
-
clustering::SPECTRAL_MAX_NODESis nowSPECTRAL_EXACT_NODES, and a second constantSPECTRAL_DENSE_GRAPH_MAXjoins it. Rust API only; the Python surface is unaffected. The old name described a landmark cap that no longer exists — the value 256 is now the size at which the exact eigensolver hands over, not the size the node set is truncated to, and the two boundaries the head actually has are worth naming separately because they exist for different reasons (O(M³)solve, thenO(M²)memory). -
linalg::orthonormalize_rowsis public, andmppca/mfa/spectralshare it. Gram-Schmidt with one re-orthogonalisation pass existed twice already, byte-identical; the Chebyshev solver needs a third caller, and three copies of a numerical primitive is how they drift apart. -
clustering::xmeansis nowclustering::kmeans_auto; the namexmeansbelongs to the new splitter. Rust API only — the Pythonmethodstrings are unaffected. The function was never x-means: it fits a full k-means at everyk ∈ [k_min, k_max]and keeps the best BIC, which is a BIC-selected sweep. X-means is defined by its per-centre split test, and now that the crate ships that too, twopubitems could not both carry the name.betula_cluster::clustering::xmeanswas published in 0.6.0, so this is a breaking rename for Rust callers;method="kmeans"withn_clusters=0still routes to the sweep and its labels are unchanged by the rename. -
method="scale-space"sweeps the bandwidth in two passes, and stops answeringk = 1. The head ranks flat runs of the mode-count-versus-log hcurve, and the swept range runs to half the data diameter — far above the scale at which the last two clusters merge. Eleven of fifteen grid points therefore sat in the fully-merged tail, and the longest flat run of a decreasing curve is always its tail: ondigitsthe head returned a single cluster in 42 of 52(PCA dimension × leaf budget)cells. The sweep now stops at the first single-mode bandwidth, leaving that run one point long, then narrows onto the merge cascade and re-sweeps it once — guarded on having seen at least two multi-mode scales, because refining below the merge scale manufactures structure out of a single blob. Same 52 cells: 46 wins, 3 losses, 3 ties, mean ARI 0.3845 against 0.0710, and 3 cells atk = 1instead of 42, for ×2.45 wall clock (one cell runs ×0.24, four times faster, because truncation stops it after two bandwidths).covtyperemains the head's worst dataset — every cell there scores a negative ARI, which the selector cannot fix. Labels change on almost every input. Tables inbench/RESULTS.md. -
method="scale-space"mollifies each leaf with its own scatter, and the head's operating envelope is now published. The head advertises the density modes of the data but was building the kernel density of the leaf centroids, one point per leaf. A leaf is a cloud, and convolving that cloud with the kernel is what the data's density is:N(μ_j, Σ_j) * N(0, h²I) = N(μ_j, Σ_j + h²I). Every leaf now carries its own widths²_j = h² + σ²_jwithσ²_j = S_j/(n_j·d)from the summary, its owns_j^(−d)amplitude — without which a fat leaf peaks as high as a tight one — and the variable-width mean-shift fixed point. At zero leaf scatter this is bit-identical to the previous path, which is a test rather than a claim. Labels can change where leaf radii vary.
Measuring it turned up something larger. Over 52 (PCA dimension × leaf budget) cells on digits
the correction is 7 wins, 7 losses, 38 ties — and 38 of the ties are k = 1 on both sides; on
covtype-20k every cell either returns one cluster or returns modes at negative ARI. The best cell
in the grid (ARI 0.59) sits one budget away from a k = 1 cell. The fragile part is the plateau
selector, which counts grid points on the mode-count-versus-log h curve rather than measuring a
persistence, so it moves whenever the sweep moves. The mollification ships because it is the model
the head documents and costs nothing, not because it measures better. Envelope and tables in
bench/RESULTS.md.
Performance¶
Three ceilings the measurements themselves named, each attacked with the label digest as the gate:
a fit that is 90–99 % insertion, Phase-3 heads that were all single-threaded O(m²), and an
ingest that duplicated the input. Every number below is a median of three alternating (A-B-A-B)
repetitions on one machine (Zen 3, 8C/16T, AVX2, no AVX-512), OMP_NUM_THREADS=1, with the two
arms differing only in the code under test.
- The input is no longer copied when it does not have to be.
fit/fit_predict/partial_fit/predictread a C-contiguous array of the tree's own dtype in place; a copy is made only for a strided view, the other float width, or a head that prepares rows (normalize=True, the directional heads,method="hyperbolic") and so must not write into the caller's array. On200 000 × 784f64— a 1 196 MB input — peak resident memory falls from 2 485 MB to 1 357 MB, i.e. the duplicate is gone rather than reduced; on1 000 000 × 20, from 350 MB to 197 MB. Label digests are unchanged on every shape measured. - Ward and HDBSCAN* use every core. Both heads were exactly
O(m²)on one thread, and at largemax_leavesthat made the head, not the tree, what a fit waits on: at 7 250 leaves ofd = 784, Ward took 67 s against a 6.8 s build. Ward's nearest-neighbour scan, HDBSCAN*'s core distances and Prim's relaxation now split across threads. At 3 631 leaves ofd = 784, median of 3: Ward 9.76 s → 2.74 s (3.6×) and HDBSCAN* 9.09 s → 1.93 s (4.7×) on 8 threads. Every reduction is exact — an index-orderedcollect, disjoint per-index writes, or a min whose tie-break is a total order on(value, index)— so the labels are bit-identical at 1, 8 and 16 threads and identical to the serial release. The argmin inside Prim stays serial deliberately: it isO(m)scalar compares againstO(m · dim)distance evaluations, about 20 ms of a 46 s run. Ward's scan also walks a dense list of surviving clusters instead of a mask over allm; the first shape of the parallel scan forked on every scan whatever its length and returned only 1.21×. aarch64gets the SIMD kernels it never had. The packedsq_euclidean/dot/manhattanreductions existed for AVX2 only, so the macOS-arm64 and linux-aarch64 wheels ran the scalar fold. There is now a NEON path, and it needs no runtime detection — ASIMD is mandatory on AArch64 — so unlike the x86 path it can inline into the caller. Verified underqemu-aarch64against the scalar fold at every width and tail length; the length threshold is inherited from the measured x86 crossover and is not measured on ARM hardware, which is stated where the constant is.- A profile-guided build of the linux x86_64 wheel, measured but not yet on the release path.
bench/pgo_train.pyis the training workload and.github/workflows/pgo-wheel.ymlthe two-pass container build, dispatch-only for now. Insertion at1 000 000 × 20runs 0.948 s → 0.841 s (1.13×) with the profile, with no overlap between the arms across three repetitions; at200 000 × 784it is a wash (2.643 s → 2.747 s, ranges overlapping) — the win is where the time is branches and pointer chasing rather than kernel arithmetic. benches/, with no benchmark framework.cargo bench --bench kernelssweeps the three kernels overd ∈ {8 … 1024}in both widths, and--bench tree_inserttimes the insert path through the Rust API, so a layout change can be measured without amaturin developin between.harness = falseandstd::timethroughout: a dependency in[dev-dependencies]still has to be justified, and the numbers that matter here (rows/s, GB/s at a givend) are not the ones a framework reports. What the first run says is the shape of ceiling 1 — the kernels reach ~70 GB/s atd = 64…128, where a pair fits in cache, and fall to 17–20 GB/s atd = 784.
Added¶
canonical_order=— the CF-tree summarises a multiset instead of a sequence, and the row order stops mattering entirely. This is the defect theleaf_refitentry below could not close, and it is structural to every BIRCH-family tree rather than a bug in this one: the prototype set is a greedy threshold-net built in arrival order, so which regions get a prototype — and how many — is a function of the sequence.leaf_refitmoves each prototype onto the centroid of its cell but never changes which cells exist, which is exactly why it improved quality and left the order gap intact.
With canonical_order=True the rows are sorted by a key derived from the data before the first
insert, and the guarantee is exact: two permutations of the same rows produce label arrays that
are equal element for element. Pairwise ARI between orders goes from 0.22–0.68 to 1.0000. The
key is a Morton code over eight random projections drawn from a fixed constant — deliberately not
from seed, so varying seed re-runs the head over one fixed summary — with ties broken on the
full row, because a key alone is not a total order and colliding rows would otherwise keep the
caller's order and void the whole guarantee. New betula_cluster::order module; the sharded path
is invariant too, because sharding splits ranks of the canonical order rather than rows — see
the CFTree::build_sharded note under Changed.
It buys reproducibility, not accuracy, and the measurements are what say so. In the published
sweep (bench/insertion_order.py, 27 cells, eight permutations each) the change against the
arrival order's median draw is mean +0.0136, median −0.0017, non-negative in 10 of 27, and
the canonical value falls inside the order arm's own [min, max] in 21 of 27 — usually
indistinguishable from a draw you would have got anyway. It fixes which draw you get rather than
moving the distribution; the positive mean is two cells where arrival order was collapsing a gmm
head (digits@360 0.1738 → 0.5146, MNIST@1000 0.0551 → 0.2457), and the worst losses are −0.062
and −0.061. The realised leaf count stops varying as well — it moved in 18 of the 27 cells and
is now constant, so two runs summarise at the same resolution. Nor is it free: on the engine itself, A-B-A-B on one build, the fit runs 0.83-1.33x
across four shapes (200k x 20 1.19x, 200k x 128 0.83x, 100k x 784 1.06x, 20k x 784 1.33x) —
a wash to modestly slower. A Python prototype read 0.66-0.96x and was misleading, because it
materialised the reordered rows and so bought back sequential access at the price of a second copy
of the input. benches/canonical_order.rs splits the cost three ways and shows the mechanism is
two opposing effects, not the "coherent inserts split less" first published here: a coherent stream
re-descends the same subtree (cache-friendly, down to 0.58x) but fills the leaf budget in one
region before the next arrives, so it rebuilds more often -- 3 -> 30 at 50k x 784,
max_leaves=8000, which is what makes that shape 1.56x. The key is 3-20 % of the fit and now runs
through the SIMD dot kernel; unvectorised it was about twice that. Low-discrepancy walks over the sorted
order (van der Corput, round-robin stride) were measured and rejected: 5/16 and 7/16 non-negative
against this scheme's 12/16, for an extra magic constant.
Default off, because it relabels. One scoping rule: it applies to a fit /
fit_predict that sees the whole dataset — a partial_fit stream never does, so it ignores the
flag rather than raising, the same asymmetry as leaf_refit.
Sparse CSR input honours it on the same terms. The key is computed over the CSR rows without
densifying, treating a missing column as an explicit zero, so a scipy.sparse matrix and its dense
equivalent are sorted into the same order and give the same labels. The merge walk that breaks a
key collision needs sorted, unique column indices — scipy's canonical form, but not an invariant of
the format — so a matrix that is not in canonical form now raises ValueError naming
X.sort_indices(). Silently ordering by a key computed from mis-read rows was the alternative, and
it would have looked like a working guarantee. The sparse= leader path (fit_predict_sparse) is
a different algorithm and takes no canonical_order.
leaf_refit=— Lloyd at the micro-cluster level, and an honest account of what it does and does not fix. A leaf CF in every BIRCH-family tree is an absorption history: the rows it swallowed as they arrived, weighted by nothing but arrival order. The tree those rows finished in routes a different partition, and at real compression the two disagree badly enough that row order moves the answer more than the seed does.leaf_refit=krunskpasses of: route every row through the finished tree, rebuild each leaf CF from exactly the rows it wins, drop the entries that win none. Available onBetula, on the freefit_predict, and asCFTree::refit_leavesin Rust. It needs the rows, so apartial_fitstream — which keeps a tree and not the data — ignores it rather than raising.
It is a quality lever at heavy compression, not a fix for order dependence, and the measurements
say so. ARI against the labels, medians of seeds 0/1/2, on raw features with everything else at
its default: digits at max_leaves=90, kmeans 0.583 → 0.607, ward 0.533 → 0.530, gmm
0.594 → 0.610; MNIST at max_leaves=200, kmeans 0.233 → 0.290, ward 0.216 → 0.258, gmm
0.156 → 0.211. (Re-measured 2026-09-05, replacing numbers that had no single stated setup and one
dataset that had been read through the column-major bug above; docs/USAGE.md carries the full
table, including what the pass is worth on top of canonical_order.) But the pairwise ARI between two row orders stays at
0.4–0.55 while two seeds stay near 1.0, with or without the pass — the residual order dependence
lives in where the prototypes end up, not in what each one summarises, and moving a prototype needs
batched routing against a frozen snapshot rather than a better CF for its current position.
Default 0, off, because it relabels: this is a Type-1 door. Two properties are worth knowing
before turning it on. Passes are not monotone past 2–3 — each pass re-routes against the tree the
previous pass produced, and the worst-leaf gap on the 4000 × 8 fixture runs 2.25 → 1.55 → 0.89 →
0.44 → 1.63 over k = 0..4. And a leaf budget above n is not a free no-op: the partition is
the one the tree's greedy descent produces, descent is not the inverse of absorption, and 600 rows
at one row per leaf come back as 436 leaves carrying the same total weight.
KPrototypesgrows a third block:directional=. Huang's k-prototypes carries a numeric(n, μ, S)and a category-count histogram per attribute. Some columns are neither — a cyclic hour written as(cos, sin), a heading, a normalised embedding — and this release gives them their own block, summarised by the resultantR = Σ w_i u_iover rows the boundary L2-normalises once each. The dissimilarity becomes‖Δnum‖² + γ_cat · Σ[x_j ≠ mode_j] + γ_dir · (2 − 2 uᵀc), withc = R/‖R‖. Unit vectors have no mean — two rows a half-turn apart average to zero rather than to a point between them — so the resultant, not a centroid, is the summary; and because‖u‖ = 1, the block's cost over a whole micro-cluster is2n_i − 2⟨R_i, c⟩, affine in the summary with no scatter term, the same structure the Lorentzian centroid has inclustering::hyperbolicand what lets a leaf carry the block in one vector. New accessorcluster_directions_(k × n_directionalunit rows). Rust:clustering::{MixedSchema, BlockWeights, MixedRows}joinMixedCf;summarize_mixed,nearest_microandkprototypesnow take the schema and aBlockWeightspair instead of a bareγ. Validation moved with it: a numeric column is still always required, but the second block may now be categorical or directional; a column may not be claimed by two blocks; and a one-column directional block is rejected outright, because a direction in one dimension is a sign and a sign is a category. A row whose directional part is all zeros keeps the zero vector — its term is then equal for every prototype, so it stops voting instead of voting arbitrarily, and the same holds for a cluster whose members' directions cancel.- What the third block buys, measured: the per-row normalisation, and nothing else. The honest
baseline is not "no direction" — it is
(cos θ, sin θ)written into two numeric columns, and on unit rows‖u − c‖² = 2 − 2uᵀcexactly, with column scaling bysplaying the role ofγ_dir = s². Given that same knob on the same grid, the two tie to three digits (0.999 vs 0.999) on unit rows. On rows carrying an amplitude the block reads 0.964 against 0.693 for the naive numeric encoding — and 0.964 again for the user who normalises those two columns by hand. Binning the angle into 8 categories reads 0.509. Sodirectional=is that informed user's preprocessing moved to the boundary, plus a named weight and a unit prototype you can read as an angle; it is not a better model, and on unit rows it provably cannot be. The second argument for the block — that a numeric prototype's+‖μ‖²makes diffuse clusters cheaper and lets them steal members — is real and did not pay: on two adjacent groups at unequal concentration the numeric encoding is 0.011 ahead at bothκ = 8andκ = 2, because for two von Mises–Fisher components of unequal concentration the Bayes boundary is not the angular midpoint and+‖μ‖²moves towards it. Tables and the fixture inbench/RESULTS.md. - What actually breaks the naive numeric encoding is the amplitude's tail, not its spread.
examples/07_mixed_data_kprototypesnow carries the third block, on a login-time vector whose angle is the hour of day and whose length is a lognormal session count independent of the segment. Sweeping that length'sσover 0.6 / 0.9 / 1.2 (ARI against the true segments, median of seeds 7/8/9): the raw-numeric encoding reads 0.997 / 0.995 / 0.007 whiledirectional=[4, 5]holds 0.995 flat, because normalisation removesσbefore the distance sees it. The collapse is not a spread threshold — atσ = 0.9the login radius already varies more thanmonthly_spenddoes (48 against 29) and costs nothing. It is the tail: atσ = 1.2squared error is dominated by the longest vectors and the fit spends two of its three clusters on 115 outliers, leaving 1085 of 1200 rows in the third. Normalising the rows by hand reproduces the drop-the-columns fit to three decimals (0.953 at everyσ), because unit columns then weigh 1 against numeric columns whose spread is tens of units — which is the scalegamma_dirnames and defaults. gamma_dirhas no literature, and the default says so.γ_catkeeps Huang's½·mean σ.γ_dirdefaults to the mean numeric variance, so that one unit of‖u − c‖²— which runs over[0, 4]whatever the data — costs one numeric variance. That is a scale-matching convention, not a result; both weights are exposed and both should be swept.-
The k-prototypes++ seeding weight had one mutant nothing could see, and now it has a test. Re-measuring
src/clustering/kprototypes.rswhole after the rewrite (162 mutants, 143 caught, 15 unviable, 0 timeout) turned upw[i] * d2[i] → w[i] + d2[i]as a new survivor. The product is what guarantees that a micro sitting exactly on an already-chosen prototype is never chosen again, whatever its mass; the sum drops that guarantee, and the existing spread-out fixture could not tell them apart because there every weight is 1 against aD²of 10⁴. The new fixture puts the mass on the coincident site instead and fails on its first seed under the mutation. The other three entries inmutants-baseline.txtcarry over at their new line numbers with their arguments intact. -
method="hyperbolic"— k-means on the Lorentz model of hyperbolic space, for embeddings of hierarchies (Law, Liao, Snavely & Dhillon, ICML 2019). Rows are(d+1)Lorentz coordinates ofH^d = {⟨x,x⟩_L = −1, x₀ > 0}; the boundary recomputesx₀ = √(1 + ‖s‖²)per row before insertion, as the directional heads L2-normalize theirs. The head clusters under the squared Lorentzian distanced_L²(x,y) = −2 − 2⟨x,y⟩_Lrather than the geodesicarccosh(−⟨x,y⟩_L), because that is the one with a closed-form Fréchet mean — the normalised sumμ = R/|R|_L,R = Σ n_i x_i— and a sum is what a cluster feature carries.d_L²is affine in each argument, so a leaf enters the objective only through(n_i, R_i)and there is no scatter term at all:feature="spherical"reaches the identical partition and identical cost as"full"(measured, ARI 0.6911 to four digits forspherical/diagonal/full), which is true of no other head in this crate. Assignment isargmax_c ⟨R_i, c⟩_L, the cost isΣ_c 2(|R_c|_L − W_c), and the merge increaseΔS = 2(|R_a+R_b|_L − |R_a|_L − |R_b|_L) ≥ 0is the hyperbolic analogue of Ward's. All of it verified in Maxima before any of it was written (local/scratch/lorentz_identities.mac): the lift lands on the sheet, Lagrange stationarity holds componentwise,S = 2(|R|_L − W)has residual exactly zero, andS ≥ 0,ΔS ≥ 0survive 400 random weighted sums. Rust:clustering::{hyperbolic_kmeans, HyperbolicKMeans, merge_increase, project_to_sheet, f64_working_radius}.n_clusters=0is not automatic — the cost falls monotonically ink, as total deviation does forkmedoids. Nopredict_proba(no density, and the Euclidean softmax fallback would disagree with the head's own labels), noprojection=(a code vector has no time-like coordinate), no sparse input (the sheet projection fills column 0 of every row), norefine(a Lloyd sweep is the Euclidean update). -
What the hyperbolic head buys is invariance, not ARI — and the measurement says so. Fixture: a 4-ary depth-4 tree laid out in geodesic polar coordinates, 15 360 points, 16 true groups,
threshold=0,max_leaves=2000, median of seeds 0/1/2. On the Lorentz coordinates it reads 0.731 againstkmeans0.407,gmm0.223,gmm-full0.663,ward0.389. But converting the same points to the Poincaré ball and runninggmm-fullreads 0.817 — the ball is a bounded chart that keeps angle and compresses radius bytanh(r/2), which is what a Euclidean head wants, and on a centred embedding it wins outright. What separates them is a Lorentz boost, an isometry ofH^d: the same points, a different origin. At rapidity 3 the ball route falls 0.817 → 0.311 and the tangent-space route 0.767 → 0.375, while this head holds 0.731 → 0.596. The residual 0.14 is the tree's, not the head's — routing and absorption are Euclidean — and raising the budget to one leaf per point makes the head exactly boost-invariant, ARI 0.772 at every rapidity tried.tests/equivariance.rsasserts the exact invariance over a fixed leaf set, with an ambient shift as the control. Thef64working radius is½ln(2/ε) ≈ 18.4and is published with the measurement behind it (local/scratch/lorentz_precision.py): relative error ofd_L²is1e−8atr = 10,2e−3at 15, and the sign flips at 18. Tables indocs/USAGE.md. -
method="mfa"— a mixture of factor analysers,mppcawith the isotropic residual relaxed to a per-dimension one (Ghahramani & Hinton 1996).Σ_c = W_c W_cᵀ + diag(ψ_c)atrank=q:d − 1more parameters per component thanmppca, againstd²/2forgmm-full. The M-step is Ghahramani & Hinton's, rewritten so the weighted scatterS_centers only through thed×qproductS_c U_cand its diagonal — both of which a leaf answers directly, the second through the newSecondMoment::add_diagonal_scaled, so nod×dmatrix is ever formed. Their publishedS βᵀ (I − βW + βSβᵀ)⁻¹collapses toW_new = (S U)(M + G)⁻¹ MwithG = Uᵀ S UandM + G ⪰ I, which factors by Cholesky where the assembled form needs a general inverse. Nine identities, with theψ_jleft as free symbols, were verified in Maxima at two shapes before any of it was written (local/scratch/mfa_identities.mac,mfa_mstep_collapse.mac; residual exactly zero).rank=0is a diagonal Gaussian mixture bit for bit. Likegmmand unlikemppcathe head is not rotation-equivariant —diag(ψ)is a statement about the coordinate axes — whichtests/equivariance.rsnow asserts as a property rather than omitting. -
The
mfa/mppcaenvelope, measured in both directions. The two heads dissociate on fixtures built to separate them:mfareads a separation on a quiet axis that two loud nuisance axes drown (ARI 1.00 againstmppca's 0.04–0.34), andmppcareads three lines that differ only in orientation (1.00 againstmfa's ≈ 0.00, becauseψabsorbs an elongation that belonged inW). On real tables, median of seeds 0/1/2 atrank=2,mfais behindmppcaat full leaf resolution everywhere it was tried —digits0.562 vs 0.738, MNIST-20k 0.277 vs 0.365,covtype-20k in raw units 0.062 vs 0.087 — at indistinguishable cost (30.9 s vs 37.6 s on MNIST). What it buys is a floor rather than a ceiling: on standardisedcovtypeit converges onto the diagonal head it contains (0.077 / 0.089,gmm's partition exactly) wheremppcascores less than half that (0.030 / 0.045). Published indocs/USAGE.md, including why the identicalcovtypelabels are not evidence of a collapsedW: at 1899 leaves in 54 dimensions every one of the 20 000 posteriors is saturated to1within1e-12formfa,gmmandgmm-fullalike, so agreement there restates the partition, not the covariance. -
method="watson"— a mixture of Watson distributions, for directional data whose sign is arbitrary (Watson 1965; Mardia & Jupp 2000 §9.4.1).p(x | μ, κ) ∝ exp(κ (μᵀx)²)is invariant underx ↦ −x, so eigenvectors, SVD/PCA axes, line orientations and any feature a pipeline signs arbitrarily are clustered as axes, not directions.movmfcannot: handed two equally populated poles of one axis it either spends two components on them or watches its resultantΣ n_i μ_icancel to nothing. The sufficient statistic is the second moment about the origin, which a cluster feature carries exactly —E[x xᵀ] = Σ_i + μ_i μ_iᵀ— so the E-step termE_{x ∈ leaf}[(μ_cᵀx)²] = μ_cᵀ (Σ_i + μ_i μ_iᵀ) μ_cis closed form and the within-leaf spread is integrated rather than dropped, the same status the Gaussian heads' E-step has.κ < 0fits girdle (equatorial) components alongside bipolar ones, chosen by likelihood rather than by a flag. Input is auto-L2-normalized as forvmf/spherical-kmeans;n_clusters=0selectskby BIC. Rust:clustering::{watson, watson_auto, Watson}. Dense path only — a sparse leaf summary is spherical and carries no off-diagonal scatter, sofit_predict_sparsedoes not accept it. - What the axial head buys, and what it costs.
N = 6 000,max_leaves=300,feature="full", median of seeds 0/1/2, ARI — on an axial fixture with half of each cluster's points at each pole of its axis: 8-D 0.870, 32-D 0.953, 64-D 0.961, againstvmf0.208 / 0.218 / 0.221,gmm-full0.309 / 0.316 / 0.200 andkmeans0.233 / 0.244 / 0.232. Its BIC answersk = 4wherevmfanswers 8 — one component per pole, the failure the head exists to fix. Ondigits-PCA20 with a random half of the rows multiplied by −1 and the labels untouched it reads 0.498 → 0.503, whilevmffalls 0.631 → 0.173,gmm-full0.685 → 0.284 andkmeans0.665 → 0.221. The trade is explicit and ships in the docs: where the sign does carry information the head is a loss (one pole per axis, 32-D: 0.952 againstvmf's 0.976), on un-flippeddigitsit is the worst of the five, a mixture of girdles is not recovered (0.100 on a three-great-circle fixture, where the circles intersect and no axial model can separate them), and it is the most expensive directional head at 5–8×vmf.feature="full", but the margin is narrower than the derivation suggests — measured acrossmax_leaves4…300,spherical/diagonal/fulltie at ARI ≈ 0.95 once every leaf mean has unit norm (~20 leaves up), andfullleads by 0.10–0.15 only below that, where a leaf straddles both poles and its mean cancels;fdis the one to avoid at a coarse budget. Tables indocs/USAGE.md. -
The two special-function identities the head rests on were verified in Maxima before it was written.
M'(a,b,z) = (a/b) M(a+1,b+1,z)gives the concentration equationg(κ) = (1/d) M(3/2, d/2+1, κ) / M(1/2, d/2, κ) = r̄, and Kummer's transformationM(a,b,z) = e^z M(b−a,b,−z)is what makesκ < 0computable at all — the ascending series alternates for a negative argument and loses every digit to cancellation, while for a positive one every term is positive and the sum is exact to roundoff. Both residuals are exactly0as series identities inzfor symbolica,b. The leading asymptoticM(a,b,κ) ≈ Γ(b)/Γ(a) e^κ κ^(a−b)is not used: against the Maxima table it is 42 % low atd = 50, κ = 50and still 1.2 % low atκ = 1000, because the expansion parameter at fixed(a,b)is(b−a)²/z ≈ d²/4z, which is not small at moderated. The series therefore runs everywhere andκis capped at10⁴instead, where its length is bounded.gis strictly increasing withg(0) = 1/d,g(−∞) = 0,g(+∞) = 1, sor̄ ≷ 1/dfixes the sign ofκbefore any solving and the solver cannot pick the wrong branch. Unit tests checklog Mandgagainst 24 Maxima goldens spanningd ∈ [2, 200]and both signs ofκ. -
method="dc-median"/method="dc-center"— exactk-median andk-center in the density-connectivity ultrametric (Beer, Draganov, Hohma, Jahn, Frey & Assent, KDD 2023). The same mutual-reachability spanning treemethod="hdbscan"takes its hierarchy from, cut for akthe caller names instead of amin_cluster_size.dc(a, b)is the heaviest edge on the path betweenaandb, so it is an ultrametric and both objectives are solvable, not approximable:k-center by deleting thek − 1heaviest edges, andk-median by anO(m·k)knapsack over the dendrogram, which exists because a leaf's service cost depends only on which subtree holds its nearest centre and never on which centre it is. Draganov et al. (NeurIPS 2025) run the same recursion for everykat once; this ships the single-kform. Both are asserted against brute force over everyC(m, k)centre set belowm = 12, scored on an independently computed dc-dist matrix. Neither head emits-1— they partition, andhdbscanremains the head that answers noise over the same tree. Neither takesn_clusters=0: both costs fall monotonically inkto 0 atk = m, the same reasonkmedoidshas no automatic mode. Rust:clustering::{dc_clustering, DcObjective}. -
dc-medianis a win on the density fixtures;dc-centeris mass-blind and ships saying so. ARI / seconds atN = 6 000,max_leaves=600,min_samples=5, median of seeds 0/1/2,kgiven: moons-0.06 1.000 / 0.021 (spectral1.000 / 0.203,ward0.434,kmeans0.255); moons-0.10 0.889 / 0.014 (spectral0.691 / 0.383,ward0.420,hdbscan0.015); circles 1.000 / 0.015;digits-PCA20 0.725 / 0.020 (ward0.721,spectral0.703,kmeans0.669,hdbscan0.458); blobs + 5 % uniform noise 0.725 againstkmeans's 0.878 — an ultrametric has no notion of a point lying between clusters.dc-centerscores 0.000 on moons-0.10, on the noise fixture and ondigits: a maximum cannot see a weight, and on a summary an outlier is a low-mass leaf, so atk = 6its clusters hold[6297, 2, 1, 0]rows againstdc-median's[1670, 1240, 1039, 1022]. This is the mass-based answer the CURE probe went looking for and could not find in shrinkage — a different objective, not a repair of the geometric one. The same property read from the other side makesdc-centerinsensitive to over-specifyingk(1.000 / 1.000 / 0.999 / 0.999 / 0.997 atk = 2/3/4/6/10on moons-0.06, againstdc-median's 1.000 / 0.754 / 0.524 / 0.465 / 0.285), because its extra clusters are singletons. Cost is the spanning tree's and within 2 % ofhdbscan's own: 0.014 / 0.047 / 0.350 / 1.528 s atmax_leaves300 / 1000 / 3000 / 6000. -
reachability()— an OPTICS reachability plot over the leaves, as the density diagnostic (Ankerst et al. 1999). Returns aReachabilityPlotwithorder/reachability/core_distances/weightsand alabels_at(eps)that reads the DBSCAN* cut off the plot. It is themethod="hdbscan"hierarchy, not a second opinion about it: OPTICS with no ε cutoff is Prim's algorithm on the mutual-reachability graph, so the sweep walks the same spanning tree that head cuts, and the crate builds it once —clustering::hdbscan::mutual_reachabilityis now shared by both. Two theorems carry it, asserted rather than measured:reachability[1..]is a permutation of that tree's edge weights, and cutting the plot at ε gives exactly its components under ε. That is also why the reachability is the mutualmax(core(p), core(q), d(p,q))rather than Ankerst's asymmetricmax(core(q), d(q,p))— the asymmetric form pictures a different tree. One sweep position per leaf, so a valley's width is a leaf count;weightsis published next to it for exactly that reason. Rust:clustering::{optics, Reachability}. -
The diagnostic's cost is the leaf count, and it loses the fixture that is about noise. Median of seeds 0/1/2. Cost does not see
N— 0.0024 / 0.0027 / 0.0027 / 0.0028 s at 5 k / 20 k / 80 k / 320 k rows against a 0.005 / 0.007 / 0.017 / 0.054 s fit — but it does see the leaf budget quadratically: 0.0003 / 0.0028 / 0.0284 / 0.2970 s at 99 / 290 / 926 / 2883 leaves.graph_degreebounds that and is not free: at 2883 leavesgraph_degree=16runs 8× faster but its cut agrees with the exact plot's at only ARI 0.518, and 32 / 64 / 128 all sit at 0.923 — an approximate graph is a plot of a slightly different tree, not a cheaper plot of the same one. Read as a partition againstsklearn.cluster.OPTICSon the raw points (N = 6 000,max_leaves=600, best ε on each side's own grid): blobs 0.452 vs 0.448, moons 0.997 vs 0.978, circles 0.997 vs 0.978, blobs + 5 % uniform noise 0.687 vs 0.753, at 0.024–0.041 s against 4.9–6.3 s. The compression is not what costs the noise fixture — raising the budget to one leaf per point takes it only to 0.707, so the residual is the convention: DBSCAN* has no border points, and the mutual reachability is a stricter link. What the budget does fix is the mass-weighted core distance — at 274 leaves 41 % of the true noise points sit in a leaf with core distance 0, which can never be labelled noise; at 3899 leaves that is 2 %, and the ARI moves 0.518 → 0.686. -
method="fuzzy-cmeans"— weighted fuzzy c-means over the leaf centroids (Bezdek 1981). The only soft head in the crate that fits no density:predict_probareturns the membershipsu_ij ∝ d_ij^(−1/(m−1)), a partition of unity over the centres rather than a posterior. Exact on the summary by the same identitykmedoidsuses — tying the membership within a leaf turnsΣ_{x ∈ leaf i} u_ij^m‖x − c_j‖²intou_ij^m · n_i · d_ijwithd_ij = ‖μ_i − c_j‖² + S_i/n_i, so the leaf-levelJ_mis the point-level one. The newfuzzifierkeyword is the exponentm > 1(default2.0); it is validated rather than clamped, because atm ≤ 1the exponent1/(m−1)inverts the rule into "farthest centre wins".n_clusters=0selects by Xie–Beni, this family's own validity index. - The fuzzy head's hard partition is a loss, and it ships saying so. ARI against
kmeansatmax_leaves=4000,feature="spherical", median of seeds 0/1/2:blobs0.847 vs 0.864,aniso0.535 vs 0.545,varied0.531 vs 0.540,highdim1.000 vs 1.000,digits0.483 vs 0.467 (all atm = 1.3;m = 2.0is worse everywhere exceptvaried). The intuition that it should win where clusters overlap is backwards: on 6 000 × 8 blobs atcluster_std3/4/5 it reads 0.990 / 0.913 / 0.623 againstkmeans's 0.990 / 0.922 / 0.804, and the confidence AUC — how wellassignment_confidenceseparates right from wrong labels — falls with it (0.975 / 0.916 / 0.836 vs 0.985 / 0.931 / 0.881), so the membership is not a better boundary detector either. Mechanism: no membership is ever zero, so every point pulls every centre toward the grand mean. Measured on thecluster_std=5.0fixture, the mean centre-to-centroid distance runs 13.75 forkmeansand 13.19 / 12.98 / 11.88 atm = 1.3 / 2.0 / 3.0against a true 13.67. Take the head for the membership, not for the labels. Rust:clustering::{fuzzy_cmeans, fuzzy_cmeans_auto}. Tables indocs/USAGE.md. method="kmedoids"— weighted k-medoids over the leaf centroids, by eager FasterPAM. The centre of a cluster is one of the summary's own micro-clusters rather than an average: an exemplar you can show, and a centre that stays on the data manifold where a mean need not. Schubert & Rousseeuw 2021; theΔTD(i, c) = shared(c) + corr_i(c)decomposition prices one candidate against every medoid slot in a singleO(m)pass. Exact on the summary under the whole-leaf restriction every shipped head accepts —Σ_{x ∈ leaf i} ‖x − μ_j‖² = S_i + n_i‖μ_i − μ_j‖², so the leaf-level total the swap search minimises is the point-level sum of squares up to the constantΣ_i S_i. Note the square: classical PAM minimises absolute distance, which has no closed form in a cluster feature, so the squared objective is the one a summary can answer exactly.
On digits (feature="spherical", threshold=0.0, ARI median [min–max] over seeds 0–4) it reads
0.554 [0.554–0.570] at one leaf per point against kmeans's 0.467 [0.443–0.571] — better on the
median and on the spread, because the centre cannot drift into the gap between two digit classes —
and 0.520 [0.468–0.520] against 0.487 [0.381–0.523] at 296 leaves. It loses at a coarse
summary, 0.219 against 0.240 at 115 leaves, where there are too few candidate centres to place ten
of them well. The O(m²) swap pass costs 6× kmeans at 1797 leaves and nothing measurable at 296.
On the synthetic fixtures at max_leaves=4000 the two are level (blobs 0.864/0.864, aniso
0.545/0.548, varied 0.540/0.548, highdim 1.000/1.000, median of seeds 0/1/2).
Two consequences the head states rather than hides. n_clusters=0 is not free auto-k: total
deviation falls monotonically in k, so this objective cannot choose one, and the automatic arm
runs the medoid silhouette (dyn_msc, Lenssen & Schubert 2024) — a different objective. And
refine is a no-op, because a Lloyd sweep is the k-means update and would move each medoid to the
mean of its cluster, off the data. fit_predict_sparse rejects the head: on its flat leader summary
a row's distance to a single micro-cluster is dominated by that micro-cluster's norm rather than by
the overlap that knows the topic, measured at ARI 0.017 (own rule) and 0.002 (micro-cluster route)
against 1.000 for kmeans — and 1.000 for kmedoids itself through Betula.fit, which builds a
real CF-tree from the same CSR. Rust: clustering::kmedoids → Pam. Tables in
docs/USAGE.md.
-
DenStream.drift_andDbStream.drift_— an ADWIN change detector on the streaming heads.decayis a schedule: it forgets at a fixed rate whether or not anything changed, and a wrong λ is silent in both directions. The detector (Bifet & Gavaldà, SDM 2007) answers the other question — did the stream change — with a stated false-positive ceiling δ = 0.002 rather than a tuned threshold, and reports{alarms, last_alarm, distance, window}. It reports; it does not act: an alarm prunes nothing, promotes nothing and relabels nothing, so no existing labels move. The statistic is the distance from each incoming point to the nearest micro-cluster in units of the micro-cluster radius, which is free (both online steps already compute it) and scale-free (a raw distance stops detecting anything on data scaled down far enough, becauseε_cut's additive term is in absolute units). Measured over jump / spread / split / gradual changes on both heads, three seeds: reported in 10 of 10 cells, three quarters of them inside the first 32-point check clock, at a false-positive rate of 0.00050 on stationary streams. The obvious alternative — a novelty indicator, 1 if the point opened a micro-cluster — was implemented first and measured to raise zero alarms in five of six(λ, ε)settings on a 50σ jump: the impulse lasts ~5–10 points and a bounded statistic cannot show a gap that short. Newbench/drift.py, tables inbench/RESULTS.md. -
The automatic-
kceiling is no longer one number for every head, andauto_k_maxoverrides it.n_clusters=0was bounded at 20 for all of them, so non_clusters=0path in the library could return more than 20 clusters. The bound exists because a sweep refits the whole head at every candidatek—Σ_{k≤K} k = O(K²)— butward,average,weighted,centroidandmedianbuild one dendrogram and score its cuts, andxmeansstops on its own split test, so they were paying a cost guard for a cost they do not have. Those six are now bounded only by the leaf count. Measured on 480 leaves in 64 dimensions holding 120 true groups,wardgoes from 5.7 ms atk= 2 and ARI 0.009 to 23.8 ms atk= 120 and ARI 1.000.method="ward"/"average"/"weighted"/"centroid"/"median"labels change atn_clusters=0whenever the data has more than 20 groups.
The sweeps keep the ceiling, because lifting it there costs 8–32× — kmeans 46 ms → 1.4 s,
gmm 0.5 s → 4.1 s on the same fixture — and take auto_k_max (0 = the default) as an
explicit opt-in. A selection that lands exactly on its ceiling now raises a UserWarning: an
argmax on the last candidate is evidence the search stopped early, not evidence about the data.
On k-means-shaped data method="xmeans" reaches the same partition as the fully-swept kmeans
120× faster (12.1 ms against 1449 ms) and is the thing to reach for before auto_k_max. Table in
docs/USAGE.md.
method="xmeans"— x-means proper (Pelleg & Moore, ICML 2000), the first auto-khead that can return more than 20 clusters. It alternates k-means with a per-centre split test — each centre is asked separately whether the leaves it owns are better described by two centres than by one, scored by BIC on that subset alone — and stops when no centre wants to split.n_clustersis an upper bound, not a target;0bounds it only by the leaf count, because the split test rather than a cost guard is what stops the recursion. The sweep behindmethod="kmeans"cannot do this: it costsO(k_max²)full k-means over every leaf, which is whyAUTO_K_MAX = 20exists and why no other auto-khead in the library can exceed 20.
The split test has a threshold and it is a function of d. A balanced binary split costs
n·ln 2 of mixture-weight likelihood and buys ½·n·d·ln(S₁/S₂), so it is accepted only when the
cut captures more than 1 − 2^(−2/d) of the region's sum of squares — half of it at d = 2,
0.13 at d = 10, 0.022 at d = 64. A cut through a cloud that is round at every scale captures
about 0.64/d, always less than the 1.39/d required. The recursion therefore starts at
k = 2: a greedy splitter has no way back from a refused split, and at k = 1 the whole answer
rides on the one comparison that rule answers worst, since a layout of many well-separated groups
is itself close to round. From k = 1 the head collapses to a single cluster in five of twenty
(k*, seed) cells, in every seed at k* = 60, and on a 3×3 grid of nine equal 2-D blobs; from
k = 2 every one of those returns the true count. Pelleg & Moore, ELKI and pyclustering all start
at 2. Measured on random blob layouts (4 leaves per blob, median of seeds 0/1/2), |Δk| against
the true count with the sweep at its cap of 20: d = 10, k* = 30 → sweep 10.0 (capped) in 4.8 ms,
x-means 0.0 in 0.8 ms; d = 64, k* = 30 → sweep 10.0 in 9.0 ms, x-means 0.0 in 1.4 ms.
What survives is d = 2 at a large count, where one cut must capture half the scatter:
d = 2, k* = 30 → sweep 10.0 (capped), x-means 6.7. Table in
docs/USAGE.md.
-
Betula.near_duplicate_pairs(..., neighbors=k): a cross-leaf recall pass. The within-leaf scan cannot see a duplicate pair whose two rows were absorbed into different leaves — at 20 000 rows with 400 planted pairs it misses 135 of them outright — andneighborsscores each leaf against itsknearest leaves as well, recovering about 40% of the split pairs atk = 8.0(the default) is the previous behaviour unchanged. Table in bench/RESULTS.md. -
Betulaacceptsmax_leavesas a fraction of the row count: a float in(0, 1)resolves toceil(frac · N)atfittime, alongside the absolute integer cap. (Estimator only — resolving the fraction lives in the Python wrapper, while the freefit_predict/fit_predict_sparseare the engine functions re-exported verbatim and take the integer.) One parameter carries both forms, discriminated by range rather than by a second flag, which is ELKI's-cftree.maxleavesconvention (its own default is0.05) and makes a configuration independent of dataset size. A fraction is undefined forpartial_fit, which never sees a finalN, and raises there rather than resolving against a batch;memory_budget_mbstill overrides either form. Reported byeffective_max_leaves_as before. -
Mapper linkage option
link="bhattacharyya": microclusters inside a cover bin are linked on the Bhattacharyya distance between their Gaussian surrogates — the centroid gap divided by the pair's own spread — instead of centroid distance alone. It attacks chaining, where a thin bridge of sparse microclusters merges two dense regions: on a dumbbell fixture at three seeds it crosses the bridge in none of 63 runs while the centroid rule merges the two lobes into one atlink_scale = 3. The default is unchanged. The bounded Hellinger distance was measured on the same fixture and refuted — it saturates at the leaf spacing a CF-tree produces, so anylink_scalepast ≈ 1.1 links the whole bin — and is not exposed. Table in bench/RESULTS.md. -
mixture_w2andBetula.summary_w2, the mixture-Wasserstein distanceMW₂between two fitted Gaussian mixtures (Delon & Desolneux, SIAM J. Imaging Sci. 13(2), 2020). The pair cost is the closed-form BuresW₂; the transport over the component grid is solved exactly by a transportation simplex, so a mixture scores exactly zero against itself rather than nearly zero.summary_w2applies it to two models' leaf summaries and is a drift metric that needs no labels, no shared points and not even a common leaf count — on a four-blob drift sweep the label ARI stays at 1.0000 in twelve of thirteen scenarios whileMW₂tracks every one of them, matching√(w·δ² + null²)to within 2.5%.mixture_w2takes parameters rather than an estimator, so an sklearn or ELKI fit can be compared without conversion. Table in bench/RESULTS.md. - Diffusion-maps α-normalization on the spectral head — measured and refuted, default unchanged.
The head's Laplacian sits over mass-weighted leaves, so sampling density enters it twice, and
Coifman & Lafon's
A_ij / (q_i^α q_j^α)is the standard correction. Over four fixtures and a density-imbalance sweep up to 1:25, medians of three seeds, α never wins a cell: wherever the columns differ at all,α = 0 ≥ α = 0.5 ≥ α = 1, and on three sheared ribbons ARI falls 0.8892 → 0.7516 → 0.6903. The reason is that the correction is already applied — the affinity is self-tuning (Zelnik-Manor & Perona local scaling), itsσ_iis a local density estimate, and α is derived for a fixed-bandwidth kernel, so stacking them divides the same bias out twice and over-corrects.DIFFUSION_ALPHAstays0.0and now records that as a measurement.
The affinity's neighbour count was measured on the same fixtures and turns out to be load-bearing:
k = 24 collapses two of four, and k = 7 beats the shipped k = 10 on anisotropic shapes
(0.9300 against 0.8892) while tying elsewhere. knn_degree now carries the Γ-convergence floor
that makes a choice defensible — García Trillos & Slepčev require k_n / log n → ∞, and at the
head's SPECTRAL_MAX_NODES = 256 cap the shipped 10 is 1.8 log n while k = 5 (the best cell
measured) is below the threshold. The default is unchanged at 10 because knn_affinity is shared
with the Leiden head and four synthetic fixtures are not the evidence base for a constant that
relabels two heads at once. Tables in
bench/RESULTS.md.
- gap_statistic: a k-selector that is allowed to answer k = 1. Calinski–Harabasz is
undefined at k = 1 and the distance-based indices hallucinate structure in noise — Schubert,
Stop using the elbow criterion for k-means (SIGKDD Explorations 25(1), 2023), Table 1 records
Silhouette and Davies–Bouldin reporting 3–22 clusters in pure noise where BIC reports one, and the
gap statistic doing so only partly. This computes the gap on the leaf summary: pooled
within-cluster dispersion from S_i + n_i‖μ_i − c‖² (exact, O(ℓ k d), no second pass over the
points) against a uniform null over the data's bounding box that is re-summarized at the same leaf
budget, so both sides pay the same quantization error and the gap measures structure rather than
compression. The reference draws are shared across k, which pairs the comparison. Measured on
that table's two nulls (1000×5, seeds 0/1/2): a single Gaussian answers k = 1 on every seed,
uniform noise on two of three — and the third is a near-tie, gap(1) − gap(2) = −0.007 against
−2.83 for a real two-cluster fixture, i.e. undecided rather than confidently wrong. Two- and
four-blob controls come back at 2 and 4 on every seed. docs/USAGE.md now carries the
which-selector-when table (BIC parametric and authoritative, CH cheap but k > 1 only, gap for the
k = 1 capability without a distributional assumption).
- tree_report() and estimate_threshold: why the tree collapsed, and whether it mattered.
Betula.tree_report() answers "why is my tree collapsing?" from the leaf summary alone — budget
fill, the heaviest leaf's share of the mass, its width against a typical leaf, and leaf-mass
quantiles — and turns them into plain-language findings. The width is the part that is not obvious:
mass concentration alone does not distinguish a real loss from a faithful summary, because
bench/size_imbalance.py's structured and flat fixtures both put 80% of the mass in one
leaf at a 93–96% full budget. The heaviest leaf's radius over the median leaf radius separates them
3×: 0.53 / 0.75 for structured (two clusters merged inside that leaf) against 0.17 / 0.27 for
flat (nothing inside to lose), at max_leaves 250 and 4000, medians of seeds 0/1/2. On MNIST the
mass trigger fires at exactly the two leaf budgets whose ARI is materially below the best cell
(0.185 and 0.235 against 0.284) and stays quiet from ×22 compression down.
Passing X adds an A-BIRCH gap-statistic threshold estimate
(betula_cluster.estimate_threshold, Lorbeer et al. 2018) next to the threshold in use, so a tree
that settled above twice the sampled estimate is named as absorbing points from more than one
cluster. The estimate is advisory — max_leaves is still the knob that binds — and it reports
each of the paper's assumptions the data breaks (comparable cluster sizes, well-separated clusters)
rather than assuming them. It departs from Tibshirani, Walther & Hastie's original selection rule
for a measured reason: on four blobs 40σ apart the gap curve is non-monotone (gap(2) = −0.186,
gap(3) = −0.274, gap(4) = +3.379), so the "first k that beats k+1 within one standard error"
rule stops at 2. Restricting that rule to the k within one standard error of the best gap
returns 4, still returns 1 on a single Gaussian blob, and declines to certify uniform noise.
- outlier_scores(X, metric="mahalanobis"): whiten by the cluster's covariance, not its trace.
The default metric="radius" divides the centroid deviation by one scalar RMS radius, which is
the trace of the cluster's pooled covariance — so an elongated cluster's short axis is judged by the
length of its long one. The new metric pools the leaves by the parallel-axis theorem
(Σ = Σ_l w_l (Σ_l + δ_l δ_lᵀ) / W, ridged at the GMM head's VAR_FLOOR_REL) and whitens against
a Cholesky factor of that. The two are calibrated rather than merely both being z-scores: on an
isotropic cluster they return the same number (checked on the 2⁵ hypercube corners and in 1-D, to
floating point), so the refinement moves a score only where the cluster has a shape. On the DAMI
fixtures, medians of seeds 0/1/2: sheared clusters 0.596 → 0.748 ROC-AUC (average precision
0.059 → 0.181), blobs+uniform 0.991 → 0.995, unequal-density unchanged at 1.000 — nothing
regresses. The off-diagonal terms are the whole gain: a per-dimension variance, which is what
"reuse the second moments the CF carries" first suggests, reaches only 0.642, because a 37°-rotated
ribbon's coordinate variances differ by under 2×. Costs O(k d³) once plus O(d²) per row against
the scalar path's O(d), so the default is unchanged. find_outliers passes metric through.
- outlier_scores measured on the DAMI 2025 axes: where it wins, and the shape it cannot see.
Sanchez Vinces, Schubert, Zimek & Cordeiro's Clustering-based outlier detection names the axes —
detection quality, resilience to parameter variation, scalability, and auto-filtering by an internal
index — and three of the four are now measured against IsolationForest and LOF over three fixtures,
medians of seeds 0/1/2. The per-cluster z-score wins the local case decisively: on clusters of
unequal density it reaches ROC-AUC 1.000 / average precision 0.998 against IsolationForest's 0.887 /
0.175, with the smallest parameter spread of the three detectors (0.001 over a 12-cell sweep). It
loses on anisotropy — ROC-AUC 0.596 on sheared clusters, and identically 0.596 at
feature="diagonal" and feature="full", which is the mechanism: the score divides by one scalar RMS
radius, so a cluster's short axis is judged by the length of its long one and the covariance the tree
already carries is never consulted. A Mahalanobis radius on the same partition lifts that to 0.762,
filed as its own task. The parameter cell can also be chosen without labels: taking the largest
validity()["calinski_harabasz"] over the sweep lands within 0.033 ROC-AUC of the sweep's best cell
on all three fixtures — so the resilience spread is filterable rather than fatal. No behaviour
changed; docs/FEATURES.md and
bench/RESULTS.md now state
both the win and the limit.
- fidelity::summary_mmd: a label-free, head-independent fidelity number for the leaf summary.
Every other quality number the crate reports needs something the summary cannot supply — ground
truth labels, a head, or a value of k — and mean_sq_radius, the one that needs none of them, is
the exact k-means fidelity and falls monotonically forever. Maximum mean discrepancy asks the
distributional question instead. The leaf surrogate is the Gaussian mixture Σ (n_i/N)·N(μ_i, s_i I)
with s_i = S_i/(n_i·d), the same isotropic leaf cloud the scale-space head mollifies with, and the
expected Gaussian kernel between two such clouds is
(1 + s/h²)^(−d/2)·exp(−‖m‖²/(2(h² + s))), so the MMD against a raw sample needs no sampling at
all. Verified against Monte Carlo before use: worst |z| = 3.02 over 24 stochastic cells at 4·10⁶
draws, the 8 deterministic cells exact to 10⁻¹².
Measured, it finds the knee the quantization error hides. On six overlapping Gaussians in 16
dimensions, ARI jumps 0.593 → 0.976 between budgets 16 and 32 and is then flat; summary_mmd falls
0.0665 → 0.0065 across the same steps and is then flat within ±5% for five more budgets, while
mean_sq_radius slides smoothly 212 → 80 with no feature at the knee. It is not a monotone
axis — in two dimensions it rises from 0.0054 at 8 leaves to 0.0092 at 32 before falling, because
coarsening also lets a leaf's ball fit a Gaussian blob better than a two-point leaf's does, and the
ARI column dips at exactly the same budget. Exposed as Betula.summary_mmd(X, bandwidth=None),
which needs only a built tree — no labels, no k, no head, so a partial_fit tree can be scored.
O(m² + m·M + M²) kernel evaluations: a diagnostic to run at a few budgets, not inside a fit.
Tables in
bench/RESULTS.md.
- The symmetry group of every (feature, head) pair is now stated and enforced. A head answers a
question about the data, not about the coordinates it arrived in, and which frame changes it may
ignore is a property of its model that is easy to lose by accident — to a bandwidth in absolute data
units, a covariance floor applied before whitening, or an initialisation that reads coordinate 0.
tests/equivariance.rs runs all eleven Euclidean heads in five frames (translation, rotation,
uniform scaling, an axis swap, and the identity) and compares the partitions up to relabelling, with
the two exceptions asserted to differ rather than skipped: gmm_diagonal is not rotation
invariant because an axis-aligned covariance is a claim about the axes, and the directional heads
are not translation invariant because a direction is read from the origin. The audit found no head
violating its claimed group — reportable only because the suite carries its own controls, including
one that superimposes two of the four groups and requires most heads to notice, since blobs separated
far enough to be found in any frame would pass an invariance suite without exercising a symmetry.
The measured half is the second axis: the leaf summary is as much of the group as the head is. A
symmetry the feature has already discarded cannot be recovered downstream, so gmm_full — a
rotation-invariant model — is rotation invariant on Full leaves, not invariant on Diagonal
ones (the default cov_dense keeps only diag(variance), and the off-diagonal scatter these
clusters live along is gone), and invariant again on Spherical ones for the trivial reason that an
isotropic summary has nothing a rotation can change. Choosing feature="diagonal" for memory is
therefore also a choice about which frames the answer may depend on.
Auditing gmm_toeplitz — neither Euclidean nor directional, since an AR(w) covariance says the
coordinates are an evenly spaced sequence — needs a third kind of frame change: shifting or
rescaling the whole sequence leaves that model alone, while reordering the axes is a different
model. It is exhibited on two AR processes differing in nothing but their autocovariance, each
window standardised so the marginal mean and variance carry no signal, which the head separates
exactly; sending lag-1 neighbours 64 apart destroys the band structure and the answer with it.
Tables in
docs/MATH.md. Tests only — no
behaviour changes.
- dyn_msc: medoid-silhouette clustering that chooses its own k. (Rust API —
clustering::dyn_msc, clustering::MedoidClustering.) Lenssen & Schubert's DynMSC
(Inf. Syst. 120, 2024): optimise the medoid silhouette by swaps, then sweep k downward reusing
the medoid set and keep the best-scoring level. Its objective is exactly the already-published
validity::medoid_silhouette, asserted rather than assumed, so the head cannot win on a number the
metric would score differently.
It is the crate's second automatic k, and it does not replace the first. Over 24 measured
cells it is 11 wins to Calinski–Harabasz's 9 with 4 ties, and the split is by cluster shape: on
round clusters DynMSC recovers k in 9 of 12 cells and gives the better partition whenever both
agree on k, while on clusters stretched 4× along one axis CH returns the top of the k range it
was handed in 5 of 12 cells — the monotone failure of a variance ratio on non-spherical data — where
DynMSC's error is bounded by collapsing toward 2. Cost scales as m^2.4–m^2.6, about 0.37 s at
960 leaves, so it belongs to the same hundreds-of-leaves envelope as the other quadratic heads.
ward_hac_auto keeps the default and there is no Python surface. Tables in
bench/RESULTS.md.
Selection: PLSCAN's persistence-chosen minimum cluster size, alongside excess of mass. (Rust API —clustering::hdbscan_selected,clustering::Selection.) Raising HDBSCAN*'smin_cluster_sizenever moves a merge, it only prunes branches that fail to reach the size, so one dendrogram already contains every clustering the parameter can produce. Each segment therefore has a size interval over which it is a leaf cluster; summing those lifetimes rates a candidate size, andSelection::Persistencereports the clustering at the argmax (Bot, McInnes & Aerts, arXiv:2512.16558).Hdbscangainedselected_sizeso the chosen number is visible.
Measured over 50 cells, it is a conditional win and ships with the condition stated: on clusters
that differ in density it wins 14 / loses 0 / ties 11 and its ARI spread across min_samples is
never larger; on clusters that differ in size it wins 3 and loses 22. The excess-of-mass default is
unchanged and there is no Python surface — a method= string cannot carry a condition. Part of the
paper's benefit is already bought by the CF tree, since a leaf of w points cannot express a cluster
finer than w; the two arms converge as leaves get heavier. Tables in
bench/RESULTS.md.
dendrogram_below: exact agglomerative clustering below a height bound, with a certificate. (Rust API —clustering::dendrogram_below,certificate_radius,BoundedDendrogram.) A cut atkclusters only needs the merges below one height, and those can be computed over a candidate radius graph instead of all pairs — exactly, not approximately. The argument is one inequality: the minimum over cross pairs is at most their mean,‖Δμ‖² + S_A/n_A + S_B/n_B. Average and weighted linkage getr² = hbecause their value is that mean; Ward getsr² = h/w_minbecause its value carries a mass factor and a cluster's scatter is the sum of theD4of the merges inside it. Centroid and median get no radius at all — their value is‖Δμ‖², which bounds neither spread, so two concentric shells merge low with no close cross pair anywhere. That case returnsCertificateError::NoRadiusrather than a plausible wrong answer, and a cut above the certified height returnsNonerather than one served from a truncated dendrogram.
Measured at 7.9× the dense driver on 32 000 leaves (10.3 s against 81.9 s, median of 3), 7.6× at
8000 and 5.9× at 2000. The graph is still an O(m²·d) scan — 26–42 % of the total — because an
exact radius query has no sublinear structure here, and an approximate k-NN index would forfeit the
certificate for the sake of the one edge it drops. Full tables, including the numeric cross-check
that the three bounds are attained, in
bench/RESULTS.md.
BregmanBetula: the CF-tree in a Bregman geometry, as a second estimator. Lane A shipped the Rust side in this release —BregmanCf,D4_φ, Bregman k-means / Ward / EM — with no Python surface, because the wiring is a type-1 door. It is now decided and recorded indocs/adr/004-bregman-public-api.md: a separate class, not adivergence=keyword onBetula.
The reason is that feature= names a covariance model and every one of its values summarises the
same Euclidean geometry, while a Bregman feature names the geometry. Collapsing the two axes
makes meaningless combinations writable — feature="kl" with method="gmm" hands a Gaussian head
a Bregman information to read as a variance, absorb="chi2" applies a variance prior to a quantity
that is not one. With a second class they are not rejected; they cannot be typed.
BregmanBetula(divergence=…, method=…, beta=…) takes divergence in
{"euclidean", "kl", "itakura-saito", "logistic"} and method in {"kmeans", "ward", "mixture"}.
beta is the mixture's inverse dispersion in nats of divergence, and it is rejected rather
than ignored under the other two heads. The domain each φ needs — x > 0 for KL and
Itakura–Saito, x ∈ (0, 1) for logistic — is validated at the Python boundary and the error names
the offending row and column, because BregmanCf::push only debug_assert!s it and a release
build would otherwise return NaN. divergence="euclidean" is kept deliberately: squared
Euclidean is a Bregman divergence, so it reduces to the shipped path, which is what makes the
agreement between the two estimators a test rather than a claim.
balance: a mass-balanced leaf budget, for the data where the geometric one fails. The absorption gate is purely geometric — one global radius, raised by the rebuild until the leaf count fits — and a single radius cannot serve two densities. Once it passes a dense region's diameter that region collapses into one leaf in a single step while sparse regions keep splitting: measured at 80 000 of 100 000 points in one leaf at every budget from 250 to 4000, with the budget itself 90–96 % filled. The budget was never under-used; it was spent on whichever points happened to be far apart.
balance = b caps a leaf at b × (total mass / max_leaves). The cap is enforced at both sites that
combine mass, since either alone undoes the other — absorption refuses a full entry and starts a new
one, compaction skips a pair that would overflow — and max_leaves stays a hard bound: a
rebuild that cannot reach its target under the cap merges over it rather than leave the tree over
budget. The ideal is read from the root CF, so a stream tightens it as data arrives instead of
needing n up front.
On the fixture that motivated it — two true clusters inside a dense core, five diffuse minorities
around it — kmeans goes from ARI 0.4174 to 1.0000 at every budget from 250 to 4000, matching
sklearn's k-means on the raw points, while the realised leaf count barely moves and the control
fixture does not regress.
Default off, because it is a lever and not a free win. Across digits, covtype-20k and
mnist-10k × three heads × three budgets, 19 of 27 cells improve — but the useful statistic is the
heaviest leaf's share of the mass, not the average: every cell with top1 ≥ 0.5 gains +0.08 to
+0.25 with all three heads agreeing, and every cell with top1 < 0.1 moves within seed noise in
both directions. max(microcluster_weights_) / sum(...) is the one-line diagnostic that says which
case you are in. Full table in bench/RESULTS.md.
- bregman: the cluster feature, generalised from squared Euclidean to any Bregman divergence.
For d_φ(x,y) = φ(x) − φ(y) − ⟨∇φ(y), x−y⟩ the within-cluster Bregman information
S_φ = Σ w_i d_φ(x_i, μ) obeys exactly the identities the Euclidean scatter does — Maxima
residual 0 for a generic differentiable φ — and the mean merge is unchanged, which is
Banerjee's characterisation rather than a coincidence: the arithmetic mean is the right-sided
Bregman centroid for every φ. So (n, μ, S_φ) is a commutative monoid for every φ and the tree
machinery is untouched.
The trait's shape is dictated by a measurement. Σ w φ(x) − W φ(μ) is the exact analogue of
BIRCH's SS − n μ² and cancels the same way — but the recurrence alone does not save you either,
since both of its new terms evaluate d_φ at arguments that nearly coincide, and expanding d_φ
from its definition subtracts two nearly equal values of φ one level down. On the logistic
divergence at a tight cluster the naive form loses everything (relative error 1) and the
expanded recurrence diverges, returning an information 10¹¹ times too large. So
BregmanDivergence requires a hand-written cancellation-free divergence() whose contract is
accuracy when x ≈ y, and keeps phi() as a test oracle for well-separated arguments only.
Ships SquaredEuclidean, KullbackLeibler, ItakuraSaito and Logistic; BregmanCf
implementing ClusterFeature; and the two measures a tree needs — BregmanCentroid (D0_φ,
routing) and BregmanIncrease (D4_φ, absorption). Both live in bregman rather than distance
because, unlike everything there, they are not feature-agnostic: they have to know which φ built
the feature. The regression test that matters is that φ(t) = t² reproduces the shipped
Spherical feature and that D4_φ collapses to VarianceIncrease — the generalisation must not
have changed the Euclidean answer.
Two Phase-3 heads over that summary: clustering::bregman_kmeans and
clustering::bregman_agglomerative. Both are exact on the leaf summary for the same reason the
Euclidean ones are -- the bias-variance identity holds for every φ, so a leaf is scored against a
candidate centre without revisiting a point -- and the k-means seeding uses the whole leaf potential
S_i + n_i·d_φ(μ_i, c) rather than the centroid's share alone. Two things deliberately do not
carry over: d_φ is not a metric, so the Hamerly bounds that accelerate the Euclidean k-means have
no analogue and the Lloyd loop is the plain one; and Bregman-Ward is not reducible, so the
agglomerative head runs Anderberg (see ADR 002 below). At φ(t) = t² the agglomerative head
reproduces the shipped Euclidean Ward labels exactly, at every k tested.
The Anderberg driver added in 0.7.0 for the non-reducible Euclidean linkages is now node-agnostic and shared by both, rather than duplicated -- a behaviour-preserving change, pinned by the existing SciPy and Lance-Williams cross-checks.
clustering::bregman_em closes the family: soft Bregman clustering is EM for the matching regular
exponential family (Banerjee's bijection), so it is the diagonal-GMM skeleton with d_φ where the
Mahalanobis term was. Two things about it are worth stating.
E_{x∈leaf}[d_φ(x, μ_k)] = S_i/n_i + d_φ(μ_i, μ_k) is exact for every φ, so the expected
complete-data log-likelihood needs no approximation of the within-leaf shape at all; tying the
responsibilities within a leaf is the only approximation, which makes this exact variational EM
with a monotone lower bound. A consequence that is testable and surprising: S_i/n_i is the same
for every k, so given the centres a leaf's internal spread cannot move its responsibilities —
only the value of the bound. (The fitted result still depends on S, through the k-means++ warm
start.)
And beta, the shared inverse dispersion, is a parameter rather than a fitted quantity — fitting
it needs the family's log-partition function, which has no form generic in φ. It is also what
decides whether the head can separate anything, because at fixed dispersion separation is measured
in nats of divergence, not in coordinates. Itakura–Saito is scale-invariant, so three groups whose
centres look far apart can be 0.33 nats apart, and a β = 1 mixture correctly reports that they
overlap and collapses all three means onto the global mean. That is the model being honest rather
than the optimiser failing, and there is a test pinning both halves of it.
Rust-only for now; nothing is wired into Method or the Python API yet.
- ADR 002 — a Bregman-Ward HAC must use Anderberg, not the nearest-neighbour chain. NN-chain is
exact only for a reducible linkage, and generalising Ward's criterion to a Bregman divergence
does not preserve reducibility. Measured rather than assumed: zero violations at d = 1 across
213 million admissible triples and 16 different φ, and violations from d = 2 for every φ
except squared Euclidean. Inside every pooled counterexample, no coordinate ever violated its own
1-D reducibility — the failure is purely that reducibility is not preserved under addition.
φ(t) = tᵖ is clean at p = 1.99 and p = 2.0 and already violates at p = 2.01, so there is no
safe neighbourhood of t².
It costs a wrong dendrogram, not a wrong triple: against exact Anderberg over 3 000 instances per
cell, squared Euclidean differs in 0 of 9 cells, while Itakura–Saito at d = 20, m = 12
differs in 1.0 % and the exponential divergence in 1.2 % — a rate that grows with m, on a
head that runs at m in the thousands — and when it fires the answer is destroyed rather than
perturbed (ARI 0.10 at k = 4, one cell at −0.11). The coordinate-wise chain proposed as a cheaper
rescue is refuted separately: a single coordinate's nearest pair matches the pooled nearest pair
2.4 %–6.5 % of the time against a 1.52 % chance rate. clustering::ward_hac is unaffected — it is
Euclidean, and its control row is clean at every dimension and size.
- Windowed stream queries, and the measurement that says why they are not done by subtraction.
CluStream (Aggarwal et al., VLDB 2003) answers "cluster the window [t₀, t₁]" by keeping
snapshots and subtracting the one at t₀ from the one at t₁. Cluster-feature additivity
makes that exact in real arithmetic, and subtraction is the one CF operation BETULA's stable form
does not protect: the entire reason for carrying (n, μ, S) instead of (n, Σx, Σx²) is never to
form a difference of two nearly equal large quantities, and an inverse merge forms exactly that,
one level up.
window::Moments::checked_subtract implements the inverse merge and conditions it. The two lines
lose about log₁₀(n_AB/n_B) and log₁₀(S_AB/S_B) decimal digits respectively — and those two
ratios are not interchangeable. On a stationary stream S grows with n and they agree. Under
drift S_AB picks up the between-window displacement, which has nothing to do with either
window's internal spread, and the scatter ratio runs away while the mass ratio does not. Measured
on a two-half fixture with a mass ratio of exactly 2.0 — a guard written on point counts sees
nothing at all — the recovered scatter is 6.25e-2 against a true 1.0153e-5: a relative error
of 6155×, at condition number 4.0e15 and 0.35 surviving digits. That is the finding, and it is
why the guard conditions on both ratios a posteriori and returns a SubtractError instead of a
number with nothing in it. (A Cholesky downdate restores the definiteness the cancellation
destroys — a real and separate problem for the full-covariance feature — but cannot restore digits
that were never stored. It treats a symptom of this, not this.)
window::WindowIndex is the constructive answer: micro-clusters stored per frame rather than
cumulatively, so a window is a sum of frames and every combination is the stable Chan merge.
window_moments and cluster_window answer the CluStream question with no condition number at
all — on the same drifting fixture the summed answer is exact to 1e-12 where the subtraction was
wrong by 6155×. The trade is explicit and runs the other way from CluStream's: exactness in real
arithmetic is given up (a window resolves only to the frame boundary) for an answer that is sound
in floating point at every ratio. Capacity is enforced by merging the two oldest adjacent
frames, so resolution coarsens with age and never with recency — the pyramidal property, reached
by merging instead of by differencing — and compaction preserves total mass exactly, since every
step of it is a CF merge.
window::TimeSpan carries timestamp moments under the same (weight, mean, ssd) contract the
spatial feature uses, so a windowed summary can say when it is from. Moments is deliberately not
a ClusterFeature: the inverse merge is well defined only for the scalar scatter, and letting a
diagonal or full feature through it would silently drop the off-diagonal structure a window query
never asked to lose.
WindowStream is the estimator on top: partial_fit(X, t) routes points into a CF-tree, seals a
frame every frame_width of stream time (snapping forward across a gap so the frame grid stays
aligned to the origin rather than to the last arrival), and hands the sealed frame's
micro-clusters to the index. close_frame, frame_spans, window_moments and cluster_window
are exposed in Python. This is what DenStream structurally cannot do: decay leaves it with only
a present, while this retains history and coarsens it with age.
- Two benchmark studies the published tables never ran: leaf budget, and insertion order.
bench/leaf_budget.py sweeps max_leaves × routing distance × head over three datasets (252
cells, median of seeds 0/1/2) and bench/insertion_order.py measures order sensitivity against a
reseeding control (54 cells, 8 runs each). Three findings land in bench/RESULTS.md:
The published digits rows run at max_leaves=4000 against n=1797 — zero compression,
confirmed at 1797 leaves, maximum leaf weight 1, mean squared radius 0. They are raw-point
clustering behind a betula wrapper and support no claim about summarization. They also understate
the library: the curve has an interior optimum, and halving the leaf count raises ward from
0.6428 to 0.6819 and kmeans from 0.4670 to 0.5600. On covtype-20k the ward head holds
0.1412–0.1430 from ×11 to ×202.
The routing distance is a function of compression, mechanically: at ×1.0 the spread across the four distances is exactly 0.0000 on all three datasets, because they build the identical singleton leaf set. Task 27 measured that lever at one budget and found it small — this says the budget it was measured at is the one regime where it provably cannot matter.
And at real compression the input order moves ARI more than the seed does: MNIST at 200 leaves
with the k-means head disagrees with itself at pairwise ARI 0.2949 across permutations against
0.7026 across seeds. The ward head's seed arm reads spread 0.0000 / pairwise 1.0000 in all
nine of its cells, which is the control that makes the order arm readable.
- export_coreset(size=…): the word "coreset" turned into a claim. export_coreset() has
always returned the leaf summary and always called it a coreset; nothing checked that it was one.
It now carries the two bounds that make the name earned, and they are kept apart on purpose,
because the summary and the sample fail in different ways and folding either into the other makes
both unfalsifiable.
Summarization, reported in both modes as coreset.offset = Δ = Σᵢ Sᵢ. The summary's cost
ĉost(C) = Σᵢ (Sᵢ + nᵢ‖μᵢ − C‖²) is exactly the cost of sending every point of a leaf to the
centre nearest that leaf's centroid — the CF identity makes it exact, not an estimate — so it can
only over-charge, and boundedly:
The proof is one triangle inequality (‖p − c_i‖ ≤ 2‖p − μᵢ‖ + ‖p − c*‖, using only that c_i
is nearest to μᵢ) plus Cauchy–Schwarz on the cross term. The relative form is 4√ρ + 4ρ at
ρ = Δ/cost(C), and cost(C) ≥ OPT_k bounds it uniformly. Coreset.summary_epsilon(alpha)
evaluates it and requires the approximation factor as an argument: reference_cost
upper-bounds OPT_k, so Δ/reference_cost under-states ρ and summary_epsilon(1.0) is an
optimistic reading, not a certificate. Defaulting that argument would have hidden exactly the
assumption the bound stands on.
Sampling, only when size is given. ĉost(C) = Δ + Σᵢ nᵢ‖μᵢ − C‖² and Δ does not depend
on C, so the sample only has to be a coreset of the weighted point set {(μᵢ, nᵢ)} — a
standard object the published theory covers verbatim — and offset carries the constant rather
than losing it. The construction is sensitivity sampling (Feldman & Langberg, STOC 2011) from an
α-approximate solution over the leaves, sᵢ = 8·cᵢ/Ctot + 2·(nᵢ/Wⱼ)·(Cⱼ/Ctot) + 4·(nᵢ/Wⱼ), whose
total is 10 + 4k — asserted, since a value away from it means the reference solution left a
cluster empty. Sensitivity sampling is now known to attain the optimal worst-case coreset size
Õ(k·ε⁻²·min(√k, ε⁻²)), matching the STOC 2022 lower bound, and Õ(k/ε²) on Ω(1)-stable
instances (arXiv 2405.01339) — which is why it replaced BICO's ring hierarchy in the design.
Coreset.cost(centers) adds the offset for you. That is deliberate: forgetting it understates
every cost by the same constant, which is invisible until two coresets are compared.
export_coreset() with no arguments is unchanged and still free — same three arrays, same single
O(n_leaves) pass, no k-means. The guarantee numbers it cannot compute are None rather than
faked. A size at or above the leaf count returns every leaf exactly instead of a noisy redraw of
something already held exactly.
The (k, ε) property is tested the way it is claimed: over candidate solutions the sensitivities
never saw, on both sides of the binding. Also asserted — that the summary never under-estimates,
that the derived bound holds against the actual points, that the bound is not vacuous on the
fixture it is checked on, that the estimator is unbiased over 80 seeds, and that a larger sample
is a better one.
- The four non-Ward linkages: average, weighted, centroid, median. method="ward" was
the only hierarchical head, because the nearest-neighbour chain that drives it is valid only for a
reducible linkage — and centroid and median linkage are not reducible, they invert. The four
others now run on an Anderberg driver (one global minimum per step over a lazily repaired
nearest-neighbour cache; O(m²·d) expected, O(m·d) extra space) and take the same n_clusters,
including n_clusters=0 for a Calinski–Harabasz-scored cut. Names follow SciPy's
linkage(method=…): average = UPGMA, weighted = WPGMA (McQuitty), centroid = UPGMC,
median = WPGMC.
All five linkages are one accumulator. Writing α for the weights a cluster gives its member
leaves, each of them is a function of m_A = Σ α_i μ_i and V_A = Σ α_i (S_i/n_i + ‖μ_i − m_A‖²),
and both compose exactly under a merge with β = w_a/(w_a + w_b):
The V recurrence is König–Huygens arranged so every term is non-negative — it never forms
Σ α‖μ‖² − ‖m‖², which is exactly the cancellation this library exists to avoid, and a test pins
it against the CF merge at an offset of 10⁷. The five linkages are then two independent choices:
weight the children by mass (UPGMA/UPGMC/Ward) or equally (WPGMA/WPGMC), and measure
‖Δm‖² + V_a + V_b, ‖Δm‖², or Ward's 2·n_a n_b/(n_a+n_b)·‖Δm‖².
On mass weights the accumulator is the merged cluster feature, so three of them are exactly the
CF distances the tree already routes by, all on squared distances: UPGMA is D2², UPGMC is
D0², Ward is 2·D4². The factor two on Ward is not decoration — it is what puts all five on one
scale, where each reduces to the plain squared distance between two points on single-point leaves.
Both facts are asserted rather than asserted-about.
weighted and median are the reason the driver is not parameterised over CFDistance: a
cluster-feature merge is mass-weighted by construction, so nothing built out of cluster features
can represent a cluster whose children were combined equally regardless of their size.
Verification is against implementations that share no algebra with the driver: every linkage
reproduces the textbook Lance–Williams recurrence step for step and height for height; centroid
and median reproduce SciPy's partition exactly on a one-leaf-per-point tree (average and
weighted are excluded there, since SciPy applies them to unsquared distances); and the Ward
arm reproduces the existing nearest-neighbour chain at every k. centroid and median are
documented as admitting inversions, which is why cuts are taken as a prefix of the agglomeration
order rather than by sorting on height.
- validity(): three internal indices off the leaf summary, and the caveat each one carries.
est.validity() returns calinski_harabasz, davies_bouldin and medoid_silhouette for the
fitted partition in O(ℓ·k·d) — no second pass over the data and no O(N²) term, because the sum
of squared distances inside a leaf is S_i + n_i‖μ_i − c‖² exactly. On a one-leaf-per-point tree
calinski_harabasz reproduces scikit-learn's point-level calinski_harabasz_score to
floating-point noise, and a test asserts it.
What is exact and what is a variant is stated rather than glossed. CH is exact. Davies–Bouldin
ships the RMS dispersion √(E‖x − c‖²); the classical mean-distance form is not a function of
a cluster feature at all, and Jensen only bounds it, so this is a deliberate variant, not an
approximation of the original. The medoid silhouette is the index of the summary — a per-leaf
ratio weighted by leaf mass, and a ratio of expectations is not the expectation of a ratio. Its
medoid is taken in the squared metric, where
Σ_{i'} n_{i'}‖μ_i − μ_{i'}‖² = n_j‖μ_i − c_j‖² + const makes the minimiser exactly the leaf
nearest the centroid — an O(m) scan where the unsquared medoid needs O(m²).
None of the three can report "there is no structure here." Schubert, Stop using the elbow
criterion for k-means (SIGKDD Explorations 25(1), 2023, arXiv 2212.12189), Table 1 has the
distance-based indices finding 3–22 clusters in pure noise where BIC correctly finds one. CH is
undefined at k = 1, which is that same limitation said out loud. The n_clusters=0 BIC path is
unchanged and remains the authority on whether there is one cluster at all.
- graph_degree: the density head off its complete graph. method="hdbscan" was quadratic in
the leaf count twice over — a full sort per leaf for the core distances, then Prim over the complete
mutual-reachability graph — so the one head that most wants a fine summary was the one that could
least afford it. graph_degree > 0 (new keyword, default 0 = the exact path, byte-identical to
before) replaces both with the two-pass construction of Okkels et al. (Inf. Syst. 142 (2026)
102768, Alg. 4): build a bounded-degree approximate k-NN graph over the leaf means, read the core
distances off that graph, take an exact MST of it. Two-pass rather than one-pass because it is
the variant whose evaluation reaches cophenetic correlation ≥ 0.9 where one-pass saturates at 0.7,
and because its edge count is fixed at O(m · degree) where one-pass lets the expansion queue grow
quadratically.
The index is flat — no HNSW layer stack — following Thordsen & Schubert (SISAP 2025), who report that the hierarchy buys little in high dimension, that approximating an RNG/SSG there is futile, and that the capped beam search is the part worth keeping. Three uniformly random out-edges per vertex stand in for the long edges the upper layers would have contributed; they are held outside the degree cap, so a random draw can never evict a true neighbour, and they are what make the diameter logarithmic — without them a capped search on a purely local graph cannot reach a distant region at all.
Median of seeds 0/1/2, one BLAS thread, min_cluster_size = N/100, min_samples = 4N/max_leaves,
timing the head only (the tree build is identical and subtracted): on MNIST (70 k × 784) at
max_leaves=8000 the head goes 52.0 s → 0.45 s at degree 32, a 116× cut with the ARI
unchanged at 0.0523 and the same ten clusters — the approximation is free there. On 100 k 2-D
blobs at max_leaves=32000, 36.6 s → 0.98 s (37×) for ARI 0.5674 → 0.5608; on covtype
(581 k, 54-D) at max_leaves=8000, 4.61 s → 0.45 s for ARI 0.0490 → 0.0457. The trade is
monotone in the degree and it is the degree, not the graph, that costs the quality — degree 8 is
not enough at d = 784 (ARI 0.0190 with 4 clusters, swinging across [0.0068, 0.0600] between
seeds), 16 is the smallest measured to be lossless there.
graph_degree is a floor, not a ceiling: core distances read off a graph saturate at the
farthest neighbour, so a degree below what min_samples needs underestimates every core distance
with no bound on the error. The head raises the request to min_samples / mean leaf mass whenever
that is larger — the paper's Ω(minPts) requirement translated into the currency a weighted
summary actually counts in.
- method="mppca": a mixture of probabilistic PCA, and the compression trade it exposes. Each
component covariance is Σ_c = W_c W_cᵀ + σ_c² I with W_c of rank rank (new keyword, default
2, clamped to dim - 1; 0 gives a spherical mixture). Both identities it rests on — the
Woodbury inverse (1/σ²)[I − W M⁻¹ Wᵀ] and |Σ| = σ^(2(d−q))|M|, M = σ²I_q + WᵀW — were
verified exactly in Maxima at three shapes before any of it was written, and nothing in the head
or in point scoring ever forms a d×d matrix. That is what makes it run at d = 784 and
max_leaves=2000, where gmm-full's per-leaf dense scatters need ~38 GB across its parallel
restarts and the benchmark table has carried a — for it since 0.1.0.
At full resolution it beats the diagonal and the full head. On digits (1797×64,
feature="fd", max_leaves=2000 ⇒ one leaf per point, median of seeds 0/1/2) rank=5 scores ARI
0.600 against gmm-full's 0.575 and the diagonal head's 0.461 — a rank-5 subspace where full
covariance spends 2016 parameters. On six 5-dimensional subspaces sharing one centre in 100-D,
where centroid distance carries no information at all, ARI peaks exactly at the true rank:
0.385 / 0.654 / 0.998 / 0.823 / 0.727 for rank 2 / 3 / 5 / 10 / 20, against the diagonal
head's 0.166 (median of seeds 0/1/2; the band at the peak is [0.9976, 0.9984]). Pull the same six
subspaces apart so the centroids separate them and every head scores 1.0000 at every one of those
ranks — the extra parameters cost nothing where they buy nothing.
And it loses on MNIST, for a reason that is about the summary rather than the head. The
expected-log E-step folds each leaf's own scatter into the component covariance as
−½ tr(Σ_c⁻¹ Σ_i), and that within-leaf term carries almost none of the between-cluster
orientation — so compression costs a head in proportion to how much orientation it models. Sweeping
the leaf budget on digits shows the ordering invert cleanly: at one leaf per point
mppca(5) 0.600 > gmm-full 0.575 > gmm 0.461; at 6:1 compression gmm 0.493 > mppca(5) 0.406 >
gmm-full 0.273; at 16:1 0.235 > 0.168 > 0.099. MNIST-20k at max_leaves=2000 is 10.6:1, and
there mppca scores 0.159 / 0.069 / 0.024 at rank 2 / 5 / 10 against gmm's 0.274. The
head is opt-in, the default is untouched, and docs/USAGE.md states the loss and the mechanism.
predict / predict_proba work as for any mixture head, and n_clusters=0 selects the component
count by BIC (the Stiefel rotation of W_c is unidentifiable, so q(q−1)/2 of the loadings are
not counted as free parameters).
- absorb="subspace": the χ² gate read on the leaf's own low-rank basis. Only feature="fd"
carries a basis, so every other feature model falls back to chi2 and the option changes nothing
unless you asked for the Frequent-Directions sketch. Same chi2_p and chi2_scale, same units.
Under the same Normal-Inverse-Gamma prior the effective covariance is Σ_eff = a·FᵀF + b·I, and
Woodbury reduces its inverse to one ℓ×ℓ solve — no d×d matrix is ever formed. Gram-Schmidt on
the sketch rows would be cheaper and would be wrong: FD rows are orthogonal only immediately after
a reduce(), and leaves are queried in between.
It works where orientation is the only signal, and loses on MNIST. On six rank-5 subspaces
sharing one centre — centroid distance carrying no information whatsoever — leaf purity goes
0.820 → 0.938 (median of seeds 0/1/2, max_leaves=2000, chi2_scale=0.01, seed ranges
disjoint), and on separated blobs it reaches the same ARI 1.0000 with 6 leaves against 99, a
16× smaller summary at identical quality. On MNIST-20k (784-D, StandardScaler, k=10) it scores
ARI 0.250–0.260 against chi2's 0.274–0.291 at every scale tried, with more leaves and ~20 %
more time. Opt-in, default untouched, and docs/USAGE.md states the loss.
The bound worth knowing is not the gate's. On that concentric fixture both gates reach purity
0.82–0.96 and ARI ≈ 0.05: every head here assigns by centroid, and there every centroid coincides.
A better-oriented summary currently has nothing that can read it, which is why this is not a
candidate default.
- projection="svd": a CF-weighted PCA of the leaf summary, and the one-call text pipeline.
Clustering TF-IDF in its own geometry does not work — the sparse path scores ARI 0.003 on
20-newsgroups — and the standard fix is to reduce first. This does the reduction inside the same
call, factorizing the M ≈ 10³ leaf centroids rather than the N documents: on 20-newsgroups
(18 846 × 2 000, k=20, rank 50, median of seeds 0/1/2) fit_predict_sparse(..., projection="svd",
max_leaves=256) scores 0.130 in 0.30 s, against TruncatedSVD(50) + KMeans at 0.143 in
0.54 s; at max_leaves=2048 it reaches 0.152 in 5.4 s. Available on fit_predict, the
Betula estimator and fit_predict_sparse, and unlike the NMF projection it accepts signed data.
The projection is only half of it — the other half is that a PCA is a linear map. A raw row
encodes as (x − x̄)Vᵀ, so a projected fit keeps the head's own point rule instead of falling back
to "answer with the row's leaf's label". Measured on the same basis and the same rows, that
distinction alone is worth 0.062 ARI (0.097 → 0.159). The NMF projection cannot be given the
same treatment: its code is the solution of a per-row nonnegative least squares, not a matrix
product, so it keeps the microcluster route and the docs now say why.
Two measured facts decide whether it works for a given corpus, both in docs/USAGE.md. Use a
cosine head: method="kmeans" on the same codes scores 0.014 against spherical-kmeans's
0.152, because the leading principal direction of a TF-IDF corpus is document length. And the
leaf budget is the cost, not the projection: sweeping rank 1 → 100 moves the total by 1.2 s,
while max_leaves 256 → 2048 moves it from 0.30 s to 5.4 s, the sparse summarizer being quadratic
in the micro-cluster count.
The basis is not a compromise for being built from a summary. Labelling raw rows in it scores 0.159
against 0.143 for TruncatedSVD's own basis on the same rows — under the spherical cluster feature
the discarded within-leaf scatter is isotropic, so it shifts eigenvalues and leaves the directions
alone. bench/_worker.py's betula-svd row now runs betula's own reduction; it previously
borrowed scikit-learn's TruncatedSVD and so measured scikit-learn's reducer.
- refine=n: BIRCH's Phase 4, which this crate never had. n Lloyd sweeps over the raw rows,
warm-started from the Phase-3 centres. Off by default, centroid heads only (kmeans,
spherical-kmeans — a mixture assigns by maximum posterior and Ward/Spectral/Leiden by
microcluster, so sweeping centres would substitute a partition they never fit), and in-memory
fit / fit_predict only: partial_fit keeps a tree rather than the data, and the CSR path would
have to densify the matrix it exists to avoid. Label-changing where enabled — which is why it
is opt-in rather than a new default.
It moves the objective exactly where the theory says — a summary coarse relative to the data — and
on this benchmark that is not the same as moving the answer. On MNIST (first 20 000 rows,
784-D, StandardScaler, k=10, spherical CF, max_leaves=4000, median of seeds 0/1/2) twenty
sweeps take the k-means objective 11 750 563 → 11 710 630 for 4.2 s → 6.4 s, and take ARI
0.315 → 0.309 with it. Five sweeps: 11 720 402 and 0.311. The descent is monotone in the objective
and monotone downwards in ARI.
The acceptance target was not met, and the misses are the interesting part. Against
sklearn.cluster.KMeans(n_init=10) (ARI 0.324, objective 11 671 351, 19.3 s) refinement lands at
3.0× the speed but 0.34 % above the objective: one warm start from a lossy summary settles in a
worse basin than the best of ten k-means++ restarts, and more sweeps cannot leave a basin.
digits and covtype gain nothing at all, structurally — digits at max_leaves=4000 realises
1 797 leaves for 1 797 rows, so Phase 3 already is exact k-means on the raw points and Phase 4
begins at its fixed point, while covtype moves 0.1993 → 0.1998. MNIST at max_leaves=16000
behaves like digits: 0.3237 at objective 11 671 813 — scikit-learn's own answer in 15.8 s
against its 19.3 s — and twenty sweeps then move the ARI by 0.0001.
The measurement also sharpens the caveat the feature ships with: on this benchmark a lower
k-means objective goes with a worse partition, twice. covtype — n_init=10 objective 827 314 /
ARI 0.174, n_init=1 objective 832 081 / ARI 0.277. digits — n_init=10 69 405 / 0.468,
n_init=1 69 749 / 0.559. refine optimizes the objective faithfully; docs/USAGE.md says
plainly that this is not the same as optimizing the answer.
- The full BIRCH absorption grid, and D3 implemented for the first time. absorb accepted only
euclidean and chi2; it now takes manhattan (D1), average (D2), diameter (D3), ward (D4)
and radius (R) as well. D3 — the mean squared distance within the cell that results from the
merge — did not exist in src/distance.rs at all, so a third of the criteria a BIRCH paper refers
to were unreachable.
D3 closes over (n, μ, S) because the double sum telescopes: Σᵢⱼ‖xᵢ−xⱼ‖² = 2·n·S, leaving
D3²(A,B) = 2·(S_A + S_B + n_A·n_B/(n_A+n_B)·‖Δμ‖²)/(n_A+n_B−1) — D4's merge term and the two
scatters over one fewer than the merged mass. Checked against brute-force enumeration of the
underlying points, both in a Rust fixture and over random point sets to 5.7e-14.
The default does not move, and docs/USAGE.md now says why rather than leaving it implicit:
the thesis tunes absorption for minimum variance and prefers D4/D2, while this crate chose
mass-invariance, and the two objectives genuinely conflict — the variance-minimising criteria
inherit the BIRCH size-imbalance bug that chi2 exists to fix. threshold is read in each
criterion's own units (L1 for manhattan, squared for the rest, a χ² quantile for chi2), so it
does not transfer between them; the table in docs/USAGE.md states the units per criterion.
The one-shot path had carried a verbatim copy of the estimator's gate resolution, which would have
meant adding every criterion twice; it now calls the same resolve_gate, and the accepted-value
list exists once so the parser and the error messages cannot drift apart.
- A UserWarning when the CF-tree compressed nothing: one leaf per point. With threshold = 0 a
point is absorbed only by an entry it equals exactly, so a max_leaves that never binds leaves the
summary at n micro-clusters. The answer is not wrong — but the tree is descended, split and
rebuilt for every point and Phase 3 then runs on the raw rows anyway. Measured single-threaded on
64-dimensional blobs, that costs 3.8× the fit time at n = 8 000 and 14× at n = 40 000 against
the same call with a binding max_leaves = 2000, and 36–46× more than clustering the raw rows
directly. The default max_leaves = 2000 means only a caller who raised it explicitly can land
here, which is exactly why it was silent.
The check reads the realised leaf count rather than comparing n against max_leaves, matching
the existing budget warning's own rule: leaves == n is the thing that is actually true, and only
it says no two points ever shared a micro-cluster.
This closes task #40's third item by refuting it. The proposed fix was a leaf-per-point bypass
that skips the routing descent in this regime; it cannot be an exact equivalence. At threshold = 0
an exact duplicate is absorbed — but only when it descends to the leaf holding its twin, and which
leaf a point reaches depends on the tree's state at the moment it was inserted. A hash-based bypass
would merge duplicates the real tree keeps apart, so it would be a relabelling sold as an
optimisation. The measurable cost is published and made visible instead.
- A
UserWarningwhen the CF summary is too coarse to carryn_clusters. Asking forkclusters from fewer than2kleaves silently produced a worse partition with no signal; the warning names the realised leaf count,k, their ratio and the currentmax_leaves, and points at the three parameters that fix it. Fires from every entry point that partitions a summary into a caller-suppliedk— thefit_predictfunction, theBetulaestimator (dense and CSR),fit_predict_sparseandKPrototypes— and stays silent for auto-kand for the heads that discover their own count (hdbscan,scale-space,leiden).
The threshold is measured, not assumed: over three seeds on the ward head, well-separated data
is already at its plateau ARI at ≈2 leaves per cluster and loses 29 % (k=50) / 55 % (k=200) of it
at ≈1, while digits and covtype score 0.000 and 0.003 at ≈1. Lang's thesis reports the same
floor from the other end (Sec. 5.5.4). It is a floor, not a recommendation — covtype peaks at
≈8 leaves per cluster and declines after — and the check reads the realised leaf count, since
the tree routinely settles below its cap and at n < max_leaves the cap never binds at all.
- A warning when a Gaussian head is fed a feature that cannot carry its covariance.
method="gmm"andmethod="gmm-full"fit a per-dimension covariance, butSpherical::variance(_d)ignores its argument — it returnsssd / (w · dim), one isotropic number for every dimension, because a spherical cluster feature holds a scalar scatter and cannot hold more. The M-step adds that number to alldimcomponent variances (andgmm-fullinherits it throughcov_dense's diagonal default). With one leaf per pointssd = 0and nothing is added; under compression each component is inflated equally in every dimension by however much leaf scatter it happens to cover, so a dimension with genuinely near-zero variance is lifted to the isotropic average.
The fit survives that. The labelling does not, because the maximum-posterior argmax is dominated by
ln|Σ_c| = Σ_d ln σ²_cd while a nearest-centroid rule ignores Σ. Measured on digits, medians of
seeds 0/1/2: at 898 leaves (×2.0) the head returns ARI 0.0088, while labelling the same points
off the same fitted model by nearest centre returns 0.5288 and by their leaf 0.5296. Ten
non-empty components, largest holding 30% — there is no degenerate component and no merged blob.
The falsifiable prediction — features carrying per-dimension scatter must not show it — holds:
fd and full read 0.3840 and 0.4403 in that same cell, and all three agree to the digit at ×1.0,
where there is no scatter to add. gmm-full on the spherical feature collapses identically (0.0096
at 1200 leaves) and never does on feature="full".
So the pairing is now a warning naming the measured cost and the fix (feature="full", or "fd"
in high dimension). This closes task #89 with a mechanism rather than a workaround; heads other
than these two read the same isotropic variance(d) but were not measured, so the warning does not
claim them.
- scripts/check_mutants_baseline.py — the staleness half of the mutation ratchet, in a second
rather than three hours. mutants-baseline.txt records accepted surviving mutants by their exact
path:line:col: description, so any edit that shifts a line silently invalidates every entry below
it in that file. Nothing checked that: the weekly mutants.yml run notices only as a "trim them"
note inside a step summary, and it had been red since 2026-08-19 for an unrelated reason. Checked
by hand on 2026-09-07, 147 of the 315 entries no longer matched anything — 121 the same
mutation at a new line, 26 gone entirely, worst in stream.rs (33), scalespace.rs (28) and
hdbscan.rs (21, rewritten for the parallel build).
cargo mutants --list builds and runs nothing — about a second on this crate — so this is
answerable on every push. The script separates the two cases that need different work: a moved
entry keeps its recorded argument and needs re-anchoring, and 42 of the 121 are unambiguous enough
for the script to propose the new line outright; a vanished entry describes code that is gone and
takes its justification with it. It is not yet a CI job.
It also surfaced a second problem it was not written for: the crate now lists 8 927 mutants
against the 4 312 that .cargo/mutants.toml sizes the 96-shard matrix on, so the weekly run cannot
finish as configured even once it is green again. (The tagged v0.8.0 text reached that conclusion
through wrong arithmetic — it multiplied a stale 101 worker-seconds per mutant and forgot -j4.
The measured basis is in [Unreleased]; the conclusion survives it.)
Changed¶
- The sparse path stores micro-cluster coordinates feature-major, and the 20-newsgroups fit is
3.4× faster with every label bit-identical. Both sparse passes — the leader summarisation and the
row labelling — held each micro-cluster's coordinates as its own dense
Vec<f64>of lengthn_features. Scoring one row againstLof them therefore pulledL · nnzscattered cache lines out ofL · n_features · 8bytes of state, none of it reused before eviction: 32 MB atL = 2048on the 20news fixture. The symptom was an elbow in the leaf-budget curve exactly where that working set leaves L3 — doublingmax_leavesfrom 512 to 1024 cost 6.3×, not 2×.
Transposed to [c · L + i], a row instead walks nnz contiguous runs of L doubles, which is both
cache-resident and an elementwise axpy the compiler may vectorize — unlike the per-cluster dot
product, which is a float reduction and so may not be reassociated. The arithmetic is deliberately
untouched: for a fixed cluster the row's terms are still accumulated in row order, so every
distance, absorption decision and scatter update is bit-for-bit what the old layout produced. That
is measured, not asserted — all twenty label digests across five budgets, three seeds and both
pipelines are identical between the two builds.
Measured single-threaded on 18 846 × 2 000 TF-IDF: betula-sparse 6.57 s → 1.94 s,
betula-svd 5.12 s → 3.31 s, and at max_leaves = 2048 on the raw kmeans path
12.76 s → 2.19 s (5.8×). The super-linear elbow is gone. Peak RSS is unchanged (332 MB): the
transposed buffer is bounded by min(max_leaves, n_rows) · n_features, the same worst case as one
vector per cluster, and it widens in place rather than through a second live copy.
A perf profile is what made this complete rather than half-done. After fixing the summarisation
pass named by the task the fit was only 1.5× faster, and the profile put 65% of the remaining
samples in the row-labelling pass against 6% in summarisation. The task named one half; the
profile named the other.
Breaking (Rust API): sparse::nearest_sparse(&means, &musq, …) is replaced by
sparse::SparseCentroids::from_features(µs).nearest(…). The free function took the centroids
and their cached ‖μ‖² as two independent slices that had to agree, and nothing could enforce it;
the type derives one from the other. The Python API is unchanged.
- The FAISS baseline is now measured in two configurations, and the "quality-per-second" hedge is
withdrawn. bench/external_baselines.py timed faiss.Kmeans at its defaults, which is not a
like-for-like fit: max_points_per_centroid defaults to 256, so at k = 8 FAISS trains on 2 048
of the 200 000 rows and assigns the rest, and it seeds from a random subset rather than
k-means++, at nredo = 1. Two of the three things that made the row fast are quality-costing
shortcuts.
A new faiss-kmeans-matched row hands it every row and ten restarts — the cheapest setting whose
median ARI reaches betula's. It costs 1.19 s at 200 000 (6.3× betula) and 5.38 s at 1 000 000
(7.7× betula), and at 1 M still returns 0.835. The ceiling is initialisation, and scikit-learn is
the control that proves it: on the same fixture with every row used, init="random" medians 0.692
at n_init=1 and 0.835 at n_init=10, while init="k-means++" medians 1.000 at n_init=1 on
every seed. ARI 0.835 is a persistent local optimum — two blobs merged, one split — that neither
more data nor more iterations escapes: ARI is flat from niter = 25 to niter = 300.
So task #73's premise was wrong. There was no throughput gap to close; betula's edge is k-means++
on the exact CF potential over a summary small enough that restarts are free. The board totals are
unchanged (8/54/6 quality, 32/1/5 speed, 30/4/0 memory) and the two default-configuration FAISS
speed losses stand as measured.
- method="ward" with n_clusters=0 selects k by the variance ratio, not by the elbow. The
auto-k path cut the dendrogram at the largest relative jump in merge height, which is the
elbow criterion wearing a dendrogram, and Schubert (SIGKDD Explorations 25(1), 2023) is a direct
argument against it. It now scores every horizontal cut in the sweep by Calinski–Harabasz — the
first alternative that paper names, exact on cluster features — and keeps the best. On two far
groups of two nearby subclusters each, the tallest relative jump is the one that joins the far
groups, so the old rule reported k = 2 on every seed where the new one reports the true 4; that
fixture is now a test, and it fails if the elbow is put back. Scoring 19 cuts is O(k_max·m·d),
nothing beside the O(m²) dendrogram that had to be built anyway. k = 1 is still outside what
CH can express — n_clusters=0 on a mixture head remains the way to ask whether there is
structure at all.
- spherical-kmeans skips the dot products it can prove will not change a label. Hamerly's two
bounds, restated on similarities rather than distances (Schubert, Lang & Feher, arXiv 2107.04074):
keep a lower bound low[i] on the cosine to the assigned centre and an upper bound high[i] on
the best of the others, loosen both by how far the centres turned, and skip the leaf entirely while
low[i] ≥ high[i]. Labels, centres and iteration count are unchanged — byte-identical SHA-256
label digests in all nine measured cells, and the Rust suite checks the bounded loop against an
independent re-derivation on both fixtures.
Restating the bound on the sphere is not a term-by-term transcription, and the place it breaks is
worth naming: cos(θ − φ) is an upper bound on the reachable similarity only while θ ≥ φ. A
centre that turns by more than the angle it started at sweeps through the point and reaches
similarity 1, so the aggregation Hamerly gets for free — nearest of the other centres paired with
the largest movement among them — is unsound here, because d − δ is monotone in d and
cos(θ − φ) is not monotone in θ. The symmetric failure sits on the other side: past
θ + φ = π the cosine turns back upward and cos(θ + φ) stops being a lower bound. Both are
guarded, both guards have a test that fails when the guard is removed.
Median of seeds 0/1/2, one BLAS thread, timing the head alone (max_iter=100 minus max_iter=1,
which never reaches the skip test, so the tree build and the first assignment pass cancel):
| data | leaves | k | head, no skip | head, bounded | speed-up |
|---|---|---|---|---|---|
| 100 k × 20-D blobs | 32000 | 10 | 0.28 s | 0.09 s | 3.04× |
| 100 k × 20-D blobs | 32000 | 50 | 3.55 s | 1.55 s | 2.29× |
| 100 k × 20-D blobs | 32000 | 200 | 12.01 s | 7.34 s | 1.64× |
| MNIST 70 k × 784-D | 8000 | 10 | 3.41 s | 2.61 s | 1.31× |
| MNIST 70 k × 784-D | 8000 | 50 | 13.82 s | 11.27 s | 1.23× |
| MNIST 70 k × 784-D | 8000 | 200 | 82.76 s | 68.53 s | 1.21× |
The bound is a dimension effect, not a k effect, and the table says so twice over: it is worth
3× at d = 20 and 1.2–1.3× at d = 784, and within each dataset the gain falls as k grows.
In high dimension the cosines from one leaf to every centre concentrate, low ≥ high almost never
holds, and what is left is the bookkeeping. This is the same concentration of measure that makes
the Euclidean path collapse on MNIST — it is not a defect of this implementation, and no amount of
tightening will make Hamerly pay there. method="vmf" shows no gain outside measurement noise
(1.05× against a base-measurement spread of the same size), which is the expected answer: its
E-step is soft, every leaf needs all k responsibilities, and only the one-restart spherical
k-means warm start is accelerated at all.
- The CF k-means++ sampling weight now carries the leaf's own scatter. Seeding drew each
candidate centre proportional to n_i·D²_i — the point-level k-means++ weight, with the leaf
treated as a point at its mean. The exact CF-adapted potential is Lang's Eq. 5.4,
S_i + n_i·D²_i, and the missing S_i is not a rounding detail: a wide leaf sitting on an
already-chosen centre has D²_i = 0 and so was unsamplable however much scatter it held. The
greedy candidate score inside the trial loop is unchanged and correct as it was — it differs from
the exact potential by Σ_i S_i, which is constant in the candidate. cop_kmeans gets the same
weight, over a chunklet scatter assembled by König-Huygens (Σ S_i + Σ n_i‖μ_i − c‖²), so with no
must-links it seeds identically to kmeans.
This is on correctness grounds and claims no quality gain — it relabels output and the
benchmark moved in both directions. It reaches kmeans, gmm, gmm-full, spectral, xmeans
and cop_kmeans; ward, leiden, hdbscan, vmf and spherical-kmeans (which has its own
angular seeder) are untouched, and every scikit-learn row reproduced to the last digit. It is a
no-op wherever the summary is lossless: at max_leaves ≥ N every leaf holds one point, S_i
is 0, and the weight is bit-identical to the old one — which is why the whole synthetic quality
table and every digits row are unchanged, and it is a useful self-check that a benchmark budget
actually compresses.
Where it does bite, over three seeds (bench/RESULTS.md re-measured whole): covtype spectral
0.037 → 0.100 and MNIST spectral 0.155 → 0.203; MNIST kmeans 0.302 → 0.307; covtype
kmeans 0.088 → 0.074, gmm 0.088 → 0.076. At 16 000 leaves covtype gmm goes 0.094 → 0.104
and kmeans 0.078 → 0.067. The covtype GMM result the README quoted at the default budget was a
win by 0.008 and is now a loss by 0.004 — both sit inside two nearly coincident seed ranges
(0.055–0.096 against 0.055–0.102), so the honest reading is that it was a tie before and is a tie
now, and the page says so. The claim that survives is the one at adequate leaf resolution, which
got stronger.
It also inverted the refine= MNIST result above: with the new seeding Phase 3 already lands at
ARI 0.315 rather than 0.275, and twenty Lloyd sweeps now lower the objective while lowering the
ARI. That entry was re-measured rather than left standing.
A prior sweep of ELKI's three CFInitWeight options at 18×/6×/2× compression over five seeds
found no consistent winner and every difference inside the seed spread
(local/scratch/elki/init_weight_ab.py); Lang's thesis separates them only at RMSD ≈ 0.005 with
±0.004 confidence intervals. This change is made because the formula is the right one, not because
it scores better.
- min_samples now counts the microcluster itself in method="hdbscan", so min_samples=1
leaves every core distance at 0 and HDBSCAN* degenerates to single linkage. The convention is a
genuine split in the field — Campello, Moulavi & Sander's Def. 3.1, sklearn.cluster.HDBSCAN and
ELKI (whose parameter reads "including this point") all include the object, while
scikit-learn-contrib/hdbscan excludes it — and it was previously stated nowhere: not in the Rust
docs, the .pyi or docs/USAGE.md. The shipped behaviour was the exclusive one, so
min_samples=5 acted like the standard min_samples=6, and the published benchmark compared our
effective 11 against sklearn-hdbscan's 10.
Aligning with the majority — and with the library this project mirrors and benchmarks against —
makes the comparison like-for-like. This relabels method="hdbscan" output: to keep the old
behaviour, add one to min_samples. The convention is now pinned by a test on the core distances
themselves rather than inferred from a downstream partition.
The four quality tables were re-measured over three seeds after the change. Cell by cell against
the previous run, only betula-hdbscan rows moved — every other method reproduced to the last
digit on every metric — so the deltas are attributable to this and nothing else. digits goes from
a loss to a win (ARI 0.146 → 0.164 against sklearn-hdbscan's 0.149, both zero-spread, with
the noise fraction down from 0.620 to 0.580); blobs 0.142 → 0.154 and varied 0.519 → 0.536;
aniso 0.569 → 0.568 and covtype +0.0001 are ties; MNIST stays all-noise.
- The three distance kernels get a hand-written AVX2 + FMA path: 1.38x on a 20-dimensional
kmeansfit, 1.59x on a 784-dimensionalgmmfit, byte-identical labels.src/kernels.rssaid its reductions were "plain inlinable loops the compiler vectorizes at each call site".perf annotateon the symbol holding 31.5% of a 1 000 000 x 20kmeansprofile returns onlysubsd,mulsdandaddsd-- not one packed instruction -- so the claim was wrong, and the doc is corrected along with the code.
It is wrong for a reason no build flag can fix. a.iter().zip(b).map(...).sum() is
Iterator::sum, a strictly-ordered left fold; IEEE addition is not associative; LLVM may therefore
not reassociate the reduction, and without reassociation there is nothing legal to pack.
Measured on this machine against the shipped scalar fold: four independent accumulators at the
crate's baseline ISA give 0.93x at d = 20 -- a loss -- 1.13x at 64 and 1.46x at 784, while
explicit AVX2 + FMA gives 2.35x / 3.89x / 2.43x. Breaking the dependency chain is not the win;
the register width is, and at a baseline x86-64 target the only way to reach it is to write the
intrinsics and detect the feature at run time.
So sq_euclidean, dot and manhattan now dispatch to hand-written AVX2 kernels for f64 and
f32 when the CPU reports avx2 and fma, the vector is at least 16 wide, and fall back to
the identical scalar fold otherwise. Non-x86_64 targets and pre-AVX2 x86_64 are unaffected --
the fallback is the old code, and the choice is made at run time, so one wheel still serves every
machine.
The width gate is the part worth reading, because without it this was a regression. A
#[target_feature] function cannot be inlined into a caller without the same features, so taking
the packed path swaps an inlined loop for a real call plus a horizontal sum. At d = 20 that is
noise; at d = 2 the scalar body is two multiply-adds and the fixed cost is the whole function.
Ungated, kmeans on 500 000 x 2 measured 0.65x -- 1.55 times slower -- and 0.89x at d = 8.
Two-dimensional input is not hypothetical: the crate's own published scaling table is measured on
d = 2 blobs. The gate plus #[inline(never)] on the dispatch helper (both were needed; with the
helper inlined, d = 8 came out 1.26x slower than the ungated version it was meant to fix) leaves:
0.93x at d = 2, 0.92x at 8, 1.08x at 16, 1.24x at 20, 1.57x on 20 000 x 784 gmm, and 1.18x /
1.58x on float32 at d = 16 / 24. An unexplained ~8% residual below d = 16 is recorded in
ADR 003 rather than argued away; a three-build control puts build-to-build noise at +/-1%.
On float32 the label digest does change at d >= 24 -- but the ARI between the two label
vectors is exactly 1.000000. The partition is identical; only the cluster numbering permutes,
because k-means++ draws its seeds in a different order when a sampling weight moves by an f32
ulp. f64 digests are identical on every shape measured. A digest comparison alone would have
called this a behaviour change and an ARI alone would have hidden that the ids move; both numbers
are reported because they mean different things.
This is the crate's first unsafe, about 130 lines in one leaf module, and it is a decision
rather than a detail: ADR 003 records the alternatives, including the safe four-accumulator version
that the numbers rejected. Two preconditions are upheld one call frame above and both are tested --
the feature detection, and n = a.len().min(b.len()), which matters because CFTree::insert
accepts a point longer than the tree's dimension and an intrinsic loop bounded by a.len() would
read out of bounds rather than merely return a wrong number.
The acceptance gate was that nothing observable changes. Eight partial sums and an FMA round
differently from one serial chain, so an argmin decided by less than an ulp could in principle
flip, and identical labels are therefore a measurement and not a theorem. What was measured:
SHA-256 label digests identical on both profiled shapes across an A-B-A-B run; the whole Rust
suite, SciPy and ELKI cross-checks included; and a re-run of the entire quality benchmark, 75 of
whose 78 rows reproduce the committed per-seed table cell for cell. The three that moved are all
betula-hdbscan, and a direct A/B on exactly those cells returns identical label digests from both
builds -- they predate this change and belong to 835d05f, which made min_samples and
min_cluster_size count points rather than leaves and relabels the summary route by design.
method="gmm"hoists the log-variance term out of the leaf loop: 1.35x on the profiled shape, byte-identical labels. A profile came first, as the task required. On 20 000 x 784 withmethod="gmm"andmax_leaves=2000(perf record -F 997 --call-graph fp, 9 014 samples,RAYON_NUM_THREADS=1),gmm_diagonal_oncewas 34.9% of the run and__ieee754_log_fmaalone was 14.6% -- one library call, a seventh of the whole fit.
The cause is a loop-invariant in the innermost E-step statement: 1/2 log(2 pi sigma^2_cd) and
log w_c depend on the component c and the coordinate d, never on the leaf i, but they sat
inside the for i in 0..m loop that runs around them. At m = 1833, k = 10, d = 784 that is
14.4 million ln calls per iteration instead of 7 850. Hoisted to the top of the iteration the
cost becomes k*(d + 1).
The accumulation order is untouched -- the same addends are summed in the same sequence, only the
transcendentals are precomputed -- so this is not an approximation and the acceptance gate was that
it changes nothing. Measured A-B-A-B on the same build with only the hoist differing: 2.847s /
2.098s / 2.781s / 2.072s, 1.35x, with the SHA-256 label digest e887117b6aaa25c2 identical in
all four runs.
The same sweep over gmm-full, gmm-toeplitz and mppca found the identical pattern -- but there
only log w_c is left inside the leaf loop, k calls per leaf against an O(d^2) Mahalanobis
term, so hoisting it would buy nothing measurable and those loops are unchanged. Recorded here so
the next reader does not re-derive it.
- Every published table re-measured on the current build; the
hdbscanrows moved both ways. The committed CSVs predated835d05f, the commit that mademin_samplesandmin_cluster_sizecount points rather than leaves and whose own message says the summary route relabels. Nothing else in the record had caught up. Re-run over seeds 0/1/2: onlybetula-hdbscanquality cells moved, every other method reproducing cell for cell, which attributes the deltas to that commit and nothing else.
It is a trade, not a win, and both halves are published. mnist at n = 20 000 goes from
all-noise (ARI 0.000, zero clusters) to 0.117 with 49 clusters, and covtype from 21
clusters to 43 at a flat ARI -- the catastrophic case is fixed. But varied drops 0.536 → 0.479
with the cluster count going 2 → 73, and blobs 0.154 → 0.142: counting in points makes the size
gate roughly N/max_leaves times weaker at the benchmark's own settings, so the head now
over-splits where it used to under-split. The three-seed range on aniso is 0.016–0.993, which
is the honest summary of that row -- a regime without a stable answer rather than six numbers.
Speed moved too, and not uniformly. Streaming 20-D is 1.29× faster and the sparse pipeline
1.10–1.42×; the d = 2 scaling fixture is 0.92×, the AVX2 gate's residual cost on the one shape
in the suite where packed kernels cannot help; betula-hdbscan is 0.79–0.84×, the price of the same
units fix doing more work per core distance. Full covtype now clusters in 1.06 s against
scikit-learn's 4.92 s.
The scoreboard ratchet fired and was accepted deliberately rather than overwritten: one genuine
demotion (betula-nmf speed, win → tie, because scikit-learn's NMF got faster by more than ours
did) and six vs-best pairings that vanished rather than lost, because the non-betula champion
changed identity at 1 M. bench/RESULTS.md names each one.
Fixed¶
-
fit_predict_sparse(method="vmf")returned a single cluster. Labels change on every sparse fit with a directional head. A von Mises-Fisher density lives on the unit sphere, and the estimator knows it:Betula._buildforcesnormalize=Trueforvmf/spherical-kmeans. Butfit_predict_sparseis a module function that never passes through_build, so it handed the head raw TF-IDF rows.leaf_meansstates the precondition it needs — on L2-normalized data a leaf mean is a resultant of lengthR̄_i ≤ 1— and violating it makes‖μ_i‖a per-leaf multiplier onκ_c. That is a positive constant only whileκis shared, which is exactly why the same omission inspherical-kmeansis invisible (its argmax is a single similarity, and the norm cancels); with a fittedκper component it reweights the components against each other, and theMixturethe head hands back — which normalizes, as vMF requires — ends up describing a different model from the labels the head returned. Measured on a 6 000 × 4 000 block-topic corpus atmax_leaves=2048, converged after two EM iterations:κ = [3558, 3514, 3552, 1402], the head's own labels[502, 499, 503, 544], and the mixture reassigning all 2048 leaders to the one broad component — 1504 of 2048 leaves disagreeing with the fit they came from. Since 0.6.0 labels every mixture head by maximum posterior, that surfaced as one cluster for the whole corpus: ARI 0.000 against the dense tree's 1.000. The sparse entry point now L2-normalizes its rows for the directional heads, inO(nnz)— scaling the stored values is the whole operation, an implicit zero stays zero. The same fixture reads 0.995;spherical-kmeansis unchanged at 1.000 on both sides, as the cancellation argument predicts. Two regression tests, each failing on revert. -
The sparse path labelled the posterior and agglomerative heads by nearest micro-cluster, which answers a different question. Labels change for
gmm,vmf,wardand the other agglomerative heads on sparse input. Two rules replace it.gmmandvmfnow label by maximum posterior, the rule the dense path has used since 0.6.0: a diagonal Gaussian's quadratic form splits asΣ_j (x_j − μ_cj)²/σ²_cj = Σ_{j: x_j ≠ 0}(x_j² − 2x_jμ_cj)/σ²_cj + Σ_j μ²_cj/σ²_cj, and the second term is one number per component built once — so the density costsO(nnz·k), not theO(d)this path exists to avoid. A vMF density already reads only the non-zeros. The full-covariance, Toeplitz and probabilistic-PCA kernels have no such split (a Cholesky solve and a Woodbury projection both read every coordinate) and are refused rather than approximated. Everything else — the agglomerative heads, which have no point model at all — now keeps the label of the micro-cluster the summarisation put the row in, whichsummarize_sparsealready computed and threw away: it agrees with the summary by construction, it is the sparse counterpart of the dense path's point-to-leaf route, and it is free where re-deriving the nearest micro-cluster costO(nnz·L).
On the 6 000 × 4 000 block-topic corpus at max_leaves=2048, median of seeds 0/1/2: gmm
0.026 → 0.110, ward 0.022 → 0.068. Both are still bad, and the reason is the summary,
not the rule. The leader pass spends its budget at row 2048 and force-assigns the remaining 3 952
into 544 leaders, so three quarters of the mass sits in mixed micro-clusters; a head that models
each one separately inherits that, while kmeans (0.976) and spherical-kmeans (1.000) survive
because pooling thousands of leaders into k centroids averages it away. The dense tree scores
ward 0.987 at the same compression ratio, because it routes hierarchically and its leaves stay
pure. Raising threshold does not help — flat from 0.5 to 14 and worse above, since near-orthogonal
sparse rows give the gate nothing to merge. Recorded rather than papered over; the sparse path
documents it and points at projection="svd".
fit_predict_sparsereturned ARI ≈ 0 wherevermax_leaveswas smaller than the row count, and the dense path scored 1.000 on the same matrix. Labels change on every input that spends the leader budget. Two faults, one geometry. (a) Past the budget the leader pass dropped the proximity gate entirely and absorbed each further row into its nearest leader — but for near-orthogonal sparse rows a leader ofnmembers has‖μ‖² ≈ ‖x‖²/n, so its distance falls toward‖x‖²as it grows while a singleton's stays near2‖x‖². The first leader to take a second member was thereafter nearer to every remaining row, and it swallowed the stream: 4001 of 6000 rows in one leader, 1999 singletons beside it on theexamples/10_sparse_highdim.pyfixture atmax_leaves = 2048. A leader may now hold at most32 × (rows / max_leaves), the same constraint the dense tree offers asbalance=— soft, so a row still lands somewhere when every leader is full. The multiple was swept 1 → 256 against the uncapped path on two workloads that pull opposite ways; the table and the reason a tight cap is worse than none are insparse.rs. (b) Each row was then labelled by its nearest micro-cluster, an argmin of‖μ_i‖² − 2⟨x, μ_i⟩that overLcentroids of one to six sparse rows is decided by‖μ_i‖²— by how many terms those rows happened to carry — rather than by overlap. The centre-based heads (kmeans/xmeans/spherical-kmeans) now label each row by its nearest cluster centroid, which is the partition the head defines and the rule the dense estimator has used since 0.6.0; it is alsoO(nnz·k)against the oldO(nnz·L). Neither fix works alone — A/B'd on the shipped settings, that fixture stays at −0.000 with only (b) and reaches 0.078 with only (a); with both it is 0.976 against the dense tree's 1.000. On 20-newsgroups TF-IDF the raw sparse row goes 0.004 → 0.038 andprojection="svd"atmax_leaves=2048goes 0.152 → 0.195, the best that table has carried. The heads with no centre rule are dealt with in the entry above. Tables inbench/RESULTS.md.- Mapper dropped a microcluster outright when
lo + (hi − lo)rounded belowhi: the cover recomputed the last bin's upper edge from the step rather than from the observed range, so the leaf holding the maximum lens value could fall outside every bin. It happened on one seed in three of the fixture that found it. The outer bin edges are now the observed range. feature="fd"was reporting up to a third less scatter than the leaf holds. The Frequent-Directions shrink subtracts the lower-median squared singular value from every direction, and that mass was discarded outright — sossd(), which the radius, the absorption gate, D2/D3 and the k-means++ potential all read, under-reported the leaf. Measured share of the true scatter still reported: 0.670 atd=64, ℓ=16, 0.660 atd=784, ℓ=16, 0.897 atd=784, ℓ=64. The discarded trace is now banked, sossd()is exact — asserted against theFullfeature in a unit test and a property test, both of which fail on revert.
Handing that trace back to the covariance shape is a modelling choice, since the shrink destroys
the directions along with the magnitudes, and both candidates were measured on the gmm head.
Spreading it isotropically over all d is refuted: it loses in all six cells, by up to 0.32
ARI, because at d ≫ ℓ it fills hundreds of directions the sketch never saw. The shipped
completion scales the retained directions up to carry the trace, which gains +0.05…+0.09 ARI on
64-dimensional digits and costs −0.04…−0.09 on 784-dimensional MNIST — the opposite error, and
the smaller one. ward is byte-identical across all variants and kmeans moves only within seed
spread, so the gmm head is the only one that can see this at all. Table in
bench/RESULTS.md.
- A rebuild now refolds every node CF from its children instead of trusting the push chain.
A node CF is defined as the merge of its subtree, but insertion maintains it by pushing each
point along its leaf→root path — so the root holds one sequential chain of N merges, which is
exactly the O(N·u) accumulation Higham's analysis warns about. Measured on an f32 tree of
200 000 offset points, root scatter against the f64 fold of the same leaves, read right after a
rebuild: 5.1e-5 before, 6.7e-8 after — a factor of 750. This is not bookkeeping: descend
routes on node CFs, so their drift decides which leaf a point lands in. Labels can therefore move
on float32 trees; on float64 the correction is at the last bit.
The task this closes (#44) proposed pairwise accumulation, and that half is refuted:
Higham's O(m·u) vs O(log m · u) separation needs a long chain, and at the leaf counts this
library runs at (180–4000) a sequential fold of the leaves and a balanced one measure 2.2e-7
against 3.2e-7 — indistinguishable. What was worth 750× is folding from the children at all.
The halving is kept because it is free and the right shape, not because it was measured to matter.
Between rebuilds the root drifts again (5.1e-5 by the end of the same stream), so summary()
documents the trade and points at folding leaf_features() when the aggregate is the answer.
absorbwas validated in two places that had to agree and did not. TheBetulaestimator restated the accepted names and thechi2_scale > 0rule inline, next toresolve_gate's own copy of both. Adding a gate to one left the other rejecting it — precisely the drift the sharedABSORB_CHOICESconstant exists to prevent, which it could not, because it shared only the message and not the set. The eager check now delegates toresolve_gateand discards the gate it builds, so the names and the rule have exactly one owner. No behaviour change for existing values.method="hdbscan"countedmin_samplesandmin_cluster_sizein leaves, not in points.hdbscan.rsthresholded a leaf count while its stability term used point mass, so the two arguments meant one thing on a summary and another on one-feature-per-point, and nothing said so. The failure that surfaced it: atN = 500 000,max_leaves = 2000and the point-levelmin_cluster_size = 1250asklearn.cluster.HDBSCANuser would transfer unchanged, the head asked for 1 250 of 2 000 leaves, found no admissible split anywhere, and returned every point as noise with no warning — ARI 0.000. The threshold was also not scale-free, changing meaning whenevermax_leaveschanged, which is a tuning knob rather than a property of the data.
Both arguments now count points: the cluster-size gate reads the same node_mass the stability
term already used, and the core distance is the smallest radius enclosing min_samples points'
worth of weight rather than the min_samples-th nearest feature. On unit weights — one feature per
point — every quantity is an integer count and the behaviour is byte-identical, so nothing changes
for threshold=0.0, max_leaves >= N. On a summary the labels change, and for the better: the
n = 500 000 blobs benchmark row moves 0.000 → 0.478, which is exactly its n = 100 000 value —
the same question now gets the same answer at both scales.
A second finding, measured but not fixed here (task #72): min_samples below a single leaf's mass
is enclosed at radius zero, which collapses every core distance and degenerates HDBSCAN* to single
linkage. Set it above N / max_leaves; docs/USAGE.md now carries the curve and
bench/RESULTS.md the comparison against fast_hdbscan that exposed it.
- distance="average" panicked with an index-out-of-bounds instead of clustering. A node split
picks the farthest pair of children as seeds and then assigns every child to the nearer seed —
which silently assumes between(cf, cf) == 0. That holds for euclidean, manhattan and ward,
but not for average (D2): the average inter-point distance between a cluster and itself is
2·S/n, not zero. A seed with enough of its own scatter therefore measured further from itself
than from the other seed and joined the wrong group; once every child did that, the sibling node
was created with no children, and the next descend indexed children[0] on it. Reproduced on
load_digits at max_leaves ≥ 800 with any feature.
The seeds now anchor their own groups by construction, so both sides are non-empty whatever the
routing measure does — the same root cause the seed scan already guards against by skipping
i == j, one step later in the same function. This is the second half of that fix, and it is what
the non-metric CF distances of the planned linkage and Bregman work need in order to be safe.
Labels are unchanged on every metric route (48/48 (dataset × feature × head × distance ×
seed) label arrays hash identically across the fix), so nothing published moves.
Fixed¶
-
The head table said
method="kmeans"needs ak. It does not, and has not for some time.n_clusters=0runs an X-means BIC selection over the leaves, exactly as the table's own prose two lines below it already claimed for "the parametric heads". Nothing caught the contradiction because the wrapper's auto-ktest enumeratedgmm/gmm-full/mppca/wardand omittedkmeans; it now coverskmeanstoo, and fails on a build with the auto arm removed. Behaviour is unchanged — only the documentation was wrong. Thexmeansdoc comment claimed its BIC is minimised while the code maximises it, which is the opposite of the convention the five other BIC sites in the crate use; it now says so. -
The BIC behind
method="kmeans"withn_clusters=0dropped the within-leaf scatter, and would answerk = n_leaveson any input allowed to look that far. Labels change. The score summed only the between-leaf part,Σ_l n_l‖μ_l − c‖², under the reasoning that the within-leaf scatterΣ_l S_lis the same for everykand therefore cancels. It is constant, but it does not cancel: it sits insideln σ̂², so the two scores differ by−½·nr·d·ln((W_k − C)/W_k), which depends onkthroughW_k. Worse, askreaches the leaf count the between-leaf part goes to zero,σ̂²hits its1e-12floor and the BIC diverges to+∞— one cluster per leaf beats every real answer. The correct quantity is the fullΣ_l (S_l + n_l‖μ_l − c‖²), the CF identity, which is exact for any whole-leaf partition and is already whatKMeans::inertiaaccumulates. Nothing caught it becauseAUTO_K_MAX = 20capped every shipped path far below the leaf count and the three tests covering the sweep all ran atk_max ≤ 8against 100+ leaves; it surfaced only once a head whose cap is the leaf count went looking. On a 24-leaf, 6-blob fixture in 10-D the sweep selected 24 before and 6 after, and the regression test fails on a build with the term removed.
Notes¶
- CURE α-shrinkage (Guha, Rastogi & Shim 1998) was implemented, measured and refuted; not shipped.
The full head — farthest-first representatives per cluster, shrunk toward the cluster centroid by
α, linked by the closest pair — dropped straight onto the Anderberg driver, and its anchors hold (α = 1reproduces UPGMC exactly at everyk). It still does not earn a place. Against the heads already here atmax_leaves=600, median of seeds 0/1/2:moons(noise 0.06) 1.000 in 0.018 s, whichhdbscanalso reads in 0.013 s without needingkorα;circles1.000, same story; and thenmoonsat noise 0.10 — the same generator, one step noisier — 0.039 againstward's 0.516 andspectral's 0.733, and 5 % uniform noise over blobs 0.008 againstward's 0.691. It never beats a shipped head on any fixture measured.
α is the reason. On moons-.06 it reads 0.032 at α = 0.05, 1.000 at α = 0.10 and 0.044 at
α = 0.15; the window moves with the representative count (α = 0.05: 0.032 at c = 10, 1.000 at
c = 20, 0.000 at c = 40) and with the leaf budget (0.983 at 300 leaves, 0.032 at 600, 0.964 at
1200). A setting that narrow and that unstable can only be found by scoring against the labels
being computed.
The mechanism is a fact about cluster features rather than about CURE, and it is the part worth
keeping. The dendrogram is right and the cut is wrong: at α = 0.05 and k = 2 the head returns
clusters of 5999 and 1 points, while k = 3 returns 3000 / 2999 / 1 at ARI 0.982. That is
single linkage's outlier failure, and shrinkage is precisely Guha's cure for it — but on a summary
an outlier is a low-mass leaf, so the linkage meets it as a cluster of one member with one
representative sitting on its own centroid, where (1 − α)·μ + α·μ = μ for every α. Shrinkage
damps an outlier inside a cluster and can never damp an outlier that is a cluster. The paper's
own outlier-elimination stage, transferred to leaf mass, rescues the clean fixtures and leaves the
noisy ones ranked backwards: on blobs + noise the best cell is α = 1.0 (0.794), which is plain
centroid linkage with no CURE in it at all. Any future rim-based head here needs a mass-based
answer to that, not a geometric one.
[0.7.0] — 2026-08-23¶
Never published, and the heading below is deliberately not a link. The version was bumped
and this section written, but no v0.7.0 tag was ever created, so nothing here reached PyPI,
crates.io or Zenodo and there is no commit range to compare against. Everything in it ships in
0.8.0, whose compare link spans v0.6.0...v0.8.0 and therefore covers both. The section is kept
as the record of what that heading described.
Changed¶
- Rust 2024 edition; minimum supported Rust version 1.82 → 1.85. The migration needed two source
changes in total (a binding mode made explicit in
gmm_toeplitz.rs); the rest isrustfmt's 2024 style edition collapsing shortif/elseonto one line. 1.85 is the edition's own floor. - A rebuild no longer rebalances the whole tree every time it compacts. 0.6.0 set the leaf count
exactly by merging the
kclosest sibling pairs, which is also the policy that grows the absorption threshold by the smallest amount that reaches the count — so the 10 % headroom refilled almost at once and the rebuild fired again. Each one then called the rebalance pass, which clears the node and entry arenas and re-routes every surviving entry: on 1 M blobs atmax_leaves=4000that is 385 rebuilds × ~3600 entries ≈ 1.4 M descents on top of the 1 M point inserts, and it madefit~3.2× slower than 0.5.0 on the tree-dominated path. A profile hides it, because the rebuild's cost isdescendandtry_absorb.
Compaction merges strictly inside a leaf, so mass is conserved per node and the tree is already
valid when it finishes — the rebalance is a re-partitioning of leaves that mix two clusters, not a
repair, and the drift it corrects accumulates with merges rather than with compactions. It now
runs once the merges since the last rebalance reach max_leaves. Measured on 1 M blobs at
max_leaves=4000, median of 5 fits, arms alternated on an idle machine: 0.743 s → 0.230 s,
385 rebuilds → 31, leaf utilization 92.4 % → 92.6 %, ARI 0.8597 → 0.8598. Labels move wherever a
rebuild actually fires — three seeds each, median [min–max]:
| 0.6.0 | this release | |
|---|---|---|
mnist-20k kmeans |
0.2275 [0.1719–0.2510] | 0.2571 [0.2275–0.3263] |
mnist-20k gmm |
0.2842 [0.2547–0.2842] | 0.2788 [0.2668–0.2788] |
mnist-20k ward |
0.3272 | 0.3666 |
covtype-20k kmeans |
0.0528 [0.0506–0.1061] | 0.0878 [0.0755–0.0999] |
covtype-20k gmm |
0.0843 [0.0782–0.0962] | 0.0882 [0.0820–0.1129] |
covtype-20k ward |
0.0910 | 0.0910 |
digits does not move: at n = 1797 against a 4000-leaf budget it never rebuilds. Widening the
compaction margin to max_leaves / 6 was measured as the alternative and rejected — it reaches
only 0.369 s and takes covtype utilization down to 83.7 %, which is the 90–99 % band the exact-count
compaction was written to provide. CFTree gains one usize field, #[serde(default)], so
persistence snapshots written by 0.6.0 still load and SCHEMA_VERSION stays at 2 — verified by
a snapshot from the released 0.6.0 wheel, committed at tests/data/v2_0.6.0.betula alongside the
attributes it should reproduce. test_save_load_roundtrip writes and reads with the same build
and cannot see a schema break at all; dropping the serde(default) makes the new test fail with
missing field merged_since_rebalance, which is the claim itself.
Fixed¶
- The
vmfhead reported the same concentration for every tight cluster in high dimension.κwas clamped at10⁴, which in 784 dimensions binds fromR̄ ≈ 0.9616— ordinary for embedding data. AtR̄ = 0.99the clamp understated the Banerjee estimate 3.9×, and atR̄ = 0.99939× (trueκ ≈ 3.91·10⁵, reported10⁴). Every saturated component therefore carried an identical, wrongκ, which biasesmovmf_auto's BIC uniformly and can move thekit selects.
The comment blamed numerical stability; the cause was cost. log I_ν(κ) came from an ascending
power series whose peak term sits at m ≈ κ/2, so it is O(κ) inside the EM loop, and its own
m > 200_000 stop truncates before the peak above κ ≈ 4·10⁵ — raising the cap alone would have
traded a wrong answer for a slow one and then for a wrong one again. log_iv now branches at
κ = 10⁴ to DLMF 10.41.3, the uniform asymptotic expansion for large order, which is O(1) and
correctly rounded there — measured against 50-digit arithmetic over ν ∈ [1, 2047],
κ ∈ [10⁴, 10⁶], the f64 result is within 0.8 ulp. The cap rises to 10⁶, set by
representability rather than by the normalizer. The expansion divides by ν = d/2 − 1 and so does
not exist below d = 4; there the series remains the only evaluator and the cap stays at 10⁴.
Labels move for vmf in high dimension, and n_clusters=0 can select a different component
count. No published benchmark table uses the head, so no committed number changes.
- predict could not return a cluster whose radius is zero. The Voronoi rule that predict
applies for the centroid heads (kmeans, spherical-kmeans, ward, …) filters out clusters with
no weight, but the stats helper it reads yields radii before weights for clusters — the opposite
of the order it uses for leaves — and the rule destructured it in leaf order. Every degenerate
cluster (a singleton, or one whose members coincide) was therefore dropped from the rule and became
unreachable: the point sitting exactly on such a centre was labelled with some other cluster, at a
distance three orders of magnitude larger. Only the filter was wrong; the centres themselves,
cluster_sizes_ and cluster_radii_ were always correct.
- Leiden was not reproducible: the same graph and the same seed gave different communities on
successive calls in one process. refine (and, on a tied graph, one_level) picked its target
sub-community by scanning a HashMap, and kept whichever tied candidate the iterator produced
first. std::collections::HashMap draws a fresh RandomState seed per instance, so the candidate
order — and with it the tie-break — changed between calls; seed only ever controlled the node
visit order. aggregate had the same problem one level down: it emitted each super-node's
adjacency row in HashMap order, and since floating-point addition is not associative, the
weighted degree summed from that row differed run to run, moving every gain computed from it.
Both now scan in community / neighbour index order. Labels change for graphs that carry tied
gains — symmetric ones above all — and are now stable. Measured: reverting the three hunks in place
and re-running every betula-leiden row of bench/results_quality.csv and bench/results_real.csv
against the same build reproduces all nine to the last digit, so no published benchmark number moves
with this fix.
- Documentation shipped with 0.6.0 still described 0.5.0. The verified-suite line in README.md
and DESIGN.md claimed 185 Rust + a 213-case Python suite; the tree now carries 404 Rust unit +
4 integration tests (402 + 4 with --no-default-features, 8 more for the cli binary) and a
230-case Python suite (229 passed / 1 skipped without the optional scipy / networkx test
dependencies). DESIGN.md also still described predict_proba as the per-leaf responsibility
matrix, which 0.6.0 replaced with scoring the row itself under the fitted mixture, and its crate
layout predated src/mixture.rs. docs/api.md omitted consensus / ConsensusResult although
both are exported from __all__; SECURITY.md still named 0.1.x as the supported line.
- Every published benchmark number is re-measured, and several move down. bench/RESULTS.md, all
eight results_*.csv, the four plots and the README headline were a single run of 2026-07-18
against a 0.2.0 build. They are now the median of seeds 0, 1, 2 against this tree, with per-cell
min/median/max in new results_*_spread.csv sidecars and the seed list in results_seeds.json —
because on the synthetic sets every row moves by more than 0.05 ARI across three seeds, and the old
single-run page had in several places published the top of that range. Corrections worth naming:
digits k-means is a tie (0.467 vs scikit-learn's 0.468, not 0.568 vs 0.468); the spectral head
matches SpectralClustering at 1.0–1.5× its speed, not 3–5× (betula's time barely moved,
scikit-learn's fell threefold between versions); normalize=True on MNIST is now a wash — its
0.203 baseline is gone and the off-vs-on sign flips between seeds — though it still earns its place
on digits (k-means 0.467 → 0.569); and CF-weighted NMF is slower than scikit-learn's at every
N measured, its win being the ±0.00 seed spread against ±0.37. The covtype loss to
sklearn-birch is confirmed as a loss on the merits: matching Birch's compression ratio leaves its
ARI unchanged (0.132 vs 0.131) and handing betula Birch's 11 774 leaves makes every head worse. The
23 notebooks still carry 0.2.0-era outputs and their provenance banners still say so.
- References to ../../math_improove/ in DESIGN.md, src/distance.rs, src/feature.rs and
src/stats.rs pointed outside the repository; they now say the working notes are local-only.
- CI on macOS and Windows broke the moment .python-version was added. That job builds a
deliberate 3.12 environment (uv venv --python 3.12) and installs its tools into it; with a
repo-wide 3.14 pin present, a bare uv run no longer accepts that environment, recreates it, and
discards the tools — error: Failed to spawn: maturin. Both uv run calls there, and the two in
the mutmut job that would have hit the same wall on its next schedule, now pass --no-sync.
- Mutation testing had never once produced a result, and covered a third of the crate. All seven
scheduled runs since 2026-07-06 were killed by the 90-minute cap: measured on the last of them, 90
minutes at -j2 completed 98 of 1011 mutants (9.7%). Sharding alone does not fix it — a
12-way split still leaves ~115 mutants per job against a suite where a single
method-dispatch test costs 25 of its 27 seconds, and every mutant pays it in full.
--test-tool nextest does, because it stops at the first failing test: measured A/B over the 13
mutants of src/kernels.rs at -j4, cargo test took 525 s and reported 8 caught with 2
timeouts, nextest took 144 s and reported 10 caught with none — 3.6× faster and more
accurate, since both former timeouts resolve to caught. That makes full coverage affordable, so
examine_globs is gone: the scope is now the whole crate, 4310 mutants (still excluding
python.rs and bin/, which have no Rust-level tests and would only yield false survivors),
split 24 ways at ~180 mutants per job. A summary job collates every shard and fails loudly on a
survivor. .cargo/mutants.toml records the per-module mutant counts and the measured throughput
so the matrix can be resized from numbers rather than guesses.
- Four mutants had been surviving in src/distance.rs unread inside a cancelled run's artifact.
Radius::between was pinned only by a case where na·nb and na/nb coincide (2·1) and where one
scatter was zero, so *→/ and +→- both went unnoticed; MahalanobisChi2::maha_sq was only
ever exercised at mean zero, where x − μ and x + μ agree, so the sign of the residual was
unconstrained. Two tests now pin both. The fourth (||→&& in VarianceIncrease::between) was
an equivalent mutant — with exactly one weight zero the formula already returns zero — so the
guard is now written on the sum, nab = na + nb, matching Radius::between directly below it.
Same result for every input; the operator is simply no longer free.
- The mutation job failed whenever any mutant survived, which meant it never carried a signal.
That sounds like the strict setting, and it is the useless one: the crate has never had an empty
survivor set — some survivors are provably equivalent mutants and no test can kill those — so the
job was red every single week and a new hole was indistinguishable from the standing debt, which
is exactly how the four src/distance.rs survivors above went unnoticed from July to August. It
now fails on survivors that are not in a committed mutants-baseline.txt. That file records the
set keyed file:line:col, grouped by module, each group carrying either the argument for why no
test can close it or the constraint a killing fixture would have to satisfy — a claim of
equivalence without an argument does not belong in it. The summary job reports both comm
directions, so an entry that has since been killed surfaces as "trim this" rather than quietly
padding the debt. Two failure modes of the job itself are closed with it: a shard that uploads no
artifact is now an error rather than a smaller total that reads as good news (run 32347677860 lost
shard 39 after 88 minutes against a ~20-minute median, and 95 of 96 collated into a number that
looked complete), and the matrix is capped at max-parallel: 12, below the account's 20-slot
limit, after the uncapped 96-shard run left ordinary CI of the same commit queued for two hours.
- Reference and boundary fixtures close 48 of the recorded survivors — mutants-baseline.txt
goes from 361 entries to 313, with src/tree.rs at 22 → 2, src/clustering/vmf.rs at 38 → 19,
src/clustering/gmm.rs at 19 → 13, src/clustering/nmf.rs at 15 → 13 and
src/clustering/kprototypes.rs at 4 → 3. Each fixture is verified the only way that means
anything: the mutation is applied at its own line:col, with an assertion that the line still
holds what the entry says it does, and the test is confirmed to fail. Four of them closed
mechanisms nothing had constrained. randomized_svd had no reference check at all — the range finder is deliberately
insensitive to its own sketch, so an end-to-end NMF assertion cannot see whether the sketch or the
power iterations are computed correctly — and is now compared against a dense symmetric
eigendecomposition of XᵀX and the Eckart–Young optimum it yields. A rebuild's grown absorption
threshold, widest · (1 + 4ε), decides absorption for the whole remainder of a run and was pinned
by nothing. And split seeded both its sides from a scan that, one +→* away, compares a child
with itself: harmless under CentroidEuclidean, where a self-distance is zero, but Radius and
AverageIntercluster both carry a child's own scatter into between, so a high-scatter child is
further from itself than any two children are from each other and the split degenerates into a
size tie-break that separates coincident entries. spherical_lloyd had the same shape of hole from
the other side: its independent reference carries the same if !changed && it > 0 { break; } line,
so no comparison against it can see that guard at all — what can is the Lloyd fixed-point condition
itself, asserted on a fixture slow enough to still be moving at the second assignment. The Rust
suite is 399 lib + 4 integration tests as a result.
0.6.0 — 2026-08-19¶
Fixed¶
predictreturned the label of an approximately-found leaf, not the label of the model's own partition. k-means assigns a point to its nearest centre; the labels came instead from routing the point down the CF-tree to a leaf and reading that leaf's cluster. The descent is greedy, so it is an approximate nearest-microcluster search, and in high dimension it is wrong often: measured disagreement with the model's partition of 2.7% (digits), 14.9% (MNIST-20k) and 18.1% (20-newsgroups TF-IDF) of points, and on TF-IDF only 14 of the 20 clusters the head found were reachable by descent at all — the estimator reportedn_clusters_ = 20whilepredictcould never emit six of them. The argmin overkcentres is also the cheaper of the two atk ≪ n_leaves. Measured ARI,max_leaves=2000, median of 3 seeds: MNIST-20kkmeans0.397 → 0.417, 20-newsgroupskmeans0.050 → 0.060 andspherical-kmeans0.071 → 0.099, digitskmeans0.667 → 0.663. Applies tomethod="kmeans"and"spherical-kmeans", whose objective is "assign to the nearest centre" (the spherical head compares unit-normalized centres, where the Euclidean argmin and the cosine argmax agree). Every other head keeps the microcluster route: the mixture heads (GMM, Toeplitz-GMM, movMF) assign by maximum posterior — which weighs each component by its own covariance and mixing weight, a different partition rather than a slower route to the same one — and Ward / Spectral / Leiden / HDBSCAN / scale-space clusters need not be convex at all. Constrained runs (fit_constrained) also keep it, since a Voronoi rule is free to violate the pairwise constraints COP-KMeans satisfied. Measured separately: a nearest-centre rule would raise the GMM heads too (digits 0.504 → 0.576, MNIST-20k 0.343 → 0.418, 20news 0.053 → 0.073), which makes a true max-posteriorpredictfor them the obvious follow-up — see the next entry, which ships it.- The mixture heads now label a point by maximum posterior instead of by tree descent.
gmm,gmm-full,vmfandgmm-toeplitz{,-full,-gs}score the point itself under the mixture they converged to —argmax_c [ln π_c + ln p(x | θ_c)]— rather than routing it to a leaf and copying that leaf's label. Each head hands its converged parameters to aMixture(diagonal / full-Cholesky / stationary / vMF kernels), so the density that labels a point is the density that was fitted: the floored diagonal variances, the ridge-regularized Cholesky, the AR predictor bank.predict_probanormalizes the same scores, sopredict_proba(X).argmax(1) == predict(X)by construction, and it now scores the row rather than the row's microcluster. A component that no leaf claims is silenced, sopredictcan only emit a label the fitted partition uses. Measured ARI, median of 3 seeds, defaults (max_leaves=2000), old rule → new rule: 20-newsgroups TF-IDFgmm0.027 → 0.054 (72.1% relabelled) andvmf0.021 → 0.026, digitsgmm0.489 → 0.507,gmm-full0.722 → 0.754,vmf0.643 → 0.631, blobsgmm1.000 → 1.000. It costs accuracy on raw image pixels at a fine leaf budget, and that is not hidden here: MNIST-20kgmm0.340 → 0.185 (52.1% relabelled). The cause is the model, not the rule: on the same fit a nearest-centre rule, which ignores the covariance entirely, scores 0.378 — above both — so it is the fitted covariance that costs the ARI. A diagonal covariance treats 784 correlated pixels as independent, so scoring a raw point sums 784 separate penalties and the per-dimension modelling error accumulates; the leaf-level score damps this through the−½ tr(Σ_c⁻¹ Σ_i)term, which a single observation genuinely does not have, and the coarser the leaves the more that term smooths. The loss is accordingly budget-specific rather than head-specific: atmax_leaves=300the new rule wins forgmm(0.206 → 0.239) and forgmm-full(0.207 → 0.212) alike. For raw images preferkmeansor aprojection. - The diagonal GMM could drive
1/σ²to1e12in a dimension with no spread. The variance floor was expressed purely relative to the spread of the dimension itself (1e-3 · gvar_d + 1e-12), so a constant or near-constant dimension — an always-zero border pixel, a term absent from every document — got a floor of essentially zero and its precision was bounded only by1e-12. Compounding it,global_variancesummed only the between-leaf term while its own doc comment claimed between + within, so the scale feeding both the floor and the NIG prior was under-estimated in proportion to how hard the tree had compressed. Neither showed while the density was only ever evaluated at leaf means, which sit close to any component mean; a raw observation does not. The floor now also carries a global term (VAR_FLOOR_REL · mean_d gvar_d, mirroring the full head'sridge = 1e-6 · tr(gcov)/d) andglobal_varianceadds the per-leaf term, asglobal_covalways did. Measured ARI, median of 3 seeds, before → after, as leaf-descent / max-posterior: MNIST-20kgmm0.317 → 0.340 / 0.135 → 0.176, 20-newsgroupsgmm0.029 → 0.027 / 0.051 → 0.059, digitsgmm0.471 → 0.479 / 0.484 → 0.496.gmm-full,vmfand blobs are byte-identical, as they must be — they never used this floor. The fix is real but partial: it lifts the MNIST point-level ARI by 30% relative and still leaves it below leaf descent, for the modelling reason given in the entry above. - Both GMM heads seeded every component with the global covariance, so a junk dimension could
decide the first E-step.
gmmstarted each component's variance atmax(gvar_d, floor_d)andgmm-fullatgcov— the spread of the whole dataset. In a dimension that separates the clusters that spread is dominated by the between-cluster distance and says little about the within-component scatter: on four blobs eight units apart it overstates it 34×. A dimension whose spread is already purely within-component — a sparse binary feature, a near-constant pixel — gets no such inflation, so a uniform-looking seed is in fact a skewed metric that weighs the junk dimension up to 889× more per dimension, and one spike there costs more (51.7) than the entire blob separation (3.9). Both heads then converge with every component mean sitting on the global mean. Reproduced with four trivially separable blobs plus twelve binary columns that are 1 with probability 0.02: ARI 0.000 → 1.000 (gmm) and 0.001 → 0.985 (gmm-full), whilekmeans,wardandhdbscanscored 1.000 on the very same CF-tree throughout — as did scikit-learn'sGaussianMixture(covariance_type="diag"), which seeds from its k-means partition. Each component is now seeded from its own k-means cluster (the same within-cluster accumulation the M-step performs, the same floor and ridge, the global value as the fallback for an empty cluster). On real data this lands EM in a different local optimum and the effect is small in both directions (max-posterior ARI, median of 3 seeds): digitsgmm0.496 → 0.507 and MNIST-20kgmm0.176 → 0.185, against digitsgmm-full0.777 → 0.754 and 20-newsgroupsgmm0.059 → 0.054.vmfnever had the defect — it seeds κ from the hard assignment — andgmm-toeplitzstarts from random responsibilities; both are unchanged to the last digit, as are all non-mixture heads. - CF-tree: the rebuild spent a third of the leaf budget, and collapsed the tree outright in high
dimension.
max_leavesis a resolution budget — the summary handed to the global clustering is only as fine as the leaves actually kept — but the rebuild grew the threshold to the mean within-leaf nearest-sibling gap and then merged whatever fell under it, which is a prediction, not a target. Measured utilization of the budget: 65.6–96.2% (mean 80.6%) across digits, blobs at n = 20 000/100 000 and d = 10/50, and uniform d = 20. On 3000-dimensional TF-IDF it failed catastrophically instead — 3 leaves against a 2000 budget, one of them holding 85.7% of the mass, ARI 0.0 for every method and every seed. The cause is concentration of measure: the achievable leaf count is near-discontinuous in the threshold (7755 leaves atthreshold=1.0, 12 at1.3), so no threshold-first policy has a safe value there. The rebuild now merges thekclosest sibling pairs withkset by the budget and reads the grown threshold off the widest gap it took —kis exact, and merging is capped at one pair per entry, so the cliff cannot be stepped over. Utilization is now 90.0–99.0% (mean 93.5%) on the same datasets and 90.0/94.1% on TF-IDF (was 0.6/0.2%), where ARI goes 0.0 → 0.071 with thespherical-kmeanshead. - CF-tree: the rebuild conflated reducing the entry count with rebalancing the node structure.
Merging two entries inside their own leaf node leaves every node CF in the tree exactly unchanged —
a node's CF is the merge of its subtree, and merging two of its children does not change that
multiset union — so mass is conserved per node, no ancestor needs touching, and no leaf can be
emptied. The count reduction is therefore done in place, in one
O(Σ_leaf child_count²)scan plus anO(m log m)sort, and the reinsertion pass that follows now merges nothing: it only re-routes entries so that leaves re-partition around the geometry the data actually has, which compaction alone cannot do (it can shrink a leaf that mixes two clusters but never split it). Keeping absorption on during that pass is what walks off the concentration cliff — measured, it collapses a d = 50 blob mixture to 9 leaves against a 500 budget. Rebuilds became more frequent (each shaves 10% rather than overshooting) and individually cheaper; end-to-end build time moved −6% to +98% across the eight configurations, with the two largest (blobs n = 100 000, d = 50) essentially unchanged at +1% and +3%.n_rebuilds_counts a different, cheaper unit of work than before and is not comparable across the change.
Quality follows the head, not the leaf count, and the finer summary is not uniformly better: on
digits (5 seeds, median) the kmeans head goes 0.646 → 0.667 while the gmm head goes
0.593 → 0.458 at max_leaves=500 — the diagonal-covariance head degrades as leaves get smaller
and is best on that dataset at ~230 leaves, which is now a max_leaves choice rather than something
the rebuild does behind the caller's back. Blob mixtures stay at ARI 1.000 throughout.
- CF-weighted NMF: the solver stopped after 5 sweeps regardless of max_iter. The convergence test
compared |prev − err| against tol · prev with prev seeded at +inf; IEEE-754 says inf <= inf,
so the first check always fired and the iteration budget was dead code. Every other EM loop in the
crate guards this with it > 0; nmf.rs was the only one that did not. The budget is now honoured,
and a regression test asserts the residual keeps falling as the budget grows.
- CF-weighted NMF: most components collapsed to zero and never recovered. Two independent causes,
both fatal because zero is an absorbing state for HALS and for the multiplicative updates. (1) The
randomized SVD recovers the right vector as v = Bᵀu/σ; below the noise floor that division amplified
round-off into a vector of arbitrary magnitude, so a rank-deficient triplet seeded a component wildly
out of scale. Such triplets are now cut at the LAPACK numerical-rank threshold and reported as exact
zeros. (2) NNDSVD's zero-fill used mean(X), but a filled component is a rank-1 block of constant
magnitude and r of them add up, so the fill swamped the data as the rank grew. The init now uses the
ar fill, mean(X)·U(0,1)/100 — small enough to stay a perturbation, random enough to break the
degeneracy a constant fill creates. Measured on a rank-12 matrix at rank 32: initial relative residual
13.5 → 1.2, dead components 28/32 → 3/32 (3 is the honest count — the data is rank 12), and the
converged residual 270× lower. On digits at rank 24: 15/24 → 0 dead, reconstruction error
0.33 → 0.20, downstream ARI 0.54 → 0.61. The reconstruction error now matches scikit-learn's
own NMF to three decimals at every rank tested (10, 16, 24, 32), against being 2.3× worse before.
- CF-weighted NMF: an arbitrary per-component scale reweighted the downstream clustering. NMF is
invariant to (W D, D⁻¹H), so the optimizer left whatever split it landed on — measured spreads of
70× between component scales on a converged fit. Because the codes are consumed as a Euclidean
feature vector by the Phase-3 head, that scale acts as a per-dimension weight and the head clustered
along whichever component drew the largest number. The factorization is now canonical (component rows
unit-L2, scale absorbed into the codes, components ordered by descending energy). Measured on a
4-topic nonnegative mixture over 8 seeds: median ARI 0.63 → 1.00, seed spread 0.37 → 0.00;
against the shipped 0.5.0 behaviour, median 0.92–1.00 → 1.00 with spread 0.31 → 0.00 at
N = 160 000. scikit-learn's own NMF shows the same 0.37 spread on this data, unfixed.
Added¶
- NNDSVDar initialization for both NMF solvers (Boutsidis & Gallopoulos, 2008), built on a self-contained randomized range finder (Halko-Martinsson-Tropp) — deterministic given the seed and far better conditioned than the previous random start, which decided the basin a non-convex coordinate descent lands in.
Betula.components_— the NMF partsHas(projection_dim, dim), unit-L2 rows ordered by descending energy, so a row reads directly as a topic over the input features.Betula.reconstruction_err_— relative reconstruction error‖X̃ − W H‖_F / ‖X̃‖_Fof the projection over the leaf centroid matrix.projection_max_iter(default 100) — the factorizer's own sweep budget, independent of the head'smax_iter. Previously the two shared one number, so raising the clustering budget silently paid for NMF sweeps too.projection="weighted-nmf"/"weighted-nmf-kl"now accept sparse CSR input; only the stored values are checked for negativity (implicit zeros are already nonnegative). Measured ARI 1.00 on a sparse topic mixture, matching the dense path.- A convergence check for the KL solver (it previously always burned the full
max_iter). The Frobenius solver's check now follows the size of the update rather than the size of the objective, compared against the first sweep's — sklearn's rule. A relative test on the residual provably never fired: HALS converges sublinearly and keeps buying more thantolof relative improvement for hundreds of sweeps, somax_iterwas the only brake (measured: a 4× budget cost 6× the time). The movement falls out of the sweep for free, so the check is also cheaper than the residual it replaced.
Changed¶
WᵀX, the NMF sweep's hot loop, restructured for locality: measured 2.4-3.4× faster. Evaluating it per output cell (Σ_j w[j][k]·x[j][c]) walks a whole column ofXfor each of ther·dcells, stridingdfloats per step through a matrix far larger than L2. Accumulating into the small, cache-residentr×doutput while reading each row ofXonce, sequentially, computes the same product with the access pattern the hardware wants (verified equal to 1e-9 by a unit test).- The NMF rank is capped by both centroid-matrix dimensions (
min(rank, d, M)):rank > Mmakes the factorization rank-deficient by construction and leaves whole components with nothing to fit.
Notes¶
- A-HALS extrapolation (Ang & Gillis, 2019) was implemented, measured and rejected. It lost to plain
HALS at every sweep budget on two planted problems (up to 445× worse residual at 200 sweeps): the
accept/reject test for the extrapolation parameter has to judge the objective at the feasible
iterate, but the Gram factors on hand belong to the extrapolated point, so making it honest costs the
extra
O(Mdr)product the acceleration was meant to save. The cheap exact convergence check that came out of the attempt was kept.
0.5.0 — 2026-07-18¶
Added¶
method="gmm-toeplitz-gs"— full-order Gohberg-Semencul MLE Toeplitz-precision GMM for ordered stationary signals: a Yule-Walker (Levinson) warm start at order ≤ 16, then coordinate ascent of the exact log-likelihood over the reflection coefficients (positive-definite by the|k| < 1constraint) — the likelihood-optimal general precision (arXiv:2311.14995), completing the three-rung Toeplitz ladder ofdocs/adr/001-gmm-toeplitz.md. Reuses the exact GS precision E-step; BIC auto-k; a truepredict_proba. Measured: competitive with the AR head on AR signals and recovers mid-lag echo structure (lags 11–16, beyond the banded capw_max = 10) the banded head is blind to, while the dense-covariancegmm-toeplitz-fullcovers arbitrarily long lags (bench/toeplitz_ar_mixture.py).projection="weighted-nmf-kl"— KL-divergence (I-divergence) CF-weighted NMF for count data (word counts / event tallies / Poisson observations), where the Frobenius objective (Gaussian noise) is mis-specified. Lee-Seung multiplicative updates with the shared components weighted by leaf mass — the Poisson maximum-likelihood fit. The advantage is largest where counts are sparse: measured up to +0.5 ARI over Frobenius on a Poisson-count mixture at mean rate < 0.5 (0.83 vs 0.24), narrowing to a few points as counts grow and Poisson → Gaussian (examples/17_nmf_topics.ipynb).
Notes¶
- NMF Phase-2 warm-start / randomized range-finder were assessed and deferred (see
plans/nmf-cf-weighted.md): in the CF-compressed regime the factorization already runs over theM ≪ Nleaves, so the compression — not these — is the speedup; they would add API/state for a marginal gain and are not shipped.
0.4.0 — 2026-07-18¶
Added¶
projection="weighted-nmf"(+projection_dim) — CF-weighted nonnegative matrix factorization as a Phase-3 reducer for nonnegative data (TF-IDF / bag-of-words / event counts / spectrogram magnitudes / histograms). Rather than factorizing the rawN×dmatrix (which defeats the compression), it factorizes theM ≪ Nleaf centroids weighted by their mass,X̃_j = √n_j·μ_j: by König-Huygens the full-data NMF objective equals the weighted-centroid one up to the within-leaf scatter constant, so the expensive factorization runs over the microclusters — memory-bounded,O(M·d·r)— and any head (k-means / GMM / Leiden) then clusters the nonnegative codes. The solver is a dependency-free weighted HALS (coordinate descent, Gram-reuse across sweeps); the compression, not a fast NMF, is the speedup. Available on the one-shotfit_predictand the streamingBetulaestimator. Signed input is rejected (no silent shifting — usevmf/spherical-kmeansor PCA / TruncatedSVD for embeddings); dense input in this release. Seedocs/MATH.md.
0.3.0 — 2026-07-18¶
Added¶
method="gmm-toeplitz-full"— general (non-AR) positive-definite Toeplitz-covariance GMM for ordered wide-sense-stationary signals whose autocovariance a low-order AR cannot capture (e.g. a long-lag echo / narrowband structure beyond the AR order). Each component covariance is the dense Toeplitz matrix built from the biased (periodogram-consistent) autocovariance — positive- semidefinite by construction, made strictly positive-definite by the within-leaf variance plus a small ridge — factored by Cholesky for an exact multivariate-Gaussian E-step.O(d²)parameters,O(d³)per component; BIC auto-katn_clusters=0; a true posterior viapredict_proba. This is the general (non-AR) rung of the Toeplitz ladder recorded indocs/adr/001-gmm-toeplitz.md. On a long-lag-echo mixture (echo lagK ∈ {16, 28, 40}, all beyond the AR order) it recovers the components (ARI 0.70 → 0.97 as the window grows) where the bandedgmm-toeplitzsits at chance; on AR-generated signals the two match (bench/toeplitz_ar_mixture.py).
Changed¶
gmm-toeplitz: raised the internal AR-order capw_max6 → 10. BIC still selects the smallest sufficient order (easy signals are bit-for-bit unchanged); higher-order / MA-like signals gain headroom before the generalgmm-toeplitz-fullhead is needed.
0.2.0 — 2026-07-18¶
Added¶
method="spherical-kmeans"/method="vmf"— directional clustering on the unit hypersphere for L2-normalized embeddings (CLIP / face / sentence / speaker vectors), where cosine — not Euclidean — geometry is what matters.spherical-kmeansis hard cosine assignment (argmax_c μ̂·μ_c, centers re-normalized to the sphere);vmfis a soft mixture of von Mises–Fisher distributions (EM, a true posterior forpredict_proba, and BIC auto-kwhenn_clusters=0). Both reduce each leaf to its weighted mean(n_i, μ_i), so the cluster resultantR_c = Σ n_i μ_istays exactly mergeable — the BETULA property carries through to the sphere — and the within-leaf spread‖μ_i‖feeds the concentrationκ(Banerjee et al. 2005), so microcluster compression does not over-estimate it. The engine L2-normalizes input automatically for these methods (get_paramsstays verbatim). Theκnormalizer uses a dependency-free, numerically stablelog I_ν(κ)(log-space series) — no Bessel library, the crate stays NumPy-only. Available on the dense one-shot / streaming estimator, the sparse (O(nnz)) path, and as thespherical_kmeans/movmf/movmf_autoRust functions.covariance_weight(method="leiden"/"leiden-cpm",feature="full") — covariance-aware community detection.β > 0adds a log-Euclidean shape termβ·‖logΣ_i − logΣ_j‖²_Fto the microcluster affinity graph, so two microclusters must be close in both centroid and covariance to be neighbours — useful when clusters differ by orientation / shape (covariance descriptors, motion / time-series windows, anisotropic blobs).logΣis computed with the in-house Jacobi eigensolver (newlinalg::matrix_log) — no new dependency;β = 0(default) is the existing centroid-only affinity, bit-for-bit unchanged.tangent_weight/tangent_rank(method="leiden"/"leiden-cpm",feature="full") — GeoBETULA manifold-aware community detection.γ > 0adds a Grassmann termγ · d²_Gr(U_i, U_j)(projection distance between each microcluster's rank-tangent_rankprincipal subspace) to the affinity, so communities must agree in centroid, covariance and manifold orientation — separating crossing / adjacent manifolds that share a centroid neighbourhood. Reuses the in-house Jacobi eigensolver (no new dependency);γ = 0(default) leaves the graph unchanged.method="scale-space"— scale-space (Morse-persistence) density-mode clustering. Treats the microclusters as a weighted point set and clusters the modes of the KDEρ_h(x) = Σ_j n_j exp(−‖x−μ_j‖²/2h²); it sweeps the bandwidthhand keeps the labelling at the most persistent mode count (the widest plateau of the modes-vs-log hcurve), so it needs nokand no bandwidth and finds non-convex, arbitrary-count structure. A prominence-based mode merge (collapse peaks separated by only a shallow density valley) cleans the mode-count curve, so it is robust from 2 to ~8+ well-separated clusters and on unequal densities. Pure-Rust mean-shift over theM ≪ Nleaves — cost bounded by the leaf budget, notN.method="gmm-toeplitz"— AR / Toeplitz-structured GMM for ordered, wide-sense-stationary signals (fixed-length time-series windows, trajectories, sensor / audio / vibration waveforms). Each component's covariance is an AR(w) process: the pooled unbiased (covariance-method) autocovariance is mapped by Levinson-Durbin to the exact Gohberg-Semencul precisionΓ = (1/σ²)(BBᵀ − ZZᵀ), evaluated by the prediction-error decomposition so thewboundary positions are modelled exactly — positive- definite by construction (the reflection-coefficient clamp is the GS box constraint),O(w)parameters, orderwchosen by BIC — so it stays well-posed in theN_k ≪ dregime where full covariance is singular and a diagonal model is blind to neighbour correlation. Reuses the CF scatter (no new tree machinery); a scalar stationary mean; BIC auto-katn_clusters=0; a true posterior viapredict_proba; parallel EM restarts (parallelfeature). For ordered coordinates only — on generic embeddings the Toeplitz prior is wrong (usegmm/gmm-full). Based on the Gohberg-Semencul Toeplitz-precision estimator of arXiv:2311.14995; design and validation indocs/adr/001-gmm-toeplitz.md.
Fixed¶
- High-dimensional GMM regularization — the expected-log E-step adds a within-leaf correction
(
−½ Σ_d (Σ_i)_dd/σ²_kdfor diagonal,−½ tr(Σ_k⁻¹ Σ_i)for full covariance) that turns over-confident when a component's own covariance goes near-singular along a low-variance direction — which is the norm in high dimensions with few effective microclusters per component. Two floors now keep the component covariances well-conditioned: method="gmm"(diagonal): per-dimension variance floor raised from1e-6·gvar_dto1e-3·gvar_d.digits(64-D) ARI 0.372 → 0.396, now ahead of scikit-learn'sGaussianMixture(covariance_type="diag")(0.324).method="gmm-full"(full covariance): added a per-dimension floor on each component's covariance diagonal at1e-3·gcov_dd(off-diagonals — orientation — untouched). Previously a component could be starved to zero responsibility and the recovered count dropped belowk; ondigitsthe fit collapsed to 9 clusters at ARI 0.391, and now holds all 10 at ARI 0.511 — ahead of scikit-learn'sGaussianMixture(covariance_type="full")(0.402).
The floors are relative to the per-dimension global variance (not the global mean scale, which
is inflated by between-cluster separation and would over-regularize tight clusters), so
low-dimensional and anisotropic fits are unchanged (well-separated blobs still ARI 1.00; the
rotated-anisotropic 2-D case still ties GaussianMixture at 0.887). No API change.
0.1.5 — 2026-07-04¶
Added¶
method="leiden"/method="leiden-cpm"— graph clustering / community detection over the microcluster affinity graph via the full Leiden algorithm (Traag, Waltman & van Eck 2019): local moving → refinement (sub-communities grown from singletons along edges, so each is connected by construction — Leiden's guarantee over Louvain) → aggregation seeded from the pre-refinement partition. It discovers the community count — nok(like the density head). Aresolution(γ) knob trades community count against size; the modularity objective ("leiden", γ = 1 default) has a resolution limit, the CPM objective ("leiden-cpm") is resolution-limit-free (γ on a smaller, density scale). Pure Rust — no eigensolver, NumPy-only. Best for community/blob structure at a moderatethreshold; usemethod="spectral"for elongated manifolds. The self-tuning k-NN affinity graph is shared between the spectral and Leiden heads.betula_cluster.consensus(X, n_clusters, n_runs=…)— clustersXunder several random insertion-order permutations and votes, turning the CF-tree's insertion-order sensitivity (Known Limitation #1) into a measurable quantity: a consensus labelling plus a per-point stability score in[0, 1](ConsensusResult.confidence— low on unstable boundaries, high where every order agrees). NumPy-only; for the partitional heads at a fixedn_clusters. The independent runs parallelize across threads withn_jobs(the Rust core releases the GIL).method="spectral"— spectral clustering over the CF-tree leaf microclusters for non-convex / manifold clusters (moons, rings, spirals) that the centroid heads cannot separate. Self-tuning symmetric k-NN affinity (Zelnik-Manor & Perona local scaling), the normalized Laplacian embedding (Ng-Jordan-Weiss) via the in-house Jacobi eigensolver — no LAPACK/ARPACK, the crate stays NumPy-only — with a k-means landmark reduction above 256 microclusters so theO(m³)solve stays bounded. Dense input only; pair it with a smallthreshold(many leaves) so the microclusters resolve the manifold. No built-in cluster-count selection:n_clusters=0defaults to 2.threshold="auto"for theBetulaestimator — removes the one hyperparameter users most often have to guess. A bounded-subsample pilot fits athreshold=0tree at the samemax_leavesand reads the threshold it converges to, warm-starting the full fit near-converged instead of growing it from zero (fewer rebuild passes, lower peak leaf count on largen). Cached across refits / streaming batches; below the pilot cap it is a no-op (growing from zero is already cheap), and it is dense-only (raises on sparse input).
Changed¶
- Benchmarks now cover every head (spectral, Leiden added to
bench/comprehensive.py) and the compression heads run atmax_leaves = 4000: betula-kmeans is at exact parity with scikit-learn (blobs 0.861 = 0.861) and Ward beats raw Ward while running the fullN. Docs / README / docs site surface the spectral, Leiden and consensus additions; test counts reconciled (190 Python, 158 Rust). The docs site now renders the CHANGELOG and redeploys on every published release.
0.1.4 — 2026-07-04¶
Added¶
MapperGraph.persistence_diagram/MapperGraph.persistence(filtration=…)— 0-D persistent homology of the Mapper nerve by single-linkage union-find (elder rule,O(E log E), pure Rust). Two filtrations:"overlap"(the1 − edge_overlapBhattacharyya gap — a finite bar's death is the depth of a bottleneck, ranking the booleanbridges) and"lens"(the lens sublevel diagram). Essential connected-component classes carryinfdeath.- Greedy weighted k-means++ init (scikit-learn's default): lower-inertia, lower-variance seeds at
~
ln k× the negligible init cost over the leaves. objective="dbcv"fortune— Density-Based Clustering Validation (Moulavi et al. 2014, in[-1, 1]). Unlike the convex Calinski-Harabasz / Davies-Bouldin metrics (which penalise correct non-convex partitions), DBCV validates variable-density / non-convex clusters, so it is the right selection metric for the HDBSCAN-CF and DbStream density heads. NumPy-only, computed over a subsample.
Changed¶
fit_predict_sparse/ the_coreCSR entry points now capn_features(MAX_SPARSE_FEATURES) and validate CSR arrays through the pure-Rustsparse::validate_csr, closing an unbounded-allocation DoS where a hostile caller could force an ~8 EB allocation with a single-nonzero row.- Docs reconciled to the current suite: 172-case Python suite, 147 Rust tests (143 unit + 4
integration under default features; the
python/persistence/clisurfaces add more, 155 total).
Tests¶
- Mutation-testing infrastructure (
cargo-mutantsscoped to the CF math core,mutmutfor the Python wrapper, a weekly non-blocking workflow) plus a CSR-fuzzing proptest and the two coverage gaps it surfaced (the CF-tree absorption boundary, exact tune-metric values).
0.1.3 — 2026-07-04¶
Added¶
betula_cluster.tune— memory-aware hyperparameter search over the CF knobs, scored by an internal metric (Calinski-Harabasz / Davies-Bouldin) or ARI, with a multi-objective quality / memory / speed Pareto mode. NumPy-only by default; an optional Optuna backend (TPE / NSGA-II) viapip install 'betula-cluster[tune]'.- Property-based tests (
proptest, dev-only) for the CF-tree invariants: the clustering feature is a commutative monoid (mergeis associative/commutative and equals a sequential build), folding a tree's leaf features reconstructs the whole-dataset feature, the full-covariance upper-triangular index is a bijection (incl.dim ≥ 4), and the Frequent-Directions sketch is lossless on low-rank data and never overshoots the exact scatter. - Sparse-text benchmark (20 newsgroups, TF-IDF): the
O(nnz)fit_predict_sparsepath and the standard reduce-then-cluster pipeline (TruncatedSVD / NMF → k-means) vs scikit-learn, written up honestly inbench/RESULTS.md(raw high-dTF-IDF concentrates for every fast clusterer; on NMF topics betula matches sklearn). MapperGraph.edge_overlap— a Bhattacharyya coefficient in(0, 1]per Mapper edge, from the pooled diagonal-Gaussian summaries of the two nodes' member microclusters. Surfaced onto_networkx()edges asoverlap=…, so a bridge between well-separated regions reads as a lower-weight edge than one inside a dense blob.- Documentation site (MkDocs Material +
mkdocstringsAPI autodoc, MathJax-rendered math) built fromdocs/, with a GitHub Pages deploy workflow;pip install 'betula-cluster[docs]'for the toolchain.
Changed¶
- Coverage floor (
cargo llvm-cov, ≥95 % lines) now also measures thepersistenceandclifeature sets, not just the default core. - Declared
rust-version = "1.82"(MSRV) and lowered the real floor to it — the streaming heads had an implicit 1.87 dependency (u64::is_multiple_of), now rewritten. AddedDocumentation/Changelogproject URLs. - Docs reconciled to the current suite: 167-case Python suite, 141 Rust tests (137 unit + 4 integration), and five end-to-end use cases (README, DESIGN.md).
- Repository hardening:
macOS/WindowsCI test legs, an sdist install smoke test, a nightlycargo auditcron, Dependabot, andSECURITY.md/CONTRIBUTING.md/ issue templates.
0.1.2 — 2026-06-28¶
Added¶
betula_cluster.__version__, resolved from the installed package metadata.
Changed¶
- README repositioned: compress-then-cluster framing, the test/coverage story surfaced at the top, a
"When to use it" section, and a stable-core / experimental capability split. HDBSCAN is labelled
HDBSCAN-CF consistently in prose (the
method="hdbscan"API string is unchanged).
Fixed¶
- Stale docs: the Python suite is 153 cases (was written as 123);
betula-indexreferences now point tolexindex(the indexing companion's published name).
0.1.1 — 2026-06-28¶
Fixed¶
- PyPI project description: README links to the docs, benchmarks, and examples are now absolute GitHub URLs so they resolve on the PyPI page (relative links only worked in the GitHub-rendered README).
0.1.0 — 2026-06-28¶
First public release.
Added¶
- Numerically stable BETULA clustering features
(n, μ, S)(Welford/Chan updates) with four covariance models: spherical, diagonal, full (PSD via Cholesky), and a Frequent-Directions sketch (O(ℓ·d)per leaf) for very high-dimensional data. - Memory-bounded CF-tree (Phase 1) with auto-rebuild under a
max_leavescap; optional parallel shard+merge build (n_jobs); EWMAdecayfor streaming concept drift. - Global clustering heads: Hamerly-accelerated exact k-means, diagonal & full-covariance GMM-EM
(expected-log E-step + NIW/MAP), Ward-HAC (nearest-neighbour chain), and HDBSCAN-on-CF; automatic
cluster count at
n_clusters=0(BIC / X-means / dendrogram cut). - χ² / Mahalanobis mass-invariant absorption gate (
absorb="chi2"). normalize=Truefor cosine/direction clustering of embeddings (L2-normalized rows on the unit sphere; squared-Euclidean is monotone in cosine). Doubles as the high-dimensional fix: at d≫100 raw Euclidean distances concentrate and the CF-tree collapses, but direction stays discriminative — on MNIST-784 it lifts ARI 0.04 → 0.44, beating scikit-learn (benchmarked inbench/results_real_normalize.csv). Off by default (magnitude is signal on tabular data).- Inline auto-vectorized distance kernels (the compiler vectorizes the tight reductions per call
site;
target-cpu=nativeopts into AVX2 / AVX-512 — see.cargo/config.toml); rayon-parallel labeling. - Python bindings: abi3 wheel (CPython 3.11+), zero-copy NumPy,
float32/float64(no upcast), GIL released during compute; one-shotfit_predictand a scikit-learn-style streamingBetulaestimator (partial_fit/fit/predict/fit_predict). - Full scikit-learn parameter protocol (
get_params/set_params) — works withclone,Pipeline, andGridSearchCV. PEP 561 typed (py.typed+ stubs). - Dataset-structure inspection:
microcluster_centers_/_weights_/_radii_,cluster_centers_/_radii_/_sizes_,outlier_scores,find_outliers,find_near_duplicates,near_duplicate_pairs(scored cosine pairs, exact within each leaf-block — the scalable counterpart to an O(N²) all-pairs scan),sample_representatives,assign_microclusters,summary, andn_rebuilds_/threshold_diagnostics. - Mapper topological skeleton (
topology::mapper→Betula.mapper()→MapperGraph): a lens (density/radius/l2norm/coordinate/eccentricity) over the microclusters, an overlapping cover, per-bin single-linkage at a data-adaptive (median-NN) scale, and a nerve graph with branch points and bridges (Tarjan); optionalto_networkx(). Exploration of structure / RAG leakage / dedup, not a partition.mapper_stability()sweeps the resolution and reports the topology's persistence across scale (β₀ components, β₁ loops, branch points, bridges per resolution). - Soft assignment & confidence:
predict_proba(true posterior for the GMM heads via the per-leaf responsibility matrixmicrocluster_proba_; a documented centroid-distance softmax heuristic for k-means / Ward / HDBSCAN) andassignment_confidence. - Coreset / diagnostics:
export_coreset()→Coreset(leaves as weighted points — a streaming coreset),diagnostics()(compression ratio, radius percentiles, cluster mass spread),representatives(method=medoid|boundary|outlier|diverse), andcluster_profile()(JSON-able geometry for LLM cluster naming). memory_budget_mb: sizemax_leavesfrom a target tree-resident memory (MiB) at fit time instead of tuning it by hand; the resolved value is exposed aseffective_max_leaves_.- Drift monitoring & curation:
snapshot()+Betula.compare_snapshots(before, after)(nearest-centroid match → centroid shifts / mass ratios) andactive_learning_batch(strategy= "uncertain"|"outlier")(rows to review/label). DenStreamstreaming density clusterer (Cao et al., SDM 2006) over fading spherical micro-clusters built on the stable CFs (decay is centroid/radius-invariant);partial_fit/cluster/fit/fit_predict/predict(-1= noise) + microcluster getters, sklearn-style.DbStreamstreaming DBSTREAM clusterer (Hahsler & Bolaños, 2016): fading micro-clusters connected by shared density (faded overlap mass) rather than distance, so it recovers arbitrarily-shaped clusters and keeps close-but-disconnected dense regions apart. Fixed-radius multi-assignment online; offline connects a pair when their overlap mass is≥ alpha·min_weight. Same fading-CF core and sklearn-style API asDenStream;core::stream::DbStreamin Rust.- Streaming quantile sketches (
betula-sketch, insrc/sketch/):KllSketch(Karnin–Lang– Liberty, rank-error) andDdSketch(Masson et al., relative-error) —update/update_many/merge/quantile/quantiles, mergeable, bounded memory. - Sparse input:
fit/fit_predict/partial_fit/predictaccept ascipy.sparsematrix (CSR-routed, rows expanded one at a time — the denseN × dmatrix is never materialized). f64; this dense-tree path keeps the cancellation-free guarantee, computeO(N·d). O(nnz)sparse-native (fit_predict_sparse): one-shot clustering of ascipy.sparsematrix that touches only the non-zeros. Rows summarize into spherical micro-clusters keeping(n, ΣX, ‖ΣX‖², S)(so the mean, cached‖μ‖², and centroid distance areO(nnz)) via a flat leader pass bounded bymax_leaves, then a parametric head (kmeansdefault — robust for high-dsparse) labels each row. Uses the expanded squared-distance form, so unlike the dense path it is not cancellation-free (accurate for sparse rows far from the dense centroid);core::sparse::{summarize_sparse, nearest_sparse}is the Rust API.- Robust insertion (
huber_k): optional Huber/winsorized point updates on the streaming estimator — each point is clamped to withinhuber_kper-dimension standard deviations of its target microcluster before the Welford fold-in, bounding any single point's pull on the centroid (O(k·σ/n)) so stream outliers cannot stretch a centroid or inflate a radius. Off by default; zero-variance dimensions pass through and a 5-point warm-up gates the clip. The result is still a valid(n, μ, S)triple, so every downstream head is unchanged. - Constrained clustering (
must_link/cannot_link): semi-supervised COP-KMeans (Wagstaff et al., 2001) over the leaf microclusters —fit(X, must_link=..., cannot_link=...)/fit_predict(...)take(m, 2)row-index pairs. Must-link is transitively closed; cannot-link is enforced per assignment. Constraints are honoured at the microcluster granularity, so a cannot-link inside one leaf (or contradictory / over-constrained inputs) raisesValueErrorrather than being silently dropped.method="kmeans", dense input;core::clustering::cop_kmeansexposes the Rust API with a typedConstraintError. - Mixed numeric + categorical clustering (
KPrototypes): k-prototypes (Huang, 1997) for mixed data. A mixed CF (MixedCf) pairs the stable numeric(n, μ, S)with a per-attribute category histogram (mode = categorical centroid); distance is‖Δnumeric‖² + γ·(categorical mismatch), withγdefaulting to Huang's heuristic. Rows are leader-summarized into bounded mixed micro-clusters, then clustered. Standalone scikit-learn-style estimator (categoricalcolumn indices,fit/fit_predict/predict,cluster_centroids_/cluster_modes_);core::clustering::{MixedCf, kprototypes, summarize_mixed}is the Rust API. - Command-line interface (
betula, behind theclifeature): a dependency-free binary that clusters a delimited numeric file or stdin and writes one label per row; flags mirror the library (--clusters/--method/--feature/--threshold/ … ;--clusters 0auto-selectsk). save/load+ pickle (joblib-compatible) persistence (serde + CBOR via ciborium, schema-versioned).- NaN/Inf input validation at the boundary.
Fixed¶
estimate_thresholdnow measures the mean nearest-sibling distance within each leaf node (ELKI/BETULA-standard,O(M·capacity)) instead of a global all-pairs scan; the rebuild threshold rises monotonically (no multiplicative bump that compounded across rebuilds and collapsed the tree far belowmax_leaves), and rebuilds reinsert in reverse-DFS leaf order. The CF-tree build is now byte-for-byte the reference (betulars) tree shape and at speed parity with matched build flags.