One subtraction of two nearly equal numbers, 1 - k / (S + k), wipes out the score when S is far below the pivot k.
FeatureFieldstores a static per-document value S, such as popularity or pagerank, andnewSaturationQuery(field, feature, weight, pivot)turns it into the scoreweight * S / (S + pivot): near 0 for tiny S, weight/2 at S = pivot, close to weight far above it.- 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.
- 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)).
- You rank by a static feature with
FeatureField.newSaturationQuery(field, feature, weight, pivot). The javadoc promisesweight * S / (S + pivot): for a value S far below the pivot that is aboutweight * S / pivot, small but positive and growing with S. - 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. - 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. - 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.
- 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.
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.
SaturationFunction.doScorereturnsweight * (1 - pivot / (f + pivot)), all in float (FeatureField.java:450at645d4fe14a20). 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".- 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, and1 - 1.0is 0.0. - 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 ofweight * 2^-24and whole ranges of S share one score. - The score, the bulk scorer and
explain()all go throughdoScore(:454-457,:460-470,:477);FeatureQuerypassesfunction.scorer(boost)into aTermScorer(FeatureQuery.java:135-137), so the weight and any outer boost multiply the error.
- 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.
newSigmoidQuery(FeatureField.java:527-532):(float) (weight * (1 - pivotPa / (Math.pow(f, a) + pivotPa)))in double. It gives 0.0 exactly whenfl(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 whenpivot^expoverflows).IntervalQuerydefault scoring (IntervalScoreFunction.java:72, pivot 1,IntervalQuery.java:71-73): the sloppy frequency adds1 / (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
newSaturationQuerywith 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;IntervalQuerywith intervals thousands to millions of positions wide. - Default vs opt-in. FeatureField scoring is opt-in (
@lucene.experimental); saturation isIntervalQuery's default scoring. Nothing else needs changing: the defaultIndexWriterConfig, codec andIndexSearcher, 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_featurequery callsFeatureField.newSaturationQuerywith 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.
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.
/*
* 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);
}
}
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Release jars 10.5.2 and 10.6.0: fail |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is plain Java float arithmetic) |
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)
# 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.*'- The javadoc promises
weight * S / (S + pivot)with no range condition, andexplain()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 asweight * S / (S + pivot)" (FeatureField.java:634); the class javadoc givessatu(S) = w * S / (S + k)and the sigmoid'sw * S^a / (S^a + k^a)(:70-77);newSigmoidQuerythe 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 literalw*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-195pins 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.
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.
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 / fis a correctly rounded division, so it does not grow when f grows;1 + x,w / xand the float cast are monotone;Math.powis 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:156expected 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, includingTestFeatureField(14 of 14, its monotonicity checks included) and the independent reproducer'sTestFeatureFunctionPrecisionV1(4 of 4; it fails on stock main).:lucene:queries:test --tests 'org.apache.lucene.queries.intervals.*': 8 classes, 118 tests, 0 failures, includingTestIntervalQuery(34),TestIntervals(43) andTestIntervalScorePrecisionV1(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.
(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.
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.
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.
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.
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 andexplain()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 whenfl(S + k) == k. - Public-API JUnit tests were written from those thresholds; the one-test minimal repro came later.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (incl. migrated JIRA issues), commits | 2026-10-31 | 75 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 issues | 2026-10-31 | changed 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, web | 2026-10-31 | dev@, java-user@, issues@ (30 queries); Elasticsearch, OpenSearch, Solr; 8 web queries | nothing on this defect |
| Closest items (read) | 2026-10-31 | threads and diffs | elastic/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 search | 2026-10-28 | 196 logged queries over the same sources | nothing on this defect |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and standalone runner); passes with the fix |
| 10.5.2, 10.6.0 | affected | minimal test fails, assertions on and off (release jars) |
| 7.4.0 - 10.5.1 | affected | every 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 affected | no FeatureField (compile error) |
| IntervalQuery: 8.0.0 - 10.6.0 and main | affected | widths 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 affected | scored by BM25 then: positive, distinct scores |
| fix (all four copies) | not affected | minimal test, the independent reproduction tests and 503 + 118 module tests pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| Finder (exhaustive run, SMT, JUnit) | candidate | Public-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 | real | Own 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 priority | No 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 | keep | Re-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 | reproduced | Minimal test through gradle before and after the fix; module tests; the staircase and ranking probe on stock and fixed code. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 448 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,667 words
- 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 theorg.apache.lucene.documenttests (503) and theorg.apache.lucene.queries.intervalstests (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 main2f424fd428dacarries the same code (read, 2026-10-31).
- 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)
- TestFeatureFieldSaturationFarBelowPivot.java (one test, public API):
repro/LFV-898/src/org/apache/lucene/document/TestFeatureFieldSaturationFarBelowPivot.java(47 lines)
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).
assets/LFV-898/bug.png(448 KB)
