IndriAndWeight.explain returns a match whenever no required clause failed. Before that it moves the scorer with advance(doc) and checks the result only with an assert, so for a non-hit it reports the score of wherever the scorer landed.
IndexSearcher.explain(query, doc)describes how a document scored, or says it does not match (Explanation.isMatch()is false). It takes any valid document id, hit or not.- A scorer walks the matching documents in order.
advance(target)jumps to the first matching document at or aftertarget, so it can land pasttarget. IndriAndQuerycombines clauses, usually SHOULD clauses; its hits are the documents that match at least one clause.
f = "a"), the one hit doc 1 with score 0.13076457, advance(0) landing on doc 1, and explain returning a match with doc 1's score instead of a no-match. Select the still to open it at full size. Generated with AI, checked against the code.- Index two documents: doc 0 is empty, doc 1 has the field
f= "a". SearchIndriAndQuery(SHOULD f:a): one hit, doc 1, score 0.13076457. - Call
searcher.explain(query, 0). You expect a no-match: doc 0 has nof:a, andTermQuery(f:a)explains it as a non-match. - Instead explain says
isMatch() == truewith the value 0.13076457, doc 1's score, under an empty "sum of:". With assertions on it throwsAssertionErroratIndriAndWeight.java:99. - Why: explain moves the query's scorer with
advance(0), which returns the first matching document at or after 0, doc 1. The code only asserts that it got 0, then returnsExplanation.match(scorer.score(), ...). - After the last hit there is nothing to land on: on a five-document text index (default codec,
MMapDirectory), explaining doc 4 throwsjava.io.EOFException: read past EOFfrom the norms.
Applications that use IndriAndQuery (a public core API that no Lucene parser builds) and call explain on a document the query does not return, alone or inside a BooleanQuery. Default codec and settings; two documents suffice. This bug does not change hits, counts or scores, nor how hits are explained (the separate Indri bugs LFV-871 and LFV-874 do).
Mechanism, terms and numbers750 words
IndriAndWeight.explain (IndriAndWeight.java:76-106 at 645d4fe14a20) collects the explanations of the matching clauses and returns a no-match only when a required clause fails (:86-94). With SHOULD clauses, the only Occur upstream TestIndriAndQuery uses, every document reaches the tail (:96-100): when at least one clause has a scorer in the segment, it takes the query's scorer, calls scorer.iterator().advance(doc), checks the result only with assert advanced == doc, and returns Explanation.match(scorer.score(), "sum of:", subs). advance(doc) returns the first match at or after doc, so for a document that matches no clause and is not a hit the scorer stands on the next hit and explain reports a match with that hit's score. If no hit follows, score() runs at NO_MORE_DOCS and the norms read throws when the norms vary (on main with the default codec: java.io.EOFException on MMapDirectory and NIOFSDirectory, IndexOutOfBoundsException on a plain ByteBuffersDirectory; the class depends on the Directory and the version); with constant or omitted norms it returns a meaningless match instead. With assertions on, every such call (the scorer past the document) throws AssertionError. BooleanWeight.explain, which this method mirrors, returns noMatch("No matching clauses") before the same tail; the Indri copy lost that guard. This bug does not change hits, counts or scores; every release since 8.9.0 and main are affected.
- Entry.
IndexSearcher.explain(Query, int)(IndexSearcher.java:941-944) callsexplain(Weight, int)(:959-968), which takes any valid document id, returns a no-match for a deleted one (:964-966) and otherwise callsweight.explain(ctx, doc)(:967). - Clause loop.
IndriAndWeight.java:79-91: a matching clause's explanation goes intosubs(:84-85); a non-matching clause setsfailonly if it is required (:86-90). A non-matching SHOULD clause is dropped. - The tail (
:96-100), reached wheneverfailis false:
Scorer scorer = scorer(context);
if (scorer != null) {
int advanced = scorer.iterator().advance(doc);
assert advanced == doc; // :99, the only check
return Explanation.match(scorer.score(), "sum of:", subs); // :100
}scorer(context) is null only when no clause has a scorer in the segment (getScorer, :47-65); then explain correctly says no match (:101-104). With one clause the scorer is that clause's scorer; with several it is an IndriAndScorer (:60-63).
- Where the scorer lands.
advance(target)"Advances to the first beyond the current whose document number is greater than or equal to target ... Exhausts the iterator and returns NO_MORE_DOCS if target is greater than the highest document number in the set" (DocIdSetIterator.java:137-139). For a document that matches no clause and is not a hit, that is the next hit, whose score explain then reports, orNO_MORE_DOCS(2147483647), wherescore()reads norms for a document that does not exist. - Everyone else checks.
BooleanWeight.explainreturnsnoMatch("No matching clauses", subs)when no clause matched (BooleanWeight.java:109-111) and only then reaches the sameadvance(doc); assert advanced == doctail (:126-129).TermWeight.explainuses the advance onlyif (newDoc == doc)(TermQuery.java:228-229); the defaultWeight#matches, whichIndriAndWeightdoes not override, returns null whenadvance(doc) != doc(Weight.java:78-92);ConstantScoreWeight.explain(:52-54) andPhraseWeight.explain(:75) check too.
| index, query | document | explain, assertions off | correct answer |
|---|---|---|---|
doc 0 empty, doc 1 f=a (StringField); IndriAndQuery(SHOULD f:a); one hit, doc 1, 0.13076457 | 0 | match, 0.13076457 = sum of: (doc 1's score) | no match |
five TextField docs "x y", "a x", "y z q", "a b c d", "z z z z z z"; default codec, MMapDirectory, BM25; IndriAndQuery(SHOULD body:a); hits 1 (0.478552) and 3 (0.37114638) | 0 | match, 0.478552 (doc 1's score) | no match |
| same | 2 | match, 0.37114638 (doc 3's score) | no match |
| same | 4 (after the last hit) | java.io.EOFException: read past EOF (pos=2147483647) from the norms (.nvd) | no match |
two SHOULD clauses f:a, f:b (finder's test) | non-hits 0 and 3 | match, 0.0 | no match |
With assertions on, every row throws AssertionError at IndriAndWeight.java:99. A BooleanQuery with the same SHOULD clause explains every non-hit as a non-match. The exception class past the last hit depends on the Directory and the version: on main EOFException on MMapDirectory and NIOFSDirectory, IndexOutOfBoundsException on a plain ByteBuffersDirectory (and on MMapDirectory too in 8.9.0, 8.11.4 and 9.0.0); with constant or omitted norms the call returns a match with a meaningless value instead (0.0 when every document had the same length).
Exposure and likelihood179 words
- Who is hit. Code that builds an
IndriAndQuery(public, inorg.apache.lucene.search, not marked experimental or internal; no in-tree parser or other code builds it) and explains a document that is not a hit, for example a "why did this document not match?" tool or a check that explains every document. - Nested queries. The false match spreads:
BooleanQuery(MUST IndriAndQuery(george, washington), SHOULD president)explains its non-hits 1 and 3 as matches (0.0 and -1.8996168) with assertions off and throwsAssertionErrorwith assertions on. - Default vs opt-in. Default codec,
IndexWriterConfig, searcher and similarity (BM25; alsoIndriDirichletSimilarity); opt-in only in choosingIndriAndQuery. Any index size. - Effect. A wrong diagnostic (a non-hit reported as a match with another document's score), or one exception for that call:
EOFExceptionorIndexOutOfBoundsExceptionwith assertions off,AssertionErrorwith assertions on (as in Lucene's own test runs). No state is left behind. - Workaround. Check that the document is a hit (or that
weight.matches(ctx, doc)is non-null) before calling explain. Both follow the query's scorer, so with two-phase clauses they share the separate over-matching of LFV-874.
One JUnit test in lucene/core, public API only: two documents in a ByteBuffersDirectory, IndriAndQuery(SHOULD f:a), and one assertion that explain(query, 0) is not a match. Deterministic, single thread. It fails on main with assertions on and off, and on the 9.12.3 and 10.6.0 release jars.
/*
* 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.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.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestIndriAndExplainNonMatch extends LuceneTestCase {
public void testExplainDocumentMatchingNoClause() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
Document doc = new Document();
doc.add(new StringField("f", "a", Field.Store.NO));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
Query query =
new IndriAndQuery(
List.of(new BooleanClause(new TermQuery(new Term("f", "a")), Occur.SHOULD)));
Explanation explanation = new IndexSearcher(reader).explain(query, 0);
// doc 0 has no f:a: expected a no-match explanation; actual a match with doc 1's score
assertFalse("expected no match for doc 0, got " + explanation, explanation.isMatch());
}
}
}
}
cp $REPRO/TestIndriAndExplainNonMatch.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestIndriAndExplainNonMatch -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
- Expected
explain(query, 0).isMatch()is false: doc 0 does not containf:aand is not a hit.- Observed
- A match,
0.13076457 = sum of:(doc 1's score), with assertions off;AssertionErroratIndriAndWeight.java:99with assertions on.
More files, environment, full output223 words
The test fixes the directory, the documents, the query and the searcher (a plain IndexSearcher, BM25); the test framework still picks its environment per seed (seed DEADBEEF ran with codec=Asserting(Lucene104), locale=nb); seeds DEADBEEF and C0FFEE gave the same message. The test needs org.apache.lucene.tests.*, so it compiles against 9.1.0 and later.
This test shows the false match. The exception after the last hit comes from the same line; the five-document case in the full explanation above shows it.
Suggested regression test: this same test, as one method in TestIndriAndQuery or as its own class.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 9.12.3 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run; pure Java logic) |
# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true
TestIndriAndExplainNonMatch > testExplainDocumentMatchingNoClause FAILED
java.lang.AssertionError
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8A07A2C5A9153847]:0)
at org.apache.lucene.search.IndriAndWeight.explain(IndriAndWeight.java:99)
at org.apache.lucene.search.IndexSearcher.explain(IndexSearcher.java:967)
at org.apache.lucene.search.IndexSearcher.explain(IndexSearcher.java:943)
at org.apache.lucene.search.TestIndriAndExplainNonMatch.testExplainDocumentMatchingNoClause(TestIndriAndExplainNonMatch.java:46)
:lucene:core:test (FAILURE): 1 test, 1 failure
# the same, -Ptests.asserts=false
TestIndriAndExplainNonMatch > testExplainDocumentMatchingNoClause FAILED
java.lang.AssertionError: expected no match for doc 0, got 0.13076457 = sum of:
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8A07A2C5A9153847]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.junit.Assert.assertFalse(Assert.java:65)
at org.apache.lucene.search.TestIndriAndExplainNonMatch.testExplainDocumentMatchingNoClause(TestIndriAndExplainNonMatch.java:48)
:lucene:core:test (FAILURE): 1 test, 1 failure
# 9.12.3 and 10.6.0 release jars, -da: the same 'expected no match for doc 0, got 0.13076457 = sum of:'
# with the advance-check fix on main: 1 test, 0 failures (assertions on and off)Weight#explainexplains "the named document",Explanation.noMatchexists for non-matches, and Lucene'sCheckHits.checkNoMatchExplanationsrequires non-hits to explain as non-matches.BooleanWeight.explain, which this code mirrors, returns "No matching clauses" before the sameassert;TermWeightandWeight#matchescheckadvance(doc) == doc.- The value is another document's score, and throwing for a valid document id is no documented outcome.
The full argument260 words
- The contract covers non-hits.
Weight#explain: "An explanation of the score computation for the named document" (Weight.java:98).Explanation.noMatchis documented "for a document which does not match" (Explanation.java:50,:55), andisMatch"Indicates whether this Explanation models a match" (:77).IndexSearcher.explaintakes any valid document id and gives a deleted one a no-match (IndexSearcher.java:964-966). - Lucene's test framework requires it.
CheckHits.checkNoMatchExplanations(lucene/test-framework/.../CheckHits.java:60-88) asserts that every non-hit up tomaxDocexplains withisMatch() == false; it runs in, among others,TestSimpleExplanationsOfNonMatches,TestComplexExplanationsOfNonMatchesandTestSpanExplanationsOfNonMatches. Applied unchanged to upstreamTestIndriAndQuery's corpus, similarity (IndriDirichletSimilarity) and query, it fails: "Explanation of [[george washington]] for #1 doesn't indicate non-match: 0.0 = sum of:". No upstream test calls explain on an Indri query. - The assert is a copied invariant, not a precondition. The method mirrors
BooleanWeight.explain(same structure, messages and tail). There the tail came with LUCENE-8008 ("Reduce leniency in CheckHits",d27ddcb409d, 2017) and is reached only after thematchCount == 0guard, which makes the assert true. The Indri copy kept the assert and dropped the guard (an inference from the code text; the PR does not say). - History. In the introducing PR (apache/lucene-solr#2097) a reviewer asked at these lines whether
scorercan be null (r555792974, 2021-01-12); the follow-up added onlyscorer != null. Nobody discussed whereadvance(doc)lands. - Precedent. LUCENE-7065 fixed the same symptom in the global-ordinals join query as a bug: "Before the explain would also indicate that non matching documents would match." (
lucene/CHANGES.txt:9496-9498).
The devil's-advocate check176 words
Strongest case (from the cross-model reviewer, the intended-behaviour checker and the adjudication): the search package docs frame explain around "a certain matching document" (package-info.java:230-232), so the assert may state a hit-only precondition; Indri smoothing gives every document a score (IndriAndScorer.java:22-25); the feature is lightly maintained (on #10577 jpountz noted in 2021 that all matching documents scored 0); and the only open PR on this code, #463 (stale), keeps the unchecked advance and copies it into a new IndriOrWeight.
Why it fails: no javadoc states a hit-only precondition, and the package sentence describes typical use. The value explain returns is not this document's smoothing score but another document's score: on the upstream corpus doc 0 explains as -2.9882789, the search score of hit doc 2, while TermScorer#smoothingScore(0) is -2.9982293. Search, count, Weight#matches and a BooleanQuery with the same clauses all agree that these documents do not match; only explain disagrees. Throwing EOFException or AssertionError for a valid document id is no documented outcome, and a stale, unmerged PR is no design decision.
Recommended Return a match only if advance(doc) == doc
Return the match only when the scorer landed on the document: if (scorer != null && scorer.iterator().advance(doc) == doc), else the existing no-match (+1/-3 in IndriAndWeight.java). With it the repro passes with assertions on and off, and the whole core org.apache.lucene.search test package passes (215 classes, 2,340 tests, 0 failures).
Scorer scorer = scorer(context);
if (scorer != null && scorer.iterator().advance(doc) == doc) {
return Explanation.match(scorer.score(), "sum of:", subs);
} else {
return Explanation.noMatch(
"Failure to meet condition(s) of required/prohibited clause(s)", subs);
}All options and trade-offs3 options · 449 words
The recommended fix was applied to a scratch worktree of 645d4fe14a20 and run through gradle; the alternatives were not run.
Fold the advance into the condition of IndriAndWeight.explain's tail (the diff replaces IndriAndWeight.java:97-99), so a document the scorer skips gets the existing no-match explanation.
Scorer scorer = scorer(context);
if (scorer != null && scorer.iterator().advance(doc) == doc) {
return Explanation.match(scorer.score(), "sum of:", subs);
} else {
return Explanation.noMatch(
"Failure to meet condition(s) of required/prohibited clause(s)", subs);
}Trade-offs
- The same
advance(doc) == doccomparison thatTermWeight.explainand the defaultWeight#matchesmake; one comparison in an already expensive diagnostic call; hits are explained exactly as before. - The no-match message is the existing generic one, which reads oddly for a SHOULD-only query; BooleanWeight's "No matching clauses" may read better (next option).
- With several clauses the check is only as exact as
IndriAndScorer's iterator, which has its own issue with two-phase clauses (LFV-874, a separate fix site). - The open, stale PR #463 would need the same check in
IndriAndWeightand in its newIndriOrWeightif it is ever revived.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20with the repro test added, seed DEADBEEF: stock, the repro fails with-Ptests.asserts=trueandfalse; with the fix it passes both ways. - With the fix:
TestIndri*and*Explanations*plus the repro, 8 classes, 238 tests, 0 failures (2 skipped); the whole coreorg.apache.lucene.search.*test package (withsimilaritiesandcomparators), 215 classes, 2,340 tests, 0 failures, 0 errors, 49 skipped. :lucene:core:checkGoogleJavaFormatpasses with the fix.- Earlier hunt runs with the fix shadowed: the finder's 2 tests, the independent reproduction's 6 tests (also seed
C0FFEE), the intended-behaviour check's probe and upstreamTestIndriAndQuerypass; on 8.9.0 the history check goes from 8 of 8 wrong explains to 0 of 8.
Before the tail, return Explanation.noMatch("No matching clauses", ...) when no clause explanation matched, and keep the advance check. Not run.
Trade-offs
A clearer message for the common case, and the method then matches BooleanWeight.explain line for line. Do not use the clause test instead of the advance check: explain must agree with the scorer, and where the scorer and the clause explanations disagree (LFV-874's two-phase case) a clause-only test would explain a returned hit as a non-match (by reading).
Leave the code and state a hit-only precondition in the javadoc. Not run.
Trade-offs
No code change, but it contradicts CheckHits.checkNoMatchExplanations, the other Weight#explain implementations and IndexSearcher.explain's handling of non-hits, and explain still throws after the last hit. Not recommended.
Downstream products are affected only if they let users build Indri queries and explain documents that are not hits; issue and PR searches of Elasticsearch, OpenSearch and Solr found no mention of Indri.
An explain-versus-search bounded-exhaustive check over the stock jars flagged IndriAndQuery non-hits explained as matches. A Z3 model of its matching and explain rules then pinned the trigger (a clause with a scorer, no clause matching the document), a bounded check of the real jar (60,288 cases) agreed with the model, and a public-API JUnit confirmed it.
The method in full185 words
Method: semi-formal. The first signal (2026-10-26) came from a bounded-exhaustive translation-validation harness that compares explain with search scores over the stock jars: its IndriAndQuery rows showed 303 non-hits explained as matches and 4 IndexOutOfBoundsException on non-hits; that unit kept it as an unfiled lead. A later unit (2026-10-28, main a9779ef3c193) wrote a Z3 model of IndriAndQuery's matching and explain rules: the wrong explanation is satisfiable with SHOULD-only clauses, needs a clause with a scorer in the segment and no matching clause, and the advance check provably removes it for SHOULD-only clauses. A bounded-exhaustive check of the real core jar against that model ran 60,288 (query, layout, doc) checks with 0 model mismatches; 1,760 non-hits were explained as matches (assertions off) or threw (assertions on), counted in two disjoint rows: 1,480 with at least one MUST, FILTER or MUST_NOT clause and 280 with SHOULD-only clauses. With the advance check shadowed both rows were empty and exactly those 1,760 explains differed from the model of the stock code. A public-API JUnit witnessed it; a later minimisation step reduced it to the one-method test shown here.
No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream issue trackers or the web (searched 2026-10-28, refreshed 2026-10-30 and 2026-10-31). The closest item is the open, stale PR #463, which rewrites this method but keeps the unchecked advance.
Searches4 searches
Context, not reports: apache/lucene-solr#2097 r555792974 (the null-scorer question at the same lines, fixed before merge); #463 (open, stale; keeps the unchecked advance and copies it into IndriOrWeight); #7737 / LUCENE-6679 (open; the same symptom in the old Filter weight) and LUCENE-7065 (fixed; global-ordinals join explain); #16267 (says the CheckHits non-match check is shallow; does not name Indri); #10577 and #11194 (feature threads, no explain discussion). Hunt siblings with separate fix sites: LFV-871 (clause Occur ignored, IndriAndWeight.java:86) and LFV-874 (two-phase clauses in IndriDisjunctionScorer). Limits: keyword search only; one manual retry hit the search quota (a cached tool run covers it); private channels are not searchable.
| Where | When | How | Found |
|---|---|---|---|
| GitHub, mailing lists, JIRA archive, web (search script) | 2026-10-28 | 65 queries: 30 GitHub issue/PR on apache/lucene, 3 commit, 2 downstream (Elasticsearch, OpenSearch, Solr), 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA archive, 6 web | 362 distinct hits, none the same bug |
| GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr) | 2026-10-28 | 17 manual searches, including the last 48-96 h; the changed files of all 410 open apache/lucene PRs; full reads of 12 threads, including all 28 review comments of apache/lucene-solr#2097 | no report; only #463 touches IndriAndWeight.java and it keeps the defect |
| GitHub refresh | 2026-10-30 | issues and PRs mentioning Indri updated since 2026-10-28; commits to IndriAndWeight.java on main and branch_11x | none; last commit to the file d0d2aa274fb5 (2024-06-06) |
| GitHub refresh (saved record) | 2026-10-31 | issues and PRs mentioning Indri updated since 2026-10-28; text searches for issues with IndriAndWeight explain and PRs with IndriAndWeight (all time); the last commit to IndriAndWeight.java on main and branch_11x | 0 issues, 0 PRs; last commit to the file on both branches d0d2aa274fb5 (#13440, 2024-06-06) |
Introduced with the Indri classes by LUCENE-9537 (9cc5c9b7983, 2021-01-29) and first released in 8.9.0. Executed on 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2, 10.6.0 and main; not fixed upstream.
Versions tested8 rows
9cc5c9b7983 ("LUCENE-9537: Add initial Indri search engine functionality to Lucene", cammiemw, PR apache/lucene-solr#2097, reviewed and merged by mikemccand, author date 2021-01-29; issue now #10577) added the Indri classes, backported to branch_8x as a6125fb9385 the same day. The unchecked advance and the assert are in the PR's first commit 087d9d2a07 (2020-11-24); the only later edit inside explain() is #13261 (c.getQuery() -> c.query()). Not a regression, so no bisect. 8.8.2 has no Indri classes; the 8.9.0 IndriAndWeight.class is instruction-identical to the backport's source, and on 8.9.0 the advance check alone turns 8 of 8 wrong explains into 0 of 8. Upstream main 2f424fd428da and branch_11x (2026-10-30, re-read 2026-10-31): the last commit to the file is d0d2aa274fb5 (2024-06-06).
| Version or commit | Verdict | Result |
|---|---|---|
| 8.8.2 | no API | no IndriAndQuery; the history check does not compile |
| 8.9.0 | first release | history check: 8 of 8 non-hit explains wrong (-ea, -da); with only the advance check, 0 of 8 |
| 8.11.4, 9.0.0 | affected | history check: 8 of 8 wrong (-ea, -da), controls pass |
| 9.1.0, 9.12.3, 10.0.0, 10.5.2 | affected | the finder's JUnit fails 2 of 2 (-ea, -da); the minimal test fails on 9.12.3 (-da) |
10.6.0 (4ae946e5a9b9) | affected | the minimal test fails (-da), same message as main |
main 645d4fe14a20 | affected | the minimal test fails, gradle and standalone, assertions on and off |
upstream main 2f424fd428da, branch_11x | source read | unchanged; last commit to IndriAndWeight.java d0d2aa274fb5 (2024-06-06) |
main 645d4fe14a20 + advance check | fix | the minimal test passes; core search package: 215 classes, 2,340 tests, 0 failures |
Independent verifiers, an adjudication, a credibility audit and a minimisation step reproduced it on main and releases; reading-only cross-model reviews agreed on the root cause (one said not real only because it could not run code). This doc's runs reproduced it and validated the fix through gradle.
Checks and verdicts336 words
Every executed check reproduced the false match or the exception; every control (hits, Weight#matches, a BooleanQuery with the same clauses, TermQuery explain) agreed that these documents do not match.
| Check | Verdict | What it found |
|---|---|---|
| first signal (explain-versus-score bounded check) | lead | IndriAndQuery rows: 303 non-hits explained as matches, 4 IndexOutOfBoundsException on non-hits. |
| finder (Z3 model + bounded check of the real jar) | candidate | 60,288 checks, 0 model mismatches, 1,760 wrong non-hits (1,480 with a non-SHOULD clause plus 280 SHOULD-only), all removed by the advance check; JUnit fails on main, branch_10_6, 10.5.2, 9.12.3. |
| finder's cross-model review (OpenAI GPT-5.6, refute lens, reading only) | not real, procedural only | Could not run code, so it voted not real on procedure only; it agreed on the root cause and wrote that the static evidence strongly supports a real explain-only defect. |
| Independent reproducer | real | Own 6-test JUnit fails 6 of 6 on main (-ea, -da), 10.5.2, 9.12.3; non-hits get exactly the next hits' scores; EOFException for a plain TermQuery clause with the default codec on MMapDirectory. |
| Cross-model adversary (OpenAI GPT-5.6, intended lens, reading only) | real, medium | Intended-behaviour case weak; its execution checks reproduce on main. |
| Intended-behaviour devil's advocate | real, high | CheckHits fails unchanged on the upstream corpus; the value is another document's score; the false match spreads into an enclosing BooleanQuery. |
| adjudication | confirmed, high | New seed C0FFEE: the independent reproduction's test fails 6 of 6 both ways and passes 6 of 6 with the fix. |
| credibility audit | keep | Independent main() (default codec, MMapDirectory): next-hit scores, EOFException after the last hit when norms vary; same on 10.5.2 and 9.12.3. |
| minimal-repro step (2026-10-29) | verified | One method on 645d4fe14a20, -ea and -da, seeds DEADBEEF and C0FFEE; the fix shadow passes. |
| this doc's runs (2026-10-30) | reproduced; fix validated | The minimal test fails on main (gradle and standalone, -ea and -da), 9.12.3 and 10.6.0; with the fix it passes, and the core search test package runs clean (215 classes, 2,340 tests). |
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 418 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,356 words
- Registry title
- IndriAndWeight.explain does not check that its scorer landed on the requested document, so a document that matches no clause is explained as a match with another document's score, or explain throws (EOFException or IndexOutOfBoundsException from a norms read past the clause scorer's last match; AssertionError with -ea)
- Area
- search / IndriAndQuery (IndriAndWeight.explain)
- Priority
- Low, rank 704: An explain-only defect where IndriAndQuery explains a non-hit as a match with another document's score or throws while search results stay correct, the same profile as LFV-864.
- Impact class
- crash
- Impact note
- Applications that call IndexSearcher.explain with an IndriAndQuery (a rarely used core API that no Lucene query parser builds) for a document the query does not return, in a segment where at least one clause has a scorer, get an exception instead of a no-match explanation when the document lies after the clause scorer's last match and the clause field has norms (score() runs at NO_MORE_DOCS and the norms read throws java.io.EOFException or IndexOutOfBoundsException, also with the default codec on MMapDirectory), and otherwise a wrong explanation, isMatch=true with the next hit's score (0.0 with several clauses); with assertions enabled every such call throws AssertionError; hits, counts and search scores are unaffected; default codec, IndexWriterConfig and searcher, BM25 or Indri similarity, ordinary scale, every release since 8.9.0.
- Summary
- You search with an IndriAndQuery of SHOULD clauses, for example IndriAndQuery(SHOULD f:a), and call IndexSearcher.explain for a document that contains none of the clause terms and is not a hit. You expect a no-match explanation (isMatch false), as TermQuery and a BooleanQuery over the same clauses give. Instead IndriAndWeight.explain advances its scorer to the document, checks the result only with 'assert advanced == doc', and returns Explanation.match(scorer.score(), ...) for wherever the scorer landed: a match carrying the next hit's score, or, past the last match, an EOFException or IndexOutOfBoundsException from a norms read (AssertionError with -ea).
- Introduced
- 9cc5c9b7983cf181280e18819f8516dc16ccf1ef (LUCENE-9537 'Add initial Indri search engine functionality to Lucene', issue now apache/lucene#10577; PR apache/lucene-solr#2097 by GitHub cammiemw, reviewed and merged by mikemccand), released through its branch_8x backport a6125fb938553fe2cd8cd035b8ffb329a7a1a288; the unchecked advance and the assert are already in the PR's first commit 087d9d2a07 (2020-11-24), and the review follow-up 88c9c35090 ('IndriAndWeight explain NullPointer fix') added only the 'scorer != null' guard [read: git blame and git log -S 'assert advanced == doc' in the full-history clone; the only later edit inside explain() is #13261 (2ee537ce160, c.getQuery() -> c.query()); exec: IndriAndWeight.class in the 8.9.0 jar is instruction-identical to the backport source compiled, and shadowing 8.9.0 with that source plus only the advance check makes the history check pass]; not a regression: the defect was born with the class (2021-01-29 (author date of 9cc5c9b7983 and of the backport a6125fb9385; first PR commit 2020-11-24))
- First affected version
- 8.9.0 [exec] (the first release with the Indri classes; 8.8.2 has no IndriAndQuery, so the check does not compile there [exec]; on the main line: 9.0.0)
- Affected versions
- 8.9.0 to 8.11.4 and every 9.x and 10.x release (9.0.0 to 10.6.0; 10.6.0 run as the branch_10_6 head 4ae946e5a9b9) and main [exec: the core-only main() IndriAndExplainHistoryCheck gets 8 of 8 non-hit explains wrong, with every control passing, on 8.9.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main a9779ef3c193, -ea and -da; the finder's JUnit fails 2 of 2 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da) and on branch_10_6 head 4ae946e5a9b9 (-da); the independent reproduction's JUnit fails 6 of 6 on main, 10.5.2 and 9.12.3 (-ea and -da); the intended-behaviour check's probe fails on main and 10.5.2] [read: every 9.x and 10.x tag contains 9cc5c9b7983 and explain() changed only by #13261; the 8.10.x and 8.11.x jars contain the class, covered by the executed 8.9.0 and 8.11.4 endpoints]; not fixed anywhere: upstream main c5ab3e57b9ba, branch_10x and branch_10_6 still have the unchecked advance (last commit to IndriAndWeight.java d0d2aa274fb5, 2024-06-06) [read 2026-10-28]. Not affected: 8.8.2 and earlier (no class). All runs with JDK 25 on aarch64 Linux.
- 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: exhaustive check; also reached by Z3
- Upstream and prior reports
- none found (the prior-report record, verdict new, searched 2026-10-28, read-only: a scripted prior-report search of 65 queries (30 GitHub issue/PR queries on apache/lucene, 3 commit, 2 downstream over Elasticsearch, OpenSearch and Solr, 20 over the dev@, java-user@, issues@ and commits@ lists, 4 JIRA archive, 6 web; 362 distinct hits, none the same bug), 17 manual GitHub searches (apache/lucene including the last 48-96 h, all of GitHub, Elasticsearch, OpenSearch, Solr), a changed-file scan of all 410 open apache/lucene PRs, the PRs and issues created 2026-10-24..28, upstream commit history of the Indri files on main, branch_10x and branch_10_6, direct lists.apache.org and JIRA REST queries, full reads of 12 issues and PRs including all 28 review comments of apache/lucene-solr#2097, local git pickaxe; plus the finder's four scripted prior-report searches and the independent reproduction's, the intended-behaviour check's and the adjudication's searches). Refresh 2026-10-28: no apache/lucene issue or PR mentioning Indri updated since 2026-10-26 and no explain PR created since then (GitHub search, 0 each); the open PRs created since 2026-10-26 (#16907 to #16926) do not touch Indri; #16919 ('Change some more AssertionErrors to proper Exceptions', author date 2026-10-28) changes four other files; upstream main c5ab3e57b9ba; the last commit on IndriAndWeight.java is still d0d2aa274fb5 (author date 2024-06-06), after 9cc5c9b7983 (author date 2021-01-29). Related, not this bug: #463 (LUCENE-10157, open, Stale), the only open PR touching IndriAndWeight.explain, rewrites it but keeps 'advance(doc); assert advanced == doc' and copies the pattern into IndriOrWeight without discussing it; apache/lucene-solr#2097 review comment r555792974 (mikemccand, 2021-01-12) asked at the same lines whether the scorer can be null, a different defect fixed before merge; #7737 (LUCENE-6679, open) is the same symptom in the old Filter weight, other code; LUCENE-7065 fixed the same symptom in the global-ordinals join explain as a bug; #16267 says CheckHits.checkNoMatchExplanations is shallow and does not name Indri; #10577 and #11194 never discuss explain. Gaps: one manual GitHub retry hit GitHub's search rate limit (the tool's cached run of the same query covers it); broad queries were read only to the first 50-100 results; the downstream Lucindri project has issues disabled; private channels cannot be searched.
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-30: the minimal test through gradle in a scratch worktree with assertions on and off, stock and with the recommended fix, plus the Indri and explanation suites and the whole coreorg.apache.lucene.searchtest package with the fix; the standalone runner with-eaand-da; the 9.12.3 and 10.6.0 release jars (-da); a five-documentmain()(default codec,MMapDirectory). Earlier hunt runs: 8.9.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2, the branch_10_6 head4ae946e5a9b9(= 10.6.0) and maina9779ef3c193, whereIndriAndWeight.javais identical. Upstream main2f424fd428daandbranch_11xwere read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.
- Finding record
lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:76-106 (explain; advance at :98, assert at :99, Explanation.match at :100), BooleanWeight.java:65-130 ('matchCount == 0' no-match return at :109 before the same assert at :128), Weight.java:78-95 (default matches checks advance(doc) != doc) and :97-105 (explain javadoc), Explanation.java:77-78 (isMatch); lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:60-87 (checkNoMatchExplanations)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:47-65, :76-106lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:941-944, :959-968lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:137-139lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:109-111, :126-129; TermQuery.java:228-229; Weight.java:78-92; ConstantScoreWeight.java:52-54; PhraseWeight.java:75
- Who is affected
grep -rl 'IndriAndQuery\|IndriQuery' lucene/*/src (only the Indri classes and TestIndriAndQuery)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/Weight.java:98, :105; Explanation.java:50, :55, :77; IndexSearcher.java:964-966lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:60-88; grep -rl checkNoMatchExplanations lucenelucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:109-111, :126-129; package-info.java:230-232; IndriAndScorer.java:22-25lucene/CHANGES.txt:9496-9498 (LUCENE-7065)
- Prior reports
gh search issues/prs --repo apache/lucene Indri --updated >=2026-10-28 (0 each); gh api repos/apache/lucene/commits?path=lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java (main and branch_11x: d0d2aa274fb5), 2026-10-30
- Diagrams
lucene/core/src/java/org/apache/lucene/search/IndriAndWeight.java:96-100; DocIdSetIterator.java:137-139
- TestIndriAndExplainNonMatch.java (lucene/core test, one method):
repro/LFV-875/src/org/apache/lucene/search/TestIndriAndExplainNonMatch.java(52 lines)
assets/LFV-875/bug.png(418 KB)
