brainy/src/indexes/columnStore/ColumnSegmentCursor.ts
David Snelling 009e50681e 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.
2026-06-19 11:01:41 -07:00

317 lines
9.9 KiB
TypeScript

/**
* @module columnStore/ColumnSegmentCursor
* @description Cursor for reading a parsed column segment with binary search,
* forward/backward iteration, and tombstone skipping.
*
* Loaded segments are immutable — the cursor does not modify segment data.
* Multiple cursors can read the same segment concurrently (e.g. during k-way merge).
*
* For the TS baseline, segments are loaded fully into memory and cached via
* UnifiedCache. The Cortex Rust implementation mmaps the segment file and
* indexes into it directly — same read interface, zero-copy access.
*/
import type { SegmentHeader } from './types.js'
import { ValueType } from './types.js'
import type { RoaringBitmap32 } from '../../utils/roaring/index.js'
import { compareCodePoints } from '../../utils/collation.js'
/**
* Binary search result.
*/
export interface SearchResult {
/** Whether an exact match was found. */
found: boolean
/** Index of the match, or the insertion point if not found. */
index: number
}
/**
* A single (value, entityIntId) entry yielded by iteration.
*/
export interface CursorEntry {
value: number | string
entityIntId: number
/** Position in the segment (for tombstone reference). */
position: number
}
/**
* Cursor over a single parsed column segment.
*
* The segment's values are sorted in ascending order. Binary search finds
* positions in O(log n). Forward/backward iteration streams entries
* while skipping tombstoned positions.
*/
export class ColumnSegmentCursor {
readonly header: SegmentHeader
readonly values: (number | string)[]
readonly entityIds: Uint32Array
readonly tombstones: RoaringBitmap32
constructor(
header: SegmentHeader,
values: (number | string)[],
entityIds: Uint32Array,
tombstones: RoaringBitmap32
) {
this.header = header
this.values = values
this.entityIds = entityIds
this.tombstones = tombstones
}
/**
* Number of non-tombstoned entries.
*/
get liveCount(): number {
return this.header.count - this.tombstones.size
}
/**
* Minimum value in the sorted column (first entry).
* Returns undefined for empty segments.
*/
get minValue(): number | string | undefined {
return this.values.length > 0 ? this.values[0] : undefined
}
/**
* Maximum value in the sorted column (last entry).
* Returns undefined for empty segments.
*/
get maxValue(): number | string | undefined {
return this.values.length > 0 ? this.values[this.values.length - 1] : undefined
}
/**
* Binary search for a target value in the sorted column.
*
* For numeric values, uses numeric comparison.
* For string values, uses lexicographic comparison.
*
* @param target - Value to search for
* @returns Search result with found flag and index
*/
binarySearchValue(target: number | string): SearchResult {
let lo = 0
let hi = this.values.length
const isString = this.header.valueType === ValueType.String
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 if (cmp > 0) {
hi = mid
} else {
// Found exact match — scan backward to find the FIRST occurrence
// (values may have duplicates in multi-value columns)
let first = mid
while (first > 0 && this.values[first - 1] === target) first--
return { found: true, index: first }
}
}
return { found: false, index: lo }
}
/**
* First index whose value is >= target (textbook lower_bound).
* O(log n) over the sorted column.
*/
private lowerBound(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
}
/**
* 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 }
}
/**
* Get all non-tombstoned entity IDs for a specific value (point query).
*
* @param value - Exact value to match
* @returns Array of matching entity int IDs
*/
getEntityIdsForValue(value: number | string): number[] {
const { found, index } = this.binarySearchValue(value)
if (!found) return []
const result: number[] = []
for (let i = index; i < this.values.length && this.values[i] === value; i++) {
if (!this.tombstones.has(i)) {
result.push(this.entityIds[i])
}
}
return result
}
/**
* Get all non-tombstoned entity IDs in a value range.
*
* @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 Array of matching entity int IDs
*/
getEntityIdsInRange(
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[] = []
for (let i = startIdx; i < endIdx; i++) {
if (!this.tombstones.has(i)) {
result.push(this.entityIds[i])
}
}
return result
}
/**
* Check if a position is tombstoned (logically deleted).
*
* @param position - Index in the segment
* @returns True if the position is tombstoned
*/
isDeleted(position: number): boolean {
return this.tombstones.has(position)
}
/**
* Iterate forward from a position, yielding non-tombstoned entries.
*
* @param from - Starting index (default: 0)
*/
*iterateForward(from = 0): Generator<CursorEntry> {
for (let i = from; i < this.values.length; i++) {
if (!this.tombstones.has(i)) {
yield { value: this.values[i], entityIntId: this.entityIds[i], position: i }
}
}
}
/**
* Iterate backward from a position, yielding non-tombstoned entries.
*
* @param from - Starting index (default: last entry)
*/
*iterateBackward(from?: number): Generator<CursorEntry> {
const start = from ?? this.values.length - 1
for (let i = start; i >= 0; i--) {
if (!this.tombstones.has(i)) {
yield { value: this.values[i], entityIntId: this.entityIds[i], position: i }
}
}
}
}
/**
* Cursor wrapper over an in-memory tail buffer's drained data.
* Provides the same iteration interface as ColumnSegmentCursor so
* the k-way merge can treat tail buffer and segment data uniformly.
*/
export class TailBufferCursor {
readonly values: (number | string)[]
readonly entityIds: Uint32Array
readonly valueType: ValueType
constructor(values: (number | string)[], entityIds: Uint32Array, valueType: ValueType) {
this.values = values
this.entityIds = entityIds
this.valueType = valueType
}
get minValue(): number | string | undefined {
return this.values.length > 0 ? this.values[0] : undefined
}
get maxValue(): number | string | undefined {
return this.values.length > 0 ? this.values[this.values.length - 1] : undefined
}
*iterateForward(from = 0): Generator<CursorEntry> {
for (let i = from; i < this.values.length; i++) {
yield { value: this.values[i], entityIntId: this.entityIds[i], position: i }
}
}
*iterateBackward(from?: number): Generator<CursorEntry> {
const start = from ?? this.values.length - 1
for (let i = start; i >= 0; i--) {
yield { value: this.values[i], entityIntId: this.entityIds[i], position: i }
}
}
getEntityIdsForValue(value: number | string): number[] {
// Linear scan — tail buffer is small (< flush threshold)
const result: number[] = []
for (let i = 0; i < this.values.length; i++) {
if (this.values[i] === value) result.push(this.entityIds[i])
}
return result
}
}