All findings · Lucene finding · LFV-295 · payload-filtered-intervals-equals-ignores-filter

Payload-filtered Intervals.term sources compare equal whatever their filter, so ordered, or, BooleanQuery and the query cache merge them

Intervals.term("a", filter) sources compare equal whenever the term is the same, whatever the payload filter, so duplicate removal merges different filters: ordered(a|x, a|y) finds 0 hits on a document it matches, and or, BooleanQuery and the query cache give missing or extra hits, silently. Since 9.9.0; payload-filter users only.

Summary

Priority
Low, rank 849Payload-filtered Intervals.term sources compare equal whatever their filter, so dedup and caching silently merge them, but no parser or downstream engine uses that overload, so it follows LFV-321.
Impact
silent wrong results
Since
9.9.0 (#12827, 2023); a ClassCastException beforeintroduced 2023-11-21 · about 2 years
Affected
9.9.0 to 10.6.0 and main
Status
documented
Found by
a bytecode scan of equals/hashCode and an exhaustive equality check

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Intervals.term(term, payloadFilter) matches a term only where its payload passes your filter. Lucene simplifies queries by dropping parts that equals calls the same, and this source's equals ignores the filter.

Background in 3 steps

  1. A payload is a few bytes stored with one occurrence (position) of a term, for example a part-of-speech tag or a label.
  2. Intervals.term("a", filter) matches a only at positions whose payload passes filter. Two filters on the same term can match different positions and different documents.
  3. While it builds and rewrites a query, Lucene removes duplicates by equals/hashCode: ordered and unordered turn equal neighbours into one repeated source, or keeps a set, BooleanQuery keeps FILTER and MUST_NOT clauses in sets, and the query cache looks queries up by equality.
Diagram of the one-document repro. The field holds 'a' with payload x at position 0 and 'a' with payload y at position 1. Intervals.term(a, x) and Intervals.term(a, y) match different positions, but equals and hashCode compare only the term 'a', so ordered() rewrites the pair into a repeat of the first filter and the query finds 0 hits instead of 1.
The one-document repro: two sources on the term a with different payload filters compare equal, so ordered() treats them as one repeated source and finds 0 hits instead of 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document whose field f holds the word a twice: the first a carries payload x, the second payload y. Intervals.term("a", x::equals) and Intervals.term("a", y::equals) each match it, at positions 0 and 1.
  2. Search ordered(term("a", x::equals), term("a", y::equals)), that is "an a|x followed by an a|y". You expect 1 hit.
  3. You get 0 hits and no error. equals and hashCode of the two sources look only at the term a, so they are equal, and ordered() rewrites the pair into "a|x twice", which the document does not have.
  4. Every other duplicate check merges them the same way. In a 3-document test, or() of the two filters returns 2 of the 3 matching documents, two FILTER clauses return 2 documents where 1 matches both, MUST one + MUST_NOT the other returns 0 instead of 1, and a query cache serves the first filter's hits for the second.

Who is affected

Applications that call the payload-filter overload of Intervals.term with two different filters on the same term, in one query (default settings, any index size) or behind a query cache (default on 9.9.0 to 10.6.0, opt-in on main). No Lucene query parser builds these sources, and a public code search found only one-argument Intervals.term calls in Elasticsearch, OpenSearch and Solr.

Mechanism, terms and numbers744 words

Intervals.term(String|BytesRef, Predicate<BytesRef>) returns a source that matches the term only at positions whose payload passes the filter. Its hashCode is Objects.hash(term) and its equals compares only the term, so two sources on the same term with different filters are equal, and so are the IntervalQuery objects built from them.

Lucene removes "duplicate" parts by equality while it builds and rewrites queries, so the two filters are merged silently: ordered() and unordered() turn the pair into a repeat of the first filter, Intervals.or keeps one of them, BooleanQuery collapses two FILTER (or MUST) clauses into one and rewrites MUST x + MUST_NOT y to MatchNoDocsQuery, DisjunctionMaxQuery uses the first filter for both disjuncts, and a query cache serves one filter's hits for the other. Results are wrong in both directions, with default settings and any index size; the smallest case is one document on which ordered(a|x, a|y) finds 0 hits instead of 1.

The term-only equality dates from LUCENE-8694 (8.1.0, 2019), written with a wrong cast, so up to 9.8.0 the same calls threw ClassCastException. #12827 (9.9.0) fixed the cast only, which made the wrong equality reachable. Affected: 9.9.0 to 10.6.0 and main. Only applications that use the payload-filter overload with two different filters on one term are hit; no Lucene query parser builds these sources.

The mechanism (main 645d4fe14a20)

  1. The filter decides which positions match. Intervals.term(BytesRef, Predicate<BytesRef>) returns new PayloadFilteredTermIntervalsSource(term, payloadFilter) (Intervals.java:86-92; the String overload at :78-84 delegates to it). Its interval iterator skips every position whose payload fails the filter: } while (filter.test(pe.getPayload()) == false); (PayloadFilteredTermIntervalsSource.java:117-126; the matches iterator at :202-212).
  2. Equality ignores the filter. hashCode() is Objects.hash(term) (:256-259) and equals ends in return Objects.equals(term, that.term); (:261-267). toString() is PAYLOAD_FILTERED(a) for both (:269-272), so the merge is invisible in a printed query too.
  3. ordered() turns equal neighbours into a repeat. OrderedIntervalsSource.deduplicate groups consecutive sources with current.get(0).equals(source) and replaces each group with RepeatingIntervalsSource.build(current.get(0), n) (OrderedIntervalsSource.java:29-57). ordered(a|x, a|y) becomes "two occurrences of a|x"; the document has one, so 0 hits. Repeats exist so that "multiple iterators do not match on a single term" (RepeatingIntervalsSource.java:33-41), which is right only for sources that match the same positions.
  4. The same equality is used everywhere else. unordered() counts sources in a LinkedHashMap (UnorderedIntervalsSource.java:44-57); Intervals.or collects them in a HashSet (DisjunctionIntervalsSource.java:43-68); IntervalQuery.equals/hashCode delegate to the field and source (IntervalQuery.java:134-146); BooleanQuery keeps FILTER and MUST_NOT clauses in HashSets (BooleanQuery.java:126-140), drops duplicates on rewrite (:343-362), returns MatchNoDocsQuery("MUST clause also in MUST_NOT") (:364-381), removes a FILTER equal to a MUST (:387-394), merges duplicate MUST clauses (:450-455, deduplicateClauses at :714) and duplicate SHOULD clauses only when minimumShouldMatch is at most 1 (:442-447); two SHOULD clauses with minimumShouldMatch 2 are first rewritten to two MUST clauses (:577-588) and then merged. DisjunctionMaxQuery keeps its disjuncts in a Multiset (DisjunctionMaxQuery.java:47) that keeps the first of two equal keys with a count of 2 (Multiset.java:41-66, :79-84), so its weights use the first filter twice (DisjunctionMaxQuery.java:115-117). A query cache looks entries up by equality.

Adding the filter to equals/hashCode, and nothing else, makes the repro and every other executed shape pass, so the equality is the cause.

The executed shapes

The hunt's 7-test JUnit (payload pos: N at position N; documents first = a x, second = x a, both = a a; a@0 and a@1 filter for pos: 0 and pos: 1; each filter alone returns its correct documents), on every release from 9.9.0 to 10.5.2, and on main with assertions on and off:

queryexpectedactual
a@0.equals(a@1)falsetrue
or(a@0, a@1)[both, first, second][both, first]
ordered(a@0, a@1)[both][]
FILTER a@0 + FILTER a@1[both][both, first]
MUST a@0 + MUST_NOT a@1[first][] (MatchNoDocsQuery)
a@1 through an always-cache LRUQueryCache after a@0 ran[both, second][both, first]

Also executed by the verifiers: unordered(a@0, a@1) returns nothing; two MUST clauses, and two SHOULD clauses with minimumShouldMatch 2, over-match; a DisjunctionMaxQuery of the two returns [both, first], missing second. By reading, atLeast is affected only when minShouldMatch equals the number of sources (it then delegates to unordered, Intervals.java:566-569); with fewer it builds a MinimumShouldMatchIntervalsSource, which does not de-duplicate (:577). atLeast was not executed.

Exposure and likelihood280 words
  • Silent, both directions. Missing hits (ordered, unordered, or, MUST + MUST_NOT, DisjunctionMaxQuery) and extra hits (two FILTERs, two MUSTs, SHOULD with minimumShouldMatch 2, the cache), with no exception.
  • Default settings, any size. The in-query shapes need no cache and no configuration: default IndexWriterConfig under Lucene's randomized test framework, a plain IndexSearcher, one document. The minimal test fails in 20 of 20 iterations with derived seeds in one run (a random directory each time, one class-level codec) and with seed DEADBEEF under another codec.
  • The cache shape. 9.9.0 to 10.6.0 ship a default LRUQueryCache; it was executed on stock 9.9.0, 9.12.3 and 10.5.2 searchers (a 12,000-document segment, the query seen 8 times), and 10.6.0 has the same default (read). By reading 10.5.2, the default cache skips segments under 10,000 documents and its policy caches such a query after 5 sightings. On main caching is off by default (IndexSearcher.java:82-84, GITHUB#14187), so the application must install a cache.
  • Who is exposed. The payload-filter overload is opt-in. A natural trigger is or(term("bank", isNoun), term("bank", isVerb)), or the same term filtered per request. The flexible query parser's interval functions use only the one-argument Intervals.term, and no in-tree code outside tests calls the overload. A read-only public code search (2026-10-26) found only one-argument Intervals.term calls in Elasticsearch, OpenSearch and Solr. The #12827 author wrote that they could not wrap payload-filtered sources in a MinimumShouldMatchIntervalsSource because UnorderedIntervalsSource.deduplicate() called this equals, so such combinations do occur; whether their filters differed is not stated.
  • Likelihood: rare. The recorded public searches found no report of wrong results since 9.9.0. The intervals package "contains experimental classes" (package-info.java:21).

Minimal repro

One JUnit test in lucene/queries, public API only: one document with two canned a tokens (payloads x and y), the default IndexWriterConfig, a plain IndexSearcher, and one count of ordered(a|x, a|y). Deterministic; it fails with assertions on and off.

TestPayloadFilteredTermOrdered.java (1 test)56 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.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 TestPayloadFilteredTermOrdered extends LuceneTestCase {

  public void testOrderedOfSameTermWithDifferentPayloadFilters() throws Exception {
    Token ax = new Token("a", 0, 1);
    ax.setPayload(new BytesRef("x"));
    Token ay = new Token("a", 2, 3);
    ay.setPayload(new BytesRef("y"));
    try (Directory dir = newDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new TextField("f", new CannedTokenStream(ax, ay)));
      w.addDocument(doc);
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        IntervalsSource source =
            Intervals.ordered(
                Intervals.term("a", new BytesRef("x")::equals),
                Intervals.term("a", new BytesRef("y")::equals));
        // the doc is "a|x a|y": expected 1 hit, actual 0 (the two sources compare equal)
        assertEquals(
            "hits of ordered(a|x, a|y)",
            1,
            new IndexSearcher(reader).count(new IntervalQuery("f", source)));
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or a later commit where PayloadFilteredTermIntervalsSource.java is unchanged)
cp $REPRO/TestPayloadFilteredTermOrdered.java lucene/queries/src/test/org/apache/lucene/queries/intervals/
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.intervals.TestPayloadFilteredTermOrdered -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
1 hit: the document has an a with payload x followed by an a with payload y.
Observed
hits of ordered(a|x, a|y) expected:<1> but was:<0> on main, 10.6.0, 10.5.2, 9.12.3 and 9.9.0; 9.8.0 throws ClassCastException instead.

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

More files, environment, full output175 words

The test builds the two tokens with the test framework's Token and CannedTokenStream, so it needs only the queries module. The filters are new BytesRef("x")::equals and new BytesRef("y")::equals. On main a plain IndexSearcher has no query cache, so the failure comes from ordered() alone. It fails in 20 of 20 iterations with derived seeds in one run (a random directory each time, one class-level codec) and with seed DEADBEEF under another codec. That each filter alone finds the document is not asserted, to keep one assertion; the hunt's control test for that passes on every version.

The same file compiles and fails unchanged on the 9.9.0, 9.12.3, 10.5.2 and 10.6.0 jars; on 9.8.0 it throws ClassCastException from PayloadFilteredTermIntervalsSource.equals, the pre-#12827 form.

Environment

lucenemain 645d4fe14a20: affected; also the 10.6.0, 10.5.2, 9.12.3 and 9.9.0 release jars with this test (9.8.0: ClassCastException)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestPayloadFilteredTermOrdered > testOrderedOfSameTermWithDifferentPayloadFilters FAILED
    java.lang.AssertionError: hits of ordered(a|x, a|y) expected:<1> but was:<0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:80F198F3455C249A]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.queries.intervals.TestPayloadFilteredTermOrdered.testOrderedOfSameTermWithDifferentPayloadFilters(TestPayloadFilteredTermOrdered.java:49)

Why it looks unintended

  • Query#equals exists "so that QueryCache works properly"; equal queries typically have identical "document-filtering properties". These two equal queries match different documents.
  • The term-only equals is a 2019 copy with a wrong cast that threw ClassCastException; #12827 fixed only the cast, which exposed the wrong equality.
  • SpanPayloadCheckQuery, the span counterpart, compares its payloads in equals; FunctionMatchQuery compares its predicate.
The full argument241 words
  • The query contract. Query#equals is "required so that QueryCache works properly", and "Typically a query will be equal to another only if it's an instance of the same class and its document-filtering properties are identical" (Query.java:95-107).
  • The factory contracts. term(term, payloadFilter) is "filtered by the value of the term's payload at each position" (Intervals.java:78-92); ordered "Returns intervals in which the subsources all appear in the given order" (:418); or returns "the disjunction of a set of sub-sources" (:118-152). The results contradict these for legal inputs.
  • History. LUCENE-8694 (#9740, romseygeek, 2019) wrote equals/hashCode as a copy of TermIntervalsSource's, with a leftover cast to TermIntervalsSource, so for two distinct instances equals threw and never returned true. #12827 (eumus, 2023) changed only that line. Commit-level A/B: 9.8.0 jars plus the #12827 class give the 9.9.0 wrong results; 9.9.0 jars with #12827 reverted give 6 ClassCastExceptions.
  • Precedents. LUCENE-8694 calls the class "the intervals equivalent of SpanPayloadCheckQuery", whose equals/hashCode compare its payload criteria and its wrapped query (SpanPayloadCheckQuery.java:264-280). There, LUCENE-7808 (#8859, 2017) fixed "incomplete .equals and .hashCode methods, causing caching issues": the wrapped span query had not been compared. FunctionMatchQuery compares its DoublePredicate with Objects.equals (FunctionMatchQuery.java:119-129), and FilteredIntervalsSource compares its name (FilteredIntervalsSource.java:142-154).
  • Nothing documents it. No javadoc says that filters are ignored by equality or that two filters on one term must not be combined.
The devil's-advocate check157 words

The strongest case for "intended": the upstream test testPayloadFilteredTermIntervalsSourceEquals (#12827) asserts that two separately written (_) -> true filters give equal sources (TestPayloadFilteredInterval.java:93-101); Java lambdas have no value equality, so for lambda filters a term-only equals is what lets rebuilt queries hit the cache and be recognised as repeats (a Predicate class with a value equals could do it too); repeat detection deliberately relies on equality (romseygeek in LUCENE-10140, #11177); and the package is experimental.

Why it fails: the #12827 test covers only two filters that behave the same, and #12827 never discussed filters that select different positions; equality of two distinct instances never worked before #12827 (it threw), so no earlier release relied on it; repeat detection is right only for sources that match the same positions; "experimental" warns about API changes, not wrong matches. Verdict: not intended, a defect (the independent reproduction, the cross-model review, the intended-behaviour check and the adjudication, high confidence).

Suggested fix

Recommended A: include the filter in equals/hashCode

Add the filter to equals and hashCode (Objects.equals(filter, that.filter), Objects.hash(term, filter)), as FunctionMatchQuery does, and change the #12827 test to share one Predicate instance. The repro passes, and so do all 513 lucene/queries tests. Trade-off: separately built identical lambdas are no longer equal, so they are no longer treated as a repeat.

  @Override
  public int hashCode() {
    return Objects.hash(term, filter);
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PayloadFilteredTermIntervalsSource that = (PayloadFilteredTermIntervalsSource) o;
    return Objects.equals(term, that.term) && Objects.equals(filter, that.filter);
  }
All options and trade-offs4 options · 619 words

One fix was built and validated through gradle on 645d4fe14a20. Because Java lambdas have no value equality, any fix that compares the filter must change the upstream test from #12827.

A: include the filter in equals/hashCode (validated by the checks) (recommended)

+2/-2 in PayloadFilteredTermIntervalsSource.java, plus the #12827 test testPayloadFilteredTermIntervalsSourceEquals changed to share one Predicate instance and to assert that a different filter is not equal (+8/-2 in TestPayloadFilteredInterval.java). For a lambda Objects.equals is identity; a Predicate class with value equals keeps value equality. FunctionMatchQuery compares its predicate the same way.

  @Override
  public int hashCode() {
    return Objects.hash(term, filter);
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    PayloadFilteredTermIntervalsSource that = (PayloadFilteredTermIntervalsSource) o;
    return Objects.equals(term, that.term) && Objects.equals(filter, that.filter);
  }

Trade-offs

  • Two separately built but identical lambdas are no longer equal: rebuilt queries miss the query cache and are not de-duplicated (no wrong result).
  • They are also no longer recognised as a repeat: on documents single = a x and double = a a, unordered(term(a, f), term(a, f')) with two separately built "payload is pos: 0" filters returns [double, single] with the fix and [] on main; with one shared always-true Predicate instance (p -> true), main and the fix both give [double], as plain unordered(term(a), term(a)) does (measured by the intended-behaviour check on main ea57360912ed). That is how unordered treats any two different sources that can match the same position ("The subsources may overlap", Intervals.java:432), but it changes behaviour for callers who rebuild the same filter; the CHANGES entry should say "reuse one immutable Predicate instance".
  • The fix is only as sound as the predicate's own equals: a Predicate class with a wrong value equals, or a predicate whose behaviour changes after the query is built, can still merge different filters or serve stale cache entries.
  • The upstream test from #12827 must change: with the source fix alone it fails (2 tests, 1 failure).
  • No in-tree code outside tests uses the overload.

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.* (8 suites 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.
  • Earlier, the same source change made the finder's 7 tests, the independent reproduction's 8, the cross-model review's 5 extra paths and the security probe pass on main ea57360912ed.

B: an explicit filter key that takes part in equality

An overload such as Intervals.term(term, String filterName, Predicate<BytesRef>) whose name goes into equals/hashCode, as FilteredIntervalsSource does with its name (FilteredIntervalsSource.java:77-89, :142-154). Not built.

Trade-offs

Keeps cache hits and repeat detection for rebuilt filters with the same name; callers must name distinct filters distinctly. An API addition; the existing overload still needs A or deprecation.

C: a value-based payload filter

A factory that takes the accepted payload values, as SpanPayloadCheckQuery takes its payload list and match operation and compares them in equals. Not built.

Trade-offs

Exact equality, but covers fewer cases than a Predicate; an API change.

Not a fix: document the current equality

Say in the javadoc that filters are ignored by equality.

Trade-offs

It would document wrong results for ordered, or and BooleanQuery. Until a fix ships, users can keep two payload-filtered sources on the same term out of one query and out of a shared query cache (for example a QueryCachingPolicy that never caches IntervalQuery).

Downstream note

Not a Lucene security issue, but an application that stores access labels as payloads and builds one filter per user on the same term would silently lose a restriction or be served another user's cached hits (executed on a stock 10.5.2 searcher).

How it was found

A bytecode scan of query components listed fields that the matching code reads but equals/hashCode do not, and flagged this filter. An exhaustive equality check over small queries on a 12-document index then found equal IntervalQuery pairs that match different documents. An AI tool reduced it to a public-API JUnit; no model checker or solver was involved.

The method in full149 words

Method: semi-formal (a hunt unit, query object contracts).

  • A bytecode scan of the stock main jars (JDK 25 ClassFile API) compared, for each query component, the fields read by its matching code with the fields read by equals/hashCode. It reported EQ-MISS PayloadFilteredTermIntervalsSource.filter, read by the interval iterators' nextInterval and next but not by equals.
  • A bounded-exhaustive equality-congruence harness then tried every ordered pair of each query family over a small parameter domain on a fixed 12-document index, and witnessed equal IntervalQuery pairs with different matched documents (payload pos: 0 vs pos: 1: documents 0 1 3 6 10 vs 1 4 8).
  • The finder, an AI tool, wrote the 7-test public-API JUnit; the one-test repro was cut from it on 2026-10-29. The finder tagged the find "formal"; the registry records it as semi-formal because no TLC, SMT or proof output produced the signal.

Prior reports, history and checks

Prior reports

No prior report: 183 recorded searches over GitHub, the mailing lists, JIRA, downstream trackers and the web, plus the files of all 413 open PRs (2026-10-26), rechecked on 2026-10-31. Closest: #12827, which fixed a ClassCastException in the same equals and never discussed filters that select different positions.

Searches4 searches

Verdict new: 17 hits read in full, none the same bug. Related, not reports of this bug: #12827 (same method, different defect; the closest), #9740 / LUCENE-8694 (origin), #8859 / LUCENE-7808 (span precedent, fixed as a caching bug), #11177 / LUCENE-10140 (repeat detection relies on equal sources), #13715 (open; the opposite equality defect for Intervals.regexp). Gaps: keyword search only; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs2026-10-26the scripted prior-report search (25 issue/PR, 4 commit and 2 downstream-tracker queries) and 83 GraphQL searches over apache/lucene, the archived lucene-solr repository, Elasticsearch, OpenSearch, Solr and all of GitHub: class names, payload filter, Intervals.term with payload or predicate, IntervalsSource equals/hashCode/cache, deduplicate, RepeatingIntervalsSource, LUCENE-8694, the #12827 author and commenteronly related items; #12827 is the closest
Open PRs2026-10-26the changed files of all 413 open apache/lucene PRs; the diffs of the 4 that touch intervals files (#16, #16847, #16851, #16899)none changes equality or touches PayloadFilteredTermIntervalsSource.java
Lists, JIRA, code search, web2026-10-26dev@, java-user@, issues@, commits@ (28 tool and 17 direct queries), JIRA (7 tool and 5 direct queries), 4 GitHub code searches, 8 web queriesno report
Recheck (GitHub)2026-10-31PayloadFilteredTermIntervalsSource; payload filter intervals equals; Intervals.term payload filter; payload intervals query cache; the upstream file historyonly #12827 or unrelated items; the file's last commit is still #12827

History

Latent since LUCENE-8694 (8.1.0, 2019), whose term-only equals threw ClassCastException; #12827 (9.9.0, 2023) fixed only the cast and made the wrong equality reachable. Executed from 8.0.0 (no such API) to 10.6.0 and main; not fixed upstream.

Versions tested6 rows

Introduced (silent wrong results) by #12827 "Fix PayloadFilteredTermIntervalsSource equals" (eumus, merged by mikemccand 2023-11-21, milestone 9.9.0): main 4991e3a1f47b, branch_9x 96bc866dfed. Latent since LUCENE-8694 "Payload-filtered term intervals" (#9740, romseygeek, committed 2019-03-18): main 5ca25249274, branch_8x 13aac3becde; the API shipped in 8.1.0 (sandbox module) and in the queries module from 8.2.0. Commit-level A/B confirms that the one-line cast change is what turns the crash into wrong results. 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, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.8.0 ClassCastExceptionequals of two distinct sources throws; on 9.8.0 this doc's test fails with ClassCastException, no wrong result
9.9.0 to 9.12.3, 10.0.0 to 10.5.2 (22 releases) first affected: 9.9.0the finder's JUnit fails 6 of 7 the same way on each; this doc's test fails on 9.9.0, 9.12.3 and 10.5.2
10.6.0 affectedthis doc's test fails (0 hits instead of 1)
main 645d4fe14a20 affectedthis doc's test fails through gradle and the standalone runner, assertions on and off; passes with fix A
9.8.0 jars + #12827 class / 9.9.0 jars without it commit A/Bwith #12827: the 9.9.0 wrong results; without it: 6 ClassCastExceptions

Independent checks

An independent reproducer, a root-cause review by another model family (OpenAI GPT-5.6) for which a separate run executed its extra paths, a devil's advocate and an adjudication all rated it real; the adjudication confirmed it with high confidence. The repros still fail on the later main commits a9779ef3c193 and 645d4fe14a20.

Checks and verdicts293 words

Before the candidate was filed, reading-only reviews by OpenAI GPT-5.6 (refute and intended lenses) agreed on the root cause; the refute review's "not real" was procedural, because it could not run Java.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateBytecode scan plus equality-congruence harness; the 7-test JUnit fails 6 of 7 on main (assertions on and off), 10.5.2, 9.12.3 and 9.9.0; 9.8.0 throws ClassCastException; the fix passes 7 of 7.
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; a separate run re-ran the repro each time and it failed.
Independent reproducer realOwn JUnit outside org.apache.lucene fails 7 of 8 on main (assertions on and off), 10.5.2, 9.12.3 and 9.9.0; adds unordered(); its fix passes 8 of 8.
Root-cause review (OpenAI GPT-5.6) real (medium)Named BooleanQuery MUST/SHOULD de-duplication, DisjunctionMaxQuery and atLeast. Separate runs covered unordered, two MUST, two SHOULD with minimumShouldMatch 2 and DisjunctionMaxQuery (all 4 fail on main and 10.5.2) plus a same-instance control (passes); with the fix 5 of 5 pass. atLeast was not run.
Devil's advocate real (high)Built the strongest intended case (the #12827 test) and rejected it; measured the repeat-detection trade-off of the fix.
Adjudication confirmed (high)Re-ran the independent reproduction's JUnit (8 run, 7 failed, assertions on and off) and the finder's (7 run, 6 failed).
Repro minimization and main refresh verified; still reproducesThe one-test repro fails on 645d4fe14a20 (seed DEADBEEF, and 20 of 20 iterations with derived seeds in one run) and passes with the fix; the finder's and the independent reproduction's tests still fail on a9779ef3c193.

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

Finding record

LFV-295, snapshot of 2026-11-06

Registry title
Intervals.term(term, payloadFilter) sources compare equal whatever their payload filter, so Intervals.or/ordered/unordered, BooleanQuery rewrite and the query cache merge sources that match different positions
Area
queries / intervals (PayloadFilteredTermIntervalsSource equality; IntervalQuery dedup and cache keys)
Priority
Low, rank 849: Payload-filtered Intervals.term sources compare equal whatever their filter, so dedup and caching silently merge them, but no parser or downstream engine uses that overload, so it follows LFV-321.
Impact class
silent wrong results
Impact note
Applications that call the public Intervals.term(String|BytesRef, Predicate<BytesRef>) with two filters that select different positions on the same term, either in one query (Intervals.or/ordered/unordered/atLeast, BooleanQuery FILTER, MUST, MUST_NOT or minimum-should-match clauses, DisjunctionMaxQuery) or in two queries behind a shared query cache, silently get wrong matches in both directions (missing hits and extra hits), with -ea and -da, default writer, codec and searcher settings and any index size; the in-query shapes need no cache, the cache shape happens with a stock IndexSearcher on 9.9.0-10.5.2 (default LRUQueryCache: segments of at least 10000 docs, after about 5 uses; executed) and needs an application-installed cache on main (GITHUB#14187); no Lucene query parser and, by public code search, no Elasticsearch, OpenSearch or Solr code builds payload-filtered intervals; 8.1.0-9.8.0 throw ClassCastException on the same calls instead; nothing is written to the index.
Summary
You index a field with a payload at each position and build two sources on the same term with different payload filters, for example a@0 = Intervals.term("a", payload is 'pos: 0') and a@1 = Intervals.term("a", payload is 'pos: 1'), which on their own match different documents. You expect Intervals.or(a@0, a@1) to match both sets, ordered(a@0, a@1) to match the document 'a a', FILTER a@0 + FILTER a@1 to match only that document, and a query cache to keep the two queries apart. Instead PayloadFilteredTermIntervalsSource.equals/hashCode compare only the term, so the two sources are equal: or() keeps one of them, ordered() and unordered() become 'a@0 twice' and match nothing, the two FILTER clauses collapse into one (extra hits), MUST a@0 + MUST_NOT a@1 is rewritten to MatchNoDocsQuery, and a cached searcher returns the first filter's hits for the second, all with no error.
Introduced
4991e3a1f47b (main; branch_9x backport 96bc866dfed, the commit in 9.9.0), apache/lucene#12827 'Fix PayloadFilteredTermIntervalsSource equals' by eumus (merged by mikemccand, 2023-11-21): it fixed a ClassCastException in equals (a cast to TermIntervalsSource) by changing only the cast, which made the existing term-only equality reachable and added testPayloadFilteredTermIntervalsSourceEquals (two separately written always-true filters must be equal) [exec: commit-level A/B, 9.8.0 jars plus the #12827 class give the 9.9.0 wrong results with 0 ClassCastException, 9.9.0 jars with #12827 reverted give 6 ClassCastException]; latent since 5ca25249274 (main) / 13aac3becde (branch_8x), LUCENE-8694 'Payload-filtered term intervals' (now apache/lucene#9740, committed by romseygeek 2019-03-18), which wrote hashCode = Objects.hash(term) and a term-only equals copied from TermIntervalsSource with the wrong cast, so before 9.9.0 equals of two distinct instances threw instead of returning true [read: git log --follow, blame -L 256,266 and show in the history clone] (2023-11-21 (#12827 merged; 9.9.0 tagged 2023-12-04); latent equality code since 2019-03-18 (LUCENE-8694, 8.1.0))
First affected version
9.9.0 [exec]; 9.8.0 is the last release without wrong results: the same calls throw ClassCastException from equals there [exec], as in every release from 8.1.0 [exec: 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.8.0]; 8.0.0 has no payload-filter overload of Intervals.term [exec: compile error]
Affected versions
every release from 9.9.0 through 10.6.0 and main ea57360912ed and 645d4fe14a20 [exec: the minimal test fails on the 10.6.0, 10.5.2, 9.12.3 and 9.9.0 release jars (-ea) and on main 645d4fe14a20 (-ea and -da), 2026-10-31; the finder's JUnit fails 6 of 7 the same way on all 22 releases 9.9.0-10.5.2 (-ea) and on main (-ea and -da); the independent reproduction's JUnit fails 7 of 8 on main (-ea, -da), 10.5.2, 9.12.3 and 9.9.0; the follow-up security probe fails on 10.5.2 (-ea, -da), 9.12.3, 9.9.0 and main]; 8.1.0-9.8.0 have the crash form instead (ClassCastException from the same equals, fixed by #12827) [exec: 8.1.0, 8.1.1, 8.2.0, 8.11.4, 9.0.0, 9.8.0]; 8.1.x shipped the class in the sandbox module, the queries module has it from 8.2.0; not affected: 8.0.0 and earlier (API absent) [exec]; branch_10x and branch_10_6 have the same equals/hashCode [read]; not fixed upstream [read, 2026-10-26; on 2026-10-31 the file's last commit on upstream main 2f424fd428da is still #12827]; 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 intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by exhaustive check
Upstream and prior reports
none found (2026-10-26, the prior-report record, verdict new plus the finder's runs, read-only: 183 recorded queries, namely a scripted prior-report search (25 GitHub issue/PR, 4 commit, 2 downstream-repository, 28 mailing-list over dev@, java-user@, issues@ and commits@, 7 JIRA and 8 web queries), 83 GitHub GraphQL searches over apache/lucene, the archived lucene-solr repository, Elasticsearch, OpenSearch, Solr and all of GitHub, 4 GitHub code searches, 17 direct lists.apache.org and 5 direct JIRA queries; 17 hits read in full, none the same bug; the changed files of all 413 open apache/lucene PRs: 4 touch intervals files (#16, #16847, #16851, #16899) and none changes equality or touches PayloadFilteredTermIntervalsSource.java). Not fixed upstream: on 2026-10-26 GitHub main was ea57360912ed, and equals/hashCode were unchanged on main, branch_10x and branch_10_6; on 2026-10-31 the file's last commit on upstream main 2f424fd428da (2026-10-30) is still #12827. Related, not reports of this bug: #12827 (2023, merged; fixed a ClassCastException in the same equals and added the test that two separately written always-true filters are equal; it never discusses filters that select different positions; its author reached equals by wrapping payload-filtered sources in Intervals.atLeast), #9740 / LUCENE-8694 (origin of the class; equality never discussed), #8859 / LUCENE-7808 (precedent: SpanPayloadCheckQuery equals/hashCode left out its payload criteria, fixed as a caching bug), #11177 / LUCENE-10140 (romseygeek: repeat detection rewrites equal consecutive sub-iterators), #10141 / LUCENE-9099 and #9347 / LUCENE-8300 (repeat handling), #13715 (open: the opposite equality defect for Intervals.regexp, a different class), #7315 / LUCENE-6253 (open: SpanPositionCheckQuery equals misses a field), #16657 (LRUQueryCache keys hash with query.hashCode() on main). Search gaps: keyword search cannot rule out a report worded without these class names, GitHub's search rate limit was saturated (GraphQL search was used as well), forums and Stack Overflow were covered only through web search, and private reports are not visible
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle with assertions on and off (seed DEADBEEF) and on the standalone runner; the same test on the 10.6.0, 10.5.2, 9.12.3 and 9.9.0 release jars (fails) and on 9.8.0 (ClassCastException). The hunt's tests ran on main ea57360912ed and on all 22 releases from 9.9.0 to 10.5.2, and again on a9779ef3c193; the classes involved are unchanged up to 645d4fe14a20 apart from javadoc lines in Intervals.java. The fix was run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-31. Upstream main 2f424fd428da (2026-10-30) still has the same code.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:35-42 (at ea57360912ed) (package-private class; fields term and filter), :256-266 (hashCode = Objects.hash(term); equals compares only term), :124 and :210 (the interval iterators call filter.test)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92 (at ea57360912ed) (public factories term(String|BytesRef, Predicate<BytesRef>) 'filtered by the value of the term's payload at each position')
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/DisjunctionIntervalsSource.java:58-68 (at ea57360912ed) (simplify collects sub-sources into a HashSet); OrderedIntervalsSource.java:40-58 (equal neighbours become a RepeatingIntervalsSource); UnorderedIntervalsSource.java:44-58 (equal sources counted in a LinkedHashMap)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java:135-146 (at ea57360912ed) (equals/hashCode delegate to the field and the source); package-info.java:21 ('This package contains experimental classes')
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:126-140 (at ea57360912ed) (clauseSets: FILTER and MUST_NOT in HashSets, SHOULD and MUST in multisets), :346-361 (rewrite drops duplicate FILTER/MUST_NOT clauses), :365-384 ('MUST clause also in MUST_NOT' -> MatchNoDocsQuery), :387-400 (FILTER clauses that are also MUST clauses removed), :442-451 and :714 (deduplicateClauses for SHOULD and MUST)
  • lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (at ea57360912ed) (equals must be implemented properly 'so that QueryCache works properly'; equal 'only if ... its document-filtering properties are identical'); IndexSearcher.java:82-84 (main: DEFAULT_QUERY_CACHE = null, caching disabled by default)
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestPayloadFilteredInterval.java:93-101 (at ea57360912ed) (testPayloadFilteredTermIntervalsSourceEquals: two separately written (_) -> true sources must be equal)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:77-89, (at ea57360912ed) :143-153 (user filters carry a name that is part of equals/hashCode); lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:48 (DoublePredicate filter field) and :119-124 (equals compares the predicate with Objects.equals); DisjunctionMaxQuery.java:47 (disjuncts kept in a Multiset)
  • gh pr view 12827 -R apache/lucene (read-only, 2026-10-26: body 'TermIntervalsSource isn't ancestor of PayloadFilteredTermIntervalsSource, so casting to it causes ClassCastException ... I wasn't unable to wrap PayloadFilteredTermIntervalsSources into a MinimumShouldMatchIntervalsSource, as it tries to deduplicate wrapped intervals using equals inside UnorderedIntervalsSource.deduplicate()'; one comment by boris-pichugin 'I've got the same problem.'; merged 2023-11-21)
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:37-43, (at 645d4fe14a20) :117-126, :202-212, :256-272
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/OrderedIntervalsSource.java:29-57; (at 645d4fe14a20) UnorderedIntervalsSource.java:44-57; DisjunctionIntervalsSource.java:43-68; RepeatingIntervalsSource.java:33-41; IntervalQuery.java:134-146
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:126-140, (at 645d4fe14a20) :343-394, :443, :451, :714; DisjunctionMaxQuery.java:47
  • lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:442-455, (at 645d4fe14a20) :577-588; Multiset.java:41-66, :79-84; DisjunctionMaxQuery.java:115-117; lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:566-577 (atLeast)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:82-84; (at 645d4fe14a20) a Lucene clone releases/lucene/10.6.0 lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:99 (default LRUQueryCache)
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/flexible/standard/nodes/intervalfn/ (at 645d4fe14a20) (no payload-filter factory); lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalBuilder.java:128, :159, :191 (one-argument term only); package-info.java:21
  • a Lucene clone releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:175-197; UsageTrackingQueryCachingPolicy.java:122-137 (cache thresholds)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Query.java:95-107 (at 645d4fe14a20)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/Intervals.java:78-92, (at 645d4fe14a20) :118-152, :415-424
  • lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:264-280; (at 645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:119-129; lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:142-154
  • lucene/queries/src/test/org/apache/lucene/queries/intervals/TestPayloadFilteredInterval.java:93-101 (at 645d4fe14a20)
Suggested fix
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/FilteredIntervalsSource.java:77-89, (at 645d4fe14a20) :142-154; lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:119-129; Intervals.java:432
Prior reports
  • gh api search/issues and repos/apache/lucene/commits?path=... (read-only, 2026-10-31)
History
  • a Lucene clone releases/lucene/10.6.0 lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:256-267 (same equals/hashCode)
  • gh api repos/apache/lucene/commits?path=lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java (read-only, 2026-10-31)
Diagrams
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/PayloadFilteredTermIntervalsSource.java:256-267; (at 645d4fe14a20) OrderedIntervalsSource.java:40-57

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 one-document repro and the equality check that merges the two sources, 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.