All findings · Lucene finding · LFV-421 · bpvectorreorderer-zero-centroid-nan

BpVectorReorderer turns a zero centroid into NaN when half of a split's vectors cancel out

In unreleased main and branch_11x, BpVectorReorderer scales each half's DOT_PRODUCT or COSINE vector sum to length 1, so a half whose vectors cancel out gets a NaN centroid. With assertions on, reordering throws and a reordering merge closes the IndexWriter; with them off, that split is not refined.

Summary

Priority
Low, rank 1180This unreleased NaN centroid shows only with assertions on, on contrived exactly-cancelling vectors, and the audit downgraded it.
Impact
minor
Since
main 02a09f6ab124 (#14097, 2025-01-07)introduced 2025-01-07 · about 1 year
Affected
main and branch_11x (unreleased 11.0.0-SNAPSHOT)
Status
documented
Found by
semi-formal: bounded-exhaustive check of computeDocMap

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

A half of a split whose vectors cancel out has no direction, but the reorderer still divides its vector sum by the sum's length, which is 0.

How BP vector reordering works

  1. BpVectorReorderer (lucene/misc; main and branch_11x, unreleased) renumbers the documents of a segment so that similar vectors get nearby doc IDs. It is opt-in: you call computeDocMap, run its offline tool, or plug it into BPReorderingMergePolicy.
  2. It splits the documents in half by doc ID and gives each half a centroid. For DOT_PRODUCT and COSINE the centroid is the sum of the half's vectors, scaled to length 1.
  3. Each document gets a bias, how much more it prefers the other half's centroid, and documents swap halves by bias for up to 20 rounds. Then each half is split again, until the halves would have fewer than 32 documents.
Diagram of the bug. 64 documents with 2-dimensional DOT_PRODUCT unit vectors are split into two halves of 32: docs 0-31 alternate (1, 0) and (-1, 0), docs 32-63 are (0, 1). The left half's vectors add up to (0, 0); its centroid is that sum times 1/sqrt(0) = Infinity, which gives (NaN, NaN), while the right half's centroid is (0, 1). With assertions on, VectorUtil.dotProduct fails with 'not finite: NaN from <[1.0, 0.0],[NaN, NaN]>'; with assertions off the split stays in doc-ID order. Expected: a valid doc map, with the zero sum kept as a zero centroid.
One split of 64 documents: the left half's vectors, (1, 0) and (-1, 0) alternating, add up to (0, 0); scaling that to length 1 divides by 0 and the centroid becomes (NaN, NaN). Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 64 documents with a 2-dimensional DOT_PRODUCT vector field: docs 0-31 alternate (1, 0) and (-1, 0), docs 32-63 are (0, 1), all unit vectors. Reorder the segment with BpVectorReorderer.computeDocMap, or merge it through BPReorderingMergePolicy.
  2. With the default minPartitionSize of 32 the reorderer splits once, docs 0-31 against docs 32-63, and describes each half by its vector sum scaled to length 1. You expect a doc map, as for any other legal vectors.
  3. Docs 0-31 sum to (0, 0). The scale factor is 1/sqrt(0) = Infinity, and 0 × Infinity makes that half's centroid (NaN, NaN).
  4. With assertions on, Lucene's own finite check fails: AssertionError: not finite: NaN from <[1.0, 0.0],[NaN, NaN]>, and a reordering merge closes the writer. With assertions off nothing throws, but that split stays in doc-ID order.

Who is affected

Only unreleased main and branch_11x: applications that opt into BpVectorReorderer on a DOT_PRODUCT or COSINE field, through BPReorderingMergePolicy, computeDocMap or the offline tool. In practice it takes a half of 32 or more vectors that sums to exactly zero, which needs symmetric or synthetic data. The visible failure needs assertions on, as in test suites.

Mechanism, terms and numbers522 words

BpVectorReorderer (lucene/misc; main and branch_11x, unreleased; opt-in) renumbers documents so that similar vectors get nearby doc IDs. For DOT_PRODUCT and COSINE, computeCentroid sums a half's vectors and scales the sum by 1 / (float) Math.sqrt(VectorUtil.dotProduct(centroid, centroid)) with no zero check (BpVectorReorderer.java:279-281). When the vectors of a half cancel out exactly, for example 32 documents alternating (1, 0) and (-1, 0), the sum is (0, 0), the factor is Infinity and the centroid becomes (NaN, NaN). The next dot product with it trips Lucene's own finite-result assertion (VectorUtil.java:68-75): AssertionError: not finite: NaN from <[1.0, 0.0],[NaN, NaN]>. Through BPReorderingMergePolicy that error fails the merge and IndexWriter closes as a tragic event. With assertions off nothing throws and the doc map is a valid permutation, but the NaN biases leave that split in doc-ID order. The inputs are legal unit vectors with default settings; the class comment states no non-zero precondition for the centroid, and upstream fixed a closely related NaN-centroid assertion in another class as a bug (#15209 / #15213, where the centroid had zero samples). Skipping the normalization when the squared norm is 0 fixes it; no release contains the class.

The bug, step by step (main 645d4fe14a20)

  1. Split. ReorderTask.compute splits a partition in half when each half has at least minPartitionSize documents (BpVectorReorderer.java:207-215; default 32, AbstractBPReorderer.java:25) and computes one centroid per half (:224-225). With 64 documents there is exactly one split, docs 0-31 against docs 32-63.
  2. Centroid. computeCentroid (:267-283) adds up the half's vectors. EUCLIDEAN and MAXIMUM_INNER_PRODUCT then divide by the count (:278); DOT_PRODUCT and COSINE scale by 1 / (float) Math.sqrt(VectorUtil.dotProduct(centroid, centroid)) (:279-281). For docs 0-31 the sum is (0, 0): the dot product is 0, sqrt(0) is 0, 1/0 is Infinity, and vectorScalarMul (:481-485) multiplies each 0 by Infinity, which gives NaN.
  3. Bias. computeBias (:564-574) returns dotProduct(vector, rightCentroid) - dotProduct(vector, leftCentroid).
  4. Assertions on. VectorUtil.dotProduct(float[], float[]) asserts a finite result (VectorUtil.java:68-75). Doc 0, (1, 0), against the NaN centroid fails it: AssertionError: not finite: NaN from <[1.0, 0.0],[NaN, NaN]>, thrown from computeBias (:571) inside computeDocMap (:591).
  5. On the merge path. BPReorderingMergePolicy catches only NotEnoughRAMException around computeDocMap (BPReorderingMergePolicy.java:133-146), and IndexWriter.merge hands any Throwable to tragicEvent (IndexWriter.java:5109-5113), which closes the writer.
  6. Assertions off. Every bias is NaN, so gain (:324) and scale (:315) are NaN and the stop test 500 * gain <= scale (:336) is false. The selector compares biases with Float.compare, which treats NaN as equal to NaN, and breaks ties on doc ID (:353-357), so the split stays in doc-ID order. Recursion then continues where the halves are large enough (read; in the repro the 32-document halves are too small to split again), and the doc map is always a valid permutation.

The same line is reached after a round of swaps too, because centroids are recomputed from scratch after every round that moved documents (MAX_CENTROID_UPDATES = 0, :88, :240-248; read, not executed separately).

Exposure and likelihood411 words
  • Entry points. BPReorderingMergePolicy (forced merges were executed; natural merges are reordered too, since minNaturalMergeNumDocs defaults to 1, BPReorderingMergePolicy.java:46, read only), the public computeDocMap (executed), and the offline main() / reorderIndexDirectory tool (BpVectorReorderer.java:653-697, read only).
  • What it takes. A half of a split, at least 32 documents with default settings, whose float32 vector sum has a squared norm of 0. In practice that is a sum that is exactly zero: antipodal pairs such as (1, 0) and (-1, 0), or v, v, v, -3v for COSINE. Learned embeddings essentially never cancel exactly; test fixtures and generated vectors can. Strictly, a non-zero sum whose square underflows does it too: 32 COSINE vectors (Float.MIN_VALUE, 0) sum to (4.5E-44, 0), whose squared norm is 0 (arithmetic checked through VectorUtil.dotProduct, not run through the reorderer); the recommended fix covers that too. EUCLIDEAN and MAXIMUM_INNER_PRODUCT divide by the count and are not affected: an executed EUCLIDEAN control passes; for MAXIMUM_INNER_PRODUCT this is from the code.
  • With assertions on (most of Lucene's own test runs, and downstream test suites that enable them): computeDocMap throws, and a reordering merge fails and closes the writer. In an executed probe the 8 documents added after the last commit were lost (116 live documents instead of 124) and a fresh writer failed the same forced merge again; the last commit stayed intact and CheckIndex-clean, so the demonstrated loss is the changes since the last commit.
  • With assertions off (the Java default): nothing throws, the merge succeeds, every document keeps its own vector, deletes stay deleted and CheckIndex is clean. The cancelling split is just not refined. On the repro's own 64 documents stock main (assertions off) and the fixed code both return the identity doc map, so nothing differs there; on another 128-document set (docs 0-63 alternate (0, 1) and (0, -1), the rest are 48 × (0, 1) and 16 × (1, 0)) the mean dot product of adjacent documents is -0.244 on stock main and 0.496 with the fix. The cost in compression or search speed was not measured.
  • Not slower, in one probe. The NaN path stops early: 16,384 64-dimensional antipodal-pair vectors took 15.5 ms against 272 ms for random unit vectors, with 0 documents moved (medians of 3 runs on one machine).
  • How likely. Rare. The searches found no upstream report or CI failure of this kind since the class was added in January 2025; a search cannot prove there is none.

Minimal repro

One JUnit test, one method: 64 legal DOT_PRODUCT unit vectors in one segment, then computeDocMap with default settings. Public API only, single thread; it failed at every seed tried, and the test method takes about 0.2 s. It fails with assertions on (-Ptests.asserts=true); with them off it passes, which is the bug's quiet path, not a fix.

TestBpVectorReordererZeroCentroid.java (JUnit, one test)49 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.misc.index;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.CodecReader;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestBpVectorReordererZeroCentroid extends LuceneTestCase {

  public void testHalfThatSumsToZero() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (int i = 0; i < 64; i++) {
          float[] v = i < 32 ? new float[] {i % 2 == 0 ? 1 : -1, 0} : new float[] {0, 1};
          Document doc = new Document();
          doc.add(new KnnFloatVectorField("v", v, VectorSimilarityFunction.DOT_PRODUCT));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        CodecReader leaf = (CodecReader) r.leaves().get(0).reader();
        // expected a DocMap; actual: docs 0..31 sum to (0, 0), the centroid is scaled by 1/sqrt(0) to NaN
        assertNotNull(new BpVectorReorderer("v").computeDocMap(leaf, dir, null));
      }
    }
  }
}
Command
cp TestBpVectorReordererZeroCentroid.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestBpVectorReordererZeroCentroid -Ptests.seed=DEADBEEF -Ptests.asserts=true
# -Ptests.asserts=false: the test passes (nothing throws; the split is just not refined)
Expected
computeDocMap returns a doc map, as it does for any other legal vectors.
Observed
AssertionError: not finite: NaN from <[1.0, 0.0],[NaN, NaN]> from VectorUtil.dotProduct, called by BpVectorReorderer$ComputeBiasTask.computeBias (BpVectorReorderer.java:571).

All files of this repro: repro/LFV-421/

More files, environment, full output290 words

The test sits in org.apache.lucene.misc.index next to the upstream TestBpVectorReorderer and uses only public API. It asserts only that a doc map comes back; the failure is Lucene's own assertion, whose message shows the NaN centroid. It failed at every seed tried (DEADBEEF, and 5EED1234 with 5 iterations). The reorderer's arithmetic uses no randomness, but LuceneTestCase randomizes the default codec behind new IndexWriterConfig(), and only these seeds were run.

It does not assert the effect with assertions off, because reorder quality is not part of the DocMap contract and on this data even the fixed code returns the identity order.

Not shown by this one test, all from the same line and executed elsewhere: the COSINE variant, the BPReorderingMergePolicy merge that closes the writer (isOpen() false, the AssertionError as tragic exception), and the unrefined split with assertions off.

As the regression test: the same test, for example as a new method of TestBpVectorReorderer. The comment on line 44 is over the 100-column limit, so ./gradlew tidy wraps it; nothing else changes. With that, :lucene:misc:check -x test (google-java-format, ecj lint, forbidden APIs) passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); no release contains BpVectorReorderer
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestBpVectorReordererZeroCentroid.java $LUCENE/lucene/misc/src/test/org/apache/lucene/misc/index/
cd $LUCENE && ./gradlew :lucene:misc:test --tests org.apache.lucene.misc.index.TestBpVectorReordererZeroCentroid -Ptests.seed=DEADBEEF -Ptests.asserts=true
# -Ptests.asserts=false: the test passes (nothing throws; the split is just not refined)

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true
TestBpVectorReordererZeroCentroid > testHalfThatSumsToZero FAILED
    java.lang.AssertionError: not finite: NaN from <[1.0, 0.0],[NaN, NaN]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:28C0EB26BD6F4DE9]:0)
        at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.util.VectorUtil.dotProduct(VectorUtil.java:74)
        at org.apache.lucene.misc.index.BpVectorReorderer$ComputeBiasTask.computeBias(BpVectorReorderer.java:571)
        at org.apache.lucene.misc.index.BpVectorReorderer$ComputeBiasTask.compute(BpVectorReorderer.java:552)
        at org.apache.lucene.misc.index.BpVectorReorderer$ReorderTask.shuffle(BpVectorReorderer.java:313)
        at org.apache.lucene.misc.index.BpVectorReorderer$ReorderTask.compute(BpVectorReorderer.java:233)
        at org.apache.lucene.misc.index.BpVectorReorderer.computePermutation(BpVectorReorderer.java:627)
        at org.apache.lucene.misc.index.BpVectorReorderer.computeValueMap(BpVectorReorderer.java:606)
        at org.apache.lucene.misc.index.BpVectorReorderer.computeDocMap(BpVectorReorderer.java:591)
        at org.apache.lucene.misc.index.TestBpVectorReordererZeroCentroid.testHalfThatSumsToZero(TestBpVectorReordererZeroCentroid.java:45)

1 test completed, 1 failed

# gradle, same seed, -Ptests.asserts=false: :lucene:misc:test (SUCCESS): 1 test

Why it looks unintended

  • The class comment sets no non-zero precondition; a zero sum has no direction, so keeping a zero centroid is natural.
  • Lucene's own arithmetic makes the NaN from legal unit vectors and default settings, and it trips Lucene's own finite-result assertion.
  • Upstream fixed a closely related NaN-centroid assertion elsewhere as a bug (#15209 / #15213, a centroid with zero samples).
The full argument330 words
  • The class comment sets no non-zero precondition. BpVectorReorderer.java:57-75 derives the centroid as sum(v) / n. Its last sentence starts with the max-inner-product case and ends: "since all collinear vectors of whatever scale will generate the same partition for these angular scores, we are free to choose any scale and ignore the normalization factor" (:71-74). It says nothing about a zero sum, which has no direction to normalize; keeping it as a zero centroid is a natural convention, though the comment does not settle it.
  • Legal input, default settings. (1, 0), (-1, 0) and (0, 1) are unit vectors, as the DOT_PRODUCT javadoc asks (VectorSimilarityFunction.java:52-58); KnnFloatVectorField's value checks reject non-finite components (KnnFloatVectorField.java:105) and, for COSINE, the all-zero vector (:53-56); its other checks cover null, empty and over-long vectors.
  • The assertion predates the class. The finite-result assertion in VectorUtil.dotProduct came with #12281 (2023); #15213 (2025) only added its "not finite" message. The reorderer was written against it.
  • Upstream treats this symptom as a bug. In #15209 a NaN centroid tripped the same assertion in the scalar-quantization writer. benwtrent wrote "it does smell like a bug. The centroid is nan", and msokolov, who also wrote BpVectorReorderer, answered "Seems like divide by zero when there are no samples. I'll post a fix". #15213 fixed that writer only. The zero centroid there came from an empty sample set, not from cancellation: a close precedent, not the same root cause.
  • The merge policy avoids failing merges over reordering, for low RAM. Its javadoc: when reordering lacks RAM, "it simply skips reordering in order not to fail the merge" (BPReorderingMergePolicy.java:34-39). That covers only the out-of-RAM case: a hint of intent, not a contract for this one.
  • No comment, test or discussion covers a zero sum. The upstream DOT_PRODUCT test uses random angles on the unit circle (TestBpVectorReorderer.java:272-310), so no half cancels; the #14097 review of computeCentroid was about a stray semicolon.
The devil's-advocate check219 words

The strongest case for "not a bug": the failure is an assertion, so production (assertions off) never throws; the doc map is then a valid permutation and the DocMap contract promises no reorder quality; the data is degenerate (an exactly cancelling half of 32 or more vectors); and the class is unreleased and, in its author's words in #15219, "hasn't gotten a lot of real-world testing".

Why it fails: Lucene uses these assertions as invariants and runs most of its own tests with them on (about 75% of seeds by default), so legal input that trips one is a bug, as #15209 / #15213 show for the same assertion with a close mechanism (a centroid normalized with zero samples). The DocMap contract indeed promises no reorder quality, so the case rests on that invariant and on the effect with assertions off, which is not a designed fallback: it works only because Float.compare orders NaN and ties break on doc ID, and it skips refinement that the fixed code performs (-0.244 against 0.496 on the 128-document set). Degenerate but legal data lowers the priority, not the validity. GPT review lenses rated the intended argument weak; a later GPT fact check rated it moderate and still called it a bug, because of the effect with assertions off and the #15213 precedent.

Suggested fix

Recommended Skip the normalization when the squared norm is 0

Skip the normalization when the half's squared norm is 0 and keep the zero centroid (a few lines in computeCentroid). The repro then passes with assertions on and off, and the whole lucene/misc test suite passes (317 tests, 0 failures).

case DOT_PRODUCT, COSINE -> {
  float norm2 = VectorUtil.dotProduct(centroid, centroid);
  // vectors that cancel out (e.g. v and -v) have no direction: keep the zero centroid
  // rather than scaling it by 1/0, which turns it into NaN
  if (norm2 > 0) {
    vectorScalarMul(1 / (float) Math.sqrt(norm2), centroid);
  }
}
All options and trade-offs3 options · 551 words

One option was validated with gradle on 645d4fe14a20; a second was run as a data point; a third is a design question.

Skip the normalization when the squared norm is 0 (validated by the checks) (recommended)

In computeCentroid, compute the squared norm once and scale only when it is positive, so a half whose vectors cancel keeps the zero centroid. A zero centroid adds 0 to every bias, so documents are placed by the other half's centroid alone and the split is still refined. This is the finder's guard.

case DOT_PRODUCT, COSINE -> {
  float norm2 = VectorUtil.dotProduct(centroid, centroid);
  // vectors that cancel out (e.g. v and -v) have no direction: keep the zero centroid
  // rather than scaling it by 1/0, which turns it into NaN
  if (norm2 > 0) {
    vectorScalarMul(1 / (float) Math.sqrt(norm2), centroid);
  }
}

Trade-offs

  • Nothing changes for a half whose squared norm is positive: the same expression runs.
  • When both halves are zero, gain and scale are 0 and the existing stop test ends the round.
  • Matches the class comment ("free to choose any scale") and the "don't normalize" fix of #15213.
  • Open PR #15430, which moves the class to core, copies the unguarded line and would need the same change.
  • The guard targets a squared norm of 0, including a sum of tiny vectors whose square underflows. A squared norm that overflows to Infinity (very large COSINE vectors) is a separate edge it leaves as it is: with assertions on, VectorUtil.dotProduct(centroid, centroid) itself asserts; with them off the factor is 0 and the centroid becomes zero (arithmetic checked, not run through the reorderer).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20: the repro fails on stock main with assertions on and passes with this fix, with -Ptests.asserts=true and =false.
  • With the fix: TestBpVectorReorderer 8 tests, 0 failures; the whole lucene/misc suite 24 classes, 317 tests, 0 failures, 9 skipped; :lucene:misc:check -x test passes with the repro test in place once ./gradlew tidy has wrapped its long comment.
  • Earlier runs of the same guard: an independent 4-test reproducer (DOT_PRODUCT, COSINE, merge path, EUCLIDEAN control) 4 of 4 with assertions on, and the merge in the integrity probe completes with assertions on.
  • On the 128-document set it reorders the split that stock main leaves in doc-ID order (mean adjacent dot product 0.496 against -0.244).

Divide by the count for every similarity (validated by the checks)

Use sum(v) / n, the centroid the class comment derives, for DOT_PRODUCT and COSINE too. A zero sum then stays zero.

Trade-offs

Changes the centroids, and possibly the order, for DOT_PRODUCT and COSINE inputs whose two halves' mean vectors differ in length: the bias becomes dot(v, sumR / nR - sumL / nL) instead of dot(v, unit(sumR) - unit(sumL)). It would need a reorder-quality benchmark.

What the checks validated

Data point only: with this change the repro passes with assertions on and the 8 upstream TestBpVectorReorderer tests pass (gradle, seed DEADBEEF); no quality comparison was run.

Design question: sum normalized vectors for COSINE

Whether COSINE centroids should sum each document's normalized vector, so every document weighs the same, rather than the raw vectors.

Trade-offs

A separate change; untested. It does not remove the zero case by itself, because antipodal unit vectors still cancel.

Downstream note

Downstream code on main that enables BpVectorReorderer and runs its tests with assertions on can see reordering and merges fail on symmetric synthetic vectors; with assertions off the index stays intact and only some reordering is lost.

How it was found

Semi-formal: a bounded-exhaustive harness ran computeDocMap from the real jar on every segment of up to 7 documents with 1-dimensional vectors (-1, 0 or +1; no 0 for DOT_PRODUCT, COSINE) for all four similarities. With assertions on it hit 12,736 'not finite: NaN' failures; with them off, 0 doc-map violations in 401,744 checks. A JUnit test with default settings followed.

The method in full160 words

Method: bounded-exhaustive translation validation (the study's "semi-formal" tier). The harness enumerated every segment of up to 7 documents, each with no vector or a 1-dimensional vector in {-1, 0, +1} (without 0 for DOT_PRODUCT and COSINE, which ask for unit or non-zero vectors), under all four similarity functions, with minPartitionSize 1 or 2 and maxIters 1 or 20, and checked each computeDocMap result from the stock main jar against the DocMap contract (a permutation of the live documents).

  • With assertions on: 12,736 runs failed with AssertionError: not finite: NaN, for example the DOT_PRODUCT segment (-1), (1), (-1) with minPartitionSize 1, whose second half (1), (-1) sums to 0.
  • With assertions off: 401,744 checks, 0 violations, 0 exceptions. So every doc map in this domain was valid; the defect is the NaN itself.

The finder then wrote a JUnit test with default settings (128 documents, computeDocMap and a reordering merge), which was later reduced to the one 64-document test above.

Prior reports, history and checks

Prior reports

No prior report found: 164 queries and checks over GitHub issues, PRs and commits, four downstream projects, the mailing lists, JIRA and the web (2026-10-26), refreshed 2026-11-01. Closest precedent: #15209 / #15213, the same NaN-centroid assertion, fixed in another class.

Searches6 searches

No issue, PR, review comment, commit, mailing-list post, JIRA entry, downstream report or web page describes this bug, and no merged or open change guards the normalization. #15209 / #15213 is a precedent, not an earlier report: it fixed a NaN centroid with zero samples in Lucene104ScalarQuantizedVectorsWriter only, and neither thread mentions the reorderer. Limits: keyword search only; GitHub search does not match diffs; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26a scripted prior-report search (40 issue/PR and 3 commit queries) and 44 manual GraphQL searches: class and method names (BpVectorReorderer, BPReorderingMergePolicy, computeCentroid, computeBias, vectorScalarMul), symptom phrases ("not finite: NaN", "zero centroid", "divide by zero")no report; related items #14097 (introduced it), #13683, #13565, #14117, #14110, #14143, #15219, #15285, #15430
Open apache/lucene PRs2026-10-26changed-file lists of all 411 open PRsfive touch the reorderer files; none guards the line; #15430 copies it into core
Downstream projects2026-10-26GitHub searches in elastic/elasticsearch, opensearch-project/OpenSearch, opensearch-project/k-NN and apache/solrno report; no use of BpVectorReorderer found
Mailing lists and JIRA archive2026-10-2620 scripted queries; 24 direct archive queries over dev@, java-user@, issues@, commits@ and builds@, plus a check that builds@ is searchable; 4 JIRA-archive queriesno report (only mirrors of the GitHub items)
Web2026-10-266 scripted queries and 7 general searchesno report
GitHub refresh2026-11-01GraphQL searches for items updated since 2026-10-25 (BpVectorReorderer, BPReorderingMergePolicy, "not finite" NaN, centroid NaN), with a control search; the file's commit list on upstream mainnothing new; the file has no new commit and #15430 is unchanged

History

Since 02a09f6ab124 (#14097, 2025-01-07), which added the class; the line never changed. Unreleased: main and branch_11x have it, no release through 10.6.0 does, and open PR #15430 copies the line into core.

Versions tested9 rows

Introduced by 02a09f6ab124 "HNSW BP reordering (#14097)" by Michael Sokolov, 2025-01-07. git blame of computeCentroid shows only this commit, and git log -S on the normalization expression returns only it. Later commits to the file, b7c7fe064ce1 (#14117, stopping heuristic) and 54283150a81 (#16255, build checks; in this file only a typo fix in the class comment), do not touch it. The finite assertion is older (#12281, 2023), so this is not a regression: the class behaved this way from its first commit.

CHANGES.txt lists GITHUB#14097 under 11.0.0. No release has the class yet; 11.0.0 would ship it unless fixed.

Versions and branches executed, with results
Version or commitVerdictResult
72d93de50c2d (parent of the introducing commit) no classthe repro does not compile
02a09f6ab124 (#14097, introducing) affectedfails with assertions on, passes with them off; the guard fixes it there
b7c7fe064ce1 (#14117) affectedfails with assertions on, passes with them off
main ea57360912ed, a9779ef3c193, 645d4fe14a20 affectedfails with assertions on, passes with them off (gradle and standalone on 645d4fe14a20)
upstream main f815e679bf6c (2026-11-01) readthe same unguarded code
branch_11x 603af870375 (2026-10-29) readthe same file (the same blob as at 645d4fe14a20; 02a09f6ab124 is an ancestor); not run
9.12.3, 10.1.0, 10.2.0, 10.5.2 no classthe repro does not compile
all 68 release tags through 10.6.0; branch_10x, branch_10_6, branch_10_5 no classthe file is absent; the 10.6.0 misc jar has no BpVectorReorderer class
open PR #15430 (BP Reordering Codec) readmoves the class to core and copies the unguarded line

Independent checks

Every check that ran code reproduced it: the finder, an independent reproducer, the intended-behaviour devil's advocate, the adjudication and a credibility audit. Read-only reviews by a model from another family agreed on the root cause; one said "not real" on procedure only. The audit downgraded it to minor because only assertions fail.

Checks and verdicts337 words

Each check worked from the claim and its own evidence; the cross-model reviews (OpenAI GPT-5.6) could not run code (reading only), so separate runs ran the repro for them.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedBounded-exhaustive check (12,736 assertion failures on, 0 doc-map violations in 401,744 checks off); JUnit 2 of 2 fail on, 0 of 2 off; guard 2 of 2 and the 8 upstream tests pass.
cross-model review, intended lens (OpenAI GPT-5.6) real (medium)Root cause agreed, inputs in contract, intended argument weak; read-only, a separate re-run failed 2 of 2.
Independent reproducer realOwn JUnit: DOT_PRODUCT, COSINE and the merge path fail with assertions on (writer closed), EUCLIDEAN control passes; 30 of 30 over 10 iterations; also with the scalar vector code; 0 of 4 off; own guard 4 of 4.
Cross-model adversary, refute lens (OpenAI GPT-5.6) not real (low), proceduralCould not run code and called its verdict "not a substantive refutation"; on substance it agreed with the root cause and found the inputs in contract.
Intended-behaviour devil's advocate real; not intendedNo intended, misuse or unreachability argument survives; the probe with assertions off shows the unrefined split (-0.244 against 0.496).
adjudication confirmed (high)Re-ran the independent reproduction's test (3 of 4 fail at two seeds, 0 of 4 off), the finder's test and the guard.
security screen noneExecuted probe: index intact with assertions off; writer close and lost uncommitted documents with them on.
credibility audit downgrade to minorRe-ran stock on and off and the guard; an assertion-only failure is not a production crash.
this doc reproduced; fix validatedGradle on 645d4fe14a20: fails on, passes off; with the guard the repro, TestBpVectorReorderer and the whole lucene/misc suite pass.
fact checks of this doc (Claude; OpenAI GPT-5.6, read-only) real; wording correctedRe-ran the repro (stock on and off, the guard) and the gradle command, and probed the repro's doc map; the corrections are applied.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,227 words

Finding record

LFV-421, snapshot of 2026-11-06

Registry title
BpVectorReorderer scales a zero DOT_PRODUCT/COSINE centroid by 1/sqrt(0), so a split whose vectors cancel out gets a NaN centroid: with assertions on, reordering fails and a reordering merge closes the IndexWriter; with assertions off, that split is silently not refined
Area
misc / BP merge-time reordering (BpVectorReorderer centroid arithmetic)
Priority
Low, rank 1180: This unreleased NaN centroid shows only with assertions on, on contrived exactly-cancelling vectors, and the audit downgraded it.
Impact class
minor
Impact note
Only applications on unreleased main and branch_11x (11.0.0-SNAPSHOT) that opt into the lucene/misc BpVectorReorderer (through BPReorderingMergePolicy, computeDocMap or the offline tool) on a DOT_PRODUCT or COSINE float vector field, and only when the vectors of one half of a recursive split (at least minPartitionSize, default 32, documents) sum to exactly zero in float32, as with symmetric or synthetic data such as alternating (1,0)/(-1,0): with Java assertions enabled (-ea, for example test suites) reordering throws AssertionError and a reordering merge closes the IndexWriter as a tragic event, losing the uncommitted documents; with assertions disabled (the production default) nothing throws, the DocMap is a valid permutation and the only effect is that this split stays in doc-ID order; ordinary scale (64 documents suffice).
Summary
You index DOT_PRODUCT or COSINE float vectors and reorder them with BpVectorReorderer, by calling computeDocMap or by merging through BPReorderingMergePolicy, for example 64 legal unit vectors where docs 0..31 alternate (1,0) and (-1,0) and docs 32..63 are (0,1); you expect a valid reordering, as for any other legal vectors. Instead the half whose vectors cancel out sums to the zero vector, computeCentroid normalizes it by 1/sqrt(0) = Infinity and the centroid becomes NaN. With assertions on, VectorUtil.dotProduct then fails with 'AssertionError: not finite: NaN from <[1.0, 0.0],[NaN, NaN]>' and the merge path closes the IndexWriter; with assertions off nothing throws, but the NaN biases leave that split in doc-ID order.
Introduced
02a09f6ab124 (#14097 'HNSW BP reordering' by msokolov, main) [exec: the finder's repro fails 2 of 2 with -ea and passes with -da at 02a09f6ab124, passes there with the one-line guard shadowed, and does not compile at its parent 72d93de50c2d, which has no BpVectorReorderer; read: git blame and git log -S show that every line of computeCentroid, including the unguarded 1 / (float) Math.sqrt(VectorUtil.dotProduct(centroid, centroid)), comes from 02a09f6ab124 and never changed]; the finite-result assertion in VectorUtil.dotProduct predates the class (071461ece541, #12281, 2023-06-13; #15213 only added the 'not finite' message); not a regression: the class has had this behaviour since it was added (2025-01-07)
First affected version
unreleased (main and branch_11x, 11.0.0-SNAPSHOT; CHANGES.txt lists GITHUB#14097 under 11.0.0 and no release tag contains BpVectorReorderer)
Affected versions
main from 02a09f6ab124 (2025-01-07) through 645d4fe14a20 [exec: 02a09f6ab124, b7c7fe064ce1 (#14117, the only later behavioural change to the class) and ea57360912ed fail 2 of 2 with -ea and pass with -da; a9779ef3c193 and 645d4fe14a20 fail with -ea and pass with -da]; branch_11x 603af870375 has the same file and contains 02a09f6ab124 [read]; upstream main cf398bfb142c (2026-10-26) still has the unguarded line [read]; open PR #15430 (moves the class to core) copies it [read]. Not affected: 9.12.3, 10.1.0, 10.2.0 and 10.5.2, where the repro does not compile because BpVectorReorderer does not exist [exec]; every release tag through 10.6.0 and branch_10x, branch_10_6 and branch_10_5 lack the file [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-26, all read-only: 164 logged queries and checks, namely a scripted prior-report search with 40 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries; 44 manual GitHub GraphQL searches, including elastic/elasticsearch, opensearch-project/OpenSearch, opensearch-project/k-NN and apache/solr; 24 direct mailing-list archive queries over builds@, dev@, issues@, commits@ and java-user@; 7 web searches; a changed-file scan of all 411 open PRs; the body, comments and review comments of #14097 read; the finder's run, the independent reproduction and the intended-behaviour check found nothing either); precedent, not a report of this bug: #15209 / #15213 (the same 'not finite: NaN' assertion from normalizing a centroid with no samples, fixed in Lucene104ScalarQuantizedVectorsWriter only); related items: #14097 (introduced it), #13683, #13565, #14117, #14110, #14143, #15219; open #15430 (moves the class to core and keeps the unguarded line) and #15285 (renames dotProduct, same assertion) would carry it along
Lucene commit tested
main 645d4fe14a20 (the minimal repro through gradle with -Ptests.asserts=true and =false on stock main and with fix A, and with =true under fix B; fix A with TestBpVectorReorderer, the whole lucene/misc test suite and :lucene:misc:check -x test; fix B with TestBpVectorReorderer, assertions on; standalone runs with assertions on and off and a second seed; a doc-map probe of the repro's 64 documents, stock with assertions off and fix A with them on); by the hunt and its verifiers, main ea57360912ed and a9779ef3c193 and the commits 02a09f6ab124 (introducing), b7c7fe064ce1 and 72d93de50c2d (its parent: does not compile); the repro does not compile on 9.12.3, 10.1.0, 10.2.0 and 10.5.2, and no release tag through 10.6.0 has the class (read). Every Lucene source file cited with a line number here is byte-identical at ea57360912ed, 645d4fe14a20 and branch_11x 603af870375 (read, blob ids); lucene/CHANGES.txt is not, so its line number is given for 645d4fe14a20. The code is unchanged on upstream main f815e679bf6c (2026-11-01, read).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:57-74 (class comment: the centroid is sum(v)/n; 'we are free to choose any scale and ignore the normalization factor'), :267-283 (computeCentroid; :279-281 scale DOT_PRODUCT/COSINE sums by 1 / (float) Math.sqrt(VectorUtil.dotProduct(centroid, centroid)) with no zero check), :481-485 (vectorScalarMul), :564-574 (computeBias calls VectorUtil.dotProduct with the centroids), :230-237 (maxIters loop), :336 (stop test '500 * gain <= scale'), :353-357 (Float.compare with doc-ID tie-break), :578-593 (computeDocMap)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:63-77 (dotProduct: 'assert Float.isFinite(r) : "not finite: ..."')
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:34-39 (javadoc: skips reordering when RAM is short 'in order not to fail the merge'), :46 (minNaturalMergeNumDocs = 1), :133-146 (reorder catches only NotEnoughRAMException)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113 (any Throwable in a merge calls tragicEvent)
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/index/AbstractBPReorderer.java:25, :31 (DEFAULT_MIN_PARTITION_SIZE = 32, DEFAULT_MAX_ITERS = 20)
  • a Lucene main checkout lucene/CHANGES.txt:158 (GITHUB#14097 under 11.0.0)
The finding in 30 seconds
  • lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:207-215, :224-225, :240-248, :267-283 (:279-281), :315, :324, :336, :353-357, :481-485, :564-574, :591
  • lucene/misc/src/java/org/apache/lucene/misc/index/AbstractBPReorderer.java:25, :31
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:133-146
  • lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:63-77
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113
Who is affected
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:46, :133-146
  • lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:653-697 (main, reorderIndexDirectory)
Minimal repro
  • build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:240-248 (tests.asserts default)
Why it looks unintended
  • lucene/misc/src/java/org/apache/lucene/misc/index/BpVectorReorderer.java:57-75 (class comment; quote at :72-74)
  • lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:52-58
  • lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:53-56, :105
  • lucene/misc/src/java/org/apache/lucene/misc/index/BPReorderingMergePolicy.java:34-39
  • lucene/misc/src/test/org/apache/lucene/misc/index/TestBpVectorReorderer.java:272-310
  • build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:240-248 (tests.asserts default: on for about 75% of seeds)
History
  • lucene/CHANGES.txt:164 (:158 at ea57360912ed, :174 on branch_11x)
  • a Lucene clone origin/branch_11x 603af870375 (git rev-parse of BpVectorReorderer.java: blob f2b987b3a516, as at 645d4fe14a20; merge-base --is-ancestor 02a09f6ab124)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.