Skip to content

fix(index): reject unattainable partition counts in weighted k-means - #9346

Merged
Xuanwo merged 13 commits into
lance-format:mainfrom
LuciferYang:fix/ivf-rq-weighted-kmeans-pad
Oct 24, 2026
Merged

Xuanwo merged 13 commits into
lance-format:mainfrom
LuciferYang:fix/ivf-rq-weighted-kmeans-pad

Conversation

@LuciferYang

@LuciferYang LuciferYang commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

Closes #9345

train_weighted_hierarchical_kmeans, the trainer behind the streaming coreset path for both f32 and f64 centroids, padded the cluster list with clones of the heaviest cluster whenever it formed fewer than target_k. A clone is bit-identical to the cluster it came from, and assignment resolves a tie to the incumbent, so a clone starts with no rows and gains some in the refinement passes that follow only if the original drifts away from it. The build succeeded with partitions that were empty or nearly so.

It now returns invalid_input naming the coreset row count and, when the coreset is not smaller than num_partitions, how many clusters were formed. The flat hierarchical trainer already rejects the same request (lance-index/src/vector/kmeans.rs:1561). The two are not the same error kind, though: the flat path raises ArrowError::InvalidArgumentError, which surfaces in Python as RuntimeError, while this one is Error::InvalidInput and surfaces as ValueError. Aligning them is a separate change. A coreset that has fewer rows than num_partitions gets its own message suggesting a larger sample_rate, since more diverse data would not help there.

A k-means split can also leave every row on one side when the rows differ. Under Dot this happens often enough that main's test_build_ivf_model_streaming_dot_coreset_training, 257 partitions over 1,024 distinct rows, came up 9 to 24 clusters short in 4 of 25 local runs with this rejection and no bisection; main padded those shortfalls with clones. Before rejecting, the trainer now bisects the remaining multi-row clusters by L2 around the row farthest from its centroid and the row farthest from that one, which always yields two non-empty halves when the rows differ. The trainer's output therefore falls short only when the coreset holds fewer distinct rows than num_partitions, and the bisection runs only where main would have padded.

That guarantee covers the trainer's output. The refinement passes after it run under the build's metric, and under Dot they can still pull distinct centroids for collinear positive data back onto the same point, as they could with the clones. That is left for a follow-up.

clusters.truncate(target_k) after the check would be a no-op once the shortfall is an error, because the loop stops at target_k and every step it takes is bounded by what is left. It is a debug_assert_eq! on that invariant instead.

How was this patch tested?

test_weighted_hierarchical_rejects_padding_with_duplicates is an rstest pair over the two shapes that reach the shortfall: fewer_rows_than_partitions, 6 rows against target_k 16, expects the coreset-size message, and enough_rows_all_identical, 32 identical rows against 16 partitions, expects the one about rows that cannot be split. Both assert the coreset row count appears in the message. Restoring the padding fails both cases.

test_weighted_hierarchical_bisects_clusters_kmeans_cannot_split trains 4 partitions from 8 distinct positive 1-D rows under Dot, where every k-means split sends all rows to the largest centroid, and asserts the trainer returns 4 distinct centroids, with one case each for f32 and f64. It fails without the bisection.

The streaming coreset trainer padded a short cluster list by duplicating
the heaviest cluster until it reached the requested partition count.
argmin ties always resolve to the first copy, so every duplicate stayed
empty for the life of the index — dead partitions inflating
num_partitions and probe lists. Return the same descriptive error the
flat hierarchical trainer already uses, telling the caller to reduce
num_partitions.

Assisted-by: GLM-5.3
@github-actions github-actions Bot added the bug Something isn't working label Sep 17, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 17, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 17, 2026
@LuciferYang

Copy link
Copy Markdown
Contributor Author

Reworked the loop rather than the wording — you were pointing at the same hazard my own review round flagged and I had left as a note.

The loop abandoned itself when the cluster at the top of the heap was a singleton. Priority there is loss, not size (Ord only stratifies on finalized, then compares loss), so a heavy singleton could outrank clusters that still had rows to give: the build was rejected while target_k was reachable, and the Reduce num_partitions to <= N in the message was lower than what the trainer could actually have formed. Only the singleton case changed. A finalized cluster at the top genuinely does mean nothing splittable is left, because Ord sorts finalized last, so breaking there is still right; the singleton is now finalized, pushed back so it sinks, and the loop keeps splitting the rest.

One thing I could not do, so it is not claimed anywhere: there is no test pinning that guard. Heap priority comes from the initial clustering's cluster_losses rather than from the losses argument a test supplies, so I could not force a heavy singleton to the top deterministically. A fixture that looked convincing passed with the old break as well, so I deleted it instead of shipping a test with no discriminating power. The two shortfall cases remain covered and both fail if the padding loop comes back.

lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 17, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 17, 2026
@LuciferYang

Copy link
Copy Markdown
Contributor Author

Pushed the regression guard, and one honest correction about the case you named.

I added the 257-of-258 shape and then mutation-tested it: reverting the singleton branch from continue back to break leaves it green, so it does not distinguish the two heads on this branch. The reason is the heap ordering itself — for non-finalized clusters Ord compares loss, and a singleton's cluster loss is ~0, so singletons already sink below anything splittable without needing the finalized flag. A singleton only reaches the top when a per-row base loss spike inflates its cluster loss (cluster_losses[c] += base_losses[row] + weight * distance), and that value is produced by the initial clustering rather than by the losses argument a test passes in, so I could not force it deterministically. Four fixtures failed that way before I stopped.

The test is in anyway, because it does guard something real: that the shortfall check cannot over-reject an attainable count. Changing if clusters.len() < target_k to <= fails it immediately with "could only form 257 … Reduce num_partitions to <= 257". So the singleton repair itself rests on the ordering argument plus the flat trainer's precedent, not on a test, and I would rather say that than imply coverage I do not have. Making it testable means injecting heap priority, which is a separate change.

On CI: linux-arm is unrelated. It fails in query::primitives::test_query_string::case_2_large_utf8 with Resources exhausted: Additional allocation failed for ExternalSorterMerge[0] while building a scalar index on a string column — several 40 MB sorter reservations against the runner's pool. PR #9004, an unrelated residual-partition-id change, fails the same test at the same panic site (rust/lance/tests/utils/mod.rs:247) with the same allocation error, differing only in which index type it happened to be building when memory ran out. Nothing in this PR allocates; it touches one function in the IVF partition trainer.

lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Sep 17, 2026
@LuciferYang

Copy link
Copy Markdown
Contributor Author

Added your fixture, and I was wrong about why my own attempts failed — worth correcting explicitly since I put it in the PR body.

I claimed the supplied losses cannot raise heap priority because priority comes from the initial clustering. That is wrong: assign_weighted_f32_points folds each base_losses[row] into initial.cluster_losses, which is exactly the ordering key. My fixtures failed for a duller reason — I used a 1e3 spike against a 0.1 baseline, nowhere near enough to dominate, and a 4- or 8-dimensional layout where the outlier did not end up alone. Your 1e20 against 0.0 at dimension = 1 makes it unconditional.

test_weighted_hierarchical_exhausts_splittable_clusters is in as written. Verified both ways: it passes on this head, and reverting the singleton continue to break fails it with could only form 16 non-empty clusters … Reduce num_partitions to <= 16 — the 257-of-258 case you described. The three weighted_hierarchical cases run in 0.03s.

I also dropped the earlier over-rejection guard I had added in its place; this fixture covers the repair directly, and that one only pinned that the shortfall check does not fire early.

@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Sep 17, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Sep 17, 2026
@ddupg

ddupg commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

We've also encountered this issue with streaming IVF training, resulting in many duplicate centroids and empty partitions. Thanks for the fix—looking forward to seeing it merged!

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. and removed K-approved Latest Gatekeeper recommendation permits acceptance. labels Oct 10, 2026
…-kmeans-pad

# Conflicts:
#	rust/lance/src/index/vector/ivf.rs
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Oct 16, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Oct 16, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Oct 16, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-risk Latest Gatekeeper recommendation includes a non-blocking risk. label Oct 16, 2026
@lance-gatekeeper lance-gatekeeper Bot removed K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Oct 22, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Oct 22, 2026
@LuciferYang

Copy link
Copy Markdown
Contributor Author

@BubbleCal could you take a look, since the streaming k-means trainer is from your #6913? When the weighted hierarchical trainer forms fewer clusters than num_partitions, it pads with clones of the heaviest one, and those partitions start empty (ddupg reported hitting this above). It now bisects the clusters k-means could not split and rejects only when the coreset has too few distinct rows. It is ported onto the generic f32/f64 trainer from #8662.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. and removed K-risk Latest Gatekeeper recommendation includes a non-blocking risk. K-approved Latest Gatekeeper recommendation permits acceptance. labels Oct 22, 2026
Xuanwo pushed a commit that referenced this pull request Oct 23, 2026
## Summary

Fix two independent recall failures seen on main:

- In streaming coreset training, a singleton can retain a large
compression loss and reach the top of the split heap while other
clusters are still splittable. Finalize that singleton and continue;
stop only when the heap's remaining clusters are finalized. This
prevents premature termination followed by duplicate-centroid padding.
Extend the existing regression test with lossy summaries and exhausted
splits.
- Give `test_legacy_ivf_pq_cosine_multivec_smoke` eight PQ sub-vectors
instead of four, reducing quantization error with its small training
sample. Keep its data, query, training sample rate, iteration count, and
`recall >= 0.5` assertion unchanged.

Failing CI: [Float64 streaming coreset
recall](https://github.com/lance-format/lance/actions/runs/39917533988/job/120294401363),
[legacy multivector PQ
recall](https://github.com/lance-format/lance/actions/runs/39919434480/job/120301400723).

Related: #9346 proposes fallback bisection and rejecting unattainable
partition counts. This PR fixes singleton termination in the existing
split loop and preserves the current exhausted-split behavior.

## Validation

- The added lossy/exhausted regression cases fail before the fix: 16
distinct centroids instead of 32. All three cases pass afterward.
- 15 weighted tests, 28 streaming tests, and the final legacy
multivector smoke test pass.
- `cargo fmt --all --check`
- `cargo clippy --locked --all --tests --benches -- -D warnings`
- `git diff --check`; commit hooks pass.

Local recall checks were measured on fix commit
`271c19a82f6b8a7c2cd9574aca3a9bb8198f664c` against
`77b7fd4250e248cce207c54389b7f7065f5252d9`, before syncing main, on
Apple M3 Max (arm64), Rust 1.98.1, default features and test profile.
Each version uses the same seeded fixture; training runs use normal OS
randomness and are independent.

| Scenario / metric | Baseline | Fix commit `271c19a82` |
| --- | ---: | ---: |
| Streaming L2: recall@5 below 0.5 | 2 / 500 runs | 0 / 500 runs |
| Streaming cosine: recall@5 below 0.5 | 2 / 500 runs | 0 / 500 runs |
| Streaming L2: distinct centroids | 107–176 | 257 |
| Streaming cosine: distinct centroids | 80–140 | 257 |
| Legacy multivector PQ: mean recall@100 | 0.57940 | 0.76845 |
| Legacy multivector PQ: minimum recall@100 | 0.50 | 0.67 |

Streaming: 600 Float64 vectors, four dimensions, two fragments, 257
partitions, sample rates 2/1, one training iteration, minimum 16 probes,
query row 321. All-partition recall is 1.0 in every run before and
after.

Multivector PQ: 200 runs per version, 512 rows with three 32-dimensional
vectors each, one fully probed partition, query row 0, 8-bit codebooks,
sample rate 1, two iterations. The Linux CI failure was 0.49; the local
baseline did not fall below 0.5. These are fixture-specific correctness
observations.

CI on `271c19a82` passed Rust tests on all platforms, Clippy, Python and
JNI checks. Its only failed check was the main-branch dependency
advisory RUSTSEC-2026-0385. Syncing main incorporates the already-merged
handling in #10269. The merged tree was rechecked with formatting, full
workspace Clippy, and all 44 focused tests above; new CI results are
pending.
@lance-gatekeeper lance-gatekeeper Bot removed K-risk Latest Gatekeeper recommendation includes a non-blocking risk. K-approved Latest Gatekeeper recommendation permits acceptance. labels Oct 23, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Oct 23, 2026
@lance-gatekeeper lance-gatekeeper Bot removed K-risk Latest Gatekeeper recommendation includes a non-blocking risk. K-approved Latest Gatekeeper recommendation permits acceptance. labels Oct 24, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Gate recommendation: approve with a non-blocking risk.

The exhausted-split expectation is corrected without changing production code. The native f32/f64 fallback still reaches attainable partition counts without duplicate padding and rejects unattainable counts.

One existing Dot limitation remains: positive collinear data can collapse back into duplicate centroids during subsequent refinement, leaving most partitions empty. This also occurs with the former padding path, so no additional change is requested here. For such workloads, verify final partition occupancy; bisection is an initialization fallback, not a guarantee of non-empty final partitions.

@lance-gatekeeper lance-gatekeeper Bot added K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk. labels Oct 24, 2026
@Xuanwo
Xuanwo merged commit fdacdb9 into lance-format:main Oct 24, 2026
42 of 44 checks passed
@LuciferYang

Copy link
Copy Markdown
Contributor Author

Thank you @Xuanwo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance. K-risk Latest Gatekeeper recommendation includes a non-blocking risk.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: weighted k-means pads the partition count with duplicate clusters, leaving them permanently empty

3 participants