/** * @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 { 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 { 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 { for (let i = from; i < this.values.length; i++) { yield { value: this.values[i], entityIntId: this.entityIds[i], position: i } } } *iterateBackward(from?: number): Generator { 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 } }