All findings · Lucene finding · LFV-464 · payload-filtered-interval-matches-for-non-matching-doc

Payload-filtered Intervals.term reports Weight#matches for documents it does not match, so a MUST_NOT clause hides its own query's hits from highlighting

Intervals.term("dog", payloadFilter) finds the right documents, but Weight#matches claims a match in every document that contains dog, even where the filter rejected every dog. So MUST_NOT queries lose their hits' highlights and Matches-based matchers report false matches, in every release since 8.1.0.

Summary

Priority
Low, rank 850The same rarely used overload reports Matches for documents whose payloads are all rejected, so MUST_NOT hits lose their highlights and monitor's HighlightsMatch matcher reports false matches, but hits, counts and scores stay correct.
Impact
silent wrong results
Since
8.1.0 (LUCENE-8694, 2019)introduced 2019-03-18 · about 7 years
Affected
8.1.0 to 10.6.0 and main
Status
documented
Found by
a Matches-versus-hits check written by hand, then a JUnit

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

Intervals.term(term, payloadFilter) matches a term only where its payload passes your filter. Search results are right, but Weight#matches, the API that says where a query matched, answers "matched" for every document that contains the term at all.

Background in 3 steps

  1. A payload is a few bytes stored with one occurrence of a term, for example a part-of-speech tag: dog|noun or dog|verb.
  2. Intervals.term("dog", filter) matches dog only at positions whose payload passes filter. A document whose every dog is rejected does not match.
  3. Weight#matches(leaf, doc) returns where a query matched in one document, or null when the document does not match. Highlighters, the monitor module and named-clause reporting use it, and BooleanQuery builds its answer from its clauses' answers.
Diagram of the bug. Two documents read 'big dog'; in doc 0 'dog' has payload noun, in doc 1 payload verb. Intervals.term(dog, payload is noun) matches doc 0 only, but for doc 1 its matches() returns an iterator before the payload filter runs, so Weight#matches is non-null with 0 positions. A BooleanQuery +big -(dog as noun) hits doc 1 yet returns no Matches for it, so the highlighter leaves doc 1 unhighlighted.
The bug in one picture: the payload-filtered dog source over a document whose only dog is a verb. The search skips the document, but matches hands out an iterator before the filter runs, so Weight#matches says "matched" and a MUST_NOT query loses its hit's highlight. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index two documents whose field f reads "big dog": in doc 0 dog carries payload noun, in doc 1 payload verb. Search IntervalQuery("f", Intervals.term("dog", payload is noun)): 1 hit, doc 0, correctly.
  2. Ask Weight#matches where that query matched in doc 1. You expect null: its javadoc says null "if the document does not match the parent query".
  3. You get a non-null Matches with 0 positions. The source hands out an iterator for every document that contains dog and applies the payload filter only later, when the iterator is read.
  4. Code that trusts it goes wrong. +f:big -<that query> correctly hits doc 1, but BooleanQuery sees its excluded clause "match" and returns no Matches for doc 1, so the default UnifiedHighlighter (offsets indexed) returns it as plain big dog. Monitor's HighlightsMatch.MATCHER reports the stored query as matching doc 1.
  5. Hits, counts, scores and explain() stay correct; only Weight#matches and what is built on it is wrong, silently, on every release from 8.1.0 (2019) to 10.6.0 and on main.

Who is affected

Applications that build payload-filtered interval terms in Java and read Weight#matches: the default UnifiedHighlighter (with offsets indexed), the monitor module's HighlightsMatch.MATCHER, NamedMatches, or their own code. Default settings, any index size, deterministic. No Lucene query parser builds these sources, and a public code search found no Elasticsearch, OpenSearch or Solr builder that does.

Mechanism, terms and numbers837 words

Intervals.term(String|BytesRef, Predicate<BytesRef>) builds a source that matches a term only at positions whose payload passes the filter. Searching with it is correct: the scorer applies the filter. But its matches(...) returns an iterator as soon as the document contains the term and applies the filter only inside next(). For a document where the filter rejects every position, IntervalQuery's Weight#matches therefore returns a non-null, empty Matches for a document the query does not match.

Code that reads Weight#matches trusts the non-null value. A BooleanQuery that excludes such a clause with MUST_NOT returns null Matches for its real hits, so the default UnifiedHighlighter returns them unhighlighted; Intervals.or, a required clause and a SHOULD clause with minimumShouldMatch over-report; the monitor module's HighlightsMatch.MATCHER and NamedMatches report matches that do not exist. Hits, counts, scores and explain() are correct. Every release from 8.1.0 (2019) to 10.6.0 and main is affected; only applications that use the payload-filter overload, which no Lucene query parser builds, are hit.

The mechanism (main 645d4fe14a20)

  1. The factory. Intervals.term(String, Predicate<BytesRef>) (Intervals.java:82-84) delegates to the BytesRef overload, which returns new PayloadFilteredTermIntervalsSource(term, payloadFilter) (:90-92).
  2. matches hands out an iterator whenever the term is in the document. matches(field, ctx, doc) (PayloadFilteredTermIntervalsSource.java:151-174) seeks the term and calls the private matches(te, doc) (:181), whose only null return is if (pe.advance(doc) != doc) { return null; } (:183-185). The iterator starts with upto = pe.freq() (:198) and applies the filter only in next(): } while (filter.test(pe.getPayload()) == false); (:202-212). If every position is rejected, the first next() returns false, but the iterator is already out.
  3. Non-null means "hit". IntervalQuery's Weight#matches returns null only when the source does (IntervalQuery.java:170-186), and MatchesUtils.forField calls the supplier "eagerly to work out if we have a hit or not", returning a Matches for any non-null iterator (MatchesUtils.java:106-117).
  4. The scorer is right. The scoring iterator applies the filter in nextInterval() (PayloadFilteredTermIntervalsSource.java:117-126) and IntervalScorer's two-phase check rejects a document with no first interval (IntervalScorer.java:80-81), so hits, counts and scores are correct.
  5. Where it spreads. BooleanWeight.matches returns null when a prohibited clause has non-null Matches (BooleanWeight.java:142-146), keeps a required clause's (:147-152) and counts a SHOULD clause's towards minimumShouldMatch (:153-162). DisjunctionIntervalsSource.matches keeps every non-null sub-iterator (DisjunctionIntervalsSource.java:86-97), and two more wrappers hand the child's iterator on unchanged: fixField (FixedFieldIntervalsSource.java:44-47) and the not* sources (notContaining, notContainedBy, nonOverlapping, notWithin) when the subtrahend is absent from the document (DifferenceIntervalsSource.java:58-61); on main, fixField(f, PF(dog)) and notContaining(PF(dog), term(cat)) give non-null Matches for the non-hit. Monitor's HighlightsMatch.MATCHER adds a match whenever Weight#matches is non-null (HighlightsMatch.java:44-62); NamedMatches names a clause whenever its Matches is non-null (NamedMatches.java:105-110). The UnifiedHighlighter uses Weight#matches by default (DEFAULT_ENABLE_WEIGHT_MATCHES = true, UnifiedHighlighter.java:110).
  6. Why conjunctions hide it. Composite sources that can drop their children's intervals check for a first interval before returning: IntervalMatches.asMatches (IntervalMatches.java:34-36; used by maxwidth/maxgaps, extend, OffsetIntervalsSource behind before/after, and the not* sources when the subtrahend is present), ConjunctionIntervalsSource.java:86-88, MinimizingConjunctionIntervalsSource.java:72-74, MinimumShouldMatchIntervalsSource.java:88-89, and core's PhraseWeight (PhraseWeight.java:115-117); on main, maxwidth(1, PF(dog)), extend(PF(dog), 0, 0) and notContaining(PF(dog), term(dog)) give null Matches for the non-hit. This leaf, the only leaf source that filters positions, can drop every position but does not check. The iterator skeleton was copied from TermIntervalsSource.matches (TermIntervalsSource.java:164-194), where laziness is safe because every position of a plain term is an interval.

The executed shapes

A plain main() probe without the test framework (only lucene-core and lucene-queries, production default codec Lucene104); doc 0 = big|adj dog|verb, doc 1 = big|adj dog|noun (the reverse of the step-by-step example's numbering); PF(dog) = "dog with payload noun"; main 645d4fe14a20, assertions on and off:

queryhits (correct)Weight#matches(doc 0) expectedactual
IntervalQuery(f, PF(dog))doc 1nullnon-null, 0 positions
+f:big -IntervalQuery(f, PF(dog))doc 0non-nullnull
IntervalQuery(f, or(PF(dog), term(cat)))doc 1nullnon-null, 0 positions

Also executed:

  • Highlighting: +f:red -IntervalQuery(f, PF(fox)) over "red fox" (fox a verb) and "red cat", offsets in the postings, UnifiedHighlighter builder defaults: expected <b>red</b> fox and <b>red</b> cat, got red fox and <b>red</b> cat (main 645d4fe14a20, 10.5.2, 9.12.3).
  • minimumShouldMatch: (f:red PF(fox))~2 hits {a, c} but has Matches for {a, b, c}, where b has fox only as a verb (main ea57360912ed, 10.5.2, 9.12.3).
  • Monitor: for a stored IntervalQuery(f, PF(dog)) and the document big|adj dog|verb, QueryMatch.SIMPLE_MATCHER reports 0 matches and HighlightsMatch.MATCHER 1; the same as a required clause, inside or(), in a two-document batch and with Presearcher.NO_FILTERING; as a MUST_NOT clause a real match is missed. NamedMatches gives [big, dogAsNoun] instead of [big] for a hit.
  • Lucene's own oracle: CheckHits.checkMatches fails on the lone query and on the MUST_NOT query.
  • Controls: maxwidth(1, PF(fox)) returns null Matches for the non-hit; a document with an accepted payload gets correct Matches.
Exposure and likelihood224 words
  • What goes wrong. For any document whose term occurs only with rejected payloads, the very case a payload filter exists for: false Matches for the lone source, or(), a required clause and SHOULD with minimumShouldMatch; lost Matches, and so lost highlights, for the hits of a query that excludes the source with MUST_NOT; false and missed matches in monitor's HighlightsMatch.MATCHER; a wrong clause name from NamedMatches.
  • Silent, narrow. No exception or warning. Hits, counts, scores and explain() are correct, and nothing is written to the index.
  • No special setup. Default IndexWriterConfig, codec and searcher; the plain-Java probe fails with the production default codec and no test framework.
  • Who is exposed. The payload-filter overload is opt-in and Java-only. LUCENE-8694 describes the source as "the intervals equivalent of SpanPayloadCheckQuery", and open issue #16820 proposes deprecating span queries in favour of intervals, so span-payload users may move to it.
  • Highlighter note. Without offsets in the postings, the UnifiedHighlighter re-analyses the text into a MemoryIndex without payloads, and payload-filtered interval queries then fail with a separate bug's IllegalArgumentException ("no indexed payloads") before this one shows.
  • Likelihood: rare. A visible symptom needs a Matches consumer, and the public searches found no report since the API shipped in 2019. The intervals package "contains experimental classes" (package-info.java:21), and Weight#matches is @lucene.experimental (Weight.java:76).

Minimal repro

One JUnit test in lucene/queries, public API only: one document with the single token dog (payload verb), the default IndexWriterConfig, a plain IndexSearcher. It checks that the query has 0 hits, then that Weight#matches for that document is null. Deterministic; fails with assertions on and off.

TestPayloadFilteredIntervalsMatches.java (1 test)58 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.queries.intervals;

import org.apache.lucene.document.Document;
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.Query;
import org.apache.lucene.search.ScoreMode;
import org.apache.lucene.search.Weight;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.CannedTokenStream;
import org.apache.lucene.tests.analysis.Token;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestPayloadFilteredIntervalsMatches extends LuceneTestCase {

  public void testNoMatchesWhenFilterRejectsEveryPosition() throws Exception {
    Token dog = new Token("dog", 0, 3);
    dog.setPayload(new BytesRef("verb"));
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new TextField("f", new CannedTokenStream(dog)));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query query = new IntervalQuery("f", Intervals.term("dog", new BytesRef("noun")::equals));
        assertEquals(0, searcher.count(query));
        Weight weight =
            searcher.createWeight(searcher.rewrite(query), ScoreMode.COMPLETE_NO_SCORES, 1f);
        // expected: null Matches for doc 0, which the query does not match; actual: non-null
        assertNull(
            "Weight#matches for non-matching doc 0", weight.matches(reader.leaves().get(0), 0));
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or a later commit where PayloadFilteredTermIntervalsSource.java is unchanged)
cp $REPRO/TestPayloadFilteredIntervalsMatches.java lucene/queries/src/test/org/apache/lucene/queries/intervals/
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestPayloadFilteredIntervalsMatches -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
Weight#matches returns null for doc 0: the query does not match it (count is 0).
Observed
Weight#matches for non-matching doc 0 expected null, but was:<org.apache.lucene.search.MatchesUtils$3@...> on main and 10.6.0 (assertions on and off) and on every release from 8.1.0.

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

More files, environment, full output152 words

The token is built with the test framework's Token and CannedTokenStream, so only the queries module is needed. assertEquals(0, searcher.count(query)) is the precondition (the document is not a hit); the failing line is the assertNull. It fails the same way with seeds DEADBEEF and 5EED1234, and in 20 of 20 iterations with a third seed; a plain-Java probe without the test framework shows the same result with the production default codec.

The file fails unchanged on 9.1.0 to 10.6.0; releases before 9.1.0 need only the old test-framework import paths (and, for 8.1.x, the sandbox package org.apache.lucene.search.intervals).

Environment

lucenemain 645d4fe14a20: affected; also every release jar from 8.1.0 to 10.6.0 with this test (8.0.0: API absent)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestPayloadFilteredIntervalsMatches > testNoMatchesWhenFilterRejectsEveryPosition FAILED
    java.lang.AssertionError: Weight#matches for non-matching doc 0 expected null, but was:<org.apache.lucene.search.MatchesUtils$3@6fff5f99>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E83CA871939DB2FA]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotNull(Assert.java:756)
        at junit@4.13.2/org.junit.Assert.assertNull(Assert.java:738)
        at org.apache.lucene.queries.intervals.TestPayloadFilteredIntervalsMatches.testNoMatchesWhenFilterRejectsEveryPosition(TestPayloadFilteredIntervalsMatches.java:53)

Why it looks unintended

  • Both javadocs reserve null for this case: IntervalsSource#matches when "no intervals exist in the given document", Weight#matches when "the document does not match the parent query".
  • Lucene's own oracles (CheckHits.checkMatches, the TestIntervals null check) fail on it.
  • Composite sources that can drop intervals check for a first one; this leaf, the only leaf that filters positions, does not.
The full argument261 words
  • Two javadoc contracts. IntervalsSource#matches: "Returns null if no intervals exist in the given document and field" (IntervalsSource.java:47-57). Weight#matches: "Returns Matches for a specific document, or null if the document does not match the parent query" (Weight.java:67-78). Matches#getMatches: "or null if there are no matches in that field" (Matches.java:35-37). The factory defines the source's intervals as the term's positions "filtered by the value of the term's payload at each position" (Intervals.java:78-92).
  • Older than the class. Both javadoc sentences were already in place at the introducing commit 5ca25249274.
  • Test oracles. CheckHits.MatchesAsserter asserts "Unexpected non-null Matches object in non-matching doc" (CheckHits.java:640-682), and TestIntervals.checkIntervals asserts "Expected null matches iterator on doc" (TestIntervals.java:180). Both fail on this source.
  • Siblings and scorer. Every composite source that can drop its children's intervals, and core's PhraseWeight, return null when there is no first match; pass-through wrappers (or(), fixField, the not* sources when the subtrahend is absent from the document) cannot drop intervals, hand the child's iterator on unchanged and rely on the leaf's null. This is the only leaf source that filters positions, so the only one that can drop every position, and the source's own scorer rejects the document.
  • Why tests missed it. TestPayloadFilteredInterval, the only upstream test of the class, calls matches() only on a document with accepted positions (TestPayloadFilteredInterval.java:79-87), and no other test in the tree uses the payload-filter overload. The LUCENE-8694 issue never mentions Matches, and no CHANGES entry weakens the contract.
The devil's-advocate check136 words

The strongest case for "intended": IntervalIterator says "it is possible for a document to return NO_MORE_INTERVALS on the first call to nextInterval()" (IntervalIterator.java:35-36), so empty iterators are a known state; Weight#matches and Matches are @lucene.experimental; the document does contain the term; and a lazy iterator avoids reading positions up front.

Why it fails: that note is about the approximation the scorer then filters, while IntervalsSource#matches reserves null for exactly this case; "experimental" warns about API changes, not a waived return value; MatchesUtils.forField and BooleanWeight.matches read non-null as "hit", so the empty iterator changes other queries' answers; and the eager check reads no more positions than the scorer already reads to decide the hit. Verdict: not intended, a defect (the independent reproduction, the cross-model review, the intended-behaviour check, the adjudication and the tie-break).

Suggested fix

Recommended Check for a first accepted position with IntervalMatches.asMatches (+6/-1)

In matches(field, ctx, doc), wrap the lazy iterator with the package's own IntervalMatches.asMatches(IntervalMatches.wrapMatches(mi, doc), mi, doc), as FilteredIntervalsSource does: it returns null when no position passes the filter. The repro, every earlier test of this bug and all 513 lucene/queries tests pass.

    IntervalMatchesIterator mi = matches(te, doc);
    if (mi == null) {
      return null;
    }
    // the payload filter is applied lazily in next(): return null if it rejects every position
    return IntervalMatches.asMatches(IntervalMatches.wrapMatches(mi, doc), mi, doc);
All options and trade-offs3 options · 594 words

Two fixes were built and run through gradle in a scratch worktree of 645d4fe14a20. Both return null from matches when the filter rejects every position and stay lazy after the first accepted position. The asMatches fix is smaller and reuses an existing idiom of the package; the look-ahead fix, the hunt's, keeps the check inside the leaf.

Check for a first accepted position with IntervalMatches.asMatches (+6/-1) (validated by the checks) (recommended)

In PayloadFilteredTermIntervalsSource.matches(String, LeafReaderContext, int), replace return matches(te, doc); (PayloadFilteredTermIntervalsSource.java:173) with the lines below. IntervalMatches.asMatches advances the wrapped iterator to the first interval and returns null when there is none (IntervalMatches.java:26-36); FilteredIntervalsSource.matches (FilteredIntervalsSource.java:111-125) and OffsetIntervalsSource.matches (OffsetIntervalsSource.java:137-144) use the same pair of helpers.

    IntervalMatchesIterator mi = matches(te, doc);
    if (mi == null) {
      return null;
    }
    // the payload filter is applied lazily in next(): return null if it rejects every position
    return IntervalMatches.asMatches(IntervalMatches.wrapMatches(mi, doc), mi, doc);

Trade-offs

  • Positions are now read eagerly up to the first accepted one: the same work the scorer's two-phase check does to decide the hit. For a document with only rejected positions it reads all of them, as the scorer does.
  • Two small wrapper objects per matches call: the IntervalIterator from wrapMatches and the iterator that asMatches returns (IntervalMatches.java:37, :99-100).
  • The wrapper's getQuery() still delegates to the leaf's iterator, which throws UnsupportedOperationException (a separate issue, LFV-463, that neither fix changes); IntervalQuery overrides getQuery() itself (IntervalQuery.java:179-183).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the repro fails on stock and passes with the fix, assertions on and off.
  • org.apache.lucene.queries.intervals.* (including the repro): 118 tests, 0 failures.
  • The whole :lucene:queries:test: 513 tests, 5 skipped, 0 failures.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of :lucene:queries pass with the fix and the repro test.
  • Shadowed into the 645d4fe14a20 jars: the independent reproduction's 7 tests, the highlight test, the 9 monitor and NamedMatches tests and the tie-break probe all pass.
  • The finder's test and the intended-behaviour check's 3 oracle tests, shadowed with the fix into the 645d4fe14a20 jars, assertions off: fail on stock and pass with the fix (re-run by the fact check).

Look ahead inside the leaf's iterator (the hunt's fix, +24/-1) (validated by the checks)

In the private matches(te, doc), read positions until the first one whose payload passes the filter before building the iterator, return null if there is none, and let the iterator report that position first and then continue lazily. Bounded memory: an earlier draft buffered all positions, and the cross-model review's O(freq) note replaced it with this one-position look-ahead.

Trade-offs

No wrapper object and the check stays inside the leaf, but more code to review; the first position is carried in two extra fields. The same eager read as the asMatches fix.

What the checks validated

  • Gradle, the same scratch worktree: the repro passes; org.apache.lucene.queries.intervals.*: 118 tests, 0 failures; checkGoogleJavaFormat and ecjLintMain pass.
  • Earlier: the finder's, the independent reproduction's, the intended-behaviour check's, the adjudication's, the highlight and the monitor/NamedMatches tests pass with it, and so do the 117 upstream intervals tests (main ea57360912ed); the minimal repro and the tie-break tests pass with it on 645d4fe14a20.

Not recommended: make the consumers check for an empty iterator

For example in MatchesUtils.forField or DisjunctionIntervalsSource.matches. Not built.

Trade-offs

forField serves many queries and would have to consume the first match and replay it; fixing or() alone leaves the lone source and BooleanQuery wrong. The source is the one place that breaks the contract.

Downstream note

Not a Lucene security issue, but code that uses Weight#matches to decide whether a stored query matched, such as the monitor module's HighlightsMatch.MATCHER, treats a payload restriction as satisfied when it is not (executed on main).

How it was found

A hunt over the intervals query builders cross-checks Weight#matches against the scorer's hits. Its harnesses do not cover payload filters, so an AI tool applied the check by hand in a small probe, saw Matches for a non-hit and none for a MUST_NOT hit, and wrote a public-API JUnit. Testing, not model checking.

The method in full122 words

Method: non-formal (a hunt unit, intervals builder and multi-term sources).

  • The unit's harnesses cross-check that Weight#matches is non-null if and only if the scorer matched. They do not cover payload-filtered leaves, so the finder applied the check by hand in a probe: two documents big|adj dog|noun and big|adj dog|verb, the payload-filtered term under 12 parent operators, plus +big -<source>.
  • The probe showed Matches for the non-matching doc 1 of the lone source and null Matches for the hit doc 1 of +big -<source>; hit counts were correct under all 12 operators.
  • The finder, an AI tool, then wrote the public-API JUnit; the one-test repro was cut from it on 2026-10-29. This is testing, not a bounded-exhaustive or model-checking run.

Prior reports, history and checks

Prior reports

No prior report: 159 recorded searches over GitHub, the mailing lists, JIRA, downstream trackers and the web, plus the files of all 410 open PRs (2026-10-31), repeating a 143-query search of 2026-10-26. Closest: #10479, empty Matches for documents that do match, the opposite case.

Searches4 searches

Verdict new; none of the hits is the same bug. Related, not reports of this bug: #9740 / LUCENE-8694 (origin of the class; Matches never discussed), #12827 (the equals fix in the same class, its last change), #10074 / LUCENE-9031 (getQuery() threw when highlighting interval queries), #10479 / LUCENE-9439 (empty iterators for matching documents on fields without positions), #11113 / LUCENE-10075 (open; NPE when highlighting overlapping wildcard intervals), #9468 / LUCENE-8422 and #9276 / LUCENE-8229 (where the null-when-no-match convention comes from). Gaps: keyword search only; one web query was throttled; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and comments2026-10-31the scripted prior-report search (16 issue/PR, 3 commit and 2 downstream queries) and 89 GraphQL searches over apache/lucene, the archived lucene-solr repository, Elasticsearch, OpenSearch and Solr: class and method names, payload filter, Intervals.term payload, empty MatchesIterator, Weight#matches with MUST_NOT or minimumShouldMatch, WEIGHT_MATCHES highlighting, monitor HighlightsMatch, LUCENE-8694only related items; none the same bug
Open PRs2026-10-31the changed files of all 410 open apache/lucene PRsnone touches PayloadFilteredTermIntervalsSource.java
Lists, JIRA, web2026-10-31dev@, java-user@, issues@, commits@ (20 tool and 16 direct queries), JIRA archive (4 queries), 7 web queries (6 run)no report
Earlier search (hunt follow-up)2026-10-26143 recorded queries over the same sourcesverdict new; 17 hits assessed, none the same bug

History

Introduced with the class by LUCENE-8694 (#9740, 2019) and first released in 8.1.0. The repro fails on all 60 releases from 8.1.0 to 10.6.0 and on main; 8.0.0 has no such API. Not fixed upstream.

Versions tested5 rows

Introduced by LUCENE-8694 "IntervalsSource that filters by payload" (#9740; romseygeek, authored 2019-02-15, committed 2019-03-18 from the JIRA patch, no GitHub PR): main 5ca25249274b, branch_8x 13aac3becde7. It created PayloadFilteredTermIntervalsSource with the lazy matches(); IntervalQuery's Weight#matches already treated any non-null iterator as a hit, so the wrong result exists from the first commit. Later changes to the file (LUCENE-8477, the LUCENE-8856 module move, LUCENE-9099 return types, LUCENE-9570 reformatting, #12827 equals) leave the emptiness logic alone. Upstream main 2f424fd428da (2026-10-30): the file's last commit is still #12827.

Versions and branches executed, with results
Version or commitVerdictResult
8.0.0 no such APIno payload-filter overload of Intervals.term (compile error)
8.1.0 first affectedthe test fails (sandbox package), assertions on and off
8.1.1 to 10.5.2 (58 releases) affectedthe test fails with the same assertion
10.6.0 affectedthe test fails, assertions on and off
main 645d4fe14a20 affectedfails through gradle and the standalone runner, assertions on and off; passes with either fix

Independent checks

An independent reproducer, a reading-only review by another model family (OpenAI GPT-5.6), a devil's advocate and an adjudication rated it real; the adjudication confirmed it with high confidence. A 2026-10-31 tie-break re-ran everything on 645d4fe14a20, including a plain-Java probe without the test framework.

Checks and verdicts321 words

No cross-model review that could execute code exists: the reviews by OpenAI GPT-5.6 (refute and intended lens on 2026-10-26, refute again on 2026-10-31) could all only read the code, and two more attempts on 2026-10-31 that could have run code failed to start. Their "not real" verdicts are procedural; their evidence agrees on the root cause.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateProbe, then a public-API JUnit that fails on main (assertions on and off), 10.5.2, 9.12.3 and 9.1.0; passes with the fix.
Refute and intended reviews (OpenAI GPT-5.6, reading only) not real (low, procedural) / real (medium)Both agree on the root cause and that the inputs are legal; the refute review's note on O(freq) buffering led to the bounded look-ahead fix. A separate run re-ran the repro each time and it failed.
Independent reproducer realOwn JUnit outside org.apache.lucene fails 6 of 7 on main, 10.5.2 and 9.12.3 (assertions on and off), including CheckHits.checkMatches; added minimumShouldMatch, or() and the highlight loss; 7 of 7 pass with the fix.
Devil's advocate real (high)Built the strongest intended case and rejected it; its test with Lucene's own oracles fails 3 of 3 and passes with the fix.
Adjudication confirmed (high)Re-ran the independent reproduction's JUnit (6 of 7 fail), the finder's with assertions off (fails) and the independent reproduction's with the fix (7 of 7 pass).
Credibility audit keepRe-ran the finder's JUnit (assertions on and off) and the independent reproduction's highlight test with a fresh seed; rated it a Matches-contract bug, not a wrong-search-results bug.
Reproducer and tie-break on 645d4fe14a20 real (high)The one-test repro fails on main and 10.6.0 (assertions on and off) and 10.5.2; a plain-Java probe with the production codec shows the lone, MUST_NOT and or() shapes; highlight, monitor and NamedMatches tests fail and pass with the fix.

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

Finding record

LFV-464, snapshot of 2026-11-06

Registry title
Intervals.term(term, payloadFilter) returns non-null Weight#matches for documents in which the filter rejects every position of the term (documents the query does not match), so a BooleanQuery that excludes such a clause has no Matches for its own hits
Area
queries / intervals (PayloadFilteredTermIntervalsSource Matches API; Weight#matches consumers)
Priority
Low, rank 850: The same rarely used overload reports Matches for documents whose payloads are all rejected, so MUST_NOT hits lose their highlights and monitor's HighlightsMatch matcher reports false matches, but hits, counts and scores stay correct.
Impact class
silent wrong results
Impact note
Only applications that call the payload-filter overload Intervals.term(String|BytesRef, Predicate<BytesRef>) (no Lucene query parser and, by public code search, no Elasticsearch, OpenSearch or Solr builder uses it) and that read Weight#matches are hit, with the default IndexWriterConfig, codec and searcher, -ea and -da, at any index size and in every release since 8.1.0: for a document in which the term occurs only with payloads the filter rejects, the source alone or inside Intervals.or reports Matches although the document is not a hit, a BooleanQuery that excludes it with MUST_NOT returns null Matches for such hits (so the UnifiedHighlighter in its default WEIGHT_MATCHES mode returns them unhighlighted when offsets are in the postings; executed on 9.12.3, 10.5.2 and main), a SHOULD clause of this kind counts towards minimumShouldMatch in Weight#matches, and Lucene monitor's HighlightsMatch.MATCHER and NamedMatches report matches the query does not have (executed); search hits, counts, scores and explain() are correct, conjunction-style wrappers (ordered, unordered, maxwidth, containing) hide the defect, and nothing is written to the index.
Summary
You index text with payloads, for example 'big|adj dog|noun' (doc 0) and 'big|adj dog|verb' (doc 1) through DelimitedPayloadTokenFilter, and query IntervalQuery(f, Intervals.term("dog", payload is 'noun')), which correctly hits only doc 0. You expect Weight#matches to be null for doc 1, which the query does not match, and non-null for doc 1 as a hit of +f:big -<that query>. Instead the payload-filtered source returns an empty but non-null MatchesIterator for every document that contains 'dog' at all, so Weight#matches reports a match for the non-hit and the BooleanQuery returns null Matches for its real hit (the default UnifiedHighlighter then leaves that hit unhighlighted, and Lucene monitor's HighlightsMatch matcher reports the non-hit as a match), while hits, counts and scores stay correct.
Introduced
5ca25249274bd4232b0faa049814d2b3b192abdb (main; branch_8x backport 13aac3becde7770c8c4e6b33102432f7dee99a10), LUCENE-8694 'IntervalsSource that filters by payload' (now GitHub issue #9740) by romseygeek, committed from the JIRA patch (no GitHub PR): it added PayloadFilteredTermIntervalsSource with a private matches(TermsEnum, int) that returns null only when pe.advance(doc) != doc and applies the payload filter lazily in next(), a shape copied from TermIntervalsSource.matches (LUCENE-8422, 1acfca5ebcc), where it is correct because every position of the term is an interval; IntervalQuery's Weight#matches already handed any non-null iterator to MatchesUtils.forField as a hit, so the wrong result existed from the first commit [read: git blame -C -C and git show at 5ca25249274 and at 8.1.0 in the history clone; exec: 8.0.0 has no such API, 8.1.0 fails]; later commits to the file (f1782d0dd11 LUCENE-8477, 114db75abc4 / 46cc24d77ec LUCENE-8856 module move, 7c1ba1aebee LUCENE-9099, 8ef6a0da568 LUCENE-9570 reformat, 4991e3a1f47 #12827 equals) leave the emptiness logic unchanged [read] (2019-03-18 (commit date on main and branch_8x; author date 2019-02-15))
First affected version
8.1.0 [exec] (sandbox module, package org.apache.lucene.search.intervals; queries module from 8.2.0 [exec], LUCENE-8856); 8.0.0 has no Intervals.term(term, payloadFilter) [exec: compile error]
Affected versions
every release from 8.1.0 through 10.5.2 (all 59 releases) and main ea57360912ed [exec: the finder's JUnit, with only its package line and LuceneTestCase import adapted for 8.1.x and for releases before 9.1.0, fails with both symptoms on every one of them with -ea, and also with -da on 8.1.0, 9.1.0, 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails 6 of 7 on main, 10.5.2 and 9.12.3 (-ea and -da); the intended-behaviour check's oracle JUnit fails 3 of 3 on main (-ea, -da) and 10.5.2 (-da); the follow-up security probe fails 7 of 9 on main (-ea, -da) and 10.5.2]; not affected: 8.0.0 and earlier (API absent) [exec: 8.0.0]; not fixed upstream: on upstream main 49483e82b357 and on branch_10x and branch_10_6 the file was last changed by #12827 (2023-11-21) [read, 2026-10-26], and 10.6.0 (released 2026-10-29) fails too [exec: the minimal repro on the 10.6.0 jars, -ea and -da, in the 2026-10-31 tie-break and fact check]; all runs on aarch64 with JDK 25.0.3
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 adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26; the prior-report record, verdict new, plus the finder's earlier runs, all read-only: 143 recorded queries, namely scripted prior-report searches (16 GitHub issue/PR, 3 commit, 2 downstream, 40 mailing-list over dev@, java-user@, issues@ and commits@, 8 JIRA-archive and 13 planned web queries, 11 of them run), 49 GitHub GraphQL searches (43 over apache/lucene issues, PRs and comments, two of them GitHub-wide, and 6 over Elasticsearch, OpenSearch, Solr and the archived lucene-solr repository), 5 REST gh searches, 4 direct JIRA queries, the changed-file lists of all 408 open apache/lucene PRs (none touches PayloadFilteredTermIntervalsSource.java) and local git history; 17 hits assessed, none the same bug). Not fixed upstream: on GitHub main 49483e82b357 (2026-10-26) and on branch_10x and branch_10_6 the file was last changed by #12827, and matches(TermsEnum, int) is unchanged since LUCENE-8694 apart from the package move, the LUCENE-9099 return-type change and reformatting. Related, not reports of this bug: #9740 / LUCENE-8694 (origin of the class and its lazy matches() shape; never discussed), #10074 / LUCENE-9031 (getQuery() UnsupportedOperationException when highlighting interval queries; a different method and symptom), #12827 (2023, merged; the equals cast fix in the same class), #10479 / LUCENE-9439 (empty iterators for matching documents on fields without positions: the opposite case), #9468 / LUCENE-8422 (introduced interval Matches and the null-when-no-interval convention this leaf breaks), #10141 / LUCENE-9099 (added the intervals-vs-matches checks of TestIntervals, never applied to payload-filtered sources), #9276 / LUCENE-8229 (origin of Weight#matches and its null contract).
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle (assertions on) and on the standalone runner (assertions on and off), seed DEADBEEF; the same test on all 60 releases from 8.1.0 to 10.6.0 (fails) and 8.0.0 (API absent). Downstream effects (MUST_NOT, or(), highlighting, monitor, NamedMatches) re-run on 645d4fe14a20 with assertions off on 2026-10-31. The hunt and its verifiers ran on main ea57360912ed; PayloadFilteredTermIntervalsSource.java is the same blob there, at 645d4fe14a20, at the 10.6.0 tag and on upstream main 2f424fd428da (2026-10-30). Both fixes were run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-31.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:150-174 (at ea57360912ed) (matches(field, ctx, doc): hasPositions/hasPayloads checks, seekExact, then matches(te, doc)), :177-179 (visit consumes the plain Term), :181-212 (matches(te, doc): returns null only if pe.advance(doc) != doc at :183-185; the iterator applies filter.test only inside next() at :202-211)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92 (at ea57360912ed) (public factories term(String|BytesRef, Predicate<BytesRef>): 'intervals for a term, filtered by the value of the term's payload at each position')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:47-57 (at ea57360912ed) (matches javadoc: 'Returns null if no intervals exist in the given document and field')
  • lucene/core/src/java/org/apache/lucene/search/Weight.java:67-78 (at ea57360912ed) (matches javadoc: 'Returns Matches for a specific document, or null if the document does not match the parent query'; @lucene.experimental :76); lucene/core/src/java/org/apache/lucene/search/Matches.java:31 (@lucene.experimental)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java:170-186 (at ea57360912ed) (Weight#matches: MatchesUtils.forField over intervalsSource.matches, null only if the source returns null); lucene/core/src/java/org/apache/lucene/search/MatchesUtils.java:105-116 (forField calls the supplier 'eagerly to work out if we have a hit or not'; non-null iterator = Matches)
  • lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:134-164 (at ea57360912ed) (matches: a prohibited clause with non-null Matches makes the result null :142-146; a SHOULD clause with non-null Matches counts towards minimumShouldMatch :153-162)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:86-97 (at ea57360912ed) (or() keeps every non-null sub-iterator); IntervalMatches.java:26-36 (asMatches returns null when nextInterval() == NO_MORE_INTERVALS, the convention conjunction-style sources follow)
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:640-680 (at ea57360912ed) (MatchesAsserter: 'Unexpected null Matches object in doc' :670, 'Unexpected non-null Matches object in non-matching doc' :674); lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:180 (assertNull 'Expected null matches iterator on doc'); TestPayloadFilteredInterval.java (the only upstream test of the class; checks matches only on a document with accepted positions)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:110 (at ea57360912ed) (DEFAULT_ENABLE_WEIGHT_MATCHES = true); lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:44-62 (MATCHER reports a match whenever Weight#matches is non-null, :56); lucene/monitor/src/java/org/apache/lucene/monitor/DocumentBatch.java:108 (MemoryIndex(true, true): payloads stored); lucene/core/src/java/org/apache/lucene/search/NamedMatches.java:100-113 (a named clause is reported whenever its inner Matches is non-null)
  • Lucene main ea57360912ed SECURITY.md lines 28, 38 and 50 (threat model, resource exhaustion, in-scope reports)
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92 (at 645d4fe14a20)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:117-126, (at 645d4fe14a20) :151-174, :181-212
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java:170-186; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/MatchesUtils.java:106-117; lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScorer.java:80-81
  • lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:134-164; (at 645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:86-97; lucene/monitor/src/java/org/apache/lucene/monitor/HighlightsMatch.java:44-62; lucene/core/src/java/org/apache/lucene/search/NamedMatches.java:105-110; lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:110
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalMatches.java:26-36; (at 645d4fe14a20) MinimizingConjunctionIntervalsSource.java:69-75; MinimumShouldMatchIntervalsSource.java:85-91; TermIntervalsSource.java:164-194; lucene/core/src/java/org/apache/lucene/search/PhraseWeight.java:110-117
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FixedFieldIntervalsSource.java:44-47; (at 645d4fe14a20) DifferenceIntervalsSource.java:52-65; ConjunctionIntervalsSource.java:71-90; Intervals.java:379-391, :455-457, :467-497, :521-549
Who is affected
  • gh issue view 16820 --repo apache/lucene (read-only, 2026-10-31: 'SpanQueries ... have been superseded by IntervalQueries. We should deprecate them')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/package-info.java:21; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/Weight.java:76
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalsSource.java:47-57; (at 645d4fe14a20) Intervals.java:78-92; IntervalIterator.java:35-36
  • lucene/core/src/java/org/apache/lucene/search/Weight.java:67-78; (at 645d4fe14a20) Matches.java:31-37
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:640-682; (at 645d4fe14a20) lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervals.java:180; TestPayloadFilteredInterval.java:79-87
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:86-104; (at 645d4fe14a20) FixedFieldIntervalsSource.java:44-47; DifferenceIntervalsSource.java:52-65; ConjunctionIntervalsSource.java:71-90; ExtendedIntervalsSource.java:117; FilteredIntervalsSource.java:124; RepeatingIntervalsSource.java:237-248
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalMatches.java:26-36, (at 645d4fe14a20) :99-190; FilteredIntervalsSource.java:111-125; OffsetIntervalsSource.java:137-144; IntervalQuery.java:179-183; PayloadFilteredTermIntervalsSource.java:173, :239-242
Diagrams
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:181-212; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:142-146

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

Repro files

About the diagrams

A picture of the two-document example and the point where matches hands out its iterator before the payload filter runs, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.