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(core,org.apache.lucene.search, since 8.9.0) implements the#andoperator 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.- Its clauses are
BooleanClauses, the typeBooleanQueryuses, so each has anOccur: SHOULD (optional), MUST (required and scored), FILTER (required, not scored) or MUST_NOT (excluded). IndexSearcher.explain(query, doc)explains how a document scored; for a document the search returned, it should say that the document matches.
[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.- Index two documents, doc 0 with
f=aand doc 1 withf=b. Buildnew 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 aBooleanQuery. - You expect doc 0 only, which is what the same two clauses give in a
BooleanQuery(1 hit), or a rejection, sinceIndriAndQueryhas no required or excluded clauses. - 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. explaindoes readOccur: 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.- On four documents,
[MUST f:a, FILTER acl:x]returns all 4 where aBooleanQueryreturns 2, and a lone[MUST_NOT f:secret]returns exactly the excluded document. No exception, on every release since 8.9.0.
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.
// 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-94BooleanClause.isRequired()isoccur == MUST || occur == FILTER(BooleanClause.java:80-82).IndriAndWeight.explainkeeps only theisRequiredbranch ofBooleanWeight.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,
getScorerreturns that clause's own scorer, so a lone MUST_NOT clause returns exactly the documents it names.
| index | clauses | IndriAndQuery hits | same 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
IndriAndQuerywith 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.explainreports some returned hits as non-matches. No exception, no index damage. - Why someone passes them. The constructor asks for an
Occuron every clause, the class is called "And", andtoStringprints+,#and-. The Lucindri parser (lemurproject/Lucindri, on lucene-core 8.10.0) builds Lucene'sIndriAndQuerywithOccur.MUSTandOccur.MUST_NOTfor its#scoreif/#scoreifnotoperators, 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);IndriAndQuerybreaks 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.
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.
/*
* 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);
}
}
}
}
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(...)throwsIllegalArgumentException, or the search returns doc 0 only andexplaincalls it a match. - Observed
expected:<[doc 0 explain isMatch=true]> but was:<[doc 0 explain isMatch=true, doc 1 explain isMatch=false]>atTestIndriAndQueryOccur.java:61.
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).
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); the same test also fails on the 10.6.0 and 9.1.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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: ...- The class reads
Occuritself:explaintreats 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, viaCheckHits.checkExplanations) asserts that every hit explains as a match. - Nothing documents that
Occuris ignored, yet the constructor demands one per clause andtoStringprints+,#and-.
The full argument148 words
- Self-inconsistent.
explainapplies MUST/FILTER (IndriAndWeight.java:86-94);getScorerandIndriDisjunctionScorerdo 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, whichQueryUtils.checkruns, 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); theOccurjavadoc gives MUST, FILTER and MUST_NOT a meaning (BooleanClause.java:27,:35,:57-61);IndriQuery.toStringprints them (:46). - Tests.
TestIndriAndQueryuses SHOULD clauses only (:113-114,:130-131); no test covers other values. - History. Both behaviours came with the introducing commit
9cc5c9b7983(LUCENE-9537); the firstexplainwas a copy ofBooleanWeight.explain, and the original author's later PR #463 deletes itsisRequiredbranch.
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".
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
Occurvalues today: they get an exception instead of silently widened hits (by reading, Lucindri's#scoreif/#scoreifnotwould start failing loudly). Fine for main; for a 10.x backport a maintainer may prefer C. IndriQuerykeeps the caller's list as is (IndriQuery.java:33), so mutating that list after construction bypasses the check;List.copyOfwould close that, butgetClauses()would then return a copy.
What the checks validated
- The repro passes with assertions on and off.
TestIndri*(TestIndriAndQuery2 tests and the repro),TestBooleanQuery(30) andTestQueryVisitor(4): 37 tests, 0 failures.:lucene:core:check -x testpasses (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.
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.
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.
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 (
IndriAndQuerydoes not rewrite its clauses; a different root and fix site), found[MUST f:a, FILTER acl:x]returning {0, 1, 2, 3} where the sameBooleanQueryreturns {0, 2}. - A Z3 model of the matching and explain rules proved that
IndriAndQueryequals theBooleanQueryover 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).
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). ItsIndriAndWeightchange deletes theisRequiredbranch ofexplain, described as tweaking the explain methods so they "do a better job of explaining the scores" (PR comment, 2022-01-10). It never mentionsOccur, 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
IndriQueryclauses (jpountz): about clause types. #10577 (LUCENE-9537): the feature issue and a scoring discussion (all scores 0). - Lucindri (downstream): builds
IndriAndQuerywith MUST and MUST_NOT clauses; no issue there reports this.
Caveat: keyword search; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream projects, dev@, java-user@, issues@, commits@, JIRA | 2026-11-04 | scripted prior-report search, 45 queries (16 GitHub, 3 commit, 2 downstream, 20 list, 4 JIRA), 2,680 distinct hits | the only Indri items are #10577, #463 and LUCENE-10157 (#11194) |
| GitHub (manual, read-only) | 2026-11-04 | Indri items created since 2026-10-27; IndriAndQuery; IndriAndWeight; "Indri Occur"; all Indri PRs and issues; commits on the four Indri files | nothing 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 PRs | 2026-10-28 | 205 queries (three tool runs, 7 web searches, 39 manual searches and reads) | only the related items above |
| Re-check | 2026-11-03 | two independent passes over the items recorded earlier | related but about a different defect; nothing describes this defect |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 8.7.0 - 8.8.2 | before | no IndriAndQuery (the probe does not compile) |
| 8.9.0 | first: 8.9.0 | history probe: all four defects |
| 8.10.0 - 9.0.0 | affected | history probe: identical output |
| 9.1.0 | affected | this doc's test fails (assertions off); history probe identical |
| 9.2.0 - 10.5.2 | affected | history probe identical; the finder's test fails on 9.12.3, 10.0.0 and 10.5.2 |
| 10.6.0 | affected | this doc's test fails (assertions off); branch_10_6 head 4ae946e5a9b9 fails the finder's test |
main 5adff78bb168 | affected | this doc's test fails through gradle, assertions on and off; passes with fix A |
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.
| Check | Verdict | What 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 | real | Own 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 validated | One-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. |
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 495 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,318 words
- 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 maina9779ef3c193, whose Indri sources are identical, a history probe on all 41 releases 8.9.0 to 10.5.2, and a re-run on maine1faeba08bde(2026-11-02).
- 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_NOTlucene/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, :33lucene/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-41lucene/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-105lucene/test-framework/src/java/org/apache/lucene/tests/search/QueryUtils.java:134-147; (at 5adff78bb168) CheckHits.java:318-342, :580, :607-627lucene/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
- TestIndriAndQueryOccur.java (one test, lucene/core):
repro/LFV-871/TestIndriAndQueryOccur.java(65 lines)
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.
assets/LFV-871/bug.png(495 KB)
