All findings · Lucene finding · LFV-273 · function-score-clamp-passes-negative-zero

FunctionScoreQuery and FunctionQuery score a function value of -0.0 as -0.0, so the top-1 hit is not the head of the top-2

FunctionScoreQuery and FunctionQuery should score a negative function value as 0, but their check value >= 0 lets -0.0 through. Since 10.2.0 the collector then ranks the two zeros inconsistently: with values -0.0 and 0.0, search(q, 1) returns doc 0 but search(q, 2) puts doc 1 first.

Summary

Priority
Low, rank 766A -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
minor
Since
8.0.0 (LUCENE-7996, 2017); the top-1/top-2 symptom since 10.2.0introduced 2017-12-06 · about 8 years
Affected
every release 8.0.0 to 10.6.0, and main
Status
documented
Found by
non-formal: a signed-zero probe in a TopDocs.merge differential, then code reading and a JUnit

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Two zeros in Java

  1. Java floats and doubles have two zeros, +0.0 and -0.0. Ordinary comparisons treat them as equal: -0.0 == 0.0 and -0.0 >= 0 are true, -0.0 < 0 is false.
  2. 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.
  3. -0.0 comes from ordinary arithmetic and input: -1f * 0f, negating a stored 0, or Float.parseFloat("-0").
Diagram of the bug: doc 0 has the float value -0.0 and doc 1 has 0.0. FunctionScoreQuery's clamp 'factor >= 0' is true for -0.0, so doc 0 scores -0.0 instead of 0. For search(q, 1) the collector keeps doc 0 and rejects doc 1 because 0.0 <= -0.0; for search(q, 2) its heap sorts 0.0 above -0.0 and returns doc 1, then doc 0. So the top-1 hit is not the head of the top-2.
Two documents, -0.0 and 0.0: the clamp lets -0.0 through, the collector's <= 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.

The finding, step by step

  1. Index two documents: doc 0 with FloatDocValuesField("f", -0f), doc 1 with FloatDocValuesField("f", 0f). Search new FunctionScoreQuery(MatchAllDocsQuery, DoubleValuesSource.fromFloatField("f")) with a plain IndexSearcher.
  2. 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] and search(q, 2) = [doc 0, doc 1].
  3. Instead doc 0 scores -0.0. The clamp tests factor >= 0 (in FunctionQuery: val >= 0 == false), and in Java -0.0 >= 0 is true. A value of -1.0 is clamped to +0.0 as intended.
  4. So search(q, 1) = [doc 0] but search(q, 2) = [doc 1 (0.0), doc 0 (-0.0)], assertions on or off. The collector's score <= topScore test sees two equal zeros and keeps doc 0, while its heap sorts -0.0 below 0.0. Paging can repeat or skip such hits.
  5. Lucene's own test framework rejects the score (AssertionError: -0.0 under newSearcher with assertions on, since 10.3.0). Only zero-scored hits move; matches, counts and positive scores are unchanged.

Who is affected

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.

The clamps

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 NaN return 0;.
  • FunctionQuery.score (FunctionQuery.java:131-136): if (val >= 0 == false) { return 0; } else { return boost * val; }.
  • explain truncates only value < 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.

The collector

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, topScore becomes -0.0, and doc 1 (0.0) is rejected by 0.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), including boostByValue with a -0.0 factor. Default codec, IndexWriterConfig and 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, searchAfter can 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.RELEVANCE searches) and 11,590 more a different order; every lost place was lost by a -0.0 document; 2,588 of 4,080 searchAfter runs 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 newSearcher can get AssertionError: -0.0 with assertions on, since 10.3.0, in tests that collect such a score.
  • Downstream (read, not tested): in Apache Solr, edismax's boost parameter and {!boost} wrap the query in boostByValue, and {!func} builds a FunctionQuery.
  • 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.

Minimal repro

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.

TestFunctionScoreNegativeZero.java (one test class)51 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.queries.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);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionScoreNegativeZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
search(q, 1) returns the head of search(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.

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

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.

Environment

lucenemain 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
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java float arithmetic)
Command (environment)
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

Observed output

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

Why it looks unintended

  • 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: AssertingScorer asserts Float.compare(score, 0f) >= 0, and new BoostQuery(q, -0f) must throw.
  • #16139 (10.6.0) mapped another -0.0 score to +0.0. And search(q, 1) disagreeing with search(q, 2) is wrong whatever -0.0 means.
The full argument206 words
  • The rule is documented: lucene/CHANGES.txt:6636 and :6732-6733, lucene/MIGRATE.md:1368-1375 ("Scorers are no longer allowed to produce negative scores ... FunctionScoreQuery maps 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's assert Float.compare(score, 0f) >= 0 (AssertingScorer.java:131) and BoostQuery's Float.compare(boost, 0f) < 0 (BoostQuery.java:40), with a test that requires new BoostQuery(q, -0f) to throw (TestBoostQuery.java:35-36).
  • Precedent: #16137 (open) reports a score from IBSimilarity/DistributionSPL that 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) uses Math.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#testTruncateNegativeScores indexes -2 and passes; a verifier's copy with -0.0 fails at AssertingScorer.java:131 on 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.

Suggested fix

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

Make the two clamps sign-aware (four lines, validated) (recommended)

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

Trade-offs

  • Same number of comparisons per document; no benchmark was run.
  • Covers FunctionScoreQuery with any DoubleValuesSource (including boostByValue) and FunctionQuery; not other -0.0 producers (a similarity, the PayloadScoreQuery sibling, 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 (newSearcher plus QueryUtils.check, which goes through AssertingScorer and compares explain() with the score) passes 6 of 6;
  • the whole :lucene:queries:test passes: 51 classes, 512 tests, 0 failures, 5 skipped;
  • checkGoogleJavaFormat, ecjLintMain and forbiddenApisMain for 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.

One signed-zero order in the collector (alternative or addition, not run)

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) equals Math.nextUp(0f)), so with pruning active a +0.0 hit could still be skipped.

The same idiom in PayloadScoreQuery (complement, not verified)

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.

Downstream note

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.

How it was found

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.merge differential 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 >= 0 and val >= 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr, apache/solr, two downstream search engines)2026-10-26a 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 read17 hits read, none this bug; #16137/#16139 are the same class in DistributionSPL
Mailing lists and JIRA2026-10-26dev@, java-user@, issues@, commits@, builds@; LUCENE and SOLR JIRA (JQL); the 2017 dev@ heads-up thread on positive scoresno -0.0 discussion
Web2026-10-269 queries (1 throttled)nothing relevant
GitHub (recheck)2026-10-316 issue/PR searches (control query returns #16137 and #16139); the 40 newest issues and PRs by title; tracking issue #16937nothing new

History

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

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal 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 affectedno 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 shadowthe first fails the top-k test, the second passes: #13935 is the 10.2.0 change
8.0.0 with the fix shadowed not affected6/6 pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (differential signal, code reading, JUnit) candidate3-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 realOwn 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 realThe 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), minorRe-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 reproducesMain a9779ef3c193: same failures; minimal repro on 645d4fe14a20: fails with assertions on and off (two seeds), passes with the fix.
This doc realGradle 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,556 words

Finding record

LFV-273, snapshot of 2026-11-06

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 main ea57360912ed. Upstream main 2f424fd428da (2026-10-30) and branch_11x 603af8703753 (2026-10-29) carry the same code [read].

Lucene and upstream sources

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-251
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:131-136, :146
  • a 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-207
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131
  • a 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-1375
  • a Lucene main checkout (645d4fe14a20) lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:250; FunctionQuery.java:38
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131; AssertingScorable.java:36
  • a 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-36
  • a 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

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

Repro files

About the diagrams

Made in parallel with this doc from 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).

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.