All findings · Lucene finding · LFV-753 · simpletext-pointtree-clone-shares-split-stack

SimpleText points: PointTree.clone() shares a saved split-value array with the tree it was cloned from, so moving one tree corrupts the node bounds the other reports

With the SimpleText codec, a cloned PointTree and its original share one array that remembers a bound, so moving one tree corrupts the other's bounds: the clone's root reports [0, 0] instead of [0, 1023]. Only hand-written PointTree code on this debug-only codec is hit, in every release since 9.1.0.

Summary

Priority
Low, rank 1208Only custom code that drives the experimental PointTree API by hand on the test-oriented SimpleText codec sees corrupted node bounds, and no in-tree caller does this.
Impact
minor
Since
9.1.0 (2022, LUCENE-9820)introduced 2021-11-19 · about 4 years
Affected
every release from 9.1.0 to 10.6.0, and main; SimpleText codec only
Status
documented
Found by
semi-formal: bounded-exhaustive search over PointTree move and clone sequences

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

The finding in 30 seconds

One line in SimpleTextPointTree.clone() copies the reference to the original's per-level "saved bound" array instead of the array, so the two cursors overwrite each other's saved bounds.

Background: how a PointTree cursor moves

  1. Each segment indexes a points field as a BKD tree: inner nodes split the value range, leaves hold up to 512 points. PointValues.getPointTree() returns a cursor on the root.
  2. The cursor moves with moveToChild(), moveToSibling() and moveToParent(), and getMinPackedValue() / getMaxPackedValue() return the current node's bounds. Going down narrows one bound to the split value; going back up restores it.
  3. To restore it, the SimpleText cursor saves the old bound in a small array, one per tree level. clone() returns a second cursor whose root is the current node.
A SimpleText PointTree over values 0..1023 and its clone share one saved-bound slot for the root level. The clone goes down and saves max 1023; the original goes down and over to the right child and saves its min 0 in the same slot; the clone moves back up and restores max 0, so its root reports [0, 0] instead of [0, 1023]. The default codec reports [0, 1023].
The original and its clone point at one shared "saved bound" slot, so the original's save of min 0 becomes the clone's restored max. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index 1,024 docs with one IntPoint field, values 0..1023, using SimpleTextCodec: one segment, two leaves split at 512. Take getPointTree(), step down and back up (moveToChild(), moveToParent()), and clone() it at the root.
  2. Move the clone down to its left child [0, 512]. Move the original down and over to its right child [512, 1023]. Move the clone back up to its root.
  3. You expect the clone to report its root's bounds [0, 1023] again: clone() promises a new tree rooted at that node, and the default codec gives [0, 1023] for the same calls.
  4. You get [0, 0]. clone() copied a reference to the original's per-level "saved bound" array, not the array. The original's moveToSibling() saved its min, 0, there, and the clone's moveToParent() restored that as its max.
  5. No error is thrown. It works both ways, and visitDocIDs() on an inner node writes the array too. No Lucene query moves trees like this, so stock queries are not affected.

Who is affected

Only code that calls the experimental PointTree API directly on a SimpleTextCodec index, clones a tree and keeps moving both. SimpleText is documented as debug-only, but Lucene's randomized tests pick it now and then, so such hand-written traversal tests can fail rarely. No Lucene query is affected, and the default codec is correct.

Mechanism, terms and numbers705 words

PointValues.PointTree#clone promises that "the current node becomes the root of the new tree". In the SimpleText points format, SimpleTextPointTree.clone() copies the reference to the original's per-level array that remembers the split dimension's old bound (SimpleTextBKDReader.java:131) instead of copying the array. Both trees then save into, and restore from, the same array at the clone's root level, so a move of one tree changes the bounds the other restores.

With 1,024 IntPoint values 0..1023, seven calls leave the clone at its root reporting [0, 0] instead of [0, 1023]; the default codec gives [0, 1023] for the same calls. It needs the debug-only SimpleText codec and code that drives the experimental PointTree API by hand; no Lucene query moves trees this way. It is in every release since the API appeared in 9.1.0, and the fix is one line in clone(): copy the array instead of its reference, or do not copy it at all, which is what the default format's clone does with its own saved-bound array.

The code (main 645d4fe14a20, SimpleTextBKDReader.java)

public PointTree clone() {                                              // :124-134
  SimpleTextPointTree index =
      new SimpleTextPointTree(in.clone(), nodeID, level, minPackedValue, maxPackedValue);
  if (isLeafNode() == false) {
    // copy node data
    index.splitDims[level] = splitDims[level];
    index.splitDimValueStack[level] = splitDimValueStack[level];        // :131, the reference
  }
  return index;
}
  • splitDimValueStack "holds the previous value of the split dimension", one byte[] per level (:92-93, allocated per tree at :110). A tree from getPointTree() starts at node 1, level 1 (:78-81).
  • pushLeft() (:145-181, used by moveToChild()) allocates the level's array only if it is null (:154-156), saves the current max of the split dimension into it (:158-159), then narrows max to the split value.
  • pushRight() (:193-227, the second half of moveToSibling()) saves the current min into the same array (:204-205), then narrows min.
  • pop() (:238-258, used by moveToParent() and the first half of moveToSibling()) copies the array back into max (coming up from a left child) or min (from a right child).
  • visitDocIDs() / visitDocValues() on an inner node run pushLeft, pop, pushRight, pop on the tree itself (:359-365, :391-397), so they fill and overwrite the array too.

The seven calls, traced (1,024 values 0..1023, split at 512)

stepcalltreeclonethe shared slot
1tree.moveToChild()[0, 512]-tree allocates it, saves max 1023
2tree.moveToParent()[0, 1023]-tree restores max 1023
3clone = tree.clone()[0, 1023][0, 1023]clone gets the same array
4clone.moveToChild()[0, 1023][0, 512]clone saves max 1023
5tree.moveToChild()[0, 512][0, 512]tree saves max 1023
6tree.moveToSibling()[512, 1023][0, 512]tree saves its min 0
7clone.moveToParent()[512, 1023][0, 0] (default codec: [0, 1023])clone restores max = 0

When it bites

  • The array is shared only if the original had already allocated it at that level before clone(): after a moveToChild() + moveToParent(), or a visit of an inner node. Otherwise the clone allocates its own on its first push.
  • Then one tree must push at that level between the other tree's push and pop there.
  • It works both ways. An independent verifier's 6-call trigger has no moveToParent(): visitDocIDs at the root, clone(), tree.moveToChild(), clone.moveToChild(), clone.moveToSibling(), tree.moveToSibling(); on values 0..9999 the original's right child reports [5000, 0] (max < min) instead of [5000, 9999].
  • The shortest known trigger has 5 calls (the devil's-advocate verifier's visit variant): tree.visitDocIDs(v) at the root, clone(), clone.moveToChild(), tree.visitDocIDs(v), clone.moveToParent(); the clone's root then reports [0, 0] for values 0..1023. The seven moves-only calls are used here because they need no visitor.

The default format

BKDReader's clone (BKDReader.java:341-376) builds the clone through the constructor that allocates a fresh splitDimValueStack for every tree (:317) and never copies that array, so the clone fills its own slot on its first push (:407-412). The byte array it does deep-copy, splitValuesStack[level].clone() (:366), is a different one: the per-level split values, which SimpleText reads from splitPackedValues instead. Its clone shares no per-level array with the original.

Exposure and likelihood247 words
  • Who. Direct users of PointValues.PointTree (@lucene.experimental, PointValues.java:235-240) on a SimpleText index that clone a tree and keep moving both trees: a custom BKD walker, or a test that navigates trees by hand.
  • Opt-in, debug-only codec. SimpleTextPointsFormat: "For debugging, curiosity, transparency only!! Do not use this codec in production." (SimpleTextPointsFormat.java:27); SimpleTextCodec: "FOR RECREATIONAL USE ONLY" (SimpleTextCodec.java:35). The default codec gives the right bounds for the same calls.
  • Randomized tests. The test framework's random codec picks SimpleTextCodec when random.nextInt(11) == 9 and rarely() (SetupAndRestoreStaticEnv.java:144, :185-190). A suite that drives PointTree clones by hand can therefore fail rarely, and only under that codec, which looks like flakiness.
  • No in-tree query reaches it. NearestNeighbor clones right after descending, at a level the source tree has not pushed at, so nothing is shared (NearestNeighbor.java:298-299). IndexSortSortedNumericDocValuesRangeQuery.lastDoc moves the original only sideways or up after its clone at :366, and its clone at :404 comes right after a descent, as in NearestNeighbor. A brute-force check of both on SimpleText ran 10,800 checks with 0 wrong results.
  • Scale. Any segment with more than one leaf, that is more than 512 points per field (SimpleTextPointsWriter.java:83, BKDConfig.DEFAULT_MAX_POINTS_IN_LEAF_NODE).
  • Effect. Wrong node bounds ([0, 0] for values 0..1023; also max < min). A traversal that prunes on them could skip or keep the wrong cells. No exception, no hang, no index damage; the same with assertions on and off.

Minimal repro

One JUnit test, public API only, deterministic: 1,024 IntPoint values in a SimpleTextCodec index, then the seven PointTree calls. The assertTrue calls only check that each move happened; the last line checks the clone's root max.

TestSimpleTextPointTreeCloneIndependence.java54 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.simpletext;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.IntPoint;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.PointValues.PointTree;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestSimpleTextPointTreeCloneIndependence extends LuceneTestCase {

  public void testMovingOriginalDoesNotChangeClone() throws Exception {
    IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new SimpleTextCodec());
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, iwc)) {
      for (int i = 0; i < 1024; i++) { // two leaves of 512 points
        Document doc = new Document();
        doc.add(new IntPoint("f", i));
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(w)) {
        PointTree tree = getOnlyLeafReader(r).getPointValues("f").getPointTree();
        assertTrue(tree.moveToChild());
        assertTrue(tree.moveToParent());
        PointTree clone = tree.clone();
        assertTrue(clone.moveToChild());
        assertTrue(tree.moveToChild());
        assertTrue(tree.moveToSibling());
        assertTrue(clone.moveToParent());
        // expected: the clone's root bounds max 1023 again; actual: 0 (overwritten by the original)
        assertEquals(1023, IntPoint.decodeDimension(clone.getMaxPackedValue(), 0));
      }
    }
  }
}
Command
cp $REPRO/TestSimpleTextPointTreeCloneIndependence.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/simpletext/
cd $LUCENE
./gradlew :lucene:codecs:test --tests org.apache.lucene.codecs.simpletext.TestSimpleTextPointTreeCloneIndependence -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
Back at its root, the clone reports max 1023 (bounds [0, 1023]), as the default codec does.
Observed
AssertionError: expected:<1023> but was:<0>: the clone's root reports [0, 0], with assertions on and off.

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

More files, environment, full output207 words
  • Smallest input. Two leaves are enough (1,024 points); the first moveToChild() + moveToParent() is needed because the per-level array is allocated lazily, and without it the clone would allocate its own.
  • Deterministic. ByteBuffersDirectory, fixed data, one thread, nothing randomized; the same result with assertions on and off.
  • Releases. The same file fails unchanged on 10.6.0 and 9.1.0; 9.0.0 has no PointTree.
  • As the regression test: this same test, for example as a method of TestSimpleTextPointsFormat. It passes with either fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0 and 9.1.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp $REPRO/TestSimpleTextPointTreeCloneIndependence.java $LUCENE/lucene/codecs/src/test/org/apache/lucene/codecs/simpletext/
cd $LUCENE
./gradlew :lucene:codecs:test --tests org.apache.lucene.codecs.simpletext.TestSimpleTextPointTreeCloneIndependence -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestSimpleTextPointTreeCloneIndependence > testMovingOriginalDoesNotChangeClone FAILED
    java.lang.AssertionError: expected:<1023> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:579C430CFEBB0A1C]: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.codecs.simpletext.TestSimpleTextPointTreeCloneIndependence.testMovingOriginalDoesNotChangeClone(TestSimpleTextPointTreeCloneIndependence.java:50)

# main 645d4fe14a20, assertions on and off (standalone): the same AssertionError
# releases 10.6.0 (assertions on and off) and 9.1.0: the same AssertionError
# release 9.0.0: does not compile (no PointValues.PointTree)
# the same calls with the default codec: pass (the clone reports [0, 1023])
# with either fix: BUILD SUCCESSFUL (the test and TestSimpleTextPointsFormat)

Why it looks unintended

  • PointTree#clone promises "the current node becomes the root of the new tree"; LUCENE-10262 (9.1.0) explicitly allowed random navigation.
  • The default codec gives [0, 1023] for the same calls, and Lucene's own NearestNeighbor relies on clones moving independently.
  • Nothing documents the sharing; LUCENE-10264, an earlier sharing bug in this same class, was fixed as a bug.
The full argument205 words
  • Contract. PointValues.java:242: "Clone, the current node becomes the root of the new tree." :263-267: the min and max "of the current node".
  • Random navigation is allowed. LUCENE-10262 (a590c6d2a08, #476, 9.1.0; "This change allows random navigation of a PointValues#PointTree") removed "The method should not be called after a successful call to moveToParent()" from clone() and moveToChild(). It updated BKDReader, not the SimpleText fork. In the review of the introducing PR (#7), a committer had called the old rule "quite trappy".
  • In-tree reliance. NearestNeighbor.java:298: "we must clone the index so that we can recurse left and right "concurrently"".
  • The copy gains nothing. The clone always writes the slot in its own pushLeft before any pop at that level reads it. BKDReader's clone does not copy its own splitDimValueStack at all (BKDReader.java:341-376), neither at the introducing commit nor on main.
  • Precedent. LUCENE-10264 (#11300, fixed by #478, 9.1.0): trees from SimpleTextBKDReader.getPointTree() shared one IndexInput; fixed as a bug. That fix edited clone() but left line 131.
  • Why tests miss it. BasePointsFormatTestCase.assertSize (:1086-1126) clones and "rarely" continues with the clone, but never interleaves moves of a clone and its original; randomPointTreeNavigation (:1128-1143) uses one tree.
The devil's-advocate check136 words

Strongest case for "not a bug": SimpleText is a debug codec, PointTree is @lucene.experimental, and clone() never says "deep copy", so sharing is acceptable.

Why it fails: those notes limit impact, not correctness. @lucene.experimental means the API may change incompatibly, not that its current contract is void. The codec implements the public PointTree API and the test framework runs suites on it at random. The javadoc promises a new tree, LUCENE-10262 allows random navigation, and the sibling implementation does not share. Of the cross-model reviews (OpenAI GPT-5.6), the works-as-designed review rated the intended-behaviour case weak, and the read-only refute-lens review rated it moderate ("This limits support and priority, but does not document corrupted bounds as intended"), while agreeing on the root cause and that the inputs are in contract.

What it does establish: low priority.

Suggested fix

Recommended A. Clone the array (recommended)

Clone the array instead of sharing its reference: index.splitDimValueStack[level] = splitDimValueStack[level] == null ? null : splitDimValueStack[level].clone(); (one line in SimpleTextBKDReader.clone()). Validated: the test fails before and passes after, and TestSimpleTextPointsFormat passes (21 tests, 0 failures).

if (isLeafNode() == false) {
  // copy node data
  index.splitDims[level] = splitDims[level];
  index.splitDimValueStack[level] =
      splitDimValueStack[level] == null ? null : splitDimValueStack[level].clone();
}
All options and trade-offs2 options · 368 words

Two one-line options. Both were applied in a scratch worktree of main 645d4fe14a20 and run with gradle against the minimal test and TestSimpleTextPointsFormat.

A. Clone the array (recommended) (validated by the checks)

In SimpleTextPointTree.clone() (SimpleTextBKDReader.java:131), copy the array instead of its reference.

if (isLeafNode() == false) {
  // copy node data
  index.splitDims[level] = splitDims[level];
  index.splitDimValueStack[level] =
      splitDimValueStack[level] == null ? null : splitDimValueStack[level].clone();
}

Trade-offs

  • Correct without any invariant: the clone starts from the same saved bound, in its own array, so it is easy to review. It follows the pattern BKDReader.clone uses for a different per-level array (splitValuesStack[level].clone(), BKDReader.java:366); for its own splitDimValueStack, BKDReader copies nothing, which is fix B.
  • Cost: one small array (bytes per dimension) per clone, only on this debug codec.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the minimal test fails before the change and passes after it; TestSimpleTextPointsFormat: 21 tests, 0 failures (3 skipped by the suite: testCheckIntegrityReadsAllBytes, testMergeStability, testRandomBinaryBig).
  • Standalone, with the change shadowed in, the minimal test passes with assertions on and off.
  • Earlier, on main a9779ef3c193: the finder's test (1 of 1), an independent verifier's 6 tests and the cross-model reviewer's 6 tests pass with the same change.
  • Not run: the whole :lucene:codecs:test or check.

B. Do not copy the slot at all (validated by the checks)

Delete index.splitDimValueStack[level] = splitDimValueStack[level];. The clone can only pop at its root level after its own pushLeft() there, which allocates and fills the slot (:154-159); moveToParent() and moveToSibling() at the clone's root return false (:185, :231). Suggested by the devil's-advocate verifier.

if (isLeafNode() == false) {
  // copy node data
  index.splitDims[level] = splitDims[level];
}

Trade-offs

This is what BKDReader does for the same field: its clone never copies splitDimValueStack (BKDReader.java:341-376); each tree allocates its own (:317) and fills a slot on its first push (:407-412). One line shorter and no allocation, but its correctness rests on that push-before-pop invariant.

What the checks validated

  • Gradle, the same scratch worktree and seed: the minimal test passes; TestSimpleTextPointsFormat: 21 tests, 0 failures (3 skipped).

Downstream note

Relevant only to downstream code that walks PointTree clones by hand on SimpleText indexes, usually tests: it gets wrong node bounds, never an exception.

How it was found

Semi-formal. A hunting AI tool modelling PointTree cursors read clone() and predicted the sharing; its bounded-exhaustive harness then ran every move/clone/visit sequence up to length 3 on up to 3 trees, plus 1,500 random sequences of length 14, checking every tree after every step. The long random sequences produced the failures, all on SimpleText.

The method in full163 words
  • The hunt unit checked the coherence of PointTree cursors across points formats on the stock main build.
  • The harness built a table of each node's true bounds from fresh trees, validated against the indexed points, then ran every sequence of length up to 3 over child, sibling, parent, clone, visitDocIDs and visitDocValues on up to 3 live trees, plus 1,500 random sequences of length 14, and checked every live tree after every step.
  • The saved log of that tier, which runs SimpleText trees only, holds 281 bound errors. The shortest known trigger has 5 calls (a visit, clone, child, visit, parent), longer than the exhaustive length-3 bound, so the signal came from the long random sequences, after a reading of clone() had predicted it.
  • A second harness checked the two in-tree clone users (NearestNeighbor, IndexSortSortedNumericDocValuesRangeQuery.lastDoc) on SimpleText: 10,800 checks, 0 wrong results.
  • The finder turned the finding into a public-API JUnit test; independent verifiers reproduced it with their own tests.

Prior reports, history and checks

Prior reports

No report found (searched 2026-10-27): GitHub issues, PRs and commits, the JIRA archive, five mailing lists, downstream projects and the web, plus the changed files of all 408 open PRs. Closest: LUCENE-10264, an earlier sharing bug in the same class, and LUCENE-10262, which allowed random navigation.

Searches4 searches

Related items, none this bug: LUCENE-10264 (#11300, fixed by #478: an uncloned IndexInput shared by SimpleText trees; its fix edited clone() but left this line); LUCENE-10262 (#11298 / #476: lifted the navigation restrictions, changed BKDReader only); #7 (LUCENE-9820, the introducing PR; its review discussed the clone-after-moveToParent rule, not shared state); #486 (open, stale: touches the same file, not clone()); LUCENE-7690 and LUCENE-8704 (SimpleText points test failures from before the PointTree API). splitDimValueStack has 0 hits anywhere on GitHub. Gaps: builds@ seems to index CI mail bodies only in part, so an unreported CI failure cannot be ruled out; private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream projects)2026-10-27two scripted searches (identifier terms; contract and symptom terms) plus 41 manual searches; the changed-file lists of all 408 open apache/lucene PRsno report; #486 is the only open PR touching the file, not clone()
JIRA archive2026-10-278 scripted queries and 5 issue reads, including the LUCENE-9820 worklogonly the introducing diff text in the LUCENE-9820 worklog
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-10-2745 scripted queries and 15 direct archive searches, including builds@ 2022-2026no report
Web2026-10-2715 queriesnothing relevant

History

Introduced with the PointTree API in 9.1.0 by LUCENE-9820 (ad911df2605, 2021-11-19); LUCENE-10262 widened the navigation contract in the same release. Fails on every version run from 9.1.0 to 10.6.0 and on main; 9.0.0 has no PointTree. Not fixed upstream.

Versions tested6 rows
  • Introduced: ad911df2605 "LUCENE-9820: Separate logic for reading the BKD index from logic to intersecting it (#7)" (2021-11-19), which created the PointTree cursor API and SimpleTextPointTree with this clone(). The same commit's BKDReader.clone did not copy its own splitDimValueStack at all (it deep-copied only splitValuesStack), so the default format never shared it. Backport to branch_9x: 9adf7e27f97 (#457). git log -S on the line finds only these two commits.
  • Contract widened: a590c6d2a08 (LUCENE-10262, #476, 2021-11-26) allowed clone and moveToChild() after moveToParent(). Before it, only the visit trigger was inside the contract; both shipped in 9.1.0, so every release has the defect under the widened contract. The unreleased window between the two commits was not run.
  • Not a regression: the defect was born with the API.
  • By reading: the exact line is present at every release tag from 9.1.0 to 10.6.0 and absent from 9.0.0; later edits to clone() and the push/pop methods were mechanical (634c22c527e, LUCENE-10264; 4404aa3fe9c, BKDConfig record accessors in 10.0.0).
  • Upstream: unchanged on main, branch_10x, branch_10_6 and branch_9_12 at the prior-report search (2026-10-27).
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone); passes with either fix
10.6.0 affectedminimal test fails with assertions on and off
main a9779ef3c193, 10.5.2, 10.0.0, 9.12.3 affectedthe finder's test and the 6-call visit trigger fail
9.1.0 first releaseminimal test, the finder's test and the visit trigger fail
9.0.0 not affectedno PointValues.PointTree: the test does not compile
default codec, same calls not affectedthe clone reports [0, 1023]

Independent checks

The finder, an independent reproducer, a cross-model reviewer (OpenAI GPT-5.6), a devil's advocate and an adjudication call it real; an earlier refute-lens review voted not real only because it could not run anything, while agreeing on the cause. Re-run on two main commits and on five releases from 9.1.0 to 10.6.0.

Checks and verdicts234 words

Gap: neither cross-model review could execute code itself; all execution evidence comes from Claude-family AI tools and separate runs of the tests.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedPublic-API JUnit fails on main (assertions on and off) and on 10.5.2 and 9.12.3 (assertions on); passes with fix A.
cross-model review, refute lens not real by procedure, lowOpenAI GPT-5.6, read-only: could not run code; its reading agrees with the root cause and that the inputs are in contract.
independent reproducer realOwn 6-test class: the 4 SimpleText cases fail on main, 10.5.2, 9.12.3 and 9.1.0; default-codec controls pass; 6 of 6 with fix A.
works-as-designed lens real, mediumOpenAI GPT-5.6: intended-behaviour case rated weak; a separate run of its 6-test class fails 3 on main, 10.5.2 and 9.12.3 and passes with fix A.
devil's advocate realThe same calls pass on the default codec; a visit variant with no moveToParent() before the clone also fails (5 calls); proposed fix B.
adjudication confirmed, highRe-ran the finder's and the independent reproduction's tests; the independent reproduction's passes 6 of 6 with fix A.
re-runs reproducesFollow-up and audit re-runs on a9779ef3c193; the minimal repro and this doc's gradle and standalone runs on 645d4fe14a20, 10.6.0 and 9.1.0; the fact check's gradle run of the documented command with assertions on and off.

Diagram file

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

Sources and provenance

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

Finding record

LFV-753, snapshot of 2026-11-06

Registry title
SimpleText points: PointTree.clone() shares a saved split-value array with the tree it was cloned from, so moving one tree corrupts the node bounds the other reports
Area
codecs / SimpleText points format (SimpleTextBKDReader.SimpleTextPointTree#clone)
Priority
Low, rank 1208: Only custom code that drives the experimental PointTree API by hand on the test-oriented SimpleText codec sees corrupted node bounds, and no in-tree caller does this.
Impact class
minor
Impact note
Hits only code that uses the @lucene.experimental PointValues.PointTree API directly on an index written with the SimpleText codec (documented as debug-only, 'Do not use this codec in production', though Lucene's randomized test framework picks it, rarely), clones a tree after the original has already saved a bound at that level (moveToParent after a child move, or visitDocIDs/visitDocValues on a non-leaf node) and then moves both trees: the clone or the original then reports getMinPackedValue/getMaxPackedValue bounds that no longer enclose its node's points (even max < min), so a traversal that prunes on them can skip or keep the wrong cells; no in-tree query reaches it (the two in-tree clone users, NearestNeighbor and IndexSortSortedNumericDocValuesRangeQuery.lastDoc, passed 10,800 brute-force checks on SimpleText), any index size, -ea and -da, every release from 9.1.0 to 10.6.0 and main.
Summary
You index points with the SimpleText codec, take a PointTree from PointValues.getPointTree(), clone it and keep moving both the clone and the original, and expect each tree to report the bounds of its own current node, as PointTree#clone promises ('the current node becomes the root of the new tree') and as the default points format does. Instead SimpleTextPointTree.clone() copies the reference to the original's saved split-value array for the current level, so a move of one tree overwrites the bound that the other tree restores when it moves back up: after tree.moveToChild(), tree.moveToParent(), clone = tree.clone(), clone.moveToChild(), tree.moveToChild(), tree.moveToSibling(), clone.moveToParent(), the clone at its root reports max 0 instead of 2047 for the values 0..2047. Any traversal that prunes on those bounds can then miss or wrongly include cells; no in-tree query moves trees this way.
Introduced
ad911df2605591a63ca58d237182f84aa8384325 (main, 2021-11-19, by iverase): 'LUCENE-9820: Separate logic for reading the BKD index from logic to intersecting it' (apache/lucene#7), which created the PointValues.PointTree cursor API and SimpleTextPointTree, including clone() with the reference copy 'index.splitDimValueStack[level] = splitDimValueStack[level];' (the default BKDReader.clone in the same commit already cloned its per-level split values); branch_9x backport 9adf7e27f97b (#457) [read: git log -S on the line finds only these two commits; git blame on main attributes clone() to ad911df2605 except the in.clone() line of LUCENE-10264]. The contract was widened a week later by a590c6d2a08 (LUCENE-10262, #476, 2021-11-26, 'Lift up restrictions for navigating PointValues#PointTree'), which updated BKDReader.clone but not the SimpleText fork; from then on every trigger is inside the contract. (2021-11-19 (LUCENE-9820, the defect); 2021-11-26 (LUCENE-10262, contract widened to random navigation))
First affected version
9.1.0 [exec]: LUCENE-9820 and LUCENE-10262 both first shipped there; 9.0.0 and all earlier releases have no PointTree API (the repro does not compile on 9.0.0) [exec], so the defect was born with the API and is not a regression
Affected versions
every release with the PointTree API, 9.1.0 through 9.12.3 and 10.0.0 through 10.5.2 (the last release before 10.6.0), 10.6.0, and main (a9779ef3c193 and 645d4fe14a20, unreleased 11.0); never fixed upstream. [exec] the minimal test fails on 10.6.0 and main 645d4fe14a20 (-ea and -da) and on 9.1.0 (-ea); the finder's JUnit fails 1 of 1 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea; main also -da); the independent reproduction's no-moveToParent visit trigger fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea); the independent reproduction's JUnit fails 4 of 6 with -da (4 of the 5 that run with -ea; its default-codec controls pass) on 10.5.2 and 9.12.3 (-ea and -da), 9.1.0 (-da) and main (-ea and -da); a separate JUnit fails 3 of 6 on 10.5.2, 9.12.3 and main (-ea and -da). [read] the other 28 releases: the clone() line is present at all 32 tags from 9.1.0 to 10.5.2 and absent from 9.0.0; the only later edits to clone/push/pop are mechanical (LUCENE-10264 in.clone() before 9.1.0; BKDConfig record accessors in 10.0.0).
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-27): two scripted searches (A: identifiers such as SimpleTextBKDReader, SimpleTextPointTree and splitDimValueStack; B: contract and symptom terms; together 95 non-web queries over GitHub issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists and the JIRA archive, plus 12 web queries), a re-read of the finder's run (45 non-web queries), 41 manual GitHub searches (splitDimValueStack has 0 hits anywhere on GitHub), the changed-file lists of all 408 open apache/lucene PRs (only the stale #486, visitor interfaces, touches the file, and not clone), 15 direct list-archive searches including builds@ 2022-2026, and 5 JIRA reads. Not fixed upstream: the line is unchanged since ad911df2605 on main f7bcc0abd816, branch_10x, branch_10_6 and branch_9_12. Related, not this bug: LUCENE-10264 (#11300, fixed by #478 in 9.1.0: an earlier state-sharing bug in the same class, the IndexInput of trees from getPointTree; its fix edited clone() but left this line) and LUCENE-10262 (#11298 / #476, 9.1.0: lifted the clone/moveToChild-after-moveToParent restriction and allowed random navigation; it changed BKDReader only). Gap: builds@ seems to index CI mail bodies only partly, so an unreported CI failure caused by this bug cannot be ruled out.
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test with assertions on and off standalone (2026-10-29 and 2026-11-01), and with gradle in a scratch worktree before and after each of the two fixes, together with TestSimpleTextPointsFormat (2026-11-01); the same calls on the default codec pass. Releases 10.6.0 (assertions on and off) and 9.1.0 fail; 9.0.0 has no PointTree and does not compile the test. Earlier runs: main a9779ef3c193, 10.5.2, 10.0.0, 9.12.3 and 9.1.0. All runs used OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextBKDReader.java:124-135 (clone), :150-159 (pushLeft saves maxPackedValue into splitDimValueStack[level]), :200-205 (pushRight saves minPackedValue), :238-256 (pop restores); lucene/core/src/java/org/apache/lucene/index/PointValues.java:238-266 (@lucene.experimental, clone and getMin/MaxPackedValue javadoc); lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:341-366 (default clone, splitValuesStack[level].clone()); lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:298; lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextPointsFormat.java:27 and SimpleTextCodec.java:35; lucene/test-framework/src/java/org/apache/lucene/tests/util/SetupAndRestoreStaticEnv.java:186-190 (read at the follow-up step)
The finding in 30 seconds
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextBKDReader.java:78-81, :92-93, :110, :124-134, :136-143, :145-181, :183-191, :193-227, :229-236, :238-258, :336-367, :369-399 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:227, :317, :341-376 (clone; :366 splitValuesStack), :407-412 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/PointValues.java:235-267 (at 645d4fe14a20)
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextPointsFormat.java:27; SimpleTextCodec.java:35, :37; SimpleTextPointsWriter.java:83 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/SetupAndRestoreStaticEnv.java:144, :185-190 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:298-299; search/IndexSortSortedNumericDocValuesRangeQuery.java:366, :404 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/index/PointValues.java:242-243, :263-267 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:341-376 (at 645d4fe14a20 and ad911df2605: clone() never copies splitDimValueStack)
  • lucene/core/src/java/org/apache/lucene/document/NearestNeighbor.java:298 (at 645d4fe14a20)
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/BasePointsFormatTestCase.java:1086-1143 (at 645d4fe14a20)
Suggested fix
  • lucene/codecs/src/java/org/apache/lucene/codecs/simpletext/SimpleTextBKDReader.java:154-159, :185, :231 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/bkd/BKDReader.java:317, :341-376, :366, :407-412 (at 645d4fe14a20)
History
  • a Lucene clone: releases/lucene/9.0.0, 9.1.0, 10.5.2, 10.6.0 (SimpleTextBKDReader.java read; line 131 present from 9.1.0)

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

Repro files

About the diagrams

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.