DoubleDocValuesStats and SortedDoubleDocValuesStats pass Double.MIN_VALUE as the starting max (DocValuesStats.java:251, :429). A value replaces the max only if it compares above it, and every value <= 0 compares below 4.9E-324, so the max never moves. The long classes start at Long.MIN_VALUE and are right.
max() ends there instead of at -3. Select the still to open it at full size. Generated with AI, checked against the code.- You compute statistics of a double doc-values field "temp" with
DoubleDocValuesStatsandDocValuesStatsCollectorManager. Three documents hold -5.0, -3.0 and -4.0. - You expect count 3, min -5.0 and max -3.0.
- You get count 3, min -5.0, sum -12.0 and mean -4.0, all right, but max 4.9E-324. The max starts at
Double.MIN_VALUE= 4.9E-324, a value only replaces it if it is larger, and no value <= 0 ever is. - It depends on what the query collects. With summer readings 21.0 and 18.5 in the same index, match-all reports max 21.0, right; a filter for the winter readings {-12.5, -3.0, -7.25} reports 4.9E-324. One document with -2.0, or a field that is 0.0 everywhere, is enough.
- No error: only the max is wrong (and, in the mirror case, min() of all +Infinity values is
Double.MAX_VALUE). The long classes are right. Every release from 6.4.0 to 10.6.0 (10.6.0 by reading) is affected.
Applications that compute double-field statistics with lucene/misc DocValuesStats, through DocValuesStatsCollectorManager or DocValuesStatsCollector, whenever the values a query collects are all zero or negative: balances, losses, temperatures, a 0.0 default. No special setting; one document is enough; every release since 6.4.0. Public searches suggest the class is rarely used.
Mechanism, terms and numbers602 words
DocValuesStats.DoubleDocValuesStats and DocValuesStats.SortedDoubleDocValuesStats (lucene/misc) compute count, missing, min, max, sum, mean, variance and stdev of a double doc-values field over the documents a query matches, through DocValuesStatsCollectorManager (since 10.5.0) or DocValuesStatsCollector. Both start the running maximum at Double.MIN_VALUE (DocValuesStats.java:251, :429), which in Java is the smallest positive double, 4.9E-324, not the most negative one. A value replaces the maximum only if it compares above it, so when every collected value is zero or negative max() returns 4.9E-324: a query for the winter temperatures {-12.5, -3.0, -7.25} gets count 3, sum -22.75 and min -12.5, but max 4.9E-324 instead of -3.0, while a match-all query over the same index, which also collects the summer values, reports the right max, 21.0. The javadoc leaves max() undefined only when count() is zero. The mirror mistake makes min() return Double.MAX_VALUE when every value is +Infinity. The long classes start at Long.MIN_VALUE / Long.MAX_VALUE and are correct. Nothing throws and nothing is written; every release since 6.4.0 is affected.
- DocValuesStats (lucene/misc, package
org.apache.lucene.misc.search): per-field statistics over the documents a query matches:count(),missing(),min(),max(), and for numeric fieldssum(),mean(),variance(),stdev().DoubleDocValuesStatsreads aDoubleDocValuesField;SortedDoubleDocValuesStatsreads a multi-valuedSortedNumericDocValuesFieldholding raw double bits. - Two entry points.
DocValuesStatsCollectorManagerwithIndexSearcher.search(Query, CollectorManager)(since 10.5.0, one stats object per slice, merged inreduce()), andDocValuesStatsCollectorwith the deprecatedIndexSearcher.search(Query, Collector). Double.MIN_VALUEis, per the JDK javadoc, "the smallest positive nonzero value of type double, 2^-1074", that is 4.9E-324. The most negative finite double is-Double.MAX_VALUE. Forlong, by contrast,Long.MIN_VALUEis the most negative value.
- The constructor stores the starting min and max (
DocValuesStats.java:39-43).DoubleDocValuesStatspassessuper(field, Double.MAX_VALUE, Double.MIN_VALUE)(:251), andSortedDoubleDocValuesStatsthe same pair (:429). The long classes passLong.MAX_VALUE, Long.MIN_VALUE(:213,:384). - For each collected value,
doAccumulatereplacesmaxonly whenDouble.compare(val, max) > 0andminonly whenDouble.compare(val, min) < 0(:255-262; per value in the sorted class,:433-442). - Every value <= 0, including 0.0 and -0.0, compares below 4.9E-324. So when every collected value is <= 0 the max never moves and
max()returns 4.9E-324. One positive value is at least 4.9E-324 and makes the max right. - On the min side only
Double.MAX_VALUE, +Infinity and NaN fail to move the min, somin()is wrong (Double.MAX_VALUE) when every value is +Infinity, and also for all-NaN input or input of only +Infinity and NaN. - The trigger is per query:
DocValuesStatsCollectoraccumulates only the documents the query matches (DocValuesStatsCollector.java:56-64). - The concurrent path cannot repair it:
reduce()merges the per-slice stats into a fresh object (DocValuesStatsCollectorManager.java:60-68), andmerge()skips empty slices, copies the first non-empty one and keeps the larger max (DocValuesStats.java:168-203, sorted:335-374). A non-empty slice of values <= 0 already holds 4.9E-324.
- {-5.0, -3.0, -4.0}, match-all: count 3, min -5.0, sum -12.0, mean -4.0, max 4.9E-324 (expected -3.0).
- Same index with summer 21.0 and 18.5: match-all max 21.0 (right); the winter filter over {-12.5, -3.0, -7.25}: count 3, sum -22.75, min -12.5, max 4.9E-324.
- One document -2.0: min = sum = mean = -2.0, max 4.9E-324. Four documents 0.0: stdev 0, min 0.0, max 4.9E-324.
SortedDoubleDocValuesStatsover {-1.5, -8.0}, {-0.5}, {-4.0, -2.0, -6.0}: min -8.0, max 4.9E-324 (expected -0.5).- Two documents +Infinity: max +Infinity, min 1.7976931348623157E308 (expected +Infinity).
- Controls: mixed signs {-5.0, 2.0, -4.0} give max 2.0;
LongDocValuesStatsover {-5, -3, -4} gives max -3.
Exposure and likelihood337 words
- Who is hit. Users of
DoubleDocValuesStatsorSortedDoubleDocValuesStatson a double field whose matching values can all be <= 0: balances, profit and loss, deltas, temperatures, coordinates, or a field with a 0.0 default. Both entry points:DocValuesStatsCollectorManager(the non-deprecated path, since 10.5.0) andDocValuesStatsCollectorwithIndexSearcher.search(Query, Collector), which is deprecated and slated for removal in 11.0 (apache/lucene#16918, #12892). - Default vs opt-in. Opt-in by using the misc class (the separate
lucene-miscartifact); no setting is involved. A plainmain()probe withnew IndexWriterConfig(), the default codec andnew IndexSearcher(reader)shows it, also with a single document; the JUnit tests show it under the test framework's randomized writer, codec and searcher. - Per query. A filter that selects only non-positive values is enough, while the same field over all documents may look right (match-all 21.0, winter filter 4.9E-324).
- Effect. A silent wrong number: a positive max for data that is entirely <= 0. A check such as "max > 0", a
[min, max]range for a histogram or a chart axis, ormax - min(12.5 instead of 9.5 in the winter example) goes wrong. With one value the statistics contradict themselves (min = mean = sum = -2.0, max 4.9E-324). Nothing throws, nothing is written, the matched documents do not change; in thismax()case count, missing, sum, mean, variance, stdev and min stay right. - How likely. The class looks rarely used: the public searches found only two java-user threads that mention it, and a read-only code search of the public default branches of Solr, Elasticsearch and OpenSearch found no use of it (Solr has its own class with the same simple name). That is a search result, not a usage census; private applications are not visible.
- The min() side needs, for non-NaN values, every collected value to be +Infinity (all-NaN input, or only +Infinity and NaN, also leaves
min()atDouble.MAX_VALUE): real, but contrived. In the all-+Infinity case mean, variance and stdev are NaN as well (Infinity - Infinity), with or without a fix.
The test shows that DocValuesStats.DoubleDocValuesStats starts max at Double.MIN_VALUE (the smallest positive double), so max() is wrong when every collected value is <= 0. Here there is one document with -1.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.misc.search;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.DoubleDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.misc.search.DocValuesStats.DoubleDocValuesStats;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDoubleDocValuesStatsNegativeMax extends LuceneTestCase {
public void testMaxOfSingleNegativeValue() throws Exception {
try (Directory dir = newDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new DoubleDocValuesField("d", -1.0));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
DocValuesStatsCollectorManager<Double, DoubleDocValuesStats> manager =
new DocValuesStatsCollectorManager<>(() -> new DoubleDocValuesStats("d"));
DoubleDocValuesStats stats =
new IndexSearcher(reader).search(MatchAllDocsQuery.INSTANCE, manager);
// expected: max() == -1.0, the only value; actual: 4.9E-324 (Double.MIN_VALUE)
assertEquals(-1.0, stats.max(), 0d);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-155/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.misc.search.TestDoubleDocValuesStatsNegativeMax
- Expected vs actual
- Expected: max() == -1.0. Actual (-ea and -da): 4.9E-324.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<-1.0> but was:<4.9E-324>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:555)
org.junit.Assert.assertEquals(Assert.java:685)
org.apache.lucene.misc.search.TestDoubleDocValuesStatsNegativeMax.testMaxOfSingleNegativeValue(TestDoubleDocValuesStatsNegativeMax.java:45)min()andmax()are undefined only whencount()is zero; every failing case has a count of 1 to 4.- The long classes start at
Long.MIN_VALUEand are right; Java'sDouble.MIN_VALUEis "the smallest positive nonzero value of type double". - The upstream test's oracle,
DoubleSummaryStatistics, starts max at -Infinity; the test only indexes values from 1.0 up.
The full argument322 words
- Contract. "The minimum value of the field. Undefined when {@link #count()} is zero." and "The maximum value of the field. Undefined when {@link #count()} is zero." (
DocValuesStats.java:111,:116). The class javadoc: "Holds DocValues statistics for a numeric field storing {@code double} values" (:243).DoubleDocValuesFieldaccepts any double. No javadoc, CHANGES entry (lucene/CHANGES.txt:8628LUCENE-7590,:986#15653), MIGRATE note, LUCENE-7590 comment (#8641, 42 comments) or #15653 review comment (41 inline) restricts the values to positive numbers or mentions the start values. - The siblings.
LongDocValuesStatsandSortedLongDocValuesStatsstart atLong.MAX_VALUE/Long.MIN_VALUE(:213,:384), the true extremes, and give max -3 for {-5, -3, -4}. The double classes were written next to them with the look-alike pair, which means something else for doubles. - The upstream test.
TestDocValuesStatsCollector.testDocsWithDoubleValues(:159-211) compares withjava.util.DoubleSummaryStatistics, whose max starts atDouble.NEGATIVE_INFINITY(JDKDoubleSummaryStatistics.java:71-72) and gives -3.0 for {-5, -3, -4}. The test indexes only values counting up from 1.0 (nextVal,:165) and uses 0 as its own "missing or deleted" marker (getPositiveValueskeepsv > 0,:440-441), so no value <= 0 ever reaches an assertion. It is an analogue for finite values only: itsacceptusesMath.min/Math.max(DoubleSummaryStatistics.java:144-145), which make both extremes NaN once a NaN is seen, whileDocValuesStatsorders values withDouble.compare, so {NaN, -1.0} gives min -1.0 and max NaN. - Self-consistency. Upstream
testOneDocasserts max = min = sum = mean = 1 for one long value (:93-100); one double -2.0 gives min = sum = mean = -2.0 but max 4.9E-324. - History. The pair has been there since the first commit of the class (LUCENE-7590, 2016) and was never discussed. The same mistake,
Double.MIN_VALUEas a starting max, was fixed as a bug in another project's own aggregators (elastic/elasticsearch#110586, and a review fix in #42035); neither involves Lucene's class.
The devil's-advocate check219 words
Strongest case for "intended" (built by a devil's advocate and by a reviewer from another model family, OpenAI GPT-5.6; both rated it weak): (a) upstream's tests only ever index positive doubles, which could be read as an unwritten positive domain; (b) the start values are sentinels and the result is "undefined" anyway; (c) Lucene uses Float.MIN_VALUE as a max start value elsewhere (TopGroupsCollector.java:80, :171, BlockGroupingCollector.java:274); (d) it is a misc-module utility and the original search path is deprecated; (e) the replacement IndexSearcher.search(Query, CollectorManager) is marked @lucene.experimental (IndexSearcher.java:742-750).
Why it fails: (a) the positive-only data is a test convenience (0 is the missing marker) and the test's own oracle has -Infinity semantics; (b) "undefined" covers only count() == 0, and every failing case has count() >= 1; (c) those start values track scores, and Lucene requires scores to be non-negative ("Queries are now required to produce positive scores", LUCENE-7996), while doc values can be any double, and the long classes in the same file use the true extremes; (d) the non-deprecated DocValuesStatsCollectorManager path, used as its javadoc shows, fails the same way, 80 of 80 runs over 20 randomized iterations; (e) an experimental API may change its signature, but that does not make a wrong max() correct. Verdict: not intended, a defect.
Recommended Start at +/-Infinity
Start the double classes at the true extremes, Double.POSITIVE_INFINITY and Double.NEGATIVE_INFINITY, as the long classes and DoubleSummaryStatistics do: two lines in DocValuesStats.java. The new test, TestDocValuesStatsCollector and all 321 misc tests pass, assertions on and off.
public DoubleDocValuesStats(String field) {
- super(field, Double.MAX_VALUE, Double.MIN_VALUE);
+ super(field, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY);
}
...
public SortedDoubleDocValuesStats(String field) {
- super(field, Double.MAX_VALUE, Double.MIN_VALUE);
+ super(field, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY);
}All options and trade-offs4 options ยท 764 words
Options A, B and C were built and validated with gradle in a scratch worktree at a9779ef3c193 (A and B also with the independent repros); option D was not run. A is the minimal fix; B and C also define the all-NaN corner case, and C keeps today's values for an empty stats object.
Two lines in DocValuesStats.java, the constructors of DoubleDocValuesStats (:251) and SortedDoubleDocValuesStats (:429).
public DoubleDocValuesStats(String field) {
- super(field, Double.MAX_VALUE, Double.MIN_VALUE);
+ super(field, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY);
}
...
public SortedDoubleDocValuesStats(String field) {
- super(field, Double.MAX_VALUE, Double.MIN_VALUE);
+ super(field, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY);
}Trade-offs
- NaN corner.
Double.compareorders NaN above +Infinity, so with every value NaN,min()stays +Infinity (todayDouble.MAX_VALUE);max()is NaN either way. With NaN mixed with numbers every option gives max NaN and min the smallest number. The javadoc does not define NaN behaviour. - Empty stats (
count() == 0, documented as undefined) now report +Infinity / -Infinity instead ofDouble.MAX_VALUE/ 4.9E-324; worth a CHANGES line. - Cost: none by reading (the same comparisons as today); not benchmarked.
- Not changed (by any option here): with repeated +Infinity values mean, variance and stdev are NaN (Infinity - Infinity at
:263-266), before and after the fix.
What the checks validated
- Gradle, JDK 25, scratch worktree at
a9779ef3c193: the new test 6 of 6 (assertions on and off; 120 of 120 over 20 iterations);TestDocValuesStatsCollector8 of 8; the whole misc module, 24 classes, 321 tests, 9 skipped, 0 failures (assertions on, and in the independent fact check also off);:lucene:misc:check -x test(google-java-format, ecjLint, forbiddenApis) passes. - Standalone runner with the patched class shadowed: the original 4-test JUnit 4 of 4, an independent reproducer's collector JUnit 7 of 7 and CollectorManager JUnit 3 of 3, the devil's advocate's JUnit 4 of 4.
- The same two-line change on the 6.4.0 release jars returns the right values for every case of the history probe.
In DoubleDocValuesStats.doAccumulate the conditions become count == 1 || Double.compare(val, max) > 0 (and the same for min); in SortedDoubleDocValuesStats.doAccumulate, valuesCount == 0 || ... (+6/-6 with option A's two lines).
double val = Double.longBitsToDouble(ndv.longValue());
- if (Double.compare(val, max) > 0) {
+ if (count == 1 || Double.compare(val, max) > 0) {
max = val;
}
- if (Double.compare(val, min) < 0) {
+ if (count == 1 || Double.compare(val, min) < 0) {
min = val;
}Trade-offs
Also gives min = max = NaN for all-NaN input, consistent with Double.compare order. A first-value check on both comparisons in each per-value loop (cost not measured) and a slightly bigger diff, for a corner case the javadoc does not define.
What the checks validated
Same gradle runs as option A (the new test, TestDocValuesStatsCollector, the misc module: 321 tests, 0 failures; check -x test), the original and the devil's advocate's JUnits, and a NaN probe: all-NaN gives min = max = NaN (option A: min +Infinity; stock: min Double.MAX_VALUE).
Option B's four guards (count == 1 || ... in DoubleDocValuesStats.doAccumulate, valuesCount == 0 || ... in SortedDoubleDocValuesStats.doAccumulate) without option A's constructor change (+4/-4).
double val = Double.longBitsToDouble(ndv.longValue());
- if (Double.compare(val, max) > 0) {
+ if (count == 1 || Double.compare(val, max) > 0) {
max = val;
}
- if (Double.compare(val, min) < 0) {
+ if (count == 1 || Double.compare(val, min) < 0) {
min = val;
}Trade-offs
Every non-empty input without NaN gets the right extremes and all-NaN gives min = max = NaN, while an empty stats object (count() == 0, documented as undefined) keeps today's Double.MAX_VALUE / 4.9E-324, so nothing changes there. The same added checks as option B; the empty-stats values stay the confusing constants.
What the checks validated
Gradle, JDK 25, scratch worktree at a9779ef3c193: the new test 6 of 6, the original 4-test JUnit (gradle copy) 4 of 4, TestDocValuesStatsCollector 8 of 8 and the NaN probe 2 of 2 (assertions on); the whole misc module with these classes added, 26 classes, 327 tests, 9 skipped, 0 failures (assertions off); :lucene:misc:check -x test passes.
Fixes every finite case.
Trade-offs
By reading, not run: a field whose values are all -Infinity would still report max -Double.MAX_VALUE, the same class of mistake one step further out.
A read-only code search of the public default branches of Solr, Elasticsearch and OpenSearch found no use of this class (Solr has its own class with the same simple name), so the exposure is applications that call lucene/misc's DocValuesStats directly.
Semi-formal: while modelling how DocValuesStatsCollectorManager merges per-slice statistics, the start values stood out, so an all-negative double field was added to a bounded-exhaustive harness that runs the real collector managers on every bounded index shape and generated value pattern, under every distinct slicing, against a brute-force reference. The double max failed every time; the long field matched.
The method in full210 words
Method: bounded-exhaustive translation validation (the semi-formal tier: a hypothesis formed while modelling, then confirmed by the tool), in a hunt unit on concurrent collector managers.
- The harness (
ManagerExhaustive.java) runs the real collector managers on every segment-size shape of 1-3 segments x 1-6 documents (258 shapes), with two generated value patterns and four fixed queries, under every distinct slicing thatIndexSearcher.slicesgives over the parameter ranges it enumerates (3,750), sequentially and with a 3-thread pool, and compares each result with a brute-force reference computed from the matching hits. It proves nothing beyond those bounds. - While reading
DocValuesStatsCollectorManager#reduce, the finder noticed the start values and added a doc-value-stats spec: a long field and an all-negative double field, with reference min/max starting at +/-Infinity.DVSTATS-double/resultfailed 30,000 times with assertions on and 30,000 times with them off; the long spec matched. With the unit's fixes shadowed, the harness reports no failure. - The finder reduced it to a public-API JUnit on the stock main jars. Then: a review by a model from another family (OpenAI GPT-5.6) that executed its own test, an independent reproducer, a read-only "works as designed" review by the same model family, a devil's advocate, an adjudication, a history sweep, a prior-report search and a security scope check.
No prior report found (searched 2026-10-26, re-checked 2026-10-27): GitHub issues and PRs, commits, JIRA, four mailing lists, downstream projects, the web and all 413 open PRs. The issue that added the class (LUCENE-7590) and the PR that added the CollectorManager (#15653) never discuss the start values.
Searches5 searches
Verdict new, confidence high but not certain. The closest items introduced or last changed the code, or fixed the same kind of mistake in another project's own code. "None found" is a search result, not proof.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream repositories | 2026-10-26 | a scripted prior-report search (20 GitHub issue/PR, 3 commit and 2 downstream queries) and own GitHub issue and code searches in apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch; git log -S over the full history | no report; #8641 / LUCENE-7590 introduced the classes (42 comments, none on the double start values); #15653 added the manager (41 inline review comments, none on them); #10880 and #2065 are unrelated |
| dev@, java-user@, issues@, commits@; JIRA archive | 2026-10-26 | 32 list and 6 JIRA queries through the tool, plus own list and JIRA REST queries | no report; the two java-user threads that mention the class (2017, 2018) report no bug; SOLR-6024 and related concern Solr's own class of the same name |
| Web | 2026-10-26 | 10 queries | no report; elastic/elasticsearch#110586 and #42035 fixed the same kind of Double.MIN_VALUE mistake in that project's own aggregators, not in Lucene's class |
| Open apache/lucene PRs | 2026-10-26 | the changed files of all 413 open PRs | none changes DocValuesStats.java; #16847 touches only the test |
| Upstream main and search re-check | 2026-10-27 | GitHub API: commits to DocValuesStats.java; searches "DocValuesStats" (issues, PRs); issues and PRs created since 2026-10-25 | at upstream main head f7bcc0abd816 the file's last commit is still 0210af757c54 (#15653, 2026-05-19); no new report |
Introduced in 6.4.0 by LUCENE-7590 (ad7152ad4739, 2016-12), which created the double class with Double.MIN_VALUE next to Long.MIN_VALUE; #15653 (10.5.0) added the CollectorManager path. Every release run from 6.4.0 to 10.5.2, and main, is affected; 6.3.0 has no such class. Never fixed.
Versions tested6 rows
Introduced by ad7152ad4739 "LUCENE-7590: add DocValuesStatsCollector" (shaie; authored 2016-12-11, committed to master 2016-12-14; JIRA LUCENE-7590, migrated as apache/lucene#8641; no PR), which created DoubleDocValuesStats with super(description, Double.MAX_VALUE, Double.MIN_VALUE) next to Long.MAX_VALUE, Long.MIN_VALUE for the long class. SortedDoubleDocValuesStats copied the pair in 944b8e07f557 (2016-12-17). Both were released in 6.4.0 through the branch_6x backports 7269c484a4a0 and 63a5cd00173f. The pair is unchanged since (git log -L, -S; 22d9af41a43 only renamed the parameter). Second entry point: #15653 (0210af757c54, gaobinlong, merged 2026-05-19, 10.5.0) added DocValuesStatsCollectorManager and merge() without touching the start values; the 10.5.0 and 10.5.2 tags contain its branch counterpart 1833c658f06 (same title and date), not 0210af757c54. Not fixed: upstream main f7bcc0abd816 (2026-10-27) still has both lines; the follow-up read the same on branch_10x, branch_10_6 and branch_9_12. 10.6.0 (released 2026-10-29) was not run; it is affected by that branch_10_6 reading. Not checked: 8.9.0-8.11.3 (between affected 8.8.2 and 8.11.4).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test fails 5 of 6 (gradle and standalone runner, assertions on and off); passes with either fix |
main ea57360912ed | affected | the original JUnit fails 4 of 4 (assertions on and off); every independent check reproduced it |
| 10.5.0, 10.5.2 | affected | the history probe and the CollectorManager JUnit fail; on 10.5.2 this doc's test fails 5 of 6, assertions on and off |
| 6.4.0 to 10.4.0 (6.4.0, 6.4.2, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.4.0) | affected | the history probe gives max 4.9E-324 and min 1.7976931348623157E308 on every release, controls right; the original JUnit (ported) fails 4 of 4 on 6.4.0, 7.0.0, 9.0.0 and 9.12.3 |
| 6.3.0 | API absent | no DocValuesStats class (compile error); the class arrived in 6.4.0 |
| 6.4.0 and main with only the two constructor lines changed | root cause | every bug case and control of the history probe is right |
Confirmed by an executed review from another model family (OpenAI GPT-5.6), an independent reproducer, a read-only "works as designed" review, a devil's advocate on the non-deprecated path, the adjudication and the follow-up's history and security runs; this doc's test and both fixes were run with gradle on main a9779ef3c193.
Checks and verdicts423 words
Each executing check wrote and ran its own test or probe on the stock jars; the controls (long variants, positive and mixed-sign doubles) pass everywhere.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Bounded-exhaustive harness: DVSTATS-double/result failed 30,000 times with assertions on and off, the long spec matched. Public-API JUnit: 4 of 4 fail on main (assertions on and off), 10.5.2 and 9.12.3; the +/-Infinity fix passes, upstream TestDocValuesStatsCollector 8 of 8 with it. |
| cross-model review, refute lens | real, high | OpenAI GPT-5.6, executed: its own test (collector and CollectorManager, single-valued and sorted, a boundary control) fails 4 of 5 on main with assertions on and off, 8 of 10 over two iterations, passes with the fix. Root cause agreed; intended-behaviour argument weak; noted the all-NaN gap of the fix. |
| independent reproducer | real | Own JUnit: 7 run, 4 failed on main (assertions on and off), 10.5.2 and 9.12.3; controls pass; count, missing, sum, mean and min asserted right before max. Own CollectorManager JUnit (one slice per segment, 2 threads): 3 run, 2 failed. Both pass with the fix. |
| cross-model review, intended lens | real, medium | Read the code only (it could not run Java), hence medium: intended-behaviour argument weak, root cause and fix agreed, inputs in contract. A separate run re-ran the original JUnit (4 of 4 failed); supplementary runs failed on main, 10.5.2 and 9.12.3. |
| devil's advocate | real | Own JUnit on the non-deprecated path, as the DocValuesStatsCollectorManager javadoc shows it (RandomIndexWriter, newSearcher, a winter TermQuery): 4 of 4 fail on main and 10.5.2, 80 of 80 over 20 randomized iterations; passes with the fix; controls pass. |
| adjudication | confirmed, high | Re-ran the independent reproduction's collector JUnit (7 run, 4 failed, assertions on and off), the independent reproduction's CollectorManager JUnit (3 run, 2 failed) and the original JUnit (4 of 4 failed). |
| Follow-up | history, prior art, security | 30 release-level runs (25 affected from 6.4.0 to main, 3 compile errors where the API is absent, the two-line fix passes on 6.4.0 and main); prior-report verdict new; security none. |
| this doc | re-run on a9779ef3c193 | New 6-test JUnit with gradle and the standalone runner (5 of 6 fail, assertions on and off, also on 10.5.2); fixes A and B validated against it, the misc module and the independent repros; a NaN probe separates the fixes. In the fact-check resolution: the gradle copy of the original JUnit (4 of 4 fail, assertions on and off) and a third fix (seed-only) validated with gradle. |
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 391 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,500 words
- Registry title
- DoubleDocValuesStats and SortedDoubleDocValuesStats (lucene/misc) start max at Double.MIN_VALUE, the smallest positive double, so max() returns 4.9E-324 instead of the real maximum whenever every collected value is <= 0 (and min() returns Double.MAX_VALUE when every value is +Infinity)
- Area
- misc / DocValuesStats double statistics (DocValuesStatsCollector, DocValuesStatsCollectorManager)
- Priority
- Medium, rank 180: Since 6.4.0 the misc DoubleDocValuesStats max() returns 4.9E-324 whenever every collected value is <= 0, a plain silent wrong statistic on ordinary data but in a rarely used class.
- Impact class
- silent wrong results
- Impact note
- Applications that compute field statistics with the lucene/misc DoubleDocValuesStats or SortedDoubleDocValuesStats, through DocValuesStatsCollector or (since 10.5.0) the non-deprecated DocValuesStatsCollectorManager, get max() = 4.9E-324 whenever every value the query collects is <= 0 (for example negative balances, below-zero temperatures selected by a filter, or a field that is 0.0 everywhere) and min() = Double.MAX_VALUE when every value is +Infinity, with the default IndexWriterConfig, codec and searcher on as little as one document; count, missing, sum, mean, variance, stdev and min stay correct in the max() case, nothing throws, the index is untouched, the long variants are correct, and every release since 6.4.0 is affected.
- Summary
- You collect statistics for a double doc-values field with DocValuesStats.DoubleDocValuesStats (or SortedDoubleDocValuesStats) and expect max() to be the largest collected value; the javadoc calls it 'the maximum value of the field' and leaves it undefined only when count() is zero. If every collected value is zero or negative, max() returns 4.9E-324 instead: max starts at Double.MIN_VALUE, which is the smallest positive double, not the most negative one, and no value <= 0 ever beats it, so for {-5, -3, -4} you get count 3 and min -5.0 but max 4.9E-324. The same mix-up on the other side makes min() return Double.MAX_VALUE when every value is +Infinity; the long variants start at Long.MAX_VALUE / Long.MIN_VALUE and are correct.
- Introduced
- ad7152ad4739 (master, 'LUCENE-7590: add DocValuesStatsCollector', by shaie; JIRA LUCENE-7590, migrated as apache/lucene#8641; pre-GitHub, no PR): DoubleDocValuesStats was created with super(description, Double.MAX_VALUE, Double.MIN_VALUE) next to LongDocValuesStats with Long.MAX_VALUE, Long.MIN_VALUE; SortedDoubleDocValuesStats copied the pair in 944b8e07f557 ('LUCENE-7590: add DocValuesStats for SortedNumeric DV fields'); both were released through the branch_6x backports 7269c484a4a0 and 63a5cd00173f in 6.4.0 [read: git log -L and -S in the history clone show the pair unchanged since, 22d9af41a43 only renamed the parameter; exec: release level only, 6.3.0 has no such API (compile error), 6.4.0 fails, and 6.4.0 with only the two constructor lines changed to +/-Infinity passes; no commit-level build]; 0210af757c54 (#15653 'Introduce DocValuesStatsCollectorManager', by gaobinlong, merged 2026-05-19, released in 10.5.0) added merge() and the manager without touching the initial values, which gave the bug a second, non-deprecated entry point [read; exec on 10.5.0, 10.5.2 and main] (2016-12-11 (authored; committed to master and branch_6x 2016-12-14, DoubleDocValuesStats); 2016-12-17 (authored; committed 2016-12-18, SortedDoubleDocValuesStats); 2026-05-19 (#15653, CollectorManager entry point))
- First affected version
- 6.4.0 [exec] (6.3.0 has no DocValuesStats [exec: compile error; lucene-misc-6.3.0.jar has no DocValuesStats classes]); the DocValuesStatsCollectorManager path since 10.5.0 [exec] (10.4.0 has no such class [exec: compile error])
- Affected versions
- every release from 6.4.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed. [exec] the follow-up's history probe (public-API main(), 4 bug cases and 3 controls) is affected on 6.4.0, 6.4.2, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.2, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.4.0, 10.5.0, 10.5.2 and main (-ea and -da), always with max = 4.9E-324 and min = 1.7976931348623157E308 while the controls are right; the finder's JUnit (ported only in package and import lines where needed) fails 4 of 4 on 6.4.0, 7.0.0, 9.0.0, 9.12.3, 10.5.2 and main; the independent reproduction's CollectorManager JUnit fails 2 of 3 on 10.5.0, 10.5.2 and main; the intended-behaviour check's CollectorManager JUnit fails 4 of 4 on 10.5.2 and main; all runs on JDK 25, also for releases that predate it. [read] the same two constructor lines at the release tags 6.4.0, 6.6.6, 7.0.0, 7.7.3, 8.0.0, 8.8.1, 9.0.0, 9.12.3, 10.0.0, 10.4.0, 10.5.0 and 10.5.2, and no change to either line in git log -L; releases between those were neither run nor read one by one, and 8.9.0-8.11.3 were neither read nor executed (between affected 8.8.2 and 8.11.4). 10.6.0 (released 2026-10-29) was not run; it is affected by the branch_10_6 reading below. Not fixed upstream then: main, branch_10x, branch_10_6 and branch_9_12 still had both lines (follow-up prior-report record, read 2026-10-26). Fixed on main from aa366ae209ae (#16971, merged 2026-11-04) [read 2026-11-04: the merged diff changes both lines to Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY; the repro was not re-run on it].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; fixed upstream.
- Found by
- semi-formal: exhaustive check
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new: a scripted prior-report search with 73 queries (20 GitHub issue/PR, 3 commit, 2 downstream-repo, 32 mailing-list over dev@, java-user@, issues@ and commits@, 6 JIRA-archive, 10 web; 351 distinct hits, 0 high, every medium hit and every hit naming a DocValuesStats class triaged) and 36 own query lines (GitHub issue and code searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch and opensearch-project/OpenSearch, JIRA REST, list searches with the two java-user threads read in full, git log -S over the full history), plus the changed files of all 413 open apache/lucene PRs; the finder's earlier run (51 queries) and the intended-behaviour check's (27 queries) also found nothing); fixed on main (milestone 11.0.0): Uwe Schindler approved and merged #16971 (by sjs004) on 2026-11-04 as aa366ae209ae, which changes both double constructors to super(field, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY) (read from the merged diff; the repro was not re-run on it); before that, as read on 2026-10-26, main, branch_10x, branch_10_6 and branch_9_12 still called super(field, Double.MAX_VALUE, Double.MIN_VALUE) in both double classes, the file last changed with #15653, whose review (41 inline comments) never touched the initial values, and no open PR then changed DocValuesStats.java (#16847 touches only the test); related, not reports of this bug: #8641 / LUCENE-7590 (introduced the classes; 42 comments, none on the double initial values), #15653 (added the CollectorManager and merge()), elastic/elasticsearch#110586 and #42035 (the same kind of Double.MIN_VALUE mistake fixed in that project's own aggregators, not in Lucene's class), SOLR-6024 and related (Solr's own class of the same simple name); gaps: GitHub code search covers only indexed default branches, the one-word GitHub queries stopped at 50 results, and the web results were weak
- Lucene commit tested
- main
a9779ef3c193(commit of 2026-10-26; tested 2026-10-27) with gradle in a scratch worktree and with the standalone JUnit runner, assertions on and off; the 10.5.2 release jars (this doc's test). The hunt, the independent checks and the follow-up ran on mainea57360912ed, whose misc module is identical, and on release jars from 6.3.0 to 10.5.2 (the history sweep). Both fix options were validated in the scratch worktree: the new test,TestDocValuesStatsCollector, the whole misc module (321 tests) and the module's format, ecjLint and forbiddenApis checks, plus the four independent repros with the patched class shadowed. During the fact-check resolution a third option (seeding from the first value only) was validated the same way with gradle, and the gradle copy of the original 4-test JUnit was run with gradle (assertions on and off).
- Finding record
lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStats.java:39-43 (constructor stores initialMin and initialMax), :111 and :116 (min()/max() javadoc: 'Undefined when count() is zero'), :168-203 (NumericDocValuesStats.merge: skips empty stats, copies or compares min/max), :213 and :384 (Long variants: Long.MAX_VALUE, Long.MIN_VALUE), :244 and :251 (DoubleDocValuesStats: super(field, Double.MAX_VALUE, Double.MIN_VALUE)), :255-267 (doAccumulate: max and min move only on Double.compare), :422 and :429 (SortedDoubleDocValuesStats: same pair), :433-452 (doAccumulate per value)lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStatsCollectorManager.java:24-34 (javadoc usage example) and :60-68 (reduce: merges every collector's stats into a fresh stats object)lucene/misc/src/java/module-info.java:25 (exports org.apache.lucene.misc.search)
- The finding in 30 seconds
lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStats.java:36-43, :111, :116, :168-203, :213, :243-267, :335-374, :384, :421-452 (main a9779ef3c193; the misc tree is identical to ea57360912ed)lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStatsCollector.java:39-65; lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStatsCollectorManager.java:24-34, :60-68JDK 25 src.zip java.base/java/lang/Double.java:404-411 (MIN_VALUE javadoc)
- Who is affected
upstream apache/lucene#16918 (deprecations slated for removal in 11.0, read 2026-10-27)
- Why it looks unintended
lucene/misc/src/java/org/apache/lucene/misc/search/DocValuesStats.java:111, :116, :213, :243, :251, :384, :429lucene/misc/src/test/org/apache/lucene/misc/search/TestDocValuesStatsCollector.java:93-100, :159-211, :165, :194, :199, :440-441JDK 25 src.zip java.base/java/lang/Double.java:404-411; java.base/java/util/DoubleSummaryStatistics.java:71-72, :144-145 (accept: Math.min / Math.max)lucene/CHANGES.txt:986, :6619, :8628; lucene/MIGRATE.md:1368lucene/grouping/src/java/org/apache/lucene/search/grouping/TopGroupsCollector.java:80, :171; BlockGroupingCollector.java:274lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:742-750 (search(Query, CollectorManager), @lucene.experimental)
- Suggested fix
JDK 25 src.zip java.base/java/util/DoubleSummaryStatistics.java:71-72
- History
a Lucene clone: git merge-base --is-ancestor 1833c658f06 releases/lucene/10.5.0 and releases/lucene/10.5.2 (yes); git branch -r --contains 1833c658f06 (branch_10_5, branch_10_6, branch_10x)
- TestDoubleDocValuesStatsNegativeMax.java (JUnit test):
repro/LFV-155/src/org/apache/lucene/misc/search/TestDoubleDocValuesStatsNegativeMax.java(49 lines)
A step-by-step picture made in parallel with this doc from an executed probe on main a9779ef3c193 (three documents {-5.0, -3.0, -4.0}, DocValuesStatsCollectorManager over MatchAllDocsQuery, stock and with the fix); the numbers on screen come from the probe's output. The PNG is the closing summary card.
assets/LFV-155/bug.png(391 KB)
