All findings · Lucene finding · LFV-084 · fp16-dequantized-view-overflows-to-infinity

Dequantized FLOAT16 vectors overflow to Infinity when the raw vectors are dropped

With a scalar-quantized FLOAT16 field whose raw vectors were dropped, Lucene rebuilds vectors from the compressed bytes without capping them at the float16 limit, so a stored -65504 comes back as -Infinity. Full-precision rescoring can then rank an exact match last.

Summary

Priority
Low, rank 1168FLOAT16 vectors read back from a quantized index with raw vectors dropped can be +-Infinity, which needs file surgery plus values near the fp16 limit, in unreleased code.
Impact
silent wrong results
Since
main: #16473 (50c1e6acb7f, 2026-08-26)introduced 2026-08-26
Affected
unreleased main and branch_11x; no release
Status
documented
Found by
formal: a Z3 floating-point query, then a real-index test

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: float16 vectors and the dropped-raw fallback

  1. A float16 value is stored in a short. The largest finite one is 65504; Float.floatToFloat16 maps 65519 to 65504 and anything of magnitude 65520 or more to Infinity.
  2. Lucene104ScalarQuantizedVectorsFormat stores each vector twice: the raw float16 values, and a compressed copy of small integers (1 to 8 bits per component) with an interval [lower, upper] measured from the centroid, the field's mean vector in that segment.
  3. A read-only index may drop the raw copy to save space; upstream's tests do it by rewriting the vector files. Reads then rebuild each component as centroid + lower + q x step, "with some precision loss".
Diagram of the bug. Doc 0, the finite float16 vector (65504, -65504, 0), is indexed with the scalar-quantized format and its raw copy is dropped. Reading it back rebuilds each component from the compressed bytes: the float reconstruction is (65461.168, -65546.8, 85.625), and -65546.8 lies past the largest float16 value 65504. Converting to float16 without a cap gives (65472, -Infinity, 85.625). Full-precision rescoring then sees an infinite distance and gives doc 0 the score 0.0. Expected: a finite approximation, such as -65504.
A stored vector at the float16 limit goes into the compressed copy, comes back slightly past the limit as a float, and turns into Infinity when it is converted to float16; rescoring then ranks the exact match last. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index two FLOAT16 vectors, (65504, -65504, 0) and (0, 0, 0), EUCLIDEAN, with Lucene104ScalarQuantizedVectorsFormat (default 8-bit encoding). 65504 is the largest finite float16 value, and the field accepts it.
  2. Drop the raw vectors after the writer closes, as the format's own tests do, so reads rebuild each vector from its compressed copy. You expect doc 0 back as roughly (65504, -65504, 0).
  3. Instead vectorValue(0) returns (65472, -Infinity, 85.625). The quantization interval reaches 42.8 past the data, so the second component rebuilds as -65546.8, and Float.floatToFloat16 turns anything of magnitude 65520 or more into Infinity.
  4. Whatever uses the rebuilt vector sees an infinite distance. In an 8-dimensional variant, full-precision rescoring of doc 0's own vector gives doc 0 the score 0.0 and ranks the zero vector first; with assertions on, VectorUtil.squareDistance fails its finiteness check. No error with assertions off.

Who is affected

Only unreleased main (and branch_11x): a FLOAT16 field on the opt-in scalar-quantized format whose raw vectors were removed by rewriting index files, with EUCLIDEAN or MAXIMUM_INNER_PRODUCT and components at or near +-65504 (tens of thousands for 1-2 bit encodings). vectorValue() and full-precision rescoring return wrong values; plain kNN search is unaffected.

Mechanism, terms and numbers678 words

Lucene104ScalarQuantizedVectorsFormat quantizes FLOAT16 vectors (since #16473). For read-only indexes whose raw float16 vectors were dropped to save storage, getFloat16VectorValues returns OffHeapScalarQuantizedFloat16VectorValues, which rebuilds each vector "with some precision loss relative to the original float16 vectors". It calls OptimizedScalarQuantizer.deQuantize(byte[], short[], ...), which narrows xi + centroid[h] with Float.floatToFloat16 and no clamp (OptimizedScalarQuantizer.java:294). The optimized interval can reach a little past the data, so a component at the fp16 limit rebuilds just beyond it and becomes Infinity: (65504, -65504, 0) reads back as (65472, -Infinity, 85.625) with the default 8-bit encoding. Full-precision rescoring then gives the document an infinite EUCLIDEAN distance (score 0.0), so an exact match can rank last; with -ea, VectorUtil.squareDistance trips its finiteness assert. The FLOAT32 sibling view stays finite on the same data. It needs the opt-in quantized format, raw vectors dropped by a file rewrite (as upstream's tests do), EUCLIDEAN or MAXIMUM_INNER_PRODUCT and components near +-65504 (tens of thousands for 1-2 bit encodings); plain kNN search is unaffected. Unreleased main and branch_11x only; saturating to +-65504 before narrowing fixes it.

Terms

  • Dequantized view: OffHeapScalarQuantizedFloat16VectorValues (@lucene.internal). Javadoc: "Used for read-only indexes whose raw float16 vectors have been dropped to save storage: only the scalar-quantized bytes remain, so vectorValue(int) reconstructs float16 values by dequantizing them, with some precision loss relative to the original float16 vectors." (OffHeapScalarQuantizedFloat16VectorValues.java:43-45).
  • Dropped raw vectors: an empty .vec file and a vector length of 0 in .vemf, written after IndexWriter closes, as TestLucene104ScalarQuantizedVectorsFormat#simulateEmptyRawVectors does (:614). The FLOAT32 view's javadoc names that method as the way to remove full-precision vectors (OffHeapScalarQuantizedFloatVectorValues.java:48-51).

The bug (main 645d4fe14a20)

  1. Lucene104ScalarQuantizedVectorsReader.getFloat16VectorValues: when the raw float16 vectors have size 0 (:327), it returns the dequantized view paired with the quantized values (:331-343).
  2. At index time OptimizedScalarQuantizer.scalarQuantize centres the vector on the centroid (:205), clamps the initial interval to the centred vector's [min, max] (:217-218), then optimizeIntervals (:219, defined at :336) can move an end past [min, max].
  3. The view's vectorValue (:105-127) calls deQuantize(byte[], short[], ...) (:117-123), which computes xi = q * step + lowerInterval (OptimizedScalarQuantizer.java:293) and Float.floatToFloat16((float) (xi + centroid[h])) with no clamp (:294). Its javadoc: "narrowing each reconstructed value to float16" (:272-273). The view is its only production caller.
  4. A rebuilt value of magnitude 65520 or more becomes +-Infinity. VectorUtil.squareDistance(short[], short[]) asserts a finite result (VectorUtil.java:144-151, assert :149); with assertions off, EUCLIDEAN scores the infinite distance as 1 / (1 + Infinity) = 0.0.
  5. Affected readers: Float16VectorValues#vectorValue and #rescorer on the view, so RescoreTopNQuery.createFullPrecisionRescorerQuery(Query, short[], String, int) (RescoreTopNQuery.java:302-306) and, by code reading, the queries module's Float16KnnVectorFieldSource (:71). Plain kNN search scores in quantized space and was executed unaffected.

The numbers (probe on main 645d4fe14a20)

doc 0 (65504, -65504, 0)doc 1 (0, 0, 0)
centroid(32752, -32752, 0)same
optimized interval (centred)[-32794.797, 32709.172] (data range [-32752, 32752])same
quantized bytes[255, 0, 128][0, 255, 128]
float reconstruction(65461.168, -65546.8, 85.625)(-42.797, -42.831, 85.625)
float16 read-back(65472, -Infinity, 85.625)(-42.8125, -42.84375, 85.625)

The gradle runs of the repro print only its message (-Infinity) with assertions on and off; the whole read-back above was printed by probes with -ea and -da.

Consequences executed on 645d4fe14a20 by probes and larger tests (not part of the repro). On the repro's two documents, rescoring doc 0's own vector with RescoreTopNQuery returns [doc=1 1.17e-10, doc=0 0.0] (-da). Default encoding, EUCLIDEAN, docs [65504, -65504, 65504, -65504, 1000, -1000, 0, 0], its negation and the zero vector: [doc=2 5.83e-11, doc=0 0.0, doc=1 0.0]. 1-bit encoding (HNSW variant), (60000, 49984, 0, 0), (20000, -49984, 0, 0), (40000, 0, 0, 0): doc 0 reads back as (Infinity, 33824, 1171, 1171) and ranks last, [doc=2 3.45e-10, doc=1 1.01e-10, doc=0 0.0]. In the controls with raw vectors kept, doc 0 ranks first. With -ea the rescoring stops at VectorUtil.java:149.

Exposure and likelihood314 words
  • Who is hit. Applications on unreleased main and branch_11x that index FLOAT16 vectors with Lucene104ScalarQuantizedVectorsFormat or Lucene104HnswScalarQuantizedVectorsFormat (both executed) and then drop the raw vectors for a read-only searcher index. No IndexWriter API does that today; open #15630 / #13158 would make the writer produce such files.
  • Similarities. EUCLIDEAN and MAXIMUM_INNER_PRODUCT (both executed). COSINE vectors are scaled to unit length before quantization (Lucene104ScalarQuantizedVectorsWriter.java:903-905), so they never come near the limit. DOT_PRODUCT is documented to need unit-length vectors (VectorSimilarityFunction.java:53-57), but nothing enforces that; it was not tested.
  • How large. There is no simple threshold: it depends on the optimized interval end, the centroid and the code a component gets. What the probes saw: with the default 8-bit encoding, 40 of 40 random 22-dimensional segments with about 25% of components at exactly +-65504 overflowed, and 0 of 200 with gaussian values of sigma 15000 capped at 60000 did. With 1 and 2 bits, values in the tens of thousands were enough (60000 at 1 bit, -50944 at 2 bits). All 5 encodings overflow on a witness at +-65504. Values exactly at 65504 arise, for example, when an application saturates float32 values while converting them to fp16.
  • What happens. vectorValue() returns an infinite component with no error. Full-precision rescoring gives the document an infinite EUCLIDEAN distance (score 0.0), so an exact match can rank last; under MAXIMUM_INNER_PRODUCT the scores became NaN or +Infinity and moved such documents into the top 3. With -ea the rescore ends in an AssertionError.
  • What does not happen. No index damage, no hang, no change to the inner query's match set; plain kNN search returns identical hits and scores with raw vectors kept and dropped.
  • Scale. Opt-in on every axis. A single affected document is enough, in a segment that also holds other vectors; a one-document segment reads back exactly, because its centroid is the vector itself.

Minimal repro

One JUnit test: index (65504, -65504, 0) and (0, 0, 0) with the default scalar-quantized format, drop the raw vectors with a copy of the format test's simulateEmptyRawVectors, and check that vectorValue(0)[1] is finite. Deterministic, single thread, about 2 s; the same result with assertions on and off.

TestFloat16DequantizedViewOverflow.java (JUnit, one test)107 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.codecs.lucene104;

import java.io.IOException;
import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.codecs.lucene95.OrdToDocDISIReaderConfiguration;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloat16VectorField;
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.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestFloat16DequantizedViewOverflow extends LuceneTestCase {

  public void testDequantizedValuesAreFinite() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc =
          new IndexWriterConfig()
              .setCodec(
                  TestUtil.alwaysKnnVectorsFormat(new Lucene104ScalarQuantizedVectorsFormat()))
              .setUseCompoundFile(false);
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (float[] v : new float[][] {{65504f, -65504f, 0f}, {0f, 0f, 0f}}) {
          short[] h = new short[v.length];
          for (int i = 0; i < v.length; i++) {
            h[i] = Float.floatToFloat16(v[i]);
          }
          Document doc = new Document();
          doc.add(new KnnFloat16VectorField("v", h, VectorSimilarityFunction.EUCLIDEAN));
          w.addDocument(doc);
        }
      }
      simulateEmptyRawVectors(dir);
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        short[] v = r.leaves().get(0).reader().getFloat16VectorValues("v").vectorValue(0);
        float y = Float.float16ToFloat(v[1]);
        assertTrue("expected a finite approximation of -65504, got " + y, Float.isFinite(y));
      }
    }
  }

  private static void simulateEmptyRawVectors(Directory dir) throws IOException {
    for (String file : dir.listAll()) {
      if (file.endsWith(".vec")) {
        byte[] header;
        try (IndexInput in = dir.openInput(file, IOContext.DEFAULT)) {
          header = CodecUtil.readIndexHeader(in);
        }
        dir.deleteFile(file);
        try (IndexOutput out = dir.createOutput(file, IOContext.DEFAULT)) {
          out.writeBytes(header, header.length);
          CodecUtil.writeFooter(out);
        }
      } else if (file.endsWith(".vemf")) {
        byte[] header;
        int field, encoding, similarity, dim;
        long offset;
        try (IndexInput in = dir.openInput(file, IOContext.DEFAULT)) {
          header = CodecUtil.readIndexHeader(in);
          field = in.readInt();
          encoding = in.readInt();
          similarity = in.readInt();
          offset = in.readVLong();
          in.readVLong();
          dim = in.readVInt();
        }
        dir.deleteFile(file);
        try (IndexOutput out = dir.createOutput(file, IOContext.DEFAULT)) {
          out.writeBytes(header, header.length);
          out.writeInt(field);
          out.writeInt(encoding);
          out.writeInt(similarity);
          out.writeVLong(offset);
          out.writeVLong(0);
          out.writeVInt(dim);
          out.writeInt(0);
          OrdToDocDISIReaderConfiguration.writeStoredMeta(16, out, null, 0, 0, null);
          out.writeInt(-1);
          CodecUtil.writeFooter(out);
        }
      }
    }
  }
}
Command
cp TestFloat16DequantizedViewOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestFloat16DequantizedViewOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
A finite approximation of -65504 for doc 0's second component (the field accepted only finite values).
Observed
expected a finite approximation of -65504, got -Infinity, with assertions on and off.

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

More files, environment, full output264 words

One JUnit class, one test method, public API only (it sits in org.apache.lucene.codecs.lucene104 like the format's own tests but uses no package-private member), ByteBuffersDirectory, an explicit codec, nothing randomized. 2 documents of 3 dimensions is the smallest default-encoding input found that overflows (1-dimensional vectors, single documents and 2-dimensional pairs read back finitely). The helper is a condensed copy of TestLucene104ScalarQuantizedVectorsFormat#simulateEmptyRawVectors (16 is DIRECT_MONOTONIC_BLOCK_SHIFT), because that method lives in core's test sources.

As the regression test: this same test, for example as a new method in TestLucene104ScalarQuantizedVectorsFormat, which already has simulateEmptyRawVectors(dir). With the fix it passes with assertions on and off, and :lucene:core:check -x test passes with the test file added. It compiles only where the float16 API exists (main from #16383) and fails from #16473 on.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); no release has the fp16 dequantized view
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestFloat16DequantizedViewOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestFloat16DequantizedViewOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestFloat16DequantizedViewOverflow > testDequantizedValuesAreFinite FAILED
    java.lang.AssertionError: expected a finite approximation of -65504, got -Infinity
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8E7A13E97D1A25A2]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.codecs.lucene104.TestFloat16DequantizedViewOverflow.testDequantizedValuesAreFinite(TestFloat16DequantizedViewOverflow.java:60)

# probe (not part of the repro): the values behind it
centroid=[32752.0, -32752.0, 0.0]
ord=0 interval=[-32794.797, 32709.172] quantized=[255, 0, 128] floatReconstruction=[65461.168, -65546.8, 85.625] fp16ReadBack=[65472.0, -Infinity, 85.625]
ord=1 interval=[-32794.797, 32709.172] quantized=[0, 255, 128] floatReconstruction=[-42.796875, -42.83142, 85.625] fp16ReadBack=[-42.8125, -42.84375, 85.625]
floatToFloat16(65519.0) = 65504.0    floatToFloat16(65520.0) = Infinity

# with the saturating fix: the test passes with assertions on and off

Why it looks unintended

  • The view promises the stored vectors back "with some precision loss"; upstream's test of this mode asserts the quantization error bound.
  • The field accepts only finite values, and VectorUtil.squareDistance asserts a finite result.
  • The FLOAT32 view rebuilds the same data finitely and ranks the exact match first.
The full argument260 words
  • The view's contract. "reconstructs float16 values by dequantizing them, with some precision loss relative to the original float16 vectors" (OffHeapScalarQuantizedFloat16VectorValues.java:43-45; the "relative to the original" wording came from a review suggestion by msokolov in #16473).
  • Upstream tests assert an error bound in this mode. testReadQuantizedFloat16VectorWithEmptyRawVectors "asserts they stay within the quantization error bound" (TestLucene104ScalarQuantizedVectorsFormat.java:444-449, added by #16473); the shared base test says formats "maintain accuracy within expected error bounds even when the original raw vector file is empty" (BaseKnnVectorsFormatTestCase.java:2187-2189). Both use small random vectors, far from 65504: the fp16 test unit-length vectors (TestLucene104ScalarQuantizedVectorsFormat.java:461), the base test unit-length vectors for COSINE and otherwise components in [0, 1) (BaseKnnVectorsFormatTestCase.java:2202-2204, :1903-1915).
  • Finite in, finite out. KnnFloat16VectorField rejects non-finite values (checkFiniteFloat16, KnnFloat16VectorField.java:72); VectorUtil.squareDistance(short[], short[]) asserts a finite result (:149).
  • The FLOAT32 sibling. On the finder's 1-bit data with raw vectors dropped, OffHeapScalarQuantizedFloatVectorValues rebuilds doc 0 as [73820.7, 33820.707, 1171.291, 1171.291] and ranks it first; on the repro's two documents it reads doc 0 back as [65461.168, -65546.8, 85.625], finite. For this data the difference that matters is that it keeps the float reconstruction, while the FLOAT16 view narrows it without saturating.
  • A supported mode. CHANGES.txt: #14792 (introduced the fallback), #15476 ("Enforce fallback support for float vector retrieval in quantized KNN vector formats"), #16415 ("Fix scoring on scalar-quantized indexes whose raw float vectors have been dropped", 10.5.1).
  • No sign of intent. None of the 41 #16473 PR texts mentions overflow, infinity, 65504, saturation, clamping, range or finiteness.
The devil's-advocate check266 words

The reviews by a reviewer from another model family (OpenAI GPT-5.6) voted not real on these grounds, while reproducing the bug and agreeing on the cause and the fix: (a) deQuantize(byte[], short[], ...) is a public static method documented as "narrowing each reconstructed value to float16", and TestOptimizedScalarQuantizer#testDeQuantizeFloat16MatchesFloat asserts equality with Float.floatToFloat16 of the float overload, so Infinity is the helper's specified result; (b) the trigger state exists only after rewriting index files ("The raw vector file is emptied below, which CheckIndex rightly rejects", BaseKnnVectorsFormatTestCase.java:2275), the view is @lucene.internal, and SECURITY.md calls modified index files undefined behaviour; (c) 65504 is extreme.

Why it fails: (a) only decides where the clamp goes; the helper's only production caller is the view, whose contract is an approximation, and the helper's own test (values in [0, 1)) passes with the clamp. (b) The quantized data is exactly what IndexWriter wrote; only the raw-vector files are rewritten (.vec emptied, its metadata .vemf set to 0 vectors; .veq and .vemq untouched), which is the mode's purpose, and upstream tests, documents and bug-fixes that mode. The quoted CheckIndex comment is in a FLOAT32 test: on the repro's index CheckIndex reports no problem before or after the drop, and its vector test covers 0 fields, because at main it skips FLOAT16 vector fields (CheckIndex.java:2864-2867; see LFV-089). (c) The values are legal input the field accepts, and the FLOAT32 path handles them. Verdict: a defect; a maintainer may still call it hardening of an opt-in internal mode, which is why fix A pairs the clamp with a javadoc sentence.

Suggested fix

Recommended A. Saturate to +-65504 in deQuantize(byte[], short[], ...), with a javadoc sentence

Saturate the rebuilt value to +-65504 before narrowing in OptimizedScalarQuantizer.deQuantize(byte[], short[], ...), and say so in its javadoc (+7/-2 lines). With it the repro passes with assertions on and off, the 6 closest core test classes (149 tests) pass, and :lucene:core:check -x test passes.

// the largest finite float16 value
private static final float FLOAT16_MAX = 65504f;
...
double xi = (double) (quantized[h] & 0xFF) * step + lowerInterval;
double value = Math.clamp(xi + centroid[h], -FLOAT16_MAX, FLOAT16_MAX);
dequantized[h] = Float.floatToFloat16((float) value);
All options and trade-offs4 options ยท 641 words

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

A. Saturate to +-65504 in deQuantize(byte[], short[], ...), with a javadoc sentence (validated by the checks) (recommended)

Clamp xi + centroid[h] to [-65504, 65504] before Float.floatToFloat16, and add to the javadoc: "The quantization interval can reach slightly past the range of the original values, so reconstructed values beyond the finite float16 range are saturated to +/-65504 instead of becoming infinite." Full diff:

--- a/lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java
+++ b/lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java
@@ -53,6 +53,8 @@
         {-3.611f, 3.611f},
         {-3.922f, 3.922f}
       };
+  // the largest finite float16 value
+  private static final float FLOAT16_MAX = 65504f;
   // the default lambda value
   private static final float DEFAULT_LAMBDA = 0.1f;
   // the default optimization iterations allowed
@@ -270,7 +272,9 @@
    * Dequantizes a quantized byte vector back to float16 values.
    *
    * <p>Behaves as {@link #deQuantize(byte[], float[], byte, float, float, float[])}, narrowing each
-   * reconstructed value to float16.
+   * reconstructed value to float16. The quantization interval can reach slightly past the range of
+   * the original values, so reconstructed values beyond the finite float16 range are saturated to
+   * +/-65504 instead of becoming infinite.
    *
    * @param quantized the quantized byte vector to dequantize
    * @param dequantized the output array to store dequantized float16 bit patterns
@@ -291,7 +295,8 @@
     double step = (upperInterval - lowerInterval) / nSteps;
     for (int h = 0; h < quantized.length; h++) {
       double xi = (double) (quantized[h] & 0xFF) * step + lowerInterval;
-      dequantized[h] = Float.floatToFloat16((float) (xi + centroid[h]));
+      double value = Math.clamp(xi + centroid[h], -FLOAT16_MAX, FLOAT16_MAX);
+      dequantized[h] = Float.floatToFloat16((float) value);
     }
     return dequantized;
   }
// the largest finite float16 value
private static final float FLOAT16_MAX = 65504f;
...
double xi = (double) (quantized[h] & 0xFF) * step + lowerInterval;
double value = Math.clamp(xi + centroid[h], -FLOAT16_MAX, FLOAT16_MAX);
dequantized[h] = Float.floatToFloat16((float) value);

Trade-offs

  • A value at the limit rebuilds as 65504 instead of Infinity: an error no larger than the float reconstruction's own (42.8 in the repro).
  • In-range values are unchanged bit for bit, so the helper's equality test still passes.
  • It changes the documented behaviour of a public static method of an @lucene.experimental class (hence the javadoc sentence); the view is its only production caller.
  • Cost: one clamp per component on the fallback read path. No on-disk change; existing indexes are fixed on read.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off.
  • With the fix: TestOptimizedScalarQuantizer (7), TestLucene104ScalarQuantizedVectorsFormat (65), TestLucene104HnswScalarQuantizedVectorsFormat (64), TestLucene104ScalarQuantizedMergeScorer (9), TestQuantizedVectorValuesPrefetch (3) and the repro: 149 tests, 0 failures, 4 skipped (two nightly testRecall, two empty-raw base tests the HNSW test class opts out of).
  • ./gradlew :lucene:core:check -x test with the fix and the repro as a new test file: BUILD SUCCESSFUL (google-java-format, ecj lint, forbidden APIs, licenses; error-prone skipped).
  • Earlier, the same clamp shadowed into the stock jars made the finder's, the independent reproducer's and the cross-model reviewers' tests pass. The full Lucene test suite was not run.

B. Saturate in the view instead

In OffHeapScalarQuantizedFloat16VectorValues.vectorValue, dequantize into a float[] scratch with the float overload and narrow with saturation.

Trade-offs

Keeps the helper's documented plain narrowing; costs a dimension-sized float scratch per view and duplicates the narrowing loop. Not validated.

C. Keep the optimized interval inside the fp16 range at write time

Constrain the interval so that centroid + lower and centroid + upper stay within +-65504.

Trade-offs

Changes the quantized encoding and quantized scores of new segments and does not fix existing indexes. Not recommended, not validated.

D. Document that reconstructions may be infinite

A javadoc note on the view.

Trade-offs

Keeps the wrong rescoring order and the assertion failure, and contradicts the view's "precision loss" wording and the error-bound tests. Not recommended.

Downstream note

No release has this code; until a fix lands, a service on main that drops raw FLOAT16 vectors should not trust vectorValue() or full-precision rescoring for vectors with components near the fp16 limit.

How it was found

Formal: a Z3 floating-point query over the quantizer's float16 read path showed that a legal float16 component inside the quantization interval can rebuild to a non-finite value, at 1, 4 and 8 bits. A probe of the real quantizer gave a witness, and a JUnit test on a real index confirmed it with assertions on and off.

The method in full143 words
  • Method: floating-point SMT precondition chaining (Z3), the hunt's formal tier, in the hunt unit on optimized scalar quantization. The obligation "the fp16 reconstruction of a legal in-interval fp16 component is finite" was refuted for 1, 4 and 8 bits (its violation is satisfiable); two more queries proved that fp16 narrowing overflows iff |y| >= 65520 and that a clamp keeps values finite.
  • Witness: a real-jar probe of scalarQuantize + deQuantize(short[]) over random fp16 vectors hit +-Infinity for 1, 2, 4, 7 and 8 bits; then a JUnit test on a real index with the raw vectors dropped (read-back and rescoring) failed 2/2 with -ea and -da, and the clamp made it pass.
  • Later: an independent reproducer, an intended-behaviour devil's advocate, cross-model reviews and an adjudication checked it (2026-10-25); the test was reduced to one method (2026-10-29) and re-verified on main 645d4fe14a20.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and review threads, commits, the JIRA archive, the mailing lists, downstream projects or the web (searched 2026-10-25, re-checked 2026-11-01). Open PR #16790 touches the quantizer but leaves the narrowing line unchanged.

Searches4 searches

Closest items, none a report of this bug: #16473 (introduced the code; its tests use unit-length vectors or values in [0, 1)), #16383 (non-finite fp16 query targets led to checkFiniteFloat16, the input side), #12373 (closed FP32 input-magnitude limit; the fix here is not an input limit), #12281 (finite-vector rule), #16415 and #15576 (FLOAT32 dropped-raw view bugs fixed as bugs), #15630 and #13158 (open work toward writer-produced empty raw vectors), #16533 and #16725 (open fp16 work). Downstream precedent, not a report: OpenSearch k-NN #1493 chose clip-to-max for fp16 because infinite components mean a vector is never returned. Related findings on this site: LFV-085, the same dropped-raw fallback but a different defect and fix (rescorer().bulk() reads quantized bytes as float32 vectors; known: dweiss described it on #16030, which was reverted for an unrelated reason), and LFV-089 (CheckIndex skips FLOAT16 vector fields). Gaps: a search shows only that no report was found; private channels were not searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs, commits and review threads2026-10-25five scripted prior-report searches (48 issue/PR and 9 commit queries); 62 distinct manual read-only searches; the changed files of all 412 open PRs; the review threads of #16473, #16383, #15549, #16710, #16628, #16687, #16696 and #15630 read directlyno report; no open PR touches the view, and #16790 leaves the narrowing line unchanged
Mailing lists, JIRA archive, downstream, web2026-10-25100 list queries over dev@, java-user@, issues@ and commits@; 16 JIRA-archive queries; 6 downstream queries (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr); 15 web queriesno report (the only list hits are mirrors of #16383; JIRA: an unrelated 2016 HalfFloatPoint item)
Re-check: apache/lucene issues and PRs2026-11-0112 read-only searches sorted by creation date (OffHeapScalarQuantizedFloat16VectorValues, "deQuantize float16", "float16 infinity quantized", "fp16 dequantize overflow", "floatToFloat16 clamp", "simulateEmptyRawVectors float16"; issues and PRs); the changed files of all 99 PRs updated since 2026-10-25nothing new: 0 issue hits, one known PR (#16710); only #16790 touches the quantizer, still without the narrowing line
Upstream main and branches2026-11-01file contents at upstream main 2f424fd428d (2026-10-30) and branch_11x 603af870375not fixed; both files unchanged since #16473 apart from a reverted rewrite that kept the line

History

Introduced by #16473 (50c1e6acb7f, merged 2026-08-26), which added float16 scalar quantization and the dropped-raw float16 view; not a regression. No release has it: 10.6.0, 10.5.2 and 9.12.3 lack float16 vectors. branch_11x carries identical files, so 11.0.0 would ship it if nothing changes.

Versions tested6 rows

Introduced by #16473 "Add scalar quantization support for Fp16 vector encoding" (GitHub Pulkitg64; reviewed and approved by msokolov), commit 50c1e6acb7f on main, 2026-08-26. git blame and git log -S attribute the narrowing line and the view to it; its parent 091a987a14d has no fp16 dequantized view. Not a regression: both are first-version code. #16628 (55fd2e75fb2, 2026-09-28) only added the RescoreTopNQuery fp16 entry point; the wrong order was already reachable through Float16VectorValues#rescorer. #16030 rewrote the quantizer and was reverted, keeping the line.

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
main 50c1e6acb7f (#16473), 8d999e9487d, 55fd2e75fb2, 7cecf795236, ea57360912ed, a9779ef3c193 introducing commit and laterall fail with -ea (infinite read-back; rescoring trips VectorUtil.java:149); -da also at 50c1e6acb7f, 55fd2e75fb2 and ea57360912ed: rescoring ranks the exact match last
upstream main 2f424fd428d, branch_11x 603af870375 readidentical files, not executed; branch_11x contains 50c1e6acb7f
parent 091a987a14d not affectedno fp16 dequantized view: the view tests are skipped
10.5.2, 9.12.3 not affectedno float16 vector API: the repro does not compile
10.6.0, branch_10_6 readno KnnFloat16VectorField, no FLOAT16 encoding, no fp16 view; 10.6.0 does not contain 50c1e6acb7f

Independent checks

Confirmed (medium-high) by an adjudication: an independent reproducer and an intended-behaviour devil's advocate said real; reviews by a reviewer from another model family (OpenAI GPT-5.6) reproduced it and agreed on cause and fix but voted not real on contract grounds. Re-verified on main 645d4fe14a20; this doc's gradle run validated the fix.

Checks and verdicts303 words

All checks agree on the root cause and that the clamp fixes it. The dissents are about the contract (the helper's javadoc and the file-rewrite trigger) and are answered in the devil's-advocate check.

Each independent check and its verdict
CheckVerdictWhat it found
cross-model review, refute lens (OpenAI GPT-5.6) not real, high confidenceReproduced it with its own witness (source 64000, float reconstruction 75063.82, float16 Infinity), agreed on the root cause and the fix; held the inputs out of contract.
independent reproducer realOwn repro and witness: all 5 encodings x EUCLIDEAN and MAXIMUM_INNER_PRODUCT, with -ea and with -da; with -da also 10/10 iterations and the scalar vectorization provider; raw-kept and quarter-scale controls pass; the fix passes.
cross-model review, intended lens (OpenAI GPT-5.6) not real, medium confidenceReproduced it and ran the fix against the upstream suites (7/7, 64 run, 0 failed); called it works-as-designed (helper javadoc, CheckIndex-rejected state; CheckIndex in fact reports this index clean), adding that maintainers may accept the clamp as robustness.
intended-behaviour devil's advocate real, medium-highThe FLOAT32 view stays finite on the same data and ranks the exact match first; upstream supports and bug-fixes the dropped-raw mode; asked for a javadoc change with the clamp.
adjudication confirmed, medium-highSided with the independent reproducer and the devil's advocate (real), with a written answer to the intended-lens review; re-ran the reproducer's test with a new seed (fails under -ea and -da, passes with the fix).
main refreshes still reproducesRe-run on main a9779ef3c193 and 645d4fe14a20 with the same failures; reduced to a one-method test on 2026-10-29.
brief validation (gradle) fix A validatedRepro fails on stock main with assertions on and off and passes with fix A; 149 tests in 6 core classes and the core module's static checks pass.

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

Finding record

LFV-084, snapshot of 2026-11-06

Registry title
FLOAT16 vectors read back from a quantized index whose raw vectors were dropped can contain +-Infinity, so full-precision rescoring ranks an exact match last
Area
codecs / vectors
Priority
Low, rank 1168: FLOAT16 vectors read back from a quantized index with raw vectors dropped can be +-Infinity, which needs file surgery plus values near the fp16 limit, in unreleased code.
Impact class
silent wrong results
Impact note
Users of unreleased main and branch_11x (11.0.0) with a FLOAT16 field on the opt-in Lucene104 scalar-quantized format whose raw vectors were dropped (the read-only fallback, made by rewriting index files after IndexWriter closes), EUCLIDEAN or MAXIMUM_INNER_PRODUCT, and components near the fp16 limit (default 8-bit encoding: at or within about one quantization step of +-65504; 1-2 bit: tens of thousands are enough); ordinary scale; plain kNN search is unaffected.
Summary
You index finite FLOAT16 vectors with large components (for example 60000) in the Lucene104 scalar-quantized format, then drop the raw vectors the way the format's own tests do (simulateEmptyRawVectors), so reads go through the dequantized fp16 view. You expect getFloat16VectorValues().vectorValue() to return an approximation of each stored vector, and full-precision rescoring with a document's own vector to rank that document first. Instead OptimizedScalarQuantizer.deQuantize(byte[], short[], ...) narrows a slightly out-of-range float reconstruction to fp16 without saturating, so 60000 comes back as Infinity, and rescoring gives the exact match the score 0.0 and ranks it last (with -ea, an AssertionError in VectorUtil.squareDistance).
Introduced
#16473 (50c1e6acb7f) [read: git log -S/-G and git blame on the narrowing line and the view; exec: the history repro fails at 50c1e6acb7f, and its parent 091a987a14d has no fp16 dequantized view] (2026-08-26)
First affected version
unreleased (main and branch_11x, 11.0.0-SNAPSHOT; no release tag contains 50c1e6acb7f)
Affected versions
main from 50c1e6acb7f (2026-08-26) through 645d4fe14a2 [exec: 50c1e6acb7f, 8d999e9487d, 55fd2e75fb2, 7cecf795236, ea57360912ed and 645d4fe14a2 (2026-10-29, -ea and -da) fail]; 10.5.2 and 9.12.3 not affected (no fp16 vector API, the repro does not compile) [exec]; branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b have no FLOAT16 encoding and no fp16 deQuantize [read 2026-10-29]; branch_11x 603af870375 contains 50c1e6acb7f and has both files unchanged [read 2026-11-01]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
formal: Z3
Upstream and prior reports
none found (2026-10-25: 5 scripted prior-report searches with 48 GitHub issue/PR, 9 commit, 6 downstream, 100 mailing-list and 16 JIRA-archive queries; 62 manual read-only GitHub searches; file lists of all 412 open PRs; review threads of 8 fp16 PRs; 15 web queries); related prior items: #16473 (introduced the code), #16383 (input-side checkFiniteFloat16), #16415 and #15576 (FLOAT32 dropped-raw view bugs fixed as bugs), #15630 / #13158 (open work toward writer-produced empty raw vectors); open #16790 and #16871 touch nearby code but not the narrowing line
Lucene commit tested
main 645d4fe14a20: the minimal repro with assertions on and off standalone and through gradle; the saturating fix in a scratch worktree with 6 core test classes (149 tests) and :lucene:core:check -x test; a probe printing the interval, bytes and float reconstruction; the finder's and the independent reproducer's rescoring tests. Main 50c1e6acb7f (#16473), 8d999e9487d, 55fd2e75fb2, 7cecf795236, ea57360912ed, a9779ef3c193: fail. Parent 091a987a14d: no fp16 view. 10.5.2 and 9.12.3: no fp16 API (the repro does not compile). 10.6.0 and branch_10_6: no fp16 view (read). Upstream main 2f424fd428d and branch_11x 603af870375: identical files (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:269-297 (javadoc 'narrowing each reconstructed value to float16'; unclamped Float.floatToFloat16 at :294)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloat16VectorValues.java:39-48 (view javadoc 'with some precision loss relative to the original float16 vectors', @lucene.internal), :106-127 (vectorValue -> deQuantize)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:327-344 (dropped-raw FLOAT16 branch)
  • lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:144-151 (squareDistance(short[], short[]) asserts a finite result)
  • lucene/core/src/java/org/apache/lucene/document/KnnFloat16VectorField.java:69-73 (checkFiniteFloat16 on field values)
  • lucene/core/src/test/org/apache/lucene/util/quantization/TestOptimizedScalarQuantizer.java:81 (testDeQuantizeFloat16MatchesFloat, plain narrowing on in-range data)
  • lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:614 (simulateEmptyRawVectors); lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:2275 ('CheckIndex rightly rejects')
  • SECURITY.md:28, 30-34, 48-50 (threat model)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:307-347 (dropped-raw FLOAT16 branch :327-344)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloat16VectorValues.java:39-48 (javadoc), :105-127 (vectorValue -> deQuantize :117-123), :239, :247 (scorer)
  • lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:40 (@lucene.experimental), :187-237 (scalarQuantize; centring :205, min/max :206-207, initial interval :217-218, optimizeIntervals :219, :336), :270-297 (deQuantize(byte[], short[], ...); narrowing :294)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloatVectorValues.java:39-51 (javadoc naming simulateEmptyRawVectors), :130, :143 (float[] overload)
  • lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:144-151 (squareDistance(short[], short[]) asserts a finite result)
  • lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:302-306; lucene/core/src/java/org/apache/lucene/search/FullPrecisionFloat16VectorSimilarityValuesSource.java:79-87
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/Float16KnnVectorFieldSource.java:71
  • lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:614 (simulateEmptyRawVectors)
Who is affected
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:903-905 (COSINE scaled to unit length); lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:53-57 (DOT_PRODUCT javadoc)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloat16VectorValues.java:43-45, :47
  • lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:444-449
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:2187-2189, 2275
  • lucene/core/src/java/org/apache/lucene/document/KnnFloat16VectorField.java:72; lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:149
  • lucene/core/src/test/org/apache/lucene/util/quantization/TestOptimizedScalarQuantizer.java:81-113
  • lucene/CHANGES.txt (GITHUB#14792, GITHUB#15476 in 10.4.0; GITHUB#16415 in 10.5.1; GITHUB#16473 in 11.0.0)
Prior reports
  • a Lucene clone (2026-11-01): git rev-parse <ref>:<path> of OptimizedScalarQuantizer.java and OffHeapScalarQuantizedFloat16VectorValues.java at origin/main, origin/branch_11x and 645d4fe14a20
History
  • a Lucene clone (2026-11-01): git rev-parse <ref>:<path> for releases/lucene/10.6.0, origin/branch_10_6, origin/branch_11x, origin/main; git merge-base --is-ancestor 50c1e6acb7f releases/lucene/10.6.0 (false), origin/branch_11x (true)
  • a Lucene main checkout: git blame -L 294,294 OptimizedScalarQuantizer.java; git blame -L 43,45 OffHeapScalarQuantizedFloat16VectorValues.java (50c1e6acb7f2)

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.