fix(8.0): column-store range queries honor exclusive bounds (lessThan/greaterThan)

getIdsForRange computed includeMin/includeMax correctly for every operator
(gt→false/true, lt→true/false, between→true/true) but DROPPED them when the
column store served the query — it called columnStore.rangeQuery(field,min,max)
with no flags, and the column-store path was inclusive-only. So whenever a field
lived in the column store, strict lessThan/greaterThan silently behaved as
lte/gte. The sparse-fallback path already forwarded the flags, so this was
column-store-only.

Thread includeMin/includeMax end to end:
- ColumnSegmentCursor: add textbook lowerBound/upperBound helpers and express
  binarySearchRange in terms of them. Inclusive/inclusive is byte-identical to
  the old impl (lowerBound(lo) == old binarySearchValue(lo).index; upperBound(hi)
  == old "first position after hi"). Exclusive lower advances past ALL duplicates
  of the boundary value; exclusive upper stops before them.
- ColumnStore.rangeQuery: accept the flags, apply them in the segment cursor AND
  the tail-buffer linear scan (strict > / < when exclusive). A bound taken from a
  segment's own min/max stays inclusive — it is a real stored value.
- VectorIndex/types interface + getIdsForRange call site updated.

Surfaced by brainy-complete dual-bound test (year/popularity exclusive both ends
→ ['Express','Vue.js']; React@95 and Angular@75 correctly excluded). Added 5
column-store unit tests: exclusive lower, exclusive upper, both, duplicate
boundary values, and the unflushed tail-buffer path. 1469 unit green.
This commit is contained in:
David Snelling 2026-06-19 11:01:41 -07:00
parent d918f49287
commit 009e50681e
5 changed files with 185 additions and 37 deletions

View file

@ -120,33 +120,71 @@ export class ColumnSegmentCursor {
} }
/** /**
* Find the range of positions where values fall within [lo, hi]. * First index whose value is >= target (textbook lower_bound).
* * O(log n) over the sorted column.
* @param lo - Lower bound (inclusive)
* @param hi - Upper bound (inclusive)
* @returns Start (inclusive) and end (exclusive) positions
*/ */
binarySearchRange(lo: number | string, hi: number | string): { startIdx: number, endIdx: number } { private lowerBound(target: number | string): number {
const startResult = this.binarySearchValue(lo)
const startIdx = startResult.index
// Find the first position AFTER hi
const isString = this.header.valueType === ValueType.String const isString = this.header.valueType === ValueType.String
let endLo = startIdx let lo = 0
let endHi = this.values.length let hi = this.values.length
while (endLo < endHi) { while (lo < hi) {
const mid = (endLo + endHi) >>> 1 const mid = (lo + hi) >>> 1
const cmp = isString const cmp = isString
? compareCodePoints(String(this.values[mid]), String(hi)) ? compareCodePoints(String(this.values[mid]), String(target))
: (this.values[mid] as number) - (hi as number) : (this.values[mid] as number) - (target as number)
if (cmp <= 0) { if (cmp < 0) lo = mid + 1
endLo = mid + 1 else hi = mid
} else {
endHi = mid
}
} }
return lo
}
return { startIdx, endIdx: endLo } /**
* First index whose value is > target (textbook upper_bound).
* O(log n) over the sorted column.
*/
private upperBound(target: number | string): number {
const isString = this.header.valueType === ValueType.String
let lo = 0
let hi = this.values.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
const cmp = isString
? compareCodePoints(String(this.values[mid]), String(target))
: (this.values[mid] as number) - (target as number)
if (cmp <= 0) lo = mid + 1
else hi = mid
}
return lo
}
/**
* Find the range of positions where values fall within the bounds.
*
* @param lo - Lower bound
* @param hi - Upper bound
* @param includeMin - Include values equal to `lo` (default: true)
* @param includeMax - Include values equal to `hi` (default: true)
* @returns Start (inclusive) and end (exclusive) positions. When the bounds
* collapse to empty (e.g. exclusive bounds on adjacent values, or lo > hi),
* startIdx >= endIdx and the caller's loop yields nothing.
*
* The default (inclusive/inclusive) path is byte-identical to the previous
* implementation: lowerBound(lo) equals the old binarySearchValue(lo).index,
* and upperBound(hi) equals the old "first position after hi" scan.
*/
binarySearchRange(
lo: number | string,
hi: number | string,
includeMin: boolean = true,
includeMax: boolean = true
): { startIdx: number, endIdx: number } {
// startIdx: skip values strictly below the lower bound. Exclusive lower also
// skips values equal to `lo`, so it advances past the last == lo.
const startIdx = includeMin ? this.lowerBound(lo) : this.upperBound(lo)
// endIdx: first position to exclude. Inclusive upper keeps values == hi
// (stop after the last == hi); exclusive upper drops them (stop at first == hi).
const endIdx = includeMax ? this.upperBound(hi) : this.lowerBound(hi)
return { startIdx, endIdx }
} }
/** /**
@ -171,12 +209,19 @@ export class ColumnSegmentCursor {
/** /**
* Get all non-tombstoned entity IDs in a value range. * Get all non-tombstoned entity IDs in a value range.
* *
* @param lo - Lower bound (inclusive) * @param lo - Lower bound
* @param hi - Upper bound (inclusive) * @param hi - Upper bound
* @param includeMin - Include values equal to `lo` (default: true)
* @param includeMax - Include values equal to `hi` (default: true)
* @returns Array of matching entity int IDs * @returns Array of matching entity int IDs
*/ */
getEntityIdsInRange(lo: number | string, hi: number | string): number[] { getEntityIdsInRange(
const { startIdx, endIdx } = this.binarySearchRange(lo, hi) lo: number | string,
hi: number | string,
includeMin: boolean = true,
includeMax: boolean = true
): number[] {
const { startIdx, endIdx } = this.binarySearchRange(lo, hi, includeMin, includeMax)
const result: number[] = [] const result: number[] = []
for (let i = startIdx; i < endIdx; i++) { for (let i = startIdx; i < endIdx; i++) {
if (!this.tombstones.has(i)) { if (!this.tombstones.has(i)) {

View file

@ -264,17 +264,42 @@ export class ColumnStore implements ColumnStoreProvider {
} }
/** /**
* Range filter: find entities where field is within [min, max]. * Range filter: find entities where field is within the bounds.
*
* @param field - Field name to filter on
* @param min - Lower bound (undefined/null = unbounded below)
* @param max - Upper bound (undefined/null = unbounded above)
* @param includeMin - Include values equal to `min` (default: true). Lets
* callers express strict `greaterThan` (exclusive lower) vs `gte`.
* @param includeMax - Include values equal to `max` (default: true). Lets
* callers express strict `lessThan` (exclusive upper) vs `lte`.
*/ */
async rangeQuery(field: string, min?: unknown, max?: unknown): Promise<RoaringBitmap32> { async rangeQuery(
field: string,
min?: unknown,
max?: unknown,
includeMin: boolean = true,
includeMax: boolean = true
): Promise<RoaringBitmap32> {
const result = new RoaringBitmap32() const result = new RoaringBitmap32()
const cursors = await this.getSegmentCursors(field) const cursors = await this.getSegmentCursors(field)
const hasMin = min !== undefined && min !== null
const hasMax = max !== undefined && max !== null
for (const cursor of cursors) { for (const cursor of cursors) {
const lo = (min !== undefined && min !== null) ? min as number | string : cursor.minValue const lo = hasMin ? min as number | string : cursor.minValue
const hi = (max !== undefined && max !== null) ? max as number | string : cursor.maxValue const hi = hasMax ? max as number | string : cursor.maxValue
if (lo === undefined || hi === undefined) continue if (lo === undefined || hi === undefined) continue
const ids = cursor.getEntityIdsInRange(lo, hi) // Exclusivity applies only to an explicitly provided bound. A bound taken
// from the segment's own min/max is a real stored value and must stay
// inclusive, or the segment's boundary entities would be wrongly dropped.
const ids = cursor.getEntityIdsInRange(
lo,
hi,
hasMin ? includeMin : true,
hasMax ? includeMax : true
)
for (const id of ids) result.add(id) for (const id of ids) result.add(id)
} }
@ -282,9 +307,10 @@ export class ColumnStore implements ColumnStoreProvider {
const tailCursor = this.getTailBufferCursor(field) const tailCursor = this.getTailBufferCursor(field)
if (tailCursor) { if (tailCursor) {
for (const entry of tailCursor.iterateForward()) { for (const entry of tailCursor.iterateForward()) {
const v = entry.value const v = entry.value as any
const inRange = (min == null || v >= min) && (max == null || v <= max) const loOk = !hasMin || (includeMin ? v >= (min as any) : v > (min as any))
if (inRange) result.add(entry.entityIntId) const hiOk = !hasMax || (includeMax ? v <= (max as any) : v < (max as any))
if (loOk && hiOk) result.add(entry.entityIntId)
} }
} }

View file

@ -212,14 +212,16 @@ export interface ColumnStoreProvider {
filter(field: string, value: unknown): Promise<RoaringBitmap32> filter(field: string, value: unknown): Promise<RoaringBitmap32>
/** /**
* Range filter: find all entities where field is within [min, max]. * Range filter: find all entities where field is within the bounds.
* *
* @param field - Field name to filter on * @param field - Field name to filter on
* @param min - Lower bound (undefined = no lower bound) * @param min - Lower bound (undefined = no lower bound)
* @param max - Upper bound (undefined = no upper bound) * @param max - Upper bound (undefined = no upper bound)
* @param includeMin - Include values equal to `min` (default: true; false = strict `greaterThan`)
* @param includeMax - Include values equal to `max` (default: true; false = strict `lessThan`)
* @returns Roaring bitmap of matching entity int IDs * @returns Roaring bitmap of matching entity int IDs
*/ */
rangeQuery(field: string, min?: unknown, max?: unknown): Promise<RoaringBitmap32> rangeQuery(field: string, min?: unknown, max?: unknown, includeMin?: boolean, includeMax?: boolean): Promise<RoaringBitmap32>
/** /**
* Sort top-K: return the K entity int IDs with the highest or lowest * Sort top-K: return the K entity int IDs with the highest or lowest

View file

@ -1023,8 +1023,10 @@ export class MetadataIndexManager implements MetadataIndexProvider {
// Column store path: O(log n) binary search on sorted column. // Column store path: O(log n) binary search on sorted column.
// Use raw values (no normalization) — column store stores exact values. // Use raw values (no normalization) — column store stores exact values.
// Thread includeMin/includeMax so strict lessThan/greaterThan stay strict
// (the column store is no longer inclusive-only).
if (this.columnStore && this.columnStore.hasField(field)) { if (this.columnStore && this.columnStore.hasField(field)) {
const bitmap = await this.columnStore.rangeQuery(field, min, max) const bitmap = await this.columnStore.rangeQuery(field, min, max, includeMin, includeMax)
return this.idMapper.intsIterableToUuids(bitmap) return this.idMapper.intsIterableToUuids(bitmap)
} }

View file

@ -162,6 +162,79 @@ describe('ColumnStore', () => {
expect(bitmap.has(ids[2])).toBe(false) expect(bitmap.has(ids[2])).toBe(false)
expect(bitmap.has(ids[8])).toBe(false) expect(bitmap.has(ids[8])).toBe(false)
}) })
// Exclusive-bound coverage — regression guard for the column-store bug where
// includeMin/includeMax were dropped, so strict greaterThan/lessThan silently
// behaved as gte/lte whenever the column store served the query.
const seedPrices = async (): Promise<number[]> => {
const ids: number[] = []
for (let i = 0; i < 10; i++) {
ids.push(idMapper.getOrAssign(`x${i}`))
store.addEntity(BigInt(ids[i]), { price: i * 10 }) // 0,10,...,90
}
await store.flush()
return ids
}
it('honors exclusive lower bound — greaterThan (30, 70]', async () => {
const ids = await seedPrices()
const bm = await store.rangeQuery('price', 30, 70, false, true)
// 40,50,60,70 → e4..e7 (30 excluded)
expect(bm.size).toBe(4)
expect(bm.has(ids[3])).toBe(false)
expect(bm.has(ids[4])).toBe(true)
expect(bm.has(ids[7])).toBe(true)
})
it('honors exclusive upper bound — lessThan [30, 70)', async () => {
const ids = await seedPrices()
const bm = await store.rangeQuery('price', 30, 70, true, false)
// 30,40,50,60 → e3..e6 (70 excluded)
expect(bm.size).toBe(4)
expect(bm.has(ids[7])).toBe(false)
expect(bm.has(ids[3])).toBe(true)
expect(bm.has(ids[6])).toBe(true)
})
it('honors both exclusive bounds — (30, 70)', async () => {
const ids = await seedPrices()
const bm = await store.rangeQuery('price', 30, 70, false, false)
// 40,50,60 → e4..e6
expect(bm.size).toBe(3)
expect(bm.has(ids[3])).toBe(false)
expect(bm.has(ids[7])).toBe(false)
expect(bm.has(ids[4])).toBe(true)
expect(bm.has(ids[6])).toBe(true)
})
it('exclusive lower excludes ALL duplicates of the boundary value', async () => {
const a = idMapper.getOrAssign('dup_a'); store.addEntity(BigInt(a), { price: 30 })
const b = idMapper.getOrAssign('dup_b'); store.addEntity(BigInt(b), { price: 30 })
const c = idMapper.getOrAssign('dup_c'); store.addEntity(BigInt(c), { price: 30 })
const d = idMapper.getOrAssign('dup_d'); store.addEntity(BigInt(d), { price: 40 })
await store.flush()
const bm = await store.rangeQuery('price', 30, 100, false, true) // (30, 100]
expect(bm.has(a)).toBe(false)
expect(bm.has(b)).toBe(false)
expect(bm.has(c)).toBe(false)
expect(bm.has(d)).toBe(true)
})
it('honors exclusive bounds in the unflushed tail buffer', async () => {
// No flush → values live only in the tail buffer (separate code path from
// the persisted segment cursor).
const ids: number[] = []
for (let i = 0; i < 5; i++) {
ids.push(idMapper.getOrAssign(`tail${i}`))
store.addEntity(BigInt(ids[i]), { score: i * 10 }) // 0,10,20,30,40
}
const bm = await store.rangeQuery('score', 0, 40, false, false) // (0, 40)
expect(bm.size).toBe(3) // 10,20,30
expect(bm.has(ids[0])).toBe(false)
expect(bm.has(ids[4])).toBe(false)
expect(bm.has(ids[1])).toBe(true)
expect(bm.has(ids[3])).toBe(true)
})
}) })
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------