FunctionRangeQuery scores a match with its raw value, so -Infinity scores -3.4e38. Top-k search of a "match at least 2 of these" query checks each document's score against a threshold that starts at 0, and drops this one.
- FunctionRangeQuery. It matches the documents whose value (here from the numeric field
d) lies in a range, and scores each match with that value. An open range,nulltonull, matches every document that has a value, -Infinity included. - No negative scores. Since Lucene 8.0 (LUCENE-7996) a query must never produce a negative score.
FunctionQuery, which scores every document with the same kind of value, returns 0 for a negative value. - minimumShouldMatch and WANDScorer. For top-k search of a
BooleanQueryof optional clauses that needs at least 2 of them, as here, Lucene usesWANDScorer. It skips documents that cannot beat the lowest score still worth collecting, a threshold that starts at 0 and relies on scores never being negative.
FunctionRangeQuery scores doc 0 at -3.4e38, the "at least 2 of" top-k check against the threshold 0 drops it, and with the clamp doc 0 scores 0.315 and is returned. Select the still to open it at full size. Generated with AI, checked against the code.- Index two documents: doc 0 with
d = -Infinity(aDoubleDocValuesField) andbody: a, doc 1 withd = 1.0andbody: b. Search "at least 2 of"FunctionRangeQuery(d, open range),body:aandbody:bwithIndexSearcher.search(q, 10). - You expect 2 hits: each document matches two of the three clauses, and
count(q)says 2. Scores must not be negative since Lucene 8.0;FunctionQueryover the same field scores doc 0 as 0. - Instead you get 1 hit, doc 1 (score 1.315), and
totalHits"1 hits", an exact count. No error with assertions off; with assertions on,WANDScorerthrowsAssertionError. - Why:
FunctionRangeQueryscores doc 0 with its raw value, -Infinity, mapped to -3.4e38. For "at least 2 of" top-k,WANDScorerrejects a document whose score is below the lowest score worth collecting, which starts at 0. A value such as -5 rounds to 0 in its scaled arithmetic and survives; -3.4e38 does not. - Clamping the score at 0, as
FunctionQuerydoes, fixes it: 2 hits, doc 0 scores 0.315 (itsbody:ascore). Negative scores exist since 8.0.0; the lost hit since 10.1.0, and this two-document shape since 10.5.0.
Applications that rank with FunctionRangeQuery as a scoring clause over values that can be negative get negative scores (since 8.0.0). Hits are lost (10.1.0 on) only with a stored -Infinity or a summed score at or below about -4e31, a range that admits it, and the query as a SHOULD clause of a minimumNumberShouldMatch >= 2 query that reaches WANDScorer.
Mechanism, terms and numbers779 words
FunctionRangeQuery (lucene/queries) matches documents whose function value lies in a range and scores each match with the value itself: ValueSourceScorer.score() returns floatVal(doc) and maps NaN and -Infinity to -Float.MAX_VALUE. LUCENE-7996 (8.0.0) made negative scores illegal and changed FunctionQuery's identical code to return 0, but did not touch ValueSourceScorer, whose comment still says "(same as FunctionQuery, but no qWeight)". So negative values give negative scores in TopDocs and explain. Since 10.1.0 it also loses hits: top-k search of a pure-SHOULD BooleanQuery with minimumNumberShouldMatch >= 2 runs on WANDScorer, which rejects a candidate whose scaled score is below the minimum competitive score of 0. An ordinary negative score rounds to 0 there and survives; -Float.MAX_VALUE does not, so a matching document whose value is -Infinity is missing from search(q, n), whose total (exact here, with fewer than 1,000 matches) is too low while count(q) is right. Two documents and the default IndexWriterConfig, codec and IndexSearcher are enough. A one-line clamp fixes it.
- The score is the raw value.
ValueSourceScorer.java:84-92:float score = values.floatVal(disi.docID());thenreturn score > Float.NEGATIVE_INFINITY ? score : -Float.MAX_VALUE;with the comments "(same as FunctionQuery, but no qWeight)" and "Current Lucene priority queues can't handle NaN and -Infinity, so map to -Float.MAX_VALUE".getMaxScorereturnsFloat.POSITIVE_INFINITY(:94-97). FunctionQueryclamps.FunctionQuery.java:130-137:if (val >= 0 == false) { // this covers NaN as well ...returns 0 (:132).- Matching is separate and correct.
FunctionRangeQuery.java:168-174builds the scorer withFunctionValues.getRangeScorer; for a double field that isDoubleDocValues.java:87-147, where an open lower bound becomesDouble.NEGATIVE_INFINITY(:95-96) and the match test isdocVal >= l && docVal <= u(:116). So -Infinity matches, NaN never does. - Routing. For this pure-SHOULD query under
ScoreMode.TOP_SCORESwithminShouldMatch > 1,BooleanScorerSupplier.java:315-322declines the bulk scorers ("Fall back to BS2/WANDScorer") and:589-590builds aWANDScorer.IndexSearcher.search(q, n)is TOP_SCORES (IndexSearcher.java:100,:599;TopScoreDocCollector.java:58). - Scaling.
WANDScorercompares scores as scaled longs. The scale comes from the sum of the clauses' max scores (WANDScorer.java:186-193); the range clause's +Infinity givesscalingFactor(+Infinity) = scalingFactor(MAX_VALUE) - 1(:66,:74-75), that is 2^-105, and scaled scores are rounded up (:104). - The check.
WANDScorer.java:333-344scales the summed score of the clauses that match the candidate and loopswhile (scaledLeadScore < minCompetitiveScore || freq < minShouldMatch), returningfalseoncescaledLeadScore + tailMaxScore < minCompetitiveScore; the threshold starts at 0 (:163). With assertions on,scaleMaxScoretripsassert maxScore >= 0(:92) first.
Doc 0 scores -3.4028235E38 + 0.31506687 (body:a). Scaled by 2^-105 that is about -8.4 million, far below 0, so WANDScorer rejects it. A score of -5 scales to about -1e-31, which rounds up to 0, so it survives with assertions off: the -5.0 control document is returned in every run that completes, 30 of 33 (score -4.837875 in the four-document check on 10.5.0); the three stock runs with assertions on throw at WANDScorer.java:92 first and return nothing. The boundary is a summed (lead) score of about -2^105 (about -4.06e31): a finite value of -1e32 is dropped and -1e31 is kept.
An exhaustive search, TopScoreDocCollectorManager(10, Integer.MAX_VALUE) (COMPLETE, TopScoreDocCollector.java:58-59), returns both documents: doc 1 = 1.3150668, doc 0 = -3.4028235E38. On this two-document index it does not run on WANDScorer: with minShouldMatch > 1 the cost threshold is maxDoc / 3 = 0, which the clauses meet (BooleanScorerSupplier.java:212-230), and optionalBulkScorer() declines only under TOP_SCORES (:315-322), so it returns a BooleanScorer (:331-337), which has no minimum-competitive-score check (an executed probe prints BooleanScorer for COMPLETE and WANDScorer for TOP_SCORES). A COMPLETE search that does reach WANDScorer also skips the check (WANDScorer.java:194-196, :334). explain gives doc 0 (abridged) "-3.4028235E38 = sum of: -3.4028235E38 = frange(double(d)):[* TO *] (-Infinity = double(d)=-Infinity), 0.31506687 = weight(body:a in 0)". FunctionQuery(d) alone scores doc 0 as 0.0.
At scale: 20,000 documents in 8 segments, 574 with value -Infinity; the same query shape returned 11,532 of 11,893 matches, reported as exactly "11532 hits", and searchAfter paging skipped the same 361 documents, all valued -Infinity (main and 10.5.2).
One document with d = -5.0 and FunctionRangeQuery(d, [-10, 10]): the score is -5.0 in TopDocs and explain, where FunctionQuery(d) scores 0.0. In a disjunction, a document that matches tag:x and the range clause scores -4.9171267, below its tag:x-only score 0.082873434. LuceneTestCase.newSearcher rejects the score (AssertingScorer.java:131), and on 8.0.0 to 10.0.0 a top-k search with assertions on throws in core TopScoreDocCollector.
Exposure and likelihood366 words
- Who. Users of
FunctionRangeQuery(an@lucene.experimentalclass) as a scoring clause, not in a FILTER clause or aConstantScoreQuery, over a value source that can be negative. Solr'sfrangeuses its own constant-scoring query; Elasticsearch and OpenSearch do not referenceFunctionRangeQuery,getRangeScorerorValueSourceScorer(read-only code search, 2026-10-26). - Negative scores (8.0.0 on). Negative scores in TopDocs and explain; matching the range clause can lower a document's score below that of a document that matches only the other clause; Lucene's own asserting test searcher rejects the score, so downstream test suites that use it can fail; on 8.0.0 to 10.0.0 any top-k search with assertions on throws in core
TopScoreDocCollector. - Lost hits (10.1.0 on). Silent wrong results: the -Infinity document is missing from top-k and from
searchAfterpaging, and an exacttotalHitsis too low whilecount()is right. No exception with assertions off. On 10.1.0 to 10.4.0 only sparse SHOULD clauses or a query nested in a conjunction reachWANDScorer; since 10.5.0 dense top-level queries such as the two-document repro do too. - What it takes. A stored -Infinity (for example the log of 0), or a finite value low enough that the document's summed score is at or below about -2^105 (about -4e31; in the tested shapes a field value of about that size); a range that admits it (an open or -Infinity lower bound; a lower bound such as -10 excludes -Infinity); and the scored query as a SHOULD clause of a
minimumNumberShouldMatch >= 2query that reachesWANDScorer(shown for the pure-SHOULD, sparse and nested shapes). NaN cannot trigger it: it fails every range test. - What it does not do. Which documents match never changes:
count(), FILTER, MUST_NOT andConstantScoreQueryuses were correct in all 17 probe shapes, and in the probe every omitted result was a document valued -Infinity; no document with a non-negative score was omitted. A -Infinity document scores about -3.4e38 (near the lowest finite float), so it would have ranked at the end; with the default 1,000-hit counting threshold it shows in short result lists, in an exact total, or when paging to the end. - How likely: contrived. Three unusual conditions must hold at once.
One JUnit test, two documents, default codec; indexing, query and search use public production APIs (the test uses Lucene's test framework): doc 0 has d = -Infinity and body:a, doc 1 has d = 1.0 and body:b; the query needs 2 of FunctionRangeQuery(d, open range), body:a, body:b, and search(q, 10) must return all count(q) matches.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.queries.function;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.DoubleDocValuesField;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.queries.function.valuesource.DoubleFieldSource;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestFunctionRangeQueryNegativeScoreHitLoss extends LuceneTestCase {
public void testMinShouldMatchKeepsNegativeInfinityDoc() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w =
new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
Document doc0 = new Document();
doc0.add(new DoubleDocValuesField("d", Double.NEGATIVE_INFINITY));
doc0.add(new TextField("body", "a", Field.Store.NO));
w.addDocument(doc0);
Document doc1 = new Document();
doc1.add(new DoubleDocValuesField("d", 1.0));
doc1.add(new TextField("body", "b", Field.Store.NO));
w.addDocument(doc1);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query query =
new BooleanQuery.Builder()
.add(
new FunctionRangeQuery(
new DoubleFieldSource("d"), (Number) null, (Number) null, true, true),
BooleanClause.Occur.SHOULD)
.add(new TermQuery(new Term("body", "a")), BooleanClause.Occur.SHOULD)
.add(new TermQuery(new Term("body", "b")), BooleanClause.Occur.SHOULD)
.setMinimumNumberShouldMatch(2)
.build();
// expected: all count(q) = 2 matches; actual: 1, doc 0 (FunctionRangeQuery score < 0) is lost
assertEquals(
"hits of search(q, 10)",
searcher.count(query),
searcher.search(query, 10).scoreDocs.length);
}
}
}
}
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionRangeQueryNegativeScoreHitLoss -Ptests.seed=DEADBEEF -Ptests.asserts=false
- Expected
search(q, 10)returns both matching documents, as many ascount(q)= 2.- Observed
- 1 hit, doc 1:
AssertionError: hits of search(q, 10) expected:<2> but was:<1>; with assertions on,AssertionErroratWANDScorer.java:92.
More files, environment, full output342 words
ByteBuffersDirectory, the default codec pinned so the seed cannot pick another one, a plain IndexSearcher that runs the one leaf on the calling thread; nothing random. Three SHOULD clauses are needed, because with two, "at least 2" is a plain conjunction and WANDScorer is not used; the open lower bound is needed, because with a lower bound of -10 the -Infinity document does not match. The test asserts only the lost hit; the negative score is the cause, shown by the assertion that fires with assertions on and by the fix making the test pass.
Release jars: the test fails on 10.5.0 and 10.6.0 (assertions on and off) and passes on 10.4.0, where this dense shape still ran on BooleanScorer; on 10.0.0 and 9.12.3 it passes with assertions off and, with assertions on, hits the old core negative-score check in TopScoreDocCollector.
Regression test: this same test; it passes with the recommended fix and fits as one more method of the existing TestFunctionRangeQuery.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; with the fix: passes. Releases 10.5.0 and 10.6.0: fail; 10.4.0: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; the code path is pure Java) |
cp $REPRO/TestFunctionRangeQueryNegativeScoreHitLoss.java $LUCENE/lucene/queries/src/test/org/apache/lucene/queries/function/
cd $LUCENE && ./gradlew :lucene:queries:test --tests TestFunctionRangeQueryNegativeScoreHitLoss -Ptests.seed=DEADBEEF -Ptests.asserts=false
# assertions on (-Ptests.asserts=true) fails earlier, with AssertionError at WANDScorer.scaleMaxScore
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=false (stack trimmed)
TestFunctionRangeQueryNegativeScoreHitLoss > testMinShouldMatchKeepsNegativeInfinityDoc FAILED
java.lang.AssertionError: hits of search(q, 10) expected:<2> but was:<1>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D7A0144D2DA9FECF]:0)
at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:647)
at org.apache.lucene.queries.function.TestFunctionRangeQueryNegativeScoreHitLoss.testMinShouldMatchKeepsNegativeInfinityDoc(TestFunctionRangeQueryNegativeScoreHitLoss.java:66)
# the same with -Ptests.asserts=true
TestFunctionRangeQueryNegativeScoreHitLoss > testMinShouldMatchKeepsNegativeInfinityDoc FAILED
java.lang.AssertionError
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D7A0144D2DA9FECF]:0)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.WANDScorer.scaleMaxScore(WANDScorer.java:92)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.WANDScorer$2.matches(WANDScorer.java:336)
at org.apache.lucene.core@11.0.0-SNAPSHOT/org.apache.lucene.search.Weight$DefaultBulkScorer.scoreTwoPhaseIterator(Weight.java:311)
# the numbers behind it (a main() probe on the same two documents, not part of the repro)
query: (frange(double(d)):[* TO *] body:a body:b)~2
count(q) = 2
search(q, 10): totalHits=1 hits hits: doc1=1.3150668
exhaustive (totalHitsThreshold MAX_VALUE): totalHits=2 hits hits: doc1=1.3150668 doc0=-3.4028235E38
frange alone, search(frange, 10): totalHits=2 hits hits: doc1=1.0 doc0=-3.4028235E38
FunctionQuery(d) alone, search(fq, 10): totalHits=2 hits hits: doc1=1.0 doc0=0.0
# with the clamp: search(q, 10): totalHits=2 hits hits: doc1=1.3150668 doc0=0.31506687- Since Lucene 8.0 "Scorers are no longer allowed to produce negative scores" (MIGRATE.md); the test framework and
WANDScorerstill assert it. ValueSourceScorersays it scores "same as FunctionQuery"; LUCENE-7996 changedFunctionQuery's identical code to return 0 but did not touch this class.count(q)says 2 andsearch(q, 10)says exactly 1: nothing documents that top-k may drop a match.
The full argument166 words
- The contract. MIGRATE.md:1368-1372 ("Scorer must produce positive scores (LUCENE-7996)": "
Scorers are no longer allowed to produce negative scores"); CHANGES.txt:6636 (8.0.0, API Changes: "Queries are now required to produce positive scores"); CHANGES.txt:6732-6733 ("FunctionQuery and FunctionScoreQuery now return a score of 0 when the function produces a negative value"). On main the test framework enforces it (AssertingScorer.java:131assert Float.compare(score, 0f) >= 0,AssertingScorable.java:36). - Core relies on it.
WANDScorerassertsmaxScore >= 0(WANDScorer.java:92) and throws "Scores must be positive or null" for a negative max score (:70-71). - The class says it mirrors
FunctionQuery.ValueSourceScorer.java:86"(same as FunctionQuery, but no qWeight)" and:28"This is similar to FunctionQuery".a8a63464e7d(LUCENE-7996) replacedFunctionQuery's identical-Float.MAX_VALUEmapping with the 0 clamp;ValueSourceScorerandFunctionRangeQueryare not among its 22 files. - Two APIs disagree on the match set.
count(q)= 2,search(q, 10)= exactly 1 hit. - Precedent. Negative scores in other code were fixed as bugs: #12727, #16139, #12342, #11184.
The devil's-advocate check159 words
The strongest case: FunctionRangeQuery.java:33 says "The score is the float value", and ValueSourceScorer.java:26 and FunctionValues.java:224-227 say the same, all @lucene.experimental. Scorable.score() states no sign, and since #13939 (10.1.0) core TopScoreDocCollector no longer asserts score >= 0. A second argument: Lucene tolerates negative scores from IndriDirichletSimilarity, which its similarity test base exempts.
Why it fails: those javadocs come from LUCENE-6720 (2015), two years before the rule, and LUCENE-7996 changed FunctionQuery's identical raw-value code. #13939 moved the assertion into the test framework (AssertingScorable); it did not drop it. The LUCENE-7996 discussion (#9044, 19 comments) never mentions this class, so it looks missed rather than exempted; that is an inference from silence, nobody said so. Even if a maintainer preferred to document negative scores, WANDScorer would still lose the hit, so a javadoc change alone cannot fix it. The Indri exemption is local to that class and is itself the cause of another hit-loss bug (LFV-190).
Recommended Clamp the score at 0 in ValueSourceScorer (validated)
Clamp the score at 0 in ValueSourceScorer.score(), as FunctionQuery does: return score > 0 ? score : 0f;, and update the javadocs that say the score is the raw value. The repro passes with assertions on and off, and so do the closest suites and the whole queries module (512 tests).
// Scores must not be negative (LUCENE-7996): map negative values, -0.0 and NaN to 0. This
// conditional handles NaN since comparisons with NaN are always false.
return score > 0 ? score : 0f;All options and trade-offs4 options · 580 words
Option A was built and run through gradle. B (document negative scores) would leave the lost hit, C (clamp only inside FunctionRangeQuery) is a narrower variant of A, and D (make WANDScorer tolerate negative scores) works against the documented rule. Workarounds that need no fix: put a FunctionRangeQuery that only restricts results in a FILTER clause or a ConstantScoreQuery, use a finite lower bound where documents valued -Infinity need not match, or reject non-finite values at index time.
ValueSourceScorer.score() (ValueSourceScorer.java:91) returns the value only when it is positive, else 0, the rule FunctionQuery applies. The same change updates the javadocs: "The score is the float value, or 0 if it is negative or NaN" in FunctionRangeQuery, a sentence in the ValueSourceScorer class javadoc, and "or 0 where that is negative or NaN" in FunctionValues.getScorer and getRangeScorer (3 files, +12/-10).
// Scores must not be negative (LUCENE-7996): map negative values, -0.0 and NaN to 0. This
// conditional handles NaN since comparisons with NaN are always false.
return score > 0 ? score : 0f;Trade-offs
- Behaviour change. Documents with negative values now tie at 0 instead of being ordered by their negative value; users who need that order should sort by the value. Every
ValueSourceScorerthat does not overridescore()changes the same way: thegetRangeScorerimplementations inFunctionValues,DoubleDocValues,LongDocValues,IntDocValues,DocTermsIndexDocValuesandEnumFieldSource,FunctionValues.getScorer, and downstream subclasses. Worth a CHANGES.txt entry. - -0.0. It becomes 0 here;
FunctionQuerylets-0.0through, which is the separate LFV-273. - Matching unchanged.
matches()is separate, socount(), filters andConstantScoreQueryuses do not change. - Cost. One comparison per scored document; no benchmark was run.
What the checks validated
Applied to a scratch worktree of 645d4fe14a20 and run through gradle with OpenJDK 25.0.3, seed DEADBEEF (2026-10-31; the worktree was removed afterwards):
- stock: the minimal test fails with assertions on and off;
- with the fix: the minimal test passes with assertions on and off;
- the closest suites
TestFunctionRangeQuery,TestValueSources,TestFunctionQuerySort,TestFunctionQueryExplanations,TestDocValuesFieldSourcesandTestFieldScoreQuerypass (64 tests, 0 failures); - the whole
:lucene:queries:testpasses (51 classes, 512 tests, 0 failures, 5 skipped), and so do:lucene:expressions:test(170 tests) and:lucene:spatial-extras:test(117 tests, 5 skipped); no module outsidelucene/queriesusesFunctionRangeQuery,ValueSourceScorerorgetRangeScorer; checkGoogleJavaFormat,ecjLintMain,forbiddenApisMainandrenderJavadocfor queries pass.- Earlier, the code-only clamp made the finder's negative-score test, an independent reproducer's tests, the 255-check security probe and all three follow-up shapes pass.
Change no code; say in the javadocs that FunctionRangeQuery may return negative scores.
Trade-offs
Matches today's "The score is the float value" wording, but breaks the global rule, the test framework keeps rejecting the score, and WANDScorer keeps losing the hit. Not run.
Wrap the scorer that FunctionRangeWeight.scorerSupplier returns (FunctionRangeQuery.java:168-174) so that it clamps, and leave ValueSourceScorer as it is.
Trade-offs
Narrower: other users of getRangeScorer/getScorer keep raw scores, but a scorer that can return negative scores stays in the API, and it is more code than the first option. Not run.
For example clamp the scaled lead score at 0 in WANDScorer.matches().
Trade-offs
Hides this symptom on one path only, against the documented rule, in a hot core loop; other scorers and collectors may make the same assumption. Not run.
Where end users write the value of a numeric field that an application ranks with a scored FunctionRangeQuery inside a minimumShouldMatch query, a stored -Infinity hides only that user's own document; no other, non-matching, deleted or filtered document is affected.
A hunt unit ran cross-path checks of FunctionRangeQuery under Lucene's asserting test searcher, which rejected a negative score. Reading the code and history traced it to LUCENE-7996, and a public-API JUnit confirmed it. A later randomized security probe of 17 query shapes found the lost hits in the minimumShouldMatch path.
The method in full135 words
Method: non-formal (the hunt's tier for findings from directed testing and code reading). The hunt unit compared range-bound behaviour across query paths.
- Assertion. Its cross-path
FunctionRangeQuerychecks ran under the test framework's asserting searcher, andAssertingScorerrejected a negative score. - Trace. Code and history reading led to LUCENE-7996 (
a8a63464e7d), which clampedFunctionQuery,BoostedQueryandFunctionScoreQuerybut notValueSourceScorer; a public-API JUnit on the stock jar (one document,d = -5.0) failed with assertions on and off. - Hit loss. After the verifier panel, the follow-up's security screen ran a randomized probe (20,000 documents, 17 query shapes, 255 checks) and found the
minimumShouldMatch >= 2hit loss (361 missing documents, all valued -Infinity). Three minimal checks then mapped it across releases, and a 2026-10-29 minimization cut it to the two-document test above.
No prior report found: 217 logged searches on 2026-10-26 (GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream code, the web) and a re-check of GitHub on 2026-10-31. Closest: #9044, the LUCENE-7996 discussion, which never mentions this class.
Searches3 searches
- #9044 (LUCENE-7996, the rule; none of its 19 comments mentions
FunctionRangeQuery,ValueSourceScorerorgetRangeScorer), #13939 (moved the coreTopScoreDocCollectorscore >= 0assertion into the test framework), #3347 (LUCENE-2271, open since 2010: -Infinity and NaN function scores breakTopScoreDocCollector; background to the-Float.MAX_VALUEmapping, which Solr'sFunctionQueryadded in 2009 with SOLR-1111,a66ef33fb18), #7778 (LUCENE-6720, addedFunctionRangeQuery), #14021 and #16176 (theWANDScorerchanges behind the hit loss; neither mentions negative scores or function queries). - Open PR #16847 (long doc ids) touches only the
ValueSourceScorerconstructor; it may conflict with a fix at merge time. - Precedents: #11184, #12342, #12727, #16139 fixed negative scores in other code.
- Same family, the hunt's own findings (different fix sites): LFV-273, LFV-203, LFV-083. Related but registered as known (partial overlap with existing upstream reports, so no own issue): LFV-190.
- Limits: GitHub's search rate limit was saturated on 2026-10-26 (GraphQL used instead); private lists and private security reports could not be searched.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive; apache/solr, apache/lucene-solr, Elasticsearch, OpenSearch; all of GitHub) | 2026-10-26 | the scripted prior-report search (16 issue and PR queries, 3 commit queries) and 97 GraphQL searches (class and method names, negative score, -Float.MAX_VALUE, LUCENE-7996 follow-ups), plus the changed-file lists of all 411 open apache/lucene PRs; for the hit loss 6 more searches (WANDScorer negative score, minShouldMatch negative score missing, scaleMaxScore assertion negative, WANDScorer -Float.MAX_VALUE, minimumShouldMatch FunctionRangeQuery) | 20 hits read, none the same bug; the hit-loss searches returned 0 hits |
| JIRA archive (LUCENE, SOLR), mailing lists (dev@, java-user@, issues@, commits@, builds@ lucene; Solr lists), downstream code, web | 2026-10-26 | 18 JIRA queries, 44 list queries, 5 downstream code queries, 15 web searches, 19 reads | no report; only notification mails of the items above; Elasticsearch and OpenSearch do not reference FunctionRangeQuery |
| GitHub issues and PRs (apache/lucene) | 2026-10-31 | issues updated since 2026-10-20 for FunctionRangeQuery, ValueSourceScorer, negative score, WANDScorer negative, frange negative, minimumShouldMatch negative; PRs for ValueSourceScorer, FunctionRangeQuery, negative scores | 0 results |
Introduced in 8.0.0 by LUCENE-7996 (a8a63464e7d, 2017), which forbade negative scores but did not clamp this scorer. The hit loss arrived with #14021 (10.1.0) and widened to dense top-level queries with #16176 (10.5.0). Unchanged on main, branch_11x and 10.6.0.
Versions tested7 rows
Introduced by a8a63464e7da63b3dbc884634fd0e00b3f0c140b, LUCENE-7996 "Queries are now required to produce positive scores." (Adrien Grand, 2017-12-06; a JIRA patch, now GitHub issue #9044; first release 8.0.0). It made negative scores illegal and clamped FunctionQuery, BoostedQuery and FunctionScoreQuery, but not ValueSourceScorer. score() itself dates from LUCENE-6720 (13f8ab93a97 / 88d137632d3, 2015, release 5.4.0), when negative scores were legal and FunctionQuery scored the same way.
The hit loss needs WANDScorer's per-candidate score check from #14021 "Make WANDScorer compute scores on the fly" (Adrien Grand, 2024-11-26; main d9aa525c9ea, release line 3f620dcd34f, first released in 10.1.0); before it, matches() compared the sum of max scores. #16176 "Restore WANDScorer for TOP_SCORES + minShouldMatch > 1" (Tianxiao Wei, 2026-06-03; main 135b348b370, release line 4f720f944b8, first released in 10.5.0) sent dense top-level queries, which 10.1.0 to 10.4.0 ran on BooleanScorer, to WANDScorer too.
#13939 (10.1.0) moved the score >= 0 assertion from core TopScoreDocCollector into the test framework, so since 10.1.0 a plain search with assertions on no longer throws in the collector on a negative score (WANDScorer still asserts on the scores it scales).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions off (1 of 2 hits) and on (AssertionError at WANDScorer.java:92), gradle and standalone runner; passes with the fix |
| 10.5.0, 10.6.0 | affected | minimal test fails, assertions on and off (release jars) |
| 10.5.2 | affected | dense, sparse and nested checks lose the hit; the security probe misses 361 -Infinity documents |
| 10.1.0, 10.4.0 | sparse and nested shapes | the sparse and nested checks lose the hit; the dense four-document check passes on both (BooleanScorer), and the minimal test on 10.4.0 |
| 8.0.0, 8.11.4, 9.12.3, 10.0.0 | negative score only | score -5.0 where FunctionQuery gives 0; no hit loss in the tested shapes; with assertions on, AssertionError in core TopScoreDocCollector |
| 7.7.3 | before the rule | FunctionQuery and FunctionRangeQuery both score -5.0, legal at the time |
upstream main 2f424fd428da, branch_11x 603af870375, branch_10_6 7e5aec4370e | same code | the same score() line and WANDScorer checks as 645d4fe14a20 (read, not run) |
Confirmed by an independent reproducer, a devil's-advocate verifier, a reviewer from another model family (OpenAI GPT-5.6) and an adjudication; the hit loss, found after that panel, was re-run by a credibility audit, the minimization and this doc's gradle run. The only dissent was a procedural "not real" from a read-only review.
Checks and verdicts358 words
The verifier panel (the independent reproduction, the cross-model review, the intended-behaviour check, adjudication) reviewed the negative score; the hit loss was found later by the follow-up's security probe and re-checked by the audit, the minimal checks and this doc, not by the panel.
| Check | Verdict | What it found |
|---|---|---|
| Finder (cross-path checks, then JUnit) | candidate | AssertingScorer rejected a negative score; the public-API JUnit (d = -5.0) fails 1/1 on main with assertions on and off and on 10.5.2. |
| Refute review (OpenAI GPT-5.6, read-only) | not real (procedural, low) | Could not run Java; agrees on the root cause and rates the "The score is the float value" argument strong. A separate run re-ran the repro: fails 1/1. |
| Independent reproducer | real | Own 5-test class: 3 of 5 fail on main and 10.5.2 (assertions on and off), 4 of 5 on 10.0.0 and 9.12.3 with assertions on; controls pass; no hit loss in its shapes. |
| Works-as-designed lens (OpenAI GPT-5.6, read-only) | real (medium) | A separate run re-ran the repro on main (assertions on and off), 10.0.0 and 10.1.0: fails each time. |
| Intended-behaviour devil's advocate | real (medium-high) | The javadoc, Scorable, collector, CHANGES/MIGRATE and Indri arguments each fail; newSearcher rejects the score on main. |
| Adjudication | confirmed (medium), class minor | Re-ran the finder's repro and the independent reproduction's tests; rated the impact before the hit loss was known. |
| Follow-up security probe | not security-sensitive; found the hit loss | 20,000 documents, 17 shapes, 255 checks: one shape misses 361 -Infinity documents on main and 10.5.2; every match set is right; 0 failures with the fix. |
| Follow-up minimal checks and credibility audit | confirmed; likelihood downgraded to contrived | 33 runs over 3 shapes, 12 releases and main; the audit re-ran the finder test and two shapes on main and executed -1e32 (dropped) and -1e31 (kept). |
| Minimal repro | verified | The two-document test fails on 645d4fe14a20 (two seeds, assertions on and off); the clamp passes. |
| This doc | real | Gradle in a scratch worktree of 645d4fe14a20: stock fails, the fix passes the test and the suites; release jars 9.12.3, 10.0.0, 10.4.0, 10.5.0 and 10.6.0. |
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 602 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,714 words
- Registry title
- FunctionRangeQuery scores a document with its raw, possibly negative, function value although LUCENE-7996 forbids negative scores and FunctionQuery over the same source clamps them to 0; since 10.1.0 a matching document whose value is -Infinity can then disappear from the top-k results of a minimumShouldMatch >= 2 BooleanQuery
- Area
- queries / function queries (ValueSourceScorer behind FunctionRangeQuery, LUCENE-7996 non-negative score rule; the hit loss surfaces in core WANDScorer)
- Priority
- Low, rank 716: FunctionRangeQuery scores with raw negative values despite LUCENE-7996, and it loses a hit only for a stored -Infinity under minimumShouldMatch >= 2, the same negative-score bound profile as LFV-203.
- Impact class
- silent wrong results
- Impact note
- Applications that score with lucene/queries FunctionRangeQuery (not wrapped in a FILTER clause or ConstantScoreQuery) over a field that can hold negative values get negative scores in TopDocs and explain, and a document that also matches the function clause can rank below one that matches only the other clause; when the field holds -Infinity (scored -Float.MAX_VALUE; by code reading also finite values at or below about -4e31) and the range admits it, a FunctionRangeQuery SHOULD clause of a minimumShouldMatch >= 2 BooleanQuery makes top-k search silently leave that matching document out and report an exact total hit count that is too low while count() includes it (executed with -da on 10.1.0, 10.4.0, 10.5.2 and main; on 10.1.0 to 10.4.0 only when the query takes the WANDScorer path, i.e. sparse SHOULD clauses or a nested query, and since 10.5.0 also for dense top-level queries), and with -ea that path throws AssertionError in WANDScorer, as plain -ea top-k searches did in core TopScoreDocCollector on 8.0.0 to 10.0.0; match sets of count(), FILTER/MUST_NOT/ConstantScoreQuery uses and every other document are unaffected; default IndexWriterConfig, codec and searcher, four to 100 documents suffice; released since 8.0.0.
- Summary
- You index a document with DoubleDocValuesField("d", -5.0) and search new FunctionRangeQuery(new DoubleFieldSource("d"), -10.0, 10.0, true, true), expecting a non-negative score, because since LUCENE-7996 (8.0) scorers must not produce negative scores and FunctionQuery over the same source scores that document 0. Instead it scores -5.0 (-Infinity and NaN become -Float.MAX_VALUE), because ValueSourceScorer.score() returns the raw value and LUCENE-7996 clamped FunctionQuery, BoostedQuery and FunctionScoreQuery but not this scorer. Since 10.1.0 this also loses hits: with minimumNumberShouldMatch = 2 over SHOULD clauses FunctionRangeQuery(d, open range), body:a and body:b (sparse term clauses; also dense ones since 10.5.0), a matching document whose value is -Infinity is missing from search(q, 10), whose totalHits says '3 hits' while count(q) is 4, because WANDScorer rejects a candidate whose scaled score is below the initial minimum competitive score of 0.
- Introduced
- a8a63464e7da63b3dbc884634fd0e00b3f0c140b (LUCENE-7996 'Queries are now required to produce positive scores.', a JIRA patch, now GitHub issue #9044, no pull request): it made non-negative scores a rule for every Scorer and replaced FunctionQuery's identical NaN/-Infinity -> -Float.MAX_VALUE mapping with a 0 clamp, but did not touch ValueSourceScorer or FunctionRangeQuery (neither is among its 22 files) [read: commit diff and tag ancestry; exec: on 7.7.3 FunctionQuery and FunctionRangeQuery both score -5.0, on 8.0.0 FunctionQuery scores 0 and FunctionRangeQuery -5.0; the commit and its parent were not built]; ValueSourceScorer.score() itself is unchanged since LUCENE-6720 (13f8ab93a97 trunk / 88d137632d3 branch_5x, 2015-08-06), the raw floatVal score dates from SOLR-2429 (e219ad401cf, 2011) [read]; the hit loss comes from WANDScorer's per-candidate score check added by #14021 'Make WANDScorer compute scores on the fly' (d9aa525c9ea main / 3f620dcd34f release line, first released in 10.1.0) [read: git log -S scaledLeadScore; exec: the sparse and nested checks pass on 10.0.0 and fail on 10.1.0]; #16176 'Restore WANDScorer for TOP_SCORES + minShouldMatch > 1' (135b348b370 main / 4f720f944b8 release line, first released in 10.5.0) widened it to dense top-level queries, which 10.1.0 to 10.4.0 ran with BooleanScorer [exec: the dense top-level check passes on 10.4.0 and fails on 10.5.0; read: BooleanScorerSupplier.booleanScorer at 10.4.0 uses BooleanScorer only when cost() >= maxDoc / 3] (2017-12-06 (a8a63464e7d); hit loss from 2024-11-26 (#14021, first released in 10.1.0), widened to dense top-level queries on 2026-06-03 (#16176, first released in 10.5.0))
- First affected version
- 8.0.0 [exec] for the negative score (first release with a8a63464e7d; 5.4.0 to 7.7.x score the same way but before the rule, identical to FunctionQuery; 7.7.3 executed); 10.1.0 [exec] for the hit loss (sparse top-level and nested minimumShouldMatch >= 2 queries); 10.5.0 [exec] for dense top-level ones
- Affected versions
- every release from 8.0.0 through 10.6.0 (10.6.0 by reading) and main ea57360912ed [exec: negative score at 8.0.0, 8.11.4, 9.12.3, 10.0.0, 10.1.0, 10.5.2 and main with -da, and at 10.1.0, 10.5.2 and main with -ea; plain -ea top-k search throws AssertionError in core TopScoreDocCollector at 8.0.0 (:71), 8.11.4 (:80), 9.12.3 and 10.0.0 (:75), moved to the test framework by #13939 in 10.1.0]; hit loss [exec -da, follow-up check wand-check/]: sparse top-level shape (4 target documents plus 96 fillers that match one clause) fails on 10.1.0, 10.4.0, 10.5.2 and main and passes on 9.12.3 and 10.0.0; nested shape (MUST (minimumShouldMatch=2 query) + MUST body:x) fails on 10.1.0, 10.4.0, 10.5.2 and main and passes on 9.12.3 and 10.0.0; dense 4-document top-level shape fails on 10.5.0, 10.5.2 and main and passes on 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.2.2, 10.3.0 and 10.4.0; with -ea all three shapes throw AssertionError at WANDScorer.scaleMaxScore (WANDScorer.java:92) on main; the follow-up security probe also shows it on main and 10.5.2 (20,000 documents, 361 -Infinity documents missing); not affected: 5.4.0 to 7.7.x (no rule yet), and FunctionRangeQuery does not exist before 5.4.0; branch_10x (unreleased) and branch_10_6 (10.6.0, released 2026-10-29) have the same ValueSourceScorer.score() [read, 2026-10-26]; not fixed upstream
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal
- Upstream and prior reports
- none found (2026-10-26, the prior-report record: 217 logged read-only queries, namely the scripted prior-report search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and the web, 97 GitHub GraphQL searches over apache/lucene, apache/solr, apache/lucene-solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub, 14 LUCENE and SOLR JIRA queries, 24 more list queries including builds@ lucene and the Solr lists, downstream code search and 19 reads including the changed-file lists of all 411 open apache/lucene PRs; 20 hits read, none the same bug; plus, for the hit loss, 6 GitHub searches by this follow-up (WANDScorer negative score(s), minShouldMatch negative score missing, scaleMaxScore assertion negative, WANDScorer -Float.MAX_VALUE, minimumShouldMatch FunctionRangeQuery: 0 hits) and reads of #16176, #14021 and #13408, which never mention negative scores or function queries); not fixed upstream (ValueSourceScorer.score() unchanged on main cf398bfb142c, branch_10x and branch_10_6; the only open PR touching ValueSourceScorer.java, #16847, changes the constructor only and may conflict at merge time); context, not reports: #9044 (LUCENE-7996, the rule and the FunctionQuery clamp; its 19 comments never mention FunctionRangeQuery, ValueSourceScorer or getRangeScorer), #13939 (moved the core TopScoreDocCollector score >= 0 assertion into the test framework), #3347 (LUCENE-2271, open since 2010: -Infinity and NaN function scores break TopScoreDocCollector; background to the -Float.MAX_VALUE mapping, which Solr's FunctionQuery added in 2009 with SOLR-1111, a66ef33fb18), #16176 and #14021 (the WANDScorer routing and scoring changes behind the hit loss); precedents for fixing negative scores elsewhere: #11184/#361, #12342, #12700/#12727, #16139; search gaps: GitHub's search rate limit was saturated (GraphQL used instead), web search is a general index, private lists not searched
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off, through gradle in a scratch worktree and through a standalone runner; with the recommended fix it passes, together with the closest suites and the whole queries module. Release jars: the test fails on 10.5.0 and 10.6.0 and passes on 10.4.0, 10.0.0 and 9.12.3 with assertions off. Upstream main2f424fd428da(now 12.0.0-SNAPSHOT),branch_11x603af870375,branch_10_67e5aec4370eand the 10.6.0 tag carry the same code (read, 2026-10-30 refs).
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/function/ValueSourceScorer.java:25-28 (at ea57360912ed) (class javadoc 'returns the result of FunctionValues#floatVal(int) as the score'), :84-92 (score(): '(same as FunctionQuery, but no qWeight)', 'return score > Float.NEGATIVE_INFINITY ? score : -Float.MAX_VALUE;'), :94-97 (getMaxScore returns Float.POSITIVE_INFINITY)lucene/queries/src/java/org/apache/lucene/queries/function/FunctionRangeQuery.java:33 (at ea57360912ed) ('The score is the float value. This can be a slow query ...'); FunctionValues.java:225-226 (getRangeScorer 'produces scores equal to floatVal(int)'); FunctionQuery.java:132 ('if (val >= 0 == false) { // this covers NaN as well ...' return 0)lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:75 (at ea57360912ed) (scalingFactor(+Infinity) = scalingFactor(Float.MAX_VALUE) - 1), :92 ('assert maxScore >= 0'), :340 ('while (scaledLeadScore < minCompetitiveScore || freq < minShouldMatch)'); BooleanScorerSupplier.java:315-322 (#16176: TOP_SCORES with minShouldMatch > 1 falls back to WANDScorer), :589-590 (WANDScorer when TOP_SCORES and top-level scoring clause, or minShouldMatch > 1); TotalHits.java:53-54 (toString 'N hits' for EQUAL_TO, 'N+ hits' otherwise)lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131 (at ea57360912ed) ('assert Float.compare(score, 0f) >= 0 : score;'), AssertingScorable.java:36 ('assert score >= 0'); lucene/MIGRATE.md:1368-1375 ('Scorer must produce positive scores (LUCENE-7996)'); lucene/CHANGES.txt:6603 ('LUCENE-7996: Queries are now required to produce positive scores.'), :6699 ('FunctionQuery and FunctionScoreQuery now return a score of 0 ...')
- The finding in 30 seconds
lucene/queries/src/java/org/apache/lucene/queries/function/ValueSourceScorer.java:25-36, (at 645d4fe14a20) :84-92, :94-97lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:130-137 (at 645d4fe14a20) (:132); FunctionRangeQuery.java:31-43, :168-174; FunctionValues.java:206-209, :224-232; docvalues/DoubleDocValues.java:87-147 (:95-96, :110-117)lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:57, (at 645d4fe14a20) :60-83, :90-105, :163, :177-196, :326-358; BooleanScorerSupplier.java:315-322, :589-590; IndexSearcher.java:100, :599; TopScoreDocCollector.java:56-59
- Who is affected
lucene/queries/src/java/org/apache/lucene/queries/function/FunctionRangeQuery.java:42 (at 645d4fe14a20) (@lucene.experimental); docvalues/DoubleDocValues.java:116; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:100 (TOTAL_HITS_THRESHOLD = 1000)
- Why it looks unintended
lucene/MIGRATE.md:1368-1375; (at 645d4fe14a20) lucene/CHANGES.txt:6611-6637 (8.0.0, API Changes), :6718-6733 (8.0.0, Changes in Runtime Behavior)lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131; (at 645d4fe14a20) AssertingScorable.java:36; lucene/core/src/java/org/apache/lucene/search/WANDScorer.java:69-71, :92; Scorable.java:27-28lucene/queries/src/java/org/apache/lucene/queries/function/ValueSourceScorer.java:26, (at 645d4fe14a20) :28, :86; FunctionRangeQuery.java:33; FunctionValues.java:224-227
- Suggested fix
lucene/queries/src/java/org/apache/lucene/queries/function/FunctionQuery.java:132; (at 645d4fe14a20) FunctionValues.java:206-232; docvalues/*DocValues.java and valuesource/EnumFieldSource.java (getRangeScorer implementations that inherit score())lucene/: (at 645d4fe14a20) grep for FunctionRangeQuery, ValueSourceScorer and getRangeScorer finds them only under lucene/queries
- History
a Lucene clone refs (2026-10-30): origin/main 2f424fd428da, origin/branch_11x 603af8703753, origin/branch_10_6 7e5aec4370e0, releases/lucene/10.6.0: ValueSourceScorer.java:91, WANDScorer.java:92, :340 (read)
- TestFunctionRangeQueryNegativeScoreHitLoss.java (one test):
repro/LFV-406/src/org/apache/lucene/queries/function/TestFunctionRangeQueryNegativeScoreHitLoss.java(73 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and the probe run of it (count, top-k, exhaustive scores, with and without the clamp).
assets/LFV-406/bug.png(602 KB)
