A payload of -0.0 slips through PayloadScoreQuery's negative-score clamp, and Lucene's hit collector then ranks the two zeros inconsistently.
- A payload is a few bytes stored with a token.
PayloadScoreQuerydecodes each matching token's payload to a float with aPayloadDecoderand folds them into one score with aPayloadFunction(Min, Max, Sum, Average or your own). - Java floats have two zeros, +0.0 and -0.0. Ordinary comparisons treat them as equal:
-0.0 == 0.0and-0.0 >= 0are true,-0.0 < 0is false. - Sign-aware comparisons do not:
Float.compare(-0f, 0f)is -1, and the sortable bits that Lucene's hit heap stores put -0.0 just below +0.0. - A float payload can be -0.0:
FloatEncoderstoresFloat.parseFloatof the token text, andFloat.parseFloat("-0.0")is -0.0.
<= test treats the zeros as equal while its heap sorts -0.0 lower, so top-1 is [doc 0] and top-2 is [doc 1, doc 0]. Select the still to open it at full size. Generated with AI, checked against the code.- Index two documents with one token each: doc 0 with the float payload -0.0 (the 4 bytes
FloatEncoderwrites for the text-0.0), doc 1 with 0.0. SearchPayloadScoreQuery(SpanTermQuery, MaxPayloadFunction, a decoder that reads the float, false)with a plainIndexSearcher. - You expect both to score 0, because a negative payload score maps to 0 (LUCENE-7996), and to tie in doc-id order:
search(q, 1)= [doc 0] andsearch(q, 2)= [doc 0, doc 1]. - Instead doc 0 scores -0.0. The clamp returns 0 only when
score >= 0is false, and in Java-0.0 >= 0is true. A payload of -1.0 is clamped to +0.0 as intended;explainreports -0.0 too. - So
search(q, 1)= [doc 0] butsearch(q, 2)= [doc 1 (0.0), doc 0 (-0.0)], assertions on or off. The collector'sscore <= topScoretest sees two equal zeros and keeps doc 0, while its heap sorts -0.0 below 0.0. - It takes a decoder that reads IEEE floats (the stock
FLOAT_DECODERcannot) and a payload function that keeps the -0.0: a lone -0.0 payload underMaxPayloadFunctionorMinPayloadFunction(Min also next to +0.0 or positive payloads), a custom function, or, in a corner case,AveragePayloadFunction. Only zero-scored hits move; matches, counts and positive scores are unchanged.
Applications that score with PayloadScoreQuery, a decoder that reads IEEE floats (such as PayloadHelper.decodeFloat; the stock FLOAT_DECODER cannot) and a payload of exactly -0.0 that the payload function keeps (a lone -0.0 under Max or Min, or a custom function). Only zero-scored hits move. Tests on Lucene's test framework can fail with assertions on.
Mechanism, terms and numbers678 words
LUCENE-7996 (8.0.0) forbids negative scores, and PayloadScoreQuery enforces it itself: getPayloadScore returns 0 unless score >= 0, and explain truncates only value < 0. In Java -0.0 >= 0 is true, so a payload score of exactly -0.0 is returned and explained as -0.0. It arises when the decoder reads IEEE floats, such as PayloadHelper.decodeFloat, the inverse of FloatEncoder, and the payload function keeps a -0.0: a lone -0.0 payload under MaxPayloadFunction or MinPayloadFunction (which take the first payload as is; Min also keeps it next to +0.0 or positive payloads, Max loses it to them), or a custom PayloadFunction. The stock FLOAT_DECODER reads one byte and cannot produce it, and Sum and Average turn a lone -0.0 into +0.0. Lucene's test framework rejects that score (Float.compare(score, 0f) >= 0), and since 10.2.0 the hit collector disagrees with itself about it: its competitiveness test score <= topScore treats -0.0 and +0.0 as equal while its heap orders -0.0 below +0.0. With doc 0 = -0.0 and doc 1 = +0.0, search(q, 1) returns doc 0 but search(q, 2) returns doc 1 first. Only hits whose correct score is 0 are affected. Every release from 8.0.0 to 10.6.0 has it; a two-line fix makes the clamp sign-aware.
LUCENE-7996: "Queries are now required to produce positive scores." (lucene/CHANGES.txt:6636); MIGRATE: "Scorers are no longer allowed to produce negative scores." (lucene/MIGRATE.md:1368-1372). The follow-up commit "LUCENE-7996: PayloadScoreQuery must produce positive scores." added:
getPayloadScore(PayloadScoreQuery.java:258-265):float score = function.docScore(...); if (score >= 0 == false) { return 0; } else { return score; }.getPayloadExplanation(:270): the "truncated score" branch onlyif (expl.getValue().floatValue() < 0), soexplainreports -0.0 too.- With
includeSpanScorethe score isgetSpanScore() * getPayloadScore()(:291), and a positive span score times -0.0 is still -0.0.
Each payload is decoded (:226) and folded into an accumulator that starts at 0 for every document (:218-221, :227-235). MaxPayloadFunction and MinPayloadFunction return the first payload as is and Math.max/Math.min afterwards (MaxPayloadFunction.java:34-37, MinPayloadFunction.java:31-34), so a lone -0.0 survives, and Math.min keeps -0.0 next to +0.0 and positive payloads (Min over [2.0, -0.0, 3.5] gives -0.0); Math.max(-0f, 0f) is +0.0, so under Max a -0.0 survives only if no payload is +0.0 or positive (payloads [-0.0, 0.0]: Min -0.0, Max +0.0). SumPayloadFunction adds (SumPayloadFunction.java:35) and 0 + -0.0 is +0.0, so Sum and Average lose the sign of a lone -0.0; Average divides by the payload count, so in a corner case a tiny negative sum rounds to -0.0 (payloads [-1.4E-45, 0.0]). A custom PayloadFunction can return -0.0 directly.
The decoder must read the IEEE float: PayloadHelper.decodeFloat (PayloadHelper.java:60-61), the inverse of encodeFloat, which FloatEncoder uses (FloatEncoder.java:31-32). The stock PayloadDecoder.FLOAT_DECODER returns the payload's first byte (PayloadDecoder.java:29): for -0.0's bytes 80 00 00 00 that is -128, which the clamp maps to 0.
TopScoreDocCollector rejects a hit when score <= topScore (TopScoreDocCollector.java:113), reasoning that "a document with equal score to pqTop.score cannot compete since HitQueue favors documents with lower doc Ids" (:122-124); 0.0f <= -0.0f is true. Its heap stores NumericUtils.floatToSortableInt(score) (DocScoreEncoder.java:31-33), the order of Float.compare; HitQueue, the heap in 10.2.x, uses Float.compare too (HitQueue.java:76-82).
- k = 1: doc 0 (-0.0) takes the single slot,
topScorebecomes -0.0, and doc 1 (0.0) is rejected by0.0 <= -0.0. Result [doc 0]. - k = 2: both documents get a slot, and the heap sorts doc 1 (0.0) above doc 0 (-0.0). Result [doc 1, doc 0]. No pruning is involved: the collector sets a minimum competitive score only after more than 1,000 hits (
IndexSearcher.java:100,TopScoreDocCollector.java:155), and here there are two.
Lucene's test framework asserts Float.compare(score, 0f) >= 0 (AssertingScorer.java:131), so searches through newSearcher that collect such a score throw AssertionError: -0.0 with assertions on (since 10.3.0).
Exposure and likelihood379 words
- Trigger. A payload that decodes to exactly -0.0: the token text
-0.0or-0in a delimited float payload field, a tiny negative weight such as-1e-50(whichFloat.parseFloatrounds to -0.0f), or a pipeline that writes a computed0 * negative weightwithFloat.toString(-0.0). Then a payload function that keeps it (a lone -0.0 underMaxPayloadFunctionorMinPayloadFunction; Min also next to +0.0 or positive payloads, Max not; a custom function; in a corner caseAveragePayloadFunction), withincludeSpanScoretrue or false, and a decoder that reads the IEEE float. No special setup: defaultIndexWriterConfigand a plainIndexSearcher(the tests ran with the test framework's randomized codec and directory); payload scoring itself is an explicit choice (PayloadFunctionis@lucene.experimental). - Effect. Only among hits whose correct score is 0: their score and explanation carry a minus sign, and since 10.2.0 their order depends on k (
search(q, 1)is not the head ofsearch(q, 2)). So offset paging (each page re-runs the search with a larger k and skips the earlier hits) can show the -0.0 hit on several pages while other zero-scored hits are never shown: with page size 1, payloads [-0.0], [0.0], [0.0] give doc 0 on all three pages (executed, assertions off).searchAfterpaging returned each hit once in the four small shapes tried; for theFunctionScoreQuerysibling of this bug, random multi-segment indexes also showed it skipping a -0.0 hit, which was not tried here. - Not affected: which documents match,
count(),totalHits, every positively scored hit, the index. With assertions off Lucene throws nothing. - Test suites that use
newSearchercan getAssertionError: -0.0with assertions on, since 10.3.0, in tests that collect such a score. - Downstream (read, not run): Apache Solr's
{!payload_score}parser offersfunc=min|max|average|sumand decodes float payload fields withPayloadHelper.decodeFloat, so the decoder this needs ships by default there, and a loneterm|-0.0value withfunc=maxorfunc=minappears to reach the same -0.0 score; not reproduced in Solr. - How likely: rare. It needs an exact -0.0 payload that survives the payload function (with several payloads, Max loses it to any +0.0 or positive payload), and then a ranking that depends on the order of zero-scored hits; the symptom is easy to miss.
One JUnit class on Lucene's test framework, one test method, two documents, public API only; payloads are set with CannedTokenStream. It uses analysis-common's PayloadHelper, so it runs with plain javac and java on Lucene's jars. A plain IndexSearcher makes the failure the ranking assertion.
/*
* 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 java.util.Arrays;
import org.apache.lucene.analysis.payloads.PayloadHelper;
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.search.ScoreDoc;
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 TestPayloadScoreQueryNegativeZero extends LuceneTestCase {
public void testTopOneIsHeadOfTopTwo() throws Exception {
try (Directory dir = newDirectory();
IndexWriter writer = new IndexWriter(dir, new IndexWriterConfig())) {
for (float payload : new float[] {-0f, 0f}) {
Token token = new Token("x", 0, 1);
token.setPayload(new BytesRef(PayloadHelper.encodeFloat(payload)));
Document doc = new Document();
doc.add(new TextField("f", new CannedTokenStream(token)));
writer.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(writer)) {
IndexSearcher searcher = new IndexSearcher(reader);
PayloadScoreQuery query =
new PayloadScoreQuery(
new SpanTermQuery(new Term("f", "x")),
new MaxPayloadFunction(),
bytes -> PayloadHelper.decodeFloat(bytes.bytes, bytes.offset),
false);
ScoreDoc[] top1 = searcher.search(query, 1).scoreDocs;
ScoreDoc[] top2 = searcher.search(query, 2).scoreDocs;
// expected: both docs score 0, so top-1 is the head of top-2; actual: doc 0 scores -0.0
assertEquals("top-2: " + Arrays.toString(top2), top2[0].doc, top1[0].doc);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:core:jar :lucene:codecs:jar :lucene:queries:jar :lucene:analysis:common:jar :lucene:test-framework:jar
L=$LUCENE/lucene; V=11.0.0-SNAPSHOT
CP=$L/core/build/libs/lucene-core-$V.jar:$L/codecs/build/libs/lucene-codecs-$V.jar:$L/queries/build/libs/lucene-queries-$V.jar
CP=$CP:$L/analysis/common/build/libs/lucene-analysis-common-$V.jar:$L/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:/path/to/junit-4.13.2.jar:/path/to/hamcrest-3.0.jar:/path/to/randomizedtesting-runner-2.9.2.jar
javac -d out -cp "$CP" $REPRO/TestPayloadScoreQueryNegativeZero.java
java -ea -Dtests.seed=DEADBEEF -cp "out:$CP" org.junit.runner.JUnitCore org.apache.lucene.queries.payloads.TestPayloadScoreQueryNegativeZero
# assertions off fails the same way: java -da -Dtests.asserts=false ...
- Expected
search(q, 1)returns the head ofsearch(q, 2): both documents score 0 and tie in doc-id order.- Observed
top-2: [doc=1 score=0.0 shardIndex=-1, doc=0 score=-0.0 shardIndex=-1] expected:<1> but was:<0>: top-1 is doc 0, top-2 starts with doc 1; assertions on and off.
More files, environment, full output381 words
Doc 0's token x carries PayloadHelper.encodeFloat(-0f), doc 1's encodeFloat(0f); the query is PayloadScoreQuery(SpanTermQuery(f:x), MaxPayloadFunction, bytes -> PayloadHelper.decodeFloat(bytes.bytes, bytes.offset), false). The test asserts that the head of search(q, 2) (expected) is the hit of search(q, 1) (actual); its message prints the top-2 with scores, which shows the -0.0. Deterministic, single thread; it failed with both seeds run (the randomized directory and codec are not expected to matter). Under newSearcher with assertions on it would throw AssertionError: -0.0 first. It checks the collector symptom, not the score itself: a collector-only change would make it pass while the score is still -0.0 (an extra assertion on the hits' score bits would pin the score too; not written or run).
The command needs core, codecs, queries, analysis-common and test-framework jars plus JUnit 4.13.2, Hamcrest 3.0 and randomizedtesting-runner 2.9.2 (from the Gradle cache).
Regression test: this same class. Inside lucene/queries, whose tests do not depend on analysis-common, replace PayloadHelper.encodeFloat(payload) with a 4-byte array filled by BitUtil.VH_BE_FLOAT.set(data, 0, payload) and the decoder with bytes -> (float) BitUtil.VH_BE_FLOAT.get(bytes.bytes, bytes.offset), which is what PayloadHelper does. That form fails through ./gradlew :lucene:queries:test on stock main and passes with the fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; releases 10.6.0 and 10.2.0: fail; 10.1.0: passes (doc 0 still scores -0.0 there, but the zeros tie); with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; plain Java float arithmetic) |
# plain javac + JUnitCore, stock 645d4fe14a20, -ea (trimmed: JDK warning, test-framework NOTE lines and stack frames removed; -da -Dtests.asserts=false identical)
JUnit version 4.13.2
.E
There was 1 failure:
1) testTopOneIsHeadOfTopTwo(org.apache.lucene.queries.payloads.TestPayloadScoreQueryNegativeZero)
java.lang.AssertionError: top-2: [doc=1 score=0.0 shardIndex=-1, doc=0 score=-0.0 shardIndex=-1] expected:<1> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:33EE9A3808BAB8DA]:0)
at org.apache.lucene.queries.payloads.TestPayloadScoreQueryNegativeZero.testTopOneIsHeadOfTopTwo(TestPayloadScoreQueryNegativeZero.java:59)
FAILURES!!!
Tests run: 1, Failures: 1
# standalone runner, seed DEADBEEF: main 645d4fe14a20 and 10.6.0 fail with -ea and -da, 10.2.0 fails, 10.1.0 passes
# an instrumented run on the same build (not part of the repro; 'PROBE ' prefix dropped)
Q doc 0 freq 1 position 0 payload bytes 80 00 00 00 decodeFloat -0.0 bits 0x80000000 FLOAT_DECODER -128.0
Q score doc 0 -0.0 bits 0x80000000 Float.compare(score, 0f) -1
Q search(q, 1) [doc 0 score -0.0] totalHits 2 hits
Q search(q, 2) [doc 1 score 0.0, doc 0 score -0.0] totalHits 2 hits- LUCENE-7996 forbids negative scores; this clamp's commit is titled "PayloadScoreQuery must produce positive scores", yet it maps -1.0 to 0 and not -0.0.
- The same LUCENE-7996 work is sign-aware elsewhere:
AssertingScorerassertsFloat.compare(score, 0f) >= 0;BoostQueryrejects-0f. - #16139 (10.6.0) mapped another -0.0 score to +0.0, and top-1 disagreeing with top-2 is wrong either way.
The full argument189 words
- The rule is documented:
lucene/CHANGES.txt:6636,lucene/MIGRATE.md:1368-1372("Scorers are no longer allowed to produce negative scores"). The clamp's commit187849f9b67is titled "LUCENE-7996: PayloadScoreQuery must produce positive scores." - The class breaks its own rule: a payload of -1.0 scores +0.0, -0.0 scores -0.0, and
explainhas a "truncated score" branch for negatives that -0.0 skips (PayloadScoreQuery.java:270-273). - Same day, sign-aware:
a8a63464e7d(LUCENE-7996, same author) addedAssertingScorer'sassert Float.compare(score, 0f) >= 0(AssertingScorer.java:131) andBoostQuery'sFloat.compare(boost, 0f) < 0(BoostQuery.java:40), with a test that requiresnew BoostQuery(q, -0f)to throw (TestBoostQuery.java:35-36). - Precedent: #16137 (open) reports a score from
IBSimilarity/DistributionSPLthat trips the test framework; a commenter found that it is -0.0. Its fix #16139 (merged 2026-09-10, CHANGES under 10.6.0) usesMath.max(0.0, ...), which maps -0.0 to +0.0. - The ranking is wrong under any reading: a top-1 hit that the top-2 ranks second contradicts the collector's own comment at
TopScoreDocCollector.java:122-124. - Not misuse: a custom
PayloadDecoderis the documented extension point, andencodeFloat/decodeFloatis the documented pair. No upstream payload test uses a negative payload.
The devil's-advocate check175 words
The strongest case for "intended": -0.0 is numerically zero and not < 0; the Scorable#score and PayloadScoreQuery javadocs state no sign rule; PayloadFunction is @lucene.experimental; AssertingScorable and AssertingSimilarity assert only score >= 0, which -0.0 passes; TopScoreDocCollector's javadoc names only NaN and negative infinity as invalid scores; the stock decoder cannot produce it; and the 19 comments of LUCENE-7996 (#9044) never mention signed zero.
How far it goes: for the score itself it has some weight, since MIGRATE.md says only "negative" and reading the rule as "no -0.0" is an inference. The inference is supported by the sign-aware checks the clamp's author added the same day (AssertingScorer, the BoostQuery(-0f) test), by the class clamping -1.0 but not -0.0, and by upstream treating a -0.0 score as a contract break in #16137/#16139; the trigger uses only public extension points. For the ranking it fails outright: the top-k result is inconsistent whatever -0.0 means. Verifiers and the adjudication rejected the intended-behaviour case; the cross-model review and a verifier rated it weak.
Recommended Make the clamp and the explanation sign-aware (two lines, validated)
Make the clamp sign-aware: score > 0 == false in getPayloadScore and Float.compare(value, 0f) < 0 in getPayloadExplanation (two lines). -0.0 then scores +0.0; NaN still maps to 0. It passes the repro, the payload tests and the whole queries module (513 tests).
// PayloadScoreQuery.PayloadSpanScorer.getPayloadScore()
if (score > 0 == false) { // also maps -0.0 (and NaN, negatives) to +0
// PayloadScoreQuery.PayloadSpanScorer.getPayloadExplanation()
if (Float.compare(expl.getValue().floatValue(), 0f) < 0) { // includes -0.0All options and trade-offs3 options ยท 438 words
One fix was written and validated in a scratch worktree of 645d4fe14a20; the collector-side alternative is described but not run.
The change is +2/-2 lines in PayloadScoreQuery.java. A payload score of -0.0 now takes the existing return 0 path, and explain its existing "truncated score" branch; Float.compare orders NaN above 0, so NaN keeps its own branches. With includeSpanScore, spanScore * +0.0 is +0.0. Every other value is unchanged.
// PayloadScoreQuery.PayloadSpanScorer.getPayloadScore()
if (score > 0 == false) { // also maps -0.0 (and NaN, negatives) to +0
// PayloadScoreQuery.PayloadSpanScorer.getPayloadExplanation()
if (Float.compare(expl.getValue().floatValue(), 0f) < 0) { // includes -0.0Trade-offs
- Constant time per document, as before (
explain, not on the scoring path, usesFloat.compareinstead of<); no benchmark was run. - Covers every
PayloadFunction, built-in or custom, and every decoder; not other -0.0 producers (theFunctionScoreQuery/FunctionQuerysibling, a similarity, custom scorers).
What the checks validated
Gradle in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31), with the repro's gradle form (the two PayloadHelper calls inlined) in lucene/queries. Stock: it fails with assertions on and off. With the fix:
- the test passes with assertions on and off;
- the payloads package passes: 7 classes, 35 tests, 0 failures, 2 skipped;
- the whole
:lucene:queries:testpasses: 52 classes, 513 tests, 0 failures, 5 skipped; checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestfor queries pass with the test in place.
The repro's own command (the unchanged class, plain javac and JUnitCore on jars built from the same worktree) fails on stock and passes with the fix, assertions on and off. Earlier: the finder's 3 tests, 32 upstream payload tests, a verifier's 7 tests and the same change shadowed into 8.0.0 (8/8) all pass with it.
Make TopScoreDocCollector's competitiveness test (:113) and searchAfter test (:103) use the heap's order (Float.compare or the encoded long).
Trade-offs
- Hides the top-k and paging symptom for every -0.0 producer.
- Leaves the -0.0 score, the -0.0 explanation and the test-framework failure, so the recommended fix is still needed.
- Touches the collector's hot path, and the minimum-competitive-score step cannot tell the zeros apart (
Math.nextUp(-0f)equalsMath.nextUp(0f)).
The same >= 0 idiom from LUCENE-7996 sits in FunctionScoreQuery and FunctionQuery (a sibling bug with its own four-line fix). The two fixes touch different files and can land together or separately. Open PR #15819 edits scorerSupplier in PayloadScoreQuery.java (not the clamp).
Where end users can write float payloads scored with max or min (for example Solr's {!payload_score}, read, not run), a -0.0 payload changes only the order of zero-scored hits: in a top-k its document can drop below the others, and offset paging can show it on several pages while other zero-scored hits never appear. Matches, filters and counts are unaffected.
A hunt on payload and span queries compared PayloadScoreQuery scores, bit for bit, with an independent reference model over small bounded inputs: 679,172 of 76,177,080 checks gave -0.0 where the reference gives 0. The reference maps -0.0 to +0.0 on purpose, after the test framework's sign-aware check; the site had already been flagged, unverified, through the sibling bug.
The method in full182 words
Method: non-formal first flag, then semi-formal (bounded-exhaustive translation validation, confirmed by execution).
- First flag. While verifying the
FunctionScoreQuerysibling (2026-10-26), a probe with a custom float decoder returned -0.0 fromPayloadScoreQuery; it was noted as an unverified sibling. - Signal. The payload/span hunt wrote an independent reference of payload scoring (Min, Max, Sum and Average folded over the decoded payloads, the final score clamped to a non-negative float with -0.0 mapped to +0.0) and compared it, by float bits, with the stock main jar over a bounded-exhaustive space of small indexes and queries (payloads including -0.0, an IEEE-float decoder): 679,172 of 76,177,080 score checks returned -0.0 where the reference gives +0.0; all other non-nested score checks matched. So the run confirmed a suspected site at scale rather than finding it blind.
- Confirmation. A public-API JUnit failed 3/3 on main with assertions on and off; then a cross-model review with a shell (OpenAI GPT-5.6), an independent reproducer, a second such review, a devil's-advocate verifier and an adjudication checked it, and a follow-up ran the version history, a prior-report search and a security screen.
No prior report found (304 logged queries on 2026-10-28 over GitHub, the lists, JIRA and the web; rechecked 2026-10-31). Same defect class elsewhere, a precedent: #16137/#16139 (DistributionSPL).
Searches4 searches
Verdict new: no issue, PR, JIRA entry, list thread or downstream report says that PayloadScoreQuery returns -0.0 or that its clamp lets -0.0 through; no open PR changes either line.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, two downstream search engines, Lucene.NET) | 2026-10-28 | a scripted prior-report search (three runs) plus 93 GraphQL searches ("negative zero", "signed zero", "-0f", "score >= 0 == false", "AssertionError: -0.0", class and method names); the changed files of all 411 open PRs | 26 hits read, none this bug; #16137/#16139 are the same class in DistributionSPL; #15819 touches PayloadScoreQuery.java outside the clamp |
| Mailing lists and JIRA | 2026-10-28 | dev@, java-user@, issues@, commits@, builds@, the Solr user lists; 18 JQL queries on LUCENE and SOLR | no -0.0 discussion; LUCENE-8038 has a 2018 comment that writes the decodeFloat decoder |
| Web | 2026-10-28 | 8 queries answered (14 planned phrasings throttled) | nothing relevant |
| GitHub (recheck) | 2026-10-31 | 6 issue/PR searches (the control query returns #16137 and #16139); the 31 issues and PRs created since 2026-10-28 by title; tracking issue #16937 | nothing new |
Introduced in 8.0.0 by 187849f9b67 (LUCENE-7996, 2017-12-06), unchanged since; the top-1/top-2 symptom appears in 10.2.0 with #13935 (HitQueue moved to Float.compare), isolated by shadow runs both ways.
Versions tested6 rows
Introduced by 187849f9b67 "LUCENE-7996: PayloadScoreQuery must produce positive scores." (Adrien Grand, 2017-12-06; for 8.0 only; the JIRA is now GitHub #9044), hours after a8a63464e7d made the rule and checked it sign-aware. Before 8.0 there was no clamp and negative payload scores were legal. Up to 10.1.0 score-sorted search still tied the two zeros, because HitQueue.lessThan compared scores with ==; #13935 (original-brownbear, merged 2025-01-06, first in 10.2.0) moved it to Float.compare; from 10.3.0 #14714's DocScoreEncoder heap gives the same order on its own. #14452 (10.3.0) routed the asserting searcher through AssertingScorer (read; observed 10.2.2 accepts, 10.3.0 rejects).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0 | affected | minimal repro fails, assertions on and off |
10.2.0 to 10.5.2, main a9779ef3c193 | affected | -0.0 score and top-1 not the head of top-2 (10.2.0, 10.2.2, 10.3.0, 10.5.2 run); AssertionError: -0.0 under the asserting searcher from 10.3.0 |
| 8.0.0 to 10.1.0 | score only | -0.0 score and explanation (8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.1.0 run); score-sorted search still ties the zeros (the minimal repro passes on 10.1.0) |
| 7.2.0, 7.7.3 | not affected | no clamp at all: -1.0 scores -1.0 (negative scores were legal) |
10.1.0 with 10.2.0's HitQueue; 10.2.0 with 10.1.0's | shadow | the first fails the top-k test, the second passes: #13935 is the 10.2.0 change |
| 8.0.0 with the fix shadowed | not affected | 8/8 pass |
A cross-model review with a shell (OpenAI GPT-5.6), an independent reproducer, a second GPT-5.6 review with a shell, a devil's-advocate verifier and an adjudication all found it real; the adjudication confirmed it (high confidence, impact minor). This doc's runs reproduced it on main, 10.6.0 and 10.2.0 and validated the fix through gradle.
Checks and verdicts315 words
Every executed check ran on stock builds; the cross-model reviewers wrote their own tests, and their run records are listed below.
| Check | Verdict | What it found |
|---|---|---|
| Finder (translation validation, then a JUnit) | candidate | 679,172 of 76,177,080 score checks gave -0.0 where the reference gives +0.0; the 3-test class fails 3/3 on main (assertions on and off) and 10.5.2, 2/3 on 9.12.3; the fix passes 3/3 and 32 upstream payload tests. |
| refute review (OpenAI GPT-5.6, with a shell) | real (high) | Own test (Min, own decoder) fails 1/1 and passes with the fix; showed that Sum and Average normalise -0.0, so the claim was narrowed to Min, Max and custom functions. |
| Independent reproducer | real | Own 7 tests on the stock DelimitedPayloadTokenFilter + FloatEncoder chain: 6 fail with assertions on and off; the -1.0 control passes; the asserting searcher throws AssertionError: -0.0; all pass with the fix. |
| Refute lens (OpenAI GPT-5.6, with a shell) | real (high) | Own test (p|-0.0, p|0.0, p|-1.0) fails on main, 9.12.3 and 10.5.2 and passes with the fix; intended-behaviour case weak. |
| Intended-behaviour devil's advocate | real | A characterization test: all 6 facts hold on main and 10.5.2; rebutted the IEEE-equality, misuse, unreachable, documented and accepted readings. |
| Adjudication | confirmed (high), minor | Re-ran the independent reproduction's tests with a fresh seed: 6 of 7 fail with assertions on and off; 7/7 pass with the fix. |
| Minimal repro | still reproduces | On 645d4fe14a20: fails with assertions on and off (two seeds), passes with the fix. |
| This doc | real | Standalone runner: main and 10.6.0 fail with assertions on and off, 10.2.0 fails, 10.1.0 passes. The repro's own javac/JUnitCore command: fails on stock, passes with the fix. Gradle in a scratch worktree: stock fails; with the fix the test, the payloads package and the queries module (513 tests) pass. |
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 507 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,668 words
- Registry title
- PayloadScoreQuery scores a payload value of -0.0 as -0.0 instead of 0 (MinPayloadFunction, MaxPayloadFunction or a custom PayloadFunction with a decoder that reads IEEE floats), because its LUCENE-7996 negative-score clamp tests 'score >= 0 == false' and its explain truncation 'value < 0'; since 10.2.0 the top-1 hit is then not the head of the top-2 hits
- Area
- queries / payloads (PayloadScoreQuery payload-score clamp, LUCENE-7996; visible through core TopScoreDocCollector tie handling; sibling of LFV-273's function-query clamps)
- Priority
- Low, rank 767: This is the PayloadScoreQuery version of LFV-273's -0.0 clamp bug, but it also needs a custom IEEE-float payload decoder and an exact -0.0 payload, so it is narrower.
- Impact class
- minor
- Impact note
- Applications that score with PayloadScoreQuery (lucene-queries) using a PayloadDecoder that reads IEEE floats (for example PayloadHelper.decodeFloat over FloatEncoder payloads; the stock FLOAT_DECODER reads one byte and cannot produce -0.0) and MinPayloadFunction, MaxPayloadFunction or a custom PayloadFunction get, for a document whose payload score is exactly -0.0, the score -0.0f and an explanation of -0.0 instead of 0, with includeSpanScore false and true; only hits whose correct score is 0 are affected: since 10.2.0 their order depends on k (search(q, 1) is not the head of search(q, 2)), and since 10.3.0 test suites built on Lucene's test framework fail with 'AssertionError: -0.0' under -ea; match sets, counts and every positively scored hit are unchanged; SumPayloadFunction and AveragePayloadFunction turn a lone -0.0 into +0.0; default IndexWriterConfig, codec and searcher, no scale needed, -ea and -da; every release since 8.0.0.
- Summary
- Index two documents whose float payloads are '-0.0' and '0.0' (WhitespaceTokenizer + DelimitedPayloadTokenFilter with FloatEncoder) and search PayloadScoreQuery(SpanTermQuery, MaxPayloadFunction or MinPayloadFunction, a decoder that calls PayloadHelper.decodeFloat, includeSpanScore false or true). You expect both hits to score 0 and tie in doc-id order, because PayloadScoreQuery maps every negative payload score to 0 (LUCENE-7996: 'Scorers are no longer allowed to produce negative scores'; the test framework's AssertingScorer checks Float.compare(score, 0f) >= 0), as it does for a payload of -1.0. Instead the -0.0 document scores and explains as -0.0, because the clamp 'score >= 0 == false' and the explain test 'value < 0' are both false for -0.0, and since 10.2.0 search(q, 1) returns that document while search(q, 2) ranks it second.
- Introduced
- 187849f9b67ba6b7e6c2d06cc25359bf53b2ce9f [read] ('LUCENE-7996: PayloadScoreQuery must produce positive scores.', by jpountz, 2017-12-06; master only, for 8.0, never backported to 7.x; JIRA era, now GitHub issue #9044, no pull request): it added both conditions, 'if (score >= 0 == false) return 0' in getPayloadScore and 'if (expl.getValue() < 0)' ... 'truncated score' in getPayloadExplanation, hours after the rule itself (a8a63464e7d, same author and day: CHANGES, MIGRATE, AssertingScorer's 'Float.compare(score, 0f) >= 0', BoostQuery's -0f rejection and the function-query clamps of LFV-273); git log -G lists only 187849f9b67 for the score condition and 187849f9b67 plus c1030eeb74e (LUCENE-8012, getValue() -> getValue().floatValue()) for the explain condition [read]; [exec: 7.2.0 and 7.7.3 have no clamp (-1.0 scores -1.0), 8.0.0 clamps -1.0 to +0.0 but returns -0.0 for -0.0, and the candidate fix shadowed into 8.0.0 passes 8 of 8]. The top-1/top-2 consequence came with #13935 (main d92d0451738, branch_10x b635f5c436c, by original-brownbear, merged 2025-01-06: HitQueue.lessThan moved from == to Float.compare) [exec: 10.1.0 with 10.2.0's HitQueue.java shadowed fails, 10.2.0 with 10.1.0's passes] and, independently of HitQueue from 10.3.0 on, with #14714 (main a309bd6da9b, branch_10x 57af705c68e, by gf2121: DocScoreEncoder sortable-bits heap) [exec]; the AssertingIndexSearcher failure with #14452 (846ff673301, by jpountz) [exec: 10.2.2 accepts, 10.3.0 rejects; read: diff] (2017-12-06 (187849f9b67); top-1/top-2 consequence from 2025-01-06 (#13935, first released in 10.2.0, tag 2025-04-10))
- First affected version
- 8.0.0 [exec] for the -0.0 score and explanation (release tag 2019-03-13); 10.2.0 [exec] for the top-1/top-2 inconsistency; 10.3.0 [exec] for the AssertingIndexSearcher failure seen by test suites with -ea. Not a regression from a clamp-free state: releases before 8.0.0 have no clamp and no non-negative score rule [exec: 7.2.0, 7.7.3]
- Affected versions
- every release from 8.0.0 through 10.5.2, 10.6.0 (by reading) and main a9779ef3c193 [exec: -0.0 score with MaxPayloadFunction (payload only and times the span score), MinPayloadFunction and explain() on 8.0.0 (-ea, -da), 8.11.4, 9.0.0, 9.1.0, 9.12.3 (-ea, -da), 10.0.0, 10.1.0 (-ea, -da), 10.2.0 (-ea, -da), 10.2.2, 10.3.0, 10.5.2 (-ea, -da) and main (-ea, -da), with the -1.0 clamp control passing from 8.0.0 on; top-1 not the head of top-2: 10.2.0 to 10.5.2 and main, not 8.0.0 to 10.1.0; AssertingIndexSearcher rejects with -ea: 10.3.0 and later]; the finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's tests agree on main, 10.5.2 and 9.12.3 [exec]; all 51 release tags from 8.0.0 in the history clone contain 187849f9b67 (8.8.2-8.11.4 come from the separate lucene-solr repository; 8.11.4 executed) [read]; not applicable before 8.0.0 (no clamp, no contract) [exec: 7.2.0, 7.7.3]; upstream main 20ced71c1e6e, branch_10x 657014b3c579 and branch_10_6 4ae946e5a9b9 have the same clamp lines [read, 2026-10-28], so 10.6.0 is affected by reading [not executed]; not fixed upstream; aarch64 with JDK 25 only
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-28, the prior-report record, verdict new plus, all read-only: 304 logged queries, namely the scripted prior-report search (the finder's run and two follow-up runs) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists, JIRA and the web, 93 GitHub GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and apache/lucenenet, 18 manual JQL queries on LUCENE and SOLR, 18 manual list searches including users@ and solr-user@ solr, and local full-history git; the changed-file lists of all 411 open apache/lucene PRs (only #15819 touches PayloadScoreQuery.java, in scorerSupplier, and #12194 touches AssertingScorer.java; neither changes the clamp or the check) and every apache/lucene issue and PR updated since 2026-10-25; 26 hits read and judged, none the same bug); re-checked 2026-10-28 (nothing new; the clamp lines are unchanged on upstream main 20ced71c1e6e, whose last commit to PayloadScoreQuery.java is dfd536186fe4, author date 2026-04-01, a cast cleanup); not fixed upstream (branch_10x 657014b3c579 and branch_10_6 4ae946e5a9b9 have the same lines); same defect class in other code, a precedent: #16137 (open, a -0.0 score from IBSimilarity/DistributionSPL trips the test framework) and its fix #16139 (merged 2026-09-10, Math.max(0.0, ...) maps -0.0 to +0.0); context, not reports: #9044 (LUCENE-7996, origin of the rule and this clamp; 19 comments, no signed-zero discussion), #13935 and #14714 (the top-k ordering changes behind the 10.2+ symptom), #3016 (LUCENE-1941, why Min and Max pass a lone payload through unchanged), #9086 (LUCENE-8038, a 2018 comment shows a user writing the decodeFloat decoder this trigger needs); search gaps: GitHub, list and JIRA search drop the minus sign of '-0.0' (covered by 'negative zero', 'signed zero', '-0f' and class- and method-name queries), the web tool throttled (HTTP 429) so 14 planned web phrasings were not run verbatim, one JIRA query was cut at 50 of 59 results, private lists not searched
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off through a standalone runner and through plainjavac+JUnitCoreon the built jars, and passes with the fixed queries jar; its gradle form (the twoPayloadHelpercalls inlined) fails through gradle in a scratch worktree and passes with the fix, as do the payloads package and the whole queries module. Releases 10.6.0 (fails, assertions on and off), 10.2.0 (fails) and 10.1.0 (passes) with the standalone runner. Earlier, the hunt ran its classes at 11 releases from 8.0.0 to 10.5.2 and at maina9779ef3c193. Upstream main2f424fd428da(2026-10-30) andbranch_11x603af8703753(2026-10-29) carry the same code [read].
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:258-265 (at a9779ef3c193) (getPayloadScore: 'if (score >= 0 == false) { return 0; } else { return score; }'), :267-282 (getPayloadExplanation: 'if (expl.getValue().floatValue() < 0)' -> 'truncated score, max of:'; NaN branch), :218-219 (doStartCurrentDoc: payloadScore = 0, why Sum/Average normalise a lone -0.0), :288-294 (scoreCurrentDoc: getSpanScore() * getPayloadScore() keeps the sign)lucene/queries/src/java/org/apache/lucene/queries/payloads/MaxPayloadFunction.java:34-37 (at a9779ef3c193) and MinPayloadFunction.java:31-34 (the first payload sets the value; Math.max/Math.min afterwards); lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadDecoder.java:29 (FLOAT_DECODER returns bytes.bytes[bytes.offset], so the stock decoder cannot produce -0.0)lucene/MIGRATE.md:1368-1372 (at a9779ef3c193) ('Scorers are no longer allowed to produce negative scores'); lucene/CHANGES.txt:6619 ('LUCENE-7996: Queries are now required to produce positive scores.'); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131 ('assert Float.compare(score, 0f) >= 0 : score;'); lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40 ('Float.compare(boost, 0f) < 0')lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:24-31 (at a9779ef3c193) (hits sorted by score descending, then doc id; only NaN and -Infinity named invalid), :113 ('if (score <= topScore)'); lucene/core/src/java/org/apache/lucene/search/HitQueue.java:77 (Float.compare); lucene/core/src/java/org/apache/lucene/search/DocScoreEncoder.java:32 (NumericUtils.floatToSortableInt(score))
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:218-221, :226-235, :258-265, :267-282, :288-294a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/payloads/MaxPayloadFunction.java:34-37, :43; MinPayloadFunction.java:31-34, :40; SumPayloadFunction.java:35; PayloadDecoder.java:29a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/payloads/PayloadHelper.java:24-31, :60-61; FloatEncoder.java:31-32a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:113, :122-124; DocScoreEncoder.java:22-33; HitQueue.java:76-82a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:6636; lucene/MIGRATE.md:1368-1372a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100, :599 (TOTAL_HITS_THRESHOLD = 1000); TopScoreDocCollector.java:154-156 (minimum competitive score only above the threshold)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadFunction.java:27
- Minimal repro
a Lucene main checkout (645d4fe14a20) lucene/queries/build.gradle (test dependencies: core, test-framework, expressions)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:6636, :795-796; lucene/MIGRATE.md:1368-1372a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:270-273a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131; AssertingScorable.java:36; tests/search/similarities/AssertingSimilarity.java:83a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40; lucene/core/src/test/org/apache/lucene/search/TestBoostQuery.java:35-36a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31 (javadoc: NaN and NEGATIVE_INFINITY are not valid scores)
- TestPayloadScoreQueryNegativeZero.java (one test class):
repro/LFV-835/src/org/apache/lucene/queries/payloads/TestPayloadScoreQueryNegativeZero.java(63 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an instrumented run of it (the payload bytes, the clamp test, the two scores and the k = 1 and k = 2 collection traces).
assets/LFV-835/bug.png(507 KB)
