All findings · Lucene finding · LFV-303 · facet-range-counter-assumes-ascending-multivalues

Multi-valued range facet counts drop values that do not arrive in ascending order

Lucene's range facet counter assumes a document's values arrive smallest first and skips any later value at or below what it already counted. A document whose distances arrive as 3.0 km, then 0.5 km, is missing from "< 1 km" and "< 2 km", though their drill-down queries find it.

Summary

Priority
Medium, rank 169Multi-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
silent wrong results
Since
8.10.0 (2021), LUCENE-9946 (#127); custom sources since 9.1.0 (#543)introduced 2021-05-30 · about 5 years
Affected
every release from 8.10.0 to 10.6.0 (10.6.0 by reading), and main
Status
documented
Found by
Z3 SMT model of the range counter (semi-formal)

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

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.

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.
One document's distances, 3.0 km then 0.5 km, through the range counter: 3.0 marks the interval [2, 5), 0.5 is at or below that interval's top and is skipped, so "< 1 km" and "< 2 km" end at 0 while their drill-downs find the document. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You count distance ranges "< 1 km", "< 2 km" and "< 5 km" with DoubleRangeFacetCounts. A document has two locations, so your own MultiDoubleValuesSource returns two distances in storage order: 3.0 km, then 0.5 km.
  2. You expect the document in all three ranges, counts 1, 1, 1: each range's drill-down query (DoubleRange.getQuery) matches it.
  3. 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.
  4. 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 fromDoubleField and the DoubleRangeFacetCounts(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.
  5. 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.

Who is affected

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.

Terms

  • Range facet counts. LongRangeFacetCounts and DoubleRangeFacetCounts count, 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 one Long.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 use ExclusiveLongRangeCounter, overlapping ones OverlappingLongRangeCounter (:52-58).
  • Multi-valued sources. MultiLongValues.nextValue() and MultiDoubleValues.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 encoding fromDoubleField decodes (Double::longBitsToDouble, MultiDoubleValuesSource.java:80-83) and the DoubleRangeFacetCounts(String, ...) constructor documents ("assumes that the field was indexed with DoubleDocValuesField", range/DoubleRangeFacetCounts.java:53-56). SortedNumeric returns the longs in signed order, and for negative doubles the raw bits sort in reverse: 0xBFF0000000000000 (-1.0) is below 0xC014000000000000 (-5.0).

The bug

  1. For a document with more than one value the callers call startMultiValuedDoc(), addMultiValued(v) for each value, then endMultiValuedDoc() (range/LongRangeFacetCounts.java:200-212, range/DoubleRangeFacetCounts.java:227-239, range/RangeFacetCounts.java:169-181). Only a document for which endMultiValuedDoc() returns true is added to the total (RangeFacetCounts.java:179-181, :213).
  2. addMultiValued (range/LongRangeCounter.java:116-161) keeps multiValuedDocLastSeenElementaryInterval, reset to -1 per document (:66-68). If it is set and v <= boundaries[lastSeen], it returns at once (:125-130); otherwise it binary-searches only the intervals above lastSeen (:139-160). The field comment: "this takes advantage of the fact that values within a given doc are sorted" (:45-49).
  3. Distances 3.0, then 0.5: 3.0 lands in E3 and lastSeen becomes 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.
  4. If the first value lies above every range ({500, 5} with [0, 10), [10, 100)), lastSeen is the last interval, every later value is skipped, and the document also drops out of the total.
  5. Raw-bit doubles with the sign bit set come in reverse order through fromDoubleField and through the field constructor, which maps with NumericUtils.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: through fromDoubleField, 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).
  6. 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).
  7. 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.

The numbers

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 (DoubleDocValuesField encodes "via Double#doubleToRawLongBits"), fromDoubleField decodes it, the MultiLongValuesSource javadoc recommends fromDoubleField(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. fromFloatField over raw float bits behaves the same.
  • Not affected (executed controls): long fields through fromLongField/fromIntField; DoubleField (sortable encoding) read with fromField(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, IndexWriterConfig and searcher; no setting. But each trigger needs a less common API shape: an @lucene.experimental custom source, hand-encoded raw-bit multi-valued doubles, or a decreasing decoder.
  • Effect. Per-range counts and FacetResult.value are 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. LongValueFacetCounts de-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).

Minimal repro

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.

TestRangeFacetUnsortedMultiValues.java (JUnit test)97 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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());
      }
    }
  }
}
Command
# 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).

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • MultiLongValues and MultiDoubleValues promise 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-46 and MultiDoubleValues.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-private LongRangeCounter (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", revision 923d35eb0cee added the last-seen-interval search. At the time every multi-valued input was sorted. #543 (LUCENE-10245, 9.1.0) then fed unordered Multi*Values into 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.

Suggested fix

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.

Order-independent addMultiValued with the ascending fast path kept (validated) (recommended)

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.
  • OverlappingLongRangeCounter already keeps its own per-document interval bitset; a maintainer may prefer to share it.
  • LongRangeCounter.java is 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; with TestRangeFacetCounts, 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} through fromDoubleField and 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.

Sort and de-duplicate each document's values at the callers (the finder's patch, validated)

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.

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.

Downstream note

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.

How it was found

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-bit SortedNumeric order 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (open and closed, migrated LUCENE issues), commits, downstream repositories2026-10-26two 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 Searchno 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-2620 list and 5 JIRA queries through the tool, plus 22 manual list queries and JIRA queriesno report; the 2021-10 dev@ design thread for multi-valued sources does not discuss order
Web2026-10-2610 queriesno 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 PRs2026-10-26the changed files of all 411 open PRs7 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-check2026-10-27GitHub 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

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis doc's test fails 5 of 6 (gradle and standalone, assertions on and off); the randomized test fails; passes with the fix
main ea57360912ed affectedthe finder's JUnit fails 3 of 3 (assertions on and off); every independent check reproduced it
10.5.2 affectedthis 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) affectedthe 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 onlythe field-constructor probe over raw-bit doubles fails on every release; the source API does not exist yet
8.9.0 not affectedthe 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 causechanging only addMultiValued makes every probe pass: the shortcut is the whole cause
fix (scratch worktree at a9779ef3c193) not affected7 of 7; facet module 313 tests, 0 failures

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateZ3 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 realOwn 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, mediumThe 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, highNo 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, highRe-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 noneRelease 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 reproducesThe 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 validatedGradle 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 passesAn 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,366 words

Finding record

LFV-303, snapshot of 2026-11-06

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 main ea57360912ed, 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, TestRangeFacetCounts and 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.

Lucene and upstream sources

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-170
  • lucene/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, :213
  • lucene/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-41
  • lucene/core/src/java/org/apache/lucene/document/DoubleDocValuesField.java:20-21
  • lucene/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, :327
  • lucene/facet/src/test/org/apache/lucene/facet/range/TestRangeFacetCounts.java:995-1015, :1053, :1072, :1338-1358, :1399, :1420
  • lucene/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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

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].

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.