All findings · Lucene finding · LFV-915 · highlightsmatch-hit-compareto-ignores-offsets

Monitor HighlightsMatch drops a hit that has the same positions as another hit but different offsets (shingles, multi-word synonyms), because Hit.compareTo ignores offsets (since 8.2.0)

A Monitor query that matches two tokens stacked at one position, such as wi and the shingle wi fi, gets only one of its two hits from HighlightsMatch.MATCHER: getHitCount() is 1, not 2, with no error. Hit.compareTo ignores offsets and the hits sit in a TreeSet; affected since 8.2.0.

Summary

Priority
Low, rank 479Opt-in Monitor HighlightsMatch drops a highlight span at each stacked-token position while matching stays correct, the same output-only Monitor profile as LFV-856.
Impact
silent wrong results
Since
8.2.0 (2019), the first monitor release; the TreeSet dates from the Luwak library (2015)introduced 2019-06-13 · about 7 years
Affected
lucene-monitor 8.2.0 to 10.6.0 and main (8 releases, branch_10_6 at the 10.6.0 RC1 revision and main run; the other releases by unchanged code)
Status
documented
Found by
code reading while comparing batch and single-document matching (non-formal)

AI-assisted page: this finding was first signalled by code reading or testing 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

A Monitor matches incoming documents against stored queries; HighlightsMatch.MATCHER also returns where each query matched, as hits with positions and character offsets. Some analyzers stack two tokens of different lengths at one position, and then two different hits share their positions.

Summary card in four panels. 1, what you do: WhitespaceTokenizer plus ShingleFilter(2, 2), the query wi fi OR wi, the document wi fi matched with HighlightsMatch.MATCHER; expected both hits that Weight#matches reports, 0(0)->0(2) and 0(0)->0(5), getHitCount() 2. 2, what Lucene does: the tokens wi and wi fi at position 0; each sub-query yields one hit; the merge adds them to one TreeSet, and the second add is rejected because compareTo returns 0 for equal positions; result one hit, getHitCount() 1. 3, why: Hit.compareTo (lines 199-203) compares positions only, while the record's equals compares all four fields. 4, result on main: getHits and getHitCount are wrong, the clause order decides the survivor, contains and equals ignore offsets, and the tie-break fix keeps both hits.
What you do (shingles, the query wi fi OR wi), what Lucene does (both sub-query hits merged into one TreeSet, where the second add is dropped because compareTo is 0), why (Hit.compareTo compares positions only) and the result on main: 2 hits expected, 1 kept. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You build a Monitor whose analyzer adds two-word shingles (ShingleFilter(2, 2), unigrams kept). The text wi fi then has the tokens wi (characters 0-2) and wi fi (characters 0-5) at the same position, 0.
  2. You register wi OR "wi fi" and match the document wi fi with HighlightsMatch.MATCHER. You expect the two hits that Weight#matches reports, 0(0)->0(2) and 0(0)->0(5) (startPosition(startOffset)->endPosition(endOffset)), and getHitCount() = 2.
  3. Instead getHits("f") is [0(0)->0(2)] and getHitCount() is 1, silently. A multi-word synonym does the same: with guinea pig => cavy, the query guinea OR cavy keeps cavy (characters 0-10) and loses guinea (0-6).
  4. The cause: the hits are kept in a TreeSet, which treats two elements as the same when compareTo returns 0, and Hit.compareTo compares positions only. Hit.equals (a record over all four numbers) says the two hits differ.
  5. The same comparator makes getHits("f").contains(new Hit(0, 2, 0, 4)) true for a set holding only 0(0)->0(2), and makes two matches with different hits equals while their hash codes (4564, 6480) differ. Which queries match is unaffected.

Who is affected

lucene-monitor applications that match with HighlightsMatch.MATCHER and analyse with stacked tokens of different lengths: ShingleFilter with unigrams, multi-word SynonymGraphFilter rules with FlattenGraphFilter, CJKBigramFilter with unigrams. Each time a query matches two such tokens, one highlight span is lost and getHitCount() is one too low. One document suffices; matching itself is unaffected.

Mechanism, terms and numbers649 words

HighlightsMatch.MATCHER reports, for every stored query that matches a document, the hits that Weight#matches finds, as Hit(startPosition, startOffset, endPosition, endOffset) records per field. It keeps them in a TreeSet, and Hit.compareTo compares the start and end positions only, although Hit is a record whose equals and hashCode use all four fields. Analyzers that stack tokens of different lengths at one position (ShingleFilter with unigrams, multi-word synonyms through SynonymGraphFilter and FlattenGraphFilter, CJKBigramFilter with unigrams) produce two different hits with equal positions, because the index stores no position length; when a query matches both, the set keeps one. For wi fi with shingles and the query wi OR "wi fi", Weight#matches reports [0(0)->0(2), 0(0)->0(5)] and HighlightsMatch returns [0(0)->0(2)], getHitCount() 1, with no error. Which hit survives depends on clause order. The same comparator makes getHits(field).contains(...) ignore offsets and lets two matches with different hits be equals while their hash codes differ. Which queries match is not affected. The fix is a tie-break on offsets in compareTo; with it alone, one upstream batch test fails because a batch reports offsets -1 for TextField, so the test must index offsets (or the batch path must change).

Terms

  • Monitor: Lucene's reverse search module (lucene-monitor). You register queries, then monitor.match(doc, matcher) tells you which of them match a document. The matcher decides what you get back; HighlightsMatch.MATCHER returns a HighlightsMatch per matching query with its hits per field.
  • Hit: HighlightsMatch.Hit(startPosition, startOffset, endPosition, endOffset), a public record (HighlightsMatch.java:189). It prints as startPosition(startOffset)->endPosition(endOffset): 0(0)->0(5) is position 0 to 0, characters 0 to 5. The hits come from Weight#matches (HighlightsMatch.java:49-95).
  • Stacked tokens: tokens at the same position. ShingleFilter(2, 2) keeps unigrams by default, so wi fi gives wi [0,2), wi fi [0,5) and fi [3,5); wi and wi fi are both at position 0. A multi-word synonym guinea pig => cavy (original kept, flattened for indexing) puts cavy [0,10) on guinea [0,6). The index stores no position length, so both hits of a pair span position 0 to 0; only the offsets tell them apart.

The bug

  1. addHit puts every hit into a TreeSet per field: hits.computeIfAbsent(field, _ -> new TreeSet<>()).add(new Hit(...)) (HighlightsMatch.java:176-179).
  2. Hit.compareTo compares startPosition, then endPosition, and stops (HighlightsMatch.java:199-203). For 0(0)->0(2) and 0(0)->0(5) it returns 0.
  3. A TreeSet decides that an element is already present when compareTo returns 0 (its javadoc: "two elements that are deemed equal by this method are, from the standpoint of the set, equal"), so the second hit is dropped, although equals says the two differ.
  4. The default query decomposer splits wi OR "wi fi" into two queries; when both match, CandidateMatcher.addMatch merges their matches through HighlightsMatch.merge (CandidateMatcher.java:75-85, HighlightsMatch.java:65-67, :142-151), which copies the hits into a new TreeSet and drops the same hit. Without decomposition, the one Weight#matches call loses it in addHit.
  5. getHits(field) returns a read-only view of that TreeSet (:125-129), so contains answers through compareTo and ignores offsets; HighlightsMatch.equals compares the hit maps (:159) and ignores offsets too, while hashCode (:164-169) includes them.

The numbers

On main a9779ef3c193: shingles, wi OR "wi fi" on wi fi: expected [0(0)->0(2), 0(0)->0(5)], got [0(0)->0(2)], getHitCount() 1. Synonym guinea pig => cavy, guinea OR cavy on guinea pig: expected [0(0)->0(6), 0(0)->0(10)], got [0(0)->0(10)]. PrefixQuery wi* over shingles keeps [0(0)->0(5)]. CJKBigramFilter with unigrams on 東京, PrefixQuery 東: Weight#matches gives 0(0)->0(1) and 0(0)->0(2), HighlightsMatch keeps 0(0)->0(2). The matches for wi and wi (hits 0(0)->0(2) and 0(2)->0(4)) are equals, with hash codes 4564 and 6480. Which hit survives depends on clause order (SHOULD(shingle, unigram) keeps 0(0)->0(2), SHOULD(unigram, shingle) keeps 0(0)->0(5)) and, in decomposed queries, on hash iteration order; the loss happened for every seed tried.

Exposure and likelihood255 words
  • Who is exposed. Applications that use the monitor with HighlightsMatch.MATCHER, for example to show which words of an incoming document matched a stored query, and whose analyzer stacks tokens of different lengths at one position. Every time a stored query matches two such stacked tokens in one document (a word and its multi-word synonym, a prefix query that matches a unigram and a shingle, a disjunction of both), one hit is silently dropped and getHitCount() is one too low.
  • Opt-in on both sides. The caller picks the matcher on every Monitor.match call (Monitor.java:218, :240; the other standard matchers are QueryMatch.SIMPLE_MATCHER, ScoringMatch.DEFAULT_MATCHER and ExplainingMatch.MATCHER), and the analysis chain is the application's. HighlightsMatch.MATCHER is one of the module's documented standard matchers, not an expert API.
  • No scale needed. One document, one query, one stacked pair. Single documents, batches whose fields index offsets (a batch of plain TextField documents reports offsets -1, so its stacked hits are identical), and both the plain and the decomposed query paths are affected.
  • Not affected. Which queries match a document; the simple, scoring and explaining matchers; hits whose offsets are not recorded (-1), which are truly identical.
  • Contract fallout. getHits(field).contains(...) cannot detect wrong offsets (upstream's own offset assertions are affected), and HighlightsMatch objects that are equals can have different hash codes, which breaks hash-based collections.
  • How likely. Rare: it needs the matcher, a stacking analyzer and a query that matches two stacked tokens. Nobody has reported it since the TreeSet was introduced in 2015. Priority Low.

Minimal repro

The test shows that Monitor's HighlightsMatch drops one of two hits at the same positions with different offsets, because HighlightsMatch.Hit.compareTo ignores offsets and the hits are kept in a TreeSet.

TestHighlightsMatchStackedHits.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.monitor;

import java.io.IOException;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.core.WhitespaceTokenizer;
import org.apache.lucene.analysis.shingle.ShingleFilter;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestHighlightsMatchStackedHits extends LuceneTestCase {

  public void testUnigramAndShingleAtSamePosition() throws IOException {
    Analyzer shingles =
        new Analyzer() {
          @Override
          protected TokenStreamComponents createComponents(String fieldName) {
            Tokenizer t = new WhitespaceTokenizer();
            return new TokenStreamComponents(t, new ShingleFilter(t, 2, 2));
          }
        };
    Query q =
        new BooleanQuery.Builder()
            .add(new TermQuery(new Term("f", "wi")), Occur.SHOULD)
            .add(new TermQuery(new Term("f", "wi fi")), Occur.SHOULD)
            .build();
    try (Monitor monitor = new Monitor(shingles)) {
      monitor.register(new MonitorQuery("q", q));
      Document doc = new Document();
      doc.add(new TextField("f", "wi fi", Field.Store.NO));
      HighlightsMatch m = monitor.match(doc, HighlightsMatch.MATCHER).matches("q");
      // expected 2 hits, 0(0)->0(2) "wi" and 0(0)->0(5) "wi fi"; actual 1
      assertEquals(m.getHits("f").toString(), 2, m.getHitCount());
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-915/, 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/analysis/common/build/libs/lucene-analysis-common-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$V.jar
CP=$CP:$LUCENE/lucene/monitor/build/libs/lucene-monitor-$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.monitor.TestHighlightsMatchStackedHits
Expected vs actual
Expected 2 hits for f:wi OR f:"wi fi" (two TermQuery clauses, the second on the shingle term) on "wi fi" with ShingleFilter(2,2) (unigram 0(0)->0(2) and shingle 0(0)->0(5)); actual: getHitCount() is 1 (expected:<2> but was:<1>), with -ea and -da.

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

More files, environment, full output34 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: [0(0)->0(5)] expected:<2> 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.monitor.TestHighlightsMatchStackedHits.testUnigramAndShingleAtSamePosition(TestHighlightsMatchStackedHits.java:56)

Why it looks unintended

  • Hit is a record whose equals uses all four numbers, and getHitCount() promises "the total number of hits for the query".
  • The deliberate dedup was tested only on fully identical hits, which the fix still merges; the survivor depends on clause order.
  • Upstream tests check hits via getHits(...).contains(new Hit(...)) 16 times; none of them can catch a wrong offset today.
The full argument347 words
  • Contract text. Hit is a public record over (startPosition, startOffset, endPosition, endOffset) (HighlightsMatch.java:181-189). getHits(): "a map of hits per field" (:105-110); getHitCount(): "the total number of hits for the query" (:131-140). The package doc says HighlightsMatch#MATCHER returns "the matching queries along with the matching terms for each query" (package-info.java:46-47); the dropped hit is a different matched term covering different text. No javadoc explicitly promises that every distinct Weight#matches hit is kept: the expectation rests on the record's equals, this wording and the JDK contracts, a strong reading rather than a quoted guarantee.
  • JDK contracts. Comparable strongly recommends a natural ordering consistent with equals because sorted sets "behave 'strangely'" otherwise; the TreeSet javadoc says a set with an inconsistent ordering "fails to obey the general contract of the Set interface"; Set.contains and Object.hashCode are specified through equals.
  • Tests. TestHighlightingMatcher asserts exact hits with getHits(...).contains(new HighlightsMatch.Hit(...)) 16 times (14 on FIELD, 2 on "field1"/"field2" at lines 108-109; for example line 63, Hit(3, 10, 3, 14)). The tests treat offsets as part of a hit, but these assertions cannot catch a wrong offset today: they fail when no hit has the expected positions, never when only the offsets differ; the batch assertion at line 541 does not even hold (a batch returns offsets -1 for TextField), and nobody could notice. testIdenticalMatches (lines 182-198) is the deliberate dedup: the same term as MUST and SHOULD, hits identical in positions and offsets, getHitCount() 1; it passes with the fix.
  • History. In Luwak, the library the module was imported from, compareTo was already position-only while hits lived in lists; commit 95e9eaf5b6 (2015, "Decompose DisjunctionQueries, and ensure highlights are only reported once") switched to TreeSets with only the identical-hit test. Hit.equals was hand-written over all four fields, and flaxsearch/luwak#87 (2016) added a hashCode "compatible with the existing equals()". The donation (LUCENE-8766) and the record conversion (#13328, 10.0.0) kept all of it. No CHANGES, MIGRATE or javadoc text says that hits with equal positions are merged.
The devil's-advocate check198 words

The intended-behaviour checker built the strongest case for "dedup by position is the design": the class javadoc says it "contains the hit positions of a matching Query" (HighlightsMatch.java:36); the position-keyed TreeSet was added on purpose; Comparable only recommends consistency with equals; core's DisjunctionMatchesIterator also orders by position (DisjunctionMatchesIterator.java:197-206); and the index has no position length anyway.

Why the evidence weighs against it: the dedup commit targeted hits reported twice by decomposed clauses, which are identical in positions and offsets and stay merged with the fix (its test is no longer decomposed today, because QueryDecomposer keeps a MUST plus SHOULD query whole, QueryDecomposer.java:93-95, but it still checks that identical hits merge); the author made offsets part of a hit's identity (equals, then hashCode); the survivor depends on clause order, which no dedup policy (such as "keep the widest span") would do; and the result breaks the Set and equals/hashCode contracts and blinds the module's own offset assertions. No document promises distinct same-position hits, so this is a judgement on the evidence. The checker rated the argument weak, a cross-model reviewer moderate; the adjudication confirmed the bug with high confidence.

Suggested fix

Recommended A1. Tie-break on offsets in Hit.compareTo, and make testHighlightBatches index offsets

Break position ties in Hit.compareTo by startOffset, then endOffset (+6/-1), so the TreeSets keep hits that differ in offsets and still merge identical ones. Pair it with a small test change: testHighlightBatches must index offsets, because a batch reports -1 offsets for TextField. Validated: the new test passes, and all 185 monitor tests pass.

     public int compareTo(Hit other) {
       if (this.startPosition != other.startPosition)
         return Integer.compare(this.startPosition, other.startPosition);
-      return Integer.compare(this.endPosition, other.endPosition);
+      if (this.endPosition != other.endPosition)
+        return Integer.compare(this.endPosition, other.endPosition);
+      // consistent with equals: the hit TreeSets must not drop a hit that differs in offsets only
+      if (this.startOffset != other.startOffset)
+        return Integer.compare(this.startOffset, other.startOffset);
+      return Integer.compare(this.endOffset, other.endOffset);
     }
All options and trade-offs5 options · 727 words

Two variants of the same one-method fix were built and run through gradle; they differ only in how they handle the one upstream test that the fix exposes. The other options were not built.

A1. Tie-break on offsets in Hit.compareTo, and make testHighlightBatches index offsets (validated by the checks) (recommended)

Hit.compareTo keeps comparing start and end positions, then compares startOffset and endOffset (+6/-1 in HighlightsMatch.java). With this alone, TestHighlightingMatcher.testHighlightBatches fails at line 541, assertTrue(m1.getHits(FIELD).contains(new HighlightsMatch.Hit(1, 4, 2, 16))): a TextField document matched in a batch of two or more documents is indexed with its own field type and gets offsets -1, while a single document goes through a MemoryIndex that records offsets. Today the assertion passes only because contains ignores offsets. The test change gives the test's three batch documents a field type that indexes offsets, so the asserted hits are what the batch really returns (all three: a batch must have one schema).

+      // a batch is indexed with the documents' own field types, so index offsets to get them back
+      FieldType withOffsets = new FieldType(TextField.TYPE_NOT_STORED);
+      withOffsets.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
       Document doc1 = new Document();
-      doc1.add(newTextField(FIELD, "the cell biology count", Field.Store.NO)); // matches
+      doc1.add(new Field(FIELD, "the cell biology count", withOffsets)); // matches
       Document doc2 = new Document();
-      doc2.add(newTextField(FIELD, "nope", Field.Store.NO));
+      doc2.add(new Field(FIELD, "nope", withOffsets));
       Document doc3 = new Document();
-      doc3.add(newTextField(FIELD, "biology text", Field.Store.NO));
+      doc3.add(new Field(FIELD, "biology text", withOffsets));

(plus the imports of FieldType, TextField and IndexOptions.)

     public int compareTo(Hit other) {
       if (this.startPosition != other.startPosition)
         return Integer.compare(this.startPosition, other.startPosition);
-      return Integer.compare(this.endPosition, other.endPosition);
+      if (this.endPosition != other.endPosition)
+        return Integer.compare(this.endPosition, other.endPosition);
+      // consistent with equals: the hit TreeSets must not drop a hit that differs in offsets only
+      if (this.startOffset != other.startOffset)
+        return Integer.compare(this.startOffset, other.startOffset);
+      return Integer.compare(this.endOffset, other.endOffset);
     }

Trade-offs

  • Hit is public and Comparable: its natural ordering gains a tie-break, but only among hits with equal positions, which cannot coexist in a HighlightsMatch today.
  • getHitCount() and getHits(field) report one more hit at each stacked position: the point of the fix, but visible; a CHANGES entry under bug fixes fits.
  • Hits without recorded offsets (-1) stay identical and are still merged, so fields without offsets keep today's counts.
  • The test change leaves the batch question open: whether a batch should record offsets for TextField is a separate, disputed behaviour.

What the checks validated

  • Stock a9779ef3c193: the new test fails 4 of 4 with assertions on and off. With the tie-break: 4 of 4 pass.
  • Tie-break alone: TestHighlightingMatcher 20 tests, 1 failure (testHighlightBatches, line 541).
  • Tie-break + test change: all of :lucene:monitor:test with assertions on, 33 classes, 185 tests (181 existing, 4 new), 1 skipped, 0 failures; the new test and TestHighlightingMatcher with assertions off, 24 tests, 0 failures; :lucene:monitor:check -x test (google-java-format, ecjLint, forbiddenApis) passes. The test change alone on stock: TestHighlightingMatcher 20 of 20 pass.
  • Earlier, in the hunt: the finder's test, the independent reproducer's 5 tests, the devil's advocate's 3 probes and the version-portable check (also on 8.2.0) pass with the same tie-break; testIdenticalMatches still passes, so identical hits are still merged.

A2. The same tie-break, together with a batch change that indexes offsets (validated by the checks)

Instead of changing the test, make the batch path index positions with offsets, as the single-document path does (a sketch from the separate batch-offsets candidate: +87/-2 in DocumentBatch.java); testHighlightBatches stays as it is.

Trade-offs

Bigger, and it settles a separate question on the way: TextField's field type indexes positions but not offsets, and that batch behaviour is already described in the open apache/solr#2382. Better raised separately, after A1.

What the checks validated

  • Tie-break + batch change: all of :lucene:monitor:test with assertions on, 185 tests, 1 skipped, 0 failures.

B. Keep the hits in an equals-based set (LinkedHashSet or HashSet)

Not built.

Trade-offs

Fixes the loss and the contracts, but drops the position-sorted iteration order that getHits() users get today.

C. An explicit dedup policy, for example keep the widest span per position

Not built.

Trade-offs

Still needs a comparator consistent with equals for the Set and equals/hashCode contracts, invents semantics, and still loses a matched term.

D. Document that hits with equal positions are merged

Not built.

Trade-offs

Leaves the lost terms, the Set contract break and the equals/hashCode inconsistency in place.

Downstream note

Applications that render monitor highlights show one span too few at such positions; the open Solr integration of the monitor (apache/solr#2382) uses HighlightsMatch.

How it was found

A hunt comparing the monitor's batch matching with single-document matching noticed, by reading the upstream batch test, that its offset assertion could never fail, and traced that to Hit.compareTo. The finder reduced it to a public-API test with Weight#matches as the reference; independent checkers then reproduced it with their own tests.

The method in full157 words

Method: code reading (the registry's "non-formal" tier). A hunt unit compared Monitor.match(Document[]) with Monitor.match(Document). While reading TestHighlightingMatcher.testHighlightBatches, the finder saw that its assertion on offsets (line 541) could never fail, and traced that to Hit.compareTo and the TreeSet.

  • The finder wrote a public-API JUnit with Weight#matches on a MemoryIndex as the reference (shingles, wi OR "wi fi"), which fails on main with and without assertions, on branch_10_6 and 10.5.2, and passes with the tie-break.
  • It replayed the upstream monitor suite with the tie-break in its own harness: 179 of 180 pass, the one failure being testHighlightBatches (the batch-offsets behaviour); 180 of 180 together with the batch change. (The 185 tests in the fix section come from a separate gradle run of the whole module, 181 existing tests plus the 4 new ones; the two counts are not comparable.)
  • Independent checks followed (see "Independent checks"), then a version sweep and this doc's gradle validation.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-28): GitHub issues, PRs and commits, every open PR by changed files, the LUCENE JIRA archive, the Lucene mailing lists, the pre-donation Luwak repository, downstream projects and the web. The nearest item, flaxsearch/luwak#87 (2016), made Hit.hashCode match equals and never mentioned compareTo.

Searches5 searches

No issue, PR, review comment, commit, JIRA ticket, list thread or downstream report describes Hit.compareTo ignoring offsets or HighlightsMatch dropping a hit whose positions equal another hit's. Searches cannot prove absence.

What was searched, when, and what turned up
WhereWhenHowFound
scripted prior-report search (GitHub, commits, downstream repos, lists, JIRA, web)2026-10-2816 GitHub issue/PR queries, 3 commit queries, 2 downstream-repo queries, 20 queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries, 6 web queries; comments of the top 15 hits read; the finder's earlier 45-query run209 distinct hits (1,441 in the earlier run), none this bug
GitHub apache/lucene (manual)2026-10-28, refreshedmore than 20 gh searches: HighlightsMatch, "HighlightsMatch.Hit", monitor compareTo, monitor highlight offsets, label:module:monitor, HighlightingMatcher, getHitCount, compareTo "inconsistent with equals", monitor synonym, monitor shingle; issues and PRs created since 2026-10-24only #12406 and #13109 (other topics); nothing new since 2026-10-24
Open PRs2026-10-28every open PR by its changed filesonly #16847 touches HighlightsMatch.java, for an unrelated loop bound
Pre-donation repository flaxsearch/luwak2026-10-28highlight, compareTo, offsets, duplicate hits, synonym, shingle, Hitluwak#87 (hashCode consistent with equals, 2016); commit 95e9eaf5b6 (introduced the TreeSet, 2015); luwak#143 (batch offsets); none reports this bug
Downstream and lists2026-10-28apache/solr, Elasticsearch, OpenSearch, Lucene.NET, lucene-grep; lists.apache.org; LUCENE JIRA archivesolr#2382 uses HighlightsMatch (batch offsets only); nothing else relevant

History

Present since the monitor module was added (LUCENE-8766, 251dbe7cea8, 2019; first release 8.2.0); the position-only TreeSet comes from the Luwak library (2015). Releases 8.2.0 to 10.6.0 and main are affected (8 releases, branch_10_6 at the 10.6.0 RC1 revision and main run, the others by unchanged code); before 8.6.0 only fields that index offsets show it. Not fixed upstream.

Versions tested7 rows

Introduced by 251dbe7cea8 on master and b8773d13ad8 on branch_8x, both "LUCENE-8766: Add monitor subproject" (2019-06-13; issue #9811, review PR apache/lucene-solr#648 by @romseygeek, committed directly), the import of the Luwak library; git blame and git log -L attribute compareTo, merge and addHit to it. In Luwak, commit 95e9eaf5b6 (2015-10-05) had switched the hit lists to TreeSets while compareTo stayed position-only. The compareTo body and both TreeSet uses are the same in the tags 8.2.0, 8.8.1, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and on main; 8.8.2-8.11.3 have no tag in the history clone and are inferred. Every later edit to the file is cosmetic or API-only, including the record conversion (#13328, 10.0.0), which kept equality over all four fields. On 8.2.0-8.5.2 a single document was indexed with IndexWriter, so a plain TextField had offsets -1, the stacked hits were identical and nothing observable was lost; LUCENE-9314 (8.6.0) moved single documents to a MemoryIndex with offsets. The last upstream commit to the file is b59848c7049 (2026-04-02, a refactor).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193, branch_10_6 affectednew gradle test 4 of 4 fail (assertions on and off); hunt test fails on main and branch_10_6; portable check 5 of 5 fail
10.0.0, 10.5.2 affectedportable check 5 of 5 fail (10.5.2 also without assertions); hunt test and independent reproducer fail on 10.5.2
9.0.0, 9.12.3 affectedportable check 5 of 5 fail; independent reproducer 5 of 5 fail on 9.12.3
8.6.0, 8.11.4 affectedportable check 5 of 5 fail; from 8.6.0 a plain TextField shows the loss too
8.2.0, 8.5.2 offsets fields onlyportable check 5 of 5 fail with a field that indexes offsets; a plain TextField had offsets -1, so the merged hits were identical
8.1.1 and earlier no monitor modulethe module did not exist
tie-break fix on 8.2.0 and main not affectedportable check 5 of 5 pass; new gradle test 4 of 4 pass on main

Independent checks

An independent reproducer, an intended-behaviour devil's advocate and an adjudication each re-ran it and confirmed it; a reviewer from another model family (OpenAI GPT-5.6, reading only) called it real in its intended-behaviour review, while its refute review could not run any command and named no flaw. A credibility audit kept it, and this doc's gradle run validated the fix.

Checks and verdicts340 words

Every check ran on main a9779ef3c193 with JDK 25 on Linux aarch64 unless stated; the GPT reviews read ea57360912ed, 7 commits earlier, none of them under lucene/monitor. Neither GPT review executed Java; a separate run re-ran the finder's test each time (it failed).

Each independent check and its verdict
CheckVerdictWhat it found
Independent reproducer realOwn 5-test JUnit in another package with another reference (Weight#matches on an IndexWriter index with offsets): 5 of 5 fail on main with and without assertions, 10.5.2 and 9.12.3; 5 of 5 pass with its own tie-break; upstream TestHighlightingMatcher 20 of 20 stock, 19 of 20 with the tie-break.
Cross-model review, refute lens not real (low), proceduralOpenAI GPT-5.6: No command ran (two attempts), so it returned the default "not real (low)"; it named no flaw and rated the intended-behaviour argument moderate, then weak.
Cross-model review, intended lens real (medium)OpenAI GPT-5.6, blind, reading only: root cause agrees, inputs in contract, intended argument moderate; "fix fixes it" false only on the strict reading that the tie-break alone breaks testHighlightBatches. The verifier's own probe showed the clause-order survivor and the contains and equals/hashCode symptoms.
Intended-behaviour devil's advocate real (medium-high)Built the "dedup by position is the design" case and rejected it; its probe (equals/hashCode, CJK bigrams, clause order) fails 3 of 3 on main with and without assertions and on 10.5.2, and passes with the fix.
Adjudication confirmed (high)Re-ran the independent reproducer: 5 of 5 fail with and without assertions, 5 of 5 pass with only the tie-break.
Credibility audit keepRe-ran the finder's test (assertions on, new seed) and the reproducer (assertions off, new seed): both fail; impact class and score kept.
This doc's fix validation fix validatedGradle in a scratch worktree: new test 4 of 4 fail on stock and pass with the fix; all 185 monitor tests pass with the fix and the test change, and with the fix and the batch change; module check passes.

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

Finding record

LFV-915, snapshot of 2026-11-06

Registry title
Monitor HighlightsMatch drops a hit when another hit has the same positions but different offsets (for example shingles or multi-word synonyms), because HighlightsMatch.Hit.compareTo ignores offsets
Area
monitor / HighlightsMatch hit set (Hit.compareTo used as TreeSet identity)
Priority
Low, rank 479: Opt-in Monitor HighlightsMatch drops a highlight span at each stacked-token position while matching stays correct, the same output-only Monitor profile as LFV-856.
Impact class
silent wrong results
Impact note
Hits lucene-monitor applications that match with HighlightsMatch.MATCHER and analyse with stacked tokens of different lengths at one position (ShingleFilter with unigrams, multi-word synonyms through SynonymGraphFilter plus FlattenGraphFilter, CJKBigramFilter with unigrams): whenever two such tokens both match, getHits(field) and getHitCount() silently lose one of the two distinct hits that Weight#matches reports, for single documents and batches and on both the addHit and the decomposer merge path, with no error and no scale needed; which queries match is not affected, and the SIMPLE and scoring matchers are not involved.
Summary
You register a query such as wi OR "wi fi" (or the prefix query wi*, or guinea OR cavy with a 'guinea pig => cavy' synonym rule) in a Monitor whose analyzer stacks tokens at one position, match a document with HighlightsMatch.MATCHER, and expect both hits that Weight#matches reports: for 'wi fi' with ShingleFilter(2,2) and unigrams these are 0(0)->0(2) for 'wi' and 0(0)->0(5) for 'wi fi'. Instead HighlightsMatch keeps only one of them and getHitCount() is 1, because Hit.compareTo compares start and end positions only and the hits are kept in TreeSets, which treat compareTo == 0 as a duplicate, although Hit's own equals (a record over all four fields) says the two hits differ. Which hit survives depends on clause order, and the same root cause makes getHits(field).contains(...) ignore offsets and lets HighlightsMatch.equals return true for two objects whose hashCodes differ.
Introduced
251dbe7cea8126cb7ecadc921ba444c63d06c618 on master and b8773d13ad8e249a045862249bf476c30a444461 on branch_8x (both 'LUCENE-8766: Add monitor subproject', the import of the Luwak library; issue apache/lucene#9811, review PR apache/lucene-solr#648, committed directly; the branch_8x commit shipped in 8.2.0) [read: git blame -w -M -C at 7cecf795236 and git log -L on compareTo attribute compareTo, merge and addHit to 251dbe7cea81; every later edit to the file is cosmetic or API-only, including the record conversion 942065cc3e7 (#13328, 10.0.0), which kept equality over all four components]. Pre-Lucene origin: Luwak commit 95e9eaf5b6 (2015-10-05, 'Decompose DisjunctionQueries, and ensure highlights are only reported once') switched the hit lists from ArrayList to TreeSet while compareTo stayed position-only [read, GitHub API] (2019-06-13 (251dbe7cea8 authored and committed; b8773d13ad8 committed the same day); 2015-10-05 for the Luwak TreeSet change before the donation)
First affected version
8.2.0 [exec] (the first lucene-monitor release; there is no earlier good Lucene version). On 8.2.0-8.5.2 a single document was indexed through IndexWriter, so the loss shows there only for fields that index offsets; from 8.6.0 (LUCENE-9314, MemoryIndex with offsets) it also shows with a plain TextField
Affected versions
Every lucene-monitor release, 8.2.0 to 10.5.2 (57 releases per Maven Central metadata), 10.6.0 (the branch_10_6 head 4ae946e5a9b, the RC1 revision) and main a9779ef3c193. [exec] The version-portable history repro (5 tests, offsets-indexed field, Weight#matches on an IndexWriter index as reference) fails 5 of 5 on 8.2.0 (-ea, -da), 8.5.2, 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea, -da) and main (-ea, -da), its 2 controls pass on 8.2.0, 9.12.3, 10.5.2 and main, and the compareTo tie-break alone turns 8.2.0 and main into 5 of 5 pass; seeds 716DB29A012ECD30, 5F754D1745C2048A, 1, 2 and 3 on 8.6.0 and main also fail 5 of 5; the finder's JUnit fails on main (-ea, -da), branch_10_6 and 10.5.2, the independent reproduction's on main (-ea, -da), 10.5.2 and 9.12.3, the intended-behaviour check's probe on main (-ea, -da) and 10.5.2. [read] The other releases by code identity (the compareTo body and both TreeSet uses are unchanged in every release tag checked). Plain TextField on single documents: 8.6.0 and later [exec]; on 8.2.0-8.5.2 such a document carried offsets -1 and the collapsed hits were truly identical [exec]. Not applicable before 8.2.0 (no monitor module). Not fixed upstream: the last upstream commit to HighlightsMatch.java is b59848c7049 (2026-04-02, instanceof refactor) and upstream main c5ab3e57b9ba (2026-10-28) has no commit to lucene/monitor since 2026-10-20 [read, 2026-10-28]
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
non-formal: code reading
Upstream and prior reports
none found (searched 2026-10-28, read-only; candidate the prior-report record, verdict new): scripted prior-report searches (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries, 6 web queries; 209 distinct hits, plus the finder's earlier 45-query run with 1,441 hits), more than 20 manual gh searches on apache/lucene (HighlightsMatch, HighlightsMatch.Hit, monitor compareTo, monitor highlight offsets, label:module:monitor, HighlightingMatcher, getHitCount, compareTo 'inconsistent with equals', ...), every open PR scanned by changed files, the pre-donation flaxsearch/luwak repo, apache/solr, Elasticsearch, OpenSearch, Lucene.NET, the LUCENE JIRA archive, lists.apache.org and the web; the independent reproduction, the intended-behaviour check and the adjudication each searched again. Nearest items, none the same bug: flaxsearch/luwak#87 (2016, added a Hit.hashCode consistent with equals over all four fields; nobody mentioned compareTo or the TreeSet), Luwak commit 95e9eaf5b6 (2015, introduced the TreeSet; not a report), flaxsearch/luwak#143 and apache/solr#2382 (batch offsets, the sibling candidate), #12406 and #13109 (use HighlightsMatch, other topics), #9811 (the donation, no discussion of Hit ordering). The only open PR that touches HighlightsMatch.java, #16847, changes an unrelated loop bound. Refresh 2026-10-28: 'repo:apache/lucene HighlightsMatch' still returns only #12406 and #13109; 'monitor highlight offsets' only #7093 (unrelated); none of the 18 PRs and 7 issues created since 2026-10-26 touches lucene/monitor or HighlightsMatch; no commit to lucene/monitor since 2026-10-20 (upstream main c5ab3e57b9ba).
Lucene commit tested
main a9779ef3c193 (2026-10-26) through gradle in a scratch worktree, removed afterwards: the new test class on stock (4 of 4 fail, assertions on and off) and with the compareTo tie-break (4 of 4 pass); TestHighlightingMatcher with the tie-break alone (1 failure, testHighlightBatches line 541); all of :lucene:monitor:test with the tie-break and the test change (185 tests, 1 skipped, 0 failures) and with the tie-break and the batch-offsets change (185, 0 failures); :lucene:monitor:check -x test. The hunt's and the verifiers' JUnit tests on main (assertions on and off), branch_10_6, 10.5.2 and 9.12.3; a version-portable check on 8.2.0, 8.5.2, 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main. JDK 25, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:199-203 (at a9779ef3c193) (Hit.compareTo: startPosition, then endPosition only), :189 (public record Hit(int startPosition, int startOffset, int endPosition, int endOffset) implements Comparable<Hit>), :176-179 (addHit: computeIfAbsent(field, _ -> new TreeSet<>()).add(new Hit(...))), :142-151 (merge: TreeSet per field), :153-168 (HighlightsMatch.equals/hashCode over the hits map), :36 (class javadoc 'contains the hit positions of a matching Query'), :106 ('a map of hits per field'), :132 ('the total number of hits for the query')
The finding in 30 seconds
  • lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:176-179 (at a9779ef3c193) (addHit, TreeSet), :189 (record Hit), :199-203 (compareTo), :142-151 (merge), :65-67 (resolve), :125-129 (getHits(field)), :153-169 (equals/hashCode), :49-95 (MATCHER, buildMatch)
  • lucene/monitor/src/java/org/apache/lucene/monitor/CandidateMatcher.java:75-85 (at a9779ef3c193) (addMatch calls resolve)
Who is affected
  • lucene/monitor/src/java/org/apache/lucene/monitor/Monitor.java:218, (at a9779ef3c193) :240 (match takes a MatcherFactory); package-info.java:35-48 (the matcher types: QueryMatch#SIMPLE_MATCHER, ScoringMatch#matchWithSimilarity, ExplainingMatch#MATCHER, HighlightsMatch#MATCHER); ScoringMatch.java:49-50 (DEFAULT_MATCHER)
Why it looks unintended
  • lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:36 (at a9779ef3c193) (class javadoc), :105-110, :131-140 (getHits/getHitCount javadoc), :181-189 (record Hit)
  • lucene/monitor/src/java/org/apache/lucene/monitor/package-info.java:46-47 (at a9779ef3c193)
  • lucene/monitor/src/test/org/apache/lucene/monitor/TestHighlightingMatcher.java:63, (at a9779ef3c193) :77, :182-198 (testIdenticalMatches), :108-109, :541 (16 getHits(...).contains(new Hit(...)) assertions, 14 on FIELD)
  • lucene/core/src/java/org/apache/lucene/search/DisjunctionMatchesIterator.java:197-206 (at a9779ef3c193)
  • lucene/monitor/src/java/org/apache/lucene/monitor/QueryDecomposer.java:93-95 (at a9779ef3c193) (a MUST plus SHOULD query is kept whole)
Suggested fix
  • lucene/monitor/src/java/org/apache/lucene/monitor/DocumentBatch.java:87 (at a9779ef3c193) (batch: IndexWriter with the documents' field types), :108 (single document: MemoryIndex(true, true))
  • lucene/monitor/src/test/org/apache/lucene/monitor/TestHighlightingMatcher.java:541 (at a9779ef3c193)

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

Repro files

About the diagrams

Summary card (bug.png), made in parallel with this doc from the diagram's own probe on main a9779ef3c193.

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.