All findings · Lucene finding · LFV-174 · expression-rescorer-explain-zero-score

ExpressionRescorer.explain shows _score as 0 and computes the expression from it (since 7.0)

Rescore hits with an expression such as _score * 100 + popularity, then explain a hit: since 7.0.0 the explanation plugs in 0 for _score, so a hit sorted by 107 is explained as 7. The ranking itself is correct.

Summary

Priority
Low, rank 1038ExpressionRescorer.explain evaluates the expression with _score = 0, a wrong explanation while the rescored ranking stays correct.
Impact
minor
Since
7.0.0 (LUCENE-7741, 2017); 6.6.6 explains correctlyintroduced 2017-05-10 · about 9 years
Affected
7.0.0 to 10.6.0, and main
Status
documented
Found by
code reading of the expressions module's explain path

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

One rescored hit: first-pass score 1.0, popularity 7, expression _score * 100 + popularity. The rescore sorts it by 107.0; its explanation says 7.0.

Background: expression rescoring and explain

  1. A rescorer re-sorts the top hits of a first search. Expression.getRescorer does it with a formula over the hit's first-pass score (_score) and document values, for example _score * 100 + popularity.
  2. explain returns a tree of numbers that says how a hit got its value. ExpressionRescorer's javadoc says its tree shows the value of each variable.
  3. SortRescorer, which ExpressionRescorer extends, explains a re-sort with a placeholder value of 0.0 and puts the real sort value in the text.
Diagram of the bug: an expression rescorer for _score * 100 + popularity and one hit with first-pass score 1.0 and popularity 7. rescore() uses the real first-pass score and sorts the hit by 107.0. ExpressionRescorer.explain first gets SortRescorer's explanation, whose value is always 0.0, and passes it to the expression as the score, so the explanation shows _score = 0.0 and the value 7.0. Passing the first-pass explanation instead gives 107.0.
One hit, first-pass score 1.0, popularity 7: the rescore computes 1.0 x 100 + 7 = 107.0, while explain feeds SortRescorer's 0.0 placeholder in as _score and computes 0 x 100 + 7 = 7.0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Rescore first-pass hits with Expression.getRescorer for _score * 100 + popularity. A hit with first-pass score 1.0 and popularity 7 is sorted by 1.0 x 100 + 7 = 107.0.
  2. Ask rescorer.explain(searcher, firstPassExplanation, doc) how that hit got its value. You expect _score = 1.0 and the value 107.0: the javadoc says explain shows "the value of each variable", and up to 6.6.6 the _score line was right.
  3. Instead you get 7.0 = _score * 100 + popularity, and the _score line reads 0.0 = sort field values for sort=.... The javadoc's own example sqrt(_score) + ln(popularity) is explained as just ln(popularity).
  4. Why: explain first asks SortRescorer for its explanation, whose value is always a 0.0 placeholder, and hands that node to the expression as the score. So the expression is evaluated with _score = 0.
  5. Only the explanation is wrong: the rescored order, sort values and scores are right. Passing the real first-pass explanation instead, labelled "first pass score", fixes it; all 171 tests of the expressions module pass.

Who is affected

Code that explains a rescorer from Expression.getRescorer whose expression reads _score: for hits with a nonzero first-pass score, the explanation shows the wrong _score and, where it depends on _score, the wrong value. Ranking, sort values and scores are correct. The API is experimental and rarely used: no Lucene code outside tests calls it; no report in nine years.

Mechanism, terms and numbers486 words

Expression.getRescorer re-sorts first-pass hits by an expression, and its explain is documented to give "more detail by showing the value of each variable". Since 7.0.0 (LUCENE-7741) ExpressionRescorer.explain hands SortRescorer's explanation, whose value is always 0.0, to the expression as the score explanation, so the expression is evaluated with _score = 0 and the _score detail is that 0.0 node. For _score * 100 + popularity, a hit with first-pass score 1.0 and popularity 7 is sorted by 107.0 but explained as 7.0 = _score * 100 + popularity; the Expression javadoc's example sqrt(_score) + ln(popularity) is explained as ln(popularity), and popularity / _score as +Infinity. The rescored order, sort values and scores are correct. 6.5.1-6.6.6 show _score correctly; 7.0.0 to 10.5.2 and main do not, and putting back only the old ExpressionRescorer.java on 7.0.0 removes the failure. Passing the first-pass explanation, labelled "first pass score", fixes the repro and passes all 171 expressions tests. Low priority: an explain-only defect in an experimental, rarely used API.

Mechanism (Lucene main 645d4fe14a20)

  1. rescore() (SortRescorer.java:38-93) sets the scorer's score to each hit's first-pass score (:77, score.score = hit.score), so the sort value is 1.0 * 100 + 7 = 107.0, and it keeps ScoreDoc.score at 1.0 (:89-91).
  2. ExpressionRescorer.explain (ExpressionRescorer.java:49-62) first calls SortRescorer.explain (:52). That re-runs the rescore for the one hit and returns Explanation.match(0.0f, "sort field values for sort=...", subs) (SortRescorer.java:125), whose details are the first-pass explanation and a 0.0 node with the sort value in its text (:118-120).
  3. It passes that 0.0 node to ExpressionValueSource.explain as the score explanation (ExpressionRescorer.java:59-61).
  4. ExpressionValueSource.explain evaluates the expression with the scores bound to DoubleValuesSource.constant(scoreExplanation.getValue()) = 0.0 (ExpressionValueSource.java:191-195): 0 * 100 + 7 = 7.0. For _score, DoubleValuesSource.SCORES.explain returns the score explanation itself (DoubleValuesSource.java:376-377), so the _score detail is the 0.0 node.

What you see on main, with assertions on and off:

7.0 = _score * 100 + popularity, computed from:
  0.0 = sort field values for sort=<expr(_score * 100 + popularity)>!     <- the _score detail
    1.0 = first pass score
      1.0 = *:*
    0.0 = sort field <expr(_score * 100 + popularity)>! value=107.0
  7.0 = double(popularity)

(The arrow is ours.) The right value, 107.0, appears only as text inside a node whose value is 0.0.

More effects

  • sqrt(_score) + ln(popularity), the Expression javadoc's example, is explained as ln(popularity) (1.9459 for popularity 7, against a sort value of 2.9459).
  • popularity / _score is explained as +Infinity, and ln(_score) + popularity as -Infinity; the rescored values stay finite and nothing throws.
  • The explained values can order hits differently from the rescore: on 7.0.0 the rescore ranks three hits 25.13, 25.11, 24.43, and their explanations say 6.0, 5.0, 7.0.
  • Expressions that do not read _score explain correctly, and the expression's own DoubleValuesSource.explain, given the real first-pass explanation, returns the sort value.
Exposure and likelihood239 words
  • Who. Applications that call explain on a rescorer from Expression.getRescorer whose expression reads _score, directly or through a binding. For every explained hit the _score detail is SortRescorer's 0.0 node instead of the first-pass explanation; when the first-pass score is not 0 (true of every hit in our runs), the _score value shown is wrong, and so is the expression value wherever it depends on _score. It reproduced with a plain IndexSearcher, the default BM25 similarity and the default codec (the release probe, 3 documents) and in randomized test fixtures of 1 and 6 documents, on main with assertions on and off; by reading, nothing on the explain path depends on the codec or the index size.
  • What is wrong. Only the Explanation: the _score value shown, and the expression value computed from it.
  • What is right. The rescored order, the FieldDoc sort values, the ScoreDoc scores and isMatch() (checked by a probe on main with assertions on and off).
  • Opt-in, rarely used API. Expression and ExpressionRescorer are @lucene.experimental; ExpressionRescorer is package-private and reached only through the public getRescorer. No Lucene code outside tests calls getRescorer.
  • Likelihood: plausible. Anyone who explains a _score-based expression rescore sees it at once, but rescorer explanations are a rare diagnostic, and nobody has reported it since 7.0.0 (2017).
  • Workaround. Explain the expression directly: expression.getDoubleValuesSource(bindings).explain(leaf, docInLeaf, firstPassExplanation) returns the sort value (an independent reproducer's control test).

Minimal repro

One JUnit test, public API only: one document with popularity 7, first pass MatchAllDocsQuery (score 1.0), a rescorer from _score * 100 + popularity. It asserts that the explanation's _score detail is the first-pass score; the failure message prints the whole explanation.

TestExpressionRescorerExplainScore.java (one test class)59 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.expressions;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.expressions.js.JavascriptCompiler;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.search.Explanation;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Rescorer;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestExpressionRescorerExplainScore extends LuceneTestCase {

  public void testScoreDetailIsFirstPassScore() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, newIndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new NumericDocValuesField("popularity", 7));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        SimpleBindings bindings = new SimpleBindings();
        bindings.add("_score", DoubleValuesSource.SCORES);
        bindings.add("popularity", DoubleValuesSource.fromIntField("popularity"));
        Rescorer rescorer =
            JavascriptCompiler.compile("_score * 100 + popularity").getRescorer(bindings);
        Explanation firstPass = searcher.explain(new MatchAllDocsQuery(), 0);
        Explanation expl = rescorer.explain(searcher, firstPass, 0);
        // expected: the _score detail is the first-pass score 1.0; actual: 0.0 (SortRescorer node)
        assertEquals(
            expl.toString(),
            firstPass.getValue().doubleValue(),
            expl.getDetails()[0].getValue().doubleValue(),
            0.0);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:expressions:test --tests org.apache.lucene.expressions.TestExpressionRescorerExplainScore -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
The explanation's _score detail is the first-pass score, 1.0, and the expression explains as 107.0.
Observed
AssertionError: 7.0 = _score * 100 + popularity, computed from: 0.0 = sort field values for sort=... expected:<1.0> but was:<0.0>, with assertions on and off.

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

More files, environment, full output253 words

One test class, one method, one document, single thread; the seed does not matter (MatchAllDocsQuery scores every hit 1.0, and the searcher is a plain IndexSearcher). It asserts only the _score detail, the value the class javadoc promises, not the root. getDetails()[0] is the _score detail because _score is the expression's first variable.

Regression test: this same test. It fails on stock main and passes with each of the three fixes that were run (the 6.x restore is by reading only).

Nit: new MatchAllDocsQuery() is deprecated on main in favour of MatchAllDocsQuery.INSTANCE (also in 10.6.0). The test passes the expressions module's format, ECJ and forbidden-apis checks as is.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the recommended fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
cp $REPRO/TestExpressionRescorerExplainScore.java $LUCENE/lucene/expressions/src/test/org/apache/lucene/expressions/
cd $LUCENE && ./gradlew :lucene:expressions:test --tests org.apache.lucene.expressions.TestExpressionRescorerExplainScore -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestExpressionRescorerExplainScore > testScoreDetailIsFirstPassScore FAILED
    java.lang.AssertionError: 7.0 = _score * 100 + popularity, computed from:
      0.0 = sort field values for sort=<expr(_score * 100 + popularity)>!
        1.0 = first pass score
          1.0 = *:*
        0.0 = sort field <expr(_score * 100 + popularity)>! value=107.0
      7.0 = double(popularity)
     expected:<1.0> but was:<0.0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:A8ECC047760D62A6]:0)
        at junit@4.13.2/org.junit.Assert.fail(Assert.java:89)
        at junit@4.13.2/org.junit.Assert.failNotEquals(Assert.java:835)
        at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:555)
        at org.apache.lucene.expressions.TestExpressionRescorerExplainScore.testScoreDetailIsFirstPassScore(TestExpressionRescorerExplainScore.java:51)
1 test completed, 1 failed

# -Ptests.asserts=false: the same message

# with the recommended fix the explanation reads
107.0 = _score * 100 + popularity, computed from:
  1.0 = first pass score
    1.0 = *:*
  7.0 = double(popularity)

Why it looks unintended

  • The class javadoc says explain shows "the value of each variable"; it shows _score as 0, not the score the rescore used.
  • A regression: 6.6.6 showed _score correctly; LUCENE-7741 (7.0.0) swapped in the 0.0 node without a stated reason.
  • The explained 7.0 = _score * 100 + popularity is wrong arithmetic, not a placeholder.
The full argument227 words
  • The class javadoc (ExpressionRescorer.java:29-33): functionally the same as SortRescorer "except for the explain method which gives more detail by showing the value of each variable".
  • The Rescorer contract (Rescorer.java:45): "Explains how the score for the specified document was computed." In the #14776 review (2025), vigyasharma wrote that "Rescorer#explain API is meant to explain how the _Rescorer_ would score this docId for given searcher, when rescore() is called".
  • The original design (LUCENE-5545, #6608, mikemccand, 2014): "explain() includes the values of each of the bindings ... so you can see the values that led to the overall expression".
  • A regression. Up to 6.6.6 the code bound the scores to firstPassExplanation.getValue() and printed variable "_score" with the first-pass score. LUCENE-7741 (fb24d451cc98) replaced that loop with the call that passes SortRescorer's node. Its issue thread (#8792) never mentions rescorers, and the commit message gives no reason for this change.
  • The code defines the parameter as the score's explanation. SCORES.explain returns it, and the default DoubleValuesSource.explain evaluates with scoreExplanation.getValue() as the score (DoubleValuesSource.java:80-89).
  • Why nobody noticed. The same commit changed the upstream test from expl.contains("= variable \"popularity\"") to expl.contains("= double(popularity)"); TestExpressionRescorer.testBasic checks only substrings, never a value.
  • Precedent. Wrong values in an explanation are fixed as bugs, for example "GITHUB#13691: Fix incorrect exponent value in explain of SigmoidFunction" under 9.12.0 Bug Fixes.
The devil's-advocate check221 words

The strongest case for "works as intended" (rated moderate by the cross-model reviews, weak by the devil's-advocate verifier): SortRescorer's 0.0 root is a deliberate placeholder, since a re-sort keeps ScoreDoc.score and puts the keys in FieldDoc.fields; ExpressionRescorer is documented as functionally the same as SortRescorer; the APIs are @lucene.experimental; nothing promises that the root equals FieldDoc.fields[0]; DoubleValuesSource.explain has no @param for scoreExplanation; and LUCENE-7741 may have passed SortRescorer's node on purpose to keep the "first pass score" text that testBasic asserts.

Why it fails: that explains a 0.0 root, not binding the documented _score variable to 0. Since 7.0 the root is not the placeholder either: it claims 7.0 = _score * 100 + popularity, computed from:, false arithmetic for the real _score, so it follows no convention. Keeping the "first pass score" text does not need the 0 value: the recommended fix keeps it and testBasic passes. The rest (experimental, explain-only) argues for Low priority, which is how it is classed, not for intent. The root never equalled the sort value, 6.6.6 included. This is evidence that the behaviour conflicts with the javadoc and the 6.x code, not proof of what LUCENE-7741 intended; the fix shape is the maintainers' choice (the recommended fix gives an expression-valued root, the 6.x restore keeps SortRescorer's 0.0 root).

Suggested fix

Recommended Pass the first-pass explanation, labelled "first pass score"

In ExpressionRescorer.explain, pass the first-pass explanation, wrapped as "first pass score", to the expression instead of SortRescorer's 0.0 node (+5/-3 lines). The hit then explains as 107.0 = _score * 100 + popularity with 1.0 = first pass score. The repro and all 171 expressions tests pass.

// rescore() computes the expression with _score = the first-pass score, so explain it that way
Explanation firstPassScore =
    Explanation.match(
        firstPassExplanation.getValue(), "first pass score", firstPassExplanation);
return expression
    .getDoubleValuesSource(bindings)
    .explain(readerContext, docIDInSegment, firstPassScore);
All options and trade-offs4 options ยท 458 words

Three fixes were run through gradle in a scratch worktree of 645d4fe14a20; the recommended one is also lint-clean. A fourth, restoring the 6.x shape, is by reading only.

Pass the first-pass explanation, labelled "first pass score" (validated by the checks) (recommended)

ExpressionRescorer.java +5/-3: drop the super.explain(...) call and pass this node as the score explanation:

// rescore() computes the expression with _score = the first-pass score, so explain it that way
Explanation firstPassScore =
    Explanation.match(
        firstPassExplanation.getValue(), "first pass score", firstPassExplanation);
return expression
    .getDoubleValuesSource(bindings)
    .explain(readerContext, docIDInSegment, firstPassScore);

Trade-offs

  • The root becomes the value the rescore sorted by (107.0); the _score detail is 1.0 = first pass score with the first-pass explanation under it; one detail per variable.
  • Keeps the "= first pass score" text that TestExpressionRescorer.testBasic asserts. Drops SortRescorer's "sort field ... value=" node, whose value is now the root, and the one-hit rescore that built it.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle --offline, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off.
  • With the fix: the whole :lucene:expressions:test (14 suites, 171 tests, the repro and TestExpressionRescorer included) passes with 0 failures with assertions on and off; the module's checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass.

Pass the first-pass explanation and keep SortRescorer's node as an extra detail (validated)

The hunt's fix, ExpressionRescorer.java +13/-3: evaluate with firstPassExplanation, then return the expression's root with SortRescorer's node appended to its details.

Trade-offs

Keeps everything SortRescorer showed, but the tree gains a 0.0-valued "computed from" detail the value does not come from, shows the first-pass explanation twice, and still runs the one-hit rescore.

What the checks validated

The whole :lucene:expressions:test with assertions on: 171 tests, 0 failures. Earlier, through the standalone runner: the repro and the 170 upstream expressions tests pass, and the version-portable probe passes 6 of 6 on main.

One line: pass firstPassExplanation instead of SortRescorer's node (not recommended as is) (validated by the checks)

Change only the last argument of the explain call.

Trade-offs

The _score detail loses its "first pass score" label, so TestExpressionRescorer.testBasic fails on assertTrue(expl.contains("= first pass score")); the super.explain result becomes unused. The recommended fix makes this change with the label and also drops the then unused super.explain call.

What the checks validated

TestExpressionRescorer and the repro with assertions on: 5 tests, 1 failure (testBasic, TestExpressionRescorer.java:123); the repro passes.

Restore the 6.x shape (by reading, not run)

Return SortRescorer's 0.0 root and append one detail per variable computed from the first-pass score, as 6.6.6 did.

Trade-offs

Matches "functionally the same as SortRescorer" literally, but keeps a root that states 0.0 for a hit sorted by 107.0; a committer who prefers the placeholder root may still pick it.

Downstream note

Downstream, Elasticsearch's expression scripts wrap this rescorer (DoubleValuesScript.getRescorer); a code search found only a test calling it.

How it was found

Code reading (non-formal), during a review of the expressions module that compared ExpressionValueSource.explain with the values it explains. The finder traced ExpressionRescorer.explain, saw SortRescorer's 0.0 node passed in as the score, and confirmed it with a probe and a public-API JUnit test on the stock jars.

The method in full142 words

Method: code reading (the hunt's non-formal tier).

  • Lead. In a hunt unit on the expressions compiler, a secondary contract review checked ExpressionValueSource.explain and getSortField against the values they compute. Tracing the explain path showed that ExpressionRescorer.explain passes SortRescorer's explanation, whose value is always 0.0, as the score explanation.
  • Confirmation. The finder wrote a probe and a public-API JUnit test on the stock main jars (rescored 107.0, explained 7.0, with assertions on and off) and a main() that failed on 10.5.2, 9.12.3 and 8.11.4.
  • Then executing cross-model reviews (OpenAI GPT-5.6), an independent reproducer, a devil's-advocate verifier (who ran 6.6.6 and found the regression), an adjudication, 12 version-history runs (8 releases from 6.5.1 to 10.5.2, main with assertions on and off, and two shadow checks), a prior-report search, a security screen with its own probe, a repro minimization and this doc's gradle runs followed.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26: 125 logged queries over GitHub issues, PRs and comments, the JIRA archive, four Lucene mailing lists, downstream trackers, Stack Overflow and the web; refreshed 2026-11-01). The open draft #16847 edits two other lines of the same method without changing the behaviour.

Searches2 searches

Verdict new: no issue, PR, review comment, list post, JIRA entry, downstream report or web page describes this bug. Related, not reports of it: #8792 (LUCENE-7741, introduced it); #6608 (LUCENE-5545, added ExpressionRescorer); #6552 (LUCENE-5489, the rescoring API); #14776 (DoubleValuesSourceRescorer, the review comment quoted above); #10895 (LUCENE-9856, deleted the private scores() helper the regression had left unused, without discussion); open draft #16847 (widens doc ids in two other lines of the same explain method and keeps the bug; a three-way merge with each fix is clean as of 2026-11-01).

Gaps: one commits@ query was truncated at 1,500 of 1,776 mails; private reports to the security list are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, comments and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr, Elasticsearch, OpenSearch, Solr), dev@ / java-user@ / issues@ / commits@, JIRA LUCENE and SOLR, Stack Overflow, the web2026-10-26scripted prior-report search (52 queries, 1,553 distinct hits triaged), 51 GitHub GraphQL searches read to the end, 10 JIRA JQL queries, 56 list queries, 4 Stack Overflow searches, downstream code searches for getRescorer, the changed files of all 413 open PRs, the file's full git historyno report; related items only
GitHub issues and PRs (apache/lucene)2026-11-01ExpressionRescorer, ExpressionRescorer explain, rescorer explain, SortRescorer explain, explain _score rescore, rescorer _score explanation; three searches over items updated since 2026-10-20only the known related items (#6608, #6552, #14776); #16847 still an open draft

History

Introduced by LUCENE-7741 (fb24d451cc98, 2017-05-10), first released in 7.0.0. 6.5.1-6.6.6 pass; 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main fail; putting back only the old ExpressionRescorer.java on 7.0.0 makes it pass. Not fixed upstream.

Versions tested7 rows

Introduced by fb24d451cc98 "LUCENE-7741: Add explain() to DoubleValuesSource" (Alan Woodward, romseygeek, 2017-05-10), committed from a JIRA patch (issue now #8792; no PR found), listed under 7.0.0 in CHANGES. It replaced ExpressionRescorer's own loop, which bound the scores to firstPassExplanation.getValue(), with a call that passes SortRescorer's explanation. Later commits on these lines are formatting or type changes only. 4.8.0 (ExpressionRescorer added) to 6.3.x bound the first-pass score through the older ValueSource API (by reading). Not fixed upstream: upstream main 2f424fd428d (2026-10-30), branch_11x, branch_10_6 and 10.6.0 have the same files.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails through gradle with assertions on and off: _score detail 0.0, root 7.0, sort value 107.0
main ea57360912ed affectedhistory probe fails 6 of 6 hits with -ea and -da (sort value 14.92, explained 7.0)
10.5.2, 9.12.3, 8.11.4, 7.7.3 affectedhistory probe fails 6 of 6 hits
7.0.0 affectedhistory probe fails 6 of 6: rescored 25.13 / 25.11 / 24.43, explained 6.0 / 5.0 / 7.0
7.0.0 jars + the pre-commit ExpressionRescorer.java A/Bpasses 6 of 6: 0.19129147 = variable "_score"
6.6.6, 6.6.0, 6.5.1 not affectedpasses 6 of 6: _score shown as the first-pass score (root 0.0 placeholder)
10.6.0, upstream main 2f424fd428d, branch_11x, branch_10_6 readthe same ExpressionRescorer, ExpressionValueSource and SortRescorer files as 645d4fe14a20

Independent checks

Reproduced by the finder, executing cross-model reviews (OpenAI GPT-5.6), an independent reproducer, a devil's-advocate verifier, the adjudication, the version-history runs, a security probe, a repro minimization and this doc's gradle runs. All say real; the adjudication lowered it to minor, explain-only.

Checks and verdicts294 words

Every reproduction ran on stock builds; the fix checks ran with the patched ExpressionRescorer.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (expressions-compiler unit) candidatePublic-API JUnit fails with assertions on and off (rescored 107.0, explained 7.0); a main() with BM25 fails on 10.5.2, 9.12.3 and 8.11.4.
Cross-model refute review (OpenAI GPT-5.6, executed) real, highOwn test fails on main (sort value 16.0, explained 6.0) with -da and a second seed; its score-free control passes; with the fix the 170 expressions tests and 3 TestSortRescorer tests pass.
Independent reproducer realOwn JUnit: 3 of 5 fail on main, 10.5.2 and 9.12.3 (first-pass 3.0: explained 7/4/1 instead of 37/34/31); 2 controls pass; its one-line fix passes 5 of 5.
Cross-model review, intended lens (OpenAI GPT-5.6, executed) real, highOwn test: first-pass 1.0, sort key 107.0, explained 7.0; the value source explained directly gives 107.0; intended argument moderate.
Devil's advocate real, intended argument weakFails on main, 7.0.0 and 10.5.2; passes on 6.6.6, so a 7.0.0 regression; "root equals the sort value" never held.
Adjudication confirmed, highRe-ran the independent reproduction's JUnit (5 run, 3 failed) and the finder's (-da, 1 failed); class minor; the claim rests on the _score detail.
Security screen noneProbe: ranking, sort values and scores correct; only the explanation is wrong; 3 of 3 pass on main with -ea and -da.
Repro minimization verifiedThe one-test repro fails on 645d4fe14a20 with -ea and -da (two seeds); the hunt's fix passes.
This doc's runs (2026-11-01) reproduced, fixes validatedGradle on 645d4fe14a20: the repro fails with assertions on and off; the recommended fix passes 171 of 171 expressions tests both ways and the lint checks.

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

Finding record

LFV-174, snapshot of 2026-11-06

Registry title
ExpressionRescorer.explain evaluates the expression with _score = 0 (the value of SortRescorer's placeholder explanation) instead of the first-pass score, so the explanation shows the wrong _score and expression values; the rescored ranking is correct
Area
expressions / ExpressionRescorer explain (Expression.getRescorer, ExpressionValueSource.explain over SortRescorer)
Priority
Low, rank 1038: ExpressionRescorer.explain evaluates the expression with _score = 0, a wrong explanation while the rescored ranking stays correct.
Impact class
minor
Impact note
Applications that call explain on a rescorer from Expression.getRescorer (expressions module, @lucene.experimental) whose expression reads _score directly or through a binding, including the 'sqrt(_score) + ln(popularity)' example from the Expression javadoc, get a wrong explanation for every explained hit, with the default searcher, similarity and codec, -ea and -da, at any scale, in every release since 7.0.0: the _score detail is SortRescorer's '0.0 = sort field values for sort=...' node and the root is the expression evaluated with _score = 0 (for example 7.0 instead of 107.0, ln(popularity) for the javadoc example, +Infinity or -Infinity for 'popularity / _score' or 'ln(_score) + popularity'), so the explained values can order the hits differently from the rescore; the rescored order, FieldDoc sort values, ScoreDoc scores and isMatch() are correct, and no Lucene non-test code or downstream production caller of this explain was found.
Summary
You rescore first-pass hits with an expression that reads _score, for example '_score * 100 + popularity' through Expression.getRescorer, and ask the rescorer to explain a hit. You expect what the ExpressionRescorer javadoc promises, an explanation 'showing the value of each variable': _score equal to the first-pass score the rescore used, and the expression value computed from it (107.0 for a first-pass score of 1.0 and popularity 7). Instead, since 7.0.0, ExpressionRescorer hands SortRescorer's explanation, whose value is always 0, to the expression as the score explanation, so the explanation reads '7.0 = _score * 100 + popularity' with a _score detail of 0.0, while the rescore itself (order, sort values, scores) is correct.
Introduced
fb24d451cc98120d5a70bd0e63a5b296ce69123c (LUCENE-7741 'Add explain() to DoubleValuesSource', now apache/lucene#8792, by romseygeek; committed to master from a JIRA patch, no PR found) [read: it replaced ExpressionRescorer.explain's own variable loop, which bound the scores to firstPassExplanation.getValue(), with expression.getDoubleValuesSource(bindings).explain(readerContext, docIDInSegment, superExpl), where superExpl = SortRescorer.explain(...) whose value has been 0.0f since LUCENE-5545; git blame --ignore-rev 7f8b7ffbcad (LUCENE-9570 reformatting) attributes today's return statement to it; later commits on these lines are formatting or type changes only] [exec: git diff fb24d451cc9..7.0.0 for ExpressionRescorer.java is empty, and shadowing the pre-commit file (fb24d451cc9^ = dea4a7981a9) onto the 7.0.0 jars makes the history probe pass 6 of 6] (2017-05-10)
First affected version
7.0.0 [exec] (CHANGES.txt lists LUCENE-7741 under 7.0.0 API Changes); 6.5.1 (the last release before the commit), 6.6.0 and 6.6.6 are not affected [exec]; the 6.x line never received the change
Affected versions
every release from 7.0.0 through 10.6.0 (10.6.0 by reading), and main ea57360912ed. [exec] the follow-up's version-portable history probe (public API, core and expressions jars) finds 6 of 6 rescored hits wrong on 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main (-ea and -da), and passes 6 of 6 on 6.5.1, 6.6.0, 6.6.6, on 7.0.0 with the pre-commit ExpressionRescorer.java shadowed, and on main with the candidate fix shadowed; the independent reproduction's JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3; the intended-behaviour check's main() fails on 7.0.0, 10.5.2 and main (-ea, -da) and passes on 6.6.6; the finder's main() fails on 8.11.4, 9.12.3 and 10.5.2. [read] all 65 release tags in the full-history clone that contain the commit (7.0.0-8.8.1, 9.0.0-10.5.2); 8.8.2-8.11.3 have no tags there (8.11.4 executed); 4.8.0-6.3.x use the older SortField/ValueSource bindings and bind the first-pass score (not run; the probe compiles only from 6.4). Not fixed upstream: the explain body is unchanged on main, branch_10x and branch_10_6 (file last changed by 945b1cb872, 2021-03-23) [read 2026-10-26]. All runs on aarch64 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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, verdict new, 125 logged query lines: a scripted prior-report search with 14 GitHub issue/PR, 2 commit, 2 downstream, 24 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA and 6 web queries, 1553 distinct hits triaged; 51 GitHub GraphQL searches read to the end (apache/lucene incl. in:comments, apache/lucene-solr, Elasticsearch, OpenSearch, Solr, unscoped); 10 JIRA JQL queries over LUCENE and SOLR; 56 lists.apache.org queries; 4 Stack Overflow searches; 3 downstream code searches for getRescorer; the changed files of all 413 open apache/lucene PRs; the full git history of the file): no issue, PR, review comment, list post, JIRA entry, downstream report or web page describes it; not fixed upstream (unchanged on main, branch_10x and branch_10_6); related, not reports of this bug: #8792 (LUCENE-7741, introduced it; its thread never mentions rescorers), #6608 (LUCENE-5545, added ExpressionRescorer; design intent: explain shows the binding values), #6552 (LUCENE-5489, rescoring API), #14776 (DoubleValuesSourceRescorer, 10.3.0; a review comment there states that Rescorer#explain should explain how the rescorer scores the doc in rescore()), #10895 (LUCENE-9856, deleted the dead scores() helper left by the regression, without discussion), open draft #16847 (widens docID in two other lines of the same explain method and keeps passing superExpl; a three-way merge of each fix with it is clean [exec 2026-11-01, git merge-file]); gaps: one commits@ query was truncated at 1500 of 1776 commit mails, and private reports to security@lucene.apache.org are not visible
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off (expected:<1.0> but was:<0.0>); with the recommended fix it passes, all 171 tests of the expressions module pass both ways, and the module's format, ECJ and forbidden-apis checks pass. The same ExpressionRescorer.java, ExpressionValueSource.java and SortRescorer.java are on upstream main 2f424fd428d, branch_11x, branch_10_6 and 10.6.0 (read). Earlier runs used a version-portable probe on 6.5.1, 6.6.0, 6.6.6 (pass) and 7.0.0, 7.7.3, 8.11.4, 9.12.3, 10.5.2 and main ea57360912ed (fail; main with assertions on and off, the releases with assertions on), and the minimal test through the standalone runner on 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionRescorer.java:29-33 (class javadoc: same as SortRescorer 'except for the explain method which gives more detail by showing the value of each variable'), :35 (@lucene.experimental), :37 (package-private class), :49-62 (explain: superExpl = super.explain(...) at :52, passed as the score explanation at :59-61)
  • lucene/core/src/java/org/apache/lucene/search/SortRescorer.java:96-125 (single-hit rescore of the caller's docID; 'first pass score' detail; sort-field details with value 0.0f and the real value in the text at :120; root Explanation.match(0.0f, 'sort field values for sort=...') at :125)
  • lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:188-205 (explain evaluates the expression with the scores bound to DoubleValuesSource.constant(scoreExplanation.getValue()) and explains each variable with scoreExplanation) and lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:376-377 (SCORES.explain returns scoreExplanation)
  • lucene/expressions/src/java/org/apache/lucene/expressions/Expression.java:28-40 (javadoc example 'sqrt(_score) + ln(popularity)' with bindings.add("_score", DoubleValuesSource.SCORES)) and :112-114 (public getRescorer); lucene/core/src/java/org/apache/lucene/search/Rescorer.java:45 ('Explains how the score for the specified document was computed.')
  • lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionRescorer.java (testBasic uses 'sqrt(_score) + ln(popularity)' but checks only substrings of the explanation, never values; per the intended-behaviour check and prior-report record)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionRescorer.java:29-37, 49-62
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/SortRescorer.java:38-93 (77, 89-91), 96-125 (118-120, 125)
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:188-205 (191-195, 200-202)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:356-378 (SCORES.explain at 376-377)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/Expression.java:66 (@lucene.experimental), 111-114 (getRescorer)
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionRescorer.java:35-37 (@lucene.experimental, package-private class)
  • a Lucene main checkout (645d4fe14a20) grep of lucene/ for getRescorer and ExpressionRescorer outside src/test: only the definition
Minimal repro
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/MatchAllDocsQuery.java:26-32 (INSTANCE; constructor deprecated)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionRescorer.java:29-33
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/Rescorer.java:45
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:72-89 (no @param for scoreExplanation; default explain), 376-377
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionRescorer.java:100-124
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:2537 (Lucene 9.12.0), 2689 (Bug Fixes), 2705 (GITHUB#13691)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionRescorer.java:123
History
  • a Lucene clone refs fetched 2026-10-30: origin/main 2f424fd428d, branch_11x 603af870375, branch_10_6 7e5aec4370e, tag releases/lucene/10.6.0 (same blobs)
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionRescorer.java:52, 59-61; a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/SortRescorer.java:77, 125; a Lucene main checkout (645d4fe14a20) lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:191-195

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.

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.