- 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. - 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.vectorand the CPU's preferred vector size is at least 256 bits. - 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).
Math.round writes 0. Select the still to open it at full size. Generated with AI, checked against the code.- Run Lucene with
--add-modules jdk.incubator.vectoron a CPU with 256-bit vectors and quantize 16 copies of x = 0.0019607842 (0x3b008080) withnew ScalarQuantizer(0f, 1f, (byte) 8). The scale is 255, and 255 × x is 0.49999997, the largest float below 0.5. - You expect byte 0: 0.49999997 is nearer to 0 than to 1,
Math.roundgives 0, the scalar code writes 0, and Lucene's own test asserts that both implementations write the same bytes. - 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.
- 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.
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 scalar code rounds with
int roundedDxs = Math.round(scale * dxc);(DefaultVectorUtilSupport.java:446).Math.roundis exact: the closest int, ties up. - The vector loop, which runs only when
VECTOR_BITSIZE >= 256(PanamaVectorUtilSupport.java:1497), rounds withfma(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).fmais a fused multiply-add when the CPU's FMA is fast, else multiply then add (:97-104);F2Itruncates. - 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 whereMath.roundwrites 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). - 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).
recalculateScalarQuantizationOffsetrepeats the expression (:1566-1567) but writes no bytes; only its float correction moves.
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 byLucene99ScalarQuantizedVectorScorer:69andLucene99MemorySegmentScalarQuantizedVectorScorer: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 publicScalarQuantizerdirectly (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.
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.
/*
* 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]);
}
}
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, asMath.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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also releases 10.2.0-10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64, 256-bit SVE (Lucene logs "Java vector incubator API enabled; uses preferredBitSize=256; FMA enabled") |
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)
# 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.- 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 assertsassertArrayEquals(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 intoDefaultVectorUtilSupportand 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.roundrounds 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.roundof the floatscale * 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).
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.
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.roundof 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
recalculateScalarQuantizationOffsetchange 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#castis 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,TestScalarQuantizedVectorSimilarityand 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.
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.
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.
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.
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)againstMath.round(scale * x). It found the witness0x3b008080at scale 255 and proved that the unfused form diverges only whenfl(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).
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. migrated JIRA), commits | 2026-11-01 | three 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, #16790 | no report; #16790 is related work only |
| Mailing lists, JIRA archive, downstream trackers, web | 2026-11-01 | 45 list queries over dev@, java-user@, issues@, commits@, builds@; 8 JIRA-archive queries; 4 downstream queries; 6 web queries | nothing beyond mirrors of the GitHub items |
| Earlier study search | 2026-10-22 | about 15 phrasings, including "0.49999997", "Math.round quantize", "quantize off by one", "quantized bytes differ" | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal 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 jars | fail with -ea and -da, expected:<0> but was:<1> |
upstream main f815e679bf6c, branch_11x, branch_10_6 | read | same expression (main and branch_11x: java25/, :1511 and :1567; branch_10_6: java21/, :1363 and :1419), not executed |
| 10.1.0 | not affected | passes with the vector provider enabled: no vectorized minMaxScalarQuantize |
| 10.0.0, 9.x | read | no minMaxScalarQuantize |
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.
| Check | Verdict | What it found |
|---|---|---|
| study verifier (independent) | real, cosmetic | Own 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 | real | Fails 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 finish | Stopped before any computation, Java run or verdict. |
| Second adjudication | real, high confidence on mechanism | Same 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 validated | Repro 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 fixed | Same 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 fixed | Re-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. |
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.
- PNG 433 KB
Finding record, Lucene and upstream sources, repro files and diagram notes421 words
- 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 mainf815e679bf6candbranch_11x(java25/) andbranch_10_6(java21/): same expression (read). OpenJDK 25.0.3, Linux aarch64 with 256-bit SVE.
- 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)
- TestScalarQuantizerNearestByte.java (JUnit, one test):
repro/LFV-071/src/org/apache/lucene/util/quantization/TestScalarQuantizerNearestByte.java(34 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-071/bug.png(433 KB)
