All findings · Lucene finding · LFV-898 · featurefield-saturation-sigmoid-one-minus-rewrite-cancellation

FeatureField saturation and sigmoid scores cancel to 0.0 or tie when the feature value is far below the pivot

FeatureField.newSaturationQuery computes the documented w*S/(S+k) as w*(1 - k/(S+k)) in float, which cancels far below the pivot: with pivot 1000, S = 2^-17 scores exactly 0.0, not 7.6e-9, and distinct values tie. The sigmoid and IntervalQuery share it; affected since 7.4.0; low priority, accuracy only.

Summary

Priority
Low, rank 693The FeatureField saturation/sigmoid and IntervalQuery formula error is released since 7.4.0 but changes scores by only about one float ulp, so it is an accuracy-only Low row.
Impact
silent wrong results
Since
7.4.0 (LUCENE-8197, 2018)introduced 2018-03-20 · about 8 years
Affected
7.4.0 to 10.6.0 and main; the IntervalQuery copy from 8.0.0
Status
documented
Found by
non-formal: a cross-model probe, then exhaustive runs of the real jars and SMT

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

The finding in 30 seconds

One subtraction of two nearly equal numbers, 1 - k / (S + k), wipes out the score when S is far below the pivot k.

Background

  1. FeatureField stores a static per-document value S, such as popularity or pagerank, and newSaturationQuery(field, feature, weight, pivot) turns it into the score weight * S / (S + pivot): near 0 for tiny S, weight/2 at S = pivot, close to weight far above it.
  2. A Java float keeps 24 significant bits, about 7 decimal digits. Adding a number more than about 33 million (2^25) times smaller leaves the larger one unchanged: in float, 1000 + 0.0000076 is 1000.
  3. Lucene's scorers must never give a larger value a lower score. To make sure rounding cannot break that, the code evaluates the formula as weight * (1 - pivot / (S + pivot)).
Diagram of the bug: newSaturationQuery with weight 1 and pivot 1000 on a document whose feature value S is 2^-17 (7.6e-6). The documented score w * S / (S + k) is 7.63e-9. The code computes w * (1 - k / (S + k)) in float: S + k rounds to exactly 1000 because floats near 1000 are 6.1e-5 apart, k / (S + k) is 1, and the score is 0.0. The fixed form w / (1 + k / S) gives 7.629395E-9.
One document with S = 2^-17 and pivot 1000: in float, S + 1000 rounds back to 1000, so the code's 1 - 1000 / (S + 1000) is 0, while the documented S / (S + 1000) is 7.6e-9. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You rank by a static feature with FeatureField.newSaturationQuery(field, feature, weight, pivot). The javadoc promises weight * S / (S + pivot): for a value S far below the pivot that is about weight * S / pivot, small but positive and growing with S.
  2. Index one document with S = 2^-17 (7.6e-6, stored exactly) and query with weight 1 and pivot 1000. You expect 7.63e-9. You get exactly 0.0, and explain() prints "computed as w * S / (S + k)" next to the value 0.0.
  3. Why: to keep scores from ever decreasing under rounding, the code computes weight * (1 - pivot / (S + pivot)) in float. Floats near 1000 are 0.000061 apart, so 1000 + 0.0000076 rounds back to 1000, the ratio is 1, and 1 - 1 = 0. A little higher up, scores snap to multiples of about weight * 6e-8.
  4. So different values tie: the 2,233 values FeatureField can store between 2^-20 and 4.2e-4 get only 8 different scores, 1,281 of them 0.0; two documents whose values differ by 56% tie and come back in doc-id order when the feature query ranks them alone. The fix keeps all 2,233 apart.
  5. A big boost makes it visible: with boost and pivot 100,000 (a linear-function workaround suggested on elastic/elasticsearch#49859), S = 0.5 and 0.504 both score 0.500679, and every S up to 0.0039 scores 0.0. Added to a text score, the documented use, the error is mostly lost in the sum.

Who is affected

Applications that rank with the opt-in FeatureField.newSaturationQuery (or a steep newSigmoidQuery), or with IntervalQuery's default scoring, when values sit many decades below the pivot and the feature query decides the order alone or carries a large boost. Default index and searcher settings, any index size. Added to a text score, the documented use, the error is mostly hidden.

Mechanism, terms and numbers855 words

FeatureField.newSaturationQuery(field, feature, weight, pivot) documents the score weight * S / (S + pivot), which is positive and strictly increasing in the feature value S. SaturationFunction evaluates it as weight * (1 - pivot / (S + pivot)) in float, a rewrite whose code comment says it is there "to make sure it doesn't decrease with f in spite of rounding". When S is far below the pivot, S + pivot rounds to the pivot or a few ulps above it and the subtraction cancels: the score is exactly 0.0, or a small multiple of about weight * 2^-24, so documents with different values tie, while explain() prints the documented formula next to the wrong value. One document with S = 2^-17 and pivot 1000 scores 0.0 instead of 7.63e-9. newSigmoidQuery and IntervalQuery's saturation and sigmoid scoring carry the same rewrite. Every release since 7.4.0 (IntervalQuery since 8.0.0) and main are affected. The absolute error is tiny, so in the documented use, a SHOULD clause added to a text score, it is mostly hidden; it decides the order when the feature query ranks on its own or carries a large boost. Scoring as weight / (1 + pivot / S) in double removes it and keeps the scores monotone.

The bug

  1. SaturationFunction.doScore returns weight * (1 - pivot / (f + pivot)), all in float (FeatureField.java:450 at 645d4fe14a20). The comment above it (:447-449): "should be f / (f + k) but we rewrite it to 1 - k / (f + k) to make sure it doesn't decrease with f in spite of rounding".
  2. For S = 2^-17 and pivot 1000: floats near 1000 are 2^-14 = 6.1e-5 apart and S is 0.125 of that, so f + pivot = 1000.0000076... rounds to exactly 1000.0; pivot / (f + pivot) is 1.0, and 1 - 1.0 is 0.0.
  3. A little higher, pivot / (f + pivot) lands one or a few floats below 1; floats just below 1 are 2^-24 = 6e-8 apart, so the score is a small multiple of weight * 2^-24 and whole ranges of S share one score.
  4. The score, the bulk scorer and explain() all go through doScore (:454-457, :460-470, :477); FeatureQuery passes function.scorer(boost) into a TermScorer (FeatureQuery.java:135-137), so the weight and any outer boost multiply the error.

The numbers

  • Thresholds (weight 1; exhaustive run of the real jar over every legal stored value and 771 pivots, plus cvc5): the score is 0.0 exactly when fl(S + k) == k, always for S/k < 2^-25 (about 3e-8) and never for S/k > 2^-24; distinct stored values tie up to S/k of about 6e-5; errors above 2 ulps up to S/k of about 0.33.
  • Staircase (pivot 1000, one document for each of the 2,233 values FeatureField can store between 2^-20 and 4.2e-4): 8 distinct scores; 1,281 documents (S up to 3.05e-5) score 0.0; 383 documents with S from 3.06e-5 to 9.13e-5 all score 5.9604645E-8. With the fix: 2,233 distinct scores, 0 zeros.
  • Ranking (FILTER body:lucene + SHOULD saturation, pivot 1000): S = 9.54e-5 and S = 1.49e-4 both score 1.1920929E-7 and come back in doc-id order; with the fix the larger value ranks first.
  • Large boost (new BoostQuery(newSaturationQuery(f, x, 1f, 1e5f), 1e5f), the linear-function workaround suggested on elastic/elasticsearch#49859): S = 0.5 and 0.50390625 both score 0.500679 (documented 0.4999975 and 0.5039037); S = 1.0 and 1.0039062 both 1.001358; every S up to 2^-8 scores 0.0 (documented about S); S = 0.0078125 scores 0.00596 (24% low) and S = 0.015625 scores 0.01788 (14% high).
  • Default pivot (newSaturationQuery(field, feature), about the geometric mean of the values): on 2,000 values spread over 1e-9..1e3 (pivot 9.6e-4), 103 adjacent ties; 51 of them (69 tied pairs in all, in tie groups of up to 4 documents) are between documented scores at least 1% apart. The other 52 are near saturation, where the documented scores themselves round to the same float; with the fix 52 adjacent ties remain and none of them is 1% apart.
  • Monotonicity holds everywhere (0 violations); no NaN, no negative score; the matching set never changes.

The other copies

  • newSigmoidQuery (FeatureField.java:527-532): (float) (weight * (1 - pivotPa / (Math.pow(f, a) + pivotPa))) in double. It gives 0.0 exactly when fl(S^a + k^a) == k^a: only when (S/k)^a <= 2^-53, and always when it is below 2^-54; the cut-off depends on the pivot (S/k up to 1.03e-4 for a = 4, 8.8e-5 at pivot 1000; up to 0.0101 for a = 8, 0.0095 at pivot 1000). For example pivot 1, exponent 8 and S = 2^-8 (documented 5.4e-20). The values lost are tiny; this half shares its lines and its fix with LFV-258 (sigmoid NaN when pivot^exp overflows).
  • IntervalQuery default scoring (IntervalScoreFunction.java:72, pivot 1, IntervalQuery.java:71-73): the sloppy frequency adds 1 / (width - minExtent + 1) per interval, so gaps of 1,000,000 and 1,030,000 positions both score 9.536743E-7 (documented 9.99999E-7 and 9.708729E-7) and a 2^25-wide gap scores 0.0. Consecutive widths tie from 2,947 positions. The 4-argument constructor's sigmoid (:128) has the same rewrite.
Exposure and likelihood246 words
  • Who is exposed. Rankings by newSaturationQuery with a pivot set far above most values, the library's default pivot (about the geometric mean of the indexed values) on a feature spread over about 12 decades, or a large boost such as the linear-function workaround with boost and pivot 100,000; steep sigmoids (exponents 4 to 8) from 2 to 4 decades below the pivot; IntervalQuery with intervals thousands to millions of positions wide.
  • Default vs opt-in. FeatureField scoring is opt-in (@lucene.experimental); saturation is IntervalQuery's default scoring. Nothing else needs changing: the default IndexWriterConfig, codec and IndexSearcher, one document, any index size.
  • Silent. Different values get the same score (or 0.0) and come back in doc-id order; explain() prints the formula next to a value it does not compute. No exception, no log line.
  • When the order changes. With a MUST text clause plus the SHOULD feature clause (the class javadoc's pattern), the float sum with the text score absorbs a feature score this small either way: 2,753 adjacent ties on stock against 2,704 with the exact formula (pivot 1000). With a FILTER clause plus the feature query, 2,522 ties against 0. With a large boost the error reaches 1% to 100% of the score.
  • Downstream. Elasticsearch's rank_feature query calls FeatureField.newSaturationQuery with the default or a request's pivot and applies the request's boost on top.
  • How likely. Rare: nobody has reported it since 2018 and the documented use masks it. Priority low, accuracy only.

Minimal repro

One JUnit test, one document, default settings, public API: index S = 2^-17, search newSaturationQuery("features", "popularity", 1f, 1000f), and the hit must score above 0.

TestFeatureFieldSaturationFarBelowPivot.java (one test, public API)47 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.document;

import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFeatureFieldSaturationFarBelowPivot extends LuceneTestCase {

  public void testSaturationScoreIsPositive() throws Exception {
    float s = 0x1p-17f;
    float pivot = 1000f;
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new FeatureField("features", "popularity", s));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        Query q = FeatureField.newSaturationQuery("features", "popularity", 1f, pivot);
        float score = new IndexSearcher(reader).search(q, 1).scoreDocs[0].score;
        double documented = s / (s + (double) pivot);
        assertTrue("expected w*S/(S+k) = " + documented + " > 0, got " + score, score > 0);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestFeatureFieldSaturationFarBelowPivot -Ptests.seed=DEADBEEF
Expected
The hit scores 1 * S / (S + 1000) = 7.63e-9, a positive score.
Observed
AssertionError: expected w*S/(S+k) = 7.62939447304234E-9 > 0, got 0.0, with assertions on and off.

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

More files, environment, full output326 words

ByteBuffersDirectory, the default IndexWriterConfig, a plain IndexSearcher; nothing random, one thread. 2^-17 is a positive normal float that FeatureField's 9-bit encoding stores exactly, so the stored value equals the input. The test asserts only what the javadoc supports, a positive score for a positive documented value, with no ulp tolerance.

It shows the FeatureField saturation, the strongest of the four copies. The ties, the sigmoid and IntervalQuery's scoring come from the same expression; they were executed in the study and in the tie-break and are described in the text, not added as more tests.

Regression test: this same test, as one more method of the existing TestFeatureField. It passes with the recommended fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release jars 10.5.2 and 10.6.0: fail
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; the code path is plain Java float arithmetic)
Command (environment)
cp $REPRO/TestFeatureFieldSaturationFarBelowPivot.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestFeatureFieldSaturationFarBelowPivot -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (the same failure)

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on, and again with -Ptests.asserts=false)
TestFeatureFieldSaturationFarBelowPivot > testSaturationScoreIsPositive FAILED
    java.lang.AssertionError: expected w*S/(S+k) = 7.62939447304234E-9 > 0, got 0.0
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:49D6D0ED47427B2D]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.document.TestFeatureFieldSaturationFarBelowPivot.testSaturationScoreIsPositive(TestFeatureFieldSaturationFarBelowPivot.java:43)
:lucene:core:test (FAILURE): 1 test, 1  failure
BUILD FAILED

# standalone runner, main 645d4fe14a20 jars, and release jars 10.5.2 and 10.6.0: -ea and -da FAIL 1/1, same message

# explain() of the same hit (stock)
0.0 = Saturation function on the features field for the popularity feature, computed as w * S / (S + k) from:
  1.0 = w, weight of this function
  1000.0 = k, pivot feature value that would give a score contribution equal to w/2
  7.6293945E-6 = S, feature value

# with the recommended fix, gradle, -Ptests.seed=DEADBEEF
:lucene:core:test (SUCCESS): 1 test                    # the minimal test (and again with -Ptests.asserts=false)
:lucene:core:test (SUCCESS): 503 tests, 26 skipped     # --tests 'org.apache.lucene.document.*'
:lucene:queries:test (SUCCESS): 118 tests              # --tests 'org.apache.lucene.queries.intervals.*'

Why it looks unintended

  • The javadoc promises weight * S / (S + pivot) with no range condition, and explain() prints "computed as w * S / (S + k)" next to 0.0.
  • The code comment says the value "should be f / (f + k)"; the rewrite's only goal, monotonicity, survives the subtraction-free fix.
  • Tied values are distinct stored values: relative error up to 100%.
The full argument260 words
  • The contract is a formula. newSaturationQuery "will score documents as weight * S / (S + pivot)" (FeatureField.java:634); the class javadoc gives satu(S) = w * S / (S + k) and the sigmoid's w * S^a / (S^a + k^a) (:70-77); newSigmoidQuery the same (:681). The only documented limits are the weight and pivot ranges (:639-642); feature values must be positive normal floats (:162), which the repro's 2^-17 is.
  • explain() says "computed as". FeatureField.java:484, :565; IntervalScoreFunction.java:82, :138.
  • The rewrite's goal does not need the cancellation. Its comment (FeatureField.java:447-449, :528-530; IntervalScoreFunction.java:69-71, :125-127) names monotonicity only. Over all 65,024 legal stored values x 6 pivots, the stock rewrite has 0 monotonicity violations but 161,285 zero scores and 173,308 lost distinctions; the literal w*f/(f+k) in float has 7 violations (why a rewrite was needed); (float) (w / (1 + (double) k / f)) has 0 violations, 0 zeros and 0 lost distinctions.
  • IntervalQuery says wider intervals score lower ("with wider intervals contributing lower values", IntervalQuery.java:41-43).
  • The storage note promises more. Values "are stored with a relative precision of 2^-8" (FeatureField.java:58-60); the score's relative error reaches 100%.
  • Tests never reach the region. TestFeatureField.java:142-195 pins the rewritten expression with delta 0 for S/k from 0.22 to 22, where it does not cancel; doTestSimScorer (:279-287) checks only finiteness and monotonicity.
  • No record accepts it. No javadoc, test, CHANGES or MIGRATE entry, commit message or discussion (#9244, #9679) presents zeros or ties as intended.
The devil's-advocate check213 words

The strongest case, from the refute review, the devil's-advocate verifier and the adjudication: the rewrite is deliberate; SimScorer only requires that the "Score must not decrease when freq increases" (Similarity.java:207) and 0.0 is a legal score; the errors are tiny and masked in the documented SHOULD use; newFeatureSort exists for ordering by value; FeatureField is @lucene.experimental; and upstream declined accuracy work before: #9120 (LUCENE-8072, Won't Fix: "There is a cost to log1p, I'm not sure we should do this per-hit") and #9547 (LUCENE-8501, jpountz: "In general scoring doesn't focus on accuracy").

Why it fails: those decisions accept small inaccuracies in formulas computed as documented (log(1+x) is what is documented there). Here a strictly positive documented formula returns 0.0, explain() says "computed as" that formula, distinct stored values tie, and a subtraction-free form keeps the monotonicity the comment asks for. Fixed counter-precedents exist: #12675 (MultiSimScorer should sum into a double, type:bug) and PR #16139 (IBSimilarity rounding gave negative scores, merged 2026-09-10). The class javadoc's "produces values between 0 and 1" (FeatureField.java:72) and IntervalQuery's "Scores are bounded to between 0 and 1" (IntervalQuery.java:56) put 0.0 inside the documented range, but they bound the values; the formula fixes them. Verdict: not intended, a defect, low priority.

Suggested fix

Recommended Score as weight / (1 + pivot / S), in double, in all four copies (validated)

Score as weight / (1 + pivot / S) in double (sigmoid: weight / (1 + (pivot / S)^a)) in FeatureField and IntervalScoreFunction: the same function without the subtraction, still monotone and within 1 ulp of the documented value. With three exact-value assertions in TestFeatureField updated, the repro and the document and intervals test packages (503 and 118 tests) pass.

// FeatureField.SaturationFunction.doScore (IntervalScoreFunction: the same with freq)
return (float) (weight / (1 + (double) pivot / f));

// FeatureField.SigmoidFunction.doScore (IntervalScoreFunction: the same with freq)
return (float) (weight / (1 + Math.pow((double) pivot / f, a)));
All options and trade-offs4 options · 683 words

Option A was built and run through gradle in a scratch worktree of 645d4fe14a20; it also fixes LFV-258 (sigmoid NaN), whose minimal test passes with it. B removes the zeros too but without a monotonicity guarantee, C was measured and rejected, D only documents the behaviour. Workaround without a fix: keep the pivot within a few decades of the values that must be told apart, or order by the value with FeatureField.newFeatureSort when the feature alone decides the order.

Score as weight / (1 + pivot / S), in double, in all four copies (validated) (recommended)

In FeatureField and IntervalScoreFunction, compute the saturation as weight / (1 + (double) pivot / f) and the sigmoid as weight / (1 + Math.pow((double) pivot / f, a)), drop the then-unused pivotPa fields and update the comments (3 files, +21/-23, formatted with ./gradlew tidy). Three zero-tolerance assertions in TestFeatureField.testBasics (:156, :190, :193) pinned the old float expression and now assert the documented formula computed in double, e.g. assertEquals((float) (6.0 * 42 / (42 + 4.5)), s.score(), 0f);.

// FeatureField.SaturationFunction.doScore (IntervalScoreFunction: the same with freq)
return (float) (weight / (1 + (double) pivot / f));

// FeatureField.SigmoidFunction.doScore (IntervalScoreFunction: the same with freq)
return (float) (weight / (1 + Math.pow((double) pivot / f, a)));

Trade-offs

  • Still monotone. pivot / f is a correctly rounded division, so it does not grow when f grows; 1 + x, w / x and the float cast are monotone; Math.pow is specified as semi-monotonic. Measured: 0 violations over all 65,024 legal stored values x 6 pivots, and in the study's exhaustive run with the fix (771 pivots, 4 weights, 6 exponents).
  • Accuracy. Returns the documented value within 1 ulp, correctly rounded except when the exact value is a float midpoint (1 of 2,861,056 checked cases; max relative error 5.8e-8 in the tie-break cases). Ordinary scores move by about 1 ulp: TestFeatureField.java:156 expected the stock 5.4193544, the fix gives 5.419355, and the exact value is 5.41935484. Exact scores change, so it is worth a CHANGES.txt line.
  • Cost. One more division per scored document, and the saturation moves from float to double arithmetic; no benchmark was run.
  • Review. No reviewer has read the diff line by line yet.

What the checks validated

  • Scratch worktree of 645d4fe14a20, gradle, seed DEADBEEF: the minimal test fails before the fix and passes after it, with assertions on and off.
  • :lucene:core:test --tests 'org.apache.lucene.document.*': 61 classes, 503 tests, 26 skipped, 0 failures, including TestFeatureField (14 of 14, its monotonicity checks included) and the independent reproducer's TestFeatureFunctionPrecisionV1 (4 of 4; it fails on stock main).
  • :lucene:queries:test --tests 'org.apache.lucene.queries.intervals.*': 8 classes, 118 tests, 0 failures, including TestIntervalQuery (34), TestIntervals (43) and TestIntervalScorePrecisionV1 (the IntervalQuery ties and zero; it fails on stock main).
  • LFV-258's minimal test (sigmoid NaN) passes with this diff (assertions off).
  • The staircase and ranking probe with the FeatureField half shadowed: 2,233 distinct scores, 0 zeros, and the larger value ranks first.

Evaluate the documented formula in double

(float) (weight * (double) f / (f + (double) pivot)), and the sigmoid's S^a / (S^a + k^a) in double.

Trade-offs

Removes the zeros and ties (the independent reproducer's root-cause shadow did this for all four lines and its tests pass), but monotonicity is not guaranteed by construction, since numerator and denominator both grow, and for the sigmoid Math.pow(f, a) can overflow to Inf/Inf = NaN, the LFV-258 failure. Not recommended for the sigmoid.

The ratio form in float only (rejected)

weight / (1f + pivot / f), staying in float.

Trade-offs

Gives false zeros when pivot / f overflows float at extreme ratios: 6,333 zero scores in the tie-break's arithmetic check over all legal stored values x 6 pivots.

Document the limitation

Say in the javadoc that scores carry an absolute error of up to about weight * 1e-7, that values far below the pivot can tie or score 0, and point to newLinearQuery or newFeatureSort for ordering by small values.

Trade-offs

Leaves the zeros, the ties and an explain() that prints a formula it does not compute.

Downstream note

Elasticsearch's rank_feature query calls FeatureField.newSaturationQuery and applies the request's boost on top, so its users see the same zeros and ties; which documents match, filters and deletions are not affected.

How it was found

A probe from a cross-model ideation round (OpenAI GPT-5.6) compared the stock scores with the documented formula on 4,000 random values and found exact zeros and ties. An exhaustive run of the real score functions and an SMT solver then pinned down when they occur, and a JUnit test was written from those results.

The method in full160 words

Method: non-formal first signal, then bounded checks (the study classes it non-formal; the finder tagged it semi-formal).

  • An independent model from another family (OpenAI GPT-5.6), asked for numeric-scoring leads, proposed "feature-saturation-sigmoid-cancellation"; its executed probe compared the stock main jar with the documented formulas evaluated in double on 4,000 log-uniform feature values: saturation with pivot 1e3 gave 430 exact zeros and 932 ties, the sigmoid with exponent 4 gave 621 zeros (2026-10-28).
  • The finder then ran bounded-exhaustive translation validation of the real core and queries jars: 1,403,738,112 function-level checks of the real jar's score(), bulk scorer and explain() over every legal stored value, 771 pivots, 4 weights and 6 exponents (5,614,952,448 together with three re-implemented fix and oracle forms), 39,144,448 end-to-end hit scores, and every float sloppy frequency for IntervalQuery; cvc5 floating-point queries showed the saturation scores 0.0 exactly when fl(S + k) == k.
  • Public-API JUnit tests were written from those thresholds; the one-test minimal repro came later.

Prior reports, history and checks

Prior reports

No prior report: GitHub issues and PRs (with the JIRA archive), commits, the mailing lists, Elasticsearch, OpenSearch and Solr trackers and the web were searched on 2026-10-28 and 2026-10-31. Closest: elastic/elasticsearch#68626, tied rank_feature scores from the 9-bit storage, a different cause.

Searches5 searches

Verdict: new. No open PR touches FeatureField.java, IntervalScoreFunction.java or TestFeatureField.java, and upstream has not changed the expressions.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. migrated JIRA issues), commits2026-10-3175 tool queries, 61 manual GitHub searches, 5 commit searches, 12 JQL queries (LUCENE and SOLR)no report of this defect
Open and recently updated PRs and issues2026-10-31changed files of all 408 open PRs; every issue and PR updated since 2026-10-28 (to #16955)none touches FeatureField or interval scoring
Mailing lists, downstream trackers, web2026-10-31dev@, java-user@, issues@ (30 queries); Elasticsearch, OpenSearch, Solr; 8 web queriesnothing on this defect
Closest items (read)2026-10-31threads and diffselastic/elasticsearch#68626 (ties from two input values sharing a 9-bit stored value, near the pivot); #9244 / LUCENE-8197 and #9679 / LUCENE-8633 (the introducing changes, no accuracy discussion); #13691 (sigmoid explain text); #16137 (open: the same cancellation class in IBSimilarity, other code); #9120 / LUCENE-8072 and #9547 / LUCENE-8501 (precedents on scoring accuracy)
Earlier search2026-10-28196 logged queries over the same sourcesnothing on this defect

History

Introduced with FeatureField itself in 7.4.0 (LUCENE-8197, 2018) and copied into IntervalQuery's scoring in 8.0.0 (LUCENE-8633, 2019). The expressions never changed: every release from 7.4.0 to 10.6.0 and main is affected.

Versions tested7 rows

Introduced by 710993435f36 (master) / 70cc7b68783b (branch_7x, shipped first, in 7.4.0), "LUCENE-8197: Efficient integration of static scoring factors." by jpountz, 2018-03-20, a JIRA patch with no PR (now #9244). The commit created FeatureField with both rewritten returns, the "in spite of rounding" comment and the "will score documents as" javadoc, so no version was correct. IntervalQuery copy: a8266492416d / 18c3986cc4bd, "LUCENE-8633: Remove term weighting from IntervalQuery scores" by romseygeek, 2019-01-16 (now #9679), first in 8.0.0 (lucene-sandbox; lucene-queries from 8.2.0). Later commits only moved or reformatted the lines.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone runner); passes with the fix
10.5.2, 10.6.0 affectedminimal test fails, assertions on and off (release jars)
7.4.0 - 10.5.1 affectedevery release: the 59 local release tags up to 10.6.0 contain the expressions and the 9 others (8.8.2-8.11.4) were run; runs on 7.4.0, 8.0.0, 8.2.0, 9.0.0, 9.1.0 and 9.12.3 fail with the same value
7.3.1 and earlier not affectedno FeatureField (compile error)
IntervalQuery: 8.0.0 - 10.6.0 and main affectedwidths 1,000,000 and 1,030,000 tie, a 2^25-wide interval scores 0.0; 61 releases (run on 8.0.0, 8.2.0, 8.8.2-8.11.4, 10.6.0 and main; the other tags read)
IntervalQuery: 7.7.3 not affectedscored by BM25 then: positive, distinct scores
fix (all four copies) not affectedminimal test, the independent reproduction tests and 503 + 118 module tests pass

Independent checks

Confirmed by an independent reproducer, a root-cause review from another model family (OpenAI GPT-5.6), an adjudication, a credibility audit, a current-main check and a tie-break; the devil's-advocate verifier was uncertain, leaning real at low priority. The only "not real" was procedural, from a review that could not run Java.

Checks and verdicts315 words

Every check that could execute re-ran a repro; none found a flaw in the claim. The adjudication and the devil's-advocate verifier set the priority to low: accuracy only, masked in the documented use.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (exhaustive run, SMT, JUnit) candidatePublic-API tests fail on main and 10.5.2 with assertions on and off and on 9.12.3 with assertions on, and pass with the fix; 1.4e9 function-level checks of the real jar (5.6e9 with three re-implemented fix and oracle forms); cvc5: 0.0 iff fl(S + k) == k.
Refute review (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run any command; agreed on the root cause and found the inputs in contract.
Independent reproducer realOwn tests (positivity and no ties between values 1% apart) fail 5 of 5 on main, 10.5.2 and 9.12.3; a shadow changing only the four return lines passes.
Root-cause review (OpenAI GPT-5.6) real (medium)First wrong state is the absorbed addition fl(S + k) == k; flagged the exact-value expectations in TestFeatureField and the dead pivotPa field; the step's executed check found the three assertions the fix must update.
Devil's advocate uncertain, leaning real, low priorityNo intended-behaviour argument holds; its masking probe shows the effect is mostly hidden in the documented MUST + SHOULD use.
Adjudication confirmed (medium)Low priority, accuracy; one combined fix with LFV-258.
Credibility audit keepRe-ran the finder's and the independent reproduction's tests with fresh seeds: 4 of 4 and 5 of 5 fail.
Current main and tie-break real (high on mechanism)On 645d4fe14a20 and 10.6.0; its own main() (large boost, default pivot, sigmoid, IntervalQuery) and an arithmetic sweep of four formula forms.
This doc reproducedMinimal test through gradle before and after the fix; module tests; the staircase and ranking probe on stock and fixed code.

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,667 words

Finding record

LFV-898, snapshot of 2026-11-03

Registry title
FeatureField saturation and sigmoid queries and IntervalQuery scoring compute w*(1 - k/(S+k)) instead of the documented w*S/(S+k) (and the same rewrite for the sigmoid), so documents whose value is far below the pivot score exactly 0.0 or tie with documents that have different values
Area
document / FeatureField score functions (SaturationFunction and SigmoidFunction behind newSaturationQuery and newSigmoidQuery); queries / intervals IntervalScoreFunction (IntervalQuery's default saturation scoring and its pivot and pivot-plus-exponent constructors)
Priority
Low, rank 693: The FeatureField saturation/sigmoid and IntervalQuery formula error is released since 7.4.0 but changes scores by only about one float ulp, so it is an accuracy-only Low row.
Impact class
silent wrong results
Impact note
Low priority, accuracy only: applications on 7.4.0 through 10.6.0 or main (IntervalQuery from 8.0.0) that rank with FeatureField.newSaturationQuery or newSigmoidQuery, or with IntervalQuery's default scoring, with the default IndexWriterConfig, codec and searcher and at any index size, get exact 0.0 scores and ties among documents whose feature value is far below the pivot (saturation: ties from about 4.5 and zeros from about 7.5 decades below it, which the library's own default pivot reaches on values spread over about 12 decades; sigmoid: only with steep exponents a = 4 to 8, from about 2 to 4 decades below; IntervalQuery: consecutive widths tie from about 2,950 positions, widths 3% apart tie near 1e6 positions, 0.0 from 2^24), and the ranking changes only when the feature query or IntervalQuery alone decides the order (for example a FILTER clause plus the feature query), because the errors are at most about w*6e-8 for the float saturation and the documented use as a SHOULD clause added to a text score hides almost all of them.
Summary
You rank with FeatureField.newSaturationQuery, whose javadoc says it 'will score documents as weight * S / (S + pivot)' (newSigmoidQuery and IntervalQuery's scoring state the same kind of formula), a value that is positive and grows with S, so a document with a smaller feature value should score lower but above 0. The code instead computes weight * (1 - pivot / (S + pivot)) (and 1 - k^a / (S^a + k^a) for the sigmoid) so that rounding cannot make scores decrease, and when S is far below the pivot the sum S + pivot rounds to almost exactly the pivot and the subtraction cancels. With pivot 1000 a document with S = 1e-5 scores exactly 0.0 while explain() prints 'computed as w * S / (S + k)', with the library's default pivot 199 adjacent pairs of distinct stored values tie, and IntervalQuery gives intervals 1,000,000 and 1,030,000 positions wide the same score and a 2^25-wide interval 0.0.
Introduced
710993435f365fce44a60b7c498ce6af8327f92c on master / 70cc7b68783b313a845996dac9e28c3ea6ad61e3 on branch_7x (the copy that shipped in 7.4.0): 'LUCENE-8197: Efficient integration of static scoring factors.' by jpountz, committed from a JIRA patch with no pull request (the issue is now #9244). It created FeatureField with SaturationFunction already returning weight * (1 - pivot / (f + pivot)) and SigmoidFunction (float) (weight * (1 - pivotPa / (Math.pow(f, a) + pivotPa))), under the comment that the value 'should be f / (f + k)' and is rewritten 'to make sure it doesn't decrease with f in spite of rounding', so no earlier version was correct. IntervalQuery copy: a8266492416dbc2fcf665983d1cac141e50fa9eb (master) / 18c3986cc4bdb38949fae14bd771621345d41776 (branch_8x), 'LUCENE-8633: Remove term weighting from IntervalQuery scores' by romseygeek (now #9679), which copied both expressions and the comment into IntervalScoreFunction [read: git log -S / -L and git blame in the full-history clone; later commits only moved the lines into doScore (#15137) or reformatted them; exec: 7.3.1 has no FeatureField (compile error) and 7.4.0 fails; 7.7.3 still scores the same IntervalQuery with BM25 and passes, 8.0.0 fails] (2018-03-20 (FeatureField, LUCENE-8197); 2019-01-16 (IntervalScoreFunction copy, LUCENE-8633))
First affected version
7.4.0 [exec] (the first release with FeatureField; 7.3.1 has no FeatureField, the repro does not compile [exec]); IntervalQuery: 8.0.0 [exec] (sandbox module; queries module from 8.2.0 [exec]; 7.7.3 scored the same IntervalQuery with BM25 and gave positive, distinct scores [exec])
Affected versions
FeatureField saturation and sigmoid: all 67 releases from 7.4.0 to 10.5.2; IntervalQuery: every release from 8.0.0 to 10.5.2 [exec: the candidate repro, or an API port with the same index, queries and assertions for 7.4.0-9.0.0, fails with bit-identical values on 7.4.0 (-ea, -da), 7.7.3 (FeatureField part), 8.0.0 (-ea, -da), 8.2.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (-ea, -da); the independent reproduction JUnit on 10.5.2 and 9.12.3 (-ea, -da); read: each of the four expressions is present in all 58 local release tags 7.4.0..10.5.2 (IntervalScoreFunction from 8.0.0); 8.8.2, 8.9.0, 8.10.0, 8.10.1 and 8.11.0-8.11.3 are not in the local tag set and are inferred from 8.8.1 and 8.11.4], the branch_10_6 head 4ae946e5a9b (10.6.0, released 2026-10-29) [exec] and main a9779ef3c193 [exec: -ea and -da; re-run at the registration check]. Not fixed upstream: the last commit on FeatureField.java is 1518164afa3 (#15874, refactor, author and commit date 2026-04-01) and on IntervalScoreFunction.java 8ef6a0da568 (2020-12-28 reformat), checked on upstream main by author and committer date on 2026-10-28 [read]. All runs on aarch64 Linux with JDK 25.
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 adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (the prior-report record, verdict new, written 2026-10-28, read-only; refreshed at the registration check 2026-10-28): 196 logged queries, namely the finder's and the follow-up's scripted prior-report searches (GitHub issues/PRs, commit messages, downstream trackers, dev@/java-user@/issues@/commits@ lists, LUCENE and SOLR JIRA, web), 33 manual GitHub searches over apache/lucene, apache/solr, apache/lucene-solr and downstream engines, 5 JQL queries and 6 JIRA issue reads, 18 list searches, 6 forum searches, 7 GitHub code searches and 7 web searches; the changed-file lists of all 410 open apache/lucene PRs and of the 54 PRs created or updated in the 48 h before the search; the per-branch history of both files. Nothing reports or discusses zeros, ties or lost accuracy from the 1 - k/(S+k) rewrite in FeatureField or IntervalScoreFunction. Closest items, not this bug: elastic/elasticsearch#68626 (tied rank_feature scores from the documented 9-bit storage of two input values, near the pivot); #9244 / LUCENE-8197 and #9679 / LUCENE-8633 (the introducing changes, no accuracy discussion); #13691 (sigmoid explain text); #16137 (the same cancellation class in IBSimilarity/DistributionSPL, other code); precedents: #9120 / LUCENE-8072 (Won't Fix, log1p accuracy in similarities) and #9547 / LUCENE-8501 ('scoring doesn't focus on accuracy'). Not fixed upstream: no commit on either file after 1518164afa3 (2026-04-01) and 8ef6a0da568 (2020-12-28) by author date, and no open PR touches them. Refresh: the newest PRs (to #16926) and issues (to #16928) and the open PRs updated since the follow-up search touch neither FeatureField nor intervals scoring; the GitHub search quota was exhausted at the registration check, so the refresh used REST listings only.
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 recommended fix it passes, together with the org.apache.lucene.document tests (503) and the org.apache.lucene.queries.intervals tests (118). Release jars 10.5.2 and 10.6.0 fail; ports of the test fail on every release they were run on, from 7.4.0 (17 releases); the other release tags carry the same code (read). Upstream main 2f424fd428da carries the same code (read, 2026-10-31).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java:70-77 (at a9779ef3c193) (class javadoc: satu(S) = w * S / (S + k), sigm(S) = w * S^a / (S^a + k^a), values in the 0..1 range, 0.5 at S = k), :104 (@lucene.experimental), :447-450 (saturation doScore: comment 'should be f / (f + k) but we rewrite it to 1 - k / (f + k) to make sure it doesn't decrease with f in spite of rounding'; return weight * (1 - pivot / (f + pivot)) in float), :484 (explain 'computed as w * S / (S + k)'), :500 (pivotPa = Math.pow(pivot, a)), :528-531 (sigmoid doScore, (float) (weight * (1 - pivotPa / (Math.pow(f, a) + pivotPa)))), :565 (explain 'computed as w * S^a / (S^a + k^a)'), :634 (newSaturationQuery 'will score documents as weight * S / (S + pivot)'), :652 and :658 (default pivot, approximately the geometric mean), :681 (newSigmoidQuery 'weight * S^a / (S^a + pivot^a)'), :664-705 (the only checks: weight in (0,64], pivot and exp positive and finite)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScoreFunction.java:69-72 (at a9779ef3c193) (saturation, weight * (1.0f - pivot / (pivot + freq)), same comment), :82 (explain formula), :112 and :117 (pivotPa), :125-128 (sigmoid, same rewrite), :138 (explain formula); IntervalQuery.java:43 ('wider intervals contributing lower values'), :56 ('Scores are bounded to between 0 and 1'), :72 (default saturationFunction(1)); IntervalScorer.java:66 (freq += 1.0 / Math.max(length - minExtent + 1, 1)); lucene/core/src/test/org/apache/lucene/document/TestFeatureField.java:156, :190, :193 (zero-tolerance assertions of the rewritten expression that change with the fix; :147-153 and :187 pass unchanged)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java (:58-60, :70-77, :446-451, :454-470, :475-489, :492-501, :527-532, :634, :649-652, :674-676)
  • lucene/core/src/java/org/apache/lucene/document/FeatureQuery.java (:135-137)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScoreFunction.java (:69-72, :125-128); lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java (:71-73); lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScorer.java (:66)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java (:104, :649-652, :674-676)
  • elastic/elasticsearch@7f8cd1c9f1bf modules/mapper-extras/src/main/java/org/elasticsearch/index/mapper/extras/RankFeatureQueryBuilder.java:180, :182 (read through the GitHub API, 2026-10-31); elastic/elasticsearch#49859 (comment of 2019-12-06, boost 100000, pivot 100000)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java (:58-60, :70-77, :162, :447-449, :484, :528-530, :565, :634, :639-642, :681)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScoreFunction.java (:69-71, :82, :125-127, :138); lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java (:41-43)
  • lucene/core/src/test/org/apache/lucene/document/TestFeatureField.java (:142-195, :279-287); lucene/core/src/java/org/apache/lucene/search/similarities/Similarity.java (:207)
  • GitHub, read-only, 2026-10-31: apache/lucene#9120 (Won't Fix; rmuir comment), #9547 (jpountz comment), #12675 (type:bug, closed), PR #16139 (merged 2026-09-10)
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java (:72); lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java (:56)

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 an executed probe (the minimal example step by step, the 2,233-value staircase, the FILTER ranking).

Built 2026-11-03 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.