All findings · Lucene finding · LFV-170 · postings-docidrunend-level1-tail-overflow

Postings docIDRunEnd() returns Integer.MIN_VALUE near 2^31 doc IDs, so CheckIndex flags a healthy segment as broken

In a segment at Lucene's 2^31-document limit, the default postings reader's docIDRunEnd() returns Integer.MIN_VALUE on the dense blocks after a term's last skip block, so CheckIndex at level 2 or higher reports a valid segment of 2,147,483,519 documents as broken. Search results stay correct; every release since 10.2.0 is affected.

Summary

Priority
Low, rank 983The postings docIDRunEnd() returns Integer.MIN_VALUE near 2^31 docs, so CheckIndex flags a healthy segment as corrupt while query results stay exact.
Impact
wrong error
Since
10.2.0 (2025)introduced 2025-03-24 · about 1 year
Affected
10.2.0 to 10.6.0 and main
Status
documented
Found by
modelling the postings skip state, then a Z3 model

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

For a dense block, docIDRunEnd() stretches the run to the end of the enclosing 8,192-doc skip block if that block is full. After a term's last complete skip block there is none: the reader stores the sentinel NO_MORE_DOCS yet counts 8,192 more docs, and the shortcut ignores the sentinel. Near 2^31 its test passes and returns NO_MORE_DOCS + 1.

Background: postings blocks, skip blocks and docIDRunEnd

  1. The default postings format stores a term's doc IDs in blocks of 256 (level 0). After every 32 blocks, 8,192 docs, it writes a level-1 skip entry with the last doc ID of that stretch, so advance can jump ahead. A term's last fewer than 8,192 docs have no level-1 entry.
  2. A block whose 256 doc IDs are consecutive is stored as a full bit set: a dense block.
  3. docIDRunEnd() returns one past the last doc of the run of consecutive matching docs that contains the current doc, and must be greater than docID(). CheckIndex (level 2 and up) checks it; query scorers use it to handle a whole run at once.
  4. Doc IDs are Java ints: NO_MORE_DOCS is 2^31 - 1 = 2,147,483,647, and adding 1 wraps to -2,147,483,648. One index holds at most IndexWriter.MAX_DOCS = 2,147,483,519 documents.
Diagram of one term's postings near 2^31: a complete level-1 skip block of 8,192 docs from 2,147,467,264 to 2,147,475,455, then one dense 256-doc block from 2,147,475,456 to 2,147,475,711. After the last skip block the reader sets level1LastDocID to NO_MORE_DOCS = 2,147,483,647 and counts 16,384 docs. The shortcut compares 2,147,483,647 - 2,147,475,711 = 7,936 with 16,384 - 8,448 = 7,936, finds them equal and returns NO_MORE_DOCS + 1 = -2,147,483,648 instead of 2,147,475,712. A control 1,000,000 doc IDs lower compares 1,007,936 with 7,936 and returns the right value; with the guard the main case returns 2,147,475,712.
One term of 8,448 consecutive doc IDs ending just below 2^31: a complete 8,192-doc skip block, then one dense 256-doc block. Both gaps the shortcut compares come to 7,936, so it returns NO_MORE_DOCS + 1 = -2,147,483,648; the same layout 1,000,000 doc IDs lower gives 1,007,936 vs 7,936 and the right answer. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You force-merge an index to one segment at the limit, IndexWriter.MAX_DOCS = 2,147,483,519 documents, each with the term f:x (the shape of Lucene's own testExactlyAtTrueLimit test), and run CheckIndex at -level 2.
  2. You expect "No problems were detected". At doc 2,147,475,456 the postings of f:x are a dense block of 256 docs, so docIDRunEnd() should return 2,147,475,712, the end of that block.
  3. Instead it returns -2,147,483,648 there, and on all 7,936 docs of the 31 dense blocks after the term's last complete 8,192-doc skip block. CheckIndex throws "Run end -2147483648 is <= doc ID 2147475456" and reports 1 broken segment containing 2,147,483,519 documents, which -exorcise would remove.
  4. Why: after the last skip block the reader marks "no next skip block" with NO_MORE_DOCS (2,147,483,647) yet counts 8,192 more docs. The shortcut compares the doc-ID gap to that phantom block's end, 2,147,483,647 - 2,147,475,711 = 7,936, with its remaining docs, 8,192 - 256 = 7,936, finds them equal and returns NO_MORE_DOCS + 1, which overflows.
  5. Smaller segments are fine (a 67,108,864-doc segment is clean), queries return exact results because the bad value only shortens a run, and with a one-condition guard the full segment passes CheckIndex.

Who is affected

Holders of one segment within about 8,000 doc IDs of the 2^31 limit, for example a shard force-merged at MAX_DOCS, who run CheckIndex at level 2 or higher (not the default) or Lucene's test framework at that scale. Default codec, every release since 10.2.0. Searches stay exact; the risk is acting on a false corruption report.

Mechanism, terms and numbers872 words

BlockPostingsEnum#docIDRunEnd() in the default postings reader (Lucene104PostingsReader, and the same code in the Lucene101 and Lucene103 readers) extends a fully dense 256-doc block's run to the end of the enclosing 8,192-doc level-1 skip block when the doc-ID gap and the doc-count gap to that block's end are equal. After a term's last complete level-1 block there is no such block: skipLevel1To sets level1LastDocID = NO_MORE_DOCS as a sentinel but still adds 8,192 to level1DocCountUpto, and the shortcut does not exclude the sentinel. For ordinary doc IDs the gaps differ by about two billion and the method is correct; within 8,192 doc IDs of 2^31 they can be equal, and the method returns NO_MORE_DOCS + 1 = Integer.MIN_VALUE, breaking its contract ("The returned doc is greater than docID()").

For a term in every document this happens on every dense block after the term's last complete level-1 block once maxDoc >= 2,147,475,712, up to IndexWriter.MAX_DOCS = 2,147,483,519. On a default-codec segment of exactly MAX_DOCS documents written by IndexWriter, CheckIndex at level 2 reports "Run end -2147483648 is <= doc ID 2147475456" and "1 broken segments (containing 2147483519 documents)", and says -exorcise would remove the segment; with a one-condition guard the same segment is clean. Query results stay exact (the value is below docID(), so consumers only lose a shortcut). Introduced by #14390 in 10.2.0; every release from 10.2.0 to 10.6.0 and main is affected.

The bug, step by step (main 645d4fe14a20)

Constants: BLOCK_SIZE = 256, LEVEL1_FACTOR = 32, LEVEL1_NUM_DOCS = 8,192 (Lucene104PostingsFormat.java:343, :348, :351).

  1. Small terms never reach it. With docFreq < LEVEL1_NUM_DOCS the enum starts with level1LastDocID = NO_MORE_DOCS and level1DocCountUpto = 0 (Lucene104PostingsReader.java:559-568); the shortcut's right side is then negative and can never match. A term needs at least one complete level-1 block.
  2. The sentinel state. skipLevel1To (:674 on) computes docCountLeft = docFreq - level1DocCountUpto (:683), then adds LEVEL1_NUM_DOCS to level1DocCountUpto (:684), and only then, if fewer than 8,192 docs are left, sets level1LastDocID = NO_MORE_DOCS and stops (:686-688). In a term's tail the counter therefore includes a full phantom level-1 block.
  3. The unguarded shortcut. docIDRunEnd() (:1099-1129), for a UNARY block whose four 64-bit words are all set (:1101-1115), computes level0DocCountUpto = docFreq - docCountLeft (:1117) and tests level1LastDocID - level0LastDocID == level1DocCountUpto - level0DocCountUpto (:1118-1119); if true it returns level1LastDocID + 1 (:1121), else level0LastDocID + 1 (:1124).
  4. When it misfires. For the j-th full block after the last complete level-1 block (j = 1..31) the right side is 8,192 - 256 x j (at most 7,936) and the left side is NO_MORE_DOCS - level0LastDocID, about two billion for ordinary doc IDs. They are equal exactly when level0LastDocID = NO_MORE_DOCS - (8,192 - 256 x j), so only blocks whose docs are all at or above 2^31 - 8,192 = 2,147,475,456 can trigger it; then :1121 returns NO_MORE_DOCS + 1 = Integer.MIN_VALUE, with assertions on or off.
  5. Worked example (the minimal test). One term of 8,448 consecutive doc IDs, 2,147,467,264 to 2,147,475,711: one complete level-1 block ending at 2,147,475,455, then one dense block 2,147,475,456 to 2,147,475,711. At doc 2,147,475,456: level1LastDocID = 2,147,483,647 (sentinel), level1DocCountUpto = 16,384 (two blocks counted, one exists), level0LastDocID = 2,147,475,711, level0DocCountUpto = 8,448. Left 7,936, right 7,936: it returns -2,147,483,648 instead of 2,147,475,712. Shifted 1,000,000 doc IDs lower, the left side is 1,007,936 and the answer is right.
  6. A term in every document. After k complete level-1 blocks the test reduces to k x 8,192 = 2^31 - 8,192, so k = 262,143: it fires on every dense tail block exactly when maxDoc is in [2,147,475,712, MAX_DOCS]. A Z3 bit-vector model with Java int semantics derived this range (unique k) and proved the bad state unreachable while level0LastDocID < MAX_DOCS - 16,386.
  7. The reader knows the sentinel elsewhere. Impacts.numLevels() returns 1 when level1LastDocID == NO_MORE_DOCS (:1358); docIDRunEnd() does not check it.
  8. Older formats. The backward-codecs readers for indexes written by 10.1.x to 10.3.x (Lucene101 and Lucene103 formats) have the same shortcut with 128-doc blocks and 4,096-doc level-1 blocks (Lucene101PostingsReader.java:1059-1062, Lucene103PostingsReader.java:1105-1108, sentinel at :679 in both); their first failing doc is 2,147,479,552. 10.1.0 code has no postings docIDRunEnd, but 10.1.0 already writes the Lucene101 format, and a term it wrote near 2^31 returns -2,147,483,648 at doc 2,147,479,552 once main reads it (executed).
  9. Who sees the value. CheckIndex testPostings runs only at level >= 2 (CheckIndex.java:1114-1125, :4754) and checks runs of every term with docFreq > 1024, every 1024th term, or all terms at level 3 (:2090-2104); checkDocIDRuns throws "Run end X is <= doc ID Y" (:2549) and the segment is reported FAILED with "exorciseIndex() would remove reference to this segment" (:1208). The CLI default is level 1 (:4742, :4748); new CheckIndex(dir) without setLevel keeps 0 (:499). The test framework's AssertingPostingsEnum asserts endDocID > startDocID (AssertingLeafReader.java:609). DenseConjunctionBulkScorer evaluates a clause with a short run end normally and DisjunctionDISIApproximation discards it with Math.max (DenseConjunctionBulkScorer.java:196-212, DisjunctionDISIApproximation.java:225-233).
Exposure and likelihood302 words
  • Who is hit. A single segment within about 8,000 doc IDs of IndexWriter.MAX_DOCS with a term that is dense near the end of the doc-ID space, for example a term in every document of a shard force-merged to one segment at the hard limit, checked with CheckIndex at level 2 or higher, or Lucene's own tests at that scale with the default postings format (such as testExactlyAtTrueLimit: predicted, not run).
  • Default vs opt-in. The default codec and IndexWriter settings produce the state; nothing is configured. Seeing it needs CheckIndex at level 2 or 3 (the CLI default is 1), the test framework's check-on-close, or a caller of docIDRunEnd() that relies on the contract.
  • Scale. maxDoc >= 2,147,475,712 for a term in every document; in general a dense 256-doc block whose docs are all at or above 2,147,475,456 (2,147,479,552 for indexes written by 10.1.x to 10.3.x) right after the term's last complete level-1 block. A 67,108,864-doc segment is clean.
  • What goes wrong. CheckIndex reports a valid segment as broken ("1 broken segments (containing 2147483519 documents)") and says -exorcise would remove it; an operator who trusts it and runs -exorcise would drop about 2^31 healthy documents (follows from CheckIndex's code and warning; not executed). docIDRunEnd() breaks its contract on up to 7,936 docs per term. Under the test framework, AssertingPostingsEnum fails.
  • Queries are fine. The value is below docID(), so it can only shorten a run. Five FILTER conjunctions, some with nested disjunctions, over a MAX_DOCS segment with holes inside the affected tail returned exact results with assertions on and off while the bad value was consumed 2 to 4 times per query; the same with the fix, with no measurable time difference.
  • Likelihood: contrived. Almost no deployment runs single segments at the 2^31 limit, but Lucene supports and tests that limit.

Minimal repro

One JUnit test, about 2 seconds: it writes one term of 8,448 consecutive doc IDs ending at 2,147,475,711 with the default postings writer, in a segment of IndexWriter.MAX_DOCS documents, reads it back, advances to doc 2,147,475,456 and checks only the docIDRunEnd() contract. It fails with assertions on and off.

TestDocIDRunEndNearMaxDoc.java (JUnit, lucene/core tests, package org.apache.lucene.codecs.lucene104)81 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.util.HashMap;
import java.util.Map;
import org.apache.lucene.codecs.Codec;
import org.apache.lucene.codecs.lucene104.Lucene104PostingsFormat.IntBlockTermState;
import org.apache.lucene.index.DocValuesSkipIndexType;
import org.apache.lucene.index.DocValuesType;
import org.apache.lucene.index.FieldInfo;
import org.apache.lucene.index.FieldInfos;
import org.apache.lucene.index.IndexOptions;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.PostingsEnum;
import org.apache.lucene.index.SegmentInfo;
import org.apache.lucene.index.SegmentReadState;
import org.apache.lucene.index.SegmentWriteState;
import org.apache.lucene.index.VectorEncoding;
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.tests.util.LuceneTestCase;
import org.apache.lucene.util.InfoStream;
import org.apache.lucene.util.StringHelper;
import org.apache.lucene.util.Version;

public class TestDocIDRunEndNearMaxDoc extends LuceneTestCase {

  public void testDenseBlockAfterLastLevel1Block() throws Exception {
    FieldInfo fi =
        new FieldInfo(
            "f", 0, false, true, false, IndexOptions.DOCS, DocValuesType.NONE,
            DocValuesSkipIndexType.NONE, -1, Map.of(), 0, 0, 0, 0, VectorEncoding.FLOAT32,
            VectorSimilarityFunction.EUCLIDEAN, false, false);
    FieldInfos fieldInfos = new FieldInfos(new FieldInfo[] {fi});
    int firstDoc = Integer.MAX_VALUE - 16383;
    try (Directory dir = new ByteBuffersDirectory()) {
      SegmentInfo si =
          new SegmentInfo(
              dir, Version.LATEST, Version.LATEST, "_0", IndexWriter.MAX_DOCS, false, false,
              Codec.getDefault(), Map.of(), StringHelper.randomId(), new HashMap<>(), null);
      IntBlockTermState state = new IntBlockTermState();
      state.docFreq = 8192 + 256;
      SegmentWriteState writeState =
          new SegmentWriteState(InfoStream.NO_OUTPUT, dir, si, fieldInfos, null, IOContext.DEFAULT);
      try (Lucene104PostingsWriter writer = new Lucene104PostingsWriter(writeState)) {
        writer.setField(fi);
        writer.startTerm(null);
        for (int i = 0; i < state.docFreq; i++) {
          writer.startDoc(firstDoc + i, 1);
          writer.finishDoc();
        }
        writer.finishTerm(state);
      }
      SegmentReadState readState = new SegmentReadState(dir, si, fieldInfos, IOContext.DEFAULT);
      try (Lucene104PostingsReader reader = new Lucene104PostingsReader(readState)) {
        PostingsEnum pe = reader.postings(fi, state, null, PostingsEnum.NONE);
        int doc = pe.advance(firstDoc + 8192);
        int runEnd = pe.docIDRunEnd();
        // one level-1 block, then a dense block: expected runEnd > docID(), actual MIN_VALUE
        assertTrue("expected docIDRunEnd() > " + doc + ", got " + runEnd, runEnd > doc);
      }
    }
  }
}
Command
cp $REPRO/TestDocIDRunEndNearMaxDoc.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestDocIDRunEndNearMaxDoc
./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestDocIDRunEndNearMaxDoc -Ptests.asserts=false
Expected
docIDRunEnd() returns a doc greater than 2,147,475,456 (2,147,475,712, the end of the dense block, with the fix).
Observed
docIDRunEnd() returns -2,147,483,648 (Integer.MIN_VALUE); on a full MAX_DOCS segment CheckIndex reports "Run end -2147483648 is <= doc ID 2147475456".

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

More files, environment, full output403 words

The test is codec level: it uses the public Lucene104PostingsWriter (setField, startTerm, startDoc/finishDoc, finishTerm) on a SegmentInfo with maxDoc = IndexWriter.MAX_DOCS, then Lucene104PostingsReader.postings (marked @lucene.experimental) with the term state the writer filled in. The terms dictionary is skipped because docIDRunEnd does not use it. The doc IDs must be consecutive across the level-1 boundary: with docs 0..8,191 followed by the 256-doc block, that block's first delta is large, it is stored PACKED rather than dense, and the test passes on main.

It is deterministic (the failing value does not depend on the seed; seeds DEADBEEF, 5EED1234 and 7E1B4EA4 fail), single-threaded and needs the default test heap. The FieldInfo arguments are packed for brevity (./gradlew tidy reformats them). As a regression test it can stay its own class or become a method of TestLucene104PostingsFormat (same package).

The CheckIndex report is not part of this test. It was seen end to end on a segment of exactly MAX_DOCS documents that IndexWriter wrote (a plain program with only lucene-core: two small single-segment indexes built with addDocument and forceMerge(1), then addIndexes(CodecReader...) of 512 copies into one new segment, about 4 minutes and 79 MB), and in the study's codec-level CheckIndex.testPostings runs. The @Monster test testExactlyAtTrueLimit itself was not run.

Environment

lucenemain 645d4fe14a20; also fails on the 10.4.0, 10.5.2 and 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, main 645d4fe14a20, stock (same failure with -Ptests.asserts=false)
> Task :lucene:core:test FAILED

TestDocIDRunEndNearMaxDoc > testDenseBlockAfterLastLevel1Block FAILED
    java.lang.AssertionError: expected docIDRunEnd() > 2147475456, got -2147483648
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:71C1222622114FDE]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.codecs.lucene104.TestDocIDRunEndNearMaxDoc.testDenseBlockAfterLastLevel1Block(TestDocIDRunEndNearMaxDoc.java:77)
:lucene:core:test (FAILURE): 1 test, 1  failure

# with the fix: :lucene:core:test (SUCCESS): 1 test (assertions on and off)

# end to end: IndexWriter-built segment of MAX_DOCS documents, CheckIndex level 2, main, assertions off (excerpt; timestamps removed)
advance(2147475456) = 2147475456, docIDRunEnd() = -2147483648
docIDRunEnd() contract (result > docID()) over 8063 postings from 2147475456 to 2147483518: violations=7936 first at doc 2147475456 (docIDRunEnd()=-2147483648), last at doc 2147483391
1 of 1: name=_0 maxDoc=2147483519
    codec=Lucene104
    test: terms, freq, prox...ERROR: org.apache.lucene.index.CheckIndex$CheckIndexException: Run end -2147483648 is <= doc ID 2147475456
FAILED
    WARNING: exorciseIndex() would remove reference to this segment; full exception:
WARNING: 1 broken segments (containing 2147483519 documents) detected

# same index with the guard
advance(2147475456) = 2147475456, docIDRunEnd() = 2147475712
    test: terms, freq, prox...OK [1 terms; 2147483519 terms/docs pairs; 2147483519 tokens]
No problems were detected with this index.

Why it looks unintended

  • docIDRunEnd() promises "The returned doc is greater than docID()", with no exception near 2^31; CheckIndex and AssertingPostingsEnum enforce it.
  • IndexWriter.MAX_DOCS = 2,147,483,519 is the documented hard limit, and Lucene's @Monster test testExactlyAtTrueLimit builds this segment shape.
  • The same reader treats the sentinel as "no level 1" in Impacts.numLevels(); no review of the shortcut ever discussed it.
The full argument200 words
  • Contract. DocIdSetIterator.java:245-263: "1. The returned doc is greater than docID(). 2. All docs in range [docID(), docIDRunEnd()) match this iterator." The only exception: "It is illegal to call this method when the iterator is exhausted or not positioned." The method is public, without @lucene.internal or @lucene.experimental.
  • Lucene enforces it. #14390 added the postings shortcut together with CheckIndex.checkDocIDRuns, which rejects a run end <= doc; AssertingPostingsEnum asserts it too.
  • The limit is supported. IndexWriter.MAX_DOCS = Integer.MAX_VALUE - 128 is the "Hard limit on the maximum number of documents that may be added to the index" (IndexWriter.java:202-208). TestIndexWriterMaxDocs#testExactlyAtTrueLimit (:47-89) adds exactly MAX_DOCS documents with one StringField, calls forceMerge(1) and closes a test directory, which runs CheckIndex.
  • The reader handles the sentinel elsewhere. Impacts.numLevels() (Lucene104PostingsReader.java:1358).
  • History. The bodies, comments and reviews of #14390, #14693 and #15160 never mention the NO_MORE_DOCS level-1 sentinel or doc IDs near 2^31; no comment, test, CHANGES.txt or MIGRATE.md entry allows Integer.MIN_VALUE.
  • Precedent. #16161 "SkipBlockRangeIterator: Don't extend docIdRunEnd into sparse blocks" (merged 2026-06-01) fixed the same class of bug, a run end extended up the skip levels without a density check, in the doc-values skipper iterator.
The devil's-advocate check214 words

The strongest case for "not a bug": (1) nobody runs a single segment within 8,192 docs of 2^31; (2) the first repros wrote postings through @lucene.experimental codec classes and a hand-built SegmentInfo instead of IndexWriter; (3) the sentinel looks meant to make the density test fail, so the author assumed doc IDs stay far from 2^31; (4) no query returns a wrong result. A later reading-only adversary also gave a procedural "not real" because it could not run code; on the merits it agreed with the root cause and the fix.

Why it fails: (1) the limit is documented and has its own test; (2) the same value at the same doc was reproduced on a segment that IndexWriter wrote and DirectoryReader opened, and the codec-level test only feeds the default writer legal doc IDs below MAX_DOCS; (3) that assumption is written nowhere, the contract has no exception, and the reader's own numLevels() handles the sentinel; (4) low impact lowers the priority, it does not make a false corruption report intended. Nor is it a case of an absurd parameter that then fails loudly: the parameter is Lucene's own tested limit, and the loud failure is a false report on a valid segment that recommends removing it. Verdict: a defect, low priority.

Suggested fix

Recommended Guard the shortcut against the sentinel, in all three readers

Skip the level-1 shortcut when level1LastDocID is the NO_MORE_DOCS sentinel: level1IsDense = level1LastDocID != NO_MORE_DOCS && (the existing equality), in the Lucene104 reader and in the Lucene101 and Lucene103 readers of backward-codecs. Reader-only, no format change; the test passes and the closest suites, including the nightly testDocIDRunEnd, stay green.

int level0DocCountUpto = docFreq - docCountLeft;
// After the last complete level-1 block, level1LastDocID is the NO_MORE_DOCS sentinel:
// there is no level-1 block to extend the run into.
boolean level1IsDense =
    level1LastDocID != NO_MORE_DOCS
        && level1LastDocID - level0LastDocID == level1DocCountUpto - level0DocCountUpto;
if (level1IsDense) {
  return level1LastDocID + 1;
}
return level0LastDocID + 1;
All options and trade-offs3 options · 516 words

Two fixes were checked; the guard is the smaller and fully validated one.

Guard the shortcut against the sentinel, in all three readers (validated by the checks) (recommended)

+12/-3 in Lucene104PostingsReader, backward_codecs/lucene101/Lucene101PostingsReader and backward_codecs/lucene103/Lucene103PostingsReader (the last two with two spaces less indentation). In a term's tail the method falls back to level0LastDocID + 1, the end of the current dense block; wherever a real level-1 block exists the shortcut is unchanged.

int level0DocCountUpto = docFreq - docCountLeft;
// After the last complete level-1 block, level1LastDocID is the NO_MORE_DOCS sentinel:
// there is no level-1 block to extend the run into.
boolean level1IsDense =
    level1LastDocID != NO_MORE_DOCS
        && level1LastDocID - level0LastDocID == level1DocCountUpto - level0DocCountUpto;
if (level1IsDense) {
  return level1LastDocID + 1;
}
return level0LastDocID + 1;

Trade-offs

  • One extra int comparison per call on a dense block. No result changes outside the bug window: for terms below 8,192 docs, and for tails far from 2^31, the equality is already false.
  • Reader-only: no format change; existing indexes written by 10.1.x to 10.6.x are read correctly without reindexing (the data on disk is valid).
  • Mirrors how Impacts.numLevels() already treats the sentinel.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20, JDK 25.0.3: the minimal test fails stock and passes with the fix, with assertions on and off.
  • With the fix: org.apache.lucene.codecs.lucene104.*, TestCheckIndex, TestDenseConjunctionBulkScorer, TestBulkScorerConsistency and TestDocIdSetIterator: 12 classes, 226 tests, 6 skipped, 0 failures; backward-codecs lucene101.* and lucene103.*: 11 classes, 106 tests, 6 skipped, 0 failures.
  • The @Nightly BasePostingsFormatTestCase#testDocIDRunEnd (100 force-merged IndexWriter segments, each checked by CheckIndex at level 3; skipped by default) with -Ptests.nightly=true for TestLucene104PostingsFormat, TestLucene101PostingsFormat, TestLucene101PostingsFormatV0 and TestLucene103PostingsFormat: 4 tests, 0 failures.
  • :lucene:core:check :lucene:backward-codecs:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • The Lucene104 hunk alone makes the IndexWriter-built MAX_DOCS segment CheckIndex-clean; ported to the 10.2.0 reader it makes the version-spanning probe pass.

Do not count the phantom level-1 block (validated by the checks)

In skipLevel1To, move level1DocCountUpto += LEVEL1_NUM_DOCS; below the if (docCountLeft < LEVEL1_NUM_DOCS) { level1LastDocID = NO_MORE_DOCS; break; } block, so the counter only counts level-1 blocks that exist. In the tail the shortcut's right side is then negative and never matches.

docCountLeft = docFreq - level1DocCountUpto;

if (docCountLeft < LEVEL1_NUM_DOCS) {
  level1LastDocID = NO_MORE_DOCS;
  break;
}
level1DocCountUpto += LEVEL1_NUM_DOCS;

Trade-offs

Fixes the state where it is created and also stops level1DocCountUpto from wrapping to Integer.MIN_VALUE for an all-docs term at MAX_DOCS (262,144 x 8,192 = 2^31 with the phantom block). The field is read only in skipLevel1To and docIDRunEnd, and the method is not entered again after the sentinel, but it touches the advance path and changes a field's meaning, so it needs a closer review; the two backward-codecs readers need the same change.

What the checks validated

The minimal test passes with only this change shadowed into the stock main jar, assertions on and off. No suites were run.

Make CheckIndex tolerate a run end <= doc (not recommended)

Would silence the false report.

Trade-offs

Hides a real contract violation from every other caller of docIDRunEnd().

Downstream note

Tools that run CheckIndex at level 2 or higher on segments near 2^31 documents and act on the verdict automatically, for example with -exorcise, would drop a healthy segment.

How it was found

While modelling the Lucene104 postings reader's skip state, the finder noticed that the level-1 shortcut does not exclude the NO_MORE_DOCS sentinel. A Z3 bit-vector model with Java int semantics then gave a witness, proved the state unreachable below about 2^31 - 2^14 doc IDs, and the exact all-docs range, which a codec-level test and an all-docs run confirmed.

The method in full245 words

Method: semi-formal (the hypothesis came from code reading; the SMT model then produced the witness, the unreachability bound and the exact trigger range).

  • The finder's hunt unit modelled the Lucene104 reader's skip state. Reading docIDRunEnd next to skipLevel1To, it noticed that the level-1 shortcut compares a doc-ID gap measured from the NO_MORE_DOCS sentinel with a doc count that includes a phantom level-1 block.
  • A Z3 bit-vector model of the skip arithmetic with 32-bit Java int semantics: Q4a found a witness (level0LastDocID = 2,147,477,503, returning NO_MORE_DOCS + 1); Q4b showed the state unreachable while level0LastDocID < MAX_DOCS - 16,386; Q4c gave the all-docs trigger range, maxDoc in [2,147,475,712, MAX_DOCS] with the unique count k = 262,143 of complete level-1 blocks.
  • A codec-level JUnit on the stock jar failed on main (assertions on and off) and on 10.4.0 and 10.5.2 (assertions on). A run on a separate test host wrote 2,147,475,712 postings of an all-docs term in 10.7 s and failed a CheckIndex-style loop at doc 2,147,475,456, the first doc of the predicted tail block.
  • The unit's postings differential (191 million checks, 0 mismatches) never reached doc IDs near 2^31; its mutant "level-1 density always true" was killed, so the shortcut is exercised at ordinary scale.
  • Cross-model reviews, an independent reproducer, a devil's advocate and an adjudication then confirmed it; the repro was later cut to the one test above, and a tie-break on current main reproduced it on an IndexWriter-built segment.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs (including the migrated JIRA issues), commits, five mailing lists, downstream projects or the web; last refreshed 2026-10-31. Closest: #16161, the same class of run-end bug in the doc-values skipper iterator.

Searches5 searches

Verdict new. "New" means no public report was found; private reports to the security list and unindexed chats cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. migrated JIRA), commits, downstream projects, web2026-10-31 (earlier rounds 2026-10-22/23 and 2026-10-26)139 queries: identifiers (docIDRunEnd, level1LastDocID, level1IsDense, level1DocCountUpto, skipLevel1To, LEVEL1_NUM_DOCS, checkDocIDRuns), symptoms ("Run end", "is <= doc ID", Integer.MIN_VALUE, -2147483648) and scale (MAX_DOCS, testExactlyAtTrueLimit, 2^31); 15 manual GitHub searches355 distinct hits, 0 high and 22 medium overlap, none the same bug
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-10-3140 queries in the main run plus 20 in a lists run61 hits in the lists run, all low overlap; no report
Open PRs2026-10-31changed files of all 410 open PRs matched against the postings readers and writers, CheckIndex and DocIdSetIterator (26 PRs)none changes docIDRunEnd, level1IsDense or checkDocIDRuns
Threads read in full2026-10-31#14390, #14693, #15160, #13585, #13862, #13892, the 10.6.0 announcement and CHANGES.txtthe sentinel and doc IDs near 2^31 are never discussed; listed docIDRunEnd fixes are for doc-values iterators and BitSetIterator
GitHub refresh2026-10-31docIDRunEnd, level1IsDense, "Run end is <= doc ID", Lucene104PostingsReader; issues and PRs updated since 2026-10-30no results

History

Introduced in 10.2.0 by #14390 (2025-03-24), which added the shortcut and CheckIndex's run check; copied into the 10.3 and 10.4 readers. Affects 10.2.0 to 10.6.0 and main; 10.1.0 and older have no postings docIDRunEnd, but 10.1.x indexes are affected once 10.2.0 or later reads them. Not fixed upstream.

Versions tested7 rows

Introduced by #14390 "Implement #docIDRunEnd() on PostingsEnum." (author and merger jpountz, approved by gf2121; db00348ef18e on main and the backport 6bef45756a96, both 2025-03-24), which added the level1IsDense shortcut to Lucene101PostingsReader and CheckIndex.checkDocIDRuns. #14693 (06afbe701640, 10.3.0) copied the method into Lucene103PostingsReader; #15160 (00f2b01e7985, 10.4.0, 256-doc blocks) kept the level-1 expression. The sentinel state itself comes from #13585 (b4a8810b7ae, 9.12.0) and was harmless before #14390. Not fixed: upstream main 2f424fd428da, branch_10_6 and the 10.6.0 tag still have the unguarded expression.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails, assertions on and off; an IndexWriter-built MAX_DOCS segment fails CheckIndex level 2 at doc 2,147,475,456
10.4.0 to 10.6.0 affectedLucene104 format: first failing doc 2,147,475,456 (minimal test on 10.4.0, 10.5.2 and 10.6.0)
10.2.0 to 10.3.2 affectedLucene101/103 formats: first failing doc 2,147,479,552; CheckIndex reports the segment broken
9.12.3, 10.0.0, 10.1.0 not affectedno postings docIDRunEnd; the same near-limit postings read back exactly and CheckIndex is clean
10.1.0 index read on main affectedLucene101 format version 0 written by 10.1.0: main's Lucene101PostingsReader returns -2,147,483,648 at doc 2,147,479,552; 1,000,000 lower it is right
10.2.0 with only the guard fixthe version-spanning probe passes
main with the guard (three readers) fixminimal test passes; closest suites and nightly testDocIDRunEnd green; MAX_DOCS segment CheckIndex-clean

Independent checks

The study's cross-model reviews (OpenAI GPT-5.6), independent reproducer, devil's advocate and adjudication rated it real. On current main a reading-only cross-model adversarial review could not run code and gave a procedural "not real"; a tie-break then reproduced the bug on an IndexWriter-built segment of 2,147,483,519 documents.

Checks and verdicts269 words
Each independent check and its verdict
CheckVerdictWhat it found
Finder (code reading, Z3 skip-state model, codec-level JUnit) candidateFails on main (assertions on and off) and on 10.4.0 and 10.5.2 (assertions on); an all-docs run of 2,147,475,712 postings fails at doc 2,147,475,456.
Cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own test fails 1 of 2, control passes; the guard passes it and an IndexWriter-built docIDRunEnd test over 100 iterations.
Independent reproducer realOwn tests with a different witness fail on main, 10.5.2, 10.4.0, 10.3.2 and 10.2.0; controls 1,000,000 doc IDs lower pass; an all-docs term fails at both ends of the Z3 range and passes just below it.
Cross-model adversary, intended lens (OpenAI GPT-5.6, reading only) real (medium)Experimental codec classes do not exempt the public docIDRunEnd contract; MAX_DOCS is supported and tested.
Devil's advocate realNo intended argument survives; corrected the CheckIndex level to 2 or higher.
Adjudication confirmed (high)Re-ran the independent repro with a new seed.
Security screen not security-sensitiveFive FILTER conjunctions over a MAX_DOCS segment return exact results while the bad value is consumed.
Current-main reproducer (2026-10-31) realMinimal test fails on main 645d4fe14a20, 10.5.2 and 10.6.0, assertions on and off; passes with the guard.
Cross-model adversary on current main (GPT, reading only) not real (procedural)Could not run code; agreed with the root cause and the fix; asked for an IndexWriter-built segment.
Tie-break (2026-10-31) real (high)IndexWriter-built segment of 2,147,483,519 documents: CheckIndex level 2 reports it broken; with the guard it is clean; 263,144- and 67,108,864-doc controls are clean.

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,399 words

Finding record

LFV-170, snapshot of 2026-11-06

Registry title
Postings docIDRunEnd() returns Integer.MIN_VALUE for dense blocks after a term's last level-1 skip block when doc IDs are within 8,192 (4,096 before 10.4) of 2^31, so CheckIndex at level 2 or higher reports a healthy segment of about 2^31 documents as corrupt
Area
codecs / postings reader (Lucene104 BlockPostingsEnum#docIDRunEnd, also the Lucene101 and Lucene103 readers); index / CheckIndex
Priority
Low, rank 983: The postings docIDRunEnd() returns Integer.MIN_VALUE near 2^31 docs, so CheckIndex flags a healthy segment as corrupt while query results stay exact.
Impact class
wrong error
Impact note
Hits only holders of a single segment within about 8,000 doc IDs of IndexWriter.MAX_DOCS (maxDoc >= 2,147,475,712 for a term in every document, for example a shard force-merged to one segment at the hard limit) who run CheckIndex at level 2 or higher (or the test framework's check-on-close): they get a false 'Run end -2147483648 is <= doc ID ...' corruption report on a healthy segment, which -exorcise would turn into a dropped segment; default codec, every release since 10.2.0; query results stay exact (executed), only a dense-run shortcut is lost on at most 31 tail blocks per term.
Summary
You have one segment of about 2^31 documents (within about 8,000 docs of IndexWriter.MAX_DOCS) with a term whose postings are dense near the end of the doc-ID space, for example a term that every document has, and you call docIDRunEnd() on its postings or run CheckIndex at level 2 or higher. You expect docIDRunEnd() to return a doc ID greater than the current one, as its javadoc promises, and CheckIndex to pass. Instead, in the dense blocks after the term's last full 8,192-doc level-1 skip block (4,096 docs in 10.2-10.3), the default postings reader returns Integer.MIN_VALUE and CheckIndex reports the healthy segment as corrupt ('Run end -2147483648 is <= doc ID 2147475456'); query results stay correct, and the repro writes the postings through the public codec API rather than indexing 2^31 documents with IndexWriter.
Introduced
db00348ef18e3a8090bdab478bdc525f8297d8b9 (main) and its branch_10x backport 6bef45756a96df61cf9c8b34644327b53d651330, PR #14390 'Implement #docIDRunEnd() on PostingsEnum.' (author and merger jpountz, approved by gf2121), which added the level1IsDense shortcut to Lucene101PostingsReader without excluding the NO_MORE_DOCS level-1 sentinel; copied verbatim into Lucene103PostingsReader by #14693 (06afbe701640, 10.3.0) and kept by #15160 (00f2b01e7985, Lucene104 with 256-doc blocks, 10.4.0) [read: git log -S 'level1IsDense' over the three readers returns exactly these 3 commits, git blame -w -M -C -C, git tag --contains and merge-base on the backports; exec: release level, 10.1.0 unaffected because the API is absent, 10.2.0 fails, and adding only the guard flips 10.2.0 and main to pass]; the sentinel state itself (level1DocCountUpto advanced by a full block, level1LastDocID = NO_MORE_DOCS) comes from #13585 (b4a8810b7ae, 9.12.0) and was harmless before #14390 [exec: 9.12.3, 10.0.0 and 10.1.0 read the same near-limit postings back exactly and CheckIndex is clean] (2025-03-24 (merged on main; branch_10x backport the same day))
First affected version
10.2.0 [exec] (10.1.0 is the last unaffected release: DocIdSetIterator#docIDRunEnd, #14312, first shipped in 10.2.0 [exec: 9.12.3, 10.0.0 and 10.1.0 pass; the independent reproduction's repro does not compile on 10.1.0])
Affected versions
10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main ea57360912ed (-ea and -da). [exec 2026-10-31] 10.6.0 release jars (SHA-1 checked against Maven Central): the minimal test fails with -ea and -da and the version-spanning probe fails with -ea. [exec, codec level] the follow-up's version-spanning probe fails on all 10 releases and main; the independent reproduction's repro fails on 10.2.0, 10.3.2, 10.4.0, 10.5.2 and main; the finder's on 10.4.0, 10.5.2 and main; window: the last 4,096 doc IDs below 2^31 for Lucene101/103 (10.2.x-10.3.x, failing doc 2147479552), the last 8,192 for Lucene104 (10.4.0 on, failing doc 2147475456). [read] the unreleased branch_10x and branch_10_6 and the backward-codecs Lucene101 and Lucene103 readers on main (which read 10.2.x and 10.3.x indexes) have the identical expression. Not fixed upstream: main, branch_10x and branch_10_6 heads still unguarded (read-only check, 2026-10-26).
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 and voted “not real” while agreeing with the root cause (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
semi-formal: Z3, code reading
Upstream and prior reports
none found (2026-10-26, verdict new: five scripted prior-report searches (the finder's and four follow-up runs) with 86 distinct queries: 30 GitHub issue/PR, 5 commit, 6 downstream (Elasticsearch, OpenSearch, Solr), 30 mailing-list over dev@, java-user@, issues@, commits@ and builds@, 9 JIRA-archive and 6 web queries; plus 9 manual checks: the changed-file lists of all 413 open apache/lucene PRs, the diffs of the 4 open PRs that touch postings readers, the comments and review comments of #14390 and #14693, the upstream commit history and contents of the readers, CHANGES.txt, LUCENE-8520 and 8 builds@ threads read in full; 22 hits read, none the same bug); not fixed: upstream main ea57360912ed, branch_10x 1309534296e3 and branch_10_6 2763db11b0a7 still have the unguarded expression, the last commit to Lucene104PostingsReader.java (#16491) changes comments only, and no open PR changes docIDRunEnd in any postings reader; related, not reports of this bug: #14390 (introduced it; its review covered BLOCK_SIZE and test coverage, not the sentinel), #14693 and #15160 (carried it forward), #16161 (closest analogue: a SkipBlockRangeIterator run end extended through skip levels without a density check, other code and fix), #16450 and #16513 (doc-values docIDRunEnd fixes and contract tests), #9566 / LUCENE-8520 (a 2018 testExactlyAtTrueLimit failure from another cause), #16847 (open draft for more than 2^31 docs per index; segments stay capped at MAX_DOCS); gaps: keyword search only, private reports to security@lucene.apache.org and unindexed chats are not visible
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test through gradle in a scratch worktree and through a standalone JUnit runner, assertions on and off; an end-to-end program on a segment of IndexWriter.MAX_DOCS documents written by IndexWriter (assertions off); the recommended fix with the closest core and backward-codecs suites. Release jars (Maven Central, SHA-1 checked): 9.12.3, 10.0.0 and 10.1.0 (clean, no postings docIDRunEnd), 10.2.0 to 10.5.2 and 10.6.0 (affected). The study ran on main ea57360912ed; Lucene104PostingsReader.java is the same blob there, at 645d4fe14a20 and at upstream main 2f424fd428da (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:559-560 (docFreq < LEVEL1_NUM_DOCS: no level-1 data), :674-689 (skipLevel1To: level1DocCountUpto += LEVEL1_NUM_DOCS, then level1LastDocID = NO_MORE_DOCS at :687), :1099-1129 (docIDRunEnd: level1IsDense test at :1118-1119, return level1LastDocID + 1 at :1121), :1358 (getImpacts already treats level1LastDocID == NO_MORE_DOCS as 'no level 1')
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene101/Lucene101PostingsReader.java:679, :1059-1061 and lucene103/Lucene103PostingsReader.java:679, :1105-1107 (the same sentinel and unguarded shortcut)
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:245-263 (docIDRunEnd contract: 'The returned doc is greater than docID()'; only exception: exhausted or unpositioned)
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114-1125 (testPostings only at level >= MIN_LEVEL_FOR_INTEGRITY_CHECKS), :2090-2104 (checkDocIDRuns for docFreq > 1024, every 1024th term, or slow checks), :2537-2559 (checkDocIDRuns throws 'Run end X is <= doc ID Y'), :4748-4754 (DEFAULT_VALUE = MIN_VALUE, MIN_LEVEL_FOR_INTEGRITY_CHECKS = 2)
  • lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java:196-212 (a run end below minRunEndThreshold puts the clause into windowClauses; collectRange only for run ends at or above it) and DisjunctionDISIApproximation.java:225-233 (Math.max discards the bad value)
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:603-610 (AssertingPostingsEnum#docIDRunEnd asserts endDocID > startDocID); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:208 (MAX_DOCS = Integer.MAX_VALUE - 128); lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMaxDocs.java:47-85 (@Monster testExactlyAtTrueLimit: MAX_DOCS docs with one StringField term, forceMerge(1))
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:559-568, :674-689 (:683-688), :1099-1129 (:1117-1124), :1358
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsFormat.java:343, :348, :351
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene101/Lucene101PostingsReader.java:679, :1059-1062; lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene103/Lucene103PostingsReader.java:679, :1105-1108
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:499, :1114-1125, :1208, :2090-2104, :2537-2559, :4742, :4748, :4754
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/AssertingLeafReader.java:603-611
  • lucene/core/src/java/org/apache/lucene/search/DenseConjunctionBulkScorer.java:196-212; lucene/core/src/java/org/apache/lucene/search/DisjunctionDISIApproximation.java:225-233
Who is affected
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1114-1125, :1208, :4742-4757
  • lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryWrapper.java:33; tests/util/TestUtil.java:302-303
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:245-263
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:202-208
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriterMaxDocs.java:47-89
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:1358
  • a Lucene clone: git show --stat db00348ef18e (#14390: Lucene101PostingsReader.java, CheckIndex.java, BasePostingsFormatTestCase.java)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsReader.java:683-688 (level1DocCountUpto is read only at :683, :684 (the += itself) and :1119)
History
  • a Lucene clone: git log -1 db00348ef18e, 6bef45756a96; git tag --contains 6bef45756a96 (releases 10.2.0 to 10.6.0)

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

Repro files

About the diagrams

Made separately from this text, from the code at 645d4fe14a20 and a probe that prints the reader's skip state for this layout and a control shifted 1,000,000 doc IDs lower, with assertions on and off, stock and with the guard; the PNG is the summary still.

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.