All findings · Lucene finding · LFV-875 · indri-and-weight-explain-unchecked-advance

IndriAndQuery explains documents that match no clause as matches, with another document's score, or throws

IndexSearcher.explain with an IndriAndQuery reports a document matching none of its clauses as a match with the next hit's score, or past the last hit throws if the field's norms vary, because IndriAndWeight.explain never checks where advance(doc) landed. This bug leaves search results unchanged; every release since 8.9.0 is affected.

Summary

Priority
Low, rank 704An 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
crash
Since
8.9.0 (LUCENE-9537, 2021-01-29), with the Indri classesintroduced 2021-01-29 · about 5 years
Affected
every release from 8.9.0 to 10.6.0, and main
Status
documented
Found by
an explain-versus-search bounded check, a Z3 model, then a public-API JUnit

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

The finding in 30 seconds

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.

Background: explain and advance

  1. 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.
  2. A scorer walks the matching documents in order. advance(target) jumps to the first matching document at or after target, so it can land past target.
  3. IndriAndQuery combines clauses, usually SHOULD clauses; its hits are the documents that match at least one clause.
LFV-875 summary: an index with doc 0 (no field) and doc 1 (f = a). IndriAndQuery(SHOULD f:a) has one hit, doc 1, score 0.13076457. explain(query, 0) should say no match; instead advance(0) lands on doc 1 and explain returns a match with doc 1's score, 0.13076457 (AssertionError at IndriAndWeight.java:99 with assertions on).
The minimal repro step by step: doc 0 (empty) and doc 1 (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.

The finding, step by step

  1. Index two documents: doc 0 is empty, doc 1 has the field f = "a". Search IndriAndQuery(SHOULD f:a): one hit, doc 1, score 0.13076457.
  2. Call searcher.explain(query, 0). You expect a no-match: doc 0 has no f:a, and TermQuery(f:a) explains it as a non-match.
  3. Instead explain says isMatch() == true with the value 0.13076457, doc 1's score, under an empty "sum of:". With assertions on it throws AssertionError at IndriAndWeight.java:99.
  4. 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 returns Explanation.match(scorer.score(), ...).
  5. After the last hit there is nothing to land on: on a five-document text index (default codec, MMapDirectory), explaining doc 4 throws java.io.EOFException: read past EOF from the norms.

Who is affected

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.

The mechanism (main 645d4fe14a20, lucene/core/src/java/org/apache/lucene/search/)

  1. Entry. IndexSearcher.explain(Query, int) (IndexSearcher.java:941-944) calls explain(Weight, int) (:959-968), which takes any valid document id, returns a no-match for a deleted one (:964-966) and otherwise calls weight.explain(ctx, doc) (:967).
  2. Clause loop. IndriAndWeight.java:79-91: a matching clause's explanation goes into subs (:84-85); a non-matching clause sets fail only if it is required (:86-90). A non-matching SHOULD clause is dropped.
  3. The tail (:96-100), reached whenever fail is 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).

  1. 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, or NO_MORE_DOCS (2147483647), where score() reads norms for a document that does not exist.
  2. Everyone else checks. BooleanWeight.explain returns noMatch("No matching clauses", subs) when no clause matched (BooleanWeight.java:109-111) and only then reaches the same advance(doc); assert advanced == doc tail (:126-129). TermWeight.explain uses the advance only if (newDoc == doc) (TermQuery.java:228-229); the default Weight#matches, which IndriAndWeight does not override, returns null when advance(doc) != doc (Weight.java:78-92); ConstantScoreWeight.explain (:52-54) and PhraseWeight.explain (:75) check too.

The numbers (executed)

index, querydocumentexplain, assertions offcorrect answer
doc 0 empty, doc 1 f=a (StringField); IndriAndQuery(SHOULD f:a); one hit, doc 1, 0.130764570match, 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)0match, 0.478552 (doc 1's score)no match
same2match, 0.37114638 (doc 3's score)no match
same4 (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 3match, 0.0no 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, in org.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 throws AssertionError with assertions on.
  • Default vs opt-in. Default codec, IndexWriterConfig, searcher and similarity (BM25; also IndriDirichletSimilarity); opt-in only in choosing IndriAndQuery. 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: EOFException or IndexOutOfBoundsException with assertions off, AssertionError with 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.

Minimal repro

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.

TestIndriAndExplainNonMatch.java (lucene/core test, one method)52 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.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());
      }
    }
  }
}
Command
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 contain f:a and is not a hit.
Observed
A match, 0.13076457 = sum of: (doc 1's score), with assertions off; AssertionError at IndriAndWeight.java:99 with assertions on.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 9.12.3 and 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run; pure Java logic)

Observed output

# 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)

Why it looks unintended

  • Weight#explain explains "the named document", Explanation.noMatch exists for non-matches, and Lucene's CheckHits.checkNoMatchExplanations requires non-hits to explain as non-matches.
  • BooleanWeight.explain, which this code mirrors, returns "No matching clauses" before the same assert; TermWeight and Weight#matches check advance(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.noMatch is documented "for a document which does not match" (Explanation.java:50, :55), and isMatch "Indicates whether this Explanation models a match" (:77). IndexSearcher.explain takes 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 to maxDoc explains with isMatch() == false; it runs in, among others, TestSimpleExplanationsOfNonMatches, TestComplexExplanationsOfNonMatches and TestSpanExplanationsOfNonMatches. Applied unchanged to upstream TestIndriAndQuery'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 the matchCount == 0 guard, 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 scorer can be null (r555792974, 2021-01-12); the follow-up added only scorer != null. Nobody discussed where advance(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.

Suggested fix

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.

Return a match only if advance(doc) == doc (validated by the checks) (recommended)

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) == doc comparison that TermWeight.explain and the default Weight#matches make; 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 IndriAndWeight and in its new IndriOrWeight if it is ever revived.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20 with the repro test added, seed DEADBEEF: stock, the repro fails with -Ptests.asserts=true and false; 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 core org.apache.lucene.search.* test package (with similarities and comparators), 215 classes, 2,340 tests, 0 failures, 0 errors, 49 skipped.
  • :lucene:core:checkGoogleJavaFormat passes 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 upstream TestIndriAndQuery pass; on 8.9.0 the history check goes from 8 of 8 wrong explains to 0 of 8.

Also mirror BooleanWeight: "No matching clauses" when no clause explanation matched

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).

Document that explain is for hits only

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 note

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.

How it was found

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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, mailing lists, JIRA archive, web (search script)2026-10-2865 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 web362 distinct hits, none the same bug
GitHub (apache/lucene, all of GitHub, Elasticsearch, OpenSearch, Solr)2026-10-2817 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#2097no report; only #463 touches IndriAndWeight.java and it keeps the defect
GitHub refresh2026-10-30issues and PRs mentioning Indri updated since 2026-10-28; commits to IndriAndWeight.java on main and branch_11xnone; last commit to the file d0d2aa274fb5 (2024-06-06)
GitHub refresh (saved record)2026-10-31issues 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_11x0 issues, 0 PRs; last commit to the file on both branches d0d2aa274fb5 (#13440, 2024-06-06)

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
8.8.2 no APIno IndriAndQuery; the history check does not compile
8.9.0 first releasehistory check: 8 of 8 non-hit explains wrong (-ea, -da); with only the advance check, 0 of 8
8.11.4, 9.0.0 affectedhistory check: 8 of 8 wrong (-ea, -da), controls pass
9.1.0, 9.12.3, 10.0.0, 10.5.2 affectedthe finder's JUnit fails 2 of 2 (-ea, -da); the minimal test fails on 9.12.3 (-da)
10.6.0 (4ae946e5a9b9) affectedthe minimal test fails (-da), same message as main
main 645d4fe14a20 affectedthe minimal test fails, gradle and standalone, assertions on and off
upstream main 2f424fd428da, branch_11x source readunchanged; last commit to IndriAndWeight.java d0d2aa274fb5 (2024-06-06)
main 645d4fe14a20 + advance check fixthe minimal test passes; core search package: 215 classes, 2,340 tests, 0 failures

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
first signal (explain-versus-score bounded check) leadIndriAndQuery rows: 303 non-hits explained as matches, 4 IndexOutOfBoundsException on non-hits.
finder (Z3 model + bounded check of the real jar) candidate60,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 onlyCould 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 realOwn 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, mediumIntended-behaviour case weak; its execution checks reproduce on main.
Intended-behaviour devil's advocate real, highCheckHits fails unchanged on the upstream corpus; the value is another document's score; the false match spreads into an enclosing BooleanQuery.
adjudication confirmed, highNew seed C0FFEE: the independent reproduction's test fails 6 of 6 both ways and passes 6 of 6 with the fix.
credibility audit keepIndependent 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) verifiedOne method on 645d4fe14a20, -ea and -da, seeds DEADBEEF and C0FFEE; the fix shadow passes.
this doc's runs (2026-10-30) reproduced; fix validatedThe 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).

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

Finding record

LFV-875, snapshot of 2026-11-06

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 core org.apache.lucene.search test package with the fix; the standalone runner with -ea and -da; the 9.12.3 and 10.6.0 release jars (-da); a five-document main() (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 head 4ae946e5a9b9 (= 10.6.0) and main a9779ef3c193, where IndriAndWeight.java is identical. Upstream main 2f424fd428da and branch_11x were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.

Lucene and upstream sources

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-106
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:941-944, :959-968
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:137-139
  • lucene/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-966
  • lucene/test-framework/src/java/org/apache/lucene/tests/search/CheckHits.java:60-88; grep -rl checkNoMatchExplanations lucene
  • lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:109-111, :126-129; package-info.java:230-232; IndriAndScorer.java:22-25
  • lucene/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

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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.