- Java floats and doubles 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. - -0.0 comes from ordinary arithmetic and input:
-1f * 0f, negating a stored 0, orFloat.parseFloat("-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: doc 0 with
FloatDocValuesField("f", -0f), doc 1 withFloatDocValuesField("f", 0f). Searchnew FunctionScoreQuery(MatchAllDocsQuery, DoubleValuesSource.fromFloatField("f"))with a plainIndexSearcher. - You expect both to score 0, because a negative function value scores 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 tests
factor >= 0(inFunctionQuery:val >= 0 == false), and in Java-0.0 >= 0is true. A value of -1.0 is clamped to +0.0 as intended. - 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. Paging can repeat or skip such hits. - Lucene's own test framework rejects the score (
AssertionError: -0.0undernewSearcherwith assertions on, since 10.3.0). Only zero-scored hits move; matches, counts and positive scores are unchanged.
Applications that score with FunctionScoreQuery (including boostByValue) or FunctionQuery where a function value is exactly -0.0 and the score reaches the collector without being summed, e.g. at top level, under BoostQuery, next to MUST_NOT, or as a MUST clause where no SHOULD clause matches. Only zero-scored hits move. Tests on Lucene's test framework can fail with assertions on.
Mechanism, terms and numbers695 words
LUCENE-7996 (8.0.0) forbids negative scores and made FunctionScoreQuery and FunctionQuery score 0 when the function value is negative. Both clamps are ordinary comparisons (factor >= 0, val >= 0 == false; explain uses < 0), and in Java -0.0 >= 0 is true, so a function value of exactly -0.0 is returned as a score of -0.0f. 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, and searchAfter or offset paging can skip or repeat zero-scored hits. With Sort.RELEVANCE the order is consistent, but from 8.0.0 on a -0.0 hit sorts after the +0.0 hits it should tie with. In every executed check only hits whose correct score is 0 are affected; match sets, counts and positive-scored hits are unchanged. Every release from 8.0.0 to 10.6.0 has the -0.0 score; a four-line fix makes the clamps sign-aware.
LUCENE-7996: "Queries are now required to produce positive scores." and "FunctionQuery and FunctionScoreQuery now return a score of 0 when the function produces a negative value." (lucene/CHANGES.txt:6636, :6732-6733); MIGRATE: "FunctionScoreQuery maps negative values to 0." (lucene/MIGRATE.md:1368-1375).
FunctionScoreQuery.score(FunctionScoreQuery.java:244-251):if (factor >= 0) { return (float) (factor * boost); }, otherwise// default: missing value, negative value or NaNreturn 0;.FunctionQuery.score(FunctionQuery.java:131-136):if (val >= 0 == false) { return 0; } else { return boost * val; }.explaintruncates onlyvalue < 0(FunctionScoreQuery.java:191,FunctionQuery.java:146), so it reports -0.0 too.
A value of -0.0 passes each test, and -0.0 times a finite non-negative boost is -0.0. The sign survives whenever the function score reaches the collector without being summed: for example at top level, under BoostQuery, as the only clause of a BooleanQuery, next to MUST_NOT clauses only, as the MUST clause next to SHOULD clauses for documents that no SHOULD clause matches (ReqOptSumScorer.java:245 starts from the required score and adds the optional one only on a match, :254-256), or inside an outer FunctionScoreQuery that multiplies by the inner score (DoubleValuesSource.SCORES, boostByValue). Conjunction scorers and pure disjunctions sum into a double that starts at +0.0 (ConjunctionScorer.java:57-61, DisjunctionSumScorer.java:41-44), and 0.0 + -0.0 is +0.0, so a MUST + FILTER query or a SHOULD-only query loses the sign.
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 (a TernaryLongHeap on main) stores NumericUtils.floatToSortableInt(score) (DocScoreEncoder.java:31-33), the order of Float.compare; HitQueue, the collector's heap in 10.2.x, orders with 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].
searchAfter has the same split (score > afterScore || (score == afterScore && doc <= afterDoc), TopScoreDocCollector.java:103): with page size 2, values [-0.0, +0.0] page as [doc 1, doc 0], then [doc 1] again, and values [-0.0, +0.0, 0.5] page as [doc 2, doc 1] and never show doc 0. Paging with Sort.RELEVANCE is unaffected (RelevanceComparator uses Float.compare), but its order is not the intended one either: there a -0.0 hit sorts after every +0.0 hit in every release from 8.0.0, so on the repro's index search(q, 2, Sort.RELEVANCE) = [doc 1, doc 0] instead of [doc 0, doc 1].
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 likelihood308 words
- Trigger. A function value of exactly -0.0 that reaches the collector unchanged: a float or double field holding -0.0 (
Float.parseFloat("-0")returns it), a negated zero, or zero times a negative factor (-1f * 0f), includingboostByValuewith a -0.0 factor. Default codec,IndexWriterConfigand searcher; the function query itself is an explicit choice from the queries module. - Effect. Only among hits whose correct score is 0. With
Sort.RELEVANCE(every release from 8.0.0) a -0.0 hit consistently sorts after every +0.0 hit instead of tying with it in doc-id order. With score-sorted search (since 10.2.0) the result also depends on k:search(q, k)lists depend on k,searchAftercan skip a -0.0 hit for good while repeating +0.0 hits, and offset paging can show a -0.0 hit twice while never showing another zero-scored hit. - Scale (randomized probe, 300 small multi-segment indexes with deletions, three searcher kinds, top-level shape): 18,268 of 44,204 top-k comparisons returned a different set of hits (6,564 of them from its
Sort.RELEVANCEsearches) and 11,590 more a different order; every lost place was lost by a -0.0 document; 2,588 of 4,080searchAfterruns and 1,500 of 4,080 offset-paging runs repeated or dropped a hit. The probe's value mix (3 of 11 values are -0.0) is not a real-world frequency. - Not affected: match sets,
count(),totalHits, filters, deny rules, deleted documents, and every positive-scored hit. - Test suites that use
newSearchercan getAssertionError: -0.0with assertions on, since 10.3.0, in tests that collect such a score. - Downstream (read, not tested): in Apache Solr, edismax's
boostparameter and{!boost}wrap the query inboostByValue, and{!func}builds aFunctionQuery. - How likely: rare. It needs an exact -0.0 that survives to the collector, and then a ranking or paging 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 in one segment, public API only. A plain IndexSearcher is used so that the failure is the ranking assertion; under newSearcher with assertions on it would throw AssertionError: -0.0 first.
/*
* 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.function;
import java.util.Arrays;
import java.util.List;
import org.apache.lucene.document.FloatDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.DoubleValuesSource;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestFunctionScoreNegativeZero extends LuceneTestCase {
public void testTopOneIsHeadOfTopTwo() throws Exception {
try (Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(List.of(new FloatDocValuesField("f", -0f)));
w.addDocument(List.of(new FloatDocValuesField("f", 0f)));
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query q = new FunctionScoreQuery(new MatchAllDocsQuery(), DoubleValuesSource.fromFloatField("f"));
TopDocs top1 = searcher.search(q, 1);
TopDocs top2 = searcher.search(q, 2);
// expected: both docs score 0 (negative values clamp to 0), so top-1 is the head of top-2; actual: doc 0 scores -0.0 and the two disagree
assertEquals("top-2: " + Arrays.toString(top2.scoreDocs), top2.scoreDocs[0].doc, top1.scoreDocs[0].doc);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionScoreNegativeZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
- 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 output314 words
Doc 0 holds FloatDocValuesField("f", -0f), doc 1 FloatDocValuesField("f", 0f); the query is FunctionScoreQuery(MatchAllDocsQuery, fromFloatField("f")). 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; the class's randomized default codec is not expected to matter (FloatDocValuesField stores the raw float bits; the test failed with each of the three seeds run).
Style: new MatchAllDocsQuery() is deprecated in favour of MatchAllDocsQuery.INSTANCE, and three lines exceed 100 columns (./gradlew tidy rewraps them); neither changes the result.
Regression test: this same class; it passes with the recommended fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; release 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) |
cp $REPRO/TestFunctionScoreNegativeZero.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/function/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionScoreNegativeZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false identical)
> Task :lucene:queries:test FAILED
TestFunctionScoreNegativeZero > testTopOneIsHeadOfTopTwo FAILED
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.function.TestFunctionScoreNegativeZero.testTopOneIsHeadOfTopTwo(TestFunctionScoreNegativeZero.java:47)
# 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 score doc 1 0.0 bits 0x00000000 Float.compare(score, 0f) 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
Q k=1 collect doc 1 score 0.0 bits 0x00000000 -> heap top doc 0 score -0.0 (topScore -0.0) all [doc 0 score -0.0]- LUCENE-7996 says these queries "return a score of 0 when the function produces a negative value".
- The same commit made the rule sign-aware elsewhere:
AssertingScorerassertsFloat.compare(score, 0f) >= 0, andnew BoostQuery(q, -0f)must throw. - #16139 (10.6.0) mapped another -0.0 score to +0.0. And
search(q, 1)disagreeing withsearch(q, 2)is wrong whatever -0.0 means.
The full argument206 words
- The rule is documented:
lucene/CHANGES.txt:6636and:6732-6733,lucene/MIGRATE.md:1368-1375("Scorers are no longer allowed to produce negative scores ...FunctionScoreQuerymaps negative values to 0."). The clamp's comment: "default: missing value, negative value or NaN" (FunctionScoreQuery.java:250). - Same commit, sign-aware:
a8a63464e7d(LUCENE-7996) added the clamps,AssertingScorer'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; the issue calls it negative, and a commenter (iprithv) 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 PR body says a negative score "breaks the contract that Lucene scores must be non-negative"; the approving review says it "is generally going to round -0.0 to 0.0". - The upstream test with -0.0:
TestFunctionScoreQuery#testTruncateNegativeScoresindexes -2 and passes; a verifier's copy with -0.0 fails atAssertingScorer.java:131on main and 10.5.2 and passes with the fix. - 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.
The devil's-advocate check136 words
The strongest case for "intended": -0.0 is numerically zero and not < 0; the Scorable#score and FunctionScoreQuery javadocs state no sign rule; FunctionQuery is @lucene.experimental; AssertingScorable and the #16139 test assert only score >= 0, which -0.0 passes; and the 19 comments of LUCENE-7996 (#9044) never mention signed zero.
Why it fails: the author of the clamps wrote the rule sign-aware in the same commit (AssertingScorer, the BoostQuery(-0f) test), upstream treated a -0.0 score as a contract break in #16137/#16139, and the top-k and paging results are inconsistent whatever -0.0 means. Verifiers and the adjudication rejected the intended-behaviour case, and the cross-model reviews rated it weak. Expect the semantic question anyway: in #16137 romseygeek asked for discussion before clamping; here the clamp already exists and the fix only makes it sign-aware.
Recommended Make the two clamps sign-aware (four lines, validated)
Make both clamps sign-aware: factor > 0 in FunctionScoreQuery.score, val > 0 == false in FunctionQuery.score, and Double.compare/Float.compare(..., 0) < 0 in the two explain methods (four lines). -0.0 then scores +0.0; NaN still maps to 0. It passes the repro, the finder's tests and the whole queries module.
// FunctionScoreQuery.score()
if (factor > 0) { // also maps -0.0 to +0.0
// FunctionScoreQuery.explain()
if (Double.compare(value, 0) < 0) { // negative values and -0.0 (NaN compares greater)
// FunctionQuery.score()
if (val > 0 == false) { // covers NaN, negative values and -0.0 (returned as +0.0)
// FunctionQuery.explain()
if (Float.compare(expl.getValue().floatValue(), 0f) < 0) { // negative values and -0.0All options and trade-offs3 options · 468 words
One fix was written and validated in a scratch worktree of 645d4fe14a20; the collector-side alternative and the PayloadScoreQuery sibling are described but not run.
The change is +4/-4 lines in FunctionScoreQuery.java and FunctionQuery.java. A function value of -0.0 now takes the existing return 0 path, and explain its existing "truncated score" branch; Double.compare/Float.compare order NaN above 0, so NaN keeps its own branches. Every other value is unchanged.
// FunctionScoreQuery.score()
if (factor > 0) { // also maps -0.0 to +0.0
// FunctionScoreQuery.explain()
if (Double.compare(value, 0) < 0) { // negative values and -0.0 (NaN compares greater)
// FunctionQuery.score()
if (val > 0 == false) { // covers NaN, negative values and -0.0 (returned as +0.0)
// FunctionQuery.explain()
if (Float.compare(expl.getValue().floatValue(), 0f) < 0) { // negative values and -0.0Trade-offs
- Same number of comparisons per document; no benchmark was run.
- Covers
FunctionScoreQuerywith anyDoubleValuesSource(includingboostByValue) andFunctionQuery; not other -0.0 producers (a similarity, thePayloadScoreQuerysibling, custom scorers).
What the checks validated
Gradle in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the minimal test fails with assertions on and off, the finder's 3-test class fails 3 of 3. With the fix:
- the minimal test passes with assertions on and off;
- the finder's 3-test class (it also covers
FunctionQuery) passes 3 of 3; - a verifier's upstream-style class (
newSearcherplusQueryUtils.check, which goes throughAssertingScorerand comparesexplain()with the score) passes 6 of 6; - the whole
:lucene:queries:testpasses: 51 classes, 512 tests, 0 failures, 5 skipped; checkGoogleJavaFormat,ecjLintMainandforbiddenApisMainfor queries pass.
Earlier: 74 upstream function-query tests, the adjudication's run of a verifier's 6 tests, the same change shadowed into 8.0.0 (6/6) and the security probe (0 differences) 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), and TopDocs.merge's score comparison (TopDocs.java:114-120) likewise. Suggested by the adjudication, a verifier and the security screen.
Trade-offs
- Covers every -0.0 producer for top-k and paging consistency.
- Does not remove the -0.0 score, the -0.0 explanation or the test-framework failure.
- Touches the collector's hot path, and the minimum-competitive-score step cannot tell the zeros apart (
Math.nextUp(-0f)equalsMath.nextUp(0f)), so with pruning active a +0.0 hit could still be skipped.
PayloadScoreQuery.getPayloadScore clamps with if (score >= 0 == false) (PayloadScoreQuery.java:260, added for LUCENE-7996). A custom float PayloadDecoder returning -0.0 gives a -0.0 score there (one executed check; not verified as a separate bug). score > 0 == false would match the recommended fix.
Where end users can set a scored float field or a boost factor, a -0.0 value only demotes its own document among zero-scored hits and, with offset paging, can push another zero-scored hit off every page; matches, filters and counts are unaffected.
A hunt on PriorityQueue and TopDocs.merge ran an exhaustive differential that showed -0.0 and 0.0 ordered differently by different code. The finder then asked whether a legal query can produce -0.0, found the >= 0 clamps by reading the LUCENE-7996 code, and confirmed them with a public-API JUnit on the stock build.
The method in full112 words
Method: non-formal (code reading guided by a differential test, confirmed by execution).
- Signal. The unit's bounded-exhaustive
TopDocs.mergedifferential had a signed-zero probe: merge and the collectors order -0.0f and 0.0f differently. Since -0.0 is outside the score contract, that is not a bug by itself. - Question. Can a legal query produce -0.0? Reading the LUCENE-7996 clamps found
factor >= 0andval >= 0 == false. - Confirmation. A public-API JUnit (two documents) failed 3/3 on main with assertions on and off; then an independent reproducer, a devil's-advocate verifier, cross-model reviews (OpenAI GPT-5.6) and an adjudication checked it, and a follow-up ran the version history, a prior-report search and a security probe.
No prior report found (299 logged queries on 2026-10-26 over GitHub, the lists, JIRA and the web; rechecked 2026-10-31). Same defect class elsewhere: #16137/#16139 (DistributionSPL).
Searches4 searches
Verdict new: no issue, PR, JIRA entry, list thread or downstream report says that FunctionScoreQuery or FunctionQuery returns -0.0 or that their clamps let -0.0 through; no open PR changes either clamp.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, two downstream search engines) | 2026-10-26 | a scripted prior-report search plus 117 GraphQL searches ("negative zero", "signed zero", "-0f", "factor >= 0", "val >= 0", "score <= topScore", class names); the changed files of all 413 open PRs, the patches of those touching the clamp or collector files read | 17 hits read, none this bug; #16137/#16139 are the same class in DistributionSPL |
| Mailing lists and JIRA | 2026-10-26 | dev@, java-user@, issues@, commits@, builds@; LUCENE and SOLR JIRA (JQL); the 2017 dev@ heads-up thread on positive scores | no -0.0 discussion |
| Web | 2026-10-26 | 9 queries (1 throttled) | nothing relevant |
| GitHub (recheck) | 2026-10-31 | 6 issue/PR searches (control query returns #16137 and #16139); the 40 newest issues and PRs by title; tracking issue #16937 | nothing new |
Introduced in 8.0.0 by LUCENE-7996 (a8a63464e7d, 2017-12-06), whose clamps have not changed 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 a8a63464e7d "LUCENE-7996: Queries are now required to produce positive scores." (Adrien Grand, 2017-12-06; a JIRA patch, now GitHub #9044), which added the rule, the four clamp conditions and the sign-aware AssertingScorer/BoostQuery checks. Up to 10.1.0 score-sorted search still tied the two zeros, because HitQueue.lessThan compared scores with ==; Sort.RELEVANCE has ranked a -0.0 hit after the +0.0 hits since 8.0.0, because RelevanceComparator uses Float.compare. #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 ea57360912ed | 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; relevance sort | -0.0 score (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), but Sort.RELEVANCE ranks the -0.0 hit after the +0.0 hits (run on 9.12.3 and 10.1.0) |
| 7.7.3 and earlier | 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 | 6/6 pass |
An independent reproducer, a devil's-advocate verifier, a read-only review by OpenAI GPT-5.6 and an adjudication found it real; the only dissent was a procedural "not real" from a second read-only reviewer that could not run Java. The adjudication confirmed it (high confidence, impact minor); this doc's gradle run reproduced it and validated the fix.
Checks and verdicts329 words
Every executed check ran on stock builds; the cross-model reviewers could only read code, and separate runs re-ran the repro.
| Check | Verdict | What it found |
|---|---|---|
| Finder (differential signal, code reading, JUnit) | candidate | 3-test class fails 3/3 on main with assertions on and off, and on 10.5.2, 10.3.0, 10.2.0; 2/3 on 10.1.0, 10.0.0 and 9.12.3 (top-k test passes); the fix passes 3/3 and 74 upstream tests. |
| Refute lens (OpenAI GPT-5.6, read-only) | not real (procedural, low) | Could not run Java and it may not vote real without a run; agreed on the root cause, rated the intended case weak, and noted that FunctionQuery.explain keeps -0.0 too (now in the fix). A separate run re-ran the repro: 3/3 fail. |
| Independent reproducer | real | Own 6 tests: 5 fail with assertions on and off (fromFloatField, fromDoubleField, boostByValue with -0.0, FunctionQuery, top-k); the -1.0 control passes; the asserting searcher throws AssertionError: -0.0; top-k passes on 10.1.0, fails on 10.2.0. |
| Works-as-designed lens (OpenAI GPT-5.6, read-only) | real (medium) | Intended argument weak; a separate check fails 3 of 6 with assertions on, explain(-0) = -0.0, controls pass; found the PayloadScoreQuery sibling. |
| Intended-behaviour devil's advocate | real | The upstream testTruncateNegativeScores with -0.0 fails at AssertingScorer.java:131 on main and 10.5.2, 4 controls pass, 6/6 with the fix; traced the sign-aware checks to the clamps' commit. |
| Adjudication | confirmed (high), minor | Re-ran the independent reproduction's tests: 5 of 6 fail with assertions on and off, 6/6 pass with the fix. |
| Re-verification and minimal repro | still reproduces | Main a9779ef3c193: same failures; minimal repro on 645d4fe14a20: fails with assertions on and off (two seeds), passes with the fix. |
| This doc | real | Gradle in a scratch worktree of 645d4fe14a20: stock fails with assertions on and off; with the fix the repro, the finder's class, a verifier's upstream-style class and the whole queries module (512 tests) pass. Standalone runner: 10.6.0 and 10.2.0 fail, 10.1.0 passes. |
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 484 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,556 words
- Registry title
- FunctionScoreQuery and FunctionQuery score a function value of -0.0 as -0.0f instead of 0, because their LUCENE-7996 negative-value clamps test value >= 0; since 10.2.0 the top-1 hit can then differ from the first of the top-2 hits
- Area
- queries / function scoring (FunctionScoreQuery and FunctionQuery negative-value clamps, LUCENE-7996; visible through core TopScoreDocCollector tie handling)
- Priority
- Low, rank 766: A -0.0 function value gives k-dependent ranking and paging skips, the same tie-order problem as LFV-272, but it needs an exact -0.0 to reach the collector, so it is rarer and follows it.
- Impact class
- minor
- Impact note
- Applications that score with FunctionScoreQuery (including boostByValue) or FunctionQuery where a document's function value is exactly -0.0 (a float or double doc value of -0.0, a negated zero, zero times a negative factor) get a score of -0.0f for that hit instead of 0; 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)), searchAfter paging can skip such a hit on every page and offset paging can show it twice while never showing another zero-scored hit (follow-up security probe), and test suites built on Lucene's test framework fail with 'AssertionError: -0.0' under -ea; match sets, counts, positive-scored hits and the index are unaffected; default IndexWriterConfig, codec and searcher, two documents, -ea or -da; released since 8.0.0.
- Summary
- You index doc 0 with FloatDocValuesField("f", -0f) and doc 1 with FloatDocValuesField("f", 0f) and search new FunctionScoreQuery(new TermQuery(k:x), DoubleValuesSource.fromFloatField("f")) or new FunctionQuery(new FloatFieldSource("f")), expecting doc 0 to score 0 like any negative function value since LUCENE-7996 ('Scorers are no longer allowed to produce negative scores'; the test framework's AssertingScorer checks Float.compare(score, 0f) >= 0) and the two zero-scored hits to tie in doc-id order. Instead doc 0 scores -0.0f, because the clamps test 'factor >= 0' (FunctionScoreQuery) and 'val >= 0 == false' (FunctionQuery) and -0.0 >= 0 is true in Java; explain() does not truncate it either. Since 10.2.0, search(q, 1) returns doc 0 while search(q, 2) returns [doc 1, doc 0], because TopScoreDocCollector's 'score <= topScore' test treats the two zeros as equal while its heap and HitQueue order -0.0 below 0.0.
- Introduced
- a8a63464e7da63b3dbc884634fd0e00b3f0c140b (LUCENE-7996 'Queries are now required to produce positive scores.', by jpountz; a JIRA patch, now GitHub issue #9044, no pull request), which created the non-negative score rule and, in the same commit, the four clamp conditions 'factor >= 0' and 'value < 0' (FunctionScoreQuery score/explain) and 'val >= 0 == false' and '< 0' (FunctionQuery score/explain) [read: git log -G on each condition in the full-history clone lists only a8a63464e7d plus 838b23ebed1 (#13319, code moved, condition unchanged) and c1030eeb74e (LUCENE-8012, getValue() -> getValue().floatValue()); exec: 7.7.3 has no clamp at all (-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 6/6]; 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] (2017-12-06 (a8a63464e7d); 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 (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
- Affected versions
- every release from 8.0.0 through 10.6.0 (10.6.0 by reading) and main ea57360912ed [exec, -ea at 8.0.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.2.2, 10.3.0, 10.5.2 and main; also -da at 8.0.0, 9.12.3, 10.1.0, 10.2.0, 10.5.2 and main, with the same score and ranking results; read: all 51 release tags from 8.0.0 on in the history clone contain a8a63464e7d (8.8.2-8.11.4 come from the separate lucene-solr repository; 8.11.4 was executed)]; top-1 not the head of top-2: 10.2.0 to 10.5.2 and main [exec], not 8.0.0 to 10.1.0 [exec]; AssertingIndexSearcher rejects the score with -ea: 10.3.0 and later (10.2.2 accepts) [exec]; not applicable: releases before 8.0.0, which have no clamp and no non-negative score rule (6.5.0, 7.1.0, 7.2.0 and 7.7.3 return -0.0 for -0.0 and -1.0 for -1.0) [exec]; branch_10x (unreleased) and branch_10_6 (the branch of 10.6.0, released 2026-10-29) have the same clamp conditions (no commit has changed them since LUCENE-7996) [read], so 10.6.0 is very likely affected [not executed]; not fixed upstream [read, 2026-10-26]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-26, the prior-report record: 299 logged read-only queries, namely the scripted prior-report search (finder and follow-up runs) over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@, commits@ and builds@ lists and the LUCENE JIRA archive, 117 GitHub GraphQL searches over apache/lucene, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch and apache/solr, manual LUCENE and SOLR JIRA queries, a list-archive read, 9 web searches and local full-history git; the changed-file lists of all 413 open apache/lucene PRs, with the patches of those touching the clamp or top-k files read; 17 hits read and judged, none the same bug; the finder's and the adjudication's searches found nothing either); not fixed upstream (main HEAD ea57360912ed has both clamps unchanged; no open PR changes them: #16847 touches FunctionQuery.java and #15819 PayloadScoreQuery.java without changing a clamp); same defect class in other code: #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, CHANGES under 10.6.0); context, not reports: #9044 (LUCENE-7996, the contract and the clamps), #13935 and #14714 (the top-k ordering changes behind the 10.2+ symptom), #3347 (LUCENE-2271, -Infinity/NaN function scores); search gaps: GitHub's search rate limit was saturated (GraphQL used instead), GitHub, list and JIRA search drop the minus sign of '-0.0' (covered by 'negative zero', 'signed zero', '-0f' and class-name queries), one web query was throttled, private lists not searched
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree and through a standalone runner; with the fix it passes, as do the finder's 3-test class, a verifier's upstream-style class and the whole queries module (51 classes, 512 tests, 0 failures). 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 3-test class at 11 releases from 8.0.0 to 10.5.2 and at mainea57360912ed. 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/function/FunctionScoreQuery.java:191 (at ea57360912ed) (explain 'if (value < 0)'), :246-251 (score 'if (factor >= 0) return (float) (factor * boost);' and '// default: missing value, negative value or NaN' 'return 0;')lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:132-136 (at ea57360912ed) (score 'if (val >= 0 == false) { // this covers NaN as well ...' 'return 0;' else 'return boost * val;'), :146 (explain 'if (expl.getValue().floatValue() < 0)')lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:103 (at ea57360912ed) (searchAfter test 'score > afterScore || (score == afterScore && doc <= afterDoc)'), :113 ('if (score <= topScore)'); DocScoreEncoder.java:32 (NumericUtils.floatToSortableInt(score)); HitQueue.java:76-82 (Float.compare(hitA.score, hitB.score))lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131 (at ea57360912ed) ('assert Float.compare(score, 0f) >= 0 : score;')lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40 (at ea57360912ed) ('Float.compare(boost, 0f) < 0'); lucene/core/src/test/org/apache/lucene/search/TestBoostQuery.java:35-36 (new BoostQuery(MatchAllDocsQuery.INSTANCE, -0f) must throw 'boost must be a positive float, got -0.0')lucene/CHANGES.txt:6603 (at ea57360912ed) ('LUCENE-7996: Queries are now required to produce positive scores.'), :6699-6700 ('LUCENE-7996: FunctionQuery and FunctionScoreQuery now return a score of 0 when the function produces a negative value.'); lucene/MIGRATE.md:1368-1375 ('Scorers are no longer allowed to produce negative scores' ... 'FunctionScoreQuery maps negative values to 0')lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:260 (at ea57360912ed) ('if (score >= 0 == false)', the unverified sibling clamp site)
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:191, :244-251a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:131-136, :146a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:103, :113, :122-124; DocScoreEncoder.java:22-33; HitQueue.java:76-82; ConjunctionScorer.java:57-61; DisjunctionSumScorer.java:41-44; ReqOptSumScorer.java:245, :254-256; ReqExclScorer.java:75; BooleanQuery.java:279-288; FieldComparator.java:140-207a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:299 (boostByValue multiplies the inner score); lucene/core/src/java/org/apache/lucene/search/BoostQuery.java:40 (finite boosts only)a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:6636, :6732-6733; lucene/MIGRATE.md:1368-1375
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/FieldComparator.java:154, :161, :207 (RelevanceComparator; the same Float.compare at the 8.0.0, 9.12.3 and 10.1.0 tags in a Lucene clone)
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:6636, :6732-6733, :795-796; lucene/MIGRATE.md:1368-1375a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:250; FunctionQuery.java:38a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131; AssertingScorable.java:36a 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/queries/src/test/org/apache/lucene/queries/function/TestFunctionScoreQuery.java:236-253
- Suggested fix
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopDocs.java:114-120; lucene/queries/src/java/org/apache/lucene/queries/payloads/PayloadScoreQuery.java:260
- TestFunctionScoreNegativeZero.java (one test class):
repro/LFV-273/src/org/apache/lucene/queries/function/TestFunctionScoreNegativeZero.java(51 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an instrumented run of it (the clamp test, the two scores and the k = 1 and k = 2 collection traces).
assets/LFV-273/bug.png(484 KB)
