All findings · Lucene finding · LFV-258 · featurefield-sigmoid-pivot-power-overflow-nan

FeatureField.newSigmoidQuery scores NaN when pivot^exp leaves the double range

FeatureField.newSigmoidQuery precomputes pivot^exp as a double: pivot 2^20 with exponent 60 gives 2^1200, which overflows to Infinity, so every hit scores NaN, not the documented 0.5 at S = pivot. With assertions off this is silent on every release since 7.4.0 and main; since 10.2.0 NaN hits rank first.

Summary

Priority
Low, rank 723FeatureField sigmoid queries return NaN scores only for extreme pivot and exponent values, which is silent but boundary-value only.
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
semi-formal: exhaustive run of the real score functions, then SMT

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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 precomputed power, pivot^exp, leaves the double range, and the query's scores become NaN.

Background

  1. FeatureField stores a static per-document value S, such as a popularity or pagerank signal, and newSigmoidQuery turns it into a score: weight * S^a / (S^a + pivot^a). The score grows from 0 to weight and is weight/2 when S equals the pivot.
  2. The exponent a sets how sharply the score switches on around the pivot: a large exponent makes it almost a step, "slower before 'pivot' and faster after 'pivot'".
  3. A Java double holds values up to about 1.8e308, just under 2^1024. A larger result becomes +Infinity, and Infinity / Infinity is NaN; a result below about 2^-1074 becomes 0, and 0 / 0 is NaN too.
Diagram of the bug: newSigmoidQuery with weight 1, pivot 2^20 and exponent 60 on a document whose feature value equals the pivot. The documented score is weight * S^a / (S^a + pivot^a) = 0.5. The code precomputes pivot^60 = 2^1200, which is above the largest double (about 2^1024) and becomes +Infinity, so the score is 1 - Infinity/Infinity = NaN. The fixed form weight / (1 + (pivot / S)^a) gives 0.5.
One document with S = pivot = 2^20 and exponent 60: pivot^60 = 2^1200 leaves the double range, so the score that should be 0.5 is NaN. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with new FeatureField("f", "x", 1 << 20) (feature value S = 1,048,576) and search FeatureField.newSigmoidQuery("f", "x", 1, 1 << 20, 60): weight 1, pivot 2^20, exponent 60. The factory accepts both; it only rejects values that are not positive and finite.
  2. You expect 0.5: the javadoc gives the score as weight * S^a / (S^a + pivot^a), which is weight/2 when S equals the pivot.
  3. You get NaN, and no exception. The sigmoid computes pivot^60 = 2^1200 once, as a double. The largest double is just under 2^1024, so it becomes +Infinity, and every document scores 1 - Infinity/Infinity = NaN.
  4. In a real query the ranking breaks: with body:lucene as MUST and this sigmoid as a SHOULD boost, a document with feature value 1 (documented boost about 0) comes first with score NaN, ahead of a better text match that scores 0.130.
  5. It needs a steep exponent: the first failing pivot is close to 2^(1024/exp), e.g. about 137,271 at exponent 60 or about 1,209.3 at exponent 100, and nothing fails for exponents up to 8. A tiny pivot can underflow to 0 and give 0/0 = NaN; the 4-argument IntervalQuery repeats the same arithmetic.

Who is affected

Applications that rank with the opt-in FeatureField.newSigmoidQuery, or the 4-argument IntervalQuery, and choose a steep exponent with a large pivot, or a tiny pivot with tiny feature values. It reproduces with one document and default index and searcher settings. Elasticsearch, OpenSearch and Solr pass a request's sigmoid pivot and exponent straight through. Typical exponents, below 2, are safe.

Mechanism, terms and numbers710 words

FeatureField.newSigmoidQuery(field, feature, weight, pivot, exp) documents the score weight * S^a / (S^a + pivot^a) and accepts any finite pivot and exponent above 0. Its SigmoidFunction evaluates it as weight * (1 - pivot^a / (S^a + pivot^a)) with pivot^a precomputed once per query as a double. When pivot^a overflows (above about 1.8e308) every document scores Inf/Inf = NaN; when it underflows to 0 and S^a does too, the score is 0/0 = NaN. With pivot 2^20 and exponent 60, a document with S = pivot, documented to score 0.5, scores NaN, silently. Since 10.2.0 NaN hits rank ahead of correctly scored ones; older releases return placeholder hits or throw an assertion from the collector. Every release since 7.4.0 and main are affected, and IntervalScoreFunction (behind the 4-argument IntervalQuery) copies the arithmetic. Scoring as weight / (1 + (pivot / S)^a) removes it.

The bug

  1. SigmoidFunction stores pivotPa = Math.pow(pivot, a) once per query (FeatureField.java:495, :500 at 645d4fe14a20).
  2. Each document scores (float) (weight * (1 - pivotPa / (Math.pow(f, a) + pivotPa))) (:531), where weight is the scorer's boost: newSigmoidQuery passes a weight other than 1 through a BoostQuery (:706-709). The comment above it (:528-530) explains the rewrite of f^a / (f^a + k^a): "to make sure it doesn't decrease with f in spite of rounding".
  3. Overflow. If pivot^a rounds past Double.MAX_VALUE (about 2^1024), pivotPa is +Infinity; the denominator is +Infinity for every finite f^a, so every document that has the feature scores 1 - Inf/Inf = NaN, whatever its value.
  4. Underflow. If pivot^a is below about 2^-1074, pivotPa is 0; documents whose f^a also underflows score 1 - 0/0 = NaN, the others score weight.
  5. An SMT model of the final expression (cvc5, floating-point theory, with the two powers as independent non-negative doubles) finds no other way to NaN. It does not model Math.pow or how FeatureField encodes values.
  6. The bulk scorer (:540-550) and explain() (:556-558) use the same arithmetic, and FeatureQuery passes the scorer straight into a TermScorer (FeatureQuery.java:135-137); nothing on the way to the collector checks for NaN.
  7. newSigmoidQuery rejects only pivot <= 0, exp <= 0 and non-finite values (FeatureField.java:700-705). The weight is capped at 64 (MAX_WEIGHT, :576-582); the exponent is not.

The numbers

  • One document, S = pivot = 2^20 = 1,048,576, weight 1, exponent 60: pivot^60 = 2^1200 overflows and the hit scores NaN instead of 0.5, with assertions on and off.
  • Boundary at the same pivot: exponent 51 (2^1020, finite) gives 0.5, exponent 52 (2^1040) gives NaN.
  • First failing float pivot, measured on the real scorer (S set to the pivot as FeatureField stores it; the overflow does not depend on S), close to 2^(1024/exp): exponent 9: 1.7804262e34; 20: 2.586639e15; 35: 6.416196e8; 50: 1,462,494.8; 60: 137,270.94; 100: 1,209.3365. Exponent 8 gives no NaN for any pivot up to Float.MAX_VALUE, because a float is below 2^128 and 128 * 8 = 1024, and none through underflow either: a stored feature value is at least 2^-126 (FeatureField.java:162-171), so S^8 >= 2^-1008 > 0.
  • A finite wrong value right next to the overflow: where pivot^a is finite but S^a + pivot^a overflows (about 2^1023 <= pivot^a < 2^1024; at pivot 2^20, exponents from about 51.15 to just under 51.2, measured at 51.16, 51.18 and 51.19) the score at S = pivot is 1.0 instead of 0.5.
  • Ranking: body:lucene as MUST plus newSigmoidQuery("f", "x", 1, 2^20, 60) as SHOULD, over doc0 "lucene apache apache" with feature value 1.0 (documented boost 1/(1+2^1200), about 0) and doc1 "lucene lucene lucene" without the feature: the result is [doc=0 score=NaN, doc=1 score=0.13022968]. With the fix doc1 ranks first.
  • Underflow (study test): pivot 2^-20 with exponent 60 over 4 documents gives 3 NaN scores, and the best document (score 1.0) is ranked last.

The second copy

IntervalScoreFunction.SigmoidFunction (IntervalScoreFunction.java:109-131, :117, :128), behind new IntervalQuery(field, source, pivot, exp) (IntervalQuery.java:97-99), has the same arithmetic and the same checks (IntervalScoreFunction.java:33-41). Sloppy frequencies are small, so it needs exponents in the hundreds: with pivot 4 and exponent 600, all 4 documents score NaN.

Exposure and likelihood287 words
  • Who is exposed. Users of the sigmoid feature function (FeatureField is @lucene.experimental) who want the boost to switch on sharply around the pivot, for example to approximate a popularity threshold; a steep exponent is the documented way to get that ("higher values make the function grow slower before 'pivot' and faster after 'pivot'", FeatureField.java:689-690).
  • No special setup. One document, the default IndexWriterConfig and codec and a plain IndexSearcher are enough; no special codec or concurrency setting was needed. The score depends only on the query's parameters and the document's feature value (FeatureField.java:527-532).
  • Silent. With assertions off there is no exception or warning. TopScoreDocCollector's javadoc says NaN values "are not valid scores. This collector will not properly collect hits with such scores" (TopScoreDocCollector.java:30-31).
  • What users see depends on the release (4-document probe; the arithmetic never changed):
    • 7.4.0-10.0.0: with -ea, search() throws an AssertionError in TopScoreDocCollector; with -da, TopDocs holds placeholder hits (doc 2147483647, score -Infinity) in place of real documents;
    • 10.1.0: only 1 of 4 hits;
    • 10.2.0 to main: every hit, with NaN scores ranked ahead of finite ones (overflow: all NaN, in doc-id order);
    • explain() reports NaN on every release.
  • Downstream. Elasticsearch's and OpenSearch's rank_feature sigmoid and Solr's {!rank function=sigm} pass the request's pivot and exponent to newSigmoidQuery unchanged, so a search client can hit it with one query parameter. Their intervals queries use the 2-argument IntervalQuery, so the interval copy needs an application that calls the 4-argument constructor itself.
  • How likely. Rare: the exponents involved are far above typical ones (Lucene's tests use 0.6 and 2; Elasticsearch documents the exponent as "typically in [0.5, 1]"), and nobody has reported it since 2018. Priority low.

Minimal repro

One JUnit test, one document, default settings, public API: index S = 2^20, search newSigmoidQuery("f", "x", 1, 2^20, 60), and the hit must score 0.5.

TestFeatureFieldSigmoidOverflow.java (one test, public API)45 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.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestFeatureFieldSigmoidOverflow extends LuceneTestCase {

  public void testScoreAtPivotWithSteepExponent() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new FeatureField("f", "x", 1 << 20));
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        TopDocs td =
            new IndexSearcher(r).search(FeatureField.newSigmoidQuery("f", "x", 1, 1 << 20, 60), 1);
        // expected weight/2 = 0.5 at S = pivot; actual NaN, because pivot^60 = 2^1200 overflows
        assertEquals(0.5f, td.scoreDocs[0].score, 1e-6f);
      }
    }
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestFeatureFieldSigmoidOverflow -Ptests.seed=DEADBEEF -Ptests.asserts=false
Expected
The hit scores 0.5, weight/2 at S = pivot.
Observed
AssertionError: expected:<0.5> but was:<NaN>, with assertions on and off.

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

More files, environment, full output288 words

ByteBuffersDirectory, the default IndexWriterConfig, a plain IndexSearcher; nothing random, one thread. 2^20 is a positive normal float that FeatureField stores exactly, and the fixed formula gives exactly 0.5 for it. The test asserts the documented value (weight/2 at S = pivot) rather than just "not NaN", so the failure reads expected:<0.5> but was:<NaN>.

It covers the overflow case of the FeatureField copy, the common one. The same root cause also gives NaN through underflow (tiny pivots with tiny feature values) and through the 4-argument IntervalQuery (for example pivot 4 with exponent 600); those were executed by the study and are described in the text, not added as more tests.

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

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 pure Java double arithmetic)
Command (environment)
cp $REPRO/TestFeatureFieldSigmoidOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestFeatureFieldSigmoidOverflow -Ptests.seed=DEADBEEF -Ptests.asserts=false
# assertions on (drop -Ptests.asserts=false): the same failure

Observed output

# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF (assertions on, and again with -Ptests.asserts=false)
TestFeatureFieldSigmoidOverflow > testScoreAtPivotWithSteepExponent FAILED
    java.lang.AssertionError: expected:<0.5> but was:<NaN>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:7964E766F43B5E25]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:577)
        at org.junit.Assert.assertEquals(Assert.java:701)
        at org.apache.lucene.document.TestFeatureFieldSigmoidOverflow.testScoreAtPivotWithSteepExponent(TestFeatureFieldSigmoidOverflow.java:41)
BUILD FAILED

# standalone runner, main 645d4fe14a20 jars, seed DEADBEEF: -ea and -da both tests_run=1 failures=1
# release jars 10.5.2 and 10.6.0, -ea and -da: FAIL 1/1, same message

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

Why it looks unintended

  • The javadoc promises weight/2 at S = pivot for any pivot and exponent in (0, +Infinity); Lucene calls NaN "not valid scores".
  • Upstream's own TestFeatureField check demands a finite max score from the sigmoid; at (2^20, 60) it is NaN.
  • Nothing documents an exponent limit: the weight is capped at 64, the exponent is not.
The full argument289 words
  • The contract is exact. newSigmoidQuery: the score is "weight * S^a / (S^a + pivot^a)", pivot is the "feature value that would give a score contribution equal to weight/2, must be in (0, +Infinity)", exp "must be in (0, +Infinity)" (FeatureField.java:680-693). Class javadoc: "values are in the 0..1 range and 0.5 is obtained when S and k are equal" (:76-77); with a weight the formula's range is [0, weight], so the 0..1 wording holds for weight 1, as in the repro. IntervalQuery: "Scores are bounded to between 0 and 1" (IntervalQuery.java:56) and pivot is "the sloppy frequency value at which the score will be 0.5" (:92).
  • Lucene treats NaN as invalid. TopScoreDocCollector.java:30-31; the test framework's AssertingScorer asserts !Float.isNaN(score) (AssertingScorer.java:129).
  • Upstream's own oracle. TestFeatureField.doTestSimScorer (:279-287) asserts Float.isFinite(maxScore) // used to compute max scores and that the score never decreases with the value. It is run for SigmoidFunction(20f, 0.6f) (:275-277); run on (2^20, 60) and (1e16, 20) it fails with max score NaN.
  • Robustness was a goal. The code comment (FeatureField.java:528-530) says the formula was rewritten so the score does not decrease "in spite of rounding".
  • No limit or NaN in the recorded and archived material. No javadoc, test, CHANGES or MIGRATE entry or commit message at 645d4fe14a20, and no discussion the searches found, limits the exponent or presents NaN as intended. In the archived LUCENE-8197 review (#9244) reviewers asked for documented and validated parameter ranges, and the committed code has the (0, +Infinity) checks; no archived comment discusses pivot^exp overflowing.
  • Tests never reach the region. Every upstream sigmoid test uses exponent 0.6 (TestFeatureField) or 2 (TestIntervalQuery.java:439).
The devil's-advocate check156 words

The strongest case, built by the cross-model reviews and by the devil's-advocate verifier: FeatureField is @lucene.experimental, its constants "typically need training" (FeatureField.java:84), and exponents of 50 to 150 are unrealistic, so a committer could say the function was never meant for them.

Why it fails: @lucene.experimental is about API stability ("might change in incompatible ways in the next release"), not about returning invalid scores; the domain is documented as (0, +Infinity) and validated, so these inputs are legal; the authors capped the one parameter they found unreasonable (MAX_WEIGHT) but not the exponent; the javadoc describes high exponents as the way to make the function steeper; and an algebraically equal one-line rewrite removes the failure, at the price of one more division per document. The realistic-exponent point lowers the priority, not the reality, and maintainers may reasonably prefer validating the parameters (option B) to changing the formula. Verdict: not intended, a defect.

Suggested fix

Recommended Score as weight / (1 + (pivot / S)^a) (validated)

Score as weight / (1 + (pivot / S)^a): the same function without a precomputed power, so it cannot form Infinity/Infinity or 0/0. The repro passes, and so do the document and intervals test packages (499 and 117 tests); on a test grid, scores of at least 1e-6 * weight move by at most 1 ulp.

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

Option A was built and run through gradle in both copies. B (validate the exponent) is a possible addition that turns NaN into an error, C (document a range) leaves NaN, and D (guard in the collector) treats the symptom. Workaround without a fix: use exponents up to 8, which never fail. With a larger exponent keep pivot^exp well inside the double range on both sides: no overflow (exp * log2(pivot) well below 1024) and, for a pivot below 1, no underflow (pivot 2^-14 with exponent 100 already underflows).

Score as weight / (1 + (pivot / S)^a) (validated) (recommended)

Drop pivotPa from FeatureField.SigmoidFunction and IntervalScoreFunction.SigmoidFunction and compute the same function as weight / (1 + (pivot / S)^a) (2 files, +7/-10). For positive finite inputs pivot / S lies in [0, +Inf], so does its power, 1 + x lies in [1, +Inf] and the result in [0, weight]: no NaN. At S = pivot it is exactly weight / 2. It stays non-decreasing in S, the property the old comment protects: the division is monotone, Math.pow is specified as semi-monotonic, and 1 + x and w / x are monotone.

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

Trade-offs

  • Ordinary scores barely change, but not bit for bit. A copy of the old and new expressions in plain double arithmetic (not the real scorer) on 1,320,480 points (7 pivots from 0.01 to 1e6, exponents 0.5 to 8, weights 1 and 3, S from Float.MIN_NORMAL to 1e30 in 1% steps): 73% bit-identical; where the old score is at least 1e-6 * weight, at most 1 ulp apart. The largest absolute difference, 9.1e-13 (2^-40), is a 1-ulp difference at a score of about 1e-5 (weight 1, pivot 4.5, exponent 2, S 0.014), and every score above about 1e-5 is bit-identical. Where the old form rounds a tiny score to 0 by cancellation, the new form keeps it (at most 3.3e-16); that gives the largest ulp distance. The new form gave no NaN, nothing outside [0, weight] and no decreasing step on the grid. A ranking that depends on exact score values can still change, so it is worth a CHANGES.txt line.
  • Cost. One more division per scored document (two instead of one) and no precomputed constant; 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 (assertions on and off) and passes after it (-Ptests.asserts=false).
  • :lucene:core:test --tests 'org.apache.lucene.document.*': 60 classes, 499 tests, 26 skipped, 0 failures, including the upstream TestFeatureField (14 of 14).
  • :lucene:queries:test --tests 'org.apache.lucene.queries.intervals.*': 7 classes, 117 tests, 0 failures, including TestIntervalQuery (34) and TestIntervals (43); the study's interval test (pivot 4, exponent 600), which fails on unfixed main, passes.
  • Earlier checks of the same diff: the study's and the independent reproducer's tests pass with it; the exhaustive sweep of the score functions (6.8e9 checks) shows no sigmoid violation; the tie-break's ranking probe and boundary tests pass.

Reject parameters whose pivot^exp is not a finite positive double

In newSigmoidQuery and IntervalScoreFunction.sigmoidFunction, throw IllegalArgumentException when Math.pow(pivot, exp) is +Infinity or 0, or reject exponents above a documented bound.

Trade-offs

Turns silent NaN into an error that downstream engines would surface to the client. Leaves the finite band where S^a + pivot^a overflows (score weight instead of weight / 2) unless the bound is tighter, and rejects parameters option A handles. An exponent bound is a judgement call; none is documented today. Not run.

Document a usable exponent range

Say in the javadoc which exponents are safe for a given pivot.

Trade-offs

Leaves NaN scores for anyone who misses the note, and contradicts the documented (0, +Infinity) domain unless that changes too.

Guard against NaN in the collector

Make top-k collection tolerate NaN scores.

Trade-offs

TopScoreDocCollector already documents that it does not handle NaN; the related collector-side issue #3347 (LUCENE-2271) has been open since 2010. Fixing the producer is local and cheap.

Downstream note

Engines that pass a request's sigmoid pivot and exponent straight to newSigmoidQuery (Elasticsearch and OpenSearch rank_feature, Solr {!rank function=sigm}) let a search client trigger it, for its own scores and order only; matching, filters and deletions are unaffected. If an application itself fixes an underflowing pivot^exp, a document with a tiny feature value can rank first with NaN.

How it was found

An exhaustive run of every FeatureField score function on the real jar, 6.8e9 checks of properties such as "never NaN", found NaN only in the sigmoid. An SMT model of the final expression then showed NaN needs pivot^exp to overflow, or to underflow together with S^exp, and a JUnit test was written from those parameters.

The method in full206 words

Method: bounded-exhaustive translation validation of the real jar (the study's "semi-formal" tier: the first signal came from executing the stock code against exact properties).

  • A harness drove every FeatureField score function on the stock main jar over every integer frequency from 1 to 65,535 plus Integer.MAX_VALUE and Float.MAX_VALUE (for the sigmoid: 287 pivots covering every float binade, 21 exponents, 2 weights), about 6.8e9 checks against seven properties: no NaN or negative score, non-decreasing in the value, within the max-score bound, a finite max score, bulk scoring equals single scoring, explanation equals score, at most the weight. Only the sigmoid produced NaN (as a score and as its max score). The only other failures were +Infinity max scores of the linear and log functions at a feature value of Float.MAX_VALUE, which were not reported.
  • cvc5 queries over the final score expression (floating-point theory, the two powers modelled as independent non-negative doubles) then showed NaN is possible only if pivot^a = +Inf, or pivot^a = 0 and S^a = 0 (the "only if" query unsat, both vacuity variants sat), and a region probe mapped which accepted (pivot, exponent) pairs fail.
  • The JUnit repro was written by hand from those parameters; the one-test minimal repro came later.

Prior reports, history and checks

Prior reports

No prior report. GitHub issues and PRs, commits, the mailing lists, the JIRA archive, Elasticsearch, OpenSearch, Solr and the web were searched on 2026-10-26 and 2026-10-31. Closest: #13691, a fix to the sigmoid's explanation text, with no NaN.

Searches5 searches

Verdict: new. No open PR touches either SigmoidFunction, and upstream has not fixed it.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. migrated JIRA issues)2026-10-3140 tool queries (comments of the top 12 read) and 14 own searches, e.g. "sigmoid NaN", newSigmoidQuery, SigmoidFunction, "FeatureField sigmoid", pivotPa, IntervalScoreFunctionno report of this defect
Open and recently closed PRs2026-10-31file lists of all 408 open PRs and the 57 closed since 2026-10-20none touches SigmoidFunction or IntervalScoreFunction
Commits, downstream projects, mailing lists, JIRA, web2026-10-316 commit queries; Elasticsearch, OpenSearch and Solr (14); dev@, java-user@, issues@, commits@ (54); JIRA LUCENE and SOLR (14); web (14)nothing on this defect
Closest items (read)2026-10-31threads and diffs#13691 and OpenSearch#14921 (explain text printed the pivot as the exponent; no NaN); #3347 / LUCENE-2271 (open since 2010: NaN or -Infinity scores leave the collector's sentinel hits in results; context for the placeholder hits on old releases); #9244 / LUCENE-8197 and #9679 / LUCENE-8633 (the introducing changes)
Earlier search2026-10-26120 queries over the same sources29 hits read, none the same bug

History

Introduced with FeatureField itself in 7.4.0 (LUCENE-8197, 2018); the IntervalQuery copy came with LUCENE-8633 in 8.0.0. The arithmetic never changed: every release from 7.4.0 to 10.6.0 and main is affected.

Versions tested9 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 first SigmoidFunction already precomputed pivotPa, and newSigmoidQuery already accepted every finite positive pivot and exponent. Interval copy: a8266492416d / 18c3986cc4bd, "LUCENE-8633: Remove term weighting from IntervalQuery scores" by romseygeek, 2019-01-16 (now #9679), first in 8.0.0. Later commits (LUCENE-8313, the 2020 reformat, #13691, #15137, #15568, #15874) left the arithmetic unchanged.

What users see changed with the collector between 10.0.0 and 10.2.0 (#13939 and neighbours), not with the sigmoid.

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)
10.2.0 - 10.5.1 affectedevery hit returned, NaN scores ranked ahead of finite ones
10.1.0 hits lostonly 1 of 4 hits returned
7.4.0 - 10.0.0 affectedassertions off: placeholder hits (doc 2147483647, score -Infinity) in place of real ones; assertions on: AssertionError in TopScoreDocCollector
7.0.0 - 7.3.1 not affectedno FeatureField (compile error)
IntervalQuery copy: 8.0.0 - 10.6.0 and main affectedpivot 4, exponent 600: NaN; 61 releases
IntervalQuery copy: 7.7.3 and earlier not affectedno 4-argument constructor (compile error)
fix (both copies) not affectedthe minimal test and the interval test pass; 499 + 117 tests pass

Independent checks

Confirmed by an independent reproducer, a devil's-advocate verifier, a reviewer from another model family (OpenAI GPT-5.6), an adjudication, a credibility audit and a tie-break on main 645d4fe14a20. The only dissent was a procedural "not real" from a read-only review that could not run Java.

Checks and verdicts314 words

None of the checks found a flaw in the claim; the ones that could run code (the independent reproduction, the intended-behaviour check, the audit, the tie-break and this doc's runs) re-ran repros. The refute review could not execute anything, so its "not real" is procedural.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (exhaustive run, SMT, JUnit) candidateOverflow and underflow JUnit tests fail with assertions on and off on main and with assertions off on 10.5.2 and 9.12.3; the interval test (pivot 4, exponent 600) too.
Refute review (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run Java (twice, 2026-10-26 and 2026-10-31); agrees on the root cause, finds the inputs in contract and the intended-behaviour argument weak; the first review pointed out the IntervalQuery copy.
Independent reproducer realOwn tests: pivot 1000 with exponent 150 gives 4 of 4 NaN; pivot 1e-4 with exponent 100 gives 2 of 3 NaN with the largest feature value ranked last; control (exponent 100, pivot 1000) passes.
Intended-behaviour lens (OpenAI GPT-5.6, read-only) real (medium)The experimental-API argument loses to the documented domain, the 0..1 range and the NaN javadoc.
Devil's advocate realUpstream's own sim-scorer oracle fails with max score NaN on accepted steep parameters.
Adjudication confirmed (high)Silent wrong results.
Credibility audit keepRe-ran the study's tests with fresh seeds: 3 of 3 fail with assertions on and off.
Current main and tie-break real (high)On 645d4fe14a20, 10.5.2 and 10.6.0; boundary control: exponent 51 passes, 52 fails; ranking probe; the fix makes all of them pass.
This doc reproducedMinimal test re-run on main (fails, assertions on and off); gradle before and after the fix; old-vs-new formula comparison. An independent fact check re-ran the gradle commands before and after the fix with the same results and located the largest old-vs-new difference.

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

Finding record

LFV-258, snapshot of 2026-11-06

Registry title
FeatureField.newSigmoidQuery (and the IntervalQuery sigmoid) return NaN scores when pivot^exp overflows or underflows a double, for pivot and exp values the factories accept
Area
document / FeatureField sigmoid scoring (FeatureField.SigmoidFunction behind newSigmoidQuery; the same arithmetic is copied in queries / intervals IntervalScoreFunction.SigmoidFunction behind the 4-argument IntervalQuery constructor)
Priority
Low, rank 723: FeatureField sigmoid queries return NaN scores only for extreme pivot and exponent values, which is silent but boundary-value only.
Impact class
silent wrong results
Impact note
Applications on Lucene 7.4.0 through 10.5.2 or main that rank with a FeatureField sigmoid query (FeatureField.newSigmoidQuery, which downstream engines' rank_feature sigmoid and Solr's {!rank function=sigm} call with the request's pivot and exponent) and choose a steep exponent with a large or tiny pivot, so that pivot^exp leaves the double range (exp*log2(pivot) >= 1024, for example exp 50 with pivot >= 1.46e6 or exp 100 with pivot >= 1209, or pivot^exp below 2^-1074 together with small feature values; never for exp <= 8, and typical exponents are below 2), get NaN scores and no error at any index size with the default IndexWriterConfig, codec and searcher: on 10.2.0 and later the NaN hits come back ranked ahead of correctly scored documents (in the overflow case every hit is NaN, in doc-id order), on 7.4.0-10.0.0 TopDocs also holds placeholder hits (doc 2147483647, score -Infinity) in place of real ones and 10.1.0 returned 1 of 4 hits, with -ea a collector assertion fires instead on 7.4.0-10.0.0, and explain() reports NaN; the 4-argument IntervalQuery constructor has the same arithmetic but needs exponents in the hundreds (600 at pivot 4) because sloppy frequencies are small.
Summary
You rank documents with FeatureField.newSigmoidQuery (or IntervalQuery's pivot-and-exponent constructor) and pick a steep exponent with a large or tiny pivot, values the factory accepts because it only requires both to be positive and finite. You expect the documented score weight * S^exp / (S^exp + pivot^exp), a number between 0 and weight that equals weight/2 at S = pivot. Instead SigmoidFunction precomputes pivot^exp as a double, which becomes +Infinity or 0 for such parameters, so scores come out as Inf/Inf or 0/0 = NaN: with pivot 2^20 and exponent 60 every document scores NaN; since 10.2.0 NaN hits rank ahead of correctly scored ones, so the best document can come last, and on 9.x (assertions off) TopDocs even returns placeholder hits with doc id 2147483647.
Introduced
710993435f365fce44a60b7c498ce6af8327f92c (master) / 70cc7b68783b313a845996dac9e28c3ea6ad61e3 (branch_7x, the copy that shipped first, in 7.4.0): 'LUCENE-8197: Efficient integration of static scoring factors.' by jpountz, a JIRA patch with no pull request (the issue is now #9244). The commit created FeatureField; its SigmoidFunction already precomputed pivotPa = Math.pow(pivot, a) and scored (float) (weight * (1 - pivotPa / (Math.pow(f, a) + pivotPa))), and newSigmoidQuery already accepted every finite pivot > 0 and exp > 0, so no earlier version of the code was correct [read: git show of both commits; git blame -w on main puts FeatureField.java:495, :500, :531 and the checks at :700-705 on 710993435f3; git log --all -S 'pivotPa' finds no other commit that changes the expression; exec: the release boundary, 7.3.1 has no FeatureField and 7.4.0 fails]. IntervalQuery copy: a8266492416dbc2fcf665983d1cac141e50fa9eb (master) / 18c3986cc4bdb38949fae14bd771621345d41776 (branch_8x), 'LUCENE-8633: Remove term weighting from IntervalQuery scores' by romseygeek (now #9679), which added IntervalScoreFunction.SigmoidFunction with the same arithmetic (IntervalScoreFunction.java:112, :117, :128 on main, blamed to a8266492416) [read]. Later commits that touched SigmoidFunction (9b9776a7143 LUCENE-8313, 2d6ad2fee6d LUCENE-9570 reformatting, 0f5359a2ac6 #13691 explain text, f59ff9220ec #15137 bulk scorer) left the arithmetic unchanged [read]. (2018-03-20 (FeatureField, LUCENE-8197); 2019-01-16 (IntervalQuery copy, LUCENE-8633))
First affected version
7.4.0 [exec] (the first release with FeatureField; 7.3.1 does not have the API, the probe does not compile [exec]); IntervalQuery copy: 8.0.0 [exec] (in lucene-sandbox under org.apache.lucene.search.intervals; 7.7.3 has IntervalQuery but not the (field, source, pivot, exp) constructor [exec: compile error]; moved to lucene-queries in 8.2.0 by LUCENE-8856)
Affected versions
FeatureField copy: every lucene-core release from 7.4.0 to 10.5.2 (67 releases: 7.4.0-7.7.3, all 27 8.x, all 21 9.x and all 12 10.x up to 10.5.2) and main ea57360912ed [exec: history probe with -ea on all of them and -da on 7.4.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.5.2 and main; the finder's JUnit on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit on main, 10.5.2 and 9.12.3 (-ea and -da)]. IntervalQuery copy: every release from 8.0.0 to 10.5.2 (60 releases) and main [exec]. Not affected: 7.0.0-7.3.1 (API absent) [exec]. The NaN arithmetic never changed, but the visible symptom depends on the collector [exec, 4-document probe]: 7.4.0-10.0.0 with -ea, search() throws TopScoreDocCollector's AssertionError; 7.4.0-10.0.0 with -da, TopDocs holds placeholder hits (doc 2147483647, score -Infinity) in place of real documents; 10.1.0 returns only 1 of 4 hits; 10.2.0 to main return every hit, with NaN scores ranked ahead of finite ones; explain() reports NaN on every release. Not fixed on main, branch_10x or branch_10_5 [read, 2026-10-26]. All runs on aarch64 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
semi-formal: exhaustive check; also reached by cvc5
Upstream and prior reports
none found (2026-10-26, the prior-report record: 120 recorded queries, namely 34 GitHub issue/PR searches plus 3 REST reads in apache/lucene, 3 commit-message searches, 5 downstream searches (Elasticsearch, OpenSearch, Solr), 52 mailing-list searches over dev@, java-user@, issues@ and commits@, 13 JIRA queries over LUCENE and SOLR, 8 web searches, 1 Q&A API read and a local history search; plus the changed-file lists of all 413 open apache/lucene PRs; 29 hits read and classified, none the same bug; the finder's earlier 45-query run, the independent reproduction and the intended-behaviour check found nothing either); not fixed upstream (main ea57360912ed, branch_10x and branch_10_5 still compute pivotPa / (Math.pow(f, a) + pivotPa), and no open PR touches FeatureField.java or IntervalScoreFunction.java); closest items, not reports: #13691 (merged 2024-08-27, fixed only the explain text of SigmoidFunction, which printed the pivot where the exponent belongs) and the downstream report behind it, opensearch-project/OpenSearch#14921 (no NaN); #9244 / LUCENE-8197 and #9679 / LUCENE-8633 (the introducing changes; their reviews asked for documented parameter ranges but never discussed pivot^exp overflowing)
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 (499) and the org.apache.lucene.queries.intervals tests (117). Release jars 10.5.2 and 10.6.0 fail; a port of the test fails on every release from 7.4.0. 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:74-77 (at ea57360912ed) (class javadoc: sigm(S) = w * S^a / (S^a + k^a) with k > 0, a > 0, 'values are in the 0..1 range and 0.5 is obtained when S and k are equal'), :104 (@lucene.experimental), :492-501 (SigmoidFunction; :500 pivotPa = Math.pow(pivot, a)), :527-532 (doScore; :531 (float) (weight * (1 - pivotPa / (Math.pow(f, a) + pivotPa))) and the comment on rounding), :540-550 (asBulkSimScorer; :547 calls the same doScore), :576-582 (MAX_WEIGHT cap and its rationale), :680-693 (newSigmoidQuery javadoc: the formula, pivot and exp 'must be in (0, +Infinity)'), :700-705 (the only checks on pivot and exp: <= 0 or not finite)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScoreFunction.java:33-41 (at ea57360912ed) (sigmoidFunction checks), :109-131 (SigmoidFunction; :117 pivotPa = Math.pow(pivot, a), :128 the same expression); IntervalQuery.java:56 ('Scores are bounded to between 0 and 1'), :87-99 (4-argument constructor javadoc: pivot 'must be within (0, +Infinity)', exp 'must be in (0, +Infinity)')
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31 (at ea57360912ed) ('The values Float#NaN and Float#NEGATIVE_INFINITY are not valid scores. This collector will not properly collect hits with such scores'); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:129 (assert !Float.isNaN(score)); lucene/core/src/test/org/apache/lucene/document/TestFeatureField.java:275-287 (testSigmSimScorer with pivot 20, exp 0.6; doTestSimScorer asserts a finite max score and monotonicity)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java (:74-77, :104, :492-501, :527-532, :540-550, :556-558, :576-582, :680-711)
  • lucene/core/src/java/org/apache/lucene/document/FeatureQuery.java (:135-137)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalScoreFunction.java (:33-41, :109-131); lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java (:56, :87-99)
Who is affected
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java (:84, :104, :689-690)
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java (:30-31)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/document/FeatureField.java (:76-77, :84, :104, :528-530, :576-582, :680-705)
  • lucene/queries/src/java/org/apache/lucene/queries/intervals/IntervalQuery.java (:56, :92); lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java (:30-31); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java (:129)
  • lucene/core/src/test/org/apache/lucene/document/TestFeatureField.java (:160, :240-258, :275-287, :445, :462, :578); lucene/queries/src/test/org/apache/lucene/queries/intervals/TestIntervalQuery.java (:439)
Suggested fix
  • registry row LFV-258 notes

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 (the minimal test, the boundary at exponents 51 and 52, the ranking probe).

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.