All findings · Lucene finding · LFV-340 · memoryindex-multivalue-dropped-end-position-increment

MemoryIndex drops the final position increment between the values of a multi-valued field, so phrase queries disagree with IndexWriter

When a field has several values and one ends in a removed stop word, MemoryIndex forgets the hole, so later values sit one position lower than with IndexWriter and phrases across values match or miss differently. Lucene Monitor inherits it: a document can match alone but not in a batch.

Summary

Priority
Medium, rank 240MemoryIndex silently drops the final position increment between values, so phrase results differ from IndexWriter, including in Monitor's single-document path, but it needs a multi-valued field, stop-word removal and a small gap, so it sits with its MemoryIndex sibling LFV-333.
Impact
silent wrong results
Since
4.5.0 (LUCENE-3849 changed IndexWriter only); 4.1.0 for the gaps after leading empty valuesintroduced 2012-11-02 · over 14 years
Affected
4.1.0 to 10.6.0 (10.6.0 by reading) and main; Monitor's single-vs-batch difference from 8.6.0
Status
documented
Found by
a bounded-exhaustive comparison of MemoryIndex with a one-document IndexWriter index, then JUnit tests

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

When a field has several values, IndexWriter starts each value after the hole left by words removed at the end of the previous one. MemoryIndex starts it right after the last kept word, so every later value sits lower.

Diagram of the bug: one document whose field body has the values 'quick the' and 'fox', analyzed with EnglishAnalyzer, which removes 'the' and reports the hole it leaves (+1) at the end of the first value. IndexWriter starts the second value after that hole: quick at 0, fox at 2, and the phrase "quick fox" gets 0 hits. MemoryIndex starts it at the last token's position plus the gap: fox at 1, and "quick fox" gets 1 hit. The reverse: for the input 'just a' + 'test of gaps', the phrase "just ? test" gets 1 hit on IndexWriter and 0 on MemoryIndex. Lucene Monitor matches the document alone (MemoryIndex) but not in a batch of two (IndexWriter). Why: MemoryIndex.storeTerms never reads the position increment that TokenStream.end() reports.
Step by step through an executed probe on main with the stock EnglishAnalyzer: the field quick the + fox, the hole that end() reports, where each index puts fox, the phrase that matches only on MemoryIndex, upstream's test input that misses, and Monitor alone versus in a batch. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index one document whose field body has two values, quick the and fox, with an analyzer that removes the stop word the (the stock EnglishAnalyzer does; default gap 0): once with IndexWriter, once with MemoryIndex.fromDocument.
  2. You expect the same positions. IndexWriter keeps the hole the removed the leaves: quick 0, fox 2, so the phrase "quick fox" gets 0 hits, as for the single value quick the fox.
  3. Instead MemoryIndex puts fox at 1 and "quick fox" gets 1 hit. The analyzer reports the hole when the value ends (TokenStream#end()); MemoryIndex never reads it before starting the next value.
  4. It also misses: on the input of Lucene's own testStopwordsPosIncHole, just a + test of gaps, the phrase "just ? test" gets 1 hit on IndexWriter and 0 on MemoryIndex.
  5. Lucene Monitor matches a single document through MemoryIndex and a batch through IndexWriter: the stored query "quick fox" matches this document alone (1) but not in a batch of two (0).

Who is affected

Anyone who matches documents with multi-valued text fields through MemoryIndex, directly or through Lucene Monitor's single-document match, with an analyzer that removes words (any stop filter, for example EnglishAnalyzer), and uses phrase, sloppy-phrase or span queries across value boundaries. Default settings, one document is enough, since 4.5.0. At a large gap such as 100 most exact phrases are unaffected.

Mechanism, terms and numbers770 words

When a document's analyzed text field has several values, IndexWriter starts each value after the previous value's final position increment, the one TokenStream#end() reports; a stop filter puts the positions of the words it removed at the end of a value there. MemoryIndex.storeTerms never reads it: it starts the next value at "last token position + gap". So after a value that ends in a removed word, every later value sits lower than in a one-document IndexWriter index of the same document, and phrase, sloppy-phrase and span queries that cross the value boundary give different answers, in both directions: [quick the | fox] matches "quick fox" only on MemoryIndex, and the input of upstream's testStopwordsPosIncHole matches "just ? test" only on IndexWriter. A second, smaller inconsistency: MemoryIndex skips the position and offset gaps after leading values that produce no tokens. Lucene Monitor matches a single document through MemoryIndex and a batch through IndexWriter, so the same stored phrase query matches a document alone but not in a batch. A divergence since 4.5.0 (the gap part since 4.1.0); default settings, and one document is enough. A fix that carries IndexingChain's accounting into storeTerms passes the repro and the memory, monitor and highlighter suites.

Terms

  • Multi-valued field: a document with several fields of the same name, for example two TextField("body", ...). Each value is analyzed on its own; positions and offsets continue from one value to the next.
  • Position hole: a stop filter removes the from quick the fox but keeps its position, so fox is at 2, not 1. A phrase "quick fox" therefore does not match. When the removed word is the last word of a value, the filter reports the hole when the value ends, as the final position increment of TokenStream#end().
  • Gaps: Analyzer#getPositionIncrementGap (default 0) and #getOffsetGap (default 1) are added between values. At gap 0 the values behave like one long value (the analysis package docs: "treat all these sections as one large section").
  • MemoryIndex: a one-document, in-memory index, documented as a substitute for a RAM-resident Directory. Lucene Monitor stores queries and matches incoming documents; it indexes a single document into a MemoryIndex and a batch into an IndexWriter.

The bug

  1. A FilteringTokenFilter (StopFilter, LengthFilter, ...) adds the positions of tokens removed after the last kept one to the position increment it reports in end() (FilteringTokenFilter.java:72-75; TokenStream#end javadoc, TokenStream.java:152-154).
  2. IndexWriter uses it: after stream.end() (IndexingChain.java:2105) it adds that increment and the final offset (:2109-2110), then the gaps for every analyzed value, with or without tokens (:2121-2124).
  3. MemoryIndex.storeTerms does not: it starts a value at lastPosition + positionIncrementGap and lastOffset + offsetGap, and only once the field has produced a token (info.numTokens > 0, MemoryIndex.java:994-999); after stream.end() it records the last kept token's position (:1045-1049). The increment end() reported is never read.
  4. After a value that ends in removed words, later values are lower by the size of that hole; after each leading value with no tokens, lower by the gap plus its hole, and their offsets by that value's final offset plus the offset gap. Positions stay in order, so only distances across a boundary and absolute positions change. Term queries and Boolean queries made only of term clauses gave the same hit counts in 108,000 random checks per run (executed); a Boolean query with a phrase clause inherits the phrase difference.
  5. Monitor: DocumentBatch.of sends one document to a MemoryIndex and two or more to an IndexWriter (DocumentBatch.java:55-63, :66-93, :101-114), so its results depend on the batch size.

The numbers (main a9779ef3c193, executed)

values (analyzer removes the)IndexWriterMemoryIndex
quick the + fox, gap 0fox at 2; "quick fox" 0 hitsfox at 1; 1 hit
just a + test of gaps (upstream test input)"just ? test" 1 hit0 hits
the + quick fox, gap 100, offset gap 1quick at 101, offsets 4-9quick at 0, offsets 0-5
Monitor, stored "quick fox", document quick the + fox0 matches in a batch of two1 match alone
control: quick the fox as one valuefox at 2fox at 2

With a large gap such as 100 most exact-phrase results do not change, because the shift is only the size of the hole; sloppy phrases with slop near the gap and absolute-position queries such as SpanFirstQuery still can. An empty value in the middle of a field is handled the same on both sides.

Exposure and likelihood319 words
  • Who. Users of MemoryIndex.fromDocument, repeated addField calls or Lucene Monitor's single-document match, with multi-valued analyzed text fields and an analyzer that removes tokens: any stop filter (the stock EnglishAnalyzer has one by default; StandardAnalyzer only when given a stop set), a LengthFilter, or a custom FilteringTokenFilter. The gap part needs only an empty or all-stop-word leading value and a positive gap.
  • Effect. Silent wrong match decisions for position-sensitive queries across a value boundary, in both directions (a false positive and a false negative, both executed), plus wrong positions, offsets and term-vector positions read from the MemoryIndex reader. No exception or crash in any run, and nothing is persisted; the effect stays within the document. Default scores are not affected; a custom Similarity that reads FieldInvertState#getPosition() could score differently, because MemoryIndex passes the last kept token's position (MemoryIndex.java:1380-1393) and IndexWriter the position after the last end() and gap (read in the code, not run).
  • Monitor. A document's result depends on whether it is matched alone (MemoryIndex) or in a batch (IndexWriter): 1 match alone, 0 in a batch of two. Monitor documents no such difference.
  • Default vs opt-in. Default MemoryIndex settings, the default Analyzer gaps (0 and 1) and stock analysis components; no opt-in feature.
  • Limits. With a large gap such as 100 most exact-phrase results are unchanged, because the shift is only the size of the hole; sloppy phrases with slop near the gap and absolute-position queries still can differ (executed: "alpha omega"~100 on alpha the + omega). Offsets differ only after leading tokenless values. No report was found in the 13 years since 4.5.0.
  • Scale. Needs no scale: one document triggers it, deterministically, every time the shape occurs.
  • Not affected in-tree: the highlighters build their MemoryIndex from one combined stream per field; the UnifiedHighlighter's MultiValueTokenStream adds end()'s increment and the gap itself (read in the code).

Minimal repro

The test shows that a field with values "quick the" and "fox" (StandardAnalyzer with stop word "the") gets the phrase "quick fox" wrong in MemoryIndex: IndexWriter keeps the hole that the removed trailing "the" leaves (the final position increment from TokenStream.end(), so fox is at position 2), and MemoryIndex drops it (fox at 1).

TestMemoryIndexValueEndHole.java (JUnit test)59 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.index.memory;

import java.util.List;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.CharArraySet;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestMemoryIndexValueEndHole extends LuceneTestCase {

  public void testTrailingStopWordHoleBetweenValues() throws Exception {
    Analyzer analyzer = new StandardAnalyzer(new CharArraySet(List.of("the"), false));
    Document doc = new Document();
    doc.add(new TextField("body", "quick the", Field.Store.NO));
    doc.add(new TextField("body", "fox", Field.Store.NO));
    PhraseQuery quickFox = new PhraseQuery("body", "quick", "fox");

    int indexWriterHits;
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        indexWriterHits = new IndexSearcher(reader).count(quickFox);
      }
    }
    int memoryIndexHits =
        MemoryIndex.fromDocument(doc, analyzer).createSearcher().count(quickFox);

    // IndexWriter keeps the removed "the" as a hole: fox at 2, 0 hits; MemoryIndex: fox at 1, 1 hit
    assertEquals(
        "hits of \"quick fox\", IndexWriter vs MemoryIndex", indexWriterHits, memoryIndexHits);
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-340/, 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:$LUCENE/lucene/memory/build/libs/lucene-memory-$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.index.memory.TestMemoryIndexValueEndHole
Expected vs actual
Expected the IndexWriter result, 0 hits for PhraseQuery("body", "quick", "fox"). Actual: MemoryIndex gives 1 hit (expected:<0> but was:<1>).

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

More files, environment, full output40 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: hits of "quick fox", IndexWriter vs MemoryIndex expected:<0> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.index.memory.TestMemoryIndexValueEndHole.testTrailingStopWordHoleBetweenValues(TestMemoryIndexValueEndHole.java:56)

Why it looks unintended

  • LUCENE-3849, a 4.5.0 bug fix, made IndexWriter keep this trailing hole between values; its test testStopwordsPosIncHole fails on MemoryIndex.
  • MemoryIndex is documented as a substitute for a RAM-resident directory, and addField as equivalent to adding a field.
  • MemoryIndex disagrees with itself: one value quick the fox puts fox at 2, two values at gap 0 put it at 1.
The full argument348 words
  • The contract. TokenStream#end (TokenStream.java:152-154): "any skipped positions (such as those removed by a stopfilter) can be applied to the position increment". LUCENE-3849 (#4922), a 4.5.0 Bug Fixes entry ("TokenStreams now set the position increment in end(), so we can handle trailing holes", CHANGES.txt:13337), made IndexWriter use it for exactly this multi-valued case; in that issue a committer wrote: “The "bug" is if a field instance ends with a stopword, that accumulated 'hole' is lost.” TestIndexWriter#testStopwordsPosIncHole (TestIndexWriter.java:1899-1927) asserts it, and its input fails on MemoryIndex.
  • MemoryIndex is documented and tested as an IndexWriter substitute. Class javadoc: "a replacement/substitute for RAM-resident Directory implementations" (MemoryIndex.java:106); addField: "Equivalent to adding a tokenized, indexed ... Field" (:683-684, :697-698, :713-714), with gaps "if fields with the same name are added more than once" (:722-724). TestMemoryIndexAgainstDirectory "Verifies that Lucene MemoryIndex and RAM-resident Directory have the same behavior" (:96-97) and already compares a two-valued field's term vectors with IndexWriter (:713-753), for values without holes. With the precedents this sets a strong expectation of equal query results, not a formal promise of identical postings.
  • Precedents. LUCENE-4880 ("Fix MemoryIndex to consume empty terms ... consistent with IndexWriter") and LUCENE-5444 ("offsets were corrupted if multiple fields with the same name were added", the same multi-value code) fixed earlier differences.
  • Self-disagreement. At gap 0, quick the fox as one value puts fox at 2, split into quick the + fox at 1; the quick puts quick at 1, the + quick at 0 (executed). The analysis package docs say values at the default gap are treated "as one large section".
  • History: an omission. Multi-value support came with LUCENE-4515 (4.1.0), nine months before LUCENE-3849, which changed 30 files and not lucene/memory. Executed: both sides agree on 4.1.0-4.4.0 and differ from 4.5.0.
  • Legal trigger. Public API, a stock StopFilter, the default gap; IndexWriter accepts the same document.
  • Monitor swaps MemoryIndex in for IndexWriter purely by batch size, for speed ("MemoryIndex benchmarks as better performing than RAMDirectory for this case", DocumentBatch.java:101-102).
The devil's-advocate check366 words

The strongest case for "intended": Analyzer#getPositionIncrementGap is "Invoked before indexing a IndexableField instance if terms have already been added to that field" and #getOffsetGap "is only called if the field produced at least one token for indexing" (Analyzer.java:297-323). MemoryIndex's numTokens > 0 guard matches that text word for word, so the leading-tokenless part could be called documented; and "replacement/substitute" describes a purpose, not identical postings. Weaker points a reviewer may raise: TokenStream#end says skipped positions "can be applied", not that every consumer must apply them; the addField(String, TokenStream, int, int) overload lets the caller choose the gaps; MemoryIndex already differs from IndexWriter in accepted ways (it keeps freqs and positions and returns term vectors for every indexed field, TestMemoryIndex#testKeywordWithoutTokenStream, #testFieldsOnlyReturnsIndexedFields); positions that have held since 4.5.0 would change; and at a gap of 100 the practical effect is small.

Why it fails: those javadocs say nothing about end()'s increment, so they do not cover the headline at all. For the gap part they are stale: IndexWriter has applied the gaps after empty values since LUCENE-2529 / LUCENE-2668 (3.1.0, CHANGES.txt:17578), and TestIndexWriter#testPositionIncrementGapEmptyField (:768-800) expects position 100 after an empty first value at gap 100. The guard descends from the comment "ensure infos.numTokens > 0 invariant; needed for correct operation of terms()" in MemoryIndex's original 2005 contribution (8f9e2a15e78); LUCENE-4515 left it as unchanged context and only made its new carry branch depend on it, and LUCENE-7091 kept it so that doc-values-only additions add no gap: no change was a decision about gaps. On the weaker points: IndexWriter has applied end()'s increment since LUCENE-3849 and its test asserts it, so "can" is not an opt-out for an index documented as a substitute for it; caller-chosen gaps do not help, because end()'s increment is ignored whatever gap is passed; the accepted differences add information IndexWriter does not keep rather than changing positions it does keep; the behaviour change belongs in a CHANGES entry. Verdict: not intended; high confidence for the headline, medium for the gap part. File the gap part as a secondary consistency fix and correct the two Analyzer javadocs in the same change.

Suggested fix

Recommended Carry IndexingChain's position and offset accounting in storeTerms (validated)

Carry IndexingChain's accounting in MemoryIndex.storeTerms: start each value after the previous value's end() position increment and final offset, plus the gaps after every tokenized value (+36/-15). With it the repro passes with assertions on and off and over 10 iterations, and the memory, monitor and highlighter suites and the lint, format and javadoc checks pass.

int pos = info.nextValuePosition;   // was: -1, or lastPosition + gap if numTokens > 0
int offset = info.nextValueOffset;  // was: 0, or lastOffset + offsetGap if numTokens > 0
// ... tokens as before; then, after stream.end():
int next = pos + posIncrAttribute.getPositionIncrement();
int nextOffset = offset + offsetAtt.endOffset();
if (applyGaps) {
  next += positionIncrementGap;
  nextOffset += offsetGap;
}
info.nextValuePosition = next;
info.nextValueOffset = nextOffset;
All options and trade-offs5 options · 1,100 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle; the alternatives were not run. Fix the two stale Analyzer javadocs in the same change.

Workarounds today (not run): in Monitor, match a single document in a batch with a dummy second document (the IndexWriter path); or, at gap 0, join a field's values into one value before handing it to MemoryIndex (one value keeps the hole, executed in the self-consistency check).

Carry IndexingChain's position and offset accounting in storeTerms (validated) (recommended)

Info gets nextValuePosition = -1 and nextValueOffset = 0. A value starts there; after stream.end() the next start is pos + posIncrAttribute.getPositionIncrement() and offset + offsetAtt.endOffset(), plus positionIncrementGap and offsetGap when the value is tokenized, whether or not it produced tokens. applyGaps is true on the addField(String, String, Analyzer) and addField(String, TokenStream, int, int) paths and field.fieldType().tokenized() on addField(IndexableField, Analyzer). With an analyzer that is IndexingChain's condition tokenized() && analyzer != null (IndexingChain.java:1972). With no analyzer it is not: IndexingChain then adds no gaps, while MemoryIndex already uses the Analyzer default gaps 0 and 1 (MemoryIndex.java:631-635) and the fix keeps applying them, so fromDocument(doc, null) follows an IndexWriter whose analyzer keeps the default gaps, not one configured with no analyzer. Executed on pre-analyzed values (offsets of the last token): [empty | a] no-analyzer IndexWriter 0-1, default-gap IndexWriter 1-2, stock MemoryIndex 0-1, with the fix 1-2; [a | b] 1-2, 2-3, 2-3, 2-3; [a | empty | b] 1-2, 3-4, 3-4, 3-4. Stock matches the default-gap IndexWriter in 2 of 3 cases, the fix in 3 of 3; the hunt variant matches the no-analyzer one in 3 of 3 but moves offsets stock gives today. Say in the PR which reference it follows. Info.lastOffset is removed: nothing reads it any more, and ecjLint rejects the unused field (it failed the first validation round). lastPosition stays (norms and the keyword-term path read it). Doc-values-only additions never reach storeTerms, so they add no gap (the LUCENE-7091 point). A second diff adds both tests; a PR also needs a CHANGES entry.

int pos = info.nextValuePosition;   // was: -1, or lastPosition + gap if numTokens > 0
int offset = info.nextValueOffset;  // was: 0, or lastOffset + offsetGap if numTokens > 0
// ... tokens as before; then, after stream.end():
int next = pos + posIncrAttribute.getPositionIncrement();
int nextOffset = offset + offsetAtt.endOffset();
if (applyGaps) {
  next += positionIncrementGap;
  nextOffset += offsetGap;
}
info.nextValuePosition = next;
info.nextValueOffset = nextOffset;

Trade-offs

  • One more int per field (three instead of two); no extra work per token. No benchmark was run.
  • Behaviour change by design: later values of multi-valued fields move to IndexWriter's positions, so stored Monitor or percolator phrase rules can match differently after an upgrade.
  • The keyword-term path (storeTerm, fake positions from lastPosition++) is untouched, but it does not update the new carry: a token-stream value after a binary-term value (reachable only through a custom IndexableField whose tokenStream() returns null) now starts from the carried value position (or -1), not from lastPosition + gap (read in the code, not tested).
  • Norms still get the last kept token's position (unchanged); only a custom Similarity that reads FieldInvertState#getPosition() would notice.

What the checks validated

  • Stock main a9779ef3c193: the 4 repro tests fail with assertions on and off and in every one of 10 iterations (33 of 33 memory, 10 of 10 Monitor executions); the whole memory (57 tests) and monitor (182) suites fail only on them.
  • With the fix: the repro passes with assertions on and off and over 10 iterations (seed C0FFEE34); lucene/memory 57 tests, lucene/monitor 182 (1 nightly test skipped, as on stock) and lucene/highlighter 722, 0 failures.
  • ecjLint (memory, monitor), checkGoogleJavaFormat and :lucene:memory:renderJavadoc pass, tidy changes nothing, and both diffs apply cleanly to a9779ef3c193. The first round, which kept Info.lastOffset, passed every test but failed ecjLint ("The value of the field MemoryIndex.Info.lastOffset is not used").
  • Not run: other modules' suites; the analyzer-less pre-analyzed path beyond the 3-case probe above; a field that mixes binary-term and token-stream values; custom Similarity implementations; no benchmark.

Fix the stale Analyzer gap javadocs (text only, not run)

getPositionIncrementGap: replace "Invoked before indexing a IndexableField instance if terms have already been added to that field" with wording about the value, for example "Returns the position increment gap added between two consecutive instances of the field, also when the earlier instance produced no tokens." Do not say when it is called: MemoryIndex reads both gaps before it consumes a value (MemoryIndex.java:516-521, :627-634), IndexWriter after (IndexingChain.java:2121-2124). getOffsetGap: drop "This method is only called if the field produced at least one token for indexing."

Trade-offs

Documentation only; describes what IndexWriter has done since LUCENE-2529 / LUCENE-2668 (3.1.0) and what the recommended fix makes MemoryIndex do.

Headline only: add end()'s increment, keep the numTokens > 0 guard (not run)

Add posIncrAttribute.getPositionIncrement() after end() to the carried position (and the final offset to the carried offset), and keep the guard.

Trade-offs

Smaller and keeps the Analyzer javadoc literally true, but leaves the + quick fox different from IndexWriter (101 vs 0 at gap 100); if done by changing lastPosition, that field no longer is the last token's position (it feeds FieldInvertState for norms).

The hunt's sketch: the same change with applyGaps = analyzer != null && tokenized() (validated by shadowing)

The same change except on the addField(IndexableField, null) path, where it applies no gaps at all, like an IndexWriter with no analyzer, and so drops the offset gap 1 that stock MemoryIndex puts between pre-analyzed values (executed: [a | b] gives b offsets 1-2 instead of 2-3).

Trade-offs

Copies IndexingChain's condition literally, but changes the analyzer-less path, which no in-tree test covers, and keeps the now-unused lastOffset field, which ecjLint rejects (seen in this doc's first validation round, which kept it too).

What the checks validated

On ea57360912ed, shadowing MemoryIndex: the hunt's repro 3 of 3 pass, the upstream TestMemoryIndex, TestMemoryIndexAgainstDirectory and TestSlicedIntBlockPool 51 of 51 pass, the independent reproducer's 8 tests pass, and in the exhaustive scopes (180,544 cases) every position, offset, term-vector, query-match and query-score mismatch is gone, while two unrelated classes stay at their stock counts (OFFSET_MINUS1, offsets other than -1 for fields indexed without offsets: 36,032 in T2 and 5,232 in SIM; 216 IW_UNEXPECTED_THROW); the random scope (3,000 documents) reports no mismatch.

Document the difference (not run)

A caveat in the MemoryIndex and Monitor javadocs.

Trade-offs

Leaves wrong positions and Monitor's single-vs-batch disagreement in place.

Downstream note

Downstream, the Elasticsearch and OpenSearch percolators use the same split as Monitor, MemoryIndex for one document and IndexWriter for several (read in their source, not run); at their default position gap of 100 the exact-phrase case does not change.

How it was found

A bounded-exhaustive comparison ran the real MemoryIndex against a one-document IndexWriter index on 177,544 small cases (ordered pairs of short canned token streams with modelled holes and gaps, plus 40 single values) through the public reader and query APIs. Position, offset and phrase-match mismatches appeared and vanished with the fix; JUnit tests on the stock jar confirmed it.

The method in full184 words

Method: bounded-exhaustive translation validation on the real jar (semi-formal tier), in a hunt unit on MemoryIndex equivalence.

  • Scope: every pair of canned token streams of up to 2 tokens over {a, b}, with position increments {0, 1, 2} and a final position increment {0, 1}, times 3 index options, norms on or off, offsets on or off, and position gap {0, 3}: 177,504 two-value cases plus 40 single-value cases (177,544), each indexed into a one-document IndexWriter index and a MemoryIndex and compared through the public LeafReader and a fixed query set.
  • Result on main: 56,448 position, 832 offset, 41,664 term-vector position, 19,232 query-match and 26,928 query-score mismatches; 0 of each with the fix shadowed in (two unrelated classes stay unchanged). A random baseline with real analyzers (3,000 documents) found 2,553 position, 1,430 offset and 39 query-match mismatches, 0 with the fix.
  • Then public-API JUnit tests on the stock jar, each asserting the IndexWriter value first. Found 2026-10-26 on main ea57360912ed; then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6) and an adjudication.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26: 214 queries and lookups across GitHub, JIRA, the mailing lists, downstream repositories and the web, plus all 411 open PRs). Closest: open #6075 (2013), wrong MemoryIndex offsets for multi-valued fields, with positions said to be correct.

Searches5 searches

Verdict new: 27 hit groups judged, none the same bug. #6075 / LUCENE-5011 (open, 2013, no repro) is most likely the LUCENE-5444 offset bug fixed in 4.7.0 (a committer asked in 2014; no answer) and overlaps at most with the secondary offset part. Precedents: LUCENE-3849 / #4922, LUCENE-2529 / #3603, LUCENE-2668 / #3742, LUCENE-5444 / #6507, LUCENE-4880 / #5945, LUCENE-7091 / #8147, LUCENE-9314 / #10354. Different causes: luwak #116 / #117 (gaps not passed to MemoryIndex) and #172 / #173 (batch analyzers). None of the 6 open PRs that touch MemoryIndex.java changes position, offset or gap code. Keyword search cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr repositories; dev@, java-user@, issues@ and commits@; the JIRA archive; the web2026-10-26the hunt's prior-report tool: 110 queries in three runsno report; precedents only (LUCENE-3849, -2529, -5444, -4880, -7091)
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, luwak)2026-10-2674 GraphQL and 10 REST searches (GitHub's search rate limit was saturated)no report; closest #6075 (offsets only)
Open apache/lucene PRs2026-10-26changed files of all 411 open PRs, filtered for lucene/memory, DocumentBatch and IndexingChain6 PRs touch MemoryIndex.java; none changes position, offset or gap code
JIRA issues read in full; luwak2026-10-26LUCENE-3849, -4515, -2529, -4880, -3087, -8349; all 191 luwak issues and PRs listed, 5 readno report; luwak #116/#117 and #172/#173 have different causes
the web2026-10-2612 searches and 1 page fetch (some throttled and retried)usage questions and documentation only

History

The multi-value code came with LUCENE-4515 (4.1.0, 2012). It became a divergence in 4.5.0, when LUCENE-3849 made IndexWriter, and not MemoryIndex, add end()'s increment. Executed: 4.1.0-4.4.0 agree on the trailing hole, 4.5.0 to 10.5.2 and main differ; Monitor's single-vs-batch difference from 8.6.0.

Versions tested9 rows

Origin of the code: LUCENE-4515 "Make MemoryIndex more memory efficient" (JIRA era, no PR; trunk 59476c30cf39, branch_4x 4ed3173891d1, 2012-11-02, first in 4.1.0) let MemoryIndex accept a field more than once and started the next value at lastPosition + positionIncrementGap, but only once the field had produced a token. 4.0.0 rejected a second value.

Headline from 4.5.0: LUCENE-3849 (trunk 2a5421ca685b, branch_4x e005a580e9f8, 2013-08-20) made IndexWriter add end()'s increment and did not touch MemoryIndex. Kept by LUCENE-5444 (4.7.0, offset carry under the same guard), LUCENE-7091 (master cf3eea26406, branch_6_0 bd0803fd41c, 6.0.0, today's storeTerms shape) and LUCENE-6819 (8ed2b764ed4). Monitor from 8.6.0: LUCENE-9314 (633ae90d7d9 / 5d5b7e14d41, 2020) routed single documents to MemoryIndex. Releases between two executed points are inferred, including minor lines where no release was run (for example 5.0-5.2 and 7.0-7.4); only refactors touched the carry code after 4.7.0. Unfixed: upstream main e1c4184cb98 (2026-10-26), branch_10x and branch_10_6 have the same storeTerms (read, not run).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe doc's tests 4 of 4 fail via gradle, assertions on and off; the hunt's repro 3 of 3
main ea57360912ed, 10.5.2, 9.12.3 affectedthe hunt's repro 3 of 3 fail; the independent reproducer 5 of 8, its 3 controls pass
4.7.0 to 10.5.2 (20 releases run) affectedfox 2 vs 1, phrase IW no match / MI match; quick 101 vs 0, offsets 4-9 vs 0-5
4.5.0, 4.5.1, 4.6.0, 4.6.1 headline startsfox 2 vs 1; the leading-tokenless part too (plus the older LUCENE-5444 offset bug)
4.1.0, 4.2.1, 4.3.1, 4.4.0 gap part onlyfox 1 vs 1 (IndexWriter did not keep the hole yet); quick 100 vs 0
4.0.0 not affecteda second value throws field must not be added more than once
Monitor 8.6.0, 8.11.4 Monitorsingle 1 match, batch 0
Monitor 8.2.0, 8.5.2 Monitorsingle documents used the IndexWriter batch path: 0 and 0
fix on a9779ef3c193 not affectedthe doc's 4 tests pass (assertions on and off, 10 iterations); memory, monitor and highlighter suites pass

Independent checks

An independent reproducer (5 of 8 fail, 3 controls pass) and an intended-behaviour devil's advocate reproduced it with their own tests. Reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause. An adjudication confirmed it, and this doc's gradle runs reproduce it on a9779ef3c193.

Checks and verdicts349 words

Every executing check wrote or re-ran its own test on stock main; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateExhaustive comparison (177,544 cases) plus a random baseline; JUnit 3 of 3 fail with assertions on and off and on 10.5.2 and 9.12.3; the fix sketch passes, with the upstream memory tests 51 of 51.
independent reproducer realOwn tests from the claim only: 5 of 8 fail on main (assertions on and off), 10.5.2 and 9.12.3; 3 controls pass; added the upstream test input (a false negative) and a leading empty value; 8 of 8 pass with the fix.
cross-model reviews (refute, intended) not real (low, procedural); real (medium)An independent reviewer from another model family (OpenAI GPT-5.6) reviewed by reading only, since it could not run Java. The lenses agreed on the root cause and rated the intended argument weak; the intended lens caught an overclaim about TestMemoryIndexAgainstDirectory (dropped). A separate run re-ran the repro: 3 of 3 fail.
intended-behaviour devil's advocate real (high headline, medium gap part)Built the strongest intended case (the Analyzer gap javadocs) and showed it covers only the gap part and is stale; showed MemoryIndex disagrees with itself (2 of 2 fail); the finder's repro fails 3 of 3 on main and 10.5.2.
adjudication confirmedRe-ran the independent reproducer (5 of 8 fail, assertions on and off) and the finder's repro (3 of 3).
Follow-up and registry re-runs still reproducesHistory on 29 releases plus main and a Monitor port on 4 releases; a security probe (3 of 7 fail as designed, 7 of 7 with the fix); re-runs 2026-10-26 and on a9779ef3c193: 3 of 3 fail.
this doc's gradle runs reproduced; fix validatedScratch worktree at a9779ef3c193, gradle: stock, the 4 tests fail with assertions on and off and in all 10 iterations, and the memory and monitor suites fail only on them; with the fix they pass, the memory (57), monitor (182) and highlighter (722) suites pass, and lint, format and javadoc pass.

Diagram file

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

Sources and provenance

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

Finding record

LFV-340, snapshot of 2026-11-06

Registry title
MemoryIndex drops the final position increment of each value of a multi-valued field (and the gaps after leading values with no tokens), so phrase and other position queries disagree with IndexWriter, including Lucene Monitor's single-document matching
Area
memory / MemoryIndex term inversion (multi-valued analyzed fields: positions and offsets carried from one value to the next); reached in-tree through monitor single-document batches
Priority
Medium, rank 240: MemoryIndex silently drops the final position increment between values, so phrase results differ from IndexWriter, including in Monitor's single-document path, but it needs a multi-valued field, stop-word removal and a small gap, so it sits with its MemoryIndex sibling LFV-333.
Impact class
silent wrong results
Impact note
Anyone who puts a document with a multi-valued analyzed text field into a MemoryIndex (directly through fromDocument or repeated addField calls, or through Lucene Monitor's single-document matching) with an analyzer that removes tokens (a stop filter, as in the stock EnglishAnalyzer, or a length filter) gets lower positions than IndexWriter gives the same document for every value that follows a value ending in a removed token, and, when the field has a position gap above 0, for the values after leading values with no tokens (offsets differ only in that second case), so phrase, sloppy-phrase and span queries that cross a value boundary or depend on absolute positions match or miss differently (executed: a false positive and a false negative at Lucene's default gap 0, and Monitor matching a document alone but not in a batch); with a large gap such as 100 most exact-phrase results do not change; default MemoryIndex settings and stock analysis components, any scale, every release since 4.5.0 (since 4.1.0 for the leading-tokenless-value part).
Summary
You add a document whose text field has two values, 'quick the' and 'fox', to a MemoryIndex with an analyzer that removes the stop word 'the', and expect the positions of a one-document IndexWriter index: IndexWriter keeps the hole the removed trailing 'the' leaves (TokenStream.end() reports it as a final position increment), so 'fox' is at position 2 and the phrase "quick fox" does not match. MemoryIndex.storeTerms never adds that final increment, puts 'fox' at position 1 and matches the phrase; the reverse also happens (the input of upstream's TestIndexWriter#testStopwordsPosIncHole matches a phrase on IndexWriter but not on MemoryIndex), and values that produce no tokens before the first token skip the position and offset gaps ([the | quick fox] with gap 100: IndexWriter quick at position 101, offsets 4-9; MemoryIndex position 0, offsets 0-5). Lucene Monitor inherits it: a stored phrase query matches the document when it is matched alone (MemoryIndex path) but not when the same document is in a two-document batch (IndexWriter path).
Introduced
59476c30cf39515cc14c26d70042632551a7138f (trunk) / 4ed3173891d176941a5a89706593f2f0a422b1c3 (branch_4x backport), LUCENE-4515 'Make MemoryIndex more memory efficient' (a JIRA patch with no GitHub PR, migrated as apache/lucene#5581; it also resolved LUCENE-3865 / #4938) [read: git log -S 'must not be added more than once' and -S 'lastPosition + positionIncrementGap' on MemoryIndex.java; exec: 4.0.0 rejects a second value, 4.1.0 shows the skipped gap]: it first let MemoryIndex accept a field more than once, starting the next value at info.lastPosition + positionIncrementGap (the end() increment ignored) and keeping the carry only if earlier values produced tokens. The headline symptom became a divergence with 2a5421ca685b7341bbead24975cd218cbcaf34f7 (trunk) / e005a580e9f856e1cdebc3bf3230768911c7105c (branch_4x), LUCENE-3849 / #4922 'position increments should be implemented by TokenStream.end()', which made IndexWriter add the increment end() reports and did not touch lucene/memory [read; exec: both sides agree on 4.1.0-4.4.0 and differ from 4.5.0]. Later commits kept the behaviour: b4129d434c0 / 15f549a738f (LUCENE-5444, offset carry added under the same numTokens > 0 condition, 4.7.0), cf3eea26406 (LUCENE-7091, today's storeTerms shape), 8ed2b764ed4 (LUCENE-6819, line 996); 5d5b7e14d41 / 633ae90d7d9 (LUCENE-9314, 8.6.0) routed Monitor's single documents to MemoryIndex (2012-11-02 (LUCENE-4515, multi-value support with this carry logic); headline divergence from 2013-08-20 (LUCENE-3849 changed IndexWriter); Monitor reach from 2020-04-27 (LUCENE-9314))
First affected version
4.1.0 [exec] for the leading-tokenless-value gap (first release that accepts a second value; 4.0.0 rejects it [exec]); the headline (dropped end() position increment) from 4.5.0 [exec: IndexWriter and MemoryIndex agree on 4.1.0-4.4.0 and differ on 4.5.0 and later]; offsets in today's narrow form from 4.7.0 [exec] (4.1.0-4.6.1 had the broader offset bug LUCENE-5444 fixed); the Monitor single-vs-batch disagreement from 8.6.0 [exec; 8.2.0 and 8.5.2 agree]
Affected versions
Every release from 4.1.0 to 10.6.0 (the headline from 4.5.0; 10.6.0 by reading), branch_10_6, branch_10x and main ea57360912ed. [exec] the MemoryIndex history harness (main() programs, SHA-1-checked release jars, -ea, seed DEADBEEF) on 4.0.0 (API absent) and 28 affected releases (4.1.0, 4.2.1, 4.3.1, 4.4.0, 4.5.0, 4.5.1, 4.6.0, 4.6.1, 4.7.0, 4.8.1, 4.9.1, 4.10.4, 5.3.2, 5.4.1, 5.5.5, 6.0.0, 6.3.0, 6.6.6, 7.5.0, 7.7.3, 8.0.0, 8.8.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.3.2, 10.5.2) plus main, with identical results from 4.7.0 to main; the finder's JUnit fails 3 of 3 on main (-ea and -da), 10.5.2 and 9.12.3 and a main() variant fails on 8.11.4; the independent reproduction's JUnit fails 5 of 8 on main (-ea and -da), 10.5.2 and 9.12.3; a Monitor port gives the same result for a single document and a batch on 8.2.0 and 8.5.2 and differs on 8.6.0 and 8.11.4. [read] releases between executed points (5.0.0-5.2.x and 7.0.0-7.4.0 not executed; only refactors touched the carry code after 4.7.0); branch_10_6, branch_10x and upstream main have the same storeTerms (fetched 2026-10-24; upstream main e1c4184cb98 re-checked 2026-10-26). Not checked: x86. Unfixed
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; also reached by fuzzing/PBT
Upstream and prior reports
none found (2026-10-26, the prior-report record, verdict new: 214 logged read-only queries and lookups: 110 through a scripted prior-report search over GitHub issues, PRs and commits, Elasticsearch, OpenSearch and Solr repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the web; 74 GitHub GraphQL and 10 REST searches over apache/lucene, Elasticsearch, OpenSearch, Solr and luwak; 12 web searches and 1 page fetch; the changed files of all 411 open apache/lucene PRs; all 191 luwak issues and PRs listed; LUCENE-3849, -4515, -2529, -4880, -3087 and -8349 read in full; local git history); not fixed upstream: apache/lucene main e1c4184cb98 (2026-10-26) has the same storeTerms code, and the 6 open PRs that touch MemoryIndex.java (#486, #13342, #13525, #13948, #14475, #14716) change no position, offset or gap code. Closest item, not a report of this bug: open #6075 (LUCENE-5011, 2013, no repro), wrong MemoryIndex offsets for multi-valued fields with positions said to be correct, most likely the LUCENE-5444 bug fixed in 4.7.0. Precedents: LUCENE-3849 / #4922 (end() position increments; TestIndexWriter#testStopwordsPosIncHole), LUCENE-2529 / #3603 and LUCENE-2668 / #3742 (IndexWriter applies the gaps whether or not a value produced tokens), LUCENE-5444 / #6507 and LUCENE-4880 / #5945 (MemoryIndex fixed to match IndexWriter), LUCENE-7091 / #8147 (review comment behind the numTokens guard), LUCENE-9314 / #10354 (Monitor's single-document MemoryIndex path); luwak #116 / #117 and #172 / #173 (different causes)
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the doc's two public-API tests through gradle in a scratch worktree, stock and with the fix, assertions on and off and 10 iterations, plus the whole lucene/memory and lucene/monitor modules stock and those two plus lucene/highlighter with the fix (2026-10-27). Hunt runs on main ea57360912ed and the 10.5.2 and 9.12.3 jars; a history harness on 28 releases from 4.1.0 (and 4.0.0, which rejects a second value) plus a Monitor port on 8.2.0, 8.5.2, 8.6.0 and 8.11.4; the main-refresh re-run on a9779ef3c193 still reproduces.

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:991-1053 (storeTerms: start at lastPosition + positionIncrementGap and lastOffset + offsetGap only if info.numTokens > 0, :994-999; after stream.end() lastPosition = last token position, end()'s increment never read, :1045-1049) and :104-107 (class javadoc: replacement/substitute for RAM-resident Directory implementations) and :712-730 (addField javadoc: 'Equivalent to adding a tokenized ... Field'; gaps apply 'if fields with the same name are added more than once')
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:2049-2068 (after stream.end(): position += end()'s position increment, offset += endOffset, then both gaps for every analyzed value)
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:142-159 (end() javadoc: skipped positions, such as those removed by a stopfilter, can be applied to the position increment), lucene/core/src/java/org/apache/lucene/analysis/Analyzer.java:296-323 (getPositionIncrementGap / getOffsetGap javadoc, default gaps 0 and 1) and lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:1900 (testStopwordsPosIncHole)
  • a Lucene main checkout lucene/monitor/src/java/org/apache/lucene/monitor/DocumentBatch.java:44-64 (one document -> SingletonDocumentBatch on MemoryIndex, more -> IndexWriter)
The finding in 30 seconds
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:991-1053 (storeTerms; start :994-999; after end() :1045-1049); :564-575, :620-641 (fromDocument, addField(IndexableField, Analyzer))
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1970-1972, :2105-2124 (end() increment, final offset, gaps)
  • lucene/core/src/java/org/apache/lucene/analysis/FilteringTokenFilter.java:72-75; TokenStream.java:142-161; package-info.java:245-260
  • lucene/monitor/src/java/org/apache/lucene/monitor/DocumentBatch.java:44-114
Who is affected
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardAnalyzer.java:51-53; lucene/analysis/common/src/java/org/apache/lucene/analysis/en/EnglishAnalyzer.java:67-69
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:1380-1393; lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1926-1935, :2109, :2122 (norms; no similarity in lucene/core reads FieldInvertState#getPosition())
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:142-161; Analyzer.java:297-323; package-info.java:245-251
  • lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:768-800, 1899-1958
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:106, 683-684, 697-698, 713-730
  • lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndexAgainstDirectory.java:96-99, 713-753
  • lucene/CHANGES.txt:12895, 13337, 14131, 14644, 17578
  • lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndex.java:159-170, 1093-1160 (MemoryIndex's accepted extra freqs, positions and term vectors)
Suggested fix
  • lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:631-635, 968-980, 1380-1390; lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:1972
History
  • lucene/CHANGES.txt:5804, 12895, 13337, 14644

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

Repro files

About the diagrams

Made in parallel with this doc from a probe run on main a9779ef3c193 with the stock EnglishAnalyzer (stock, assertions on and off, 10.5.2, and with the fix shadowed in); the values on screen come from the probe's log. The doc's repro uses its own analyzers (a StopFilter("the") chain, and upstream's MockTokenFilter(ENGLISH_STOPSET) for the upstream input) and gives the same numbers; "with the fix shadowed in" means the hunt's fix sketch.

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.