All findings · Lucene finding · LFV-071 · panama-scalar-quantize-rounding

Vectorized scalar quantization rounds 0.49999997 up to byte 1, unlike Math.round

On CPUs with 256-bit or wider vectors and the vector module enabled, Lucene's vectorized scalar quantizer turns 0.49999997 into byte 1, where Math.round and the scalar code give 0. Accuracy barely changes, but legacy Lucene99 quantized bytes then depend on the hardware, and a Lucene test can fail at random.

Summary

Priority
Low, rank 1209The Panama minMaxScalarQuantize rounds one boundary value differently from the default implementation on legacy Lucene99 segments, one byte off by one with no accuracy loss.
Impact
minor
Since
#14304 (1e8a1468768, 2025-03-25); first release 10.2.0introduced 2025-03-25 · about 1 year
Affected
10.2.0-10.6.0 and main; only with the vector module on 256-bit or wider vectors
Status
documented
Found by
formal: a Z3 floating-point query

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Background in three steps

  1. Scalar quantization stores each vector component as a small integer: clamp it to [min, max], multiply its offset from min by scale = (2^bits - 1) / (max - min), round to the nearest whole number, store it as a byte. ScalarQuantizer (a public class) does this; in Lucene's codecs it serves the legacy Lucene99 int7/int4 vector formats.
  2. Lucene has two implementations of that loop: a scalar one, and a vectorized one (Java Vector API) that runs when the JVM has --add-modules jdk.incubator.vector and the CPU's preferred vector size is at least 256 bits.
  3. Floats are not evenly spaced: just below 0.5 they are 2^-25 apart (0.49999997 is the last one), just below 1.0 they are 2^-24 apart (0.99999994, then 1.0).
Diagram of the bug. A vector component x = 0.0019607842 is quantized with scale 255, giving 255 × x = 0.49999997, the largest float below 0.5. The scalar code rounds it with Math.round to byte 0, the nearest byte. The vectorized code adds 0.5: the exact sum 0.99999997 lies halfway between the floats 0.99999994 and 1.0, rounds to 1.0, and truncation gives byte 1. Same input, two different bytes.
Adding 0.5 to 0.49999997 lands exactly between two floats and rounds up to 1.0, so "add 0.5 and drop the fraction" writes byte 1 where Math.round writes 0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Run Lucene with --add-modules jdk.incubator.vector on a CPU with 256-bit vectors and quantize 16 copies of x = 0.0019607842 (0x3b008080) with new ScalarQuantizer(0f, 1f, (byte) 8). The scale is 255, and 255 × x is 0.49999997, the largest float below 0.5.
  2. You expect byte 0: 0.49999997 is nearer to 0 than to 1, Math.round gives 0, the scalar code writes 0, and Lucene's own test asserts that both implementations write the same bytes.
  3. Instead the vector loop writes 1. It rounds by adding 0.5 and dropping the fraction, but 0.49999997 + 0.5 = 0.99999997 is not a float: it lies exactly halfway between 0.99999994 and 1.0, the tie goes to the even neighbour 1.0, and only then is the fraction dropped.
  4. The same input gives 0 on a 128-bit CPU or without the vector module. About 1 component in 5.5 billion hits this case (study simulation of inputs like Lucene's own test); the two bytes are almost equally accurate, but quantized query bytes and scores of legacy Lucene99 segments now differ between hosts.

Who is affected

Applications with --add-modules jdk.incubator.vector on CPUs with 256-bit or wider vectors (AVX2, AVX-512, 256-bit SVE) that use the legacy Lucene99 int7/int4 formats (query time on 10.4+ and main; also indexing and merging on 10.2.x-10.3.x) or call ScalarQuantizer directly. Rare (about 1 in 5.5 billion components), almost no accuracy cost; visible as host-dependent bytes and a latent flaky test.

Mechanism, terms and numbers530 words

ScalarQuantizer.quantize (public API; in Lucene's codecs, the legacy Lucene99 int7/int4 scalar quantization) has had two implementations since #14304 (10.2.0): a scalar loop that rounds with Math.round(scale * dxc), and a vectorized loop, used with the vector incubator module on CPUs with 256-bit or wider vectors, that adds 0.5 and truncates. The two disagree only just below 0.5: when scale * dxc is 0.49999997f, the sum with 0.5 is a tie that rounds to 1.0, so the vector loop writes byte 1 where Math.round writes 0. Example: new ScalarQuantizer(0f, 1f, (byte) 8) and the component 0x3b008080 (0.0019607842) give 0 on the scalar path and 1 on the vectorized path, on main and every release from 10.2.0 to 10.6.0. The accuracy impact is negligible (byte 0 is nearer by only 2^-24 × alpha), but quantized bytes depend on the hardware and JVM flags, contrary to the code comment and to the test that came with the kernel.

The bug

  1. The scalar code rounds with int roundedDxs = Math.round(scale * dxc); (DefaultVectorUtilSupport.java:446). Math.round is exact: the closest int, ties up.
  2. The vector loop, which runs only when VECTOR_BITSIZE >= 256 (PanamaVectorUtilSupport.java:1497), rounds with fma(dxc, dxc.broadcast(scale), dxc.broadcast(0.5f)).convert(VectorOperators.F2I, 0) (:1510-1511), under the comment "Math.round rounds to positive infinity, so do the same by +0.5 then truncating to int" (:1509). fma is a fused multiply-add when the CPU's FMA is fast, else multiply then add (:97-104); F2I truncates.
  3. For y = 0.49999997f (exactly 0.5 - 2^-25), y + 0.5 = 1 - 2^-25 lies exactly halfway between the floats 1 - 2^-24 and 1.0. Round-half-to-even gives 1.0, and truncation gives 1. Math.round(y) gives 0. Without FMA the sum is the same tie. In general the vector path writes 1 where Math.round writes 0 when the exact product lies in [0.5 - 2^-25, 0.5 - 2^-26) (with FMA) or the float product is 0.49999997f (without FMA).
  4. Only this boundary can differ. For k >= 1 the floats just below k + 0.5 and just below k + 1 share a binade, so adding 0.5 is exact (the study's Z3 query proves this for the unfused form; for the fused form it did not finish, and the argument plus executed scans cover it: every float in [0, 256] through the real vector rounding step, only 0.49999997 mismatches; with FMA on, 0 differences at the boundaries k >= 1 in 13.0M elements).
  5. recalculateScalarQuantizationOffset repeats the expression (:1566-1567) but writes no bytes; only its float correction moves.

The numbers

new ScalarQuantizer(0f, 1f, (byte) 8): scale = 255 (ScalarQuantizer.java:101, 110). x = Float.intBitsToFloat(0x3b008080) = 0.0019607842 = 8421504 / 2^32, so 255 × x = 0.5 - 2^-25 exactly, the float 0x3effffff = 0.49999997. Scalar path: byte 0. Vector path (256-bit SVE, FMA on or off): byte 1. Scanning every float in [0, 1] through the public API finds exactly one such value for 8 bits (this one) and for 4 bits (0x3d088888), none for 7 bits with (min, max) = (0, 1); random legal quantiles have their own witnesses (700 of 20,000 4-bit and 125 of 20,000 7-bit (min, max) pairs near the first boundary).

Exposure and likelihood307 words
  • Opt-in path. The vector module is opt-in (Lucene's startup warning recommends it, VectorizationProvider.java:150-153), and the loop needs a preferred vector size of at least 256 bits (PanamaVectorUtilSupport.java:1497). 128-bit hosts (NEON, SVE-128, x86 without AVX2) and JVMs without the module use the scalar code. Only 256-bit SVE was executed; x86 follows the same branch by code reading.
  • Legacy formats, or direct API use. On main and 10.4+, the kernel runs when a query is quantized against Lucene99 scalar-quantized segments (ScalarQuantizedVectorScorer.quantizeQuery, used by Lucene99ScalarQuantizedVectorScorer:69 and Lucene99MemorySegmentScalarQuantizedVectorScorer:223); the Lucene99 writer is test code there. In 10.2.x and 10.3.x the writer is in lucene-core, so indexing and merging use it too. The current Lucene104 quantized format does not call this kernel. Code that calls the public ScalarQuantizer directly (up to 8 bits, as the repro does) gets it on any version from 10.2.0.
  • Rare. About 1.8e-10 per quantized component for inputs drawn like Lucene's own test generator (study simulation, 2 × 20M configurations).
  • Almost no accuracy cost. The two bytes have reconstruction errors of -0.49999997 × alpha and +0.50000003 × alpha (byte 0 is nearer by 2^-24 × alpha); the vector path computes its corrective offset from its own byte, so its scores stay self-consistent. No end-to-end recall or ranking effect was measured.
  • What is visible. The same query vector quantizes to different bytes on different hosts or JVM flags (vector module on or off, 128 vs 256+ bits; with FMA on vs off the set of diverging inputs also differs), so scores of legacy segments are not bit-reproducible across hosts. And TestVectorUtilSupport#testMinMaxScalarQuantize, which asserts identical bytes, can fail on inputs its own generator produces (48 of 200,000 random (min, max) pairs carry one; about 1 failure in 300,000 full runs, study estimate). The prior-report searches found no report of such a CI failure.

Minimal repro

One JUnit test, public API only: quantize 16 copies of Float.intBitsToFloat(0x3b008080) with new ScalarQuantizer(0f, 1f, (byte) 8) and check that the first byte is 0. Deterministic, single thread, no randomness. It fails only where the vector loop runs: the vector module, C2, and a CPU with 256-bit or wider vectors.

TestScalarQuantizerNearestByte.java (JUnit, one test)34 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.util.quantization;

import java.util.Arrays;
import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestScalarQuantizerNearestByte extends LuceneTestCase {

  public void testJustBelowHalfRoundsDown() {
    ScalarQuantizer quantizer = new ScalarQuantizer(0f, 1f, (byte) 8);
    float[] vector = new float[16];
    Arrays.fill(vector, Float.intBitsToFloat(0x3b008080));
    byte[] dest = new byte[16];
    quantizer.quantize(vector, dest, VectorSimilarityFunction.EUCLIDEAN);
    // 255 * x rounds to 0.49999997f: expected byte 0 (Math.round, default provider), Panama gives 1
    assertEquals(0, dest[0]);
  }
}
Command
cp TestScalarQuantizerNearestByte.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/quantization/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.quantization.TestScalarQuantizerNearestByte -Ptests.seed=DEADBEEF -Ptests.defaultvectorization=true
# assertions off: add -Ptests.asserts=false (same result)
Expected
dest[0] == 0, as Math.round(0.49999997f) and the scalar implementation give.
Observed
java.lang.AssertionError: expected:<0> but was:<1> with assertions on and off; it passes on 128-bit vectors or without the vector module.

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

More files, environment, full output323 words

16 elements fill at least one whole vector at 256 and 512 bits, so the first element goes through the vector loop. The assertion is the one Lucene's own TestVectorUtilSupport#testMinMaxScalarQuantize makes: the bytes must equal the scalar implementation's.

Run it with -Ptests.defaultvectorization=true. The test uses the public API, and outside test mode VectorizationProvider.getInstance() falls back to the scalar code when the JVM runs C1 only or when tests.vectorsize is set to a number (VectorizationProvider.java:157-166). A plain local gradle run always hits one of them: the seed picks a vector size among default/128/256/512, and a numeric size triggers the size check, which comes first; with default, the C1 check applies, because local test JVMs get -XX:TieredStopAtLevel=1 (CI builds do not) (TestsAndRandomizationPlugin.java:139-152, 629-642). Without the option the test passes on stock main for the wrong reason.

As the regression test: this same test, for example as a new method in TestScalarQuantizer. It can only fail on hardware with 256-bit or wider vectors and the vector module; elsewhere it passes.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also releases 10.2.0-10.6.0
jdkOpenJDK 25.0.3
osLinux, aarch64, 256-bit SVE (Lucene logs "Java vector incubator API enabled; uses preferredBitSize=256; FMA enabled")
Command (environment)
cp TestScalarQuantizerNearestByte.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/quantization/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.quantization.TestScalarQuantizerNearestByte -Ptests.seed=DEADBEEF -Ptests.defaultvectorization=true
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.defaultvectorization=true, assertions on (the same with -Ptests.asserts=false)
TestScalarQuantizerNearestByte > testJustBelowHalfRoundsDown FAILED
    java.lang.AssertionError: expected:<0> but was:<1>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:36974EA7A6649FA]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.util.quantization.TestScalarQuantizerNearestByte.testJustBelowHalfRoundsDown(TestScalarQuantizerNearestByte.java:32)
  2> INFO: Java vector incubator API enabled; uses preferredBitSize=256; FMA enabled

# controls on the same jars (standalone): 128-bit vectors (-XX:UseSVE=0, preferredBitSize=128) pass (-da);
# no vector module (scalar provider) passes (-ea and -da); -Dlucene.useVectorFMA=false still fails with the same message (-ea).
# gradle with the local default test JVM args (-XX:TieredStopAtLevel=1) passes: by the code (VectorizationProvider.java:157-166) the public API then uses the scalar provider.

Why it looks unintended

  • The code says it copies Math.round: "Math.round rounds to positive infinity, so do the same by +0.5 then truncating to int" (PanamaVectorUtilSupport.java:1509).
  • The commit that added the kernel also added a test asserting that both implementations write identical bytes ("// check the outputs are identical").
  • ScalarQuantizer's javadoc promises the "nearest byte"; 0.49999997 is nearest to 0.
The full argument222 words
  • The test. TestVectorUtilSupport#testMinMaxScalarQuantize (TestVectorUtilSupport.java:338-363) runs every provider on the same input and asserts assertArrayEquals(outputs.getFirst(), outputs.get(o)) under "// check the outputs are identical" (:360-362). ScalarQuantizer's class javadoc: values are "bucketed into the nearest byte values" (ScalarQuantizer.java:37-41); at every divergence found, both the float product and the exact product are below 0.5.
  • A port, not a new algorithm. #14304 ("Create vectorized versions of ScalarQuantizer.quantize and recalculateCorrectiveOffset") moved ScalarQuantizer's (byte) Math.round(...) unchanged into DefaultVectorUtilSupport and added the vector kernel; none of its 13 review comments or 8 conversation comments discusses rounding.
  • Later fixes kept the byte assertion. #14402 / PR #14403 and PR #15443 fixed CI failures of the same test by widening only the float-correction tolerance.
  • Legal inputs. Public constructor, finite quantiles, legal bit widths (also 4 and 7, the widths the Lucene99 format accepts), an ordinary finite float inside [min, max]; it fails with assertions off, in the output bytes.
  • Careful wording. Even the scalar path is not always the nearest byte of the exact real product: when the float product rounds up to exactly k + 0.5, Math.round rounds up (7, 64 and 127 such floats in [0, 1] for 4, 7 and 8 bits). The contract at stake is (a) identical bytes across implementations and (b) Math.round of the float scale * dxc.
The devil's-advocate check151 words

Both bytes are almost equally good; Lucene already accepts that the implementations return different float corrections (the widened tolerances); VectorUtil documents that FMA is used where it is fast; and open PR #16790 (vectorizing OptimizedScalarQuantizer loops) uses "+0.5f then truncate" on purpose, with a reviewer proposing t.add(0.5f).convert(F2I) for Math.round(t).

Why it fails: #16790 changes its scalar code to the same "+0.5f then truncate" so both implementations agree ("Adds 0.5 and truncates, as Math.round is costly to match in the Panama version"; "Not fma, so k matches the scalar version"; new javadoc "Every implementation must write the same bytes and return the same sum"). The rounding rule can be chosen, but the implementations must agree, and minMaxScalarQuantize breaks exactly that. The tolerance covers the float correction, not the bytes. Verdict: real but minor; maintainers may still prefer to align the scalar code (option B) or to relax the test (option C).

Suggested fix

Recommended A. Round exactly in the vector loop

Make the vector loop round exactly like Math.round: multiply without FMA, truncate, and add 1 when the dropped fraction is at least 0.5 (a small helper used at both rounding sites). Validated: the repro passes with assertions on and off, the closest core and backward-codecs suites pass (839 test runs, 0 failures), and :lucene:core:check -x test passes.

private static Vector<Integer> roundScaled(FloatVector dxc, float scale) {
  FloatVector y = dxc.mul(scale);
  Vector<Integer> truncated = y.convert(VectorOperators.F2I, 0);
  FloatVector t = (FloatVector) truncated.convert(VectorOperators.I2F, 0);
  VectorMask<Float> roundUp = y.sub(t).compare(VectorOperators.GE, 0.5f);
  return t.add(1f, roundUp).convert(VectorOperators.F2I, 0);
}
...
Vector<Integer> roundedDxs = roundScaled(dxc, scale);
All options and trade-offs3 options · 816 words

Option A was validated through gradle in a scratch worktree of 645d4fe14a20; options B and C were not run.

A. Round exactly in the vector loop (validated by the checks) (recommended)

Compute the product unfused, truncate it, and add 1 when the dropped fraction is at least 0.5. For y >= 0 that is exactly Math.round(y): t = (float) (int) y and y - t are exact, so the comparison sees the true fraction. One private helper, used at both rounding sites (+13/-5). The increment is a masked float add, because Lucene's forbidden-APIs check rejects VectorMask#cast ("Potentially slow on some CPUs"):

--- a/lucene/core/src/java25/org/apache/lucene/internal/vectorization/PanamaVectorUtilSupport.java
+++ b/lucene/core/src/java25/org/apache/lucene/internal/vectorization/PanamaVectorUtilSupport.java
@@ -1487,6 +1487,17 @@ final class PanamaVectorUtilSupport implements VectorUtilSupport {
     return subRet0 + (subRet1 << 1) + (subRet2 << 2) + (subRet3 << 3);
   }
 
+  // Math.round(scale * dxc) of DefaultVectorUtilSupport, lane by lane, for dxc >= 0: an unfused
+  // product, truncated, plus one when the dropped fraction is at least 0.5 (t and y - t are exact).
+  // Adding 0.5f before truncating is not the same: it rounds 0.49999997f up to 1.
+  private static Vector<Integer> roundScaled(FloatVector dxc, float scale) {
+    FloatVector y = dxc.mul(scale);
+    Vector<Integer> truncated = y.convert(VectorOperators.F2I, 0);
+    FloatVector t = (FloatVector) truncated.convert(VectorOperators.I2F, 0);
+    VectorMask<Float> roundUp = y.sub(t).compare(VectorOperators.GE, 0.5f);
+    return t.add(1f, roundUp).convert(VectorOperators.F2I, 0);
+  }
+
   @Override
   public float minMaxScalarQuantize(
       float[] vector, byte[] dest, float scale, float alpha, float minQuantile, float maxQuantile) {
@@ -1506,9 +1517,7 @@ final class PanamaVectorUtilSupport implements VectorUtilSupport {
         FloatVector dxc = v.min(maxQuantile).max(minQuantile).sub(minQuantile);
         // Scale the value to the range [0, 127], this is our quantized value
         // scale = 127/(maxQuantile - minQuantile)
-        // Math.round rounds to positive infinity, so do the same by +0.5 then truncating to int
-        Vector<Integer> roundedDxs =
-            fma(dxc, dxc.broadcast(scale), dxc.broadcast(0.5f)).convert(VectorOperators.F2I, 0);
+        Vector<Integer> roundedDxs = roundScaled(dxc, scale);
         // output this to the array
         ((ByteVector) roundedDxs.castShape(BYTE_SPECIES, 0)).intoArray(dest, i);
         // We multiply by `alpha` here to get the quantized value back into the original range
@@ -1563,8 +1572,7 @@ final class PanamaVectorUtilSupport implements VectorUtilSupport {
 
         // same operations as in quantize above
         FloatVector dxc = v.min(maxQuantile).max(minQuantile).sub(minQuantile);
-        Vector<Integer> roundedDxs =
-            fma(dxc, dxc.broadcast(scale), dxc.broadcast(0.5f)).convert(VectorOperators.F2I, 0);
+        Vector<Integer> roundedDxs = roundScaled(dxc, scale);
         FloatVector dxq = ((FloatVector) roundedDxs.castShape(FLOAT_SPECIES, 0)).mul(alpha);
         sum =
             fma(
private static Vector<Integer> roundScaled(FloatVector dxc, float scale) {
  FloatVector y = dxc.mul(scale);
  Vector<Integer> truncated = y.convert(VectorOperators.F2I, 0);
  FloatVector t = (FloatVector) truncated.convert(VectorOperators.I2F, 0);
  VectorMask<Float> roundUp = y.sub(t).compare(VectorOperators.GE, 0.5f);
  return t.add(1f, roundUp).convert(VectorOperators.F2I, 0);
}
...
Vector<Integer> roundedDxs = roundScaled(dxc, scale);

Trade-offs

  • For the quantizer's scaled values (non-negative, at most about 2^bits - 1) it computes exactly Math.round of the unfused product, so it writes the scalar provider's bytes, as 10.1.0 did (checked on the repro and the suites); no format change, no change to the scalar code.
  • A few more lane operations per vector (multiply, two converts, subtract, compare, masked add, convert, instead of one fused multiply-add and a convert); not benchmarked.
  • The recalculateScalarQuantizationOffset change is optional (that method writes no bytes) but keeps "same operations as in quantize above" true.
  • The study's version of this fix adds the increment with IntVector.add(1, mask.cast(INT_SPECIES)); it passes the tests but fails :lucene:core:check (forbiddenApisMain25: VectorMask#cast is forbidden as "Potentially slow on some CPUs").

What the checks validated

Gradle in a scratch worktree of 645d4fe14a20, every run with -Ptests.defaultvectorization=true:

  • the repro fails on stock main with assertions on and off and passes with the fix with assertions on and off;
  • TestVectorUtilSupport, TestScalarQuantizer, TestScalarQuantizedVectorSimilarity and the repro at the host's vector size (256): 322 tests, 0 failures (16 skipped by the tests' own assumptions); the three classes at -Ptests.vectorsize=512 (the 512-bit species forced on the 256-bit host): 321 tests, 0 failures;
  • five Lucene99 scalar-quantized backward-codecs suites: 196 tests, 0 failures;
  • :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs, license headers) with the fix and the repro as a new test file: BUILD SUCCESSFUL (errorProne skipped by the build's default; eclint not configured).

Not benchmarked; x86 not run.

B. Make the scalar code use "+0.5f then truncate" too (not run)

Change DefaultVectorUtilSupport.java:446 to (int) (scale * dxc + 0.5f) and drop FMA from the vector expression (dxc.mul(scale).add(0.5f)), so both compute the same two roundings. This is the approach open PR #16790 takes for its new kernels.

Trade-offs

Byte identity and slightly cheaper vector code, but both implementations then write 1 at 0.49999997f, so neither is Math.round or the nearest byte there, the scalar output changes from what 10.1.0 and earlier wrote, and the "nearest byte" javadoc needs a footnote.

What the checks validated

Not run.

C. Accept the difference and relax the test (not run)

Let TestVectorUtilSupport#testMinMaxScalarQuantize accept a difference of 1 where fl(scale * dxc) == 0.49999997f, and correct the code comment.

Trade-offs

No runtime change and no cost, but quantized bytes stay hardware-dependent, against the comment's stated intent.

What the checks validated

Not run.

Downstream note

Services that compare scores across replicas on different hardware can see last-bit score differences for legacy Lucene99 int7/int4 segments when one host runs the vector loop (256-bit or wider vectors) and another does not.

How it was found

Formal: a Z3 floating-point query compared both rounding expressions, found the witness and proved the unfused form diverges only at the float product 0.49999997f (it left the FMA case unfinished; hand derivation and scans cover it). An independent verifier swept every float in [0, 256] through the real vector operations and reproduced the bug on main and 10.5.2.

The method in full172 words
  • Method: floating-point SMT (Z3), the hunt's formal tier, in the earlier verification study's SIMD-equivalence unit: (int) fma(x, scale, 0.5f) and the unfused (int) (scale * x + 0.5f) against Math.round(scale * x). It found the witness 0x3b008080 at scale 255 and proved that the unfused form diverges only when fl(scale * x) is 0.49999997f; for the fused form it found the window [0.5 - 2^-25, 0.5 - 2^-26) but did not finish the "only there" query (unknown at 600 s), which the hand derivation and the executed boundary scans cover.
  • Independent verifier: its own JUnit test searched witnesses itself (3,006 quantizer configurations, a 671M-element lattice through both real kernels) instead of reusing the original witness, and reproduced at 256 bits and at a forced 512-bit vector size, with and without FMA, on main and 10.5.2; exhaustive float sweeps (FixCheck, ScaleExhaustive) confirmed the single boundary and checked the exact-rounding fix.
  • Later: reduced to the one-test repro (2026-10-29), re-verified on main 645d4fe14a20, and settled by a tie-break with two derived-witness checks (2026-11-01).

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs (including the JIRA archive), commits, mailing lists, downstream trackers or the web (2026-11-01). Related only: #14402 / #14403 and #15443 (float tolerance of the same test) and open PR #16790 (the same rounding idiom in a different method).

Searches3 searches

Closest items, none a report of this bug: #14304 (introduced the code; its review never discusses rounding), #14402 / PR #14403 and PR #15443 (the same test failed on the float-correction tolerance; only the delta changed), open PR #16790 ("+0.5f then truncate" in OptimizedScalarQuantizer kernels, applied to both implementations so they agree; does not touch minMaxScalarQuantize), #13922 (the feature request behind #14304). A search shows only that no report was found.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. migrated JIRA), commits2026-11-01three rounds of scripted searches (45 issue/PR and 6 commit queries); 11 manual searches; the changed files of all 409 open PRs, diffs of the 9 touching vectorization or quantizer files; full reads of #14304, #14402, #14403, #15443, #16790no report; #16790 is related work only
Mailing lists, JIRA archive, downstream trackers, web2026-11-0145 list queries over dev@, java-user@, issues@, commits@, builds@; 8 JIRA-archive queries; 4 downstream queries; 6 web queriesnothing beyond mirrors of the GitHub items
Earlier study search2026-10-22about 15 phrasings, including "0.49999997", "Math.round quantize", "quantize off by one", "quantized bytes differ"no report

History

Introduced by #14304 (1e8a1468768, 2025-03-25) together with the test that asserts identical bytes; first release 10.2.0. Fails on every release from 10.2.0 to 10.6.0 and on main; 10.1.0 and the 9.x line are unaffected (no vectorized quantizer). Not fixed upstream.

Versions tested5 rows

Introduced by #14304 "Create vectorized versions of ScalarQuantizer.quantize and recalculateCorrectiveOffset" (GitHub thecoop; reviewed and merged by benwtrent), commit 1e8a1468768 on main, 2025-03-25, backported as c31bea89298 the same day. git blame of the rounding lines points only to it. Original defect, not a regression: before it every provider rounded with Math.round. On JDK 25, 10.1.x and 10.2.x need -Dorg.apache.lucene.vectorization.upperJavaFeatureVersion=25 to enable their vector provider (they accept Java only up to 23 and 24, so those runs use a JDK these releases do not support); 10.3.0 and later accept JDK 25 as is. All release runs set the flag (a no-op from 10.3.0) and logged the vector provider; 10.3.0 without it fails the same way.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (standalone and gradle); passes with fix A
10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 release jarsfail with -ea and -da, expected:<0> but was:<1>
upstream main f815e679bf6c, branch_11x, branch_10_6 readsame expression (main and branch_11x: java25/, :1511 and :1567; branch_10_6: java21/, :1363 and :1419), not executed
10.1.0 not affectedpasses with the vector provider enabled: no vectorized minMaxScalarQuantize
10.0.0, 9.x readno minMaxScalarQuantize

Independent checks

Reproduced by the study's independent verifier, by a re-run on main 645d4fe14a20 with assertions on and off, and by a tie-break with two new derived-witness checks; this doc's gradle run validated fix A. The cross-model refute review (OpenAI GPT-5.6) never finished; a later cross-model fact check of the brief (OpenAI GPT-5.6) judged the defect real as described.

Checks and verdicts375 words

All completed checks agree on the mechanism and on the fix. The open question is not whether the bytes differ but how upstream rates a divergence with a negligible accuracy cost.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier (independent) real, cosmeticOwn witnesses (3,006 configurations, a 671M-element lattice through both real kernels); reproduced on main and 10.5.2 at 256 bits and at a forced 512-bit vector size, with and without FMA; passes at 128 bits and without the module.
re-verification on main 645d4fe14a20 realFails with -ea and -da; passes without the vector module; still fails without FMA; the study fix passes.
cross-model review, refute lens (OpenAI GPT-5.6) did not finishStopped before any computation, Java run or verdict.
Second adjudication real, high confidence on mechanismSame model family as the finder. Re-ran the test on main and every release 10.2.0-10.6.0 (10.1.0 passes); a public-API scan of every float in [0, 1]; Lucene's own cross-provider assertion fails for 48 of 200,000 random (min, max) pairs; 0 differences at 128 bits and with the study fix.
doc validation (gradle) fix A validatedRepro fails on stock main with assertions on and off and passes with fix A; 839 test runs in the closest core (256 and 512 bits) and backward-codecs suites pass, and :lucene:core:check -x test passes. The study's mask-cast version of the fix fails Lucene's forbidden-APIs check (VectorMask#cast). A plain local gradle run passes the repro for the wrong reason (C1-only test JVM, scalar provider).
independent fact check of this doc real; wording fixedSame model family. Re-ran the repro standalone (-ea and -da) and the documented gradle command, the controls and fix A, re-derived every number with a plain-Java model, and re-read every cited line and commit. Found three wording errors (reversed FMA on/off counts, the JDK 25 flag scope, the Z3 scope for the FMA form), fixed here.
cross-model fact check of the brief (OpenAI GPT-5.6) real; wording fixedRe-derived the witness arithmetic and read the cited code, commits and saved runs; calls the defect real as described. Its corrections (the branch_11x path, accuracy and reach wording, the FMA trigger condition, overclaims and missing caveats) are applied here.

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 notes421 words

Finding record

LFV-071, snapshot of 2026-11-06

Registry title
Panama minMaxScalarQuantize rounds differently from the default implementation at one boundary value
Area
codecs / vectors
Priority
Low, rank 1209: The Panama minMaxScalarQuantize rounds one boundary value differently from the default implementation on legacy Lucene99 segments, one byte off by one with no accuracy loss.
Impact class
minor
Impact note
Panama vector provider with legacy Lucene99 scalar-quantized segments; one boundary value; cosmetic (a latent flaky test).
Summary
You scalar-quantize vectors with the Panama vector provider. You expect the same bytes as the default implementation. Instead Panama rounds with (int)fma(x, scale, 0.5f), not Math.round, so the two can differ only when fl(scale*x) == 0.49999997f.
Introduced
#14304 (1e8a1468768) [read: git blame] (2025-03-25)
First affected version
10.2.0
Affected versions
10.2.0-10.6.0 and main [exec]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; not yet triaged by a person.
Found by
formal: Z3
Upstream and prior reports
none found (study searches 2026-10-22); #14402 / #15443 are about tolerance
Lucene commit tested
main 645d4fe14a20: the minimal repro with assertions on and off standalone and through gradle (with -Ptests.defaultvectorization=true); controls with 128-bit vectors (-XX:UseSVE=0), without the vector module, and without FMA; the exact-rounding fix in a scratch worktree with the closest core and backward-codecs suites and :lucene:core:check -x test; a public-API scan of every float in [0, 1] and a cross-provider check of 200,000 random (min, max) pairs. Release jars 10.2.0, 10.2.1, 10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2 and 10.6.0: fail; 10.1.0: passes. Upstream main f815e679bf6c and branch_11x (java25/) and branch_10_6 (java21/): same expression (read). OpenJDK 25.0.3, Linux aarch64 with 256-bit SVE.

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout: PanamaVectorUtilSupport.java:97-104, 1490-1538, 1566-1567; DefaultVectorUtilSupport.java:437-459; ScalarQuantizer.java:37-41, 101-127; PanamaVectorConstants.java:37-39
Minimal repro
  • a Lucene main checkout: VectorizationProvider.java:157-166; build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:139-152, 629-642
Why it looks unintended
  • a Lucene main checkout: PanamaVectorUtilSupport.java:1509; TestVectorUtilSupport.java:338-363; ScalarQuantizer.java:37-41; VectorUtil.java:30-43
Suggested fix
  • a Lucene main checkout: gradle/validation/forbidden-apis/non-standard/incubator-vector.txt (VectorMask#cast)

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 mechanism in the brief; the PNG is the summary frame.

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.