All findings · Lucene finding · LFV-871 · indri-and-query-silently-ignores-clause-occur

IndriAndQuery silently accepts MUST, FILTER and MUST_NOT clauses, matches them as SHOULD, and explains some of its own hits as non-matches

IndriAndQuery takes BooleanClauses but ignores MUST, FILTER and MUST_NOT when it matches: +f:a -f:b returns doc 1, the document it should exclude, next to doc 0, and its explain calls doc 1 a non-match. Every release since 8.9.0; a niche API, so a Low-priority hardening bug.

Summary

Priority
Low, rank 601IndriAndQuery widens hits for MUST, FILTER or MUST_NOT clauses and its explain disagrees with its hits, but the class is niche, the contract is arguable, and open PR #463, a related feature PR, already deletes the faulty explain branch.
Impact
minor
Since
8.9.0 (2021, LUCENE-9537)introduced 2021-01-29 · about 5 years
Affected
8.9.0 through 10.6.0 and main
Status
documented
Found by
a public-API test against BooleanQuery, then a Z3 model and a bounded-exhaustive check

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

IndriAndQuery takes clauses that each carry an Occur and accepts any of them. When it matches it treats every clause as SHOULD; its explain still treats MUST and FILTER as required.

IndriAndQuery and Occur in one minute

  1. IndriAndQuery (core, org.apache.lucene.search, since 8.9.0) implements the #and operator of the Indri search engine: it combines the scores of its clauses, and a document matches if it matches any clause. No Lucene query parser builds it; applications call its constructor.
  2. Its clauses are BooleanClauses, the type BooleanQuery uses, so each has an Occur: SHOULD (optional), MUST (required and scored), FILTER (required, not scored) or MUST_NOT (excluded).
  3. IndexSearcher.explain(query, doc) explains how a document scored; for a document the search returned, it should say that the document matches.
Diagram of the bug: an index of four documents (doc 0 f=a acl=x, doc 1 f=a acl=y, doc 2 f=a f=secret acl=x, doc 3 f=b acl=x) and an IndriAndQuery with MUST f:a and FILTER acl:x. IndriAndQuery returns docs 0, 1, 2 and 3 while a BooleanQuery over the same clauses returns docs 0 and 2; explain reports a returned hit as a non-match; a MUST_NOT clause keeps the document it names, and alone returns exactly it.
The finder's four-document index and the query [MUST f:a, FILTER acl:x]: nothing on the matching path (the weight's constructor, getScorer, the disjunction) reads the Occur, only explain does, so all four documents come back where a BooleanQuery returns two, explain disagrees, and a MUST_NOT clause keeps what it should exclude (alone, it returns exactly that). 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, doc 0 with f=a and doc 1 with f=b. Build new IndriAndQuery(List.of(new BooleanClause(f:a, MUST), new BooleanClause(f:b, MUST_NOT))). The constructor accepts it, and it prints as +f:a -f:b, like a BooleanQuery.
  2. You expect doc 0 only, which is what the same two clauses give in a BooleanQuery (1 hit), or a rejection, since IndriAndQuery has no required or excluded clauses.
  3. Instead it returns doc 0 and doc 1 (2 hits). Matching never reads Occur, so every clause acts as SHOULD and the query matches the union: the MUST_NOT clause adds the very document it names, and MUST and FILTER clauses narrow nothing.
  4. explain does read Occur: for hit doc 1 it answers "Failure to meet condition(s) of required/prohibited clause(s)" ("no match on required clause (f:a)"). Search and explain of the same query disagree.
  5. On four documents, [MUST f:a, FILTER acl:x] returns all 4 where a BooleanQuery returns 2, and a lone [MUST_NOT f:secret] returns exactly the excluded document. No exception, on every release since 8.9.0.

Who is affected

Applications that build an IndriAndQuery (no Lucene parser produces it) with a MUST, FILTER or MUST_NOT clause, on any release since 8.9.0 with default settings. They get more hits instead of fewer, and explanations that contradict the hits. The one downstream user found, an Indri query parser, passes MUST and MUST_NOT clauses for its filter operators (by reading its code).

Mechanism, terms and numbers513 words

IndriAndQuery (core, since 8.9.0) takes a List<BooleanClause>, so every clause carries an Occur, and its public constructor accepts any of them without a check. When it matches, it builds a scorer for every clause and iterates their union, whatever the Occur: MUST and FILTER clauses do not narrow the hits, and a MUST_NOT clause is one more disjunct, so it keeps the documents it names instead of removing them (a lone MUST_NOT clause returns exactly them). Its explain, a trimmed copy of BooleanWeight.explain, does treat MUST and FILTER as required, so IndexSearcher.explain reports some returned hits as non-matches. In the repro, +f:a -f:b over doc 0 {f=a} and doc 1 {f=b} returns both documents (a BooleanQuery returns doc 0) and explains doc 1 as "Failure to meet condition(s) of required/prohibited clause(s)". Matching all clauses as a disjunction is the Indri design for SHOULD clauses; what is wrong is that other Occur values are accepted silently and undocumented, and that explain contradicts the hits.

The code (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/search/)

// IndriAndQuery.java
/** A Query that matches documents matching combinations of subqueries. */          // :22
public IndriAndQuery(List<BooleanClause> clauses) { super(clauses); }              // :25-27, no check
// IndriQuery.java: this.clauses = clauses; (:33); toString appends c.occur() (:46) -> "+f:a -f:b"

// IndriAndWeight.java
for (BooleanClause c : query) {                                                    // :41
  Weight w = searcher.createWeight(c.query(), scoreMode, 1.0f);                    // :42, Occur not read
  weights.add(w);
}
private Scorer getScorer(LeafReaderContext context) {                              // :47
  for (Weight w : weights) { /* keep every non-null scorer */ }                    // :50-55
  Scorer scorer = subScorers.get(0);                                               // :60, lone clause as is
  if (subScorers.size() > 1) scorer = new IndriAndScorer(subScorers, scoreMode, boost);  // :61-63
}
// IndriDisjunctionScorer.java:36-41: DisjunctionDISIApproximation over ALL sub-scorers

public Explanation explain(LeafReaderContext context, int doc) {                   // :76
  ...
  if (e.isMatch()) { subs.add(e); }
  else if (c.isRequired()) {                                                       // :86, MUST or FILTER
    subs.add(Explanation.noMatch("no match on required clause (" + c.query() + ")", e));
    fail = true;
  }
  ...
  if (fail) return Explanation.noMatch("Failure to meet condition(s) of required/prohibited clause(s)", subs); // :92-94
  • BooleanClause.isRequired() is occur == MUST || occur == FILTER (BooleanClause.java:80-82).
  • IndriAndWeight.explain keeps only the isRequired branch of BooleanWeight.explain (BooleanWeight.java:65-108, same failure message at :108); Boolean's MUST_NOT branch (:86) was not copied, which is why a MUST_NOT hit still explains as a match.
  • With one clause scorer, getScorer returns that clause's own scorer, so a lone MUST_NOT clause returns exactly the documents it names.

The numbers

indexclausesIndriAndQuery hitssame clauses in a BooleanQuery
doc 0 {f=a}, doc 1 {f=b}[MUST f:a, MUST_NOT f:b]{0, 1}; explain(1).isMatch() false{0}
four documents (below)[MUST_NOT f:secret]{2}{}
four documents[SHOULD f:a, MUST_NOT f:secret]{0, 1, 2}{0, 1}
four documents[MUST f:a, FILTER acl:x]{0, 1, 2, 3}{0, 2}
four documents[MUST f:a, SHOULD f:b]doc 3 is a hit, explain(3).isMatch() false-

The four documents: doc 0 {f=a, acl=x}, 1 {f=a, acl=y}, 2 {f=a, f=secret, acl=x}, 3 {f=b, acl=x} (StringFields, default similarity). Same results with assertions on and off.

Exposure and likelihood202 words
  • Who is hit. Applications that build an IndriAndQuery with a MUST, FILTER or MUST_NOT clause. Default codec, writer config, similarity and searcher; any index size; public API only. Queries with SHOULD clauses only are unaffected.
  • What they see. MUST and FILTER clauses do not narrow the hits, a MUST_NOT clause is matched as one more disjunct, so it keeps the documents it names instead of removing them (alone, it returns exactly them), and IndexSearcher.explain reports some returned hits as non-matches. No exception, no index damage.
  • Why someone passes them. The constructor asks for an Occur on every clause, the class is called "And", and toString prints +, # and -. The Lucindri parser (lemurproject/Lucindri, on lucene-core 8.10.0) builds Lucene's IndriAndQuery with Occur.MUST and Occur.MUST_NOT for its #scoreif / #scoreifnot operators, which its README documents as filters (read, not executed); on Lucene's class they would not filter.
  • Diagnostics. Lucene's generic query check asserts, for any query it is given, that each collected hit explains as a match (QueryUtils.check -> CheckHits.checkExplanations -> ExplanationAsserter); IndriAndQuery breaks that agreement through its public constructor.
  • Scale. Every release since 8.9.0 (2021). Low priority: a niche API, and the matching half has no stated contract.

Minimal repro

One JUnit test in lucene/core (package org.apache.lucene.search), two documents, one query [MUST f:a, MUST_NOT f:b]. It accepts a rejection; otherwise it expects doc 0 only, with every hit explained as a match. It fails on main with assertions on and off, and on 10.6.0 and 9.1.0.

TestIndriAndQueryOccur.java (one test, lucene/core)65 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.search;

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIndriAndQueryOccur extends LuceneTestCase {

  public void testMustAndMustNotClausesAreNotSilentlyIgnored() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        for (String value : new String[] {"a", "b"}) { // doc 0: f=a, doc 1: f=b
          Document doc = new Document();
          doc.add(new StringField("f", value, Field.Store.NO));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        List<BooleanClause> clauses =
            List.of(
                new BooleanClause(new TermQuery(new Term("f", "a")), Occur.MUST),
                new BooleanClause(new TermQuery(new Term("f", "b")), Occur.MUST_NOT));
        IndriAndQuery query;
        try {
          query = new IndriAndQuery(clauses);
        } catch (IllegalArgumentException _) {
          return; // rejecting clauses it cannot honour is fine
        }
        // if accepted, +f:a -f:b must match doc 0 only, and each hit must explain as a match
        List<String> hits = new ArrayList<>();
        for (ScoreDoc sd : searcher.search(query, 10).scoreDocs) {
          hits.add(
              "doc " + sd.doc + " explain isMatch=" + searcher.explain(query, sd.doc).isMatch());
        }
        assertEquals(List.of("doc 0 explain isMatch=true"), hits);
      }
    }
  }
}
Command
cd $LUCENE
cp $REPRO/TestIndriAndQueryOccur.java lucene/core/src/test/org/apache/lucene/search/
./gradlew :lucene:core:test --tests org.apache.lucene.search.TestIndriAndQueryOccur -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
Expected
Either new IndriAndQuery(...) throws IllegalArgumentException, or the search returns doc 0 only and explain calls it a match.
Observed
expected:<[doc 0 explain isMatch=true]> but was:<[doc 0 explain isMatch=true, doc 1 explain isMatch=false]> at TestIndriAndQueryOccur.java:61.

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

More files, environment, full output278 words

One test method, two documents, one segment, single thread, deterministic; public API only. It sits next to TestIndriAndQuery. The test accepts the two outcomes that make the hits follow the clauses: rejecting the clauses (fix A) or honouring them with an explain that agrees (fix B). It still fails under the document-only option C.

Regression test: this same test. It passes with fix A (gradle) and with fix B (standalone runner, with the replacement classes).

catch (IllegalArgumentException _) needs Java 22 or later; on a 9.x or 10.x branch name the variable if that branch's lint allows it. The test does not run on 9.0.0, whose test framework predates the 9.1.0 package rename (a limit of the test setup, not the bug). The gradle test framework randomizes the codec (CheapBastard for seed DEADBEEF, Asserting(Lucene104) for seed 1CEB00DA, same failure); the supporting probe uses the plain defaults (default codec, BM25).

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); the same test also fails on the 10.6.0 and 9.1.0 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestIndriAndQueryOccur > testMustAndMustNotClausesAreNotSilentlyIgnored FAILED
    java.lang.AssertionError: expected:<[doc 0 explain isMatch=true]> but was:<[doc 0 explain isMatch=true, doc 1 explain isMatch=false]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:FA465899BAD282F6]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.search.TestIndriAndQueryOccur.testMustAndMustNotClausesAreNotSilentlyIgnored(TestIndriAndQueryOccur.java:61)
:lucene:core:test (FAILURE): 1 test, 1  failure

# supporting probe on the same two documents (not the repro)
BooleanQuery +f:a -f:b -> hits 0 ... count=1
IndriAndQuery +f:a -f:b -> hits 0 1 ... count=2
explain(IndriAndQuery, doc 1):
0.0 = Failure to meet condition(s) of required/prohibited clause(s)
  0.0 = no match on required clause (f:a)
    0.0 = no matching term
  0.31506687 = weight(f:b in 1) [BM25Similarity], result of: ...

Why it looks unintended

  • The class reads Occur itself: explain treats MUST and FILTER as required (IndriAndWeight.java:86) while search ignores them, so one query's hits and explanations disagree.
  • Lucene's generic query check (QueryUtils.check, via CheckHits.checkExplanations) asserts that every hit explains as a match.
  • Nothing documents that Occur is ignored, yet the constructor demands one per clause and toString prints +, # and -.
The full argument148 words
  • Self-inconsistent. explain applies MUST/FILTER (IndriAndWeight.java:86-94); getScorer and IndriDisjunctionScorer do not (IndriAndWeight.java:47-65, IndriDisjunctionScorer.java:36-41). Whatever the design, one of the two halves is wrong.
  • Explain contract. Weight.explain: "An explanation of the score computation for the named document" (Weight.java:97-105); CheckHits.checkExplanations, which QueryUtils.check runs, requires every hit's explanation to "indicate match" (CheckHits.java:607-627).
  • No documentation of ignoring. The javadoc says only "A Query that matches documents matching combinations of subqueries" (IndriAndQuery.java:22); the Occur javadoc gives MUST, FILTER and MUST_NOT a meaning (BooleanClause.java:27, :35, :57-61); IndriQuery.toString prints them (:46).
  • Tests. TestIndriAndQuery uses SHOULD clauses only (:113-114, :130-131); no test covers other values.
  • History. Both behaviours came with the introducing commit 9cc5c9b7983 (LUCENE-9537); the first explain was a copy of BooleanWeight.explain, and the original author's later PR #463 deletes its isRequired branch.
The devil's-advocate check165 words

Strongest case for "intended": IndriAndQuery implements the Indri #and belief operator, which combines all its children and has no required or excluded clauses (Indri filters with other operators); the scorer is documented as "The Indri implementation of a disjunction scorer"; in the original review mikemccand wrote "Normally in Lucene AND implies MUST ... But this query is actually disjunctive, right?" (lucene-solr#2097, r532223725) and the name was kept; and BooleanClause is documented as "A clause in a BooleanQuery", so Occur semantics are BooleanQuery's contract.

Why it fails: that shows the union is right for SHOULD clauses, which nobody disputes. It does not justify accepting MUST, FILTER and MUST_NOT silently and undocumented, printing them as if they meant something, or an explain that applies MUST and FILTER while search does not. The intended-behaviour verifier built exactly this case and still called the explain half a real self-inconsistency; an independent reviewer from another model family (OpenAI GPT-5.6) called the search/explain disagreement "especially difficult to defend as intended".

Suggested fix

Recommended A: reject non-SHOULD clauses, drop explain's isRequired branch

Reject non-SHOULD clauses in the IndriAndQuery constructor (IllegalArgumentException, stated in its javadoc) and drop the isRequired branch of IndriAndWeight.explain, the change open PR #463 already makes. Validated: the repro passes with assertions on and off, 37 Indri, BooleanQuery and QueryVisitor tests pass, and :lucene:core:check -x test passes.

public IndriAndQuery(List<BooleanClause> clauses) {
  super(clauses);
  for (BooleanClause clause : clauses) {
    if (clause.occur() != BooleanClause.Occur.SHOULD) {
      throw new IllegalArgumentException(
          "IndriAndQuery only supports SHOULD clauses, got: "
              + clause.occur().name()
              + " "
              + clause.query());
    }
  }
}
// IndriAndWeight.explain: drop the `else if (c.isRequired())` branch and `fail` (as in #463)
All options and trade-offs3 options · 558 words

Fix A was validated with gradle in a scratch worktree of 5adff78bb168; B was only checked with two replacement classes put ahead of the core jar in standalone runs; C was not run. The assert advanced == doc line in explain is a separate bug (LFV-875) and stays as it is.

A: reject non-SHOULD clauses, drop explain's isRequired branch (validated by the checks) (recommended)

The constructor throws IllegalArgumentException for any clause that is not SHOULD, and its javadoc says so and points to an enclosing BooleanQuery for required, filter and excluded clauses. IndriAndWeight.explain loses the else if (c.isRequired()) branch and the fail flag, exactly as PR #463 does (plus the now unused query field and Iterator import). The diff is formatted with ./gradlew tidy.

public IndriAndQuery(List<BooleanClause> clauses) {
  super(clauses);
  for (BooleanClause clause : clauses) {
    if (clause.occur() != BooleanClause.Occur.SHOULD) {
      throw new IllegalArgumentException(
          "IndriAndQuery only supports SHOULD clauses, got: "
              + clause.occur().name()
              + " "
              + clause.query());
    }
  }
}
// IndriAndWeight.explain: drop the `else if (c.isRequired())` branch and `fail` (as in #463)

Trade-offs

  • A behaviour change for callers that pass other Occur values today: they get an exception instead of silently widened hits (by reading, Lucindri's #scoreif / #scoreifnot would start failing loudly). Fine for main; for a 10.x backport a maintainer may prefer C.
  • IndriQuery keeps the caller's list as is (IndriQuery.java:33), so mutating that list after construction bypasses the check; List.copyOf would close that, but getClauses() would then return a copy.

What the checks validated

  • The repro passes with assertions on and off.
  • TestIndri* (TestIndriAndQuery 2 tests and the repro), TestBooleanQuery (30) and TestQueryVisitor (4): 37 tests, 0 failures.
  • :lucene:core:check -x test passes (google-java-format, ecj lint, forbidden APIs) with the fix and the new test.

B: honour Occur by rewriting into a BooleanQuery (not recommended; validated only with replacement classes, not by a build)

Rewrite a query with non-SHOULD clauses into a BooleanQuery: MUST and FILTER clauses as FILTER, MUST_NOT clauses as MUST_NOT, plus an IndriAndQuery of the SHOULD and MUST clauses (optional when a MUST/FILTER clause exists, as in BooleanQuery), together with the LFV-875 advance check in explain.

Trade-offs

Gives the Indri operator semantics it does not have in Indri, changes the Indri scores of such queries, and goes against the maintainers' direction of narrowing what Indri queries accept (#11194).

What the checks validated

A replacement-class experiment, not a build: the two replacement classes are compiled alone and put ahead of the core jar. This doc's test passes that way. The finder's bounded check of the real classes, run the same way on main a9779ef3c193, no longer reports a widened hit or a hit explained as a non-match over 60,288 checks, but the run as a whole still fails: its model describes the stock behaviour and it reports 45,392 differences from that model, so it is not a clean validation of B. Not run through gradle.

C: document that Occur is ignored (not validated) (not run)

Say in the javadoc that every clause is treated as SHOULD, and drop the isRequired branch of explain (PR #463's change).

Trade-offs

Non-breaking and backportable, and explain then agrees with the hits; but a MUST_NOT clause still matches as one more disjunct instead of excluding, so this doc's test still fails under C.

What the checks validated

Not run.

Downstream note

For downstream services: a restriction such as a per-user FILTER or MUST_NOT clause placed inside an IndriAndQuery does not restrict anything; put restrictions in an enclosing BooleanQuery.

How it was found

A public-API test that compared IndriAndQuery with a BooleanQuery over the same clauses, written while checking the related Indri bug LFV-299, first showed it. A Z3 model of the matching and explain rules and a bounded-exhaustive check of the real classes (60,288 checks, 0 model mismatches) then pinned down exactly when it widens and when explain disagrees.

The method in full174 words

Method: non-formal first signal, then formal and semi-formal characterization (2026-10-26 to 2026-10-28; the model and the bounded check ran on main a9779ef3c193).

  • An executed public-API JUnit test, written while checking the related bug LFV-299 (IndriAndQuery does not rewrite its clauses; a different root and fix site), found [MUST f:a, FILTER acl:x] returning {0, 1, 2, 3} where the same BooleanQuery returns {0, 2}.
  • A Z3 model of the matching and explain rules proved that IndriAndQuery equals the BooleanQuery over the same clauses when all clauses are SHOULD, is always a superset, widens exactly when a MUST/FILTER clause misses or a MUST_NOT clause matches, and explains a hit as a non-match only when a MUST/FILTER clause is present.
  • A bounded-exhaustive translation validation of the real classes ran 1,884 clause lists (60,288 checks) and found 30,840 widened hits and 18,048 hits explained as non-matches, with 0 mismatches against the model; with the fix-B sketch's replacement classes, it reported neither family (that run still fails as a whole, since its model describes the stock behaviour).

Prior reports, history and checks

Prior reports

No report of this defect (re-checked 2026-11-04). Related, not reports: open PR #463 deletes the faulty explain branch inside a feature PR without calling it a bug; the original review (lucene-solr#2097) noted the query is "actually disjunctive"; #11194 proposes narrowing what Indri queries accept.

Searches4 searches

Re-checked 2026-11-03 in two independent passes and again on 2026-11-04: no Lucene issue, PR, JIRA entry, list thread or commit, and nothing outside Lucene, reports that IndriAndQuery ignores Occur or that its explain contradicts its hits. #463 and the other items below are related but about different things.

  • #463 (open, unmerged, stale; LUCENE-10157, by cammiemw, opened 2021-11-22; commit 20e8c9c1c7, 2022-01-10): a feature PR (IndriOr, IndriWeightedSum, an Indri query parser). Its IndriAndWeight change deletes the isRequired branch of explain, described as tweaking the explain methods so they "do a better job of explaining the scores" (PR comment, 2022-01-10). It never mentions Occur, adds no validation, and its parser builds SHOULD clauses only: the code fix for the explain half, not a report of the defect.
  • lucene-solr#2097 review r532223725 (mikemccand, 2020-11-29): about naming a disjunctive query "And"; design context. r532221029 asks which clause query types are supported, not about Occur.
  • #11194 (LUCENE-10157): proposes moving Indri to the sandbox and accepting only IndriQuery clauses (jpountz): about clause types. #10577 (LUCENE-9537): the feature issue and a scoring discussion (all scores 0).
  • Lucindri (downstream): builds IndriAndQuery with MUST and MUST_NOT clauses; no issue there reports this.

Caveat: keyword search; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream projects, dev@, java-user@, issues@, commits@, JIRA2026-11-04scripted prior-report search, 45 queries (16 GitHub, 3 commit, 2 downstream, 20 list, 4 JIRA), 2,680 distinct hitsthe only Indri items are #10577, #463 and LUCENE-10157 (#11194)
GitHub (manual, read-only)2026-11-04Indri items created since 2026-10-27; IndriAndQuery; IndriAndWeight; "Indri Occur"; all Indri PRs and issues; commits on the four Indri filesnothing new: #463, #11194, #10577, #4033, #2614, unrelated #15818; no commit after 5adff78bb168 on upstream main f72004515ec2
GitHub, downstream projects, mailing lists, JIRA, the web, the changed files of all 410 open PRs2026-10-28205 queries (three tool runs, 7 web searches, 39 manual searches and reads)only the related items above
Re-check2026-11-03two independent passes over the items recorded earlierrelated but about a different defect; nothing describes this defect

History

Present since IndriAndQuery was added (LUCENE-9537, 9cc5c9b7983, 2021-01-29) and first released in 8.9.0; never changed since. Executed on all 41 releases 8.9.0 to 10.5.2, on 10.6.0 and on main: affected each time. 8.8.2 and earlier have no such class.

Versions tested7 rows

Introduced by LUCENE-9537 "Add initial Indri search engine functionality to Lucene" (cammiemw, merged by mikemccand; apache/lucene-solr#2097, issue now #10577): 9cc5c9b7983 on main and a6125fb9385 on branch_8x, both 2021-01-29; the Occur-blind scorer and the Occur-reading explain are both in that commit. Not a regression: later commits (#40, #13261, #13319, #13408, #13440, #14023, #14052, #14216, #16255) changed APIs, iterator internals, annotations, lint and typos only. Upstream main f72004515ec2 (2026-11-03) has no later commit on the Indri files.

Versions and branches executed, with results
Version or commitVerdictResult
8.7.0 - 8.8.2 beforeno IndriAndQuery (the probe does not compile)
8.9.0 first: 8.9.0history probe: all four defects
8.10.0 - 9.0.0 affectedhistory probe: identical output
9.1.0 affectedthis doc's test fails (assertions off); history probe identical
9.2.0 - 10.5.2 affectedhistory probe identical; the finder's test fails on 9.12.3, 10.0.0 and 10.5.2
10.6.0 affectedthis doc's test fails (assertions off); branch_10_6 head 4ae946e5a9b9 fails the finder's test
main 5adff78bb168 affectedthis doc's test fails through gradle, assertions on and off; passes with fix A

Independent checks

An independent reproducer and a reviewer from another model family (OpenAI GPT-5.6, reading-only) rated it real; the intended-behaviour verifier split it (matching by design but undocumented, explain a real inconsistency); the adjudication confirmed it at Low.

Checks and verdicts267 words

The reviews by another model family could not run code; a separate run re-ran the finder's test each time and it failed. The refute-lens review's "not real" is procedural (it could not run code) and it found a real flaw in the first fix-B sketch, which was corrected.

Each independent check and its verdict
CheckVerdictWhat it found
finder found (Low)Four-case JUnit fails on main (assertions on and off), branch_10_6, 10.5.2 and 9.12.3; Z3 model and bounded check; fix-B sketch passes.
cross-model review, refute lens (OpenAI GPT-5.6, reading-only) not real (procedural, low)Root cause agreed, inputs in contract; called the search/explain disagreement "especially difficult to defend as intended"; found a flaw in the first fix sketch.
independent reproducer realOwn JUnit with a SHOULD-only control fails 5 of 6 on main (on, off), 10.5.2 and 9.12.3; the control passes.
cross-model review, intended lens (OpenAI GPT-5.6, reading-only) real (medium)The all-SHOULD union is intended; the silent acceptance of other values and the explain inconsistency are not.
intended-behaviour verifier uncertain (split)Matching: by design but undocumented (hardening). Explain: a real self-inconsistency. Probe: over 192 two-clause lists hits equal the SHOULD re-tagged lists; all 224 explain disagreements involve MUST or FILTER.
adjudication confirmed (Low, medium confidence)Re-ran the reproducer's JUnit with a new seed (5 of 6 fail, on and off) and the finder's test; a Low-tier hardening issue.
this doc's runs reproduced; fix validatedOne-test repro fails on main (gradle, on and off), 10.6.0 and 9.1.0; passes with fix A (37 tests, core check) and with fix B's replacement classes.

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

Finding record

LFV-871, snapshot of 2026-11-06

Registry title
IndriAndQuery accepts MUST, FILTER and MUST_NOT clauses without complaint but ignores them when matching (it always matches the union of all clauses), while its explain does read MUST/FILTER, so it explains some of its own hits as non-matches
Area
search / IndriAndQuery (clause Occur handling in matching and explain)
Priority
Low, rank 601: IndriAndQuery widens hits for MUST, FILTER or MUST_NOT clauses and its explain disagrees with its hits, but the class is niche, the contract is arguable, and open PR #463, a related feature PR, already deletes the faulty explain branch.
Impact class
minor
Impact note
Only applications that build an IndriAndQuery (a niche core API that no Lucene query parser produces) with a MUST, FILTER or MUST_NOT clause are affected: matching treats every clause as a disjunct, which fits the Indri #and design but is undocumented and unvalidated, so such clauses widen instead of restrict the hits, and IndexSearcher.explain reports some returned hits as 'Failure to meet condition(s) of required/prohibited clause(s)'; default codec, writer config and searcher, any index size, every release since 8.9.0; no exception and no index damage.
Summary
You build an IndriAndQuery from BooleanClauses with Occur values other than SHOULD, for example [MUST f:a, FILTER acl:x] or [SHOULD f:a, MUST_NOT f:secret], which the public constructor accepts, and run it. You expect either the meaning that BooleanClause.Occur documents (the same clauses in a BooleanQuery give {0, 2} and {0, 1}) or a rejection, and in any case an explain that agrees with the hits. Instead IndriAndWeight builds one disjunction over all clauses whatever their Occur, so MUST and FILTER clauses do not restrict ({0, 1, 2, 3}), a MUST_NOT clause adds the very documents it names ({0, 1, 2}; a lone MUST_NOT clause returns exactly them), and IndriAndWeight.explain, which does treat MUST/FILTER clauses as required, explains such hits as non-matches.
Introduced
9cc5c9b7983cf181280e18819f8516dc16ccf1ef (LUCENE-9537 'Add initial Indri search engine functionality to Lucene', author cammiemw, merged by mikemccand; apache/lucene-solr#2097, feature issue now apache/lucene#10577), released through its branch_8x backport a6125fb93855 (same author date, committed 2021-01-29 16:38 -0500) [read: git log --diff-filter=A over the Indri classes in the full-history clone; the Occur-blind getScorer/IndriDisjunctionScorer and the Occur-reading explain (c.isRequired()) are both in the introducing commit; exec: affected on 8.9.0, API absent on 8.8.2]; not a regression: no later commit changed either behaviour (2021-01-29)
First affected version
8.9.0 [exec] (first on the main line: 9.0.0)
Affected versions
Every release that has the class: 8.9.0 to 8.11.4, 9.0.0 to 9.12.3 and 10.0.0 to 10.5.2, 41 releases [exec: the history probe IndriOccurHistoryProbe (public-API main(), -da) gives identical output on each: SHOULD-only control passes, lone MUST_NOT -> {2}, SHOULD+MUST_NOT -> {0,1,2}, MUST+FILTER -> {0,1,2,3}, a hit explained as a non-match]; the finder's JUnit fails on 9.1.0, 9.12.3, 10.0.0 and 10.5.2 (-ea) and the independent reproduction's on 10.5.2 and 9.12.3; the finder's JUnit does not compile on 9.0.0, whose test framework predates the 9.1.0 package rename (a limit of the test setup, not a statement about the bug); branch_10_6 head 4ae946e5a9b9 (10.6.0, released 2026-10-29) [exec, -da]; main a9779ef3c193 [exec, -ea and -da, re-run at the registration check]; upstream main c5ab3e57b9b (2026-10-28) unchanged [read: the last commits on IndriAndWeight.java (d0d2aa274fb, 2024-06-06), IndriAndQuery.java (9cc5c9b7983) and IndriDisjunctionScorer.java (54283150a81, typo check) predate it]. Not applicable to 8.8.2 and earlier: IndriAndQuery does not exist [exec: compile error on 8.7.0, 8.8.0, 8.8.1, 8.8.2]. All runs on aarch64 Linux with JDK 25.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal; also reached by Z3, exhaustive check
Upstream and prior reports
related items only, no report of this defect (searched 2026-10-28, read-only, refreshed the same day; re-checked 2026-11-03 in two independent passes): no issue, PR, review comment, JIRA entry, mailing-list thread, downstream issue or web page reports that IndriAndQuery ignores BooleanClause.Occur or that its explain contradicts its hits (205 queries: three scripted prior-report searches over GitHub issues/PRs and commits, Elasticsearch/OpenSearch/Solr, dev@/java-user@/issues@/commits@ and the JIRA archive; 7 web searches; 39 manual searches and reads, including the changed-file lists of all 410 open apache/lucene PRs and of every PR created since 2026-10-20; plus the finder's four runs and the adjudication's check). Related: open, unmerged, stale PR apache/lucene#463 (LUCENE-10157, by the original author cammiemw; its commit 20e8c9c1c7, author date 2022-01-10) deletes the 'else if (c.isRequired())' branch of IndriAndWeight.explain, which is the code fix of the explain half, without describing it as a bug; it does not touch matching or validate Occur. Design context, not reports: lucene-solr#2097 review comment r532223725 (mikemccand, 2020-11-29: 'this query is actually disjunctive'); r532221029 asks which clause Query types are supported, not about Occur. Related, not this bug: #10577 (all scores 0, a scoring defect), #11194 (proposal to move Indri to the sandbox and accept only IndriQuery clauses), #11290/#469/#669 (smoothingScore removal), #11928, #12295. Downstream usage, not a report: the Lucindri parser (lemurproject/Lucindri, on lucene-core 8.10.0) builds IndriAndQuery with Occur.MUST / MUST_NOT for its documented #scoreif / #scoreifnot filters [read, not executed]. Refresh: gh search for Indri PRs and issues returns only #463, #15818 (unrelated), #11194, #10577, #4033 and #2614; nothing created since 2026-10-26; the 40 newest PRs (to #16926) have no Indri change and #16919 touches no Indri file; no upstream commit after a9779ef3c193 touches the Indri files. Not fixed anywhere.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), 2026-11-04: the one-test repro through gradle in a scratch worktree, stock (fails, assertions on and off) and with fix A (passes; 37 tests in the Indri, BooleanQuery and QueryVisitor suites; :lucene:core:check -x test); the standalone JUnit runner on main (assertions on and off) and on the 10.6.0 and 9.1.0 release jars (fails), and with option B's replacement classes (passes). Earlier records: the finder's and verifiers' tests on main a9779ef3c193, whose Indri sources are identical, a history probe on all 41 releases 8.9.0 to 10.5.2, and a re-run on main e1faeba08bde (2026-11-02).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:41-44 (a weight per clause, Occur not consulted), :47-65 (getScorer: a scorer for every clause; a lone clause's scorer returned as is), :76-106 (explain: c.isRequired() at :86 -> 'no match on required clause' and 'Failure to meet condition(s) of required/prohibited clause(s)' at :93-94)
  • lucene/core/src/java/org/apache/lucene/search/IndriAndQuery.java:22 (javadoc 'A Query that matches documents matching combinations of subqueries'), :25-27 (public constructor taking List<BooleanClause>, no validation)
  • lucene/core/src/java/org/apache/lucene/search/IndriDisjunctionScorer.java:23-41 ('The Indri implementation of a disjunction scorer'; DisjunctionDISIApproximation over all sub-scorers)
  • lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:21 ('A clause in a BooleanQuery'), Occur javadoc for MUST, FILTER and MUST_NOT
  • lucene/core/src/test/org/apache/lucene/search/TestIndriAndQuery.java:113-131 (SHOULD clauses only)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/IndriAndQuery.java:22, (at 5adff78bb168) :25-27, :33
  • lucene/core/src/java/org/apache/lucene/search/IndriQuery.java:32-34, (at 5adff78bb168) :41-64 (toString prints c.occur() at :46)
  • lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:41-44, (at 5adff78bb168) :47-65, :76-106 (isRequired at :86, failure message at :92-94)
  • lucene/core/src/java/org/apache/lucene/search/IndriDisjunctionScorer.java:23-27 (at 5adff78bb168) (javadoc), :36-41
  • lucene/core/src/java/org/apache/lucene/search/BooleanClause.java:21-67 (at 5adff78bb168) (Occur javadoc), :80-82 (isRequired); BooleanWeight.java:65-108 (:86 isProhibited branch, :108 message)
Who is affected
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:134-147 (at 5adff78bb168) (check runs checkExplanations at :147); CheckHits.java:318-342 (checkExplanations), :580 (ExplanationAsserter), :607-627 (collect, assertTrue isMatch at :619-626)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:47-65, (at 5adff78bb168) :86-94; IndriDisjunctionScorer.java:23-27, :36-41; IndriAndQuery.java:22; IndriQuery.java:46; BooleanClause.java:21, :27, :35, :57-61; Weight.java:97-105
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:134-147; (at 5adff78bb168) CheckHits.java:318-342, :580, :607-627
  • lucene/core/src/test/org/apache/lucene/search/TestIndriAndQuery.java:113-114, (at 5adff78bb168) :130-131
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/IndriQuery.java:33, (at 5adff78bb168) :94-96 (clauses stored and returned as given)
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:41-44, (at 5adff78bb168) :47-65, :86; IndriDisjunctionScorer.java:36-41

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 matching path and its result on the finder's four-document index, made in parallel with this doc from the code at 5adff78bb168 and executed runs; 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.