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.
317 lines
9.9 KiB
TypeScript
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
|
|
}
|
|
}
|