A rescorer re-ranks the top hits of a first search. DoubleValuesSourceRescorer does it with one value per hit from a DoubleValuesSource. A source that reads the hit's score says so with needsScores(), and whoever calls getValues must then pass the scores in.
sqrt(_score) + ln(popularity) on the three hits: what rescore() should return, what explain() returns with _score read as 0, and line 73 handing the source null where the scores belong. Select the still to open it at full size. Generated with AI, checked against the code.- You index 4 documents with a text
bodyand a numericpopularity(1, 20, 5, 7) and searchbody:contents: 3 hits, doc 0 = 0.2229, doc 1 = 0.2038, doc 2 = 0.1151. - You re-rank them with a
DoubleValuesSourceRescorerwhose source is theExpressionjavadoc examplesqrt(_score) + ln(popularity), with_scorebound toDoubleValuesSource.SCORES. You expect doc 1 = 3.4472, doc 2 = 1.9486, doc 0 = 0.4721. - Instead
rescore()throws on the first hit, doc 0: with assertions off,RuntimeException: Unrecognized variable (_score) referenced in expression (sqrt(_score) + ln(popularity))., although_scoreis bound; with them on, anAssertionError.SCORESalone and aFunctionScoreQuery.boostByValuesource fail with aNullPointerException. - The cause:
rescore()callssource.getValues(ctx, null)for every hit and never checksneedsScores(). ThegetValuesjavadoc saysnullis safe for a source that does not need scores, andSCORESsays callers must not pass it. explain()for the same hits returns normally, because it hands the source a placeholder whose value is 0: it reports 0.0000, 2.9957 and 1.6094 (ln(popularity)alone), valuesrescore()never produces.
Code that subclasses DoubleValuesSourceRescorer (10.3.0 and later) with a source that reads the score: DoubleValuesSource.SCORES, a _score expression or a FunctionScoreQuery boost source. For those stock sources every rescore() call with at least one hit fails on the first hit it processes; no special setting is needed. LateInteractionRescorer and score-free sources are unaffected.
Mechanism, terms and numbers743 words
DoubleValuesSourceRescorer (new in 10.3.0) re-scores first-pass hits with a value from any DoubleValuesSource; its constructor accepts any source and its javadoc states no restriction. But rescore() always calls source.getValues(ctx, null) and never looks at needsScores(), while DoubleValuesSource#getValues says callers may safely pass null scores when needsScores() is false and DoubleValuesSource.SCORES says callers must not pass null. So these stock score-reading sources fail on the first hit rescore() processes: the Expression javadoc example sqrt(_score) + ln(popularity) (an AssertionError with -ea, a misleading "Unrecognized variable (_score)" with -da), SCORES itself (AssertionError or NullPointerException) and the source of FunctionScoreQuery.boostByValue (NullPointerException). explain() for the same hits returns normally, because it passes a placeholder explanation (value 0) instead of the score, so it reports values rescore() never computes. Sibling paths (RescoreTopNQuery, FunctionScoreQuery, SortRescorer, ExpressionRescorer) all supply scores; LateInteractionRescorer, the only production subclass in Lucene, uses a score-free source and is unaffected.
- Rescorer: takes the top hits of a first, cheap search and gives them new scores, usually from something more expensive.
rescore(searcher, firstPassTopDocs, topN)returns the re-ranked hits;explain(searcher, firstPassExplanation, doc)explains one hit's new score. DoubleValuesSource: produces onedoubleper document, for example a field value, a constant, an expression or a function of the query score.getValues(ctx, scores)returns those values for one segment; thescoresargument carries the query scores to sources that use them.needsScores(): a source returns true when its values depend on the score.DoubleValuesSource.SCORESis the source whose value is the score; an expression with_scorebound toSCORES, orFunctionScoreQuery.boostByValue's source (score times boost), also needs scores.DoubleValuesSourceRescorer: an abstract rescorer added in 10.3.0 (#14776). A subclass gives it a source and acombine(firstPassScore, valuePresent, sourceValue)that turns the first-pass score and the source's value into the new score.LateInteractionRescoreris the only production subclass in Lucene.
- For each hit,
rescore()callsDoubleValues values = source.getValues(ctx, null);(DoubleValuesSourceRescorer.java:73ata9779ef3c193), thenvalues.advanceExact(doc)(:74) andvalues.doubleValue()(:75). It never callsneedsScores(). The hit's first-pass score is in hand, but it goes only tocombine(:76). getValuessays callers may safely passnullwhenneedsScores()is false (DoubleValuesSource.java:63-64).SCORESsays "callers must not pass null" (:352-354) and runsassert scores != null; return scores;(:361-362).- So
SCORESfails the assertion with-ea, and with-dareturnsnull, which the rescorer dereferences at:74. An expression asksSCORESfor its values, getsnulland reports it as an unknown variable (ExpressionValueSource.java:73-80). TheboostByValuesource multipliesscores.doubleValue()by the boost and dereferencesnull(FunctionScoreQuery.java:299). - It happens on the first hit processed (hits go in doc-id order, so the lowest doc id), before any score is written, on every call with at least one hit; nothing on the path depends on the index size.
explain()passesExplanation.noMatch("DoubleValuesSource was not initialized with query scores")instead (DoubleValuesSourceRescorer.java:107-112).SCORESreturns that placeholder as its explanation, so the rescorer reports "no value in DoubleValuesSource"; an expression evaluates_scoreas the placeholder's value, 0.explain()returns normally with a scorerescore()never computes.
Four documents (body, popularity): doc 0 "contents contents" (1), doc 1 "contents" (20), doc 2 "contents of the book" (5), doc 3 "other" (7). body:contents, top 3: doc 0 = 0.22292183, doc 1 = 0.20381427, doc 2 = 0.115056425.
| source | expected rescore() | rescore(), assertions off | explain() |
|---|---|---|---|
sqrt(_score) + ln(popularity) | doc 1 = 3.4471903, doc 2 = 1.9486376, doc 0 = 0.47214597 | RuntimeException: Unrecognized variable (_score) ... | 0.0, 2.9957323, 1.609438 |
DoubleValuesSource.SCORES | each hit's own score | NullPointerException at DoubleValuesSourceRescorer.java:74 | -1.0 ("no value", mapped to -1 by the test's combine) |
boostByValue(q, popularity).getSource() | doc 1 = 4.0763, doc 2 = 0.5753, doc 0 = 0.2229 (score times popularity) | NullPointerException at FunctionScoreQuery.java:299 | not run |
The expected values are what the fix computes; with assertions on, the first two throw an AssertionError at DoubleValuesSource.java:361.
A related silent form: a function-query ValueSource wrapped with asDoubleValuesSource() reports needsScores() true and turns null scores into an empty value (ValueSource.java:92-94, :224-227), so a wrapped function that reads the score sees 0 instead of throwing. ValueSource falls back to 0 on purpose and no contract says what such a function should see here, so this is an observation, not a second bug.
Exposure and likelihood221 words
- Who is hit. Applications that subclass the public abstract
DoubleValuesSourceRescorerand give it a source that reads the score:DoubleValuesSource.SCORES; an expression using_score(theExpressionandSimpleBindingsjavadoc examples bind_scoretoSCORES);FunctionScoreQuery.boostByValue(...).getSource(); or any custom source withneedsScores()true that reads its scores. A natural use is "re-rank the top hits by score combined with popularity or recency". - Default vs opt-in. No special index, codec or searcher setting is needed: the tests reproduce under the test framework's randomized codec and similarity, the numbers probe with
new IndexWriterConfig()and BM25. The opt-in part is the API itself: the class is new in 10.3.0 and its motivating uses (full-precision vectors, late interaction) read no score. - Loud, not silent. For these stock sources the request fails with an exception; no result is silently wrong. The one silent form is a wrapped function-query
ValueSourcethat reads the score: it sees 0. - Workaround. Use a score-free source (for example
ln(popularity)) and apply the score part incombine(firstPassScore, ...), which already receives the first-pass score. That covers any formula of the first-pass score and one source value, but not an existing expression or boost source reused as is. - Not affected.
LateInteractionRescorer(its source'sneedsScores()is false) and any source that does not need scores (field values, constants, vector similarity).
The test shows that DoubleValuesSourceRescorer.rescore with DoubleValuesSource.SCORES calls getValues(ctx, null) on a source whose needsScores() is true and fails on the first hit (one empty doc, MatchAllDocsQuery first pass).
/*
* 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 org.apache.lucene.document.Document;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDoubleValuesSourceRescorerScores extends LuceneTestCase {
public void testRescoreWithScoresSource() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
TopDocs firstPass = searcher.search(MatchAllDocsQuery.INSTANCE, 1);
try {
new DoubleValuesSourceRescorer(DoubleValuesSource.SCORES) {
@Override
protected float combine(float firstPassScore, boolean valuePresent, double value) {
return (float) value;
}
}.rescore(searcher, firstPass, 1);
} catch (IllegalArgumentException rejected) {
// rejecting a source that needs scores up front would also be a valid fix
} catch (AssertionError | RuntimeException e) {
throw new AssertionError(
"expected rescore() to supply scores to SCORES or reject it with"
+ " IllegalArgumentException, but got "
+ e,
e);
}
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-334/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.TestDoubleValuesSourceRescorerScores
- Expected vs actual
- Expected: rescored hits (or an IllegalArgumentException rejecting the source); actual: AssertionError from
assert scores != null(DoubleValuesSource.java:361) with -ea, NullPointerException "values is null" (DoubleValuesSourceRescorer.java:74) with -da.
More files, environment, full output48 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected rescore() to supply scores to SCORES or reject it with IllegalArgumentException, but got java.lang.AssertionError
org.apache.lucene.search.TestDoubleValuesSourceRescorerScores.testRescoreWithScoresSource(TestDoubleValuesSourceRescorerScores.java:47)
Caused by: java.lang.AssertionError
org.apache.lucene.search.DoubleValuesSource$1.getValues(DoubleValuesSource.java:361)
org.apache.lucene.search.DoubleValuesSourceRescorer.rescore(DoubleValuesSourceRescorer.java:73)
org.apache.lucene.search.TestDoubleValuesSourceRescorerScores.testRescoreWithScoresSource(TestDoubleValuesSourceRescorerScores.java:43)getValuessaysnullscores are safe whenneedsScores()is false, andSCORESsays "callers must not pass null"; here the rescorer is the caller.- Nothing documents a restriction: the constructor takes any
DoubleValuesSource, andexplain()returns normally on the same input. - Every sibling path supplies scores:
RescoreTopNQuerychecksneedsScores(), andFunctionScoreQuery,SortRescorerandExpressionRescorerpass the hit's score.
The full argument352 words
- The callee contract.
DoubleValuesSource.java:63-64: "If scores are not needed to calculate the values (ieneedsScores()returns false), callers may safely passnullfor thescoresparameter."SCORES,:352-354: "callers must not passnullas theDoubleValuesparameter". The class javadoc (:43-46) says scores reach a source by "passing the resulting DoubleValues togetValues". - No documented restriction. The class javadoc ("A
Rescorerthat uses provided DoubleValuesSource to rescore first pass hits",DoubleValuesSourceRescorer.java:25), the constructor (:30-32), thecombinejavadoc (:34-41),Rescorerand the 10.3.0 CHANGES entry ("GITHUB#14776: Add a Rescorer that uses values from provided DoubleValuesSource to re-score first pass hits.") say nothing about score-reading sources. explain()andrescore()disagree.Rescorer#explain: "Explains how the score for the specified document was computed." In the #14776 review the author wrote thatRescorer#explain"is meant to explain how the _Rescorer_ would score this docId for given searcher, whenrescore()is called" (in reply to a question aboutexplain()for a doc the searcher does not hold, not about scores).- Siblings.
RescoreTopNQuerybranches onvaluesSource.needsScores()and passesDoubleValuesSource.fromScorer(innerScorer)(RescoreTopNQuery.java:74-79,:230-236);FunctionScoreQuerypassesfromScorer(FunctionScoreQuery.java:182,:239);SortRescorerfeeds each hit's first-pass score to the sort (SortRescorer.java:58,:77), which is howExpressionRescorergives_scorea value;TestExpressionRescorerbinds_scoretoSCORES(:100-103). - Tests never covered it.
TestDoubleValuesSourceRescoreruses onlyDoubleValuesSource.fromIntField(:38),TestLateInteractionRescorera score-free source; no test expects an exception for a score-reading source. nullis normal elsewhere; the missing check is the defect. A literal search forgetValues(ctx, null)ata9779ef3c193finds 11 other non-test sites, 9 of them on a score-free source of a known kind (the source itself, or a constant, field, vector or query source the code creates), 2 in facet wrappers. Here a public constructor accepts any source andrescore()passesnullwithout looking atneedsScores().- History. The
nullis in the PR's first commit ("doubleValuesSource rescorer; no explain support"); the second ("add explain impl") added the placeholder. The PR's 6 review comments never mentionneedsScores()or score-reading sources.
The devil's-advocate check151 words
The strongest case for "intended": the placeholder text "DoubleValuesSource was not initialized with query scores" shows the author knew rescore() passes no scores; combine(firstPassScore, ...) is the designed place to mix in the first-pass score; Rescorer is @lucene.experimental; the only subclass and the tests use score-free sources.
Why it fails: the placeholder is a string inside explain(), not documentation a user reads; @lucene.experimental lets an API change, not break the contract of the class it calls; the rescorer itself breaks the caller obligation of getValues; and even if such sources were deliberately unsupported, the answer would be a clear error, as LUCENE-5500 gave SortingMergePolicy for a sort on the score, not an AssertionError, a NullPointerException or "Unrecognized variable (_score)". Verdict: a defect under either reading: the API needs score plumbing or explicit validation. Only the remedy, and with it what SCORES means in a rescorer, is open.
Recommended Pass the hit's first-pass score to sources that need scores
Give a score-reading source the hit's first-pass score: when needsScores() is true, pass a DoubleValues that returns hit.score instead of null, and pass the first-pass explanation to source.explain (+20/-5, one file). The three repros pass with assertions on and off, and so do 35 nearby upstream test classes.
All options and trade-offs3 options · 636 words
One fix was built and checked (A). B and C are the alternatives a maintainer may prefer. With A or B, explain() must follow the same rule as rescore(), because today it evaluates a score-reading source against a placeholder whose value is 0; C leaves that mismatch.
In rescore(), when source.needsScores() is true, build one DoubleValues whose value is the current hit's first-pass score and pass it to getValues instead of null; sources with needsScores() false still get null. In explain(), pass firstPassExplanation to source.explain(...) instead of the noMatch("DoubleValuesSource was not initialized with query scores") placeholder. The first-pass score is the only score a rescorer has for a hit and is what combine already receives; it is what SortRescorer and ExpressionRescorer give _score, and the per-hit analogue of RescoreTopNQuery's fromScorer(innerScorer).
// a source that needs scores sees the first-pass score of the hit being rescored
final float[] firstPassScore = new float[1];
final DoubleValues firstPassScores =
source.needsScores()
? new DoubleValues() {
@Override
public double doubleValue() {
return firstPassScore[0];
}
@Override
public boolean advanceExact(int doc) {
return true;
}
}
: null;
...
firstPassScore[0] = hit.score;
DoubleValues values = source.getValues(ctx, firstPassScores); // was: (ctx, null)Trade-offs
- Every source with
needsScores()true sees a change: the stock ones that throw today now work, and one that toleratesnullnow gets the first-pass score (the wrapped function-queryValueSourcethat reads the score, which sees 0 today, or a custom source with its ownnullfallback). Sources withneedsScores()false are unchanged inrescore(). explain()passes the first-pass explanation tosource.explain()for every source; stock score-free sources ignore it, but a custom source whoseexplain()embeds it would show a different tree.- A design choice: with no live
Scorerafter the first pass, fix A makesSCORESin a rescorer mean the hit'sScoreDoc.score. The API does not promise that; maintainers may prefer B. explain()then reports whatrescore()computes (expression: 0.4721, 3.4472, 1.9486).- Cost per
rescore()call: a one-elementfloat[](every source) and one anonymousDoubleValueswhenneedsScores()is true, plus one array write per hit (also done, unused, for score-free sources); no benchmark was run. - The open draft #16847 edits the same file (mechanically); one of the two may need a rebase.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, seed DEADBEEF: stock, each repro test fails 1 of 2 with assertions on and off; with the fix all three pass 2 of 2 in both modes. - Closest suites with the fix: core
TestDoubleValuesSourceRescorer,TestLateInteractionRescorer,TestSortRescorer,TestQueryRescorer, the threeTestRescoreTopNQuery*andTestDoubleValuesSource; all oflucene/expressions; all oforg.apache.lucene.queries.function: 35 upstream classes, 316 tests, 0 failures (2 skipped). ecjLintandcheckGoogleJavaFormatfor core, expressions and queries pass;fix.diffandfix-with-test.diffapply cleanly. Not run: the full core and queries suites. Not tested:explain()for the boost source, with a non-matching first-pass explanation, a customexplain()override, anull-tolerant source or several segments.
Throw in rescore() and explain() (or the constructor) when source.needsScores() is true, telling the caller to use a score-free source and mix in the score in combine(). LUCENE-5500 is a precedent. Not run.
Trade-offs
Removes a natural use (re-ranking by an existing _score expression or boost) and breaks sources that work today: the generic wrapper of ValueSource#asDoubleValuesSource() reports needsScores() true for every function query, even one like LongFieldSource that never reads the score, and such a wrapped source rescores fine now.
Javadoc on the class and constructor.
Trade-offs
Leaves the AssertionError, the NullPointerException and the misleading "Unrecognized variable (_score)" in place, and explain() still disagrees with rescore(). Not recommended.
Unreleased Solr re-rank code (SOLR-17951, fix version 10.1) maps a FunctionScoreQuery re-rank query to this class, so a boost-style re-rank query there would fail with the boostByValue NullPointerException (read from Solr source, not run). Which value a fix should give there (fix A: first-pass score times boost) is a Solr-side question.
Code reading, not a tool's counterexample: while writing a bounded model of DoubleValuesSourceRescorer, the finder had to state which scores the source receives, and the code always passes null. A targeted probe and a public-API JUnit on the stock jar confirmed it; the model's own harness used only score-free sources.
The method in full124 words
Method: code reading during modelling (the hunt's non-formal tier), in a hunt unit on rescorer equivalence (DoubleValuesSourceRescorer, RescoreTopNQuery, QueryRescorer).
- The finder wrote a bounded-exhaustive translation-validation model of
DoubleValuesSourceRescorer. The model has to say what the values source receives as scores; the code passesnullfor every source. - The harness itself used only sources with
needsScores()false, so it produced no signal for this bug. A probe withDoubleValuesSource.SCORESand with the expression_score * 2then threw, and the finder wrote a public-API JUnit with a constant-source control. - Found 2026-10-26 on main
ea57360912ed; then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication and a credibility audit.
No prior report found (2026-10-26) in 107 logged search entries (queries, enumerations and reads) over GitHub, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive, Solr, Elasticsearch, OpenSearch and the web, plus the changed files of all 411 open PRs. The only open PR touching the file, #16847, keeps the null.
Searches3 searches
Verdict new: none of the hits is this bug. The class dates from June 2025, after the move from JIRA to GitHub, so JIRA cannot hold a report. Keyword search cannot prove absence; GitHub's search rate limit was reached (all planned queries completed), and private channels cannot be searched.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; the web | 2026-10-26 | the hunt's prior-report tool: 16 GitHub issue/PR queries, 3 commit queries, 2 downstream queries, 40 list queries, 4 JIRA queries, 6 web searches; the finder's earlier 24-query list/JIRA run | no report; only the GitHub mirror of #14776, #14729 and #15826 names the class on the lists |
| GitHub (apache/lucene, Solr, Elasticsearch, OpenSearch, the k-NN plugin, all of GitHub) | 2026-10-26 | 17 manual apache/lucene searches (class names, SCORES, needsScores, _score, "Unrecognized variable", NPE/AssertionError, open rescorer issues and PRs), 6 downstream searches, 2 commit searches | no report; related only: #14776, #14729, #16705, #14009, #14793, #14948, luceneutil #435, k-NN #3084 and #3439 |
| open apache/lucene PRs and upstream branches | 2026-10-26 | changed files of all 411 open PRs; the file's history on main, branch_10x, branch_10_6 and branch_10_5 | #16847 (draft, long doc ids) is the only open PR that edits the file and keeps the null; not fixed on any branch |
In the class since it was added: #14776 (c8147c9e6fa1, merged 2025-06-15; backport 02f5dc30e2fe), first released in 10.3.0. Executed: 10.3.0 to 10.5.2 and main fail; 10.2.2 and older have no such class. Not fixed upstream (main, branch_10x, branch_10_6, branch_10_5).
Versions tested5 rows
Introduced by c8147c9e6fa19e12390f1b2f66e18c0af3654d44 on main and its branch_10x backport 02f5dc30e2fe, "Add a rescorer that uses DoubleValuesSource values to re-score first pass hits (#14776)" (@vigyasharma, approved by @romseygeek, merged 2025-06-15, milestone 10.3.0). git log -S 'source.getValues(ctx, null)' on the file finds only this commit; the file's only later commit, #15826 (2026-03-16), rewrote the top-N copy and kept the null. The callee contract it breaks is older: LUCENE-5325 (3f24fd81c836, 2016).
Not fixed upstream (read 2026-10-26): line 73 is unchanged on main, branch_10x, branch_10_6 and branch_10_5.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | the three repro tests fail through gradle, assertions on and off; controls pass; all pass with the fix |
main ea57360912ed | affected | the finder's and the reproducer's JUnits fail, -ea and -da; the expression and boost variants too |
| 10.5.2, 10.5.1, 10.5.0, 10.4.0 | affected | the finder's JUnit fails 1 of 2 (Maven Central jars, SHA-1 checked); this doc's core and expression tests on 10.5.2 and the boost test on 10.4.0, -da; the independent reproducer's _score * 2 expression test on 10.5.2, -da |
| 10.3.0, 10.3.1, 10.3.2 | first release | the finder's JUnit fails 1 of 2 (-ea, and -da on 10.3.0); this doc's core test fails on 10.3.0; the independent reproducer's _score * 2 expression and spy-source tests on 10.3.0, -da |
| 10.2.2, 9.12.3 | no such class | the repro does not compile: DoubleValuesSourceRescorer does not exist |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing. Reading-only reviews by a model from another family (OpenAI GPT-5.6) agreed on the root cause; an adjudication and a credibility audit confirmed it, and this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.
Checks and verdicts265 words
Every executing check wrote or re-ran its own JUnit on stock main; every control passed.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API JUnit with SCORES and a constant control: 1 of 2 fail on main (-ea, -da), 10.5.2 and 10.3.0; the fix sketch passes it and the 5 upstream rescorer tests. |
| independent reproducer | real | Own JUnit: 2 of 4 fail on main, 10.5.2 and 10.3.0; a spy source with needsScores() true receives null in 10 of 10 calls in both assertion modes; the _score * 2 expression variant fails too. |
| cross-model reviews (refute, intended) | not real (low, procedural); real (high) | An independent reviewer from another model family (OpenAI GPT-5.6) could only read code. The lenses agreed on the root cause; the refute lens could not execute and asked for the constant-source control, which was added; the intended lens rated the intended argument moderate. Separate runs re-ran the repro: it fails. |
| intended-behaviour devil's advocate | real | Built the strongest "intended" case (the explain placeholder, combine already receiving the score) and showed the contracts, the #14776 author's comment and the sibling paths contradict it. |
| adjudication | confirmed, high | Re-ran the independent reproduction's JUnit (2 of 4 fail, -ea and -da) and the expression variant; only crash versus wrong error depends on the maintainers' intent. |
| credibility audit | keep | Re-ran the finder's JUnit and the expression variant; re-checked the score arithmetic and the dedup. |
| main refresh | still reproduces | The finder's and the independent reproduction's JUnits fail on a9779ef3c193 with the same tests and messages as on ea57360912ed. |
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 568 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,249 words
- Registry title
- DoubleValuesSourceRescorer.rescore passes null scores to a DoubleValuesSource whose needsScores() is true, so rescoring with DoubleValuesSource.SCORES, an expression that reads _score or a FunctionScoreQuery.boostByValue source throws (AssertionError with -ea; NullPointerException or a misleading 'Unrecognized variable (_score)' RuntimeException with -da), while explain() on the same input returns normally
- Area
- core / search: DoubleValuesSourceRescorer.rescore and explain (score-dependent DoubleValuesSource)
- Priority
- Low, rank 365: A loud, deterministic exception in the new expert DoubleValuesSourceRescorer with a score-reading source; nothing is silently wrong.
- Impact class
- crash
- Impact note
- Applications that subclass the public DoubleValuesSourceRescorer (new in 10.3.0) and give it a source that reads the query score (DoubleValuesSource.SCORES, an expression bound to _score as in the Expression and SimpleBindings javadoc examples, FunctionScoreQuery.boostByValue(...).getSource(), or a custom source with needsScores() true) get a deterministic unchecked exception from every rescore() call, on the first hit, with default index and searcher settings and at any index size; for these stock sources nothing is silently wrong (a wrapped function-query source that reads the score instead silently sees 0, same root cause), LateInteractionRescorer, the only in-tree subclass, uses a score-free source and is unaffected, and the bug is in every release from 10.3.0 to 10.6.0 (10.6.0 by reading).
- Summary
- You subclass DoubleValuesSourceRescorer to re-rank first-pass hits with a source that uses the query score, for example DoubleValuesSource.SCORES or the expression '_score * 2' with _score bound to SCORES, and expect rescore() to work as it does for any other source: the constructor accepts any DoubleValuesSource and documents no restriction. Instead rescore() calls source.getValues(ctx, null) for every source (DoubleValuesSourceRescorer.java:73), although DoubleValuesSource#getValues allows a null scores argument only when needsScores() is false and the SCORES javadoc says callers must not pass null, so it throws on the first hit: an AssertionError with -ea, and with -da a NullPointerException at line 74 or, for the expression, RuntimeException 'Unrecognized variable (_score) referenced in expression (_score * 2).'. explain() for the same rescorer, source and document returns a normal explanation, because it passes a placeholder 'DoubleValuesSource was not initialized with query scores' explanation instead of scores.
- Introduced
- c8147c9e6fa19e12390f1b2f66e18c0af3654d44 on main and its branch_10x backport 02f5dc30e2fe (the commit the 10.3.0 tag contains), 'Add a rescorer that uses DoubleValuesSource values to re-score first pass hits', apache/lucene#14776 by vigyasharma [read: git log --follow, git blame of lines 60-115 and git log -S 'source.getValues(ctx, null)' in the full-history clone attribute the null argument to this commit only; the PR's first commit cd8201c25f already had it, and its commit 56bc5dc113 added the explain() placeholder; exec: the repro does not compile on 10.2.2 (class absent) and fails on 10.3.0]. Not a regression: the defect is in the first version of the class. The violated callee contract (getValues javadoc, SCORES 'assert scores != null') dates from LUCENE-5325 (3f24fd81c836, 2016) [read] (2025-06-15 (#14776 merged; author date 2025-06-14))
- First affected version
- 10.3.0 [exec] (first release with DoubleValuesSourceRescorer; 10.2.2, all earlier 10.x and all 9.x releases do not have the class: compile_error on 10.2.2 and 9.12.3)
- Affected versions
- 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main ea57360912ed [exec: the finder's JUnit fails 1 of 2 on every one of them with -ea, and also with -da on 10.3.0, 10.5.2 and main, the constant-source control passing every time; the independent reproduction's spy-source JUnit fails 2 of 4 on main (-ea and -da), 10.5.2 (-ea and -da) and 10.3.0 (-ea and -da); the '_score * 2' expression variant fails on main (-ea and -da), 10.5.2 and 10.3.0 (-da); the FunctionScoreQuery.boostByValue source throws NullPointerException on main (-ea and -da) and 10.4.0 (-da)]. Not applicable: 10.2.2 and every earlier release (class absent; compile_error on 10.2.2 and 9.12.3 [exec]). Not fixed: line 73 is unchanged on upstream main, branch_10x, branch_10_6 and branch_10_5 (the file's last change, #15826 on 2026-03-16, keeps the null), so the released 10.6.0 has it, and no open PR fixes it [read, 2026-10-26]. All runs on JDK 25, 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record, verdict new, 107 logged queries and reads: a scripted prior-report search with 16 GitHub issue/PR queries on apache/lucene, 3 commit queries, 2 downstream-repo queries, 40 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries and 6 web searches; 17 manual apache/lucene issue/PR searches, 6 downstream searches (all of GitHub, Solr, Elasticsearch, OpenSearch, the k-NN plugin) and 2 commit searches; the changed-file lists of all 411 open apache/lucene PRs; the file's upstream commit history on main, branch_10x, branch_10_6 and branch_10_5; plus the finder's earlier 24-query lists/JIRA run); not fixed upstream (the file's last change, #15826 on 2026-03-16, keeps getValues(ctx, null); the only open PR that edits the file, #16847, a draft for long doc ids by jimczi, is mechanical and keeps the null). Related items, not reports of this defect: #14776 (introducing PR; the review did not discuss score-dependent sources), #14729 (LateInteractionRescorer, score-free source), #16705 and #14009 (RescoreTopNQuery, which checks needsScores()), LUCENE-5500 / #6563 (precedent: reject score-dependent input with a clear error), LUCENE-5545 / #6608 (ExpressionRescorer feeds the first-pass score to _score), LUCENE-8574 (origin of the 'Unrecognized variable' check), #14793 and #14948 (test fixes), luceneutil #435 (benchmark use with a score-free source)
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26): the three repro tests through gradle in a clean worktree, stock and with fix A, assertions on and off, plus 35 nearby upstream test classes in core, expressions and queries with the fix (2026-10-27); through the hunt's classpath runner, the core test on main, 10.5.2 and 10.3.0, the expression test on main and 10.5.2 and the boost test on main and 10.4.0 (main also with the fix shadowed in), and a compile error on 10.2.2. Hunt runs on mainea57360912edand every release from 10.3.0 to 10.5.2; the finder's and the independent reproducer's JUnits fail with identical messages ona9779ef3c193.
- Finding record
lucene/core/src/java/org/apache/lucene/search/DoubleValuesSourceRescorer.java:25-32 (class javadoc and constructor: any DoubleValuesSource, no restriction), :34-42 (combine receives the first-pass score), :73-76 (source.getValues(ctx, null) for every source, then values.advanceExact), :107-112 (explain passes Explanation.noMatch('DoubleValuesSource was not initialized with query scores'))lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:60-67 (getValues: callers may pass null scores only when needsScores() is false), :349-362 (SCORES: 'callers must not pass null', assert scores != null)lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:77, :232-235 (sibling path checks needsScores() and uses DoubleValuesSource.fromScorer)lucene/expressions/src/java/org/apache/lucene/expressions/Expression.java:33-37 and SimpleBindings.java:35 (javadoc examples bind _score to DoubleValuesSource.SCORES); ExpressionValueSource.java:72-76 (null scores reported as 'Unrecognized variable')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/search/DoubleValuesSourceRescorer.java:73-76, 107-117; DoubleValuesSource.java:60-67, 349-363, 376-378lucene/expressions/src/java/org/apache/lucene/expressions/ExpressionValueSource.java:63-85; lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:285-312; ValueSource.java:85-100, 215-232
- Who is affected
lucene/core/src/java/org/apache/lucene/search/LateInteractionRescorer.java:34-35; LateInteractionFloatValuesSource.java:106-108; lucene/expressions/src/java/org/apache/lucene/expressions/Expression.java:33-37; SimpleBindings.java:35
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:43-46, 60-67, 349-355; DoubleValuesSourceRescorer.java:25-42; Rescorer.java:30, 45; RescoreTopNQuery.java:74-79, 230-236; SortRescorer.java:58-77; lucene/queries/.../FunctionScoreQuery.java:182, 239; lucene/CHANGES.txt:1538, 1562, 1571lucene/core/src/test/org/apache/lucene/search/TestDoubleValuesSourceRescorer.java:38; lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionRescorer.java:100-103
- TestDoubleValuesSourceRescorerScores.java (JUnit test):
repro/LFV-334/src/org/apache/lucene/search/TestDoubleValuesSourceRescorerScores.java(56 lines)
Made in parallel with this doc from a probe run on main a9779ef3c193 (stock with assertions on and off, and with the fix) and on 10.5.2; the values on screen come from that probe and match this doc's repro tests, which use the same four documents.
assets/LFV-334/bug.png(568 KB)
