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.
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.- You index one document whose field
bodyhas two values,quick theandfox, with an analyzer that removes the stop wordthe(the stockEnglishAnalyzerdoes; default gap 0): once withIndexWriter, once withMemoryIndex.fromDocument. - You expect the same positions.
IndexWriterkeeps the hole the removedtheleaves:quick0,fox2, so the phrase"quick fox"gets 0 hits, as for the single valuequick the fox. - Instead
MemoryIndexputsfoxat 1 and"quick fox"gets 1 hit. The analyzer reports the hole when the value ends (TokenStream#end());MemoryIndexnever reads it before starting the next value. - It also misses: on the input of Lucene's own
testStopwordsPosIncHole,just a+test of gaps, the phrase"just ? test"gets 1 hit onIndexWriterand 0 onMemoryIndex. - Lucene Monitor matches a single document through
MemoryIndexand a batch throughIndexWriter: the stored query"quick fox"matches this document alone (1) but not in a batch of two (0).
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.
- 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
thefromquick the foxbut keeps its position, sofoxis 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 ofTokenStream#end(). - Gaps:
Analyzer#getPositionIncrementGap(default 0) and#getOffsetGap(default 1) are added between values. At gap 0 the values behave like one long value (theanalysispackage 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 aMemoryIndexand a batch into anIndexWriter.
- A
FilteringTokenFilter(StopFilter,LengthFilter, ...) adds the positions of tokens removed after the last kept one to the position increment it reports inend()(FilteringTokenFilter.java:72-75;TokenStream#endjavadoc,TokenStream.java:152-154). IndexWriteruses it: afterstream.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).MemoryIndex.storeTermsdoes not: it starts a value atlastPosition + positionIncrementGapandlastOffset + offsetGap, and only once the field has produced a token (info.numTokens > 0,MemoryIndex.java:994-999); afterstream.end()it records the last kept token's position (:1045-1049). The incrementend()reported is never read.- 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.
- Monitor:
DocumentBatch.ofsends one document to aMemoryIndexand two or more to anIndexWriter(DocumentBatch.java:55-63,:66-93,:101-114), so its results depend on the batch size.
values (analyzer removes the) | IndexWriter | MemoryIndex |
|---|---|---|
quick the + fox, gap 0 | fox at 2; "quick fox" 0 hits | fox at 1; 1 hit |
just a + test of gaps (upstream test input) | "just ? test" 1 hit | 0 hits |
the + quick fox, gap 100, offset gap 1 | quick at 101, offsets 4-9 | quick at 0, offsets 0-5 |
Monitor, stored "quick fox", document quick the + fox | 0 matches in a batch of two | 1 match alone |
control: quick the fox as one value | fox at 2 | fox 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, repeatedaddFieldcalls or Lucene Monitor's single-documentmatch, with multi-valued analyzed text fields and an analyzer that removes tokens: any stop filter (the stockEnglishAnalyzerhas one by default;StandardAnalyzeronly when given a stop set), aLengthFilter, or a customFilteringTokenFilter. 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
MemoryIndexreader. No exception or crash in any run, and nothing is persisted; the effect stays within the document. Default scores are not affected; a customSimilaritythat readsFieldInvertState#getPosition()could score differently, becauseMemoryIndexpasses the last kept token's position (MemoryIndex.java:1380-1393) andIndexWriterthe position after the lastend()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
MemoryIndexsettings, the defaultAnalyzergaps (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"~100onalpha 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
MemoryIndexfrom one combined stream per field; the UnifiedHighlighter'sMultiValueTokenStreamaddsend()'s increment and the gap itself (read in the code).
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).
/*
* 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);
}
}
# 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>).
More files, environment, full output40 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: 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)- LUCENE-3849, a 4.5.0 bug fix, made
IndexWriterkeep this trailing hole between values; its testtestStopwordsPosIncHolefails onMemoryIndex. MemoryIndexis documented as a substitute for a RAM-resident directory, andaddFieldas equivalent to adding a field.MemoryIndexdisagrees with itself: one valuequick the foxputsfoxat 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), madeIndexWriteruse 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 onMemoryIndex. - 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 withIndexWriter(: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 foxas one value putsfoxat 2, split intoquick the+foxat 1;the quickputsquickat 1,the+quickat 0 (executed). Theanalysispackage 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;IndexWriteraccepts the same document. - Monitor swaps
MemoryIndexin forIndexWriterpurely 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.
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).
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 fromlastPosition++) is untouched, but it does not update the new carry: a token-stream value after a binary-term value (reachable only through a customIndexableFieldwhosetokenStream()returns null) now starts from the carried value position (or -1), not fromlastPosition + gap(read in the code, not tested). - Norms still get the last kept token's position (unchanged); only a custom
Similaritythat readsFieldInvertState#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/memory57 tests,lucene/monitor182 (1 nightly test skipped, as on stock) andlucene/highlighter722, 0 failures. ecjLint(memory, monitor),checkGoogleJavaFormatand:lucene:memory:renderJavadocpass,tidychanges nothing, and both diffs apply cleanly toa9779ef3c193. The first round, which keptInfo.lastOffset, passed every test but failedecjLint("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
Similarityimplementations; no benchmark.
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.
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.
A caveat in the MemoryIndex and Monitor javadocs.
Trade-offs
Leaves wrong positions and Monitor's single-vs-batch disagreement in place.
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.
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
IndexWriterindex and aMemoryIndexand compared through the publicLeafReaderand 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
IndexWritervalue first. Found 2026-10-26 on mainea57360912ed; 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr repositories; dev@, java-user@, issues@ and commits@; the JIRA archive; the web | 2026-10-26 | the hunt's prior-report tool: 110 queries in three runs | no report; precedents only (LUCENE-3849, -2529, -5444, -4880, -7091) |
| GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, luwak) | 2026-10-26 | 74 GraphQL and 10 REST searches (GitHub's search rate limit was saturated) | no report; closest #6075 (offsets only) |
| Open apache/lucene PRs | 2026-10-26 | changed files of all 411 open PRs, filtered for lucene/memory, DocumentBatch and IndexingChain | 6 PRs touch MemoryIndex.java; none changes position, offset or gap code |
| JIRA issues read in full; luwak | 2026-10-26 | LUCENE-3849, -4515, -2529, -4880, -3087, -8349; all 191 luwak issues and PRs listed, 5 read | no report; luwak #116/#117 and #172/#173 have different causes |
| the web | 2026-10-26 | 12 searches and 1 page fetch (some throttled and retried) | usage questions and documentation only |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the 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 | affected | the 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) | affected | fox 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 starts | fox 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 only | fox 1 vs 1 (IndexWriter did not keep the hole yet); quick 100 vs 0 |
| 4.0.0 | not affected | a second value throws field must not be added more than once |
| Monitor 8.6.0, 8.11.4 | Monitor | single 1 match, batch 0 |
| Monitor 8.2.0, 8.5.2 | Monitor | single documents used the IndexWriter batch path: 0 and 0 |
fix on a9779ef3c193 | not affected | the doc's 4 tests pass (assertions on and off, 10 iterations); memory, monitor and highlighter suites pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Exhaustive 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 | real | Own 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 | confirmed | Re-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 reproduces | History 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 validated | Scratch 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 551 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,634 words
- 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 wholelucene/memoryandlucene/monitormodules stock and those two pluslucene/highlighterwith the fix (2026-10-27). Hunt runs on mainea57360912edand 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 ona9779ef3c193still reproduces.
- 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-260lucene/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-69lucene/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-251lucene/core/src/test/org/apache/lucene/index/TestIndexWriter.java:768-800, 1899-1958lucene/memory/src/java/org/apache/lucene/index/memory/MemoryIndex.java:106, 683-684, 697-698, 713-730lucene/memory/src/test/org/apache/lucene/index/memory/TestMemoryIndexAgainstDirectory.java:96-99, 713-753lucene/CHANGES.txt:12895, 13337, 14131, 14644, 17578lucene/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
- TestMemoryIndexValueEndHole.java (JUnit test):
repro/LFV-340/src/org/apache/lucene/index/memory/TestMemoryIndexValueEndHole.java(59 lines)
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.
assets/LFV-340/bug.png(551 KB)
