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.
- A payload is a few bytes stored with one occurrence of a term, for example a part-of-speech tag:
dog|nounordog|verb. Intervals.term("dog", filter)matchesdogonly at positions whose payload passesfilter. A document whose everydogis rejected does not match.Weight#matches(leaf, doc)returns where a query matched in one document, ornullwhen the document does not match. Highlighters, the monitor module and named-clause reporting use it, andBooleanQuerybuilds its answer from its clauses' answers.
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.- Index two documents whose field
freads "big dog": in doc 0dogcarries payloadnoun, in doc 1 payloadverb. SearchIntervalQuery("f", Intervals.term("dog", payload is noun)): 1 hit, doc 0, correctly. - Ask
Weight#matcheswhere that query matched in doc 1. You expectnull: its javadoc says null "if the document does not match the parent query". - You get a non-null
Matcheswith 0 positions. The source hands out an iterator for every document that containsdogand applies the payload filter only later, when the iterator is read. - Code that trusts it goes wrong.
+f:big -<that query>correctly hits doc 1, butBooleanQuerysees its excluded clause "match" and returns no Matches for doc 1, so the default UnifiedHighlighter (offsets indexed) returns it as plainbig dog. Monitor'sHighlightsMatch.MATCHERreports the stored query as matching doc 1. - Hits, counts, scores and
explain()stay correct; onlyWeight#matchesand what is built on it is wrong, silently, on every release from 8.1.0 (2019) to 10.6.0 and on main.
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 factory.
Intervals.term(String, Predicate<BytesRef>)(Intervals.java:82-84) delegates to theBytesRefoverload, which returnsnew PayloadFilteredTermIntervalsSource(term, payloadFilter)(:90-92). matcheshands out an iterator whenever the term is in the document.matches(field, ctx, doc)(PayloadFilteredTermIntervalsSource.java:151-174) seeks the term and calls the privatematches(te, doc)(:181), whose only null return isif (pe.advance(doc) != doc) { return null; }(:183-185). The iterator starts withupto = pe.freq()(:198) and applies the filter only innext():} while (filter.test(pe.getPayload()) == false);(:202-212). If every position is rejected, the firstnext()returns false, but the iterator is already out.- Non-null means "hit".
IntervalQuery'sWeight#matchesreturns null only when the source does (IntervalQuery.java:170-186), andMatchesUtils.forFieldcalls the supplier "eagerly to work out if we have a hit or not", returning aMatchesfor any non-null iterator (MatchesUtils.java:106-117). - The scorer is right. The scoring iterator applies the filter in
nextInterval()(PayloadFilteredTermIntervalsSource.java:117-126) andIntervalScorer's two-phase check rejects a document with no first interval (IntervalScorer.java:80-81), so hits, counts and scores are correct. - Where it spreads.
BooleanWeight.matchesreturns 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 towardsminimumShouldMatch(:153-162).DisjunctionIntervalsSource.matcheskeeps 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 thenot*sources (notContaining,notContainedBy,nonOverlapping,notWithin) when the subtrahend is absent from the document (DifferenceIntervalsSource.java:58-61); on main,fixField(f, PF(dog))andnotContaining(PF(dog), term(cat))give non-null Matches for the non-hit. Monitor'sHighlightsMatch.MATCHERadds a match wheneverWeight#matchesis non-null (HighlightsMatch.java:44-62);NamedMatchesnames a clause whenever its Matches is non-null (NamedMatches.java:105-110). TheUnifiedHighlighterusesWeight#matchesby default (DEFAULT_ENABLE_WEIGHT_MATCHES = true,UnifiedHighlighter.java:110). - 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 bymaxwidth/maxgaps,extend,OffsetIntervalsSourcebehindbefore/after, and thenot*sources when the subtrahend is present),ConjunctionIntervalsSource.java:86-88,MinimizingConjunctionIntervalsSource.java:72-74,MinimumShouldMatchIntervalsSource.java:88-89, and core'sPhraseWeight(PhraseWeight.java:115-117); on main,maxwidth(1, PF(dog)),extend(PF(dog), 0, 0)andnotContaining(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 fromTermIntervalsSource.matches(TermIntervalsSource.java:164-194), where laziness is safe because every position of a plain term is an interval.
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:
| query | hits (correct) | Weight#matches(doc 0) expected | actual |
|---|---|---|---|
IntervalQuery(f, PF(dog)) | doc 1 | null | non-null, 0 positions |
+f:big -IntervalQuery(f, PF(dog)) | doc 0 | non-null | null |
IntervalQuery(f, or(PF(dog), term(cat))) | doc 1 | null | non-null, 0 positions |
Also executed:
- Highlighting:
+f:red -IntervalQuery(f, PF(fox))over "red fox" (foxa verb) and "red cat", offsets in the postings,UnifiedHighlighterbuilder defaults: expected<b>red</b> foxand<b>red</b> cat, gotred foxand<b>red</b> cat(main645d4fe14a20, 10.5.2, 9.12.3). - minimumShouldMatch:
(f:red PF(fox))~2hits {a, c} but has Matches for {a, b, c}, where b hasfoxonly as a verb (mainea57360912ed, 10.5.2, 9.12.3). - Monitor: for a stored
IntervalQuery(f, PF(dog))and the documentbig|adj dog|verb,QueryMatch.SIMPLE_MATCHERreports 0 matches andHighlightsMatch.MATCHER1; the same as a required clause, insideor(), in a two-document batch and withPresearcher.NO_FILTERING; as a MUST_NOT clause a real match is missed.NamedMatchesgives[big, dogAsNoun]instead of[big]for a hit. - Lucene's own oracle:
CheckHits.checkMatchesfails 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 withminimumShouldMatch; 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'sHighlightsMatch.MATCHER; a wrong clause name fromNamedMatches. - 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
UnifiedHighlighterre-analyses the text into aMemoryIndexwithout payloads, and payload-filtered interval queries then fail with a separate bug'sIllegalArgumentException("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), andWeight#matchesis@lucene.experimental(Weight.java:76).
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.
/*
* 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));
}
}
}
}
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#matchesreturns null for doc 0: the query does not match it (countis 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.
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).
| lucene | main 645d4fe14a20: affected; also every release jar from 8.1.0 to 10.6.0 with this test (8.0.0: API absent) |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)- Both javadocs reserve
nullfor this case:IntervalsSource#matcheswhen "no intervals exist in the given document",Weight#matcheswhen "the document does not match the parent query". - Lucene's own oracles (
CheckHits.checkMatches, theTestIntervalsnull 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: "Returnsnullif no intervals exist in the given document and field" (IntervalsSource.java:47-57).Weight#matches: "Returns Matches for a specific document, ornullif the document does not match the parent query" (Weight.java:67-78).Matches#getMatches: "ornullif 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.MatchesAsserterasserts "Unexpected non-null Matches object in non-matching doc" (CheckHits.java:640-682), andTestIntervals.checkIntervalsasserts "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, thenot*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, callsmatches()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).
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
matchescall: theIntervalIteratorfromwrapMatchesand the iterator thatasMatchesreturns (IntervalMatches.java:37,:99-100). - The wrapper's
getQuery()still delegates to the leaf's iterator, which throwsUnsupportedOperationException(a separate issue, LFV-463, that neither fix changes);IntervalQueryoverridesgetQuery()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,forbiddenApisMainandforbiddenApisTestof:lucene:queriespass with the fix and the repro test.- Shadowed into the
645d4fe14a20jars: the independent reproduction's 7 tests, the highlight test, the 9 monitor andNamedMatchestests 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
645d4fe14a20jars, assertions off: fail on stock and pass with the fix (re-run by the fact check).
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;checkGoogleJavaFormatandecjLintMainpass. - Earlier: the finder's, the independent reproduction's, the intended-behaviour check's, the adjudication's, the highlight and the monitor/
NamedMatchestests pass with it, and so do the 117 upstream intervals tests (mainea57360912ed); the minimal repro and the tie-break tests pass with it on645d4fe14a20.
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.
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).
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#matchesis 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 documentsbig|adj dog|nounandbig|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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and comments | 2026-10-31 | the 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-8694 | only related items; none the same bug |
| Open PRs | 2026-10-31 | the changed files of all 410 open apache/lucene PRs | none touches PayloadFilteredTermIntervalsSource.java |
| Lists, JIRA, web | 2026-10-31 | dev@, 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-26 | 143 recorded queries over the same sources | verdict new; 17 hits assessed, none the same bug |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.0.0 | no such API | no payload-filter overload of Intervals.term (compile error) |
| 8.1.0 | first affected | the test fails (sandbox package), assertions on and off |
| 8.1.1 to 10.5.2 (58 releases) | affected | the test fails with the same assertion |
| 10.6.0 | affected | the test fails, assertions on and off |
| main 645d4fe14a20 | affected | fails through gradle and the standalone runner, assertions on and off; passes with either fix |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Probe, 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 | real | Own 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 | keep | Re-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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 457 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,625 words
- 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 on645d4fe14a20with assertions off on 2026-10-31. The hunt and its verifiers ran on mainea57360912ed;PayloadFilteredTermIntervalsSource.javais the same blob there, at645d4fe14a20, at the 10.6.0 tag and on upstream main2f424fd428da(2026-10-30). Both fixes were run through gradle in a scratch worktree of645d4fe14a20on 2026-10-31.
- 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-212lucene/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-81lucene/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:110lucene/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-117lucene/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-36lucene/core/src/java/org/apache/lucene/search/Weight.java:67-78; (at 645d4fe14a20) Matches.java:31-37lucene/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-87lucene/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
- TestPayloadFilteredIntervalsMatches.java (1 test):
repro/LFV-464/src/org/apache/lucene/queries/intervals/TestPayloadFilteredIntervalsMatches.java(58 lines)
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.
assets/LFV-464/bug.png(457 KB)
