All findings · Lucene finding · LFV-834 · nested-payloadscorequery-null-simscorer

A PayloadScoreQuery that wraps another PayloadScoreQuery sees no payloads, and with the 3-argument constructor every matching search throws

Wrap one PayloadScoreQuery in another and the outer payload function never sees a payload: with Max, documents with payloads 3 and 5 both score 1.0, with no error. With the 3-argument constructor every search that matches a document throws instead; 5.3.0 (2015) to 10.6.0 and main are affected.

Summary

Priority
Low, rank 865Nesting one PayloadScoreQuery inside another silently scores every hit 1.0 (or throws with the default constructor), but only hand-built composition creates that shape and nobody is known to do it.
Impact
silent wrong results
Since
5.3.0 (LUCENE-6706, 2015)introduced 2015-08-03 · over 11 years
Affected
5.3.0 to 10.6.0 and main
Status
documented
Found by
a bounded-exhaustive check of payload scoring against a reference model

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

The finding in 30 seconds

PayloadScoreQuery turns the payloads at a span query's matches into a score. It accepts any span query to wrap, including another PayloadScoreQuery. If you do that, the outer query never reads a payload: every hit gets the function's no-payload score (1.0 for the built-in functions), or, with the 3-argument constructor, every matching search throws.

Background in 3 steps

  1. A payload is a few bytes stored with one occurrence of a term, for example a weight: x|3 stores 3 with this x.
  2. PayloadScoreQuery(spanQuery, function, decoder, includeSpanScore) scores each match of spanQuery by combining the payloads at the matched positions with function (Max, Min, Sum, Average). With includeSpanScore=true, the default of the 3-argument constructor, it also multiplies in the usual BM25 span score.
  3. Span queries score through a SpanWeight and its SimScorer (the BM25 part). A SpanWeight built without term statistics has no SimScorer, which by its javadoc is meant for searches that need no scores.
Diagram of the bug. Two documents, field f, token x with payload 3 and 5. PayloadScoreQuery(x, Max) scores 3.0 and 5.0. PayloadScoreQuery(PayloadScoreQuery(x, Sum), Max) builds an outer PayloadSpanWeight around an inner PayloadSpanWeight; the outer scorer takes the inner weight's SimScorer, which is null, so SpanScorer stops before collecting payloads and both documents score 1.0. With the 3-argument constructor the same query throws.
The bug in one picture: an outer PayloadScoreQuery asks its inner one for a SimScorer, gets none, and SpanScorer then skips payload collection, so both documents score 1.0 instead of 3.0 and 5.0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index two documents whose field f holds one token x, with payload 3 in doc 0 and 5 in doc 1. PayloadScoreQuery(SpanTermQuery(f:x), Max, FLOAT_DECODER, false) scores them 3.0 and 5.0.
  2. Wrap a payload query once more: PayloadScoreQuery(PayloadScoreQuery(f:x, Sum, ..., false), Max, ..., false). A PayloadScoreQuery's spans are the spans of the query it wraps, so you expect 3.0 and 5.0 again.
  3. You get 1.0 and 1.0, the value Max returns when it has seen no payload, and no error. With the 3-argument constructors (span score included, includeSpanScore=true), IndexSearcher.search throws: AssertionError with assertions on, NullPointerException with them off.
  4. Why: the outer query scores with the inner weight's SimScorer, but a PayloadScoreQuery weight never has one (it passes no term statistics to SpanWeight). SpanScorer reads the missing SimScorer as "scores not needed" and stops before it collects any payload.
  5. A same-field FieldMaskingSpanQuery in between fails the same way; any real span query in between, such as SpanFirstQuery, gives the right 3.0 and 5.0. Affected: 5.3.0 to 10.6.0 and main.

Who is affected

Java code that builds a PayloadScoreQuery around a span query that already is one, directly or under a same-field FieldMaskingSpanQuery, for example generic code that adds payload scoring to any span query it is given. Deterministic with default settings. No Lucene query parser builds the shape, and the recorded public searches found no earlier report.

Mechanism, terms and numbers650 words

PayloadScoreQuery scores span matches by the payloads stored at the matched positions, and its constructors accept any SpanQuery, including another PayloadScoreQuery. When one wraps another, directly or through a same-field FieldMaskingSpanQuery, the outer weight scores with the inner weight's SimScorer, which is null because a PayloadSpanWeight passes no term statistics to SpanWeight. SpanScorer treats a null SimScorer as "scores not required" and returns before it collects payloads.

So with includeSpanScore=false every hit gets the payload function's no-payload value (1.0 for the built-in functions) instead of its payload score, silently, and with includeSpanScore=true, the 3-argument constructor's default, every search in which that query matches a document throws (AssertionError with -ea, NullPointerException with -da). The match set is unchanged. Only Java code that builds the nested shape itself is hit; no Lucene query parser builds it. 5.3.0 (2015) to 10.6.0 and main are affected (22 releases and main run; all 95 release tags in that range have the two defect lines); a one-file fix is validated.

The mechanism (main 645d4fe14a20)

  1. A payload weight exists only when scores are needed. PayloadScoreQuery.createWeight asks the wrapped query for its weight with the same score mode and, when scores are needed, wraps it in a PayloadSpanWeight (PayloadScoreQuery.java:118-125). A nested inner PayloadScoreQuery therefore builds a PayloadSpanWeight too.
  2. That weight has no SimScorer. Its constructor calls super(PayloadScoreQuery.this, searcher, null, boost) (:150). SpanWeight returns a null SimScorer for null term states (SpanWeight.java:98, :104), getSimScorer() returns that field (:160-163), and PayloadSpanWeight does not override it. The SpanWeight javadoc allows null term states only "if scores are not required" (:88-89).
  3. Its scorer borrows the inner weight's SimScorer: new PayloadSpanScorer(payloadSpans, innerWeight.getSimScorer(), norms) (PayloadScoreQuery.java:196). For a SpanTermQuery inner weight that exists; for an inner PayloadSpanWeight it is null.
  4. A null SimScorer skips the payloads. SpanScorer.setFreqCurrentDoc does if (scorer == null) { // scores not required, break out here then freq = 1; return; before spans.doCurrentSpans() (SpanScorer.java:108-113), which is where PayloadSpans collects payloads (PayloadScoreQuery.java:243-245). The function then returns its no-payload value: MaxPayloadFunction.docScore is numPayloadsSeen > 0 ? payloadScore : 1 (MaxPayloadFunction.java:42-43).
  5. With the span score it throws. PayloadSpanScorer.scoreCurrentDoc multiplies by super.scoreCurrentDoc() when includeSpanScore is true (PayloadScoreQuery.java:284-294), which asserts scorer != null : ... " has a null docScorer!" (SpanScorer.java:75) and then calls scorer.score(freq, norm) (:80).
  6. Second trigger. FieldMaskingSpanQuery.createWeight returns the masked query's weight (FieldMaskingSpanQuery.java:88-92), so a same-field mask hands the outer query the same inner PayloadSpanWeight.
  7. Why a real span query in between works. Every other wrapping span weight builds its own SimScorer with scoreMode.needsScores() ? getTermStates(...) : null (SpanPositionCheckQuery.java:70, SpanNearQuery.java:181, SpanOrQuery.java:131, SpanNotQuery.java:116, SpanContainingQuery.java:53, SpanWithinQuery.java:52, SpanPayloadCheckQuery.java:113). PayloadScoreQuery.java:196 is the only main-source line in lucene/queries that scores with another weight's SimScorer.

The executed shapes

A plain main() without the test framework (production default codec Lucene104; payloads from WhitespaceTokenizer + DelimitedPayloadTokenFilter); two documents, f = x|3 and x|5; main 645d4fe14a20, assertions on:

querydoc 0doc 1
PSQ(x, Max, false) (not nested)3.05.0
PSQ(SpanFirst(PSQ(x, Sum, false), 10), Max, false) (control)3.05.0
PSQ(PSQ(x, Sum, false), Max, false)1.01.0
PSQ(FieldMasking(PSQ(x, Sum, false), "f"), Max, false)1.01.0
PSQ(x, Max) (3-argument constructor, not nested)0.24862030.41436717
PSQ(PSQ(x, Sum), Max) and the masked formthrow

explain() agrees with the wrong score ("1.0 = MaxPayloadFunction.docScore()"). The same four mismatches on 10.6.0 and 10.5.2. 1.0 is the no-payload value of all four built-in functions; a custom count function scored 0.0.

No javadoc or test defines the exact score of a nested query with includeSpanScore=true. The expected values above are what the same queries give when a real span query (SpanFirstQuery) separates them; the unambiguous defect is that the outer function collects no payloads and that matching searches throw.

Exposure and likelihood249 words
  • What goes wrong. With includeSpanScore=false every hit of that query gets the same score (1.0 for the built-in functions): the payload ranking is lost, with no error, and explain() reports the same 1.0. With includeSpanScore=true, the 3-argument constructor's default, every search in which that query matches a document throws (AssertionError with -ea, NullPointerException with -da); a search with no matching document does not, because the null SimScorer is only used to score a hit (and scorerSupplier returns null for a segment without spans, PayloadScoreQuery.java:190-192).
  • Limits. The match set is unchanged; nothing is written to the index; no hang or resource growth was observed in any run.
  • No special setup. Default IndexWriterConfig, codec and searcher; the plain-Java main() fails with the production default codec (Lucene104) and no test framework; the JUnit tests fail under whichever codec the test framework picks (Lucene104 and Asserting(Lucene104) were recorded). Runs used one- and two-document indexes; nothing in the mechanism depends on index size.
  • Reach: rare. No Lucene query parser builds the shape: the XML parser's payload builder always wraps a SpanTermQuery (BoostingTermBuilder.java:38-41), and neither does Solr's payload_score parser. GitHub code search finds no code that nests the constructors. By reading only, a 2021 outside article describes a search-engine plugin whose builder wraps any parsed span query in a PayloadScoreQuery, so a nested request could build the shape there (not run).
  • Context. PayloadFunction is @lucene.experimental (PayloadFunction.java:27), and open issue #16820 proposes deprecating span queries.

Minimal repro

One JUnit test in lucene/queries, public API only: one document whose token x carries payload 3, the default IndexWriterConfig, a plain IndexSearcher. It searches the nested query and expects score 3.0. Deterministic; fails with assertions on and off.

TestNestedPayloadScoreQuery.java (1 test)61 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.queries.payloads;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.queries.spans.SpanTermQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.analysis.CannedTokenStream;
import org.apache.lucene.tests.analysis.Token;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestNestedPayloadScoreQuery extends LuceneTestCase {

  public void testNestedPayloadScore() throws Exception {
    Token x = new Token("x", 0, 1);
    x.setPayload(new BytesRef(new byte[] {3}));
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new TextField("f", new CannedTokenStream(x)));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        PayloadScoreQuery inner =
            new PayloadScoreQuery(
                new SpanTermQuery(new Term("f", "x")),
                new MaxPayloadFunction(),
                PayloadDecoder.FLOAT_DECODER,
                false);
        PayloadScoreQuery outer =
            new PayloadScoreQuery(
                inner, new MaxPayloadFunction(), PayloadDecoder.FLOAT_DECODER, false);
        float score = new IndexSearcher(reader).search(outer, 1).scoreDocs[0].score;
        // expected: payload 3 (the inner spans carry it); actual: 1, the no-payload score
        assertEquals("score of the nested PayloadScoreQuery", 3f, score, 0f);
      }
    }
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or a later commit with the same PayloadScoreQuery.java)
cp $REPRO/TestNestedPayloadScoreQuery.java lucene/queries/src/test/org/apache/lucene/queries/payloads/
./gradlew :lucene:queries:test --tests org.apache.lucene.queries.payloads.TestNestedPayloadScoreQuery -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false
Expected
The nested query scores the document 3.0, its payload, like the non-nested query.
Observed
score of the nested PayloadScoreQuery expected:<3.0> but was:<1.0> on main, 10.6.0 and 10.5.2, with assertions on and off.

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

More files, environment, full output241 words

The token is built with the test framework's Token and CannedTokenStream, so only the queries module is needed. It fails the same way with seeds DEADBEEF, 5EED1234, 7B1E0834 and 7B1E0835, through gradle and the standalone runner, and on the 10.6.0 and 10.5.2 release jars.

The test shows the silent form. The throwing form has the same cause and the same fix: build both queries with the 3-argument constructor and search throws (AssertionError at SpanScorer.java:75 with -ea, NullPointerException at :80 with -da).

The file fails unchanged on 9.1.0, 10.5.2 and 10.6.0 (the releases it was run on); before 9.1.0 CannedTokenStream lives in another package.

The one-byte payload is valid for PayloadDecoder.FLOAT_DECODER: despite its javadoc ("interprets the bytes of a payload as a float"), it returns the payload's first byte as a number, bytes.bytes[bytes.offset], not a 4-byte float (PayloadDecoder.java:28-29), so {3} decodes to 3.0.

Environment

lucenemain 645d4fe14a20: affected; also the 10.6.0 and 10.5.2 release jars with this test
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64

Observed output

TestNestedPayloadScoreQuery > testNestedPayloadScore FAILED
    java.lang.AssertionError: score of the nested PayloadScoreQuery expected:<3.0> but was:<1.0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:6F06E58C236B1799]: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:577)
        at org.apache.lucene.queries.payloads.TestNestedPayloadScoreQuery.testNestedPayloadScore(TestNestedPayloadScoreQuery.java:57)

# the throwing form (3-argument constructors), from the hunt's four-test class on the same commit:
-ea: java.lang.AssertionError: class org.apache.lucene.queries.payloads.PayloadScoreQuery$PayloadSpanScorer has a null docScorer!
       org.apache.lucene.queries.spans.SpanScorer.scoreCurrentDoc(SpanScorer.java:75)
       org.apache.lucene.queries.payloads.PayloadScoreQuery$PayloadSpanScorer.getSpanScore(PayloadScoreQuery.java:285)
       org.apache.lucene.queries.payloads.PayloadScoreQuery$PayloadSpanScorer.scoreCurrentDoc(PayloadScoreQuery.java:291)
       org.apache.lucene.queries.spans.SpanScorer.score(SpanScorer.java:137)
       ...
-da: java.lang.NullPointerException: Cannot invoke "org.apache.lucene.search.similarities.Similarity$SimScorer.score(float, long)" because "this.scorer" is null
       org.apache.lucene.queries.spans.SpanScorer.scoreCurrentDoc(SpanScorer.java:80)
       org.apache.lucene.queries.payloads.PayloadScoreQuery$PayloadSpanScorer.getSpanScore(PayloadScoreQuery.java:285)
       org.apache.lucene.queries.payloads.PayloadScoreQuery$PayloadSpanScorer.scoreCurrentDoc(PayloadScoreQuery.java:291)
       org.apache.lucene.queries.spans.SpanScorer.score(SpanScorer.java:137)
       ...

Why it looks unintended

  • The API accepts it: the constructors take any SpanQuery, and CHANGES for LUCENE-6706 says "use PayloadScoreQuery to wrap any SpanQuery".
  • SpanWeight allows a missing SimScorer only "if scores are not required", yet this weight is built only when they are.
  • Every other wrapping span weight builds its own SimScorer; with SpanFirstQuery in between, the nesting scores 3.0 and 5.0.
The full argument215 words
  • The API accepts it. The constructors take any non-null SpanQuery (PayloadScoreQuery.java:63-83); the class javadoc says it modifies "the score of a wrapped SpanQuery" (:43-47); the package javadoc says "For all terms matched by a SpanQuery, boost the score based on the value of the payload located at those terms" (package-info.java:24-26); CHANGES for LUCENE-6706 says "use PayloadScoreQuery to wrap any SpanQuery" and that it generalises payload scoring "to all Span queries" (CHANGES.txt:9407-9408, :10351-10352).
  • SpanWeight's contract. Null term states "May be null if scores are not required" (SpanWeight.java:88-89); PayloadSpanWeight passes null although it is built only when scores are required (PayloadScoreQuery.java:121-124, :150).
  • Siblings. Every other wrapping span weight builds its own SimScorer. The test sources' delegating AssertingSpanWeight also passes null term states but overrides getSimScorer() to return the wrapped weight's (AssertingSpanWeight.java:41, :58-60); that override came with LUCENE-6845 (2015), from the same author as PayloadScoreQuery, and PayloadSpanWeight never got one.
  • One step away it works. With SpanFirstQuery (or an all-positions SpanPositionRangeQuery) between the two, the outer function sees the payloads and the scores match the non-nested query.
  • Why tests missed it. No upstream test nests PayloadScoreQuery or wraps a FieldMaskingSpanQuery in one; neither did the introducing commit's tests.
The devil's-advocate check180 words

The strongest case for "intended": span queries score only at the top level (LUCENE-8143 removed SpanBoostQuery as "a no-op unless used at the top level of a SpanQuery nested structure", MIGRATE.md:1604-1607), so nesting a payload query is redundant; no javadoc or test says nesting must work; PayloadFunction is experimental; FieldMaskingSpanQuery warns that masking "may lead to exceptions, poor performance, and unexpected scoring behavior" (FieldMaskingSpanQuery.java:63-65); and #16820 proposes deprecating span queries.

Why it fails: the top-level rule explains the inner function being ignored, but here the top-level query loses its own function or every search that matches a document throws; a query the constructors accept should not make IndexSearcher.search throw NullPointerException; the masking warning is about a different field's statistics, while the trigger masks the same field and direct nesting masks nothing; a null SimScorer means "scores not required" in both SpanWeight and SpanScorer, and this weight exists only when scores are required. Verdict: not intended, a defect (finder, an execution-capable cross-model review, the independent reproduction, the intended-behaviour check, the adjudication, the reproducer and the tie-break).

Suggested fix

Recommended Delegate getSimScorer() and explain the span score from the same weight (+14/-2)

Give PayloadSpanWeight a getSimScorer() that returns the inner weight's, so an outer payload query gets a real SimScorer, and explain the span part from the weight that owns it. One file, +14/-2. The repro, the earlier tests of this bug and all 513 lucene/queries tests pass; non-nested scores are unchanged.

    @Override
    public SimScorer getSimScorer() {
      return innerWeight.getSimScorer();
    }

    /** Explains the span score, which comes from the innermost non-payload span weight. */
    private Explanation explainSpanScore(LeafReaderContext context, int doc) throws IOException {
      if (innerWeight instanceof PayloadSpanWeight inner) {
        return inner.explainSpanScore(context, doc);
      }
      return innerWeight.explain(context, doc);
    }
All options and trade-offs4 options · 731 words

Three fixes were run on the 645d4fe14a20 jars; the recommended one and the hunt's were also run through gradle in a scratch worktree of 645d4fe14a20. A probe for this page (four nested shapes, not the repro) checked that nested scores equal the non-nested ones and that explain() agrees with them under CheckHits.checkExplanations(..., deep=true).

Delegate getSimScorer() and explain the span score from the same weight (+14/-2) (validated by the checks) (recommended)

In PayloadScoreQuery.PayloadSpanWeight, add the override below, and in explain replace innerWeight.explain(context, doc) (PayloadScoreQuery.java:179-180) with explainSpanScore(context, doc). The override is the same idea as AssertingSpanWeight.getSimScorer(); the explain change keeps the "product of" details equal to the score for nested queries. The fix gives a nested query the scores the same queries get when a real span query separates them; the exact nested score with includeSpanScore=true is undocumented, so this is one reasonable choice for the maintainers.

    @Override
    public SimScorer getSimScorer() {
      return innerWeight.getSimScorer();
    }

    /** Explains the span score, which comes from the innermost non-payload span weight. */
    private Explanation explainSpanScore(LeafReaderContext context, int doc) throws IOException {
      if (innerWeight instanceof PayloadSpanWeight inner) {
        return inner.explainSpanScore(context, doc);
      }
      return innerWeight.explain(context, doc);
    }

Trade-offs

  • In-tree queries that work today take the same code paths, so their scores and explanations are unchanged (executed for the non-nested cross-field FieldMaskingSpanQuery form: 0.2635535/1.1898826 before and after). SpanWeight.getSimScorer() is public, so code outside Lucene that calls it on a PayloadSpanWeight would now get the inner SimScorer instead of null (not checked).
  • The inner query's payload function still contributes nothing to a nested score; the outer function now sees the inner spans' payloads. A maintainer may prefer to forbid nesting instead.
  • Open PR #15819 rewrites the scorerSupplier line (:196), which this fix leaves alone; the two apply together without a textual conflict, in either order. But #15819 takes the span score from the inner weight's new createSpanScorer(), which builds the scorer from the simScorer field, null for an inner PayloadSpanWeight, so whichever change lands second must also cover that path (read, not built). The hunt's fix rewrites :196 and does conflict with #15819.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF: the repro fails on stock and passes with the fix, assertions on and off.
  • org.apache.lucene.queries.payloads.* and org.apache.lucene.queries.spans.* with nightly tests: 267 tests, 2 skipped, 0 failures.
  • The whole :lucene:queries:test: 513 tests, 5 skipped, 0 failures.
  • :lucene:queries:check -x test (google-java-format, ecj lint, forbidden APIs) passes with the repro test in place.
  • The explain probe passes 4 of 4. Shadowed into the 645d4fe14a20 jars, the hunt's four tests, the tie-break's JUnit (25 runs) and its plain main() pass, assertions on and off.

Build PayloadSpanWeight's own SimScorer like the other span wrappers (the hunt's fix, +5/-4) (validated by the checks)

Pass getTermStates(innerWeight) to super, score with this weight's own getSimScorer(), and use super.explain(context, doc) for the span part.

Trade-offs

Matches how every other wrapping span weight builds its SimScorer, but changes the scores of an existing non-nested query: PSQ(FieldMaskingSpanQuery(SpanTermQuery(g:x), "f"), Max, true) goes from 0.2635535/1.1898826 to 0.57384163/1.5741748, because the field statistics move from g to f (which is what FieldMaskingSpanQuery's javadoc describes; the term statistics stay those of g:x). Shown for that one composition only. It fits only where that score change is wanted.

What the checks validated

  • Gradle, the same scratch worktree: the repro passes; payloads.* and spans.* with nightly tests: 267 tests, 2 skipped, 0 failures; the explain probe passes 4 of 4.
  • Earlier: the hunt's, the independent reproduction's and the tie-break's tests pass with it, and the six upstream payload suites pass 34 of 34 with nightly tests.

Not enough on its own: only the getSimScorer() override (+5)

The first five lines above. Every score and every exception is fixed, but explain() for the nested includeSpanScore=true forms then has details whose product is not the score (3 of 4 probe tests fail, for example 0.2486203 x 3.0 against a score of 0.2486203); the top-level explanation value is right.

Trade-offs

Smallest change; leaves an explanation that Lucene's own CheckHits rejects for nested queries.

Not recommended: reject a wrapped PayloadScoreQuery in the constructor

Throw IllegalArgumentException for a nested payload query. Not built.

Trade-offs

Turns the silent wrong score into a clear error, but cannot see through a FieldMaskingSpanQuery without unwrapping it, breaks code that nests today, and contradicts "wrap any SpanQuery".

Downstream note

Not a Lucene security issue; downstream, a server plugin that wraps any parsed span query in a PayloadScoreQuery could build this shape from a nested request and then return an error or flat scores for it (by reading another contributor's description, not run).

How it was found

A bounded-exhaustive check of payload scoring against a reference model, run on the stock jars. Its harness also wrapped every query shape in a second PayloadScoreQuery, expecting the same scores as the unwrapped query; 12,120,610 nested score checks returned 1.0 and 3,072 nested searches threw. An AI tool then read the root cause in the code.

The method in full151 words

Method: semi-formal (a hunt unit, payload and span queries against a reference semantics).

  • The harness built small indexes with payloads (int, float and string payloads; one and three segments, with and without deletions) and compared Lucene's scores for each query shape with a reference implementation of payload scoring.
  • As one of its checks it ran every shape also as PayloadScoreQuery(PayloadScoreQuery(shape, Sum, decoder, false), fn, decoder, incl), with the reference "same scores as PayloadScoreQuery(shape, fn, decoder, incl)", because a payload query's spans are its wrapped query's spans. So nesting was a deliberate check, not a blind discovery.
  • Out of 76,177,080 payload-score checks, 12,120,610 nested ones returned 1.0 and 3,072 nested searches threw, while the non-nested and SpanPositionRangeQuery-wrapped forms matched the reference exactly.
  • The finder, an AI tool, then read the root cause in the code and wrote a public-API JUnit; the one-test repro was cut from it on 2026-10-29.

Prior reports, history and checks

Prior reports

No prior report: two scripted prior-report search runs, 36 GitHub searches, a code search for nested constructors (0 files), the files of 59 recent PRs, 51 list queries, JIRA and the web (2026-10-31), repeating a search of 2026-10-28. Closest: open PR #15819, which edits the same line but does not fix it.

Searches4 searches

Verdict new; none of the hits is the same bug. Related, not reports of this bug: #15819 (open PR, SpanOrQuery IDF; keeps innerWeight.getSimScorer()), #12991 (same SpanScorer crash line, closed as collector misuse under COMPLETE_NO_SCORES), #9652 / LUCENE-8606 and LUCENE-9524 (SpanWeight.explain with a null SimScorer when scores are not needed), #8437 / LUCENE-7384 (span wrappers once lost their docScorer), #7764 / LUCENE-6706 and #7902 / LUCENE-6844 (introduced the class and the flag; nesting never discussed), #4797 / LUCENE-3723 (open; FieldMaskingSpanQuery statistics), #16820 (open; proposes deprecating span queries), and two 2017 solr-user threads (a non-nested query scoring 0.0; a debug toString that only looks nested). Gaps: keyword search only; GitHub search does not index JIRA attachments or every review comment; the lists API returns only the start of each mail; one web query was throttled, then retried; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and code2026-10-31two scripted prior-report search runs (16 issue/PR, 3 commit and 2 downstream queries each), 36 manual searches over apache/lucene, lucene-solr, solr, Elasticsearch, OpenSearch and global; code search for new PayloadScoreQuery(new PayloadScoreQuery and new PayloadScoreQuery(new FieldMaskingSpanQueryno report; 0 files nest the constructors
Recent PRs and issues; commit history2026-10-31the changed files of all 59 PRs and the 17 issues created or updated since 2026-10-28; the history of the four files on main, branch_10x and branch_10_6no fix; #15819 keeps the borrowed SimScorer
Lists, JIRA, web2026-10-3151 list queries (Lucene and Solr lists), 9 JQL queries (LUCENE, SOLR), 13 web queries, 2 Stack Overflow questionsno report
Earlier search (hunt follow-up)2026-10-28the same sources, plus the files of all 411 open PRsverdict new

History

Introduced with the class by LUCENE-6706 (#7764, 2015) and first released in 5.3.0, where every nested search throws; the silent 1.0 form since 5.4.0 (LUCENE-6844 added includeSpanScore). 22 releases from 5.3.0 to 10.6.0 and main fail; 5.2.1 has no such class. Not fixed upstream.

Versions tested6 rows

Introduced by LUCENE-6706 (issue #7764, "Support Payload scoring for all SpanQueries"; commit "LUCENE-6706: Add PayloadScoreQuery"; Alan Woodward, 2015-08-03; trunk 8914ec5f750, branch_5x eeae87518e3, the commit that shipped in 5.3.0; JIRA/SVN era, no PR). The class's first version passed null term states to SpanWeight and scored with the inner weight's SimScorer, and the same commit put the payload-collecting hook after SpanScorer's older "scores not required" return (LUCENE-6466). LUCENE-6844 (#7902, 2015-10-19, 5.4.0) added includeSpanScore, which made the silent form possible. Later commits (LUCENE-6845, LUCENE-6919, LUCENE-7368, LUCENE-8116, #13319, #13957) reshaped the two lines and kept the borrowed SimScorer. All 95 release tags from 5.3.0 to 10.6.0 have both lines (22 of them were run; the other 73 were checked by reading only).

Versions and branches executed, with results
Version or commitVerdictResult
5.2.1 no such classPayloadScoreQuery does not exist (compile error)
5.3.0, 5.3.1, 5.3.2 first affectedevery nested search throws NullPointerException (no includeSpanScore option yet), assertions on and off on 5.3.0
5.4.0 silent formnested 1.0 (assertions on and off); with the span score AssertionError (-ea) or NullPointerException (-da)
5.5.5 to 10.5.2 (17 releases run) affectednested 1.0 where 3.0 is expected; the non-nested control 3.0 where run
10.6.0 affectednested 1.0 and the throwing form, assertions on and off
main 645d4fe14a20 affectedfails through gradle and the standalone runner, assertions on and off; passes with either fix

Independent checks

An execution-capable review by another model family (OpenAI GPT-5.6), an independent reproducer, a devil's advocate and an adjudication rated it real; the adjudication confirmed it with high confidence. A 2026-10-31 reproducer and tie-break re-ran everything on 645d4fe14a20, including a plain-Java main() without the test framework.

Checks and verdicts267 words

Later reviews by OpenAI GPT-5.6 could only read the code (none of their attempts to run commands started) and returned a procedural "not real" while agreeing on the root cause and rating the intended-behaviour argument weak.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (a hunt unit) candidateReference-model harness, then a public-API JUnit (4 tests) that fails 3 of 4 on main and 10.5.2 (assertions on and off) and on 9.12.3 (assertions on); the control passes; passes 4 of 4 with the fix.
Cross-model review (OpenAI GPT-5.6, executing) real (high)Own two-term SpanNear test fails 2 of 2 with -ea and -da and passes with the fix; rated the intended-behaviour argument weak.
Independent reproducer realOwn JUnit (own data and decoder, outer Sum over inner Max) fails 3 of 5 on main, 10.5.2, 9.12.3 and 8.11.4; both controls pass; a probe shows the PayloadSpanWeight SimScorer is null.
Devil's advocate real (high)Rejected five defences; own JUnit fails 4 of 8 with 4 controls passing; proposed the getSimScorer() delegation.
Adjudication confirmed (high)Re-ran the independent reproduction's JUnit with a new seed: 3 of 5 fail with -ea and -da, controls pass.
Adversary reviews (OpenAI GPT-5.6, reading only) not real (procedural)Could not run code; agreed on the root cause and that the inputs are legal.
Reproducer and tie-break on 645d4fe14a20 real (high)The one-test repro fails on main, 10.6.0 and 10.5.2; own JUnit fails 15 of 25 runs with all controls passing; a plain main() fails on main, 10.6.0 and 10.5.2; a delegating fix passes everything.

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

Finding record

LFV-834, snapshot of 2026-11-06

Registry title
A PayloadScoreQuery that wraps another PayloadScoreQuery (directly or through a same-field FieldMaskingSpanQuery) ignores every payload and scores each hit 1.0 with includeSpanScore=false, and with includeSpanScore=true (the 3-argument constructor's default) fails every search with an AssertionError (-ea) or NullPointerException (-da), because the outer weight borrows the inner PayloadSpanWeight's SimScorer, which is null (since 5.3.0, LUCENE-6706)
Area
queries / payloads (PayloadScoreQuery.PayloadSpanWeight SimScorer source; SpanScorer null-SimScorer early return)
Priority
Low, rank 865: Nesting one PayloadScoreQuery inside another silently scores every hit 1.0 (or throws with the default constructor), but only hand-built composition creates that shape and nobody is known to do it.
Impact class
silent wrong results
Impact note
Applications that build a PayloadScoreQuery (lucene-queries) whose wrapped query is itself a PayloadScoreQuery, directly or through a FieldMaskingSpanQuery (whose createWeight returns the inner weight), get for that query every hit scored 1.0 with includeSpanScore=false (payloads ignored, match set unchanged, no error) and an AssertionError (-ea) or NullPointerException (-da) from IndexSearcher.search with includeSpanScore=true, the 3-argument constructor's default; only programmatic composition produces the shape (no Lucene query parser and not Solr's payload_score parser builds it); default IndexWriterConfig, codec and searcher, no scale needed; every release from 5.3.0 (throwing only) and 5.4.0 (silent form) to 10.6.0 and main.
Summary
Wrap a PayloadScoreQuery in another one, for example PayloadScoreQuery(PayloadScoreQuery(SpanTermQuery(f:x), Sum, FLOAT_DECODER, false), Max, FLOAT_DECODER, false), over two documents whose token x carries the payloads 3 and 5. You expect the outer payload function to see those payloads and score the documents 3.0 and 5.0, as PayloadScoreQuery(SpanTermQuery(f:x), Max, FLOAT_DECODER, false) does, because a PayloadScoreQuery's spans are those of the query it wraps. Instead both hits score 1.0, the function's value for 'no payloads seen', and the same query built with the 3-argument constructor (includeSpanScore=true) makes IndexSearcher.search throw an AssertionError ('PayloadSpanScorer has a null docScorer!') with -ea or a NullPointerException with -da.
Introduced
8914ec5f75079d07233e4dbb4f93df7ac5c6f403 [read] (trunk, svn r1693921; branch_5x backport eeae87518e3e48b9ffa9d082ffad574b636bb5a7, svn r1693924, the commit that shipped in 5.3.0), 'LUCENE-6706: Add PayloadScoreQuery' (now apache/lucene#7764; author Alan Woodward, GitHub romseygeek; JIRA/SVN era, no pull request): PayloadSpanWeight called super(PayloadScoreQuery.this, searcher, null), so it has no SimScorer, and its scorer took innerWeight.getSimScorer(context); the same commit put the payload-collecting doCurrentSpans() hook after SpanScorer's older 'scores not required' early return (LUCENE-6466). Later commits only touched those lines (LUCENE-7368 5def78ba101 appended the boost argument; #13957 a3a00f3f58f dropped the context argument). The silent includeSpanScore=false form arrived with LUCENE-6844 (0b305f9279a trunk, f04f42207ed branch_5x). [exec: on 5.3.0 jars, the 5.3.0 PayloadScoreQuery.java shadowed unchanged fails 2 of 3, and with the fix idea backported passes 3 of 3] (2015-08-03 (LUCENE-6706, both commits); silent form 2015-10-19 (LUCENE-6844))
First affected version
5.3.0 [exec] (first release with PayloadScoreQuery; 5.3.0-5.3.2 have no includeSpanScore option, so every nested search throws NullPointerException with -ea and -da); the silent 1.0-score form from 5.4.0 [exec]. Not a regression: 5.2.1 and earlier have no PayloadScoreQuery [exec: compile error]
Affected versions
every release from 5.3.0 to 10.6.0 and main a9779ef3c193 [exec: the one-test repro fails on the 10.6.0 jars with -ea and -da (2026-10-31); the finder's JUnit, or an API-adapted copy for 5.3.0-9.0.0, fails on 5.3.0 (-ea, -da), 5.3.2, 5.4.0 (-ea, -da), 5.5.5, 6.0.0, 6.5.1, 6.6.6, 7.1.0, 7.7.3, 8.0.0, 8.8.1, 8.11.4, 9.0.0, 9.1.0, 9.11.1, 9.12.3, 10.0.0, 10.1.0, 10.5.2 (-ea, -da) and main (-ea, -da), with the non-nested control passing; the independent reproduction's JUnit fails on 8.11.4, 9.12.3, 10.5.2 (-ea, -da) and main (-ea, -da); the intended-behaviour check's on 10.5.2 and main]; every release blob of PayloadScoreQuery.java from 5.3.0 to 10.5.2 keeps both defect lines [read]; upstream main 20ced71c1e6, branch_10x and branch_10_6 have the same lines [read], so the released 10.6.0 has it too. Not fixed upstream.
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 “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (2026-10-28, the prior-report record, verdict new: scripted prior-report searches over GitHub issues/PRs/commits, downstream projects, the dev@, java-user@, issues@ and commits@ lists and JIRA (LUCENE, SOLR); 36 manual GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch and global; the changed files of all 411 open apache/lucene PRs; the 66 PRs created, closed or updated in the last 48 h; commit history of the four files on main, branch_10x and branch_10_6; GitHub code search for nested constructors; solr-user@/users@/dev@ solr; web and Stack Overflow; plus the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches). Re-checked 2026-10-28: no issue or PR mentions nested PayloadScoreQuery, PayloadSpanWeight, PayloadSpanScorer or 'null docScorer'; the only open PR touching PayloadScoreQuery.java is #15819 (SpanOrQuery IDF, open since 2026-03-13), which keeps innerWeight.getSimScorer() and does not fix this (likely merge conflict with a fix); #16919, #16248 and #16926 change no spans or payloads file; the newest upstream commits on the four files (dfd536186fe, a3a00f3f58f, 3a63f5e0723, fa0e704d2a0) are all in a9779ef3c193. Nearest items, none of them this bug: #12991 (same SpanScorer NPE line, closed as collector misuse under COMPLETE_NO_SCORES), #10564/LUCENE-9524 and #9652/LUCENE-8606 (SpanWeight.explain with a null simScorer), #8437/LUCENE-7384 (historic null docScorer through span wrappers), #4797/LUCENE-3723 (FieldMaskingSpanQuery statistics), #7764/LUCENE-6706 and #7902/LUCENE-6844 (introducing changes), #16820 (proposed span deprecation).
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): this doc's one-method test through gradle (assertions on and off; the assertions-off stock run by the fact check) and on the standalone runner (assertions on and off), seed DEADBEEF and three other seeds; the release jars of 10.6.0 and 10.5.2 (assertions on and off); 22 releases from 5.3.0 to 10.6.0 in the study's history sweep. The four files involved have the same blobs at 645d4fe14a20, at a9779ef3c193 (where the study confirmed the bug) and on upstream main 2f424fd428da (2026-10-30). The recommended fix and the hunt's fix were run through gradle in a scratch worktree of 645d4fe14a20 on 2026-10-31.

Lucene and upstream sources

Finding record
  • lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:43-47 (at a9779ef3c193) (class javadoc: 'uses a PayloadFunction to modify the score of a wrapped SpanQuery'), :55-83 (constructors accept any non-null SpanQuery; the 3-argument one passes includeSpanScore=true), :118-125 (createWeight builds PayloadSpanWeight only when scoreMode.needsScores()), :148-152 (PayloadSpanWeight: super(PayloadScoreQuery.this, searcher, null, boost)), :170-186 (explain uses innerWeight.explain for the span part), :189-198 (scorerSupplier: new PayloadSpanScorer(payloadSpans, innerWeight.getSimScorer(), norms))
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanScorer.java:74-81 (at a9779ef3c193) (scoreCurrentDoc: assert scorer != null, then scorer.score), :108-113 ('if (scorer == null) { // scores not required, break out here' with freq = 1 before spans.doCurrentSpans())
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:83-99 (at a9779ef3c193) (termStates 'May be null if scores are not required'), :101-104 (buildSimWeight returns null for null term states)
  • lucene/queries/src/java/org/apache/lucene/queries/spans/FieldMaskingSpanQuery.java:88-92 (at a9779ef3c193) (createWeight returns maskedQuery.createWeight(...))
The finding in 30 seconds
  • lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:43-48, (at 645d4fe14a20) :63-83, :118-125, :148-152, :159-162, :189-198, :243-245, :258-265, :284-294
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanScorer.java:74-81, (at 645d4fe14a20) :108-113; lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:83-104, :160-163; lucene/queries/src/java/org/apache/lucene/queries/spans/FieldMaskingSpanQuery.java:88-92; lucene/queries/src/java/org/apache/lucene/queries/payloads/MaxPayloadFunction.java:42-43
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanPositionCheckQuery.java:70, (at 645d4fe14a20) SpanNearQuery.java:181, SpanOrQuery.java:131, SpanNotQuery.java:116, SpanContainingQuery.java:53, SpanWithinQuery.java:52; lucene/queries/src/java/org/apache/lucene/queries/payloads/SpanPayloadCheckQuery.java:113 (grep of getTermStates and getSimScorer() call sites)
Who is affected
  • lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/BoostingTermBuilder.java:38-41; (at 645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadFunction.java:27
Why it looks unintended
  • lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:43-47, (at 645d4fe14a20) :63-83, :121-124, :150; lucene/queries/src/java/org/apache/lucene/queries/payloads/package-info.java:24-26; lucene/CHANGES.txt:9407-9408, :10351-10352
  • lucene/queries/src/java/org/apache/lucene/queries/spans/SpanWeight.java:88-89; (at 645d4fe14a20) lucene/queries/src/test/org/apache/lucene/queries/spans/AssertingSpanWeight.java:41, :58-60; lucene/queries/src/java/org/apache/lucene/queries/spans/FieldMaskingSpanQuery.java:63-65; lucene/MIGRATE.md:1604-1607
Suggested fix
  • lucene/queries/src/test/org/apache/lucene/queries/spans/AssertingSpanWeight.java:58-60; (at 645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:170-186
Diagrams
  • lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:150, (at 645d4fe14a20) :196; lucene/queries/src/java/org/apache/lucene/queries/spans/SpanScorer.java:108-113

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

Repro files

About the diagrams

A picture of the two-document example and the point where the outer weight borrows a null SimScorer, made in parallel with this doc from the code at 645d4fe14a20; the PNG is the summary still.

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.