A BytesRef may point into the middle of a larger array. A shape doc value built from such a slice reads its header at the right place, then rewinds to byte 0 of the array, so relate() answers for whatever bytes start the array.
rewind() goes to byte 0, so relate() answers for B. Select the still to open it at full size. Generated with AI, checked against the code.- Encode two squares, B (latitude and longitude 40-50) and A (0-10), 76 bytes each, and copy them back to back into one array: B in bytes 0-75, A in bytes 76-151. Build A's doc value with
LatLonShape.createLatLonShapeDocValues(new BytesRef(buffer, 76, 76)). - You expect A's answers: box 5-15 crosses A (CROSSES), box -20 to 20 contains it (INSIDE), box 45-55 misses it (OUTSIDE).
getBoundingBox()andgetCentroid()do report A's. relate()returns OUTSIDE, OUTSIDE, CROSSES: B's answers. The same withXYShape. In aMemoryIndexthat holds the slice as a shape field, an INTERSECTS box query misses A and a DISJOINT one matches it.- Why: the reader reads the header at byte 76, then calls
ByteArrayDataInput.rewind(), which goes to position 0 of the array, not of the slice (its own comment says so). Everyrelate()then decodes B. - Not affected:
IndexWriter+IndexSearcherwithnewSlowDocValuesBoxQueryon Lucene's codecs, whose per-document values start at offset 0. On 10.6.0 (released 2026-10-29), main andbranch_10x, the new bulkBinaryDocValues#binaryValuesreturns slices, so there doc 1 relates as doc 0.
Code that builds shape doc values from slices: the public factories (since 9.5.0), a MemoryIndex field built from a slice (since 9.4.0), or values from the bulk binaryValues API (10.6.0, released 2026-10-29). Relations are wrong in either direction, silently when a valid shape precedes the slice. Ordinary indexing and search on Lucene's codecs are not affected; experimental API. Rare.
Mechanism, terms and numbers632 words
LatLonShape.createLatLonShapeDocValues(BytesRef), XYShape.createXYShapeDocValues(BytesRef) and createDocValueField(String, BytesRef) accept any BytesRef, which Lucene defines as a slice (offset + length) of a larger array. ShapeDocValues reads the slice's header at the offset, so the bounding box and centroid are right, but its reader then calls ByteArrayDataInput.rewind(), which sets the position to 0 of the whole array; every relate() decodes from there. With square B in bytes 0-75 and square A in bytes 76-151, A's doc value answers OUTSIDE, OUTSIDE, CROSSES for boxes that cross A, contain A and touch only B, instead of CROSSES, INSIDE, OUTSIDE: B's answers; on a MemoryIndex, an INTERSECTS newSlowDocValuesBoxQuery misses A and DISJOINT matches it. With a valid shape before the slice, as here, there is no error, assertions on or off; other prefix bytes give garbage or an exception. Reached through the public factories (since 9.5.0), MemoryIndex (since 9.4.0) and, since 10.6.0 (released 2026-10-29), the bulk BinaryDocValues#binaryValues API, which returns slices; ordinary IndexWriter + IndexSearcher on Lucene's codecs reads values at offset 0 and is not affected. The classes are @lucene.experimental. The defect came with the feature (LUCENE-10654, 2022); a small fix in the inner class ShapeDocValues.Reader that rewinds to the slice's start passes the repros and the shape and MemoryIndex suites.
- Shape doc values:
LatLonShapeandXYShapecan store a shape as one binary doc value: a header (version, number of triangles, bounding box, centroid) followed by a tree of the shape's triangles.LatLonShapeDocValues/XYShapeDocValuesread it back;relate(Component2D)answers whether a query shape is outside, inside or crossing it. The slow doc-values shape queries call it per document. BytesRefslice: aBytesRefisbytes[offset .. offset + length), "a slice (offset + length) into an existing byte[]" (BytesRef.java:22). Several values can share one array at different offsets.ByteArrayDataInput: a reader over a byte array.reset(bytes, offset, len)starts it at the offset;rewind()sets the position to 0, under the comment "NOTE: sets pos to 0, which is not right if you had called reset w/ non-zero offset!!" (ByteArrayDataInput.java:51-55).
ShapeDocValues.Readeropensnew ByteArrayDataInput(binaryValue.bytes, binaryValue.offset, binaryValue.length)(ShapeDocValues.java:581-582), so it starts at the slice.- The comparator reads the header there (
:685-694) and then rewinds (:695);Reader.rewind()isthis.data.rewind()(:594-596), which goes to position 0 of the array. relate()reads the bounding box from that position, returns at once if the query does not cross it, otherwise walks the tree, and rewinds to 0 again infinally(:743-800).readByte()does not check the slice's end (ByteArrayDataInput.java:131-133), so nothing notices.getBoundingBox()andgetCentroid()are computed once from the header (:91-92), andnumberOfTerms()andgetHighestDimension()return values read there too (:101-102,:139-140), so they stay correct and hide the problem;relate()is the public method that reads the bytes again.
Squares B (40-50) and A (0-10) encode to 76 bytes each; B fills bytes 0-75 and A bytes 76-151. For boxes 5-15 (crosses A), -20 to 20 (contains A) and 45-55 (touches only B), A's own bytes give CROSSES, INSIDE, OUTSIDE; the slice at offset 76 gives OUTSIDE, OUTSIDE, CROSSES, B's answers, because a complete valid shape (B) starts the array. The same for XYShape. In a MemoryIndex holding the slice: INTERSECTS for box 5-15 does not match, DISJOINT matches. With bytes before the slice that are not a valid encoding (the finder's 3-byte zero prefix) the answer is garbage: OUTSIDE for a crossing box, or, for XYShape with assertions on, an AssertionError in XYEncodingUtils.decode. From the code, other invalid prefixes can also throw an ArithmeticException or ArrayIndexOutOfBoundsException (:599-605; ByteArrayDataInput.java:80-82, :131-133 do not check the limit; not executed).
Exposure and likelihood372 words
- Who is exposed. Code that uses the experimental shape doc-values API directly with a
BytesRefthat does not start at index 0: application or library code that callscreateLatLonShapeDocValues/createXYShapeDocValues(BytesRef)on a slice (the factories were made public in #11753 so that clients can relate shape doc values in their own collectors); applications that add acreateDocValueField(String, BytesRef)field built from a slice to aMemoryIndexand query it withnewSlowDocValuesBoxQuery(MemoryIndexkeeps the offset,MemoryIndex.java:954); and, on 10.6.0 (released 2026-10-29), main andbranch_10x(GITHUB#16286, listed under 10.6.0), code that passes values from the bulkBinaryDocValues#binaryValuesstraight to the factories: for a dense field the default codec returns every value after the first of a batch as a slice of one array (for values of varying length only in a batch of consecutive doc ids;Lucene90DocValuesProducer.java:1341-1357,:1383-1415), so each relates as the first value. Sparse fields get offset-0 copies (BinaryDocValues.java:99-108). - Not affected. Ordinary
IndexWriter+IndexSearcherwith the slow doc-values shape queries on Lucene's codecs: the writer stores the slice's bytes, and per-documentbinaryValue()returns a buffer at offset 0 (Lucene90DocValuesProducer.java:1331-1338,:1372-1380; the executed control, one segment with the default codec, passes; the other shipped codecs and paths were checked by reading the code only). No Lucene class creates such a slice itself: a slice reaches Lucene's shape queries only when the application supplies one (for example throughMemoryIndex), and nothing in Lucene passes bulk values to the shape classes. A custom codec orLeafReaderthat returns slices would trigger it. - Opt-in. The classes are
@lucene.experimental; otherwise default configuration. One value suffices for a factory call or aMemoryIndexfield; the bulk path needs at least two values in one batch. - What you see. CROSSES or INSIDE become OUTSIDE and OUTSIDE becomes CROSSES, so a box query misses shapes it overlaps and DISJOINT matches them. No exception when a valid shape precedes the slice (other prefix bytes give garbage or an exception); nothing is written to the index.
- How likely. Rare (registry). A downstream remark, from code reading only: one downstream search engine builds its shape doc values from per-document values at offset 0, so it is not affected today.
The test shows that a LatLonShapeDocValues built with LatLonShape.createLatLonShapeDocValues from a BytesRef slice (offset != 0) relates to a query as the bytes at the start of the backing array, because ShapeDocValues.Reader.rewind() goes to position 0, not to the slice's offset.
/*
* 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.document;
import org.apache.lucene.geo.Component2D;
import org.apache.lucene.geo.LatLonGeometry;
import org.apache.lucene.geo.Polygon;
import org.apache.lucene.geo.Rectangle;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;
public class TestShapeDocValuesBytesRefOffset extends LuceneTestCase {
private static BytesRef square(double min, double max) {
Polygon p =
new Polygon(new double[] {min, min, max, max, min}, new double[] {min, max, max, min, min});
return BytesRef.deepCopyOf(LatLonShape.createDocValueField("f", p).binaryValue());
}
public void testRelateWithNonZeroOffset() throws Exception {
BytesRef a = square(0, 10);
BytesRef b = square(40, 50);
byte[] buf = new byte[b.length + a.length];
System.arraycopy(b.bytes, 0, buf, 0, b.length);
System.arraycopy(a.bytes, 0, buf, b.length, a.length);
Component2D box = LatLonGeometry.create(new Rectangle(5, 15, 5, 15));
// expected: the slice of a relates like a at offset 0 (CROSSES); actual: like b (OUTSIDE)
assertEquals(
LatLonShape.createLatLonShapeDocValues(a).relate(box),
LatLonShape.createLatLonShapeDocValues(new BytesRef(buf, b.length, a.length)).relate(box));
}
}
# in a copy of this finding's repro folder (repro/LFV-396/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.document.TestShapeDocValuesBytesRefOffset
- Expected vs actual
- Expected: square a, stored after square b in one array, relates to the box 5..15 x 5..15 like its offset-0 copy (
CELL_CROSSES_QUERY). Actual: it relates like b (expected:<CELL_CROSSES_QUERY> but was:<CELL_OUTSIDE_QUERY>), with-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<CELL_CROSSES_QUERY> but was:<CELL_OUTSIDE_QUERY>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.document.TestShapeDocValuesBytesRefOffset.testRelateWithNonZeroOffset(TestShapeDocValuesBytesRefOffset.java:42)- A
BytesRefis "a slice (offset + length) into an existing byte[]"; no factory restricts the offset. - The same class reads its header at the offset, so the bounding box and centroid are right; only
relate()goes back to byte 0. ByteArrayDataInput.rewind()warns it "is not right" after a non-zero offset; Lucene'sMemoryIndexand bulk API hand out slices.
The full argument221 words
- The contract.
BytesRef.java:22: "Represents byte[], as a slice (offset + length) into an existing byte[]." The factories and field constructors (LatLonShape.java:210-214,:344-352;XYShape.java:193-196,:274-282) take anyBytesRefand say nothing about the offset. - Inconsistent with itself. The reader starts at the offset and the header is read there (
ShapeDocValues.java:582,:685-694);relate()alone rewinds to 0. - The helper's own warning.
ByteArrayDataInput.java:51-52, "sets pos to 0, which is not right if you had called reset w/ non-zero offset!!", dates from 2011, beforeShapeDocValuesexisted. - Lucene produces slices.
MemoryIndex.java:954keeps the caller's offset; and those values reach the shape queries (BaseShapeDocValuesQuery.java:78); the bulkbinaryValues(new in 10.6.0) returns slices of one batch array (Lucene90DocValuesProducer.java:1356,:1401), each "a deep copy owned by the caller" (BinaryDocValues.java:75-76) but not at offset 0. - Intent. The code came with the feature (#1017, 2022); none of its review comments mentions offsets. #11753 made the
BytesReffactories public for "custom collectors that work with shapes", which is where values from doc-values APIs, and so slices, arrive. No test calls theBytesReffactories or uses a non-zero offset. - Precedent. Lucene fixed the same class of bug before: "respect bytesref offset" (
1b671cfba3a, 2010) and LUCENE-8766 "Pass BytesRef offset/length when decoding from input stream" (2019).
The devil's-advocate check204 words
The strongest case for "intended": the classes and ByteArrayDataInput are @lucene.experimental; the format is opaque; the constructor javadoc speaks of "an already retrieved binary format" (LatLonShapeDocValues.java:41-42), which could mean the field's own value or a per-document binaryValue(), both at offset 0 on Lucene's codecs; and rewind() is documented to go to 0. So offset 0 might be an unwritten precondition, and callers should deep-copy first.
Why it fails: no javadoc states that precondition, and BytesRef defines the value as the slice; the class honours the offset for the header, so offset 0 is not even its own assumption; MemoryIndex and the bulk API, both Lucene's, produce slices; @lucene.experimental relaxes API stability, not the meaning of the bytes passed in; the rewind NOTE warns callers of the helper, and ShapeDocValues is the caller that ignored it; the fix leaves offset-0 behaviour unchanged (not benchmarked). The devil's advocate, a reviewer from another model family (OpenAI GPT-5.6, intended lens) and the adjudication all rated the argument weak, as did the cross-model fact check. For the bulk path alone, a maintainer could instead make binaryValues return offset-0 copies; that would not help the factory and MemoryIndex paths, which are in releases. Verdict: a defect.
Recommended Rewind to the slice's start (validated)
In ShapeDocValues.Reader, remember the slice's start offset and make rewind() call data.setPosition(startOffset) instead of ByteArrayDataInput.rewind(). One inner class (a field, a constructor line and rewind(), +6/-1); identical for offset 0. The new tests and the bulk-API test pass, and so do 30 existing lucene/core and lucene/memory suites (247 tests, the shape doc-values suites among them), with assertions on and off.
/** start of the doc value in the backing array */
private final int startOffset;
Reader(BytesRef binaryValue) {
this.data = new ByteArrayDataInput(binaryValue.bytes, binaryValue.offset, binaryValue.length);
this.startOffset = binaryValue.offset;
...
}
protected void rewind() {
// ByteArrayDataInput#rewind() goes to position 0, not to the start of the slice
this.data.setPosition(startOffset);
}All options and trade-offs4 options · 495 words
One fix was built and validated through gradle (the same change as the finder's); three alternatives were not built. Workaround for users today: BytesRef.deepCopyOf(value) before building a shape doc value from a slice.
Store binaryValue.offset in the Reader and have rewind() set the position back to it.
/** start of the doc value in the backing array */
private final int startOffset;
Reader(BytesRef binaryValue) {
this.data = new ByteArrayDataInput(binaryValue.bytes, binaryValue.offset, binaryValue.length);
this.startOffset = binaryValue.offset;
...
}
protected void rewind() {
// ByteArrayDataInput#rewind() goes to position 0, not to the start of the slice
this.data.setPosition(startOffset);
}Trade-offs
One int per reader; setPosition is a field store like rewind(). Identical for offset 0, which is every value Lucene's on-disk codecs hand to the shape queries. Reader.clone() rebuilds from the same BytesRef (ShapeDocValues.java:589-591), and the tree walk's position arithmetic is relative (:813, :823), so nothing else depends on absolute positions. No benchmark was run.
What the checks validated
- Scratch worktree of
a9779ef3c193, gradle, JDK 25: stock + the two new tests and the finder's bulk test: 4 of 4 fail with assertions on and off; with the fix all 4 pass. - With the fix,
:lucene:core:test --tests 'org.apache.lucene.document.*Shape*'and the whole:lucene:memory:test: 33 suites, 251 tests, 0 failures, 19 skipped (18 by the nightly group), with assertions on and off. Besides the new tests: 27 existing core suites (196 tests, among themTestShapeDocValuesand the six*ShapeDVQueries) and the 3 existing memory suites (51 tests). The independent fact check got the same counts on a clean extraction. :lucene:core:check -x test :lucene:memory:check -x test(format, ecjLint, forbiddenApis) with the fix and the tests: BUILD SUCCESSFUL.- Standalone runner with the fix: this doc's tests (assertions on and off), and earlier the finder's, the independent reproducer's and the intended-lens stage's tests.
- Not run: the full Lucene test suite, and any benchmark.
rewind() calls data.reset(bytes, offset, length) with the BytesRef's fields.
Trade-offs
Same effect as long as nobody changes the BytesRef after construction: ShapeDocValues keeps the caller's mutable BytesRef (ShapeDocValues.java:84-85), and this variant re-reads its fields on every rewind while the recommended one captures the offset once. Also restores the limit, which nothing changes today.
In ShapeDocValues(BytesRef), use BytesRef.deepCopyOf(binaryValue) when the offset is not 0.
Trade-offs
Copies every sliced value (for example every bulk value after the first) and leaves the reader's absolute rewind() for the next user of Reader.
Remember the reset offset and rewind to it, retiring the 2011 NOTE.
Trade-offs
Changes the meaning of a method that other code calls: the blocktreeords and idversion terms enum frames call rewind() on a ByteArrayDataInput (OrdsSegmentTermsEnumFrame.java:244, IDVersionSegmentTermsEnumFrame.java:215); the default lucene103 frame no longer does (its old calls at SegmentTermsEnumFrame.java:300-307 are inside a comment). Needs its own discussion.
Downstream code that relates shape doc values in its own collectors or aggregations is hit as soon as it passes slices, for example after moving to the bulk BinaryDocValues#binaryValues API; per-document values at offset 0 are safe.
Found by reading the code while writing an independent reference decoder for a bounded-exhaustive check of the shape doc-values tree: the reader starts at the BytesRef offset but rewinds to 0. The exhaustive check itself put every value at offset 0 and found nothing; public-API JUnits then confirmed the reading.
The method in full134 words
Method: code reading during semi-formal work (the hunt's semi-formal tier).
- The hunt unit built a bounded-exhaustive translation-validation harness for the shape doc-values format: every sequence of up to 4 elements over small grids plus random trees, each encoded with the stock jar, decoded by an independent reference decoder and related against brute force. It checked 63,276,054 trees and 499,269,156
relate()calls with 0 mismatches, assertions on and off, and killed 8 of 8 mutants ofShapeDocValues. - All of those values sat at offset 0, so the harness could not see this bug. The signal came from writing the reference decoder:
Readeropens atbinaryValue.offset,rewind()goes to 0, andByteArrayDataInput.rewind()'s comment warns about exactly that. - An executed probe and then public-API JUnits (the factories,
MemoryIndex, the bulk API) confirmed it.
No prior report found (searched 2026-10-26, re-checked 2026-10-27): 242 queries and reads over GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream repositories and the web, among them the changed files of all 410 open Lucene PRs (260 logged records, including 18 planned web queries that were not run). Nearest: #11753, which made the BytesRef factories public.
Searches4 searches
No issue, PR, review comment, list mail, JIRA entry, downstream report or web page describes ShapeDocValues relating wrongly for a BytesRef with a non-zero offset, or links ByteArrayDataInput.rewind()'s 2011 warning to it.
Closest, not this bug: #1017 / #1064 (introduced the code; no review comment mentions offsets), #11752 / #11753 / #11890 (made the BytesRef factories public for client collectors), #11973 / #11974 (a = vs == typo in the header writer), #13948 (open; copies values to offset 0 in BaseShapeDocValuesQuery, neither fixes nor triggers this), #12459 / #12460 (binary doc values as a DataInput) and #16286 (the bulk binaryValues of 10.6.0, which returns slices).
| Where | When | How | Found |
|---|---|---|---|
| GitHub, commits, downstream repositories, mailing lists, JIRA archive | 2026-10-26 | four scripted prior-report search runs: 64 GitHub issue/PR, 12 commit, 8 downstream, 80 mailing-list (dev@, java-user@, issues@, commits@) and 16 JIRA queries (they also planned 18 web queries, not run); the three follow-up runs returned 284, 712 and 923 distinct hits, none with high overlap | only the related items above |
| GitHub, lists, JIRA, web (manual) | 2026-10-26 | 20 GitHub searches (7 rate-limit retries, all then succeeded), 20 list and 4 JIRA queries, 13 web searches | no report |
| Open PRs | 2026-10-26 | the changed files of all 410 open apache/lucene PRs | none touches ShapeDocValues.java or ByteArrayDataInput.java; #13948 touches BaseShapeDocValuesQuery.java only |
| GitHub, re-check for this doc | 2026-10-27 | 7 read-only searches by class, factory and symptom (two repeated after the rate limit); every issue and PR created since 2026-10-26 | nothing new (17 recent items, none about shape doc values or BytesRef offsets) |
Introduced with the feature by LUCENE-10654 (d7fd48c9502, 2022-08-09, 9.4.0); the public relate() factories followed in 9.5.0 (#11753). Fails on every release tried from 9.4.0 (MemoryIndex) and 9.5.0 (factories) to 10.5.2, and on main; the lines are unchanged since.
Versions tested8 rows
Introduced by LUCENE-10654 "Add new ShapeDocValuesField for LatLonShape and XYShape" (@nknize, d7fd48c9502, 2022-08-09, #1017; 9.x backport ddf0d0acf4e, #1064), released in 9.4.0. git blame puts the Reader constructor, Reader.rewind() and both rewind calls there; no later commit touched them. The public createLatLonShapeDocValues / createXYShapeDocValues(BytesRef) came with #11753 / #11890 (@navneet1v, 2022-10-28, 9.5.0). The bulk binaryValues trigger came with #16286 (2026-06-30, listed under 10.6.0, released 2026-10-29; on main, branch_10x and branch_10_6). Not fixed upstream (upstream main cf398bfb142c, 2026-10-26; re-read on 2026-10-27 at main 20ced71c1e6e and the two release branches).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's core and MemoryIndex tests fail (gradle and the standalone runner, assertions on and off); the finder's bulk test fails; on-disk control passes |
| 10.5.2 | affected | core and MemoryIndex tests fail (assertions on and off); bulk API absent |
| 10.0.0 | affected | Follow-up: factory and MemoryIndex tests fail |
| 9.12.3 | affected | core test fails (assertions on and off); MemoryIndex test fails (on) |
| 9.5.0 | affected | core test fails, assertions on and off (first release with the public factories) |
| 9.4.0, 9.4.2 | MemoryIndex only | MemoryIndex test fails (9.4.0: assertions on and off); the factories are absent (compile error); on-disk control passes on 9.4.0 |
| 9.3.0 | API absent | no shape doc values; the tests do not compile |
| main + fix | not affected | all tests pass; shape and MemoryIndex suites pass |
An independent reproducer and an intended-behaviour devil's advocate call it real; reviews by another model family (OpenAI GPT-5.6, reading only) agree on the root cause; the adjudication re-ran the tests and confirmed it with high confidence; an audit and a refresh on a9779ef3c193 reproduced it again.
Checks and verdicts456 words
The hunt-stage checks ran on main ea57360912ed, where none of the involved sources differ from a9779ef3c193. The cross-model reviews could only read code; separate runs and the intended-lens stage executed the tests. This doc's own runs are on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Found by code reading while writing a reference decoder; JUnits (offset 3, bulk, MemoryIndex) fail on main (-ea, -da), 10.5.2, 9.12.3, 9.5.0 and 9.4.0 (MemoryIndex); the fix passes them and 31 existing shape doc-values tests. |
| cross-model review, refute lens | not real (low, procedural) | OpenAI GPT-5.6, reading only: could not run Java; agreed on the root cause, found the inputs within the contract and the intended argument weak; a separate run re-ran the repros: 5 of 5 fail. |
| Independent reproducer | real | Own client-package tests with a valid shape before the value (offset 76): CROSSES -> OUTSIDE, INSIDE -> OUTSIDE, OUTSIDE -> CROSSES on main (-ea, -da), 10.5.2 and 9.12.3; bulk and MemoryIndex tests fail; on-disk control passes; all pass with the fix. |
| Cross-model review, intended lens | real (medium); intended argument weak | OpenAI GPT-5.6, reading only; a separate invariant test (several shapes, offsets, prefixes and boxes) fails on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the fix. |
| Intended-behaviour devil's advocate | real | No javadoc, test, CHANGES entry or PR discussion restricts the offset; own client repro (two squares back to back) fails with -ea and -da. |
| adjudication | confirmed (high) | Re-ran the independent reproduction (fails; passes with the fix), the MemoryIndex and bulk tests (fail) and the on-disk control (passes). |
| registry re-run and audit | keep | Re-ran the independent reproduction, the MemoryIndex test and the fix on main (-da); the refresh on a9779ef3c193 reproduced the finder's tests 3 of 3. |
| this doc | reproduced; fix validated | New core and MemoryIndex tests fail on main (gradle and standalone, -ea and -da), 10.5.2, 9.12.3, 9.5.0 (core) and 9.4.0 (MemoryIndex); they pass with the fix, as do the shape and MemoryIndex suites. |
| independent fact check | reproduced; two wording errors, fixed | Re-ran this doc's gradle commands on a clean extraction of a9779ef3c193 (3 of 3 fail with assertions on, 4 of 4 off), the standalone runner with new seeds on main and four release targets (all fail), and the fix (passes; the same 33 suites, 251 tests); probed the bulk path per field layout. |
| cross-model fact check | bug real, root cause right; wording corrections applied | OpenAI GPT-5.6, reading only: traced positions 76, 109, 0 and B's box by hand; asked for the bulk-path, exception and reach precisions now in this doc (resolution in an unpublished 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.
- PNG 423 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,210 words
- Registry title
- ShapeDocValues.relate() rewinds to position 0 of the backing array instead of to the BytesRef's offset, so a LatLonShape or XYShape doc value built from a BytesRef slice (offset != 0) silently relates wrongly to queries
- Area
- core / document: shape doc values (ShapeDocValues reader behind LatLonShapeDocValues / XYShapeDocValues relate and the slow doc-values shape queries; MemoryIndex shape doc-values fields)
- Priority
- Low, rank 371: Needs a BytesRef slice passed to the experimental shape doc-values factories; values read from the index come back at offset 0.
- Impact class
- silent wrong results
- Impact note
- Only code that builds a shape doc value from a BytesRef whose offset is not 0 is hit, and then on every relate() call: an application that passes such a slice to LatLonShape.createLatLonShapeDocValues or XYShape.createXYShapeDocValues (released since 9.5.0), or adds a createDocValueField(String, BytesRef) field built from one to a MemoryIndex (since 9.4.0), or, on 10.6.0 (released 2026-10-29) and main, feeds values from the new bulk BinaryDocValues#binaryValues API to those factories; ordinary indexing plus IndexSearcher with newSlowDocValuesBoxQuery on Lucene's codecs reads per-document values at offset 0 and is not affected (executed control); the classes are @lucene.experimental; one document suffices, with -ea and -da.
- Summary
- You build a LatLonShape or XYShape doc value from a BytesRef that points into the middle of a larger byte array (offset != 0), for example with LatLonShape.createLatLonShapeDocValues(BytesRef) or as a MemoryIndex field, and expect relate() and the doc-values shape queries to answer exactly as for the same bytes at offset 0. Instead, ShapeDocValues reads its header at the offset but then rewinds its ByteArrayDataInput to position 0 of the backing array, so relate() decodes whatever bytes sit at the start of that array: the relation is silently wrong in either direction (CROSSES or INSIDE becomes OUTSIDE, OUTSIDE becomes CROSSES), and on a MemoryIndex an overlapping shape is missed by an INTERSECTS box query and matched by a DISJOINT one. getBoundingBox() and getCentroid() stay correct, which hides the problem.
- Introduced
- d7fd48c9502c567e4760a011fa99b1a491fea2cb, apache/lucene#1017 LUCENE-10654 'Add new ShapeDocValuesField for LatLonShape and XYShape' (by nknize, merged to main 2022-08-09; 9.x backport ddf0d0acf4e4, #1064, the same day) [read: git blame -w -M -C attributes the Reader(BytesRef) constructor, Reader.rewind() -> ByteArrayDataInput.rewind() and both rewind calls of ShapeComparator to it, and no later commit touched those lines; the introducing commit and its parent were not built, the release pair 9.3.0 (API absent, compile error) / 9.4.0 (fails) brackets it on 9.x [exec]]. The public relate() entry point createLatLonShapeDocValues / createXYShapeDocValues(BytesRef) came with e7253f112de (#11753, main) and a4ef70f0656 (#11890, 9.x) on 2022-10-28; ByteArrayDataInput.rewind() and its warning NOTE date from 2011 (b7846ffaa1f). (2022-08-09 (public relate() factories: 2022-10-28))
- First affected version
- 9.4.0 (2022-09-30; MemoryIndex path [exec]; 9.3.0 lacks the API [exec: compile error]); public relate() factory path from 9.5.0 [exec]; first 10.x release 10.0.0 [exec]
- Affected versions
- Every release from 9.4.0 to 9.12.3 and from 10.0.0 to 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] MemoryIndex path fails on 9.4.0 (-ea and -da), 9.4.2, 9.5.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main; the public factory relate() path fails on 9.5.0 (-ea and -da), 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da); the bulk BinaryDocValues#binaryValues path fails on main (API absent in 10.5.2: compile error; not executed on 10.6.0, the first release with it); the on-disk IndexWriter + IndexSearcher control passes on 9.4.0, 9.12.3, 10.5.2 and main. [read] every release tag from 9.4.0 on contains the buggy lines, ShapeDocValues.java is byte-identical in 10.0.0 to 10.5.2 and main, and upstream main cf398bfb142c (2026-10-26) and the branch_10_6 / branch_10x heads of 2026-10-24 still have them. Releases between those executed were checked by grep only; old releases ran on JDK 25.
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record verdict new: 260 queries, i.e. scripted prior-report searches over GitHub issues and PRs, commits, downstream repositories (Elasticsearch, OpenSearch, Solr), the dev@, java-user@, issues@ and commits@ lists and JIRA, plus manual GitHub API, list-archive, JIRA and web queries and the changed-file lists of all 410 open apache/lucene PRs; the independent reproduction's and the intended-behaviour check's own searches agree, some of the intended-behaviour check's were rate-limited). Related items, none the same bug: #1017 / #1064 (introduced the code; no review comment mentions offsets), #11752 / #11753 / #11890 (made the BytesRef factories public for client-side collectors), #11973 / #11974 (a different header-serialization typo), open #13948 (BaseShapeDocValuesQuery copies values to offset 0; neither fixes nor triggers this), #16286 (the bulk binaryValues of main and 10.6.0, which returns slices). Not fixed upstream (main cf398bfb142c, 2026-10-26).
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) through gradle in a scratch worktree and through the standalone JUnit runner, with assertions on and off; the 10.5.2, 9.12.3, 9.5.0 and 9.4.0 release jars, assertions on and off (9.4.0 lacks the factories, 9.3.0 the whole API; some assertions-off release runs are the independent fact check's). The hunt-stage checks and the follow-up's release sweep ran on mainea57360912ed, whereShapeDocValues,ByteArrayDataInput,BytesRef,BinaryDocValues, thelucene90doc-values producer andlucene/memoryare identical toa9779ef3c193. The fix was validated in the scratch worktree: the new tests, the finder's bulk test, thelucene/coreshape suites and thelucene/memorysuites with assertions on and off, andcheck -x testfor both modules. An independent fact check repeated the gradle runs on a clean extraction ofa9779ef3c193with the same results.
- Finding record
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:49 (@lucene.experimental), 581-596 (Reader at binaryValue.offset; rewind() -> data.rewind()), 685-696 (ShapeComparator reads the header, then rewinds), 743-801 (relate() re-reads from the rewound position, finally rewinds); lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:51-55 (rewind() sets pos = 0, NOTE); lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22; LatLonShape.java:350; XYShape.java:194; lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954 (shallow clone keeps the offset); Lucene90DocValuesProducer.java:1356, 1401 (bulk values as slices); lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:75; lucene/CHANGES.txt:451 (GITHUB#16286)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:84-93, :91-92, :101-102, :139-140, :581-582, :594-596, :599-605, :685-695, :743-800lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:51-55, :65-69, :80-82, :131-133lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22
- Who is affected
lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954 (shallow BytesRef.clone() keeps the offset)lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1331-1338, :1341-1357, :1372-1380, :1383-1415lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:75-76, :99-108lucene/core/src/java/org/apache/lucene/document/BaseShapeDocValuesQuery.java:78; lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:1684-1694 (MemoryIndex values reach the shape queries)lucene/CHANGES.txt:446, :459 (GITHUB#16286 under 10.6.0)lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:49; LatLonShapeDocValues.java:32; XYShapeDocValues.java:32 (@lucene.experimental)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/BytesRef.java:22lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:210-214, :344-352; XYShape.java:193-196, :274-282lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:582, :685-695; LatLonShapeDocValues.java:41-44lucene/core/src/java/org/apache/lucene/store/ByteArrayDataInput.java:51-55lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:954; lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java:1356, :1401; lucene/core/src/java/org/apache/lucene/index/BinaryDocValues.java:75-76
- Suggested fix
lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:589-591, :813, :823lucene/codecs/src/java/org/apache/lucene/codecs/blocktreeords/OrdsSegmentTermsEnumFrame.java:244; lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/idversion/IDVersionSegmentTermsEnumFrame.java:215; lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/SegmentTermsEnumFrame.java:278, :285-317lucene/core/src/java/org/apache/lucene/document/ShapeDocValues.java:84-85 (the retained BytesRef)
- History
lucene/CHANGES.txt:3916 (GITHUB#11752 under 9.5.0), :3966 (LUCENE-10654 under 9.4.0), :459 (GITHUB#16286 under 10.6.0)
- TestShapeDocValuesBytesRefOffset.java (JUnit test):
repro/LFV-396/src/org/apache/lucene/document/TestShapeDocValuesBytesRefOffset.java(46 lines)
Made in parallel with this doc on the same layout, from a probe executed on main a9779ef3c193 with assertions on and off and with the fix; every value on screen is parsed from the probe's output and asserted before it is drawn.
assets/LFV-396/bug.png(423 KB)
