diff --git a/.changeset/afraid-otters-fail.md b/.changeset/afraid-otters-fail.md new file mode 100644 index 000000000..7a1aedd4a --- /dev/null +++ b/.changeset/afraid-otters-fail.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db-ivm': patch +--- + +Adds a GroupedTopKWithFractionalIndexOperator that maintains separate topK windows for each group. diff --git a/packages/db-ivm/src/operators/groupedTopKWithFractionalIndex.ts b/packages/db-ivm/src/operators/groupedTopKWithFractionalIndex.ts new file mode 100644 index 000000000..47e7a309b --- /dev/null +++ b/packages/db-ivm/src/operators/groupedTopKWithFractionalIndex.ts @@ -0,0 +1,192 @@ +import { DifferenceStreamWriter, UnaryOperator } from '../graph.js' +import { StreamBuilder } from '../d2.js' +import { MultiSet } from '../multiset.js' +import { TopKState, handleMoveIn, handleMoveOut } from './topKState.js' +import { TopKArray, createKeyedComparator } from './topKArray.js' +import type { IndexedValue, TopK } from './topKArray.js' +import type { DifferenceStreamReader } from '../graph.js' +import type { IStreamBuilder, PipedOperator } from '../types.js' + +export interface GroupedTopKWithFractionalIndexOptions { + limit?: number + offset?: number + setSizeCallback?: (getSize: () => number) => void + setWindowFn?: ( + windowFn: (options: { offset?: number; limit?: number }) => void, + ) => void + /** + * Function to extract a group key from the element's key and value. + * Elements with the same group key will be sorted and limited together. + */ + groupKeyFn: (key: K, value: T) => unknown +} + +/** + * Operator for grouped fractional indexed topK operations. + * This operator maintains separate topK windows for each group, + * allowing per-group limits and ordering. + * + * The input is a keyed stream [K, T] and outputs [K, IndexedValue]. + * Elements are grouped by the groupKeyFn, and each group maintains + * its own sorted collection with independent limit/offset. + */ +export class GroupedTopKWithFractionalIndexOperator< + K extends string | number, + T, +> extends UnaryOperator<[K, T], [K, IndexedValue]> { + #groupStates: Map> = new Map() + #groupKeyFn: (key: K, value: T) => unknown + #comparator: (a: [K, T], b: [K, T]) => number + #offset: number + #limit: number + + constructor( + id: number, + inputA: DifferenceStreamReader<[K, T]>, + output: DifferenceStreamWriter<[K, IndexedValue]>, + comparator: (a: T, b: T) => number, + options: GroupedTopKWithFractionalIndexOptions, + ) { + super(id, inputA, output) + this.#groupKeyFn = options.groupKeyFn + this.#limit = options.limit ?? Infinity + this.#offset = options.offset ?? 0 + this.#comparator = createKeyedComparator(comparator) + options.setSizeCallback?.(() => this.#getTotalSize()) + options.setWindowFn?.(this.#moveTopK.bind(this)) + } + + /** + * Creates a new TopK data structure for a group. + * Can be overridden in subclasses to use different implementations (e.g., B+ tree). + */ + protected createTopK( + offset: number, + limit: number, + comparator: (a: [K, T], b: [K, T]) => number, + ): TopK<[K, T]> { + return new TopKArray(offset, limit, comparator) + } + + #getTotalSize(): number { + let size = 0 + for (const state of this.#groupStates.values()) { + size += state.size + } + return size + } + + #getOrCreateGroupState(groupKey: unknown): TopKState { + let state = this.#groupStates.get(groupKey) + if (!state) { + const topK = this.createTopK(this.#offset, this.#limit, this.#comparator) + state = new TopKState(topK) + this.#groupStates.set(groupKey, state) + } + return state + } + + #cleanupGroupIfEmpty(groupKey: unknown, state: TopKState): void { + if (state.isEmpty) { + this.#groupStates.delete(groupKey) + } + } + + /** + * Moves the topK window for all groups based on the provided offset and limit. + * Any changes to the topK are sent to the output. + */ + #moveTopK({ offset, limit }: { offset?: number; limit?: number }): void { + if (offset !== undefined) { + this.#offset = offset + } + if (limit !== undefined) { + this.#limit = limit + } + + const result: Array<[[K, IndexedValue], number]> = [] + let hasChanges = false + + for (const state of this.#groupStates.values()) { + const diff = state.move({ offset: this.#offset, limit: this.#limit }) // TODO: think we should just pass offset and limit + + diff.moveIns.forEach((moveIn) => handleMoveIn(moveIn, result)) + diff.moveOuts.forEach((moveOut) => handleMoveOut(moveOut, result)) + + if (diff.changes) { + hasChanges = true + } + } + + if (hasChanges) { + this.output.sendData(new MultiSet(result)) + } + } + + run(): void { + const result: Array<[[K, IndexedValue], number]> = [] + for (const message of this.inputMessages()) { + for (const [item, multiplicity] of message.getInner()) { + const [key, value] = item + this.#processElement(key, value, multiplicity, result) + } + } + + if (result.length > 0) { + this.output.sendData(new MultiSet(result)) + } + } + + #processElement( + key: K, + value: T, + multiplicity: number, + result: Array<[[K, IndexedValue], number]>, + ): void { + const groupKey = this.#groupKeyFn(key, value) + const state = this.#getOrCreateGroupState(groupKey) + + const changes = state.processElement(key, value, multiplicity) + handleMoveIn(changes.moveIn, result) + handleMoveOut(changes.moveOut, result) + + // Cleanup empty groups to prevent memory leaks + this.#cleanupGroupIfEmpty(groupKey, state) + } +} + +/** + * Limits the number of results per group based on a comparator, with optional offset. + * Uses fractional indexing to minimize the number of changes when elements move positions. + * Each element is assigned a fractional index that is lexicographically sortable. + * When elements move, only the indices of the moved elements are updated, not all elements. + * + * This operator groups elements by the provided groupKeyFn and applies the limit/offset + * independently to each group. + * + * @param comparator - A function that compares two elements for ordering + * @param options - Configuration including groupKeyFn, limit, and offset + * @returns A piped operator that orders elements per group and limits results per group + */ +export function groupedTopKWithFractionalIndex( + comparator: (a: T, b: T) => number, + options: GroupedTopKWithFractionalIndexOptions, +): PipedOperator<[K, T], [K, IndexedValue]> { + return ( + stream: IStreamBuilder<[K, T]>, + ): IStreamBuilder<[K, IndexedValue]> => { + const output = new StreamBuilder<[K, IndexedValue]>( + stream.graph, + new DifferenceStreamWriter<[K, IndexedValue]>(), + ) + const operator = new GroupedTopKWithFractionalIndexOperator( + stream.graph.getNextOperatorId(), + stream.connectReader(), + output.writer, + comparator, + options, + ) + stream.graph.addOperator(operator) + return output + } +} diff --git a/packages/db-ivm/src/operators/index.ts b/packages/db-ivm/src/operators/index.ts index 7cd72f1e8..3f8ab8cb8 100644 --- a/packages/db-ivm/src/operators/index.ts +++ b/packages/db-ivm/src/operators/index.ts @@ -14,6 +14,7 @@ export * from './distinct.js' export * from './keying.js' export * from './topK.js' export * from './topKWithFractionalIndex.js' +export * from './groupedTopKWithFractionalIndex.js' export * from './orderBy.js' export * from './filterBy.js' export { groupBy, groupByOperators } from './groupBy.js' diff --git a/packages/db-ivm/src/operators/topKArray.ts b/packages/db-ivm/src/operators/topKArray.ts new file mode 100644 index 000000000..e78bd5bce --- /dev/null +++ b/packages/db-ivm/src/operators/topKArray.ts @@ -0,0 +1,255 @@ +import { generateKeyBetween } from 'fractional-indexing' +import { binarySearch, compareKeys, diffHalfOpen } from '../utils.js' +import type { HRange } from '../utils.js' + +// Abstraction for fractionally indexed values +export type FractionalIndex = string +export type IndexedValue = [V, FractionalIndex] + +export function indexedValue( + value: V, + index: FractionalIndex, +): IndexedValue { + return [value, index] +} + +export function getValue(indexedVal: IndexedValue): V { + return indexedVal[0] +} + +export function getIndex(indexedVal: IndexedValue): FractionalIndex { + return indexedVal[1] +} + +/** + * Creates a comparator for [key, value] tuples that first compares values, + * then uses the row key as a stable tie-breaker. + */ +export function createKeyedComparator( + comparator: (a: T, b: T) => number, +): (a: [K, T], b: [K, T]) => number { + return ([aKey, aVal], [bKey, bVal]) => { + // First compare on the value + const valueComparison = comparator(aVal, bVal) + if (valueComparison !== 0) { + return valueComparison + } + // If the values are equal, use the row key as tie-breaker + // This provides stable, deterministic ordering since keys are string | number + return compareKeys(aKey, bKey) + } +} + +export type TopKChanges = { + /** Indicates which element moves into the topK (if any) */ + moveIn: IndexedValue | null + /** Indicates which element moves out of the topK (if any) */ + moveOut: IndexedValue | null +} + +export type TopKMoveChanges = { + /** Flag that marks whether there were any changes to the topK */ + changes: boolean + /** Indicates which elements move into the topK (if any) */ + moveIns: Array> + /** Indicates which elements move out of the topK (if any) */ + moveOuts: Array> +} + +/** + * A topK data structure that supports insertions and deletions + * and returns changes to the topK. + */ +export interface TopK { + size: number + insert: (value: V) => TopKChanges + delete: (value: V) => TopKChanges +} + +/** + * Implementation of a topK data structure. + * Uses a sorted array internally to store the values and keeps a topK window over that array. + * Inserts and deletes are O(n) operations because worst case an element is inserted/deleted + * at the start of the array which causes all the elements to shift to the right/left. + */ +export class TopKArray implements TopK { + #sortedValues: Array> = [] + #comparator: (a: V, b: V) => number + #topKStart: number + #topKEnd: number + + constructor( + offset: number, + limit: number, + comparator: (a: V, b: V) => number, + ) { + this.#topKStart = offset + this.#topKEnd = offset + limit + this.#comparator = comparator + } + + get size(): number { + const offset = this.#topKStart + const limit = this.#topKEnd - this.#topKStart + const available = this.#sortedValues.length - offset + return Math.max(0, Math.min(limit, available)) + } + + /** + * Moves the topK window + */ + move({ + offset, + limit, + }: { + offset?: number + limit?: number + }): TopKMoveChanges { + const oldOffset = this.#topKStart + const oldLimit = this.#topKEnd - this.#topKStart + + // `this.#topKEnd` can be `Infinity` if it has no limit + // but `diffHalfOpen` expects a finite range + // so we restrict it to the size of the topK if topKEnd is infinite + const oldRange: HRange = [ + this.#topKStart, + this.#topKEnd === Infinity ? this.#topKStart + this.size : this.#topKEnd, + ] + + this.#topKStart = offset ?? oldOffset + this.#topKEnd = this.#topKStart + (limit ?? oldLimit) // can be `Infinity` if limit is `Infinity` + + // Also handle `Infinity` in the newRange + const newRange: HRange = [ + this.#topKStart, + this.#topKEnd === Infinity + ? Math.max(this.#topKStart + this.size, oldRange[1]) // since the new limit is Infinity we need to take everything (so we need to take the biggest (finite) topKEnd) + : this.#topKEnd, + ] + const { onlyInA, onlyInB } = diffHalfOpen(oldRange, newRange) + + const moveIns: Array> = [] + onlyInB.forEach((index) => { + const value = this.#sortedValues[index] + if (value) { + moveIns.push(value) + } + }) + + const moveOuts: Array> = [] + onlyInA.forEach((index) => { + const value = this.#sortedValues[index] + if (value) { + moveOuts.push(value) + } + }) + + // It could be that there are changes (i.e. moveIns or moveOuts) + // but that the collection is lazy so we don't have the data yet that needs to move in/out + // so `moveIns` and `moveOuts` will be empty but `changes` will be true + // this will tell the caller that it needs to run the graph to load more data + return { moveIns, moveOuts, changes: onlyInA.length + onlyInB.length > 0 } + } + + insert(value: V): TopKChanges { + const result: TopKChanges = { moveIn: null, moveOut: null } + + // Lookup insert position + const index = this.#findIndex(value) + // Generate fractional index based on the fractional indices of the elements before and after it + const indexBefore = + index === 0 ? null : getIndex(this.#sortedValues[index - 1]!) + const indexAfter = + index === this.#sortedValues.length + ? null + : getIndex(this.#sortedValues[index]!) + const fractionalIndex = generateKeyBetween(indexBefore, indexAfter) + + // Insert the value at the correct position + const val = indexedValue(value, fractionalIndex) + // Splice is O(n) where n = all elements in the collection (i.e. n >= k) ! + this.#sortedValues.splice(index, 0, val) + + // Check if the topK changed + if (index < this.#topKEnd) { + // The inserted element is either before the top K or within the top K + // If it is before the top K then it moves the element that was right before the topK into the topK + // If it is within the top K then the inserted element moves into the top K + // In both cases the last element of the old top K now moves out of the top K + const moveInIndex = Math.max(index, this.#topKStart) + if (moveInIndex < this.#sortedValues.length) { + // We actually have a topK + // because in some cases there may not be enough elements in the array to reach the start of the topK + // e.g. [1, 2, 3] with K = 2 and offset = 3 does not have a topK + result.moveIn = this.#sortedValues[moveInIndex]! + + // We need to remove the element that falls out of the top K + // The element that falls out of the top K has shifted one to the right + // because of the element we inserted, so we find it at index topKEnd + if (this.#topKEnd < this.#sortedValues.length) { + result.moveOut = this.#sortedValues[this.#topKEnd]! + } + } + } + + return result + } + + /** + * Deletes a value that may or may not be in the topK. + * IMPORTANT: this assumes that the value is present in the collection + * if it's not the case it will remove the element + * that is on the position where the provided `value` would be. + */ + delete(value: V): TopKChanges { + const result: TopKChanges = { moveIn: null, moveOut: null } + + // Lookup delete position + const index = this.#findIndex(value) + // Remove the value at that position + const [removedElem] = this.#sortedValues.splice(index, 1) + + // Check if the topK changed + if (index < this.#topKEnd) { + // The removed element is either before the top K or within the top K + // If it is before the top K then the first element of the topK moves out of the topK + // If it is within the top K then the removed element moves out of the topK + result.moveOut = removedElem! + if (index < this.#topKStart) { + // The removed element is before the topK + // so actually, the first element of the topK moves out of the topK + // and not the element that we removed + // The first element of the topK is now at index topKStart - 1 + // since we removed an element before the topK + const moveOutIndex = this.#topKStart - 1 + if (moveOutIndex < this.#sortedValues.length) { + result.moveOut = this.#sortedValues[moveOutIndex]! + } else { + // No value is moving out of the topK + // because there are no elements in the topK + result.moveOut = null + } + } + + // Since we removed an element that was before or in the topK + // the first element after the topK moved one position to the left + // and thus falls into the topK now + const moveInIndex = this.#topKEnd - 1 + if (moveInIndex < this.#sortedValues.length) { + result.moveIn = this.#sortedValues[moveInIndex]! + } + } + + return result + } + + // TODO: see if there is a way to refactor the code for insert and delete in the topK above + // because they are very similar, one is shifting the topK window to the left and the other is shifting it to the right + // so i have the feeling there is a common pattern here and we can implement both cases using that pattern + + #findIndex(value: V): number { + return binarySearch(this.#sortedValues, indexedValue(value, ``), (a, b) => + this.#comparator(getValue(a), getValue(b)), + ) + } +} diff --git a/packages/db-ivm/src/operators/topKState.ts b/packages/db-ivm/src/operators/topKState.ts new file mode 100644 index 000000000..e5dadd213 --- /dev/null +++ b/packages/db-ivm/src/operators/topKState.ts @@ -0,0 +1,111 @@ +import { TopKArray } from './topKArray.js' +import type { + IndexedValue, + TopK, + TopKChanges, + TopKMoveChanges, +} from './topKArray.js' + +/** + * Helper class that manages the state for a single topK window. + * Encapsulates the multiplicity tracking and topK data structure, + * providing a clean interface for processing elements and moving the window. + * + * This class is used by both TopKWithFractionalIndexOperator (single instance) + * and GroupedTopKWithFractionalIndexOperator (one instance per group). + */ +export class TopKState { + #multiplicities: Map = new Map() + #topK: TopK<[K, T]> + + constructor(topK: TopK<[K, T]>) { + this.#topK = topK + } + + get size(): number { + return this.#topK.size + } + + get isEmpty(): boolean { + return this.#multiplicities.size === 0 && this.#topK.size === 0 + } + + /** + * Process an element update (insert or delete based on multiplicity change). + * Returns the changes to the topK window. + */ + processElement(key: K, value: T, multiplicity: number): TopKChanges<[K, T]> { + const { oldMultiplicity, newMultiplicity } = this.#updateMultiplicity( + key, + multiplicity, + ) + + if (oldMultiplicity <= 0 && newMultiplicity > 0) { + // The value was invisible but should now be visible + return this.#topK.insert([key, value]) + } else if (oldMultiplicity > 0 && newMultiplicity <= 0) { + // The value was visible but should now be invisible + return this.#topK.delete([key, value]) + } + // The value was invisible and remains invisible, + // or was visible and remains visible - no topK change + return { moveIn: null, moveOut: null } + } + + /** + * Move the topK window. Only works with TopKArray implementation. + */ + move(options: { offset?: number; limit?: number }): TopKMoveChanges<[K, T]> { + if (!(this.#topK instanceof TopKArray)) { + throw new Error( + `Cannot move B+-tree implementation of TopK with fractional index`, + ) + } + return this.#topK.move(options) + } + + #updateMultiplicity( + key: K, + multiplicity: number, + ): { oldMultiplicity: number; newMultiplicity: number } { + if (multiplicity === 0) { + const current = this.#multiplicities.get(key) ?? 0 + return { oldMultiplicity: current, newMultiplicity: current } + } + + const oldMultiplicity = this.#multiplicities.get(key) ?? 0 + const newMultiplicity = oldMultiplicity + multiplicity + if (newMultiplicity === 0) { + this.#multiplicities.delete(key) + } else { + this.#multiplicities.set(key, newMultiplicity) + } + return { oldMultiplicity, newMultiplicity } + } +} + +/** + * Handles a moveIn change by adding it to the result array. + */ +export function handleMoveIn( + moveIn: IndexedValue<[K, T]> | null, + result: Array<[[K, IndexedValue], number]>, +): void { + if (moveIn) { + const [[key, value], index] = moveIn + result.push([[key, [value, index]], 1]) + } +} + +/** + * Handles a moveOut change by adding it to the result array. + */ +export function handleMoveOut( + moveOut: IndexedValue<[K, T]> | null, + result: Array<[[K, IndexedValue], number]>, +): void { + if (moveOut) { + const [[key, value], index] = moveOut + result.push([[key, [value, index]], -1]) + } +} diff --git a/packages/db-ivm/src/operators/topKWithFractionalIndex.ts b/packages/db-ivm/src/operators/topKWithFractionalIndex.ts index 3609703e5..740c3b840 100644 --- a/packages/db-ivm/src/operators/topKWithFractionalIndex.ts +++ b/packages/db-ivm/src/operators/topKWithFractionalIndex.ts @@ -1,9 +1,9 @@ -import { generateKeyBetween } from 'fractional-indexing' import { DifferenceStreamWriter, UnaryOperator } from '../graph.js' import { StreamBuilder } from '../d2.js' import { MultiSet } from '../multiset.js' -import { binarySearch, compareKeys, diffHalfOpen } from '../utils.js' -import type { HRange } from '../utils.js' +import { TopKState, handleMoveIn, handleMoveOut } from './topKState.js' +import { TopKArray, createKeyedComparator } from './topKArray.js' +import type { IndexedValue, TopK } from './topKArray.js' import type { DifferenceStreamReader } from '../graph.js' import type { IStreamBuilder, PipedOperator } from '../types.js' @@ -16,220 +16,6 @@ export interface TopKWithFractionalIndexOptions { ) => void } -export type TopKChanges = { - /** Indicates which element moves into the topK (if any) */ - moveIn: IndexedValue | null - /** Indicates which element moves out of the topK (if any) */ - moveOut: IndexedValue | null -} - -export type TopKMoveChanges = { - /** Flag that marks whether there were any changes to the topK */ - changes: boolean - /** Indicates which elements move into the topK (if any) */ - moveIns: Array> - /** Indicates which elements move out of the topK (if any) */ - moveOuts: Array> -} - -/** - * A topK data structure that supports insertions and deletions - * and returns changes to the topK. - */ -export interface TopK { - size: number - insert: (value: V) => TopKChanges - delete: (value: V) => TopKChanges -} - -/** - * Implementation of a topK data structure. - * Uses a sorted array internally to store the values and keeps a topK window over that array. - * Inserts and deletes are O(n) operations because worst case an element is inserted/deleted - * at the start of the array which causes all the elements to shift to the right/left. - */ -class TopKArray implements TopK { - #sortedValues: Array> = [] - #comparator: (a: V, b: V) => number - #topKStart: number - #topKEnd: number - - constructor( - offset: number, - limit: number, - comparator: (a: V, b: V) => number, - ) { - this.#topKStart = offset - this.#topKEnd = offset + limit - this.#comparator = comparator - } - - get size(): number { - const offset = this.#topKStart - const limit = this.#topKEnd - this.#topKStart - const available = this.#sortedValues.length - offset - return Math.max(0, Math.min(limit, available)) - } - - /** - * Moves the topK window - */ - move({ - offset, - limit, - }: { - offset?: number - limit?: number - }): TopKMoveChanges { - const oldOffset = this.#topKStart - const oldLimit = this.#topKEnd - this.#topKStart - - // `this.#topKEnd` can be `Infinity` if it has no limit - // but `diffHalfOpen` expects a finite range - // so we restrict it to the size of the topK if topKEnd is infinite - const oldRange: HRange = [ - this.#topKStart, - this.#topKEnd === Infinity ? this.#topKStart + this.size : this.#topKEnd, - ] - - this.#topKStart = offset ?? oldOffset - this.#topKEnd = this.#topKStart + (limit ?? oldLimit) // can be `Infinity` if limit is `Infinity` - - // Also handle `Infinity` in the newRange - const newRange: HRange = [ - this.#topKStart, - this.#topKEnd === Infinity - ? Math.max(this.#topKStart + this.size, oldRange[1]) // since the new limit is Infinity we need to take everything (so we need to take the biggest (finite) topKEnd) - : this.#topKEnd, - ] - const { onlyInA, onlyInB } = diffHalfOpen(oldRange, newRange) - - const moveIns: Array> = [] - onlyInB.forEach((index) => { - const value = this.#sortedValues[index] - if (value) { - moveIns.push(value) - } - }) - - const moveOuts: Array> = [] - onlyInA.forEach((index) => { - const value = this.#sortedValues[index] - if (value) { - moveOuts.push(value) - } - }) - - // It could be that there are changes (i.e. moveIns or moveOuts) - // but that the collection is lazy so we don't have the data yet that needs to move in/out - // so `moveIns` and `moveOuts` will be empty but `changes` will be true - // this will tell the caller that it needs to run the graph to load more data - return { moveIns, moveOuts, changes: onlyInA.length + onlyInB.length > 0 } - } - - insert(value: V): TopKChanges { - const result: TopKChanges = { moveIn: null, moveOut: null } - - // Lookup insert position - const index = this.#findIndex(value) - // Generate fractional index based on the fractional indices of the elements before and after it - const indexBefore = - index === 0 ? null : getIndex(this.#sortedValues[index - 1]!) - const indexAfter = - index === this.#sortedValues.length - ? null - : getIndex(this.#sortedValues[index]!) - const fractionalIndex = generateKeyBetween(indexBefore, indexAfter) - - // Insert the value at the correct position - const val = indexedValue(value, fractionalIndex) - // Splice is O(n) where n = all elements in the collection (i.e. n >= k) ! - this.#sortedValues.splice(index, 0, val) - - // Check if the topK changed - if (index < this.#topKEnd) { - // The inserted element is either before the top K or within the top K - // If it is before the top K then it moves the element that was right before the topK into the topK - // If it is within the top K then the inserted element moves into the top K - // In both cases the last element of the old top K now moves out of the top K - const moveInIndex = Math.max(index, this.#topKStart) - if (moveInIndex < this.#sortedValues.length) { - // We actually have a topK - // because in some cases there may not be enough elements in the array to reach the start of the topK - // e.g. [1, 2, 3] with K = 2 and offset = 3 does not have a topK - result.moveIn = this.#sortedValues[moveInIndex]! - - // We need to remove the element that falls out of the top K - // The element that falls out of the top K has shifted one to the right - // because of the element we inserted, so we find it at index topKEnd - if (this.#topKEnd < this.#sortedValues.length) { - result.moveOut = this.#sortedValues[this.#topKEnd]! - } - } - } - - return result - } - - /** - * Deletes a value that may or may not be in the topK. - * IMPORTANT: this assumes that the value is present in the collection - * if it's not the case it will remove the element - * that is on the position where the provided `value` would be. - */ - delete(value: V): TopKChanges { - const result: TopKChanges = { moveIn: null, moveOut: null } - - // Lookup delete position - const index = this.#findIndex(value) - // Remove the value at that position - const [removedElem] = this.#sortedValues.splice(index, 1) - - // Check if the topK changed - if (index < this.#topKEnd) { - // The removed element is either before the top K or within the top K - // If it is before the top K then the first element of the topK moves out of the topK - // If it is within the top K then the removed element moves out of the topK - result.moveOut = removedElem! - if (index < this.#topKStart) { - // The removed element is before the topK - // so actually, the first element of the topK moves out of the topK - // and not the element that we removed - // The first element of the topK is now at index topKStart - 1 - // since we removed an element before the topK - const moveOutIndex = this.#topKStart - 1 - if (moveOutIndex < this.#sortedValues.length) { - result.moveOut = this.#sortedValues[moveOutIndex]! - } else { - // No value is moving out of the topK - // because there are no elements in the topK - result.moveOut = null - } - } - - // Since we removed an element that was before or in the topK - // the first element after the topK moved one position to the left - // and thus falls into the topK now - const moveInIndex = this.#topKEnd - 1 - if (moveInIndex < this.#sortedValues.length) { - result.moveIn = this.#sortedValues[moveInIndex]! - } - } - - return result - } - - // TODO: see if there is a way to refactor the code for insert and delete in the topK above - // because they are very similar, one is shifting the topK window to the left and the other is shifting it to the right - // so i have the feeling there is a common pattern here and we can implement both cases using that pattern - - #findIndex(value: V): number { - return binarySearch(this.#sortedValues, indexedValue(value, ``), (a, b) => - this.#comparator(getValue(a), getValue(b)), - ) - } -} - /** * Operator for fractional indexed topK operations * This operator maintains fractional indices for sorted elements @@ -239,14 +25,7 @@ export class TopKWithFractionalIndexOperator< K extends string | number, T, > extends UnaryOperator<[K, T], [K, IndexedValue]> { - #index: Map = new Map() // maps keys to their multiplicity - - /** - * topK data structure that supports insertions and deletions - * and returns changes to the topK. - * Elements are stored as [key, value] tuples for stable tie-breaking. - */ - #topK: TopK<[K, T]> + #state: TopKState constructor( id: number, @@ -258,12 +37,13 @@ export class TopKWithFractionalIndexOperator< super(id, inputA, output) const limit = options.limit ?? Infinity const offset = options.offset ?? 0 - this.#topK = this.createTopK( + const topK = this.createTopK( offset, limit, createKeyedComparator(comparator), ) - options.setSizeCallback?.(() => this.#topK.size) + this.#state = new TopKState(topK) + options.setSizeCallback?.(() => this.#state.size) options.setWindowFn?.(this.moveTopK.bind(this)) } @@ -280,18 +60,11 @@ export class TopKWithFractionalIndexOperator< * Any changes to the topK are sent to the output. */ moveTopK({ offset, limit }: { offset?: number; limit?: number }) { - if (!(this.#topK instanceof TopKArray)) { - throw new Error( - `Cannot move B+-tree implementation of TopK with fractional index`, - ) - } - const result: Array<[[K, IndexedValue], number]> = [] + const diff = this.#state.move({ offset, limit }) - const diff = this.#topK.move({ offset, limit }) - - diff.moveIns.forEach((moveIn) => this.handleMoveIn(moveIn, result)) - diff.moveOuts.forEach((moveOut) => this.handleMoveOut(moveOut, result)) + diff.moveIns.forEach((moveIn) => handleMoveIn(moveIn, result)) + diff.moveOuts.forEach((moveOut) => handleMoveOut(moveOut, result)) if (diff.changes) { // There are changes to the topK @@ -321,68 +94,9 @@ export class TopKWithFractionalIndexOperator< multiplicity: number, result: Array<[[K, IndexedValue], number]>, ): void { - const { oldMultiplicity, newMultiplicity } = this.addKey(key, multiplicity) - - let res: TopKChanges<[K, T]> = { - moveIn: null, - moveOut: null, - } - if (oldMultiplicity <= 0 && newMultiplicity > 0) { - // The value was invisible but should now be visible - // Need to insert it into the array of sorted values - res = this.#topK.insert([key, value]) - } else if (oldMultiplicity > 0 && newMultiplicity <= 0) { - // The value was visible but should now be invisible - // Need to remove it from the array of sorted values - res = this.#topK.delete([key, value]) - } else { - // The value was invisible and it remains invisible - // or it was visible and remains visible - // so it doesn't affect the topK - } - - this.handleMoveIn(res.moveIn, result) - this.handleMoveOut(res.moveOut, result) - - return - } - - private handleMoveIn( - moveIn: IndexedValue<[K, T]> | null, - result: Array<[[K, IndexedValue], number]>, - ) { - if (moveIn) { - const [[key, value], index] = moveIn - result.push([[key, [value, index]], 1]) - } - } - - private handleMoveOut( - moveOut: IndexedValue<[K, T]> | null, - result: Array<[[K, IndexedValue], number]>, - ) { - if (moveOut) { - const [[key, value], index] = moveOut - result.push([[key, [value, index]], -1]) - } - } - - private getMultiplicity(key: K): number { - return this.#index.get(key) ?? 0 - } - - private addKey( - key: K, - multiplicity: number, - ): { oldMultiplicity: number; newMultiplicity: number } { - const oldMultiplicity = this.getMultiplicity(key) - const newMultiplicity = oldMultiplicity + multiplicity - if (newMultiplicity === 0) { - this.#index.delete(key) - } else { - this.#index.set(key, newMultiplicity) - } - return { oldMultiplicity, newMultiplicity } + const changes = this.#state.processElement(key, value, multiplicity) + handleMoveIn(changes.moveIn, result) + handleMoveOut(changes.moveOut, result) } } @@ -420,41 +134,3 @@ export function topKWithFractionalIndex( return output } } - -// Abstraction for fractionally indexed values -export type FractionalIndex = string -export type IndexedValue = [V, FractionalIndex] - -export function indexedValue( - value: V, - index: FractionalIndex, -): IndexedValue { - return [value, index] -} - -export function getValue(indexedVal: IndexedValue): V { - return indexedVal[0] -} - -export function getIndex(indexedVal: IndexedValue): FractionalIndex { - return indexedVal[1] -} - -/** - * Creates a comparator for [key, value] tuples that first compares values, - * then uses the row key as a stable tie-breaker. - */ -function createKeyedComparator( - comparator: (a: T, b: T) => number, -): (a: [K, T], b: [K, T]) => number { - return ([aKey, aVal], [bKey, bVal]) => { - // First compare on the value - const valueComparison = comparator(aVal, bVal) - if (valueComparison !== 0) { - return valueComparison - } - // If the values are equal, use the row key as tie-breaker - // This provides stable, deterministic ordering since keys are string | number - return compareKeys(aKey, bKey) - } -} diff --git a/packages/db-ivm/src/operators/topKWithFractionalIndexBTree.ts b/packages/db-ivm/src/operators/topKWithFractionalIndexBTree.ts index 8114325dc..3ca29ac1c 100644 --- a/packages/db-ivm/src/operators/topKWithFractionalIndexBTree.ts +++ b/packages/db-ivm/src/operators/topKWithFractionalIndexBTree.ts @@ -1,19 +1,11 @@ import { generateKeyBetween } from 'fractional-indexing' import { DifferenceStreamWriter } from '../graph.js' import { StreamBuilder } from '../d2.js' -import { - TopKWithFractionalIndexOperator, - getIndex, - getValue, - indexedValue, -} from './topKWithFractionalIndex.js' +import { TopKWithFractionalIndexOperator } from './topKWithFractionalIndex.js' +import { getIndex, getValue, indexedValue } from './topKArray.js' +import type { IndexedValue, TopK, TopKChanges } from './topKArray.js' import type { IStreamBuilder, PipedOperator } from '../types.js' -import type { - IndexedValue, - TopK, - TopKChanges, - TopKWithFractionalIndexOptions, -} from './topKWithFractionalIndex.js' +import type { TopKWithFractionalIndexOptions } from './topKWithFractionalIndex.js' interface BTree { nextLowerPair: (key: Key) => [Key, Value] | undefined diff --git a/packages/db-ivm/tests/operators/groupedTopKWithFractionalIndex.test.ts b/packages/db-ivm/tests/operators/groupedTopKWithFractionalIndex.test.ts new file mode 100644 index 000000000..c5890d22c --- /dev/null +++ b/packages/db-ivm/tests/operators/groupedTopKWithFractionalIndex.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it } from 'vitest' +import { D2 } from '../../src/d2.js' +import { MultiSet } from '../../src/multiset.js' +import { groupedTopKWithFractionalIndex } from '../../src/operators/groupedTopKWithFractionalIndex.js' +import { output } from '../../src/operators/index.js' +import { MessageTracker, compareFractionalIndex } from '../test-utils.js' + +describe(`Operators`, () => { + describe(`GroupedTopKWithFractionalIndex operator`, () => { + it(`should maintain separate topK per group`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + groupKeyFn: (_key, value) => value.group, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + // Initial data - 3 items per group + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 5 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 1 }], 1], + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 3 }], 1], + [[`g2-a`, { id: `g2-a`, group: `group2`, value: 4 }], 1], + [[`g2-b`, { id: `g2-b`, group: `group2`, value: 2 }], 1], + [[`g2-c`, { id: `g2-c`, group: `group2`, value: 6 }], 1], + ]), + ) + graph.run() + + const result = tracker.getResult(compareFractionalIndex) + + // Each group should have limit 2, so 4 total results + expect(result.sortedResults.length).toBe(4) + + // Group by group key and verify each group's results + const groupedValues = new Map>() + for (const [_key, [value, _index]] of result.sortedResults) { + const group = value.group + const list = groupedValues.get(group) ?? [] + list.push(value.value) + groupedValues.set(group, list) + } + + // Sort values within each group for consistent comparison + for (const [group, values] of groupedValues) { + values.sort((a, b) => a - b) + groupedValues.set(group, values) + } + + // group1 should have values 1, 3 (top 2 by ascending value) + expect(groupedValues.get(`group1`)).toEqual([1, 3]) + // group2 should have values 2, 4 (top 2 by ascending value) + expect(groupedValues.get(`group2`)).toEqual([2, 4]) + }) + + it(`should handle incremental updates within a group`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + groupKeyFn: (_key, value) => value.group, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + // Initial data + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 5 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 1 }], 1], + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 3 }], 1], + ]), + ) + graph.run() + + // Initial should have 2 items (limit 2): values 1 and 3 + const initialResult = tracker.getResult(compareFractionalIndex) + expect(initialResult.sortedResults.length).toBe(2) + const initialValues = initialResult.sortedResults + .map(([_key, [value, _index]]) => value.value) + .sort((a, b) => a - b) + expect(initialValues).toEqual([1, 3]) + + const initialMessageCount = initialResult.messageCount + + // Insert a better value (0) which should evict value 3 + input.sendData( + new MultiSet([ + [[`g1-d`, { id: `g1-d`, group: `group1`, value: 0 }], 1], + ]), + ) + graph.run() + + const updateResult = tracker.getResult(compareFractionalIndex) + // Should have 2 new messages: add 0, remove 3 + expect(updateResult.messageCount - initialMessageCount).toBe(2) + + // Check final state (cumulative) + const finalValues = updateResult.sortedResults + .map(([_key, [value, _index]]) => value.value) + .sort((a, b) => a - b) + expect(finalValues).toEqual([0, 1]) + }) + + it(`should handle removal of elements from topK`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + groupKeyFn: (_key, value) => value.group, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + // Initial data + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 5 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 1 }], 1], + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 3 }], 1], + ]), + ) + graph.run() + + const initialMessageCount = tracker.getResult().messageCount + + // Remove the element with value 1 (which is in topK) + input.sendData( + new MultiSet([ + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 1 }], -1], + ]), + ) + graph.run() + + const updateResult = tracker.getResult(compareFractionalIndex) + // Should have 2 new messages: remove 1, add 5 + expect(updateResult.messageCount - initialMessageCount).toBe(2) + + // Final state should have values 3 and 5 + const finalValues = updateResult.sortedResults + .map(([_key, [value, _index]]) => value.value) + .sort((a, b) => a - b) + expect(finalValues).toEqual([3, 5]) + }) + + it(`should handle multiple groups independently`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + groupKeyFn: (_key, value) => value.group, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + // Initial data for two groups + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 10 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 20 }], 1], + [[`g2-a`, { id: `g2-a`, group: `group2`, value: 5 }], 1], + [[`g2-b`, { id: `g2-b`, group: `group2`, value: 15 }], 1], + ]), + ) + graph.run() + + tracker.reset() + + // Update only group1 - add a better value + input.sendData( + new MultiSet([ + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 5 }], 1], + ]), + ) + graph.run() + + const updateResult = tracker.getResult() + + // Should have exactly 2 messages: one removal and one addition + expect(updateResult.messages.length).toBe(2) + + // Find the removal message (multiplicity -1) and addition message (multiplicity 1) + const removalMessage = updateResult.messages.find( + ([_item, mult]) => mult === -1, + ) + const additionMessage = updateResult.messages.find( + ([_item, mult]) => mult === 1, + ) + + expect(removalMessage).toBeDefined() + expect(additionMessage).toBeDefined() + + // Check that removal is for value 20 (g1-b) + const [_removalKey, [removalValue, _removalIdx]] = removalMessage![0] + expect(removalValue.value).toBe(20) + expect(removalValue.id).toBe(`g1-b`) + + // Check that addition is for value 5 (g1-c) + const [_additionKey, [additionValue, _additionIdx]] = additionMessage![0] + expect(additionValue.value).toBe(5) + expect(additionValue.id).toBe(`g1-c`) + }) + + it(`should support offset within groups`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + offset: 1, + groupKeyFn: (_key, value) => value.group, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + // Initial data - 4 items per group + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 1 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 2 }], 1], + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 3 }], 1], + [[`g1-d`, { id: `g1-d`, group: `group1`, value: 4 }], 1], + ]), + ) + graph.run() + + const result = tracker.getResult(compareFractionalIndex) + + // With offset 1 and limit 2, should get values 2 and 3 + const values = result.sortedResults + .map(([_key, [value, _index]]) => value.value) + .sort((a, b) => a - b) + expect(values).toEqual([2, 3]) + }) + + it(`should use groupKeyFn to extract group from key with delimiter`, () => { + const graph = new D2() + // Use keys with format "group:itemId" + const input = graph.newInput<[string, { id: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + // Extract group from key "group:itemId" + groupKeyFn: (key, _value) => key.split(`:`)[0], + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + input.sendData( + new MultiSet([ + [[`group1:a`, { id: `g1-a`, value: 5 }], 1], + [[`group1:b`, { id: `g1-b`, value: 1 }], 1], + [[`group1:c`, { id: `g1-c`, value: 3 }], 1], + [[`group2:a`, { id: `g2-a`, value: 4 }], 1], + [[`group2:b`, { id: `g2-b`, value: 2 }], 1], + [[`group2:c`, { id: `g2-c`, value: 6 }], 1], + ]), + ) + graph.run() + + const result = tracker.getResult(compareFractionalIndex) + + // Group results by group extracted from key + const groupedValues = new Map>() + for (const [key, [value, _index]] of result.sortedResults) { + const group = key.split(`:`)[0]! + const list = groupedValues.get(group) ?? [] + list.push(value.value) + groupedValues.set(group, list) + } + + for (const [group, values] of groupedValues) { + values.sort((a, b) => a - b) + groupedValues.set(group, values) + } + + expect(groupedValues.get(`group1`)).toEqual([1, 3]) + expect(groupedValues.get(`group2`)).toEqual([2, 4]) + }) + + it(`should support infinite limit (no limit)`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + // No limit specified - defaults to Infinity + groupKeyFn: (_key, value) => value.group, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 5 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 1 }], 1], + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 3 }], 1], + ]), + ) + graph.run() + + const result = tracker.getResult(compareFractionalIndex) + + // All 3 items should be in the result + expect(result.sortedResults.length).toBe(3) + const values = result.sortedResults + .map(([_key, [value, _index]]) => value.value) + .sort((a, b) => a - b) + expect(values).toEqual([1, 3, 5]) + }) + + it(`should handle setSizeCallback correctly`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + let getSize: (() => number) | undefined + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + groupKeyFn: (_key, value) => value.group, + setSizeCallback: (fn) => { + getSize = fn + }, + }), + output(() => {}), + ) + + graph.finalize() + + expect(getSize).toBeDefined() + expect(getSize!()).toBe(0) // Initially empty + + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 5 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 1 }], 1], + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 3 }], 1], + [[`g2-a`, { id: `g2-a`, group: `group2`, value: 4 }], 1], + [[`g2-b`, { id: `g2-b`, group: `group2`, value: 2 }], 1], + ]), + ) + graph.run() + + // group1 has 2 items in topK, group2 has 2 items + expect(getSize!()).toBe(4) + }) + + it(`should handle moving window with setWindowFn`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + let windowFn: + | ((options: { offset?: number; limit?: number }) => void) + | undefined + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + offset: 0, + groupKeyFn: (_key, value) => value.group, + setWindowFn: (fn) => { + windowFn = fn + }, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 1 }], 1], + [[`g1-b`, { id: `g1-b`, group: `group1`, value: 2 }], 1], + [[`g1-c`, { id: `g1-c`, group: `group1`, value: 3 }], 1], + [[`g1-d`, { id: `g1-d`, group: `group1`, value: 4 }], 1], + ]), + ) + graph.run() + + // Initial: values 1, 2 + const initialResult = tracker.getResult(compareFractionalIndex) + const initialValues = initialResult.sortedResults + .map(([_key, [value, _index]]) => value.value) + .sort((a, b) => a - b) + expect(initialValues).toEqual([1, 2]) + + // Move window to offset 1 + windowFn!({ offset: 1 }) + graph.run() + + // Now should have values 2, 3 + const movedResult = tracker.getResult(compareFractionalIndex) + const movedValues = movedResult.sortedResults + .map(([_key, [value, _index]]) => value.value) + .sort((a, b) => a - b) + expect(movedValues).toEqual([2, 3]) + }) + + it(`should cleanup empty groups`, () => { + const graph = new D2() + const input = + graph.newInput<[string, { id: string; group: string; value: number }]>() + const tracker = new MessageTracker< + [string, [{ id: string; group: string; value: number }, string]] + >() + + input.pipe( + groupedTopKWithFractionalIndex((a, b) => a.value - b.value, { + limit: 2, + groupKeyFn: (_key, value) => value.group, + }), + output((message) => { + tracker.addMessage(message) + }), + ) + + graph.finalize() + + // Add items to two groups + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 1 }], 1], + [[`g2-a`, { id: `g2-a`, group: `group2`, value: 2 }], 1], + ]), + ) + graph.run() + + expect(tracker.getResult().sortedResults.length).toBe(2) + + // Remove all items from group1 + input.sendData( + new MultiSet([ + [[`g1-a`, { id: `g1-a`, group: `group1`, value: 1 }], -1], + ]), + ) + graph.run() + + // Should have only group2 left in materialized results + const updateResult = tracker.getResult(compareFractionalIndex) + expect(updateResult.sortedResults.length).toBe(1) + expect(updateResult.sortedResults[0]![1][0].group).toBe(`group2`) + }) + }) +})