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.
- A rescorer re-sorts the top hits of a first search.
Expression.getRescorerdoes it with a formula over the hit's first-pass score (_score) and document values, for example_score * 100 + popularity. explainreturns a tree of numbers that says how a hit got its value. ExpressionRescorer's javadoc says its tree shows the value of each variable.SortRescorer, which ExpressionRescorer extends, explains a re-sort with a placeholder value of 0.0 and puts the real sort value in the text.
_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.- Rescore first-pass hits with
Expression.getRescorerfor_score * 100 + popularity. A hit with first-pass score 1.0 and popularity 7 is sorted by 1.0 x 100 + 7 = 107.0. - 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_scoreline was right. - Instead you get
7.0 = _score * 100 + popularity, and the_scoreline reads0.0 = sort field values for sort=.... The javadoc's own examplesqrt(_score) + ln(popularity)is explained as justln(popularity). - Why: explain first asks
SortRescorerfor 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. - 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.
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.
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 keepsScoreDoc.scoreat 1.0 (:89-91).ExpressionRescorer.explain(ExpressionRescorer.java:49-62) first callsSortRescorer.explain(:52). That re-runs the rescore for the one hit and returnsExplanation.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).- It passes that 0.0 node to
ExpressionValueSource.explainas the score explanation (ExpressionRescorer.java:59-61). ExpressionValueSource.explainevaluates the expression with the scores bound toDoubleValuesSource.constant(scoreExplanation.getValue())= 0.0 (ExpressionValueSource.java:191-195): 0 * 100 + 7 = 7.0. For_score,DoubleValuesSource.SCORES.explainreturns the score explanation itself (DoubleValuesSource.java:376-377), so the_scoredetail 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.
sqrt(_score) + ln(popularity), the Expression javadoc's example, is explained asln(popularity)(1.9459 for popularity 7, against a sort value of 2.9459).popularity / _scoreis explained as+Infinity, andln(_score) + popularityas-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
_scoreexplain correctly, and the expression's ownDoubleValuesSource.explain, given the real first-pass explanation, returns the sort value.
Exposure and likelihood239 words
- Who. Applications that call
explainon a rescorer fromExpression.getRescorerwhose expression reads_score, directly or through a binding. For every explained hit the_scoredetail 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_scorevalue shown is wrong, and so is the expression value wherever it depends on_score. It reproduced with a plainIndexSearcher, 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_scorevalue shown, and the expression value computed from it. - What is right. The rescored order, the
FieldDocsort values, theScoreDocscores andisMatch()(checked by a probe on main with assertions on and off). - Opt-in, rarely used API.
ExpressionandExpressionRescorerare@lucene.experimental;ExpressionRescoreris package-private and reached only through the publicgetRescorer. No Lucene code outside tests callsgetRescorer. - 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).
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.
/*
* 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);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:expressions:test --tests org.apache.lucene.expressions.TestExpressionRescorerExplainScore -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
- The explanation's
_scoredetail 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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the recommended fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run) |
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
# 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)- The class javadoc says explain shows "the value of each variable"; it shows
_scoreas 0, not the score the rescore used. - A regression: 6.6.6 showed
_scorecorrectly; LUCENE-7741 (7.0.0) swapped in the 0.0 node without a stated reason. - The explained
7.0 = _score * 100 + popularityis 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#explainAPI is meant to explain how the _Rescorer_ would score this docId for given searcher, whenrescore()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 printedvariable "_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.explainreturns it, and the defaultDoubleValuesSource.explainevaluates withscoreExplanation.getValue()as the score (DoubleValuesSource.java:80-89). - Why nobody noticed. The same commit changed the upstream test from
expl.contains("= variable \"popularity\"")toexpl.contains("= double(popularity)");TestExpressionRescorer.testBasicchecks 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).
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
_scoredetail is1.0 = first pass scorewith the first-pass explanation under it; one detail per variable. - Keeps the "= first pass score" text that
TestExpressionRescorer.testBasicasserts. 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 andTestExpressionRescorerincluded) passes with 0 failures with assertions on and off; the module'scheckGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestpass.
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.
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, Elasticsearch's expression scripts wrap this rescorer (DoubleValuesScript.getRescorer); a code search found only a test calling it.
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.explainandgetSortFieldagainst the values they compute. Tracing the explain path showed thatExpressionRescorer.explainpasses 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| 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 web | 2026-10-26 | scripted 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 history | no report; related items only |
| GitHub issues and PRs (apache/lucene) | 2026-11-01 | ExpressionRescorer, ExpressionRescorer explain, rescorer explain, SortRescorer explain, explain _score rescore, rescorer _score explanation; three searches over items updated since 2026-10-20 | only the known related items (#6608, #6552, #14776); #16847 still an open draft |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails through gradle with assertions on and off: _score detail 0.0, root 7.0, sort value 107.0 |
main ea57360912ed | affected | history 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 | affected | history probe fails 6 of 6 hits |
| 7.0.0 | affected | history 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/B | passes 6 of 6: 0.19129147 = variable "_score" |
| 6.6.6, 6.6.0, 6.5.1 | not affected | passes 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 | read | the same ExpressionRescorer, ExpressionValueSource and SortRescorer files as 645d4fe14a20 |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (expressions-compiler unit) | candidate | Public-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, high | Own 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 | real | Own 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, high | Own 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 weak | Fails 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, high | Re-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 | none | Probe: ranking, sort values and scores correct; only the explanation is wrong; 3 of 3 pass on main with -ea and -da. |
| Repro minimization | verified | The 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 validated | Gradle 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. |
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 465 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,441 words
- 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 sameExpressionRescorer.java,ExpressionValueSource.javaandSortRescorer.javaare on upstream main2f424fd428d,branch_11x,branch_10_6and 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 mainea57360912ed(fail; main with assertions on and off, the releases with assertions on), and the minimal test through the standalone runner on645d4fe14a20.
- 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-62a 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-33a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/Rescorer.java:45a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:72-89 (no @param for scoreExplanation; default explain), 376-377a Lucene main checkout (645d4fe14a20) lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionRescorer.java:100-124a 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
- TestExpressionRescorerExplainScore.java (one test class):
repro/LFV-174/src/org/apache/lucene/expressions/TestExpressionRescorerExplainScore.java(59 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-174/bug.png(465 KB)
