diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index 2cf190740d8f..6263f4af2ab4 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -383,6 +383,8 @@ Optimizations * GITHUB#16380: Speed up filtered disjunction queries when the filter is part of the index sort by advancing the filter before calculating impacts. (Alan Woodward) +* GITHUB#16180: Add bulk intoBitSet for SortedDocValues ordinal ranges. (Prithvi S) + Bug Fixes --------------------- * GITHUB#16350: Disable bulk-scoring in monitor queries. (Alan Woodward) diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java index 25eefc2660c7..fa6d59a149a7 100644 --- a/lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java +++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene90/Lucene90DocValuesProducer.java @@ -1485,6 +1485,15 @@ public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOExcept advance(upTo); } } + + @Override + public void ordinalRangeIntoBitSet( + int fromDoc, int toDoc, long minOrd, long maxOrd, FixedBitSet bitSet, int offset) + throws IOException { + // Dense packed ordinals: bulk evaluate via the same SIMD path as NumericDocValues + Lucene90DocValuesProducer.rangeIntoBitSet( + values, fromDoc, toDoc, minOrd, maxOrd, bitSet, offset); + } }; } else if (ordsEntry.docsWithFieldOffset >= 0) { // sparse but non-empty final IndexedDISI disi = @@ -1583,6 +1592,15 @@ public int docIDRunEnd() throws IOException { public void intoBitSet(int upTo, FixedBitSet bitSet, int offset) throws IOException { ords.intoBitSet(upTo, bitSet, offset); } + + @Override + public void ordinalRangeIntoBitSet( + int fromDoc, int toDoc, long minOrd, long maxOrd, FixedBitSet bitSet, int offset) + throws IOException { + // Delegate to the underlying NumericDocValues which already has a (possibly SIMD) + // rangeIntoBitSet override. + ords.rangeIntoBitSet(fromDoc, toDoc, minOrd, maxOrd, bitSet, offset); + } }; } diff --git a/lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java b/lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java index 15bc16a12810..1547920300bf 100644 --- a/lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java +++ b/lucene/core/src/java/org/apache/lucene/index/SortedDocValues.java @@ -18,6 +18,7 @@ import java.io.IOException; import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.FixedBitSet; import org.apache.lucene.util.automaton.CompiledAutomaton; /** @@ -95,6 +96,32 @@ public TermsEnum termsEnum() throws IOException { return new SortedDocValuesTermsEnum(this); } + /** + * Fills {@code bitSet} with the doc IDs in {@code [fromDoc, toDoc)} whose ordinals are in {@code + * [minOrd, maxOrd]}. This is a bulk operation that avoids per-doc virtual dispatch overhead. + * + *

The default implementation falls back to per-doc evaluation via {@link #advanceExact} and + * {@link #ordValue}. Subclasses with random-access storage (e.g., dense fixed-bitsPerValue + * fields) can override this for significantly better performance. + * + * @param fromDoc first doc ID to evaluate (inclusive) + * @param toDoc last doc ID to evaluate (exclusive) + * @param minOrd lower bound of the ordinal range (inclusive) + * @param maxOrd upper bound of the ordinal range (inclusive) + * @param bitSet the bitset to fill + * @param offset subtracted from each doc ID before setting the bit + */ + public void ordinalRangeIntoBitSet( + int fromDoc, int toDoc, long minOrd, long maxOrd, FixedBitSet bitSet, int offset) + throws IOException { + for (int doc = docID() >= fromDoc ? docID() : advance(fromDoc); doc < toDoc; doc = nextDoc()) { + long ord = ordValue(); + if (ord >= minOrd && ord <= maxOrd) { + bitSet.set(doc - offset); + } + } + } + /** * Returns a {@link TermsEnum} over the values, filtered by a {@link CompiledAutomaton} The enum * supports {@link TermsEnum#ord()}. diff --git a/lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java b/lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java index 64077fa4efdc..632ad1238efb 100644 --- a/lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java +++ b/lucene/core/src/java/org/apache/lucene/search/DocValuesRangeIterator.java @@ -99,8 +99,8 @@ public static DocValuesRangeIterator forOrdinalRange( }; return skipper == null ? new DocValuesValueRangeIterator(values, check, 2) - : new BulkOrdinalRangeIterator( - values, new SkipBlockRangeIterator(skipper, min, max), check, 2); + : new BulkSortedRangeIterator( + values, new SkipBlockRangeIterator(skipper, min, max), check, 2, min, max); } /** @@ -474,9 +474,43 @@ void intoMaybeBlock(int blockStart, int blockEnd, FixedBitSet bitSet, int offset } /** - * Bulk range iterator over ordinal (sorted / sorted-set) doc values. There is no columnar - * range-decode like the numeric path, so MAYBE blocks confirm the ordinal predicate one doc at a - * time, visiting only docs that have a value. + * Bulk range iterator over single-valued sorted (ordinal) doc values. Delegates MAYBE blocks to + * {@link SortedDocValues#ordinalRangeIntoBitSet} so that dense packed implementations can use + * direct (SIMD) range evaluation over the ordinal array. + */ + private static final class BulkSortedRangeIterator extends BulkBlockRangeIterator { + + private final SortedDocValues sortedValues; + private final long minOrd; + private final long maxOrd; + + private BulkSortedRangeIterator( + SortedDocValues values, + SkipBlockRangeIterator blockIterator, + IOBooleanSupplier predicate, + float matchCost, + long minOrd, + long maxOrd) { + super(values, blockIterator, predicate, matchCost); + this.sortedValues = values; + this.minOrd = minOrd; + this.maxOrd = maxOrd; + } + + @Override + void intoMaybeBlock(int blockStart, int blockEnd, FixedBitSet bitSet, int offset) + throws IOException { + // sortedValues is the same instance as disi, so a preceding matches() call may have + // moved it beyond blockStart. Adjust the starting point. + int from = Math.max(blockStart, sortedValues.docID()); + sortedValues.ordinalRangeIntoBitSet(from, blockEnd, minOrd, maxOrd, bitSet, offset); + } + } + + /** + * Bulk range iterator over ordinal (sorted-set) doc values. There is no columnar range-decode + * like the numeric path, and for multi-valued we must check the predicate (which walks the + * per-doc ords), so MAYBE blocks confirm by visiting only docs that have a value. */ private static final class BulkOrdinalRangeIterator extends BulkBlockRangeIterator { diff --git a/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalRangeIterator.java b/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalRangeIterator.java index fe8915656878..c942cba818fa 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalRangeIterator.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalRangeIterator.java @@ -134,7 +134,12 @@ public int getValueCount() { @Override public boolean advanceExact(int target) { - throw new UnsupportedOperationException(); + if (docHasValue(target)) { + doc = target; + return true; + } + doc = target; + return false; } @Override @@ -349,7 +354,12 @@ public long getValueCount() { @Override public boolean advanceExact(int target) { - throw new UnsupportedOperationException(); + if (docHasValue(target)) { + doc = target; + return true; + } + doc = target; + return false; } @Override diff --git a/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalSetIterator.java b/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalSetIterator.java index b5734d08e4d5..a76c02eca483 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalSetIterator.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestDocValuesOrdinalSetIterator.java @@ -209,7 +209,12 @@ public int getValueCount() { @Override public boolean advanceExact(int target) { - throw new UnsupportedOperationException(); + if (docHasValue(target)) { + doc = target; + return true; + } + doc = target; + return false; } @Override @@ -500,7 +505,12 @@ public long getValueCount() { @Override public boolean advanceExact(int target) { - throw new UnsupportedOperationException(); + if (docHasValue(target)) { + doc = target; + return true; + } + doc = target; + return false; } @Override diff --git a/lucene/core/src/test/org/apache/lucene/search/TestDocValuesRangeIteratorIntoBitSet.java b/lucene/core/src/test/org/apache/lucene/search/TestDocValuesRangeIteratorIntoBitSet.java new file mode 100644 index 000000000000..73eb90bbcdd6 --- /dev/null +++ b/lucene/core/src/test/org/apache/lucene/search/TestDocValuesRangeIteratorIntoBitSet.java @@ -0,0 +1,1095 @@ +/* + * 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.search; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Random; +import java.util.function.LongUnaryOperator; +import org.apache.lucene.codecs.lucene104.Lucene104Codec; +import org.apache.lucene.document.Document; +import org.apache.lucene.document.NumericDocValuesField; +import org.apache.lucene.document.SortedDocValuesField; +import org.apache.lucene.document.SortedNumericDocValuesField; +import org.apache.lucene.index.DirectoryReader; +import org.apache.lucene.index.DocValues; +import org.apache.lucene.index.DocValuesSkipper; +import org.apache.lucene.index.IndexWriter; +import org.apache.lucene.index.IndexWriterConfig; +import org.apache.lucene.index.LeafReaderContext; +import org.apache.lucene.index.NumericDocValues; +import org.apache.lucene.index.SortedDocValues; +import org.apache.lucene.index.SortedNumericDocValues; +import org.apache.lucene.search.BooleanClause.Occur; +import org.apache.lucene.store.Directory; +import org.apache.lucene.util.BytesRef; +import org.apache.lucene.util.FixedBitSet; + +/** + * Tests the {@code intoBitSet()} bulk path of {@link DocValuesRangeIterator} over single-valued + * numeric doc values, including YES, YES_IF_PRESENT, and MAYBE block states. + */ +public class TestDocValuesRangeIteratorIntoBitSet extends BaseDocValuesSkipperTests { + + // Use enough docs to span at least 4 skip blocks (default skip block size = 4096 docs). + private static final int DOC_COUNT = 4096 * 4; + + private Directory dir; + private DirectoryReader reader; + private IndexSearcher searcher; + + private long[] values; + + @Override + public void setUp() throws Exception { + super.setUp(); + dir = newDirectory(); + IndexWriterConfig iwc = new IndexWriterConfig(); + iwc.setCodec(new Lucene104Codec()); + IndexWriter w = new IndexWriter(dir, iwc); + values = new long[DOC_COUNT]; + for (int i = 0; i < DOC_COUNT; i++) { + values[i] = i % 100; // deterministic: 0..99 repeating + Document doc = new Document(); + doc.add(NumericDocValuesField.indexedField("age", values[i])); + doc.add(SortedNumericDocValuesField.indexedField("multi_age", values[i] - 1000)); + doc.add(SortedNumericDocValuesField.indexedField("multi_age", values[i])); + doc.add(NumericDocValuesField.indexedField("score", (i * 7L) % 1000)); + w.addDocument(doc); + } + w.forceMerge(1); + reader = DirectoryReader.open(w); + w.close(); + searcher = new IndexSearcher(reader); + } + + @Override + public void tearDown() throws Exception { + reader.close(); + dir.close(); + super.tearDown(); + } + + /** + * Verifies that SortedNumericDocValuesRangeQuery wires a DocValuesRangeIterator two-phase when + * the field has a skip index and is single-valued. + */ + public void testTwoPhaseIteratorIsWired() throws Exception { + LeafReaderContext ctx = reader.leaves().get(0); + // Use a range that won't be rewritten to MatchAll or MatchNone + Query q = SortedNumericDocValuesField.newSlowRangeQuery("age", 20, 40); + Weight weight = q.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, 1f); + ScorerSupplier ss = weight.scorerSupplier(ctx); + assertNotNull("ScorerSupplier must not be null", ss); + + assertTrue( + "ScorerSupplier must be a ConstantScoreScorerSupplier", + ss instanceof ConstantScoreScorerSupplier); + DocIdSetIterator iter = ((ConstantScoreScorerSupplier) ss).iterator(Long.MAX_VALUE); + TwoPhaseIterator twoPhase = TwoPhaseIterator.unwrap(iter); + assertTrue( + "Range query on single-valued field with skip index must use a DocValuesRangeIterator," + + " but got: " + + (twoPhase == null ? iter.getClass() : twoPhase.getClass()).getSimpleName(), + twoPhase instanceof DocValuesRangeIterator); + } + + public void testMultiValuedTwoPhaseIteratorIsWired() throws Exception { + LeafReaderContext ctx = reader.leaves().get(0); + Query q = SortedNumericDocValuesField.newSlowRangeQuery("multi_age", 20, 40); + Weight weight = q.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, 1f); + ScorerSupplier ss = weight.scorerSupplier(ctx); + assertNotNull("ScorerSupplier must not be null", ss); + + assertTrue( + "ScorerSupplier must be a ConstantScoreScorerSupplier", + ss instanceof ConstantScoreScorerSupplier); + DocIdSetIterator iter = ((ConstantScoreScorerSupplier) ss).iterator(Long.MAX_VALUE); + TwoPhaseIterator twoPhase = TwoPhaseIterator.unwrap(iter); + assertTrue( + "Range query on multi-valued field with skip index must use a DocValuesRangeIterator," + + " but got: " + + (twoPhase == null ? iter.getClass() : twoPhase.getClass()).getSimpleName(), + twoPhase instanceof DocValuesRangeIterator); + assertEquals( + searcher.count(SortedNumericDocValuesField.newSlowRangeQuery("age", 20, 40)), + searcher.count(q)); + } + + public void testSingleFieldRangeCorrectness() throws Exception { + // Count expected matches by scanning values[] directly + int expected = 0; + for (long v : values) { + if (v >= 20 && v <= 40) expected++; + } + assertTrue("Should have some matches with controlled data", expected > 0); + + Query q = SortedNumericDocValuesField.newSlowRangeQuery("age", 20, 40); + assertEquals("Single-field count must match linear scan", expected, searcher.count(q)); + } + + public void testMultiFieldRangeCorrectness() throws Exception { + int expectedBoth = 0; + for (int i = 0; i < DOC_COUNT; i++) { + long age = values[i]; + long score = (i * 7L) % 1000; + if (age >= 20 && age <= 40 && score >= 100 && score <= 500) expectedBoth++; + } + + Query ageOnly = SortedNumericDocValuesField.newSlowRangeQuery("age", 20, 40); + Query scoreOnly = SortedNumericDocValuesField.newSlowRangeQuery("score", 100, 500); + Query both = + new BooleanQuery.Builder().add(ageOnly, Occur.FILTER).add(scoreOnly, Occur.FILTER).build(); + + assertEquals("Multi-field count must match linear scan", expectedBoth, searcher.count(both)); + assertTrue( + "Conjunction must be <= age-only count", searcher.count(both) <= searcher.count(ageOnly)); + assertTrue( + "Conjunction must be <= score-only count", + searcher.count(both) <= searcher.count(scoreOnly)); + } + + public void testResultsAreRepeatable() throws Exception { + Query q = + new BooleanQuery.Builder() + .add(SortedNumericDocValuesField.newSlowRangeQuery("age", 20, 40), Occur.FILTER) + .add(SortedNumericDocValuesField.newSlowRangeQuery("score", 100, 500), Occur.FILTER) + .build(); + + int count1 = searcher.count(q); + int count2 = searcher.count(q); + assertEquals("Results must be deterministic", count1, count2); + } + + /** + * Single-field range with a restrictive second clause exercises the single-field bitset path via + * DenseConjunctionBulkScorer. Uses a second range query on a different field as the restrictive + * clause — MatchAllDocsQuery is not used because BooleanQuery rewrites it away. + */ + public void testSingleFieldWithRestrictiveSecondClause() throws Exception { + // age in [20,40] AND score in [0,0]..score=0 only for docs where (i*7)%1000==0, i.e., i=0 + Query ageQ = SortedNumericDocValuesField.newSlowRangeQuery("age", 0, 99); // matches all + Query scoreQ = SortedNumericDocValuesField.newSlowRangeQuery("score", 0, 0); // very restrictive + + int expectedScore0 = 0; + for (int i = 0; i < DOC_COUNT; i++) { + if ((i * 7L) % 1000 == 0) expectedScore0++; + } + + Query combined = + new BooleanQuery.Builder().add(ageQ, Occur.FILTER).add(scoreQ, Occur.FILTER).build(); + + assertEquals("Should match only docs where score=0", expectedScore0, searcher.count(combined)); + } + + public void testIntoBitSetAdvancesWhenUpToIsBlockBoundary() throws Exception { + long queryMin = 10; + long queryMax = 20; + + NumericDocValues values = docValues(queryMin, queryMax); + DocValuesSkipper skipper = docValuesSkipper(queryMin, queryMax, true); + DocValuesRangeIterator iter = + DocValuesRangeIterator.forRange(values, skipper, queryMin, queryMax); + DocIdSetIterator approximation = iter.approximation(); + assertEquals(0, approximation.nextDoc()); + + FixedBitSet bitSet = new FixedBitSet(2048); + iter.intoBitSet(128, bitSet, 0); + + assertEquals("All docs in the YES block must match", 128, bitSet.cardinality()); + assertTrue( + "approximation must skip the NO block that starts at the block boundary, but was " + + approximation.docID(), + approximation.docID() >= 512); + + // Continue filling the bitset in a second window, as DenseConjunctionBulkScorer does. + iter.intoBitSet(2048, bitSet, 0); + FixedBitSet expected = new FixedBitSet(2048); + values = docValues(queryMin, queryMax); + for (int d = values.nextDoc(); + d != DocIdSetIterator.NO_MORE_DOCS && d < 2048; + d = values.nextDoc()) { + if (values.longValue() >= queryMin && values.longValue() <= queryMax) { + expected.set(d); + } + } + FixedBitSet diff = expected.clone(); + diff.andNot(bitSet); + assertEquals("Second intoBitSet window must not miss matching docs", 0, diff.cardinality()); + } + + /** Directly tests intoBitSet() against a linear scan reference. */ + public void testIntoBitSetMatchesLinearScan() throws Exception { + doTestIntoBitSetMatchesLinearScan(reader); + } + + /** Large-scale version — 100 skip blocks */ + @Nightly + public void testIntoBitSetMatchesLinearScanHuge() throws Exception { + try (Directory hugeDir = newDirectory()) { + buildIndex(hugeDir, 4096 * 100); + try (DirectoryReader r = DirectoryReader.open(hugeDir)) { + doTestIntoBitSetMatchesLinearScan(r); + } + } + } + + private void doTestIntoBitSetMatchesLinearScan(DirectoryReader r) throws Exception { + LeafReaderContext ctx = r.leaves().get(0); + int maxDoc = ctx.reader().maxDoc(); + int windowSize = DenseConjunctionBulkScorer.WINDOW_SIZE; // 4096 + + FixedBitSet expected = new FixedBitSet(windowSize); + NumericDocValues refValues = ctx.reader().getNumericDocValues("age"); + for (int d = 0; d < Math.min(maxDoc, windowSize); d++) { + if (refValues.advanceExact(d) && refValues.longValue() >= 20 && refValues.longValue() <= 40) { + expected.set(d); + } + } + + DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("age"); + NumericDocValues dv = ctx.reader().getNumericDocValues("age"); + assertNotNull("Field must have a skip index", skipper); + + DocValuesRangeIterator iter = DocValuesRangeIterator.forRange(dv, skipper, 20, 40); + iter.approximation().nextDoc(); + + FixedBitSet actual = new FixedBitSet(windowSize); + iter.intoBitSet(Math.min(maxDoc, windowSize), actual, 0); + + assertEquals( + "intoBitSet must set exactly the same bits as linear scan", + expected.cardinality(), + actual.cardinality()); + FixedBitSet diff = expected.clone(); + diff.xor(actual); + assertEquals("No bits should differ", 0, diff.cardinality()); + } + + /** Tests YES block path — range covers all values, all docs match. */ + public void testIntoBitSetAllMatchRange() throws Exception { + doTestIntoBitSetAllMatchRange(reader); + } + + @Nightly + public void testIntoBitSetAllMatchRangeHuge() throws Exception { + try (Directory hugeDir = newDirectory()) { + buildIndex(hugeDir, 4096 * 100); + try (DirectoryReader r = DirectoryReader.open(hugeDir)) { + doTestIntoBitSetAllMatchRange(r); + } + } + } + + private void doTestIntoBitSetAllMatchRange(DirectoryReader r) throws Exception { + LeafReaderContext ctx = r.leaves().get(0); + int maxDoc = ctx.reader().maxDoc(); + + DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("age"); + NumericDocValues dv = ctx.reader().getNumericDocValues("age"); + assertNotNull(skipper); + + DocValuesRangeIterator iter = DocValuesRangeIterator.forRange(dv, skipper, 0, 99); + iter.approximation().nextDoc(); + + FixedBitSet bitSet = new FixedBitSet(maxDoc); + iter.intoBitSet(maxDoc, bitSet, 0); + + assertEquals("All docs should match range [0,99]", maxDoc, bitSet.cardinality()); + } + + /** Tests NO block path — range matches nothing. */ + public void testIntoBitSetNoMatchRange() throws Exception { + doTestIntoBitSetNoMatchRange(reader); + } + + @Nightly + public void testIntoBitSetNoMatchRangeHuge() throws Exception { + try (Directory hugeDir = newDirectory()) { + buildIndex(hugeDir, 4096 * 100); + try (DirectoryReader r = DirectoryReader.open(hugeDir)) { + doTestIntoBitSetNoMatchRange(r); + } + } + } + + private void doTestIntoBitSetNoMatchRange(DirectoryReader r) throws Exception { + LeafReaderContext ctx = r.leaves().get(0); + int maxDoc = ctx.reader().maxDoc(); + + DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("age"); + NumericDocValues dv = ctx.reader().getNumericDocValues("age"); + assertNotNull(skipper); + + DocValuesRangeIterator iter = DocValuesRangeIterator.forRange(dv, skipper, 200, 300); + iter.approximation().nextDoc(); + + FixedBitSet bitSet = new FixedBitSet(maxDoc); + iter.intoBitSet(maxDoc, bitSet, 0); + + assertEquals("No docs should match out-of-range query", 0, bitSet.cardinality()); + } + + /** Tests YES_IF_PRESENT block path — sparse field, only even docs have a value. */ + public void testIntoBitSetSparseField() throws Exception { + doTestIntoBitSetSparseField(1000); + } + + @Nightly + public void testIntoBitSetSparseFieldHuge() throws Exception { + doTestIntoBitSetSparseField(4096 * 20); + } + + private void doTestIntoBitSetSparseField(int numDocs) throws Exception { + try (Directory sparseDir = newDirectory()) { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + IndexWriter w = new IndexWriter(sparseDir, iwc); + List expectedDocs = new ArrayList<>(); + for (int i = 0; i < numDocs; i++) { + Document doc = new Document(); + if (i % 2 == 0) { + // All values within [20, 40] so blocks are YES_IF_PRESENT (not MAYBE) + long val = 20 + (i % 21); + doc.add(NumericDocValuesField.indexedField("sparse", val)); + expectedDocs.add(i); + } + w.addDocument(doc); + } + w.forceMerge(1); + try (DirectoryReader sparseReader = DirectoryReader.open(w)) { + w.close(); + LeafReaderContext ctx = sparseReader.leaves().get(0); + + DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("sparse"); + NumericDocValues dv = ctx.reader().getNumericDocValues("sparse"); + if (skipper == null) return; + + DocValuesRangeIterator iter = DocValuesRangeIterator.forRange(dv, skipper, 20, 40); + iter.approximation().nextDoc(); + + FixedBitSet bitSet = new FixedBitSet(numDocs); + iter.intoBitSet(numDocs, bitSet, 0); + + assertEquals( + "Sparse field intoBitSet must match expected count", + expectedDocs.size(), + bitSet.cardinality()); + for (int i = 1; i < numDocs; i += 2) { + assertFalse("Odd doc " + i + " has no value and must not be set", bitSet.get(i)); + } + } + } + } + + /** Tests nextDoc() and advance() return the same docs as a linear scan. */ + public void testIterationCorrectness() throws Exception { + doTestIterationCorrectness(reader); + } + + @Nightly + public void testIterationCorrectnessHuge() throws Exception { + try (Directory hugeDir = newDirectory()) { + buildIndex(hugeDir, 4096 * 100); + try (DirectoryReader r = DirectoryReader.open(hugeDir)) { + doTestIterationCorrectness(r); + } + } + } + + private void doTestIterationCorrectness(DirectoryReader r) throws Exception { + LeafReaderContext ctx = r.leaves().get(0); + + List expected = new ArrayList<>(); + NumericDocValues refValues = ctx.reader().getNumericDocValues("age"); + for (int d = 0; d < ctx.reader().maxDoc(); d++) { + if (refValues.advanceExact(d) && refValues.longValue() >= 20 && refValues.longValue() <= 40) { + expected.add(d); + } + } + assertTrue("Test requires some matching docs", expected.size() > 0); + + // Test 1: nextDoc() returns all matching docs in order + DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("age"); + NumericDocValues dv = ctx.reader().getNumericDocValues("age"); + assertNotNull(skipper); + + DocIdSetIterator iter = + TwoPhaseIterator.asDocIdSetIterator(DocValuesRangeIterator.forRange(dv, skipper, 20, 40)); + List actual = new ArrayList<>(); + for (int d = iter.nextDoc(); d != DocIdSetIterator.NO_MORE_DOCS; d = iter.nextDoc()) { + actual.add(d); + } + assertEquals("nextDoc() must return same docs as expected", expected, actual); + + // Test 2: advance() forward through the iterator with random skips. + // Uses a single iterator instance and advances forward multiple times, + // verifying each advance lands on the correct next matching doc. + skipper = ctx.reader().getDocValuesSkipper("age"); + dv = ctx.reader().getNumericDocValues("age"); + iter = + TwoPhaseIterator.asDocIdSetIterator(DocValuesRangeIterator.forRange(dv, skipper, 20, 40)); + Random rng = random(); + int idx = 0; + while (idx < expected.size()) { + int target = expected.get(idx); + int doc = iter.advance(target); + assertEquals("advance(" + target + ") must return the target", target, doc); + // Skip forward by a random amount (1-5 matching docs) + idx += rng.nextInt(5) + 1; + } + + // Test 3: advance() to a non-matching doc lands on the next match. + // Picks random targets between consecutive matches and verifies + // advance returns the next matching doc after the target. + skipper = ctx.reader().getDocValuesSkipper("age"); + dv = ctx.reader().getNumericDocValues("age"); + iter = + TwoPhaseIterator.asDocIdSetIterator(DocValuesRangeIterator.forRange(dv, skipper, 20, 40)); + int prevDoc = -1; + for (int i = 0; i < expected.size() && i < 20; i++) { + int matchDoc = expected.get(i); + if (matchDoc > prevDoc + 1) { + // Target a gap between previous position and this match + int target = prevDoc + 1 + rng.nextInt(matchDoc - prevDoc - 1); + int doc = iter.advance(target); + assertEquals( + "advance(" + target + ") must skip to next match at " + matchDoc, matchDoc, doc); + prevDoc = doc; + } else { + prevDoc = matchDoc; + } + } + + // Test 4: advance() past all docs returns NO_MORE_DOCS + skipper = ctx.reader().getDocValuesSkipper("age"); + dv = ctx.reader().getNumericDocValues("age"); + iter = + TwoPhaseIterator.asDocIdSetIterator(DocValuesRangeIterator.forRange(dv, skipper, 20, 40)); + int doc = iter.advance(ctx.reader().maxDoc()); + assertEquals(DocIdSetIterator.NO_MORE_DOCS, doc); + } + + /** Helper: builds an index with {@code numDocs} docs, values = i % 100. */ + private void buildIndex(Directory dir, int numDocs) throws Exception { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + try (IndexWriter w = new IndexWriter(dir, iwc)) { + for (int i = 0; i < numDocs; i++) { + Document doc = new Document(); + doc.add(NumericDocValuesField.indexedField("age", i % 100)); + w.addDocument(doc); + } + w.forceMerge(1); + } + } + + /** + * Tests the DocValuesRangeIterator iteration and intoBitSet paths using the fake NumericDocValues + * and DocValuesSkipper from BaseDocValuesSkipperTests, which exercises all block types (YES, NO, + * MAYBE, YES_IF_PRESENT) across both dense and sparse regions. + */ + public void testAllBlockTypesWithFakeSkipper() throws Exception { + long queryMin = 10; + long queryMax = 20; + + // Collect expected matching docs by brute-force scan (up to 2048, the skipper's range) + List expected = new ArrayList<>(); + NumericDocValues refValues = docValues(queryMin, queryMax); + for (int d = refValues.nextDoc(); + d != DocIdSetIterator.NO_MORE_DOCS && d < 2048; + d = refValues.nextDoc()) { + long v = refValues.longValue(); + if (v >= queryMin && v <= queryMax) { + expected.add(d); + } + } + assertTrue("Should have matching docs", expected.size() > 0); + + // Test nextDoc() through all block types + NumericDocValues values = docValues(queryMin, queryMax); + DocValuesSkipper skipper = docValuesSkipper(queryMin, queryMax, true); + DocIdSetIterator iter = + TwoPhaseIterator.asDocIdSetIterator( + DocValuesRangeIterator.forRange(values, skipper, queryMin, queryMax)); + List actual = new ArrayList<>(); + for (int d = iter.nextDoc(); d != DocIdSetIterator.NO_MORE_DOCS; d = iter.nextDoc()) { + actual.add(d); + } + assertEquals("Must return same docs across all block types", expected, actual); + + // Test advance() across block types: + // - advance into YES block (docs 0-127) + // - advance into NO block, should skip to MAYBE (docs 512+) + // - advance into MAYBE block + // - advance into YES_IF_PRESENT block (docs 1024+, sparse) + // - advance past end + values = docValues(queryMin, queryMax); + skipper = docValuesSkipper(queryMin, queryMax, true); + iter = + TwoPhaseIterator.asDocIdSetIterator( + DocValuesRangeIterator.forRange(values, skipper, queryMin, queryMax)); + + // advance into YES block — should land exactly on target + assertEquals(50, iter.advance(50)); + // advance within same YES block + assertEquals(100, iter.advance(100)); + // advance into NO block (128-511) — should skip to first MAYBE match (doc 514) + int doc = iter.advance(200); + assertTrue("advance(200) should skip NO blocks, got " + doc, doc >= 512); + assertTrue("advance(200) result must be in expected", expected.contains(doc)); + // advance into sparse region (YES_IF_PRESENT, docs 1024+) + doc = iter.advance(1024); + assertTrue("advance(1024) should find a doc >= 1024, got " + doc, doc >= 1024); + assertTrue("advance(1024) result must be in expected", expected.contains(doc)); + // advance past all docs + assertEquals(DocIdSetIterator.NO_MORE_DOCS, iter.advance(2048)); + + // Test intoBitSet() across all block types + values = docValues(queryMin, queryMax); + skipper = docValuesSkipper(queryMin, queryMax, true); + DocValuesRangeIterator rangeIter = + DocValuesRangeIterator.forRange(values, skipper, queryMin, queryMax); + int firstDoc = rangeIter.approximation().nextDoc(); + + FixedBitSet bitSet = new FixedBitSet(2048); + rangeIter.intoBitSet(2048, bitSet, 0); + + // All expected docs after firstDoc should be set + for (int expectedDoc : expected) { + if (expectedDoc > firstDoc) { + assertTrue("Doc " + expectedDoc + " should be set in bitset", bitSet.get(expectedDoc)); + } + } + // No unexpected docs should be set + for (int d = bitSet.nextSetBit(0); + d != DocIdSetIterator.NO_MORE_DOCS; + d = d + 1 < bitSet.length() ? bitSet.nextSetBit(d + 1) : DocIdSetIterator.NO_MORE_DOCS) { + assertTrue("Doc " + d + " set in bitset but not in expected", expected.contains(d)); + } + } + + /** + * Tests the intoBitSet bulk path for SortedDocValues ordinal ranges. Dense field with values + * repeating every 100 docs, queried over an ordinal range that exercises YES, MAYBE, and NO + * blocks. + */ + public void testOrdinalIntoBitSetMatchesLinearScan() throws Exception { + try (Directory ordDir = newDirectory()) { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + try (IndexWriter w = new IndexWriter(ordDir, iwc)) { + for (int i = 0; i < DOC_COUNT; i++) { + Document doc = new Document(); + // ordinals = i % 100, deterministic, formatted as zero-padded strings + String val = String.format(Locale.ROOT, "%03d", i % 100); + doc.add(SortedDocValuesField.indexedField("ord", new BytesRef(val))); + w.addDocument(doc); + } + w.forceMerge(1); + } + + try (DirectoryReader ordReader = DirectoryReader.open(ordDir)) { + LeafReaderContext ctx = ordReader.leaves().get(0); + int maxDoc = ctx.reader().maxDoc(); + int windowSize = DenseConjunctionBulkScorer.WINDOW_SIZE; + + // Linear scan reference + FixedBitSet expected = new FixedBitSet(windowSize); + SortedDocValues refValues = ctx.reader().getSortedDocValues("ord"); + for (int d = 0; d < Math.min(maxDoc, windowSize); d++) { + if (refValues.advanceExact(d)) { + int ord = refValues.ordValue(); + if (ord >= 20 && ord <= 40) { + expected.set(d); + } + } + } + + DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("ord"); + SortedDocValues dv = ctx.reader().getSortedDocValues("ord"); + assertNotNull("Field must have a skip index", skipper); + + DocValuesRangeIterator iter = DocValuesRangeIterator.forOrdinalRange(dv, skipper, 20, 40); + iter.approximation().nextDoc(); + + FixedBitSet actual = new FixedBitSet(windowSize); + iter.intoBitSet(Math.min(maxDoc, windowSize), actual, 0); + + assertEquals( + "Ordinal intoBitSet must match linear scan", + expected.cardinality(), + actual.cardinality()); + FixedBitSet diff = expected.clone(); + diff.xor(actual); + assertEquals("No bits should differ for ordinal range", 0, diff.cardinality()); + } + } + } + + /** + * Tests that the two-phase iterator is wired for SortedDocValues ordinal ranges with a skip + * index. + */ + public void testOrdinalTwoPhaseIteratorIsWired() throws Exception { + try (Directory ordDir = newDirectory()) { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + try (IndexWriter w = new IndexWriter(ordDir, iwc)) { + for (int i = 0; i < 10000; i++) { + Document doc = new Document(); + String val = String.format(Locale.ROOT, "%03d", i % 100); + doc.add(SortedDocValuesField.indexedField("ord", new BytesRef(val))); + w.addDocument(doc); + } + w.forceMerge(1); + } + + try (DirectoryReader ordReader = DirectoryReader.open(ordDir)) { + LeafReaderContext ctx = ordReader.leaves().get(0); + Query q = + SortedDocValuesField.newSlowRangeQuery( + "ord", new BytesRef("020"), new BytesRef("040"), true, true); + IndexSearcher searcher = new IndexSearcher(ordReader); + Weight weight = q.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, 1f); + ScorerSupplier ss = weight.scorerSupplier(ctx); + assertNotNull("ScorerSupplier must not be null", ss); + assertTrue( + "ScorerSupplier must be a ConstantScoreScorerSupplier", + ss instanceof ConstantScoreScorerSupplier); + DocIdSetIterator iter = ((ConstantScoreScorerSupplier) ss).iterator(Long.MAX_VALUE); + TwoPhaseIterator twoPhase = TwoPhaseIterator.unwrap(iter); + assertTrue( + "Ordinal range query on single-valued field with skip index must use a" + + " DocValuesRangeIterator, but got: " + + (twoPhase == null ? iter.getClass() : twoPhase.getClass()).getSimpleName(), + twoPhase instanceof DocValuesRangeIterator); + } + } + } + + /** + * Tests that ordinalRangeIntoBitSet (bulk/fast path) produces the exact same results as per-doc + * evaluation (slow path) across random data with various densities, range selectivities, and edge + * cases (all-match, no-match, single-doc blocks, boundary docs). + */ + public void testOrdinalRangeIntoBitSetMatchesPerDocEvaluation() throws Exception { + Random rng = random(); + for (int iter = 0; iter < 10; iter++) { + int numDocs = rng.nextInt(4096, 4096 * 5); + int numUnique = rng.nextInt(10, 500); + // Random range selectivity in terms of ordinals + int minOrd = rng.nextInt(0, numUnique / 2); + int maxOrd = minOrd + rng.nextInt(1, numUnique / 2); + + try (Directory dir = newDirectory()) { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + try (IndexWriter w = new IndexWriter(dir, iwc)) { + for (int i = 0; i < numDocs; i++) { + Document doc = new Document(); + // Use string values so ordinal ordering is deterministic and independent of + // byte-value ordering. + String val = String.format(Locale.ROOT, "%05d", i % numUnique); + doc.add(SortedDocValuesField.indexedField("val", new BytesRef(val))); + w.addDocument(doc); + } + w.forceMerge(1); + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + LeafReaderContext ctx = reader.leaves().get(0); + + // Slow path: per-doc evaluation via advanceExact + ordValue + FixedBitSet expected = new FixedBitSet(numDocs); + SortedDocValues slowDv = ctx.reader().getSortedDocValues("val"); + for (int d = 0; d < numDocs; d++) { + if (slowDv.advanceExact(d)) { + int ord = slowDv.ordValue(); + if (ord >= minOrd && ord <= maxOrd) { + expected.set(d); + } + } + } + + // Fast path: DocValuesRangeIterator.forOrdinalRange with intoBitSet + DocValuesSkipper skipper = ctx.reader().getDocValuesSkipper("val"); + SortedDocValues fastDv = ctx.reader().getSortedDocValues("val"); + assertNotNull("Field must have a skip index for randomized test", skipper); + + DocValuesRangeIterator rangeIter = + DocValuesRangeIterator.forOrdinalRange(fastDv, skipper, minOrd, maxOrd); + rangeIter.approximation().nextDoc(); + + FixedBitSet actual = new FixedBitSet(numDocs); + rangeIter.intoBitSet(numDocs, actual, 0); + + assertEquals( + "ordinal intoBitSet must match per-doc evaluation (numDocs=" + + numDocs + + ", numUnique=" + + numUnique + + ", ordRange=[" + + minOrd + + "," + + maxOrd + + "])", + expected, + actual); + } + } + } + } + + /** + * Tests that rangeIntoBitSet (SIMD/fast path) produces the exact same results as per-doc + * evaluation (slow path) across random data with various densities and range selectivities. + */ + public void testRangeIntoBitSetMatchesPerDocEvaluation() throws Exception { + Random rng = random(); + for (int iter = 0; iter < 10; iter++) { + int numDocs = rng.nextInt(4096, 4096 * 5); + long maxValue = rng.nextInt(100, 10000); + // Random range selectivity + long rangeMin = rng.nextLong(0, maxValue / 2); + long rangeMax = rangeMin + rng.nextLong(1, maxValue / 2); + + try (Directory dir = newDirectory()) { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + try (IndexWriter w = new IndexWriter(dir, iwc)) { + for (int i = 0; i < numDocs; i++) { + Document doc = new Document(); + doc.add(NumericDocValuesField.indexedField("val", rng.nextLong(0, maxValue))); + w.addDocument(doc); + } + w.forceMerge(1); + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + LeafReaderContext ctx = reader.leaves().get(0); + + // Slow path: per-doc evaluation + FixedBitSet expected = new FixedBitSet(numDocs); + NumericDocValues slowDv = ctx.reader().getNumericDocValues("val"); + for (int d = 0; d < numDocs; d++) { + if (slowDv.advanceExact(d)) { + long v = slowDv.longValue(); + if (v >= rangeMin && v <= rangeMax) { + expected.set(d); + } + } + } + + // Fast path: rangeIntoBitSet + FixedBitSet actual = new FixedBitSet(numDocs); + NumericDocValues fastDv = ctx.reader().getNumericDocValues("val"); + fastDv.rangeIntoBitSet(0, numDocs, rangeMin, rangeMax, actual, 0); + + assertEquals( + "rangeIntoBitSet must match per-doc evaluation (numDocs=" + + numDocs + + ", range=[" + + rangeMin + + "," + + rangeMax + + "])", + expected, + actual); + } + } + } + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithDeltaEncoding() throws Exception { + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "delta-only encoded range must match decoded evaluation", + values, + delta + 127, + delta + 4097); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithGcdEncoding() throws Exception { + long[] values = rangeValues(DOC_COUNT, doc -> doc * 1_000L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "gcd encoded range must match decoded evaluation", values, 123_456L, 4_567_890L); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithGcdAndDeltaEncoding() throws Exception { + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc * 100L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "gcd+delta encoded range must match decoded evaluation", + values, + delta + 123, + delta + 456_789); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWhenGcdRangeFallsBetweenValues() + throws Exception { + long[] values = rangeValues(DOC_COUNT, doc -> 10L + doc * 5L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "gcd encoded gap range must match no docs", values, 11, 14); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithOpenLowerBound() throws Exception { + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "open Long.MIN_VALUE lower bound must saturate and match all docs up to the upper bound", + values, + Long.MIN_VALUE, + delta + 127); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithOpenUpperBound() throws Exception { + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc * 100L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "open Long.MAX_VALUE upper bound must saturate and match all docs from the lower bound", + values, + delta + 50L * 100L, + Long.MAX_VALUE); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithBothOpenBounds() throws Exception { + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc * 100L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "fully open range must match every doc", values, Long.MIN_VALUE, Long.MAX_VALUE); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithNegativeEncodedLowerBound() + throws Exception { + // Stored values are non-negative (min == delta), but the query lower bound is below delta so + // (minValue - delta) is negative even though Math.subtractExact succeeds. This exercises the + // Math.max(0, encodedMin) clamp. + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "negative encoded lower bound must clamp to 0 and match docs up to the upper bound", + values, + delta - 50, + delta + 100); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithRangeBelowStoredValues() + throws Exception { + // The whole query range is below the stored minimum, so encodedMin > encodedMax after the + // bound transformation and the SIMD path is skipped without iterating. + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc * 100L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "query range below stored values must match no docs", values, delta - 1_000L, delta - 1L); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithRangeAboveStoredValues() + throws Exception { + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc * 100L); + long max = delta + (DOC_COUNT - 1L) * 100L; + assertRangeIntoBitSetMatchesPerDocEvaluation( + "query range above stored values must match no docs", values, max + 1L, max + 1_000L); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithFullGcdDeltaRange() throws Exception { + long delta = 1_000_000L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc * 100L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "full gcd+delta encoded range must match all docs", + values, + delta, + delta + (DOC_COUNT - 1L) * 100L); + } + + public void testRangeIntoBitSetMatchesPerDocEvaluationWithSingleGcdDeltaValue() throws Exception { + long delta = 1_000_000L; + long value = delta + 123L * 100L; + long[] values = rangeValues(DOC_COUNT, doc -> delta + doc * 100L); + assertRangeIntoBitSetMatchesPerDocEvaluation( + "single gcd+delta encoded value range must match one doc", values, value, value); + } + + public void testSortedNumericRangeIntoBitSetDenseFixedCardinality() throws Exception { + doTestSortedNumericRangeIntoBitSet(true, true); + } + + public void testSortedNumericRangeIntoBitSetDenseVariableCardinality() throws Exception { + doTestSortedNumericRangeIntoBitSet(true, false); + } + + public void testSortedNumericRangeIntoBitSetSparseFixedCardinality() throws Exception { + doTestSortedNumericRangeIntoBitSet(false, true); + } + + public void testSortedNumericRangeIntoBitSetSparseVariableCardinality() throws Exception { + doTestSortedNumericRangeIntoBitSet(false, false); + } + + private void doTestSortedNumericRangeIntoBitSet(boolean dense, boolean fixedCardinality) + throws Exception { + doTestSortedNumericRangeIntoBitSet(dense, fixedCardinality, 4); + } + + public void testSortedNumericRangeIntoBitSetVaryingCardinality() throws Exception { + for (int cardinality : new int[] {2, 3, 4, 5, 7, 8}) { + doTestSortedNumericRangeIntoBitSet(true, true, cardinality); + } + } + + private void doTestSortedNumericRangeIntoBitSet( + boolean dense, boolean fixedCardinality, int fixedCardinalityValue) throws Exception { + int numDocs = 4096 * 2; + try (Directory dir = newDirectory()) { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + try (IndexWriter w = new IndexWriter(dir, iwc)) { + for (int docID = 0; docID < numDocs; docID++) { + Document doc = new Document(); + if (dense || docID % 3 != 0) { + int valueCount = fixedCardinality ? fixedCardinalityValue : 1 + (docID & 3); + long firstValue = (docID * 13L) % 100; + for (int i = 0; i < valueCount; i++) { + doc.add(SortedNumericDocValuesField.indexedField("sn", firstValue + i * 3L)); + } + } + w.addDocument(doc); + } + w.forceMerge(1); + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + LeafReaderContext ctx = reader.leaves().get(0); + FixedBitSet expected = new FixedBitSet(numDocs); + var expectedValues = ctx.reader().getSortedNumericDocValues("sn"); + for (int docID = 0; docID < numDocs; docID++) { + if (expectedValues.advanceExact(docID)) { + for (int i = 0, count = expectedValues.docValueCount(); i < count; i++) { + long value = expectedValues.nextValue(); + if (value >= 20) { + if (value <= 40) { + expected.set(docID); + } + break; + } + } + } + } + + FixedBitSet actual = new FixedBitSet(numDocs); + ctx.reader().getSortedNumericDocValues("sn").rangeIntoBitSet(0, numDocs, 20, 40, actual, 0); + assertEquals(expected, actual); + } + } + } + + public void testSingletonDelegatesRangeIntoBitSet() throws Exception { + int maxDoc = 100; + boolean[] delegated = {false}; + NumericDocValues spy = + new NumericDocValues() { + private int doc = -1; + + @Override + public long longValue() { + return doc; + } + + @Override + public boolean advanceExact(int target) { + doc = target; + return true; + } + + @Override + public int docID() { + return doc; + } + + @Override + public int nextDoc() { + return ++doc < maxDoc ? doc : NO_MORE_DOCS; + } + + @Override + public int advance(int target) { + doc = target; + return doc < maxDoc ? doc : NO_MORE_DOCS; + } + + @Override + public long cost() { + return maxDoc; + } + + @Override + public void rangeIntoBitSet( + int fromDoc, int toDoc, long minValue, long maxValue, FixedBitSet bitSet, int offset) + throws IOException { + delegated[0] = true; + super.rangeIntoBitSet(fromDoc, toDoc, minValue, maxValue, bitSet, offset); + } + }; + + SortedNumericDocValues singleton = DocValues.singleton(spy); + FixedBitSet bitSet = new FixedBitSet(maxDoc); + singleton.rangeIntoBitSet(0, maxDoc, 20, 40, bitSet, 0); + + assertTrue("Expected delegation to NumericDocValues.rangeIntoBitSet", delegated[0]); + assertTrue("Expected some bits set", bitSet.cardinality() > 0); + } + + private static long[] rangeValues(int numDocs, LongUnaryOperator valueFunction) { + long[] values = new long[numDocs]; + for (int i = 0; i < numDocs; i++) { + values[i] = valueFunction.applyAsLong(i); + } + return values; + } + + private void assertRangeIntoBitSetMatchesPerDocEvaluation( + String message, long[] values, long rangeMin, long rangeMax) throws Exception { + try (Directory dir = newDirectory()) { + IndexWriterConfig iwc = new IndexWriterConfig().setCodec(new Lucene104Codec()); + try (IndexWriter w = new IndexWriter(dir, iwc)) { + for (long value : values) { + Document doc = new Document(); + doc.add(NumericDocValuesField.indexedField("val", value)); + w.addDocument(doc); + } + w.forceMerge(1); + } + + try (DirectoryReader reader = DirectoryReader.open(dir)) { + LeafReaderContext ctx = reader.leaves().get(0); + FixedBitSet expected = new FixedBitSet(values.length); + NumericDocValues slowDv = ctx.reader().getNumericDocValues("val"); + for (int d = 0; d < values.length; d++) { + if (slowDv.advanceExact(d)) { + long value = slowDv.longValue(); + if (value >= rangeMin && value <= rangeMax) { + expected.set(d); + } + } + } + + FixedBitSet actual = new FixedBitSet(values.length); + NumericDocValues fastDv = ctx.reader().getNumericDocValues("val"); + fastDv.rangeIntoBitSet(0, values.length, rangeMin, rangeMax, actual, 0); + + assertEquals(message, expected, actual); + } + } + } +}