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.
- A payload is a few bytes stored with one occurrence of a term, for example a weight:
x|3stores 3 with thisx. PayloadScoreQuery(spanQuery, function, decoder, includeSpanScore)scores each match ofspanQueryby combining the payloads at the matched positions withfunction(Max, Min, Sum, Average). WithincludeSpanScore=true, the default of the 3-argument constructor, it also multiplies in the usual BM25 span score.- Span queries score through a
SpanWeightand itsSimScorer(the BM25 part). ASpanWeightbuilt without term statistics has no SimScorer, which by its javadoc is meant for searches that need no scores.
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.- Index two documents whose field
fholds one tokenx, 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. - Wrap a payload query once more:
PayloadScoreQuery(PayloadScoreQuery(f:x, Sum, ..., false), Max, ..., false). APayloadScoreQuery's spans are the spans of the query it wraps, so you expect 3.0 and 5.0 again. - You get 1.0 and 1.0, the value
Maxreturns when it has seen no payload, and no error. With the 3-argument constructors (span score included,includeSpanScore=true),IndexSearcher.searchthrows:AssertionErrorwith assertions on,NullPointerExceptionwith them off. - Why: the outer query scores with the inner weight's SimScorer, but a
PayloadScoreQueryweight never has one (it passes no term statistics toSpanWeight).SpanScorerreads the missing SimScorer as "scores not needed" and stops before it collects any payload. - A same-field
FieldMaskingSpanQueryin between fails the same way; any real span query in between, such asSpanFirstQuery, gives the right 3.0 and 5.0. Affected: 5.3.0 to 10.6.0 and main.
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.
- A payload weight exists only when scores are needed.
PayloadScoreQuery.createWeightasks the wrapped query for its weight with the same score mode and, when scores are needed, wraps it in aPayloadSpanWeight(PayloadScoreQuery.java:118-125). A nested innerPayloadScoreQuerytherefore builds aPayloadSpanWeighttoo. - That weight has no SimScorer. Its constructor calls
super(PayloadScoreQuery.this, searcher, null, boost)(:150).SpanWeightreturns a null SimScorer for null term states (SpanWeight.java:98,:104),getSimScorer()returns that field (:160-163), andPayloadSpanWeightdoes not override it. TheSpanWeightjavadoc allows null term states only "if scores are not required" (:88-89). - Its scorer borrows the inner weight's SimScorer:
new PayloadSpanScorer(payloadSpans, innerWeight.getSimScorer(), norms)(PayloadScoreQuery.java:196). For aSpanTermQueryinner weight that exists; for an innerPayloadSpanWeightit is null. - A null SimScorer skips the payloads.
SpanScorer.setFreqCurrentDocdoesif (scorer == null) { // scores not required, break out herethenfreq = 1; return;beforespans.doCurrentSpans()(SpanScorer.java:108-113), which is wherePayloadSpanscollects payloads (PayloadScoreQuery.java:243-245). The function then returns its no-payload value:MaxPayloadFunction.docScoreisnumPayloadsSeen > 0 ? payloadScore : 1(MaxPayloadFunction.java:42-43). - With the span score it throws.
PayloadSpanScorer.scoreCurrentDocmultiplies bysuper.scoreCurrentDoc()whenincludeSpanScoreis true (PayloadScoreQuery.java:284-294), which assertsscorer != null : ... " has a null docScorer!"(SpanScorer.java:75) and then callsscorer.score(freq, norm)(:80). - Second trigger.
FieldMaskingSpanQuery.createWeightreturns the masked query's weight (FieldMaskingSpanQuery.java:88-92), so a same-field mask hands the outer query the same innerPayloadSpanWeight. - 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:196is the only main-source line inlucene/queriesthat scores with another weight's SimScorer.
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:
| query | doc 0 | doc 1 |
|---|---|---|
PSQ(x, Max, false) (not nested) | 3.0 | 5.0 |
PSQ(SpanFirst(PSQ(x, Sum, false), 10), Max, false) (control) | 3.0 | 5.0 |
PSQ(PSQ(x, Sum, false), Max, false) | 1.0 | 1.0 |
PSQ(FieldMasking(PSQ(x, Sum, false), "f"), Max, false) | 1.0 | 1.0 |
PSQ(x, Max) (3-argument constructor, not nested) | 0.2486203 | 0.41436717 |
PSQ(PSQ(x, Sum), Max) and the masked form | throw |
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=falseevery hit of that query gets the same score (1.0 for the built-in functions): the payload ranking is lost, with no error, andexplain()reports the same 1.0. WithincludeSpanScore=true, the 3-argument constructor's default, every search in which that query matches a document throws (AssertionErrorwith-ea,NullPointerExceptionwith-da); a search with no matching document does not, because the null SimScorer is only used to score a hit (andscorerSupplierreturns 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-Javamain()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'spayload_scoreparser. 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 aPayloadScoreQuery, so a nested request could build the shape there (not run). - Context.
PayloadFunctionis@lucene.experimental(PayloadFunction.java:27), and open issue #16820 proposes deprecating span queries.
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.
/*
* 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);
}
}
}
}
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.
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.
| lucene | main 645d4fe14a20: affected; also the 10.6.0 and 10.5.2 release jars with this test |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
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)
...- The API accepts it: the constructors take any
SpanQuery, and CHANGES for LUCENE-6706 says "use PayloadScoreQuery to wrap any SpanQuery". SpanWeightallows 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
SpanFirstQueryin 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);PayloadSpanWeightpasses 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
AssertingSpanWeightalso passes null term states but overridesgetSimScorer()to return the wrapped weight's (AssertingSpanWeight.java:41,:58-60); that override came with LUCENE-6845 (2015), from the same author asPayloadScoreQuery, andPayloadSpanWeightnever got one. - One step away it works. With
SpanFirstQuery(or an all-positionsSpanPositionRangeQuery) 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
PayloadScoreQueryor wraps aFieldMaskingSpanQueryin 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).
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
FieldMaskingSpanQueryform: 0.2635535/1.1898826 before and after).SpanWeight.getSimScorer()is public, so code outside Lucene that calls it on aPayloadSpanWeightwould 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
scorerSupplierline (: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 newcreateSpanScorer(), which builds the scorer from thesimScorerfield, null for an innerPayloadSpanWeight, so whichever change lands second must also cover that path (read, not built). The hunt's fix rewrites:196and 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.*andorg.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
645d4fe14a20jars, the hunt's four tests, the tie-break's JUnit (25 runs) and its plainmain()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.*andspans.*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.
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.
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".
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).
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 asPayloadScoreQuery(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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and code | 2026-10-31 | two 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 FieldMaskingSpanQuery | no report; 0 files nest the constructors |
| Recent PRs and issues; commit history | 2026-10-31 | the 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_6 | no fix; #15819 keeps the borrowed SimScorer |
| Lists, JIRA, web | 2026-10-31 | 51 list queries (Lucene and Solr lists), 9 JQL queries (LUCENE, SOLR), 13 web queries, 2 Stack Overflow questions | no report |
| Earlier search (hunt follow-up) | 2026-10-28 | the same sources, plus the files of all 411 open PRs | verdict new |
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).
| Version or commit | Verdict | Result |
|---|---|---|
| 5.2.1 | no such class | PayloadScoreQuery does not exist (compile error) |
| 5.3.0, 5.3.1, 5.3.2 | first affected | every nested search throws NullPointerException (no includeSpanScore option yet), assertions on and off on 5.3.0 |
| 5.4.0 | silent form | nested 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) | affected | nested 1.0 where 3.0 is expected; the non-nested control 3.0 where run |
| 10.6.0 | affected | nested 1.0 and the throwing form, assertions on and off |
| main 645d4fe14a20 | affected | fails through gradle and the standalone runner, assertions on and off; passes with either fix |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (a hunt unit) | candidate | Reference-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 | real | Own 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. |
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 506 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,274 words
- 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 at645d4fe14a20, ata9779ef3c193(where the study confirmed the bug) and on upstream main2f424fd428da(2026-10-30). The recommended fix and the hunt's fix were run through gradle in a scratch worktree of645d4fe14a20on 2026-10-31.
- 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-294lucene/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-43lucene/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-10352lucene/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
- TestNestedPayloadScoreQuery.java (1 test):
repro/LFV-834/src/org/apache/lucene/queries/payloads/TestNestedPayloadScoreQuery.java(61 lines)
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.
assets/LFV-834/bug.png(506 KB)
