All findings · Lucene finding · LFV-072 · distributionll-negative-zero

IBSimilarity with DistributionLL returns -0.0f instead of 0 when the normalized term frequency is 0

With IBSimilarity + DistributionLL and a normalized term frequency of 0 (for example NormalizationH1(0)), every match scores -0.0f instead of 0.0f: LL computes -log(1.0), and the minus sign flips the zero. Lucene's score check rejects that, and the same -0.0 in DistributionSPL was fixed in 10.6.0; cosmetic, since 8.0.0.

Summary

Priority
Low, rank 1211IBSimilarity with DistributionLL can return -0.0f under degenerate parameters, which is cosmetic.
Impact
minor
Since
8.0.0 (LUCENE-7996 made negative scores illegal; the formula dates from 2011)introduced 2017-12-06 · about 8 years
Affected
8.0.0 to 10.6.0, and main
Status
documented
Found by
non-formal: code reading prompted by #16137 during an SMT signed-zero check

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

One score with real numbers: lambda = 0.109 and tfn = 0, so LL returns -log(1.0) = -0.0 instead of 0.

Background: the IB similarity and the no-negative-scores rule

  1. IBSimilarity scores a term from three parts: a distribution (DistributionLL, log-logistic, or DistributionSPL), a lambda (LambdaDF: (docFreq + 1) / (docCount + 1)) and a normalization that turns the term's count into tfn (NormalizationH1: tf x c x average length / length).
  2. Since 8.0.0 a scorer must not return a negative score. Lucene's test framework checks it with Float.compare(score, 0f) >= 0.
  3. Java floats have two zeros, +0.0 and -0.0. They are equal under ==, but Float.compare puts -0.0 below 0.0, so the check above counts -0.0 as negative.
Diagram of the bug: IBSimilarity with DistributionLL, LambdaDF and NormalizationH1(c = 0) scores one match. lambda = 11 / 101 = 0.109 and tfn = 1 x 0 x (avgfl / len) = 0, so LL computes -log(0.109 / (0 + 0.109)) = -log(1.0). log(1.0) is +0.0 and the minus sign makes it -0.0 (bits 0x80000000), which Float.compare(score, 0f) counts as negative. DistributionSPL and the fixed LL, which clamp with Math.max(0.0, ...), return +0.0.
One LL score with real numbers: lambda = 0.109 and tfn = 0 give -log(0.109 / 0.109) = -log(1.0) = -0.0, which Float.compare counts as below 0; DistributionSPL and the clamp give +0.0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Score with new IBSimilarity(new DistributionLL(), new LambdaDF(), new NormalizationH1(0f)): a field with 100 documents, a term in 10 of them, freq 1. c = 0 is a legal value, and Lucene's own similarity tests use it.
  2. LL's score is -log(lambda / (tfn + lambda)). Here lambda = 11 / 101 = 0.109 and tfn = 1 x 0 x (average length / length) = 0, so the score is -log(0.109 / 0.109) = -log(1.0). You expect 0.0, which DistributionSPL returns for the same input.
  3. Instead you get -0.0f (bits 0x80000000): log(1.0) is +0.0 and the minus sign makes it -0.0. A real search shows ScoreDoc.score = -0.0.
  4. Lucene's AssertingScorer rejects it as negative (Float.compare(score, 0f) >= 0): in the TestSynonymQuery boosts scenario of #16137 with the same c = 0, 80 of 80 searches fail (SPL: 0 of 80). Since 10.2.0 a -0.0 hit also sorts after a +0.0 hit with a higher doc id: [1, 0] instead of [0, 1].

Who is affected

Only users of the experimental IBSimilarity + DistributionLL. Default parameters with whole-number frequencies never hit it (0 of 199,665 sweep points per configuration). It needs c or mu = 0 (every score 0), c or mu near 1e-9, a SynonymQuery boost near 1e-20, or a freq near 2^-31. Effects: -0.0 scores, test-framework assertion failures, zero-score tie order.

Mechanism, terms and numbers568 words

DistributionLL.score returns -Math.log(lambda / (tfn + lambda)). When tfn is 0, or so small that tfn + lambda rounds to lambda, the quotient is exactly 1.0, Math.log(1.0) is +0.0 and the unary minus makes it -0.0. NormalizationH1(0) is legal and gives tfn = 0, so IBSimilarity(DistributionLL, LambdaDF, NormalizationH1(0)) scores every match -0.0f. Since 8.0.0 (LUCENE-7996) scorers must not return negative scores, and the test framework's AssertingScorer checks that with Float.compare, which rejects -0.0f. Upstream fixed DistributionSPL's negative scores, which iprithv traced on #16137 to the same -0.0 from -Math.log(1.0), with Math.max(0.0, ...) in 10.6.0 (#16139) but left LL as it is; the same one-line clamp fixes it. Effects are cosmetic: -0.0 in scores and explanations, test-framework assertion failures, and since 10.2.0 zero-score hits out of doc-id order. Default parameters with whole-number frequencies never reach it (a sweep found it at 0 of 199,665 such points per default configuration).

Mechanism (Lucene main 645d4fe14a20)

  • DistributionLL.java:33-35: return -Math.log(lambda / (tfn + lambda));. For finite tfn >= 0 and finite lambda > 0, which is what IBSimilarity passes, the quotient is at most 1, so -0.0 is the only out-of-range value; it appears when tfn == 0 or tfn / lambda is below about 2^-53. (The method does not check its arguments; NaN or a negative tfn are outside this claim.)
  • IBSimilarity.java:115-117 multiplies by the positive query boost, and SimilarityBase.java:185-186 casts to float: -0.0 reaches the scorer as -0.0f.
  • tfn is 0 when c = 0 (NormalizationH1.java:57, also H2) or mu = 0; it is tiny when c or mu is tiny, or, depending on the normalization, when freq or a SynonymQuery term boost is tiny (in the study's sweep: with default H1 and H2, and Z with LambdaTTF, but never with default H3).
  • Composite scorers that sum from +0.0 turn -0.0 back into +0.0, so the value reaches the collector from single-term-like scorers (TermQuery, SynonymQuery, PhraseQuery) or pass-through ones (a BooleanQuery whose optional clause does not match, BoostQuery).
  • The same -0.0 in DistributionSPL was traced to -Math.log(1.0) by iprithv on #16137; #16139 clamps SPL (DistributionSPL.java:61-62). LL has no clamp.

Tie order (10.2.0 and later)

DocScoreEncoder.java:32 orders hits with NumericUtils.floatToSortableInt, which puts -0.0f below +0.0f, while TopScoreDocCollector documents ties as "docID ascending" (:24-26) and TopDocs.merge compares with < / > (TopDocs.java:114-116), which treat the two zeros as equal. Example: three documents (f:a; f:a + g:x; f:z) and MUST f:a + SHOULD BoostQuery(ConstantScoreQuery(g:x), 0): doc 0 scores -0.0, doc 1 scores -0.0 + 0.0 = +0.0, and the hits come back [1, 0] instead of [0, 1] (10.1.0 gives [0, 1]). With one document per segment the order is [1, 0] with 1 slice and [0, 1] with 3 slices. #13935 (10.2.0) made HitQueue break ties with Float.compare (it changed only HitQueue.java and TopDocsCollector.java); #14714 (10.3.0) replaced that queue with DocScoreEncoder, which keeps the same order. Neither is claimed as a separate bug.

Why Lucene's tests miss it

BaseSimilarityTestCase.java:389, AssertingSimilarity.java:83, 116 and AssertingScorable.java:36 check score >= 0, which accepts -0.0. So TestDistributionLL, whose DistributionTestCase picks c = 0 as the "minimum value" in one of its four random choices, computes -0.0 in those runs and still passes. A plain top-k TermQuery search through newSearcher() also passes, because the bulk-scorer path goes through AssertingScorable, not AssertingScorer.

Exposure and likelihood254 words
  • Who. Users of IBSimilarity with DistributionLL (public constructors, @lucene.experimental; the default similarity is BM25).
  • When. The study's sweep of the default configurations (H1/H2 c = 1, H3 mu = 800, Z z = 0.3) found it at 0 of the 199,665 integer-freq points (freq 1, 2 and 1000) per configuration; the same sweep, 332,775 points per configuration over five freqs, does find -0.0f at freq 2^-31 and 1e-12 with the H1 and H2 defaults and with Z + LambdaTTF. Reachable with c = 0 (H1, H2) or mu = 0, where every document scores 0; with c or mu of about 1e-9; or, depending on the normalization (default H1 or H2, not H3), with a SynonymQuery term boost of about 1e-20 (the allowed range is (0, 1]) or a sloppy freq of about 2^-31.
  • What they see. -0.0 in ScoreDoc.score and explanations; AssertionError: -0.0 from Lucene's test framework in their own tests run with -ea when the score goes through the Scorer path; and since 10.2.0 zero-score hits out of doc-id order, with the order depending on how segments are sliced.
  • Not affected. Matching, hit counts and totals, and ranking among non-zero scores; the score is -0.0f with assertions on and off (only AssertingScorer's check is a Java assert that needs -ea). Lucene's CI computes the value but does not catch it: TestDistributionLL can draw c = 0 or mu = 0 and then scores -0.0, but its score >= 0 checks accept that, and RandomSimilarity uses default c and mu.

Minimal repro

One JUnit test, no index: it builds the scorer from fixed statistics and checks that the score of one match is +0.0f, comparing Float objects so the sign of zero counts. Deterministic, single thread; fails with assertions on and off.

TestDistributionLLNegativeZero.java (one test class)36 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.search.similarities;

import org.apache.lucene.search.FieldStats;
import org.apache.lucene.search.TermStats;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.util.BytesRef;

public class TestDistributionLLNegativeZero extends LuceneTestCase {

  public void testZeroScoreIsPositiveZero() {
    Similarity.SimScorer scorer =
        new IBSimilarity(new DistributionLL(), new LambdaDF(), new NormalizationH1(0f))
            .scorer(
                1f,
                new FieldStats("f", 100, 100, 1000, 500),
                new TermStats(new BytesRef("t"), 10, 20));
    // c = 0 gives tfn = 0, so LL computes -log(1): expected +0.0 (as DistributionSPL), actual -0.0
    assertEquals(Float.valueOf(0f), Float.valueOf(scorer.score(1f, 1L)));
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.similarities.TestDistributionLLNegativeZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
+0.0f, the value DistributionSPL returns for the same input.
Observed
java.lang.AssertionError: expected:<0.0> but was:<-0.0> with assertions on and off; with the fix the test passes.

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

More files, environment, full output266 words

One test class, one method, nothing random (the seed only satisfies LuceneTestCase). Public API only (IBSimilarity, DistributionLL, LambdaDF, NormalizationH1, Similarity.scorer, SimScorer.score, the FieldStats / TermStats records); it sits in the similarities package like Lucene's other similarity tests but uses nothing package-private. With 100 documents and docFreq 10, lambda = 11 / 101; c = 0 makes tfn = 0.

On 9.x and 10.x the records are called CollectionStatistics / TermStatistics (renamed on main by #15929); the release runs used a copy with only that rename.

Regression test: this same test. It fails on stock main and passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 9.12.3: fail; with the fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run)
Command (environment)
cp $REPRO/TestDistributionLLNegativeZero.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/similarities/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.similarities.TestDistributionLLNegativeZero -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true
TestDistributionLLNegativeZero > testZeroScoreIsPositiveZero FAILED
    java.lang.AssertionError: expected:<0.0> but was:<-0.0>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:B791AEA760E3E4E]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.search.similarities.TestDistributionLLNegativeZero.testZeroScoreIsPositiveZero(TestDistributionLLNegativeZero.java:34)
BUILD FAILED

# -Ptests.asserts=false: the same AssertionError; 10.6.0, 10.5.2 (-ea and -da) and 9.12.3: the same
# with the clamp: 1 test, 0 failures, both ways

# end-to-end probes on main (plain IndexSearcher, lucene-core only)
top score = -0.0 (raw bits 0x80000000)                 # one doc "lucene", TermQuery f:lucene
doc 1 score 0.0 (raw bits 0x0)                          # MUST f:a + SHOULD BoostQuery(ConstantScore(g:x), 0)
doc 0 score -0.0 (raw bits 0x80000000)
order = [1, 0], scores equal by == : true               # 10.1.0: order = [0, 1]

Why it looks unintended

  • Since 8.0.0 scorers must not return negative scores; AssertingScorer enforces that with Float.compare(score, 0f) >= 0, which rejects -0.0f.
  • Upstream fixed DistributionSPL's negative scores, traced on #16137 to the same -0.0 from -log(1.0), as a bug (#16139, 10.6.0); LL was left out.
  • c = 0 is legal (NormalizationH1 accepts any non-negative finite c); Lucene's DistributionTestCase tests it on purpose.
The full argument175 words
  • Contract. lucene/MIGRATE.md:1368-1372: "### Scorer must produce positive scores (LUCENE-7996)" / "Scorers are no longer allowed to produce negative scores." The enforcing check is assert Float.compare(score, 0f) >= 0 : score; (AssertingScorer.java:131, added by the same change).
  • The sibling was fixed as a bug. #16137 (romseygeek: "Impacts and competitive iterators all assume that scores can't be negative, a contract which this Similarity breaks"); iprithv traced SPL's value to "-0.0, coming from -Math.log(1.0)"; #16139 added // Clamp to avoid negative scores due to floating-point rounding errors / return Math.max(0.0, -Math.log(fraction));, listed under 10.6.0 Bug Fixes, approved by msfroh ("it sounds like this is generally going to round -0.0 to 0.0"). Neither thread mentions LL. #16139 is framed more broadly, as negative scores "due to floating-point rounding errors", so it is a close precedent rather than a ruling on LL.
  • Legal trigger. NormalizationH1(float c) rejects only non-finite or negative c; DistributionTestCase picks c = 0 and mu = 0 as the "minimum value"; SimScorer.score asks only that freq be "finite and positive".
The devil's-advocate check140 words

The strongest case for "not a bug" (rated moderate by the cross-model refute review): -0.0f == 0.0f in Java; Lucene's similarity checks use score >= 0 and accept it; the SimScorer javadoc does not spell out the sign of zero; IBSimilarity and DistributionLL are @lucene.experimental; and c = 0 makes every score 0, so nobody would run it.

Why it fails: the rule Lucene enforces for scorers is Float.compare(score, 0f) >= 0, and upstream fixed SPL's negative scores, which iprithv traced to the same -0.0 from -Math.log(1.0), as a bug in 10.6.0. c = 0 is validated as legal and tested on purpose, and the tiny-boost route needs no degenerate parameter. The rest argues for Low priority, which is how it is classed, not for intent; the score >= 0 checks explain why the tests miss it.

Suggested fix

Recommended Clamp at zero, as #16139 did for DistributionSPL (validated)

Clamp like #16139: return Math.max(0.0, -Math.log(lambda / (tfn + lambda))); in DistributionLL.score. For valid inputs only -0.0 changes (Math.max(0.0, x) returns x for any x > 0). The repro passes, and the similarities tests and TestSynonymQuery pass with assertions on and off.

@@ -31,7 +31,8 @@
 
   @Override
   public final double score(BasicStats stats, double tfn, double lambda) {
-    return -Math.log(lambda / (tfn + lambda));
+    // Clamp to avoid returning -0.0 when tfn + lambda rounds to lambda
+    return Math.max(0.0, -Math.log(lambda / (tfn + lambda)));
   }
All options and trade-offs4 options ยท 506 words

One fix was validated through gradle in a scratch worktree of 645d4fe14a20; two equivalent rewrites and a test-side complement are listed but were not run.

Clamp at zero, as #16139 did for DistributionSPL (validated) (recommended)

DistributionLL.java +2/-1:

@@ -31,7 +31,8 @@
 
   @Override
   public final double score(BasicStats stats, double tfn, double lambda) {
-    return -Math.log(lambda / (tfn + lambda));
+    // Clamp to avoid returning -0.0 when tfn + lambda rounds to lambda
+    return Math.max(0.0, -Math.log(lambda / (tfn + lambda)));
   }

Trade-offs

  • The same idiom and comment style as DistributionSPL.java:61-62. Math.max(0.0, -0.0) is +0.0 in Java.
  • Math.max(0.0, x) returns x for every x > 0 and for NaN, so for the inputs IBSimilarity passes (finite tfn >= 0, finite lambda > 0) only -0.0 changes. The differentials agree: the study's 5,000,000 pairs found the clamped copy different from the real class only where the real class returns -0.0, and a re-run of 10,000,000 pairs (the study's sampling scheme plus typical inputs) found the same. For invalid inputs (a negative tfn) the clamp also turns a genuinely negative result into +0.0.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle with --offline (in the fresh worktree gradlew first fetched its own wrapper jar), OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off. With the fix: the repro passes both ways; the whole org.apache.lucene.search.similarities test package plus TestSynonymQuery pass (assertions on: 28 suites, 129 tests, 0 failures; off: 28 suites, 129 tests, 0 failures); :lucene:core:checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass with the fix and the test file in place.
  • Earlier runs: the study's 9 tests pass with it (AssertingScorer accepts every score, tie order [0, 1] for 1 and 3 slices); TestDistributionLL and TestDistributionSPL pass; the cross-model reviewer's 5 tests pass and TestDistributionLL passes 10/10 over 5 iterations.

Write it as 0.0 - Math.log(...)

return 0.0 - Math.log(lambda / (tfn + lambda)); also maps -0.0 to +0.0 (0.0 - 0.0 = +0.0); for any other result 0.0 - y equals -y exactly, so every other value stays the same, including a genuinely negative result from invalid inputs, which it does not clamp.

Trade-offs

Less obvious to a reader than the clamp, and different from the SPL fix. Not run as a fix; the re-run differential (10,000,000 pairs) found it equal to the clamp on every pair.

Use Math.log1p(tfn / lambda)

Mathematically equal, and never negative for finite tfn >= 0.

Trade-offs

More accurate for small tfn, but it changes the last bits of many scores: in 5,000,000 typical inputs it changed the double result of 502,054 (about 10%) but no float score; with the study's extreme sampling it changed 577,673 float scores that were not -0.0. Not run as a fix.

Complement: make the similarity test checks see the sign of zero

Use Float.compare(score, 0f) >= 0 in BaseSimilarityTestCase and AssertingSimilarity, as AssertingScorer does, so this class of bug is caught.

Trade-offs

Not a fix on its own. Untested: other similarities might then fail too.

Downstream note

Elasticsearch documents an IB similarity with distribution ll and the DFR normalizations as an expert index setting, so the same -0.0 scores can appear there with such settings (by reading its documentation, not run).

How it was found

Not a formal find. While running an SMT signed-zero range check over Lucene's similarities, an AI tool read upstream #16137 (the same -0.0 in DistributionSPL) and then checked LL by hand; the model did not find it on its own. An independent verifier reproduced it on the real jar.

The method in full161 words

Method: non-formal (code reading prompted by an upstream issue). Study round 3, an SMT experiment on similarities and impacts (2026-10-22, main 7cecf79523).

  • The experiment ran an SMT signed-zero range check over the similarities. Its write-up says it looked at LL after reading #16137, so the study does not credit the SMT method with the find: it prompted the look but did not find it independently.
  • An independent verifier wrote 9 deterministic JUnit tests (6 LL tests fail, 3 SPL controls pass), re-ran the #16137 doTestBoosts scenario with LL (80 of 80 searches trip AssertingScorer, SPL 0 of 80), compared a clamped copy on 5,000,000 random pairs, and swept the default configurations (0 of 199,665 integer-freq points each; -0.0f only at freq 2^-31 and 1e-12, with the H1, H2 and Z defaults).
  • 2026-10-29 the repro was reduced to one public-API test; 2026-11-01 it was re-run on 645d4fe14a20 and three releases, and a cross-model reviewer reproduced it with its own tests.

Prior reports, history and checks

Prior reports

No report of this defect names DistributionLL (2026-11-01: GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, downstream trackers, the web; none of the 409 open PRs touched it that day). The same symptom is public for DistributionSPL in open #16137, fixed by #16139.

Searches3 searches

No issue, PR, commit, JIRA entry, list post or downstream report says that DistributionLL returns -0.0 or any negative score. But #16137 is still open, its title ("IBSimilarity causes test failure in TestSynonymQuery") covers all of IBSimilarity, and its comments contain the mechanism; its body, comments and fix cover SPL only. A maintainer could reasonably read LL as part of #16137.

Related, not this bug: #9044 (LUCENE-7996, the contract), #9045 (LUCENE-7997: rmuir wrote "LL passes the test, and SPL fails as expected" with a score >= 0 check that cannot see the sign), #9058 (LUCENE-8010), #8406 (LUCENE-7352), and the open javadoc items #16909 / #16916 (the IBSimilarity class javadoc lists DistributionLL twice).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; Elasticsearch, OpenSearch, Solr), dev@/java-user@/issues@/commits@ lists, the web2026-11-01scripted searches, 118 + 12 queries (DistributionLL, IBSimilarity, log-logistic, "negative zero", -0.0, "signed zero", "Math.log(1.0)", AssertingScorer, ...); #16137 and #16139 read in full; 17 related threads grepped; the changed files of all 409 open PRsno report for LL; #16137 / #16139 cover SPL only
apache/lucene issues and PRs; upstream main2026-11-01 (this doc)#16137 state; "DistributionLL"; "negative zero score" and "-0.0 score similarity" since 2026-10-25; DistributionLL.java on main f815e679bf6cnothing new; #16137 open, no comment since 2026-05-28; the file is unchanged
the study's searches2026-10-22GitHub and Apache JIRA text searches for DistributionLL, log-logistic, negative or signed zero, IBSimilarity, AssertingScorerno report for LL

History

The formula dates from 2011 (LUCENE-2959) and always returned -0.0 here; LUCENE-7996 (a8a63464e7d, 2017) made negative scores illegal, so 8.0.0 is the first affected release (7.x passes). Every release 8.0.0 to 10.6.0 and main return -0.0f; the tie-order effect starts in 10.2.0.

Versions tested6 rows

The formula -Math.log(lambda / (tfn + lambda)) came with DistributionLL itself (cfaf91c7398, LUCENE-2959, 2011-09-11, first in 4.0.0-alpha); in DistributionLL and NormalizationH1, 42717d5f4bb (LUCENE-7997, 2017, a broad 78-file similarity change) only changed float to double and added the c validation. What made it a bug is a8a63464e7d (LUCENE-7996, "Queries are now required to produce positive scores", 2017-12-06, issue now #9044), which added the MIGRATE rule and the Float.compare check; 8.0.0 is the first release that contains it, and no 7.x release does (7.7.2 and 7.7.3, released after 8.0.0, do not). "First affected" means the first release whose score contract the value breaks; the -0.0 itself is computed since 4.0.0-alpha. The tie-order effect came with the collector change #13935 (10.2.0: HitQueue breaks ties with Float.compare); since #14714 (10.3.0) DocScoreEncoder keeps that order. DistributionLL.java is the same blob from 9.0.0 to upstream main.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails through gradle (-ea, -da); search probe -0.0f; tie order [1, 0]
10.2.0 to 10.6.0 affected-0.0f; tie order [1, 0]; minimal test fails on 10.5.2 and 10.6.0
8.0.0 to 10.1.0 no tie effect-0.0f and AssertingScorer rejects it (8.0.0, 9.0.0 run); ties keep doc-id order [0, 1]
7.0.0 to 7.7.3 not affectedthe same -0.0f, but negative scores were still allowed and AssertingScorer accepts it
4.0.0 to 6.6.6 not affectedthe formula returns -0.0 (4.0.0, 4.10.4, 5.5.5, 6.6.6 run); no score-sign rule
main + clamp not affected+0.0f; the repro, the similarities tests and TestSynonymQuery pass

Independent checks

Reproduced by the study's independent verifier, two later reruns, a repro minimization, a re-verification on main and three releases, a cross-model refute review (OpenAI GPT-5.6, with execution) and this doc's gradle runs. All say real and cosmetic; the works-as-designed case was rated moderate.

Checks and verdicts238 words

Every reproduction ran on stock builds; the fix checks ran with the patched DistributionLL only.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (similarity SMT) real, cosmetic9 tests (6 LL fail, 3 SPL controls pass); #16137 scenario 80/80 vs SPL 0/80; 5,000,000-pair clamp differential; default sweep: 0 of 199,665 integer-freq points per configuration (-0.0f only at freq 2^-31 and 1e-12, with the H1, H2 and Z defaults).
Re-runs on later main commits reproducesThe study's tests on main b83101cdd2 and a9779ef3c193: 9 run, 6 fail.
Repro minimization (2026-10-29) verifiedThe one-test repro fails -ea/-da; passes with the clamp shadowed.
Re-verification on 645d4fe14a20 confirmedFails on main (-ea, -da), 10.6.0, 10.5.2 (-ea, -da), 9.12.3; the study's 9 tests 9/9 with the clamp.
Cross-model refute review (OpenAI GPT-5.6, with execution) real (high)Own tests: the direct scorer and a writer-built index's ScoreDoc give bits 0x80000000; controls pass; the clamp passes 5/5 and TestDistributionLL 10/10. Noted that a plain AssertingIndexSearcher TermQuery search passes (bulk-scorer path).
History follow-up 8.0.0 first affected171 recorded runs, 3.6.2 (a compile check) to main; 7.x passes the contract probe, 8.0.0 fails; tie effect from 10.2.0.
This doc (2026-11-01) reproduced, fix validatedGradle in a scratch worktree: stock fails both ways; with the clamp the repro, the similarities tests and TestSynonymQuery pass, lint pass.

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 notes526 words

Finding record

LFV-072, snapshot of 2026-11-06

Registry title
IBSimilarity with DistributionLL returns -0.0f scores
Area
search / similarities
Priority
Low, rank 1211: IBSimilarity with DistributionLL can return -0.0f under degenerate parameters, which is cosmetic.
Impact class
minor
Impact note
Users of the non-default IBSimilarity with DistributionLL in degenerate cases (degenerate c/mu, or tiny freq/boost); cosmetic.
Summary
You score with IBSimilarity and DistributionLL in a degenerate case. You expect a non-negative score. Instead it returns -0.0f, which AssertingScorer rejects, and tie order then depends on slicing.
Introduced
LUCENE-7996 (a8a63464e7d), which made negative scores illegal; the formula itself dates from LUCENE-2959 (cfaf91c7398, 2011) [exec: 7.x passes the contract probe, 8.0.0 fails] (2017-12-06)
First affected version
8.0.0 [exec]
Affected versions
8.0.0 to 10.6.0 (61 releases) and main [exec]; the zero-score tie order only from 10.2.0
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
non-formal: code reading
Upstream and prior reports
none found for DistributionLL (study searches 2026-10-22); sibling of open #16137
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off; with the clamp it passes both ways, the whole org.apache.lucene.search.similarities test package plus TestSynonymQuery pass (assertions on: 28 suites, 129 tests, 0 failures; off: 28 suites, 129 tests, 0 failures), and the :lucene:core format, ECJ and forbidden-apis checks pass. Standalone, the minimal test fails on 10.6.0, 10.5.2 (-ea and -da) and 9.12.3; the version-history search probe ran on every release from 7.0.0 to 10.6.0 and on main, and a formula probe sampled 4.0.0, 4.10.4, 5.5.5 and 6.6.6. Earlier runs used main b83101cdd2 and a9779ef3c193 (identical DistributionLL.java).

Lucene and upstream sources

The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/DistributionLL.java:33-35; IBSimilarity.java:115-117; SimilarityBase.java:185-186; NormalizationH1.java:43-46, 57; DistributionSPL.java:60-62; LambdaDF.java:32-38
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/DocScoreEncoder.java:22-33; TopScoreDocCollector.java:24-26; TopDocs.java:114-116
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131; AssertingScorable.java:36; similarities/AssertingSimilarity.java:83, 116; similarities/BaseSimilarityTestCase.java:389
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/similarities/DistributionTestCase.java:34-39, 75-80
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/IBSimilarity.java:66; DistributionLL.java:25 (@lucene.experimental)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/SynonymQuery.java:90-93 (boost range)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/similarities/DistributionTestCase.java:34-39, 75-80 (c = 0, mu = 0 in one of four random choices); lucene/test-framework/src/java/org/apache/lucene/tests/search/similarities/BaseSimilarityTestCase.java:389
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:1368-1372
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/DistributionSPL.java:61-62; NormalizationH1.java:41-48; Similarity.java:218
  • a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:456, 714, 795-796 (10.6.0 Bug Fixes, GITHUB#16139)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/search/similarities/DistributionTestCase.java:37-38, 79-80
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/DistributionSPL.java:61-62
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/similarities/DistributionLL.java:33-35; LambdaDF.java:32-38; NormalizationH1.java:56-58

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 and the executed runs.

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.