All findings · Lucene finding · LFV-334 · dvs-rescorer-null-scores-for-score-source

DoubleValuesSourceRescorer passes null scores to a source that needs them, so re-ranking with DoubleValuesSource.SCORES or a _score expression throws

DoubleValuesSourceRescorer never gives its source the hits' scores: rescore() calls getValues(ctx, null) for every source. So re-ranking with a score-reading source, such as the Expression javadoc example sqrt(_score) + ln(popularity) or DoubleValuesSource.SCORES, throws on the first hit, while explain() returns normally, reading the score as 0.

Summary

Priority
Low, rank 365A loud, deterministic exception in the new expert DoubleValuesSourceRescorer with a score-reading source; nothing is silently wrong.
Impact
crash
Since
10.3.0 (#14776, 2025), when the class was addedintroduced 2025-06-15 · about 1 year
Affected
10.3.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading while modelling the rescorer

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

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.

Summary card. Example: re-rank the top 3 hits of body:contents by sqrt(_score) + ln(popularity), the Expression javadoc example. Step 1: the first pass gives doc 0 = 0.2229, doc 1 = 0.2038, doc 2 = 0.1151 (doc 3 does not match). Step 2: _score is bound to DoubleValuesSource.SCORES, so the source's needsScores() is true; combine() returns the source's value. Step 3: rescore() takes the hits in doc-id order and for doc 0 line 73 calls source.getValues(ctx, null). Expected: doc 1 = 3.4472, doc 2 = 1.9486, doc 0 = 0.4721. Actual: rescore() throws on doc 0 and no hits come back: AssertionError at DoubleValuesSource.java:361 with -ea, RuntimeException 'Unrecognized variable (_score) ...' with -da. SCORES alone and the boostByValue source also throw; explain() returns normally but reads _score as 0 (0.0000, 2.9957, 1.6094); a score-free source re-ranks fine, and with the fix the expression gives 3.4472, 1.9486, 0.4721. Why: rescore() calls source.getValues(ctx, null) for every source (line 73) and never checks needsScores(); the contract allows null only when needsScores() is false, and explain() passes a no-match placeholder instead (line 112). Affected: 10.3.0 to 10.5.2 and main; LateInteractionRescorer is not affected; a wrapped function-query ValueSource that reads the score silently sees 0.
The javadoc expression 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.
  1. You index 4 documents with a text body and a numeric popularity (1, 20, 5, 7) and search body:contents: 3 hits, doc 0 = 0.2229, doc 1 = 0.2038, doc 2 = 0.1151.
  2. You re-rank them with a DoubleValuesSourceRescorer whose source is the Expression javadoc example sqrt(_score) + ln(popularity), with _score bound to DoubleValuesSource.SCORES. You expect doc 1 = 3.4472, doc 2 = 1.9486, doc 0 = 0.4721.
  3. Instead rescore() throws on the first hit, doc 0: with assertions off, RuntimeException: Unrecognized variable (_score) referenced in expression (sqrt(_score) + ln(popularity))., although _score is bound; with them on, an AssertionError. SCORES alone and a FunctionScoreQuery.boostByValue source fail with a NullPointerException.
  4. The cause: rescore() calls source.getValues(ctx, null) for every hit and never checks needsScores(). The getValues javadoc says null is safe for a source that does not need scores, and SCORES says callers must not pass it.
  5. 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), values rescore() never produces.

Who is affected

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.

Terms

  • 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 one double per 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; the scores argument carries the query scores to sources that use them.
  • needsScores(): a source returns true when its values depend on the score. DoubleValuesSource.SCORES is the source whose value is the score; an expression with _score bound to SCORES, or FunctionScoreQuery.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 a combine(firstPassScore, valuePresent, sourceValue) that turns the first-pass score and the source's value into the new score. LateInteractionRescorer is the only production subclass in Lucene.

The bug

  1. For each hit, rescore() calls DoubleValues values = source.getValues(ctx, null); (DoubleValuesSourceRescorer.java:73 at a9779ef3c193), then values.advanceExact(doc) (:74) and values.doubleValue() (:75). It never calls needsScores(). The hit's first-pass score is in hand, but it goes only to combine (:76).
  2. getValues says callers may safely pass null when needsScores() is false (DoubleValuesSource.java:63-64). SCORES says "callers must not pass null" (:352-354) and runs assert scores != null; return scores; (:361-362).
  3. So SCORES fails the assertion with -ea, and with -da returns null, which the rescorer dereferences at :74. An expression asks SCORES for its values, gets null and reports it as an unknown variable (ExpressionValueSource.java:73-80). The boostByValue source multiplies scores.doubleValue() by the boost and dereferences null (FunctionScoreQuery.java:299).
  4. 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.
  5. explain() passes Explanation.noMatch("DoubleValuesSource was not initialized with query scores") instead (DoubleValuesSourceRescorer.java:107-112). SCORES returns that placeholder as its explanation, so the rescorer reports "no value in DoubleValuesSource"; an expression evaluates _score as the placeholder's value, 0. explain() returns normally with a score rescore() never computes.

The numbers

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.

sourceexpected rescore()rescore(), assertions offexplain()
sqrt(_score) + ln(popularity)doc 1 = 3.4471903, doc 2 = 1.9486376, doc 0 = 0.47214597RuntimeException: Unrecognized variable (_score) ...0.0, 2.9957323, 1.609438
DoubleValuesSource.SCORESeach hit's own scoreNullPointerException 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:299not 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 DoubleValuesSourceRescorer and give it a source that reads the score: DoubleValuesSource.SCORES; an expression using _score (the Expression and SimpleBindings javadoc examples bind _score to SCORES); FunctionScoreQuery.boostByValue(...).getSource(); or any custom source with needsScores() 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 ValueSource that reads the score: it sees 0.
  • Workaround. Use a score-free source (for example ln(popularity)) and apply the score part in combine(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's needsScores() is false) and any source that does not need scores (field values, constants, vector similarity).

Minimal repro

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

TestDoubleValuesSourceRescorerScores.java (JUnit test)56 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.search;

import 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);
        }
      }
    }
  }
}
Command
# 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.

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

More files, environment, full output48 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

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

Why it looks unintended

  • getValues says null scores are safe when needsScores() is false, and SCORES says "callers must not pass null"; here the rescorer is the caller.
  • Nothing documents a restriction: the constructor takes any DoubleValuesSource, and explain() returns normally on the same input.
  • Every sibling path supplies scores: RescoreTopNQuery checks needsScores(), and FunctionScoreQuery, SortRescorer and ExpressionRescorer pass the hit's score.
The full argument352 words
  • The callee contract. DoubleValuesSource.java:63-64: "If scores are not needed to calculate the values (ie needsScores() returns false), callers may safely pass null for the scores parameter." SCORES, :352-354: "callers must not pass null as the DoubleValues parameter". The class javadoc (:43-46) says scores reach a source by "passing the resulting DoubleValues to getValues".
  • No documented restriction. The class javadoc ("A Rescorer that uses provided DoubleValuesSource to rescore first pass hits", DoubleValuesSourceRescorer.java:25), the constructor (:30-32), the combine javadoc (:34-41), Rescorer and 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() and rescore() disagree. Rescorer#explain: "Explains how the score for the specified document was computed." In the #14776 review the author wrote that Rescorer#explain "is meant to explain how the _Rescorer_ would score this docId for given searcher, when rescore() is called" (in reply to a question about explain() for a doc the searcher does not hold, not about scores).
  • Siblings. RescoreTopNQuery branches on valuesSource.needsScores() and passes DoubleValuesSource.fromScorer(innerScorer) (RescoreTopNQuery.java:74-79, :230-236); FunctionScoreQuery passes fromScorer (FunctionScoreQuery.java:182, :239); SortRescorer feeds each hit's first-pass score to the sort (SortRescorer.java:58, :77), which is how ExpressionRescorer gives _score a value; TestExpressionRescorer binds _score to SCORES (:100-103).
  • Tests never covered it. TestDoubleValuesSourceRescorer uses only DoubleValuesSource.fromIntField (:38), TestLateInteractionRescorer a score-free source; no test expects an exception for a score-reading source.
  • null is normal elsewhere; the missing check is the defect. A literal search for getValues(ctx, null) at a9779ef3c193 finds 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 and rescore() passes null without looking at needsScores().
  • History. The null is 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 mention needsScores() 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.

Suggested fix

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.

Pass the hit's first-pass score to sources that need scores (validated by the checks) (recommended)

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 tolerates null now gets the first-pass score (the wrapped function-query ValueSource that reads the score, which sees 0 today, or a custom source with its own null fallback). Sources with needsScores() false are unchanged in rescore().
  • explain() passes the first-pass explanation to source.explain() for every source; stock score-free sources ignore it, but a custom source whose explain() embeds it would show a different tree.
  • A design choice: with no live Scorer after the first pass, fix A makes SCORES in a rescorer mean the hit's ScoreDoc.score. The API does not promise that; maintainers may prefer B.
  • explain() then reports what rescore() computes (expression: 0.4721, 3.4472, 1.9486).
  • Cost per rescore() call: a one-element float[] (every source) and one anonymous DoubleValues when needsScores() 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 three TestRescoreTopNQuery* and TestDoubleValuesSource; all of lucene/expressions; all of org.apache.lucene.queries.function: 35 upstream classes, 316 tests, 0 failures (2 skipped).
  • ecjLint and checkGoogleJavaFormat for core, expressions and queries pass; fix.diff and fix-with-test.diff apply cleanly. Not run: the full core and queries suites. Not tested: explain() for the boost source, with a non-matching first-pass explanation, a custom explain() override, a null-tolerant source or several segments.

Reject score-reading sources with a clear IllegalArgumentException

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.

Document the restriction only

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.

Downstream note

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.

How it was found

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 passes null for every source.
  • The harness itself used only sources with needsScores() false, so it produced no signal for this bug. A probe with DoubleValuesSource.SCORES and with the expression _score * 2 then 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; JIRA archive; downstream repositories; the web2026-10-26the 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 runno 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-2617 manual apache/lucene searches (class names, SCORES, needsScores, _score, "Unrecognized variable", NPE/AssertionError, open rescorer issues and PRs), 6 downstream searches, 2 commit searchesno report; related only: #14776, #14729, #16705, #14009, #14793, #14948, luceneutil #435, k-NN #3084 and #3439
open apache/lucene PRs and upstream branches2026-10-26changed 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

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthe three repro tests fail through gradle, assertions on and off; controls pass; all pass with the fix
main ea57360912ed affectedthe 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 affectedthe 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 releasethe 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 classthe repro does not compile: DoubleValuesSourceRescorer does not exist

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-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 realOwn 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 realBuilt 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, highRe-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 keepRe-ran the finder's JUnit and the expression variant; re-checked the score arithmetic and the dedup.
main refresh still reproducesThe finder's and the independent reproduction's JUnits fail on a9779ef3c193 with the same tests and messages as on ea57360912ed.

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

Finding record

LFV-334, snapshot of 2026-11-06

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 main ea57360912ed and every release from 10.3.0 to 10.5.2; the finder's and the independent reproducer's JUnits fail with identical messages on a9779ef3c193.

Lucene and upstream sources

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-378
  • lucene/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, 1571
  • lucene/core/src/test/org/apache/lucene/search/TestDoubleValuesSourceRescorer.java:38; lucene/expressions/src/test/org/apache/lucene/expressions/TestExpressionRescorer.java:100-103

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

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.