A range facet counts, per range, the documents with at least one value in it. For a multi-valued document, LongRangeCounter.addMultiValued keeps the highest interval it has counted and returns early for any later value at or below it. That is only right when values arrive in ascending order, which nothing guarantees.
- You count distance ranges "< 1 km", "< 2 km" and "< 5 km" with
DoubleRangeFacetCounts. A document has two locations, so your ownMultiDoubleValuesSourcereturns two distances in storage order: 3.0 km, then 0.5 km. - You expect the document in all three ranges, counts 1, 1, 1: each range's drill-down query (
DoubleRange.getQuery) matches it. - You get 0, 0, 1. The counter put 3.0 in the interval [2, 5) and then skips every later value at or below that interval's top, so 0.5 is never placed.
- At scale, 1,000 documents with two random distances in 0-10 km give 85, 180, 494 instead of 154, 325, 740. Lucene's own
fromDoubleFieldand theDoubleRangeFacetCounts(String, ...)constructor can do the same for raw-bit doubles with two negative values: -1.0 arrives before -5.0, and -5.0 is skipped. - No error: counts and the total are only ever too low. Every release from 8.10.0 to 10.6.0 (10.6.0 by reading) is affected; ascending inputs such as long fields are not.
Applications that range-facet multi-valued numbers whose values are not ascending: a custom MultiDoubleValuesSource or MultiLongValuesSource, such as distances to a document's several locations; raw-bit doubles or floats with two or more negative values per document; or fromField with a decreasing decoder. Default settings, every release since 8.10.0. Long fields are not affected.
Mechanism, terms and numbers894 words
LongRangeFacetCounts and DoubleRangeFacetCounts count, for each range, the documents that have at least one value in it. For a document with several values, LongRangeCounter.addMultiValued remembers the highest elementary interval it has counted for that document and returns early for any later value at or below that interval's upper end; its field comment states the assumption, "this takes advantage of the fact that values within a given doc are sorted". Nothing guarantees that order: MultiLongValues.nextValue and MultiDoubleValues.nextValue promise none, so a custom source such as the distances from the user to each of a document's locations (the use case both class javadocs name) is counted in delivery order, and Lucene's own fromDoubleField and the DoubleRangeFacetCounts(String, ...) constructor read raw-bit doubles in signed-long order, which reverses negative values: two different negative values in one document reach the counter in descending order. A document with distances 3.0 km and 0.5 km is counted under "< 5 km" but not under "< 1 km" or "< 2 km", although those ranges' drill-down queries find it; across 1,000 such documents the counts are 85, 180, 494 instead of 154, 325, 740. Counts and FacetResult.value are only ever too low, and nothing fails. Every release from 8.10.0 to 10.6.0 (10.6.0 by reading), and main, is affected.
- Range facet counts.
LongRangeFacetCountsandDoubleRangeFacetCountscount, for each requested range, the documents that have at least one value in it; the result's value (FacetResult.value) is the number of documents that hit any range. Doubles are mapped to sortable longs, so both classes share one counter,LongRangeCounter. - Elementary intervals. The counter cuts the number line at the range bounds;
boundaries()holds each interval's inclusive upper end, the last oneLong.MAX_VALUE(range/LongRangeCounter.java:90-92,:169-170). For "< 1 km", "< 2 km", "< 5 km": E0 below 0, E1 [0, 1), E2 [1, 2), E3 [2, 5), E4 [5, +inf). Non-overlapping ranges useExclusiveLongRangeCounter, overlapping onesOverlappingLongRangeCounter(:52-58). - Multi-valued sources.
MultiLongValues.nextValue()andMultiDoubleValues.nextValue(): "Iterates to the next value in the current document", with no ordering (MultiLongValues.java:42-46,MultiDoubleValues.java:42-46). Custom sources are the stated purpose of #543 (9.1.0). - Raw-bit doubles.
SortedNumericDocValuesField(Double.doubleToRawLongBits(v)), the encodingfromDoubleFielddecodes (Double::longBitsToDouble,MultiDoubleValuesSource.java:80-83) and theDoubleRangeFacetCounts(String, ...)constructor documents ("assumes that the field was indexed with DoubleDocValuesField",range/DoubleRangeFacetCounts.java:53-56).SortedNumericreturns the longs in signed order, and for negative doubles the raw bits sort in reverse:0xBFF0000000000000(-1.0) is below0xC014000000000000(-5.0).
- For a document with more than one value the callers call
startMultiValuedDoc(),addMultiValued(v)for each value, thenendMultiValuedDoc()(range/LongRangeFacetCounts.java:200-212,range/DoubleRangeFacetCounts.java:227-239,range/RangeFacetCounts.java:169-181). Only a document for whichendMultiValuedDoc()returns true is added to the total (RangeFacetCounts.java:179-181,:213). addMultiValued(range/LongRangeCounter.java:116-161) keepsmultiValuedDocLastSeenElementaryInterval, reset to -1 per document (:66-68). If it is set andv <= boundaries[lastSeen], it returns at once (:125-130); otherwise it binary-searches only the intervals abovelastSeen(:139-160). The field comment: "this takes advantage of the fact that values within a given doc are sorted" (:45-49).- Distances 3.0, then 0.5: 3.0 lands in E3 and
lastSeenbecomes 3; 0.5 is<= boundaries[3](just below 5.0), so the method returns. E1 is never marked: "< 1 km" and "< 2 km" get 0. - If the first value lies above every range (
{500, 5}with[0, 10),[10, 100)),lastSeenis the last interval, every later value is skipped, and the document also drops out of the total. - Raw-bit doubles with the sign bit set come in reverse order through
fromDoubleFieldand through the field constructor, which maps withNumericUtils.sortableDoubleBits(range/DoubleRangeFacetCounts.java:272-275): two different negative values arrive descending, and so do -0.0 and a negative value. An out-of-order value is lost only when it falls in a lower elementary interval than one already counted (raw{-5.0, -6.0}with[-10, -2],[-2, 0], both in one interval, is counted right). Without -0.0 or NaN, a document with at most one negative value arrives ascending and is counted right. One more edge: throughfromDoubleField, a NaN with the sign bit set arrives first but maps above every finite value, so the next value is skipped (executed:{NaN, 5.0}in[0, 10]counts 0, total 0). - Both counters lose the skipped values. The exclusive one relies on the shortcut to count each interval at most once per document ("We can do this without fear of double-counting since we know the requested ranges don't overlap",
range/ExclusiveLongRangeCounter.java:122-132). - Counts can only go down: a value that is searched always lands in its true interval, and no interval is counted twice for one document.
One document per test: distances 3.0, 0.5 give [0, 0, 1] instead of [1, 1, 1]; longs {30, 5} with [0, 10), [10, 100) give [0, 1] instead of [1, 1]; {500, 5} gives [0, 0] and total 0 instead of [1, 0] and total 1; raw-bit {-1.0, -5.0} with [-10, -2], [-2, 0] gives [0, 1] instead of [1, 1], through fromDoubleField and through the field constructor. 1,000 documents with two random distances in [0, 10) km: [85, 180, 494], total 494, against drill-down [154, 325, 740]. Two random raw-bit temperatures in [-20, 20): [-20, -10) counts 271, drill-down 390.
Exposure and likelihood362 words
- Custom sources. Both class javadocs name per-request values such as "distance from the user's location, '< 1 km', '< 2 km', etc." (
range/DoubleRangeFacetCounts.java:36-39,range/LongRangeFacetCounts.java:33-37), and #543 says "This change allows users to provide custom multi-valued 'value sources' for numeric faceting". Such a source returns one value per location in whatever order it computes them; nothing asks it to sort them. - Raw-bit doubles. The field constructor documents the raw-bit encoding (
DoubleDocValuesFieldencodes "via Double#doubleToRawLongBits"),fromDoubleFielddecodes it, theMultiLongValuesSourcejavadoc recommendsfromDoubleField(f).toMultiLongValuesSource()for double fields, and upstream's own multi-valued tests index the IEEE bits (doubleToLongBits/doubleToRawLongBits, the same bits for non-NaN values) with values in [0, 1) only. A document with two or more different negative values (western longitudes, sub-zero temperatures) reaches the counter in descending order and loses a later value that falls in a lower elementary interval; -0.0 next to a negative value does the same.fromFloatFieldover raw float bits behaves the same. - Not affected (executed controls): long fields through
fromLongField/fromIntField;DoubleField(sortable encoding) read withfromField(f, NumericUtils::sortableLongToDouble); raw-bit documents with at most one negative value and no -0.0 or NaN; single-valued documents; ascending custom sources. - Default vs opt-in. Default codec,
IndexWriterConfigand searcher; no setting. But each trigger needs a less common API shape: an@lucene.experimentalcustom source, hand-encoded raw-bit multi-valued doubles, or a decreasing decoder. - Effect. Per-range counts and
FacetResult.valueare silently too low and disagree with the range's drill-down; a document whose first value lies above every range vanishes from all of them. Never too high, no error, nothing written to the index. With 1,000 documents and two random distances each, one third of the documents are missing from "< 5 km" (494 of 740). - Likelihood. Plausible: wrong on every affected document, but these paths are uncommon, upstream tests never deliver unordered values, and no public report was found in the recorded searches, although the code has shipped since 8.10.0 (2021).
- Same assumption elsewhere.
LongValueFacetCountsde-duplicates a document's values only against the previous value, so a source that returns 5, 3, 5 counts 5 twice for one document (tracked with this bug; needs its own change).
The test shows that LongRangeFacetCounts drops a match when a custom MultiLongValuesSource gives one doc the values {30, 5} (not ascending): LongRangeCounter.addMultiValued skips 5 after counting 30.
/*
* 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.facet.range;
import org.apache.lucene.document.Document;
import org.apache.lucene.facet.FacetsCollector;
import org.apache.lucene.facet.FacetsCollectorManager;
import org.apache.lucene.facet.MultiLongValues;
import org.apache.lucene.facet.MultiLongValuesSource;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestRangeFacetUnsortedMultiValues extends LuceneTestCase {
public void testValuesNotInAscendingOrder() throws Exception {
MultiLongValuesSource source =
new MultiLongValuesSource() {
@Override
public MultiLongValues getValues(LeafReaderContext ctx) {
return new MultiLongValues() {
final long[] values = {30, 5};
int upto;
@Override
public boolean advanceExact(int doc) {
upto = 0;
return true;
}
@Override
public long getValueCount() {
return values.length;
}
@Override
public long nextValue() {
return values[upto++];
}
};
}
@Override
public boolean isCacheable(LeafReaderContext ctx) {
return false;
}
@Override
public int hashCode() {
return 0;
}
@Override
public boolean equals(Object o) {
return o == this;
}
@Override
public String toString() {
return "{30, 5}";
}
};
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
w.addDocument(new Document());
}
try (DirectoryReader r = DirectoryReader.open(dir)) {
FacetsCollector fc =
new IndexSearcher(r).search(MatchAllDocsQuery.INSTANCE, new FacetsCollectorManager());
LongRange range = new LongRange("[0, 10)", 0, true, 10, false);
LongRangeFacetCounts facets = new LongRangeFacetCounts("x", source, fc, range);
// expected 1 (value 5 is in [0, 10), as range.getQuery(null, source) agrees), actual 0
assertEquals(1, facets.getAllChildren("x").labelValues[0].value.intValue());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-303/, 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/facet/build/libs/lucene-facet-$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.facet.range.TestRangeFacetUnsortedMultiValues
# the module(s) facet also need their third-party runtime jars
- Expected vs actual
- Expected count 1 for range [0, 10) (the range's own drill-down query finds the doc); actual 0 (-ea and -da).
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> but was:<0>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.junit.Assert.assertEquals(Assert.java:633)
org.apache.lucene.facet.range.TestRangeFacetUnsortedMultiValues.testValuesNotInAscendingOrder(TestRangeFacetUnsortedMultiValues.java:93)MultiLongValuesandMultiDoubleValuespromise no order; "values within a given doc are sorted" is only a comment in a package-private class.- The same range's drill-down query checks every value and finds the document.
- The shortcut was added in review when every input was sorted; unordered sources came in 9.1.0 without any discussion of order.
The full argument216 words
- Contract.
MultiLongValues.java:22-46andMultiDoubleValues.java:22-46: "Documents may produce multiple values";nextValue()"Iterates to the next value in the current document". No ordering, then or now. The precondition lives only in the field comment of the package-privateLongRangeCounter(range/LongRangeCounter.java:40,:45-49). - What a count means. The upstream random multi-valued tests count a document once per range if any of its values is in it ("ensure each doc can contribute at most 1 count to each range",
range/TestRangeFacetCounts.java:1015,:1358) and assert that the facet count and the drill-down count both equal that (:1053,:1072,:1399,:1420). - The same API disagrees with itself.
DoubleRange/LongRange.getQuery(fastMatchQuery, Multi*ValuesSource), "Create a Query that matches documents in this range", accepts a document if any value is in the range, in any order (range/DoubleRange.java:298-302,range/LongRange.java:285-289). - History. #127 (LUCENE-9946, 8.10.0) first searched from interval 0 with a per-document bitset; after a reviewer asked whether it took advantage of "the sortedness of
SORTED_NUMERIC", revision923d35eb0ceeadded the last-seen-interval search. At the time every multi-valued input was sorted. #543 (LUCENE-10245, 9.1.0) then fed unorderedMulti*Valuesinto the unchanged counter; its review, issue and dev@ thread never mention order. - Tests. No upstream test builds a custom multi-valued source, and no multi-valued test uses negative doubles.
The devil's-advocate check206 words
Strongest case (built by the devil's advocate; a reviewer from another model family rated it moderate): (a) the counter documents its precondition and the review asked for it; (b) Multi*Values copy the SortedNumericDocValues shape, and every consumer in the module assumes ascending values; (c) SortedNumericDocValuesField says doubles need NumericUtils encoding "with proper sort order", and in #11997 a committer noted that raw-bit multi-valued doubles are not ordered within a document; (d) the classes are @lucene.experimental.
Why it fails: (a) is a package-private invariant that no public API passes on; (b) is unwritten, and Lucene's own fromDoubleField and field constructor break it; (c) does not touch the custom-source trigger, and the facet API documents and decodes raw bits, while with the sortable encoding the field constructor mis-decodes negatives ({-5.0, -6.0} lands in [-2, 0] instead of [-10, -2], executed), so for that constructor neither encoding is reliable for documents with two or more negative values (the sortable encoding mis-decodes every negative value; raw bits drop a lower value that arrives second, as with {-1.0, -5.0}, while raw {-5.0, -6.0}, both in one interval, is counted right); (d) experimental is not unsupported, and custom sources are the purpose of #543. Verdict: a defect.
Recommended Order-independent addMultiValued with the ascending fast path kept (validated)
Make LongRangeCounter.addMultiValued order-independent: keep today's path for ascending values, but search all intervals when a value falls below the last counted one, and count each interval once per document with a small bitset (one file, +43/-25). The new tests, the three independent repros and all 313 facet tests pass, assertions on and off.
All options and trade-offs4 options · 832 words
Option A was built and validated with gradle in a scratch worktree at a9779ef3c193; option B (the finder's patch) was re-run there; option C ran as a shadow class in independent checks; option D was not run. Any fix should also go with LFV-314's separate change in LongValueFacetCounts (merged into this bug), which the range-counter fix does not touch. By reading, the sandbox LongRangeFacetCutter (LongRangeFacetCutter.java:324-352) keeps a similar per-document lastIntervalSeen shortcut; it was not run, and none of these options touches it.
One file, range/LongRangeCounter.java: a per-document FixedBitSet of counted elementary intervals, allocated once and cleared in startMultiValuedDoc(). A value above the highest counted interval is searched from there, as today; a value inside that interval returns, as today; a value below it is searched over all intervals. Each interval is passed to processMultiValuedHit at most once per document.
final int lastSeen = multiValuedDocLastSeenElementaryInterval;
final int firstCandidateElementaryInterval;
if (lastSeen == -1 || v > boundaries[lastSeen]) {
firstCandidateElementaryInterval = lastSeen + 1; // ascending: as today
} else if (lastSeen == 0 || v > boundaries[lastSeen - 1]) {
return; // same interval as the last one counted
} else {
firstCandidateElementaryInterval = 0; // out of order
}
// ... unchanged binary search from firstCandidateElementaryInterval -> idx
if (multiValuedDocCountedElementaryIntervals.getAndSet(idx) == false) {
processMultiValuedHit(idx);
}
if (idx > lastSeen) {
multiValuedDocLastSeenElementaryInterval = idx;
}Trade-offs
- Fixes every caller at once: the source paths of both facet classes, the field constructor, and both counters.
- Cost: ascending input takes the same path as today plus one bitset write per counted interval and a clear per multi-valued document (intervals / 64 words); each out-of-order value pays a full binary search and one bitset write, even when its interval was already counted. Not benchmarked; not reviewed upstream.
OverlappingLongRangeCounteralready keeps its own per-document interval bitset; a maintainer may prefer to share it.LongRangeCounter.javais unchanged since 2021, so the patch applies to every affected branch. Open PR #16439 also edits this file (its single-valued, exclusive path); expect a small merge.
What the checks validated
- Scratch worktree at
a9779ef3c193, gradle, JDK 25: the new tests fail 6 of 7 on stock and pass 7 of 7 with the fix; withTestRangeFacetCounts, 29 tests, 0 failures, assertions on and off; the randomized test passes 30 of 30 seeds (-Ptests.iters=30 -Ptests.multiplier=2). - The whole facet module: 41 suites, 313 tests (306 upstream + 7 new), 0 failures, with assertions on (7 skipped) and off (8 skipped);
:lucene:facet:check -x test(google-java-format, ecjLint, forbiddenApis) passes. - Standalone runner with the patched class: the finder's JUnit (3 of 3), the independent reproducer's (15 of 15), the devil's advocate's probes (3 of 3) and the scale probe (facet counts equal drill-down:
[154, 325, 740], total 740) pass; the patched class also passes both tests against the 10.5.2 jars. - Edge cases, added after the fact checks (8 tests, standalone runner): raw
{-0.0, -5.0}and{NaN with the sign bit set, 5.0}throughfromDoubleFieldand the field constructor, custom longs{Long.MAX_VALUE, Long.MIN_VALUE},{100, 10, 9}on exact range bounds, and{50, 5, 50, 5, 7}(a lower interval that comes back) with exclusive and overlapping ranges: stock main fails 7 of 8 (assertions on and off), 10.5.2 fails 7 of 8 (assertions on), and with the fix all 8 pass (assertions on and off). Not run through gradle.
Three files (RangeFacetCounts, LongRangeFacetCounts, DoubleRangeFacetCounts, +34/-24): buffer each multi-valued document's mapped values, sort them, and feed the distinct ones to the unchanged counter.
Trade-offs
Keeps the counter's precondition true, but every multi-valued document pays a buffer copy and an O(k log k) sort even when already sorted (a sortedness check could avoid it), and any new caller must remember to sort.
What the checks validated
On a9779ef3c193 the new tests and TestRangeFacetCounts pass (29 tests); on ea57360912ed the finder's repro (3 of 3) and TestRangeFacetCounts (22 of 22). Format not checked.
Always search all intervals, with the per-document bitset (shadow-validated) (validated by the checks)
The simplest code, and essentially what #127 did before its review added the shortcut.
Trade-offs
Gives up the ascending fast path that the original review asked for: every value pays a full binary search.
What the checks validated
As a shadow class it turned the independent reproducer's 15 tests green and passes the history probes on 8.10.0, 9.1.0 and main.
Document an ascending-order contract on Multi*Values.nextValue (a possible API decision; not run, not enough alone)
Say that values must arrive in ascending order.
Trade-offs
No counter change, but Lucene's own fromDoubleField, fromFloatField and the field constructor break that contract for raw-bit negatives and would need fixing too, and existing custom sources would stay silently wrong. Paired with those repairs it is a coherent choice a maintainer may prefer: the #127 author relied on sorted input on purpose.
A read-only code search of the default branches of Elasticsearch, OpenSearch and Solr found no use of these range-facet classes, so the exposure is mainly applications that embed Lucene's facet module directly.
Semi-formal: a Z3 model of LongRangeCounter turned its comment "values within a given doc are sorted" into proof obligations. The solver found wrong hits once the order is free, and that raw-bit doubles can arrive unsorted, though never when both are non-negative (why upstream tests in [0, 1) pass). A bounded-exhaustive run of the real counters and JUnit tests followed.
The method in full186 words
Method: SMT solving with Z3 (the semi-formal tier: a hypothesis formed while modelling, then witnessed by the tool), in a hunt unit on facet taxonomy and range counting. The finder tagged it formal; the registry records it as semi-formal because the key obligation was posed as an expected counterexample search from the code comment.
- Obligations on a model of
addMultiValued: with the ascending precondition the per-document hit set equals the true set (unsatisfiable, so it always holds); without it a mismatch exists (satisfiable); raw-bitSortedNumericorder is not ascending in sortable space (satisfiable) but is when both values are non-negative (unsatisfiable), which is why the upstream random tests, all in [0, 1), pass. - A bounded-exhaustive run of the real counters: 93,196 range sequences, 0 mismatches with ascending values, 92,376 sequences mis-count when the same documents are fed in descending order; a 24-seed randomized differential against a brute-force oracle classified every mismatch.
- Then a public-API JUnit on the stock jars, an independent reproducer, read-only reviews by a model from another family (OpenAI GPT-5.6), a devil's advocate, an adjudication, a history sweep and a security scope check.
No prior report found (searched 2026-10-26, upstream re-checked 2026-10-27): GitHub issues and PRs, JIRA, four mailing lists, downstream projects, the web and all 411 open PRs. The PR that added the shortcut (#127) and the one that added unordered sources (#543) never discuss value order.
Searches5 searches
Verdict new, confidence high but not certain. The nearest items introduced the shortcut, added unordered sources, or noted raw-bit double order elsewhere; none describes this failure.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues and PRs (open and closed, migrated LUCENE issues), commits, downstream repositories | 2026-10-26 | two runs of a scripted prior-report search (103 queries in all; 538 distinct hits in the follow-up run, comments of the top 20 read) and 30 GraphQL searches over apache/lucene, apache/lucenenet, Elasticsearch, OpenSearch, Solr and Hibernate Search | no report; related #127 / #10985 (introduced the shortcut on purpose), #543 / #11281 (unordered sources, order not discussed), #11997 (raw-bit multi-valued doubles not ordered within a document; facets unchanged), #11085 (an earlier, different de-dup bug in the same loop) |
| JIRA archive, dev@, java-user@, issues@, commits@ | 2026-10-26 | 20 list and 5 JIRA queries through the tool, plus 22 manual list queries and JIRA queries | no report; the 2021-10 dev@ design thread for multi-valued sources does not discuss order |
| Web | 2026-10-26 | 10 queries | no report; Stack Overflow 59361094 (Lucene.NET 4.8, 2019) shows a similar symptom in code without multi-valued range faceting, before the shortcut existed; SOLR-11043 is Solr's own code |
| Open apache/lucene PRs | 2026-10-26 | the changed files of all 411 open PRs | 7 touch these files; none changes addMultiValued, the multi-valued loops or the Multi*Values contracts (#16439 edits only the single-valued path) |
| Upstream main and search re-check | 2026-10-27 | GitHub API: commits to LongRangeCounter.java, compare a9779ef3c193...a95c754ee003; searches "addMultiValued" (issues), "LongRangeCounter" (PRs) | last commit to the file is still d669ddebc538 (2021); no facet file changed since a9779ef3c193; no new report |
Introduced in 8.10.0 by LUCENE-9946 (#127, merged 2021-05-31 UTC), whose review added the ascending-order shortcut; 9.1.0 (#543) let unordered custom sources reach it. All 40 releases from 8.10.0 to 10.5.2 and main fail; 8.9.0 has no multi-valued range faceting. Never fixed.
Versions tested8 rows
Introduced by #127 "LUCENE-9946: Support multi-value fields in range facet counting" (gsmiller, issue #10985), merged 2021-05-31 UTC (commit date 2021-05-30, -0700) as d669ddebc538; branch_8x backport e1ffcb11d9c2 (lucene-solr #2507, 2021-06-24); first released in 8.10.0. The shortcut entered with PR revision 923d35eb0cee ("optimize the bsearch for multi-valued cases"); the first public trigger, the field constructor over raw-bit doubles with two negatives, existed at once. Exposure widened by #543 (LUCENE-10245, 82703757fe05, 2022-01-10, 9.1.0), which added Multi*Values(Source). Not fixed: LongRangeCounter.java has no commit after d669ddebc538 on main (upstream a95c754ee003) or branch_10x; 10.6.0 (released 2026-10-29) was not run, but branch_10_6 carries a byte-identical LongRangeCounter.java (read).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's test fails 5 of 6 (gradle and standalone, assertions on and off); the randomized test fails; passes with the fix |
main ea57360912ed | affected | the finder's JUnit fails 3 of 3 (assertions on and off); every independent check reproduced it |
| 10.5.2 | affected | this doc's test fails 5 of 6, assertions on and off; the finder's JUnit 3 of 3 |
| 9.1.0 to 10.5.2 (32 releases) | affected | the finder's JUnit (a mechanical port for 9.1.0-10.3.x) fails 3 of 3 on every release; 9.1.0 also with assertions off |
| 8.10.0 to 9.0.0 | field constructor only | the field-constructor probe over raw-bit doubles fails on every release; the source API does not exist yet |
| 8.9.0 | not affected | the multi-valued field is rejected (IllegalStateException: unexpected docvalues type SORTED_NUMERIC); multi-valued range faceting arrived in 8.10.0 (LUCENE-9946) |
| 8.10.0, 9.1.0, main + order-independent shadow | root cause | changing only addMultiValued makes every probe pass: the shortcut is the whole cause |
fix (scratch worktree at a9779ef3c193) | not affected | 7 of 7; facet module 313 tests, 0 failures |
Confirmed by an independent reproducer, a devil's advocate, read-only reviews by a model from another family (OpenAI GPT-5.6), the adjudication and the follow-up's history and security runs; this doc's tests and fix were run with gradle on main a9779ef3c193.
Checks and verdicts516 words
Each executing check wrote and ran its own test or probe on the stock jars; the ascending-order controls pass everywhere.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Z3 obligations, a bounded-exhaustive run of the real counters (92,376 of 93,196 descending sequences mis-count, 0 ascending), and a 3-test public-API JUnit: 3 of 3 fail on main (assertions on and off), 10.5.2 and 9.12.3. Its sort-per-document fix passes the repro and TestRangeFacetCounts 22 of 22. |
| cross-model review, refute lens | not real, low (procedural) | Read-only, ran no Java; its "not real" is procedural (it could not execute, and its notes say static reading strongly favours a real defect): agreed with the root cause and that the inputs are legal, rated the intended-behaviour argument moderate, and 9 of its 11 evidence items support a real bug. A separate run re-ran the repro: 3 of 3 fail. |
| independent reproducer | real | Own JUnits from the claim alone: 9 of 15 fail on main (assertions on and off), 10.5.2 and 9.12.3, 8 of 13 on 9.1.0; exclusive and overlapping counters, custom long and double sources, fromFloatField, a decreasing decoder, the dimension total; the 6 controls pass. A shadow LongRangeCounter that changes only addMultiValued turns all 15 green. |
| cross-model review, intended lens | real, medium | The model read the code only and ran no Java: intended-behaviour argument moderate, root cause agrees. A separate verifier in the same step ran its own test (custom long source 30, 5): 2 of 4 fail on main and 10.5.2; its ascending controls pass. |
| devil's advocate | real, high | No intended or misuse argument survives (section above). Own probes fail on main (assertions on and off) and 10.5.2, including fromDoubleField(f).toMultiLongValuesSource(); its sortable-encoding control passes. |
| adjudication | confirmed, high | Re-ran the finder's JUnit with a new seed (3 of 3 fail) and its own main() probe: custom long source 30, 5 and raw-bit -1.0, -5.0 give facet 0 vs drill-down 1, assertions on and off. Advised leading with the custom-source trigger. |
| Follow-up: history and security | 8.10.0 to 10.5.2 affected; security none | Release sweep over 41 versions; root-cause shadow on 8.10.0, 9.1.0 and main. Security scope check (300 random documents, 6 range sets, 4 sources, 2 seeds): counts never too high, no effect across documents, no slowdown. |
re-check on main a9779ef3c193 | still reproduces | The finder's JUnit fails 3 of 3 with assertions on and off, with the same messages as on ea57360912ed. |
| this doc's tests and fix | reproduced; fix validated | Gradle in a scratch worktree: 6 of 7 fail on stock; the recommended fix passes the new tests, TestRangeFacetCounts and the whole facet module, assertions on and off. |
| fact-check follow-up: edge cases | edge cases reproduce; fix passes | An 8-test JUnit for the edge values the checks raised (signed zero, a NaN with the sign bit set, extreme longs, exact bounds, a lower interval that comes back): 7 of 8 fail on stock main (assertions on and off) and on 10.5.2 (assertions on); the field-constructor NaN control passes; with the recommended fix 8 of 8 pass. |
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 369 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,366 words
- Registry title
- Multi-valued range facet counts drop matches when a document's values do not arrive in ascending order (custom MultiLongValuesSource/MultiDoubleValuesSource, or raw-bit doubles with two or more negative values): LongRangeCounter.addMultiValued assumes sorted values
- Area
- facet / range facet counts (LongRangeCounter multi-valued path, exclusive and overlapping counters)
- Priority
- Medium, rank 169: Multi-valued range facet counts come out silently too low for the javadoc's own distance-source use case, which is more plausible than the fastMatchQuery trigger.
- Impact class
- silent wrong results
- Impact note
- Applications that range-facet multi-valued numbers whose values reach the counter out of ascending order get per-range counts (and FacetResult.value) that are silently too low and disagree with the range's own drill-down query: a custom MultiLongValuesSource or MultiDoubleValuesSource such as per-request distances to each of a document's locations (the use case the class javadoc names), MultiDoubleValuesSource.fromDoubleField/fromFloatField or the DoubleRangeFacetCounts(String, ...) constructor over raw-bit SortedNumeric doubles when a document has two or more negative values, or fromField with a decreasing decoder; default codec, IndexWriterConfig and searcher, any scale (one document with two values suffices), released 8.10.0-10.6.0 (10.6.0 by reading); long fields, fromLongField/fromIntField and sortable-encoded doubles are not affected, and counts are only ever too low, never too high.
- Summary
- You compute range facet counts over a multi-valued source, for example the distances from the user to each of a document's locations computed by your own MultiDoubleValuesSource, or doubles stored as raw bits in a SortedNumeric field and read with fromDoubleField or the DoubleRangeFacetCounts(String, ...) constructor. You expect each range to count every document that has at least one value in it, which is exactly what the range's own drill-down query finds. Instead, when a document's values arrive in descending order (a custom source in its natural order, or raw-bit doubles whenever the document has two or more negative values), the counter skips every later value that sorts below an interval it already counted for that document, so a document with distances {3.0, 0.5} is missing from '< 1 km' and '< 2 km'.
- Introduced
- d669ddebc538 (LUCENE-9946, PR #127 'Support multi-value fields in range facet counting', by GitHub gsmiller), released first in 8.10.0 through the lucene-solr branch_8x backport e1ffcb11d9c2 (PR #2507) [read: git log -S 'multiValuedDocLastSeenElementaryInterval' finds only d669ddebc538; git blame -w on LongRangeCounter.java:45-50 and 117-161; read-only GitHub API for the PR revisions and the backport's containment in the 8.10.0 but not the 8.9.0 release commit; exec: 8.9.0 rejects multi-valued range faceting, 8.10.0 fails]. The ascending-order shortcut came in during review (PR revision 923d35eb0cee 'optimize the bsearch for multi-valued cases', after rmuir asked about using the sortedness of SORTED_NUMERIC); the PR's earlier search from interval 0 did not depend on order. Exposure widened by 82703757fe05 (LUCENE-10245, PR #543, 9.1.0), which fed MultiLong/MultiDoubleValues, whose nextValue() promises no order, into the unchanged counter. A shortcut that was valid while SortedNumericDocValues was the only multi-valued input, not a later regression (2021-05-30 (d669ddebc538, -0700; 2021-05-31 UTC); branch_8x backport 2021-06-24; exposure widened 2022-01-10 (82703757fe05))
- First affected version
- 8.10.0 [exec] (DoubleRangeFacetCounts(String, ...) over raw-bit doubles with two or more negative values per document); 9.1.0 [exec] for the custom-source and fromDoubleField/fromFloatField/fromField(decoder) triggers
- Affected versions
- String-constructor raw-bit trigger: every release from 8.10.0 to 10.6.0 (8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0-9.12.3, 10.0.0-10.5.2, and 10.6.0 by reading) and main ea57360912ed; source-based triggers: every release from 9.1.0 to 10.6.0 (10.6.0 by reading) and main [exec: 40 releases with the String-constructor probe and the 32 releases from 9.1.0 on with the source-path repro, each failing with the bug's assertion and with ascending controls passing; -da also on 8.10.0, 9.1.0, 9.12.3, 10.5.2 and main]; not affected: 8.9.0 and earlier, where multi-valued range faceting does not exist (IllegalStateException on the SORTED_NUMERIC field) [exec]; 10.6.0 (released 2026-10-29) very likely affected (LongRangeCounter.java unchanged on upstream main and branch_10x as of 2026-10-26) but not verified [read]; not fixed anywhere
- 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
- semi-formal: Z3; also reached by exhaustive check, fuzzing/PBT
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new, confidence high but not certain: the follow-up's scripted prior-report search with 58 queries (22 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list over dev@, java-user@, issues@ and commits@, 5 JIRA-archive, 6 web; 538 distinct hits, none the same bug, comments of the top 20 read), the finder's scripted prior-report search (45 queries) and 25+ gh searches, 30 manual GraphQL searches (GitHub's search rate limit was exhausted) over apache/lucene, apache/lucenenet, Elasticsearch, OpenSearch, Solr and Hibernate Search, 22 manual list queries, JIRA queries, and a file-level scan of all 411 open apache/lucene PRs: 7 touch these files and none changes addMultiValued, the multi-valued loops or the Multi*Values contracts; upstream main e1c4184cb983 and branch_10x still carry LongRangeCounter.java from d669ddebc538); related, not reports of this bug: #127 / #10985 (LUCENE-9946: introduced the shortcut on purpose while SortedNumericDocValues was the only multi-valued input; nobody discussed unordered inputs), #543 / #11281 (LUCENE-10245: added unordered sources without discussing order), #11997 (jpountz noted that raw-bit multi-valued doubles are not ordered within a document; facets were not changed), #11085 (LUCENE-10047: an earlier de-dup bug in the same loop, fixed in 9.0.0), StackOverflow 59361094 (Lucene.NET 4.8, a similar symptom without multi-valued range faceting, predates the shortcut), SOLR-11043 (Solr's own code); gaps: GitHub REST search rate-limited (repeated through GraphQL with the same strings), apache/lucene has no GitHub Discussions, list search covers dev@, java-user@, issues@ and commits@ only
- 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 facet module is identical, and on the release jars from 8.9.0 to 10.5.2 (the history sweep). The recommended fix was validated in the scratch worktree: the new tests,TestRangeFacetCountsand the whole facet module (313 tests), assertions on and off, plus the module's format, ecjLint and forbiddenApis checks. After the fact checks, an 8-test edge-case JUnit (signed zero, a NaN with the sign bit set, extreme longs, exact bounds, a lower interval that comes back) ran through the standalone runner on main (stock and with the fix, assertions on and off) and on 10.5.2.
- Finding record
lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeCounter.java:23-24 ('Works for both single- and multi-valued cases (assuming you use it correctly)'), :45-50 ('this takes advantage of the fact that values within a given doc are sorted'), :117-161 (addMultiValued; early return at :127-130, binary search from lastSeen+1 at :139-160)lucene/facet/src/java/org/apache/lucene/facet/MultiLongValues.java:36-46 and MultiDoubleValues.java:36-46 (nextValue: 'Iterates to the next value in the current document', no ordering); MultiDoubleValuesSource.java:76-88 (fromField(decoder); fromDoubleField = Double::longBitsToDouble; fromFloatField = Float.intBitsToFloat)lucene/facet/src/java/org/apache/lucene/facet/range/RangeFacetCounts.java:160-182 (String-field multi-valued loop with mapDocValue); DoubleRangeFacetCounts.java:36-56 (class javadoc: per-request values such as distance; String constructor: multi-valued SortedNumeric, assumes DoubleDocValuesField encoding), :217-240 (source loop), :272-275 (mapDocValue = NumericUtils.sortableDoubleBits); LongRangeFacetCounts.java:190-213 (source loop)
- The finding in 30 seconds
lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeCounter.java:45-49, :52-58, :66-68, :90-92, :116-161, :169-170lucene/facet/src/java/org/apache/lucene/facet/range/LongRangeFacetCounts.java:200-212; range/DoubleRangeFacetCounts.java:53-56, :227-239, :272-275; range/RangeFacetCounts.java:169-181, :213lucene/facet/src/java/org/apache/lucene/facet/MultiLongValues.java:42-46; MultiDoubleValues.java:42-46; MultiDoubleValuesSource.java:80-83; range/ExclusiveLongRangeCounter.java:122-132
- Who is affected
lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRangeFacetCounts.java:36-39, :48-56; range/LongRangeFacetCounts.java:33-37; MultiLongValuesSource.java:37-41lucene/core/src/java/org/apache/lucene/document/DoubleDocValuesField.java:20-21lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:1268; MultiValuesSourceTestCase.java:83
- Why it looks unintended
lucene/facet/src/java/org/apache/lucene/facet/MultiLongValues.java:22-46; MultiDoubleValues.java:22-46; range/LongRangeCounter.java:40, :45-49; range/DoubleRange.java:298-302, :340; range/LongRange.java:285-289, :327lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:995-1015, :1053, :1072, :1338-1358, :1399, :1420lucene/core/src/java/org/apache/lucene/document/SortedNumericDocValuesField.java:33-38
- Suggested fix
lucene/sandbox/src/java/org/apache/lucene/sandbox/facet/cutters/ranges/LongRangeFacetCutter.java:324-352 (read)
- TestRangeFacetUnsortedMultiValues.java (JUnit test):
repro/LFV-303/src/org/apache/lucene/facet/range/TestRangeFacetUnsortedMultiValues.java(97 lines)
A step-by-step picture made in parallel with this doc from an executed probe of the custom-source example on main a9779ef3c193 (stock and the finder's sort-per-document fix, option B, assertions on and off); every number on screen is parsed from the probe's output. The diagram's last step shows that option B fix; the summary card (the PNG) names the recommended order-independent addMultiValued first, and the same probe re-run with it shadowed in also gives [1, 1, 1].
assets/LFV-303/bug.png(369 KB)
![Summary card: a number line split into the elementary intervals E0 to E4 of the ranges < 1 km, < 2 km and < 5 km. The document's first value, 3.0, marks E3 and sets lastSeen to 3; the second value, 0.5, is at or below boundaries[3], so addMultiValued returns and E1 is never marked. Expected counts (each range's drill-down): 1, 1, 1; actual getAllChildren on main: 0, 0, 1.](../assets/LFV-303/bug.png)