From 5281986c089cdc1df766dfae59b6c2c45f4f35ba Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 28 Aug 2026 00:08:23 +0100 Subject: [PATCH 1/3] Fix approximate lookup type compatibility --- CHANGELOG.md | 4 +++ docs/guide/list-of-differences.md | 1 + src/Lookup/AdvancedFind.ts | 20 ++++++++------ src/Lookup/ColumnIndex.ts | 8 ++---- src/Lookup/SearchStrategy.ts | 17 ++++++++++++ src/interpreter/binarySearch.ts | 38 +++++++++++++++----------- src/interpreter/plugin/LookupPlugin.ts | 11 +++++--- 7 files changed, 66 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ecffc1c4..af8cbfa5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Fixed + +- Fixed approximate lookups to handle values of different types consistently across linear and binary search paths. `MATCH`, `VLOOKUP`, and `HLOOKUP` ignore cross-type candidates, while `XLOOKUP` follows Microsoft Excel's total ordering. + ## [3.4.0] - 2026-08-10 ### Added diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index fae264e4bd..2387e52c53 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -42,6 +42,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | EDATE function | =EDATE(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | | EOMONTH function | =EOMONTH(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | | Empty cells in lookup search | =XLOOKUP(3, A1:A4, A1:A4, "NF", 0, 2)
where A1:A4 = 1, 2, (empty), 3 | Empty cells are skipped during the search. A value that is present is found even when it sits past an interspersed empty cell (exact match is gap-independent), and approximate `MATCH`/`VLOOKUP`/`HLOOKUP`/`XLOOKUP` skip empty cells — but not empty strings — when finding the lower/upper bound. Returns `3`. On an all-empty range in a binary search mode, HyperFormula returns the `if_not_found` result (never row 1). | Skips empty cells in approximate search (parity with HyperFormula). | With binary search modes (`search_mode` ±2), a range with interspersed empty cells is not strictly sorted; per Excel's documentation the result may be invalid, so a value past an empty cell is not reliably found. On an all-empty range in a binary mode, Excel returns the first row's value. | +| Approximate lookup matching across value types | =XLOOKUP("a", A1:A2, B1:B2, "NF", -1, 1)
where A1:A2 = 10, 20 and B1:B2 = "x", "y" | Approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` ignore candidates whose type differs from the lookup value. Approximate `XLOOKUP` follows Microsoft Excel's total ordering across value types in every linear and binary search mode. Returns `"y"` for the example. | Approximate lookups ignore candidates whose type differs from the lookup value. Returns `"NF"` for the example. | Same as HyperFormula. | ## Built-in functions diff --git a/src/Lookup/AdvancedFind.ts b/src/Lookup/AdvancedFind.ts index 5c5943cde5..70433c47e1 100644 --- a/src/Lookup/AdvancedFind.ts +++ b/src/Lookup/AdvancedFind.ts @@ -42,22 +42,22 @@ export abstract class AdvancedFind { return NOT_FOUND } - protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number { + protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, approximateMatchPolicy, returnOccurrence }: SearchOptions): number { const normalizedSearchKey = typeof searchKey === 'string' ? forceNormalizeString(searchKey) : searchKey const range = rangeValue.range if (range === undefined) { - return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, returnOccurrence) + return this.findNormalizedValue(normalizedSearchKey, rangeValue.valuesFromTopLeftCorner(), ifNoMatch, approximateMatchPolicy, returnOccurrence) } if (ordering === 'none') { - return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, returnOccurrence) + return this.findNormalizedValue(normalizedSearchKey, this.dependencyGraph.computeListOfValuesInRange(range), ifNoMatch, approximateMatchPolicy, returnOccurrence) } return findLastOccurrenceInOrderedRange( normalizedSearchKey, range, - { searchCoordinate, orderingDirection: ordering, ifNoMatch }, + { searchCoordinate, orderingDirection: ordering, ifNoMatch, approximateMatchPolicy }, this.dependencyGraph ) } @@ -67,9 +67,11 @@ export abstract class AdvancedFind { * is `returnLowerBound`/`returnUpperBound` — the closest non-exceeding/non-preceding value. * Genuinely empty cells (`EmptyValue`) are skipped, consistent with `findLastOccurrenceInOrderedRange` * and with Excel/Google Sheets, which ignore empty cells (but not empty strings) in approximate search. + * Other cross-type candidates are either ignored or compared using the total ordering, according to + * `approximateMatchPolicy`. * Returns the 0-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches. */ - protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' = 'returnNotFound', returnOccurrence: 'first' | 'last' = 'first'): number { + protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: SearchOptions['approximateMatchPolicy'], returnOccurrence: 'first' | 'last' = 'first'): number { const normalizedArray = searchArray .map(getRawValue) .map(val => typeof val === 'string' ? forceNormalizeString(val) : val) @@ -82,7 +84,6 @@ export abstract class AdvancedFind { ? (left: RawNoErrorScalarValue, right: RawInterpreterValue) => compare(left, right) : (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right) - let bestValue: RawNoErrorScalarValue = ifNoMatch === 'returnLowerBound' ? -Infinity : Infinity let bestIndex = NOT_FOUND const initialIterationIndex = returnOccurrence === 'first' ? 0 : normalizedArray.length-1 @@ -103,12 +104,15 @@ export abstract class AdvancedFind { continue } + if (approximateMatchPolicy === 'sameType' && typeof value !== typeof searchKey) { + continue + } + if (compareFn(value, searchKey) > 0) { continue } - if (compareFn(bestValue, value) < 0) { - bestValue = value + if (bestIndex === NOT_FOUND || compareFn(normalizedArray[bestIndex] as RawNoErrorScalarValue, value) < 0) { bestIndex = i } } diff --git a/src/Lookup/ColumnIndex.ts b/src/Lookup/ColumnIndex.ts index d7c32e5894..ea3fc27f36 100644 --- a/src/Lookup/ColumnIndex.ts +++ b/src/Lookup/ColumnIndex.ts @@ -107,13 +107,11 @@ export class ColumnIndex implements ColumnSearchStrategy { } } - public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, { ordering, ifNoMatch, returnOccurrence }: SearchOptions): number { - if (returnOccurrence == null) { - returnOccurrence = ordering === 'none' ? 'first' : 'last' - } + public find(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchOptions: SearchOptions): number { + const returnOccurrence = searchOptions.returnOccurrence ?? (searchOptions.ordering === 'none' ? 'first' : 'last') const resultUsingColumnIndex = this.findUsingColumnIndex(searchKey, rangeValue, returnOccurrence) - return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ordering, ifNoMatch, returnOccurrence }) + return resultUsingColumnIndex !== undefined ? resultUsingColumnIndex : this.binarySearchStrategy.find(searchKey, rangeValue, { ...searchOptions, returnOccurrence }) } private findUsingColumnIndex(key: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, returnOccurrence: 'first' | 'last'): Maybe { diff --git a/src/Lookup/SearchStrategy.ts b/src/Lookup/SearchStrategy.ts index 2316e586c9..c60399b313 100644 --- a/src/Lookup/SearchStrategy.ts +++ b/src/Lookup/SearchStrategy.ts @@ -14,9 +14,26 @@ import {Statistics} from '../statistics' import {ColumnBinarySearch} from './ColumnBinarySearch' import {ColumnIndex} from './ColumnIndex' +/** + * Controls whether approximate lookups require the lookup value's type or use the comparator's total ordering. + * + * @internal + */ +export type ApproximateMatchPolicy = 'sameType' | 'totalOrder' + +/** + * Defines the lookup semantics passed to a search strategy. + * + * @property {('asc'|'desc'|'none')} ordering - Ordering assumed by the selected search algorithm. + * @property {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Result requested when an exact value is absent. + * @property {ApproximateMatchPolicy} approximateMatchPolicy - Whether approximate bounds require the key type or use the comparator's total ordering. + * @property {('first'|'last')} [returnOccurrence] - Which exact duplicate to return. + * @internal + */ export interface SearchOptions { ordering: 'asc' | 'desc' | 'none', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', + approximateMatchPolicy: ApproximateMatchPolicy, returnOccurrence?: 'first' | 'last', } diff --git a/src/interpreter/binarySearch.ts b/src/interpreter/binarySearch.ts index 01e7b07350..cad6e94d5e 100644 --- a/src/interpreter/binarySearch.ts +++ b/src/interpreter/binarySearch.ts @@ -6,6 +6,7 @@ import {AbsoluteCellRange} from '../AbsoluteCellRange' import {CellError, simpleCellAddress} from '../Cell' import {DependencyGraph} from '../DependencyGraph' +import {ApproximateMatchPolicy} from '../Lookup/SearchStrategy' import {EmptyValue, getRawValue, RawInterpreterValue, RawNoErrorScalarValue} from './InterpreterValue' const NOT_FOUND = -1 @@ -16,7 +17,8 @@ const NOT_FOUND = -1 * Options: * - searchCoordinate - must be set to either 'row' or 'col' to indicate the dimension of the search, * - orderingDirection - must be set to either 'asc' or 'desc' to indicate the ordering direction for the search range, - * - ifNoMatch - must be set to 'returnLowerBound', 'returnUpperBound' or 'returnNotFound' + * - ifNoMatch - must be set to 'returnLowerBound', 'returnUpperBound' or 'returnNotFound', + * - approximateMatchPolicy - controls whether an approximate result must have the key's type * * If the search range contains duplicates, returns the last matching value, with one caveat: in the * 'returnNotFound' mode, when the range contains both duplicates of the searchKey and interspersed @@ -42,7 +44,7 @@ const NOT_FOUND = -1 export function findLastOccurrenceInOrderedRange( searchKey: RawNoErrorScalarValue, range: AbsoluteCellRange, - { searchCoordinate, orderingDirection, ifNoMatch }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound' }, + { searchCoordinate, orderingDirection, ifNoMatch, approximateMatchPolicy }: { searchCoordinate: 'row' | 'col', orderingDirection: 'asc' | 'desc', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: ApproximateMatchPolicy }, dependencyGraph: DependencyGraph, ): number { const start = range.start[searchCoordinate] @@ -117,6 +119,18 @@ export function findLastOccurrenceInOrderedRange( const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex) + const returnApproximateResult = (index: number | undefined): number => { + if (index === undefined || index === NOT_FOUND) { + return NOT_FOUND + } + + if (approximateMatchPolicy === 'sameType' && typeof getValueFromIndexFn(index) !== typeof searchKey) { + return NOT_FOUND + } + + return index - start + } + if (foundValue === searchKey) { return foundIndex - start } @@ -131,22 +145,18 @@ export function findLastOccurrenceInOrderedRange( // is the first (largest) non-empty value — never an empty leading cell, and NOT_FOUND on an // all-empty range. const firstNonEmptyIndex = findNextNonEmptyIndex(start) - return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND - } - - if (typeof foundValue !== typeof searchKey) { - return NOT_FOUND + return returnApproximateResult(firstNonEmptyIndex) } // here: foundValue !== searchKey if (orderingDirection === 'asc') { - return foundIndex - start + return returnApproximateResult(foundIndex) } // orderingDirection === 'desc': step to the next non-empty cell, so skipped empty slots never // shift the reported position. const nextIndex = findNextNonEmptyIndex(foundIndex + 1) - return nextIndex !== undefined ? nextIndex - start : NOT_FOUND + return returnApproximateResult(nextIndex) } if (ifNoMatch === 'returnUpperBound') { @@ -159,22 +169,18 @@ export function findLastOccurrenceInOrderedRange( // is the first (smallest) non-empty value — never an empty leading cell, and NOT_FOUND on an // all-empty range. const firstNonEmptyIndex = findNextNonEmptyIndex(start) - return firstNonEmptyIndex !== undefined ? firstNonEmptyIndex - start : NOT_FOUND - } - - if (typeof foundValue !== typeof searchKey) { - return NOT_FOUND + return returnApproximateResult(firstNonEmptyIndex) } // here: foundValue !== searchKey if (orderingDirection === 'desc') { - return foundIndex - start + return returnApproximateResult(foundIndex) } // orderingDirection === 'asc': step to the next non-empty cell, so skipped empty slots never // shift the reported position. const nextIndex = findNextNonEmptyIndex(foundIndex + 1) - return nextIndex !== undefined ? nextIndex - start : NOT_FOUND + return returnApproximateResult(nextIndex) } // ifNoMatch === 'returnNotFound' diff --git a/src/interpreter/plugin/LookupPlugin.ts b/src/interpreter/plugin/LookupPlugin.ts index 625a68735d..3b66243f53 100644 --- a/src/interpreter/plugin/LookupPlugin.ts +++ b/src/interpreter/plugin/LookupPlugin.ts @@ -90,7 +90,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchOptions: SearchOptions = { ordering: sorted ? 'asc' : 'none', - ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound' + ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound', + approximateMatchPolicy: 'sameType' } return this.doVlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions) @@ -120,7 +121,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchOptions: SearchOptions = { ordering: sorted ? 'asc' : 'none', - ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound' + ifNoMatch: sorted ? 'returnLowerBound' : 'returnNotFound', + approximateMatchPolicy: 'sameType' } return this.doHlookup(zeroIfEmpty(key), rangeValue, index - 1, searchOptions) @@ -149,6 +151,7 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchOptions: SearchOptions = { ordering: searchMode === 2 ? 'asc' : searchMode === -2 ? 'desc' : 'none', returnOccurrence: searchMode === -1 ? 'last' : 'first', + approximateMatchPolicy: 'totalOrder', ifNoMatch: matchMode === -1 ? 'returnLowerBound' : matchMode === 1 @@ -310,8 +313,8 @@ export class LookupPlugin extends FunctionPlugin implements FunctionPluginTypech const searchStrategy = rangeValue.width() === 1 ? this.columnSearch : this.rowSearch const searchOptions: SearchOptions = type === 0 - ? { ordering: 'none', ifNoMatch: 'returnNotFound' } - : { ordering: type === -1 ? 'desc' : 'asc', ifNoMatch: type === -1 ? 'returnUpperBound' : 'returnLowerBound' } + ? { ordering: 'none', ifNoMatch: 'returnNotFound', approximateMatchPolicy: 'sameType' } + : { ordering: type === -1 ? 'desc' : 'asc', ifNoMatch: type === -1 ? 'returnUpperBound' : 'returnLowerBound', approximateMatchPolicy: 'sameType' } const index = searchStrategy.find(key, rangeValue, searchOptions) if (index === -1) { From d14d00e20914b449446a20acb9c0a77973477d5c Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 28 Aug 2026 23:42:55 +0100 Subject: [PATCH 2/3] Document approximate lookup type policy --- src/Lookup/AdvancedFind.ts | 22 ++++++++++++++++++--- src/Lookup/SearchStrategy.ts | 14 ++++++++++++-- src/interpreter/binarySearch.ts | 34 ++++++++++++++++++++++++++------- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/Lookup/AdvancedFind.ts b/src/Lookup/AdvancedFind.ts index 70433c47e1..d2c3f68577 100644 --- a/src/Lookup/AdvancedFind.ts +++ b/src/Lookup/AdvancedFind.ts @@ -29,7 +29,7 @@ export abstract class AdvancedFind { const values: InternalScalarValue[] = (range === undefined) ? rangeValue.valuesFromTopLeftCorner() : this.dependencyGraph.computeListOfValuesInRange(range) - + const initialIterationIndex = returnOccurrence === 'first' ? 0 : values.length-1 const iterationCondition = returnOccurrence === 'first' ? (i: number) => i < values.length : (i: number) => i >= 0 const incrementIndex = returnOccurrence === 'first' ? (i: number) => i+1 : (i: number) => i-1 @@ -69,7 +69,23 @@ export abstract class AdvancedFind { * and with Excel/Google Sheets, which ignore empty cells (but not empty strings) in approximate search. * Other cross-type candidates are either ignored or compared using the total ordering, according to * `approximateMatchPolicy`. - * Returns the 0-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches. + * + * @param {RawNoErrorScalarValue} searchKey - Normalized lookup value. + * @param {InternalScalarValue[]} searchArray - Values to search from the top-left corner of the lookup array. + * @param {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Whether an absent exact value requests a lower bound, upper bound, or no result. + * @param {SearchOptions['approximateMatchPolicy']} approximateMatchPolicy - Cross-type candidate policy for approximate bounds. + * @param {('first'|'last')} returnOccurrence - Which exact duplicate to return and the direction used to scan candidates. + * @returns {number} The zero-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches. + * + * Candidate selection: + * + * Approximate candidate selection remains in the `NOT_FOUND` index state until the first eligible + * value seeds the result. A scalar sentinel such as positive or negative infinity would participate + * in `compare()` and is not type-neutral: numeric infinity is not a universal extremum under the + * number/string/boolean total ordering. Keeping absence in the index domain ensures that only actual + * lookup values are compared. + * + * @internal */ protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: SearchOptions['approximateMatchPolicy'], returnOccurrence: 'first' | 'last' = 'first'): number { const normalizedArray = searchArray @@ -111,7 +127,7 @@ export abstract class AdvancedFind { if (compareFn(value, searchKey) > 0) { continue } - + if (bestIndex === NOT_FOUND || compareFn(normalizedArray[bestIndex] as RawNoErrorScalarValue, value) < 0) { bestIndex = i } diff --git a/src/Lookup/SearchStrategy.ts b/src/Lookup/SearchStrategy.ts index c60399b313..eb6c7ac06e 100644 --- a/src/Lookup/SearchStrategy.ts +++ b/src/Lookup/SearchStrategy.ts @@ -15,7 +15,17 @@ import {ColumnBinarySearch} from './ColumnBinarySearch' import {ColumnIndex} from './ColumnIndex' /** - * Controls whether approximate lookups require the lookup value's type or use the comparator's total ordering. + * Selects how approximate lower and upper bounds handle non-empty candidates whose scalar type + * differs from the lookup value's type. + * + * Mode semantics: + * + * - `'sameType'` accepts only candidates with the same JavaScript scalar type as the lookup value. + * `MATCH`, `VLOOKUP`, and `HLOOKUP` use this policy. + * - `'totalOrder'` accepts cross-type candidates and orders them with the lookup comparator. + * `XLOOKUP` uses this policy to match Microsoft Excel. + * - Exact matches are unaffected. The policy is required so every search strategy receives an + * explicit choice and linear, indexed, and binary paths cannot silently diverge. * * @internal */ @@ -26,7 +36,7 @@ export type ApproximateMatchPolicy = 'sameType' | 'totalOrder' * * @property {('asc'|'desc'|'none')} ordering - Ordering assumed by the selected search algorithm. * @property {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Result requested when an exact value is absent. - * @property {ApproximateMatchPolicy} approximateMatchPolicy - Whether approximate bounds require the key type or use the comparator's total ordering. + * @property {ApproximateMatchPolicy} approximateMatchPolicy - Cross-type candidate policy for approximate bounds; exact matches ignore it. * @property {('first'|'last')} [returnOccurrence] - Which exact duplicate to return. * @internal */ diff --git a/src/interpreter/binarySearch.ts b/src/interpreter/binarySearch.ts index cad6e94d5e..7f215a73a6 100644 --- a/src/interpreter/binarySearch.ts +++ b/src/interpreter/binarySearch.ts @@ -11,15 +11,9 @@ import {EmptyValue, getRawValue, RawInterpreterValue, RawNoErrorScalarValue} fro const NOT_FOUND = -1 -/* +/** * Searches for the searchKey in a sorted 1-D range. * - * Options: - * - searchCoordinate - must be set to either 'row' or 'col' to indicate the dimension of the search, - * - orderingDirection - must be set to either 'asc' or 'desc' to indicate the ordering direction for the search range, - * - ifNoMatch - must be set to 'returnLowerBound', 'returnUpperBound' or 'returnNotFound', - * - approximateMatchPolicy - controls whether an approximate result must have the key's type - * * If the search range contains duplicates, returns the last matching value, with one caveat: in the * 'returnNotFound' mode, when the range contains both duplicates of the searchKey and interspersed * empty cells, the search may report any of the duplicated positions (Excel's binary-search modes @@ -40,6 +34,24 @@ const NOT_FOUND = -1 * back to an O(n) scan that compacts the non-empty indices and re-runs the binary search over them. * * Note: this function does not normalize input strings. + * + * @param {RawNoErrorScalarValue} searchKey - Lookup value. + * @param {AbsoluteCellRange} range - Sorted one-dimensional cell range. + * @param {object} options - Search direction, requested miss result, and approximate-match policy. + * @param {('row'|'col')} options.searchCoordinate - Dimension containing the one-dimensional lookup values. + * @param {('asc'|'desc')} options.orderingDirection - Ascending or descending ordering assumed by the binary search. + * @param {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} options.ifNoMatch - Whether an absent exact value requests a lower bound, upper bound, or no result. + * @param {ApproximateMatchPolicy} options.approximateMatchPolicy - Cross-type candidate policy for approximate bounds. + * @param {DependencyGraph} dependencyGraph - Source of current cell values for the range. + * @returns {number} The range-relative index of the match, or `NOT_FOUND` when no valid result exists. + * + * Approximate result invariant: + * + * Exact equality is resolved before approximate policy validation. Every approximate exit path, + * including candidates reached by stepping over empty cells, is finalized through the same policy + * check so linear and binary lookup routes cannot assign different cross-type semantics. + * + * @internal */ export function findLastOccurrenceInOrderedRange( searchKey: RawNoErrorScalarValue, @@ -119,6 +131,14 @@ export function findLastOccurrenceInOrderedRange( const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex) + /** + * Validates and converts an absolute approximate candidate index into a range-relative result. + * + * @param {(number|undefined)} index - Absolute candidate index, or `undefined` when no non-empty candidate exists. + * @returns {number} The range-relative index, or `NOT_FOUND` when the candidate is absent or disallowed. + * + * @internal + */ const returnApproximateResult = (index: number | undefined): number => { if (index === undefined || index === NOT_FOUND) { return NOT_FOUND From e119019a5310e9c4f74108ba9be552bacfc3e4a3 Mon Sep 17 00:00:00 2001 From: tobiadefami Date: Fri, 2 Oct 2026 14:29:41 +0100 Subject: [PATCH 3/3] Skip cross-type cells in binary search descent Under the sameType policy, binary search now skips cells whose type differs from the lookup value during the descent, the same way HF-223 skips empty cells, instead of only rejecting the landing cell. Linear and binary search now pick from the same candidates, so MATCH, VLOOKUP and HLOOKUP no longer depend on whether they get a cell range, an inline array, or a different useColumnIndex setting. Make SearchOptions.approximateMatchPolicy optional, defaulting to 'sameType' in AdvancedFind.basicFind, so custom plugins calling columnSearch.find keep compiling and get a defined default. Narrow the list-of-differences row and the changelog entry to the measured behaviour. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- docs/guide/list-of-differences.md | 2 +- src/Lookup/AdvancedFind.ts | 8 +-- src/Lookup/SearchStrategy.ts | 10 +-- src/interpreter/binarySearch.ts | 103 +++++++++++++++++------------- 5 files changed, 71 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd74261a13..5eb9a865b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - Fixed the MAXPOOL and MEDIANPOOL functions throwing an uncaught `TypeError` instead of returning the `#VALUE!` error when the range dimensions are not a whole multiple of the window size and the stride. [#1718](https://github.com/handsontable/hyperformula/pull/1718) - Fixed the `MOD` function returning a remainder with the sign of the dividend instead of the sign of the divisor, which made the results differ from Excel and Google Sheets for arguments with opposite signs (e.g. `=MOD(-3, 12)` now returns `9` instead of `-3`). [#1747](https://github.com/handsontable/hyperformula/issues/1747) -- Fixed approximate lookups to handle values of different types consistently across linear and binary search paths. `MATCH`, `VLOOKUP`, and `HLOOKUP` ignore cross-type candidates, while `XLOOKUP` follows Microsoft Excel's total ordering. [#1755](https://github.com/handsontable/hyperformula/pull/1755) +- Fixed approximate `XLOOKUP` ignoring values of a different type in some search modes, and approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` taking such values into account for non-range arrays, single-cell ranges, and binary search over sorted cell ranges; both now behave the same in linear and binary search, consistent with Microsoft Excel. [#1755](https://github.com/handsontable/hyperformula/pull/1755) ## [3.4.0] - 2026-08-10 diff --git a/docs/guide/list-of-differences.md b/docs/guide/list-of-differences.md index 5aeff3ff4c..0f5929e5e7 100644 --- a/docs/guide/list-of-differences.md +++ b/docs/guide/list-of-differences.md @@ -52,7 +52,7 @@ See a full list of differences between HyperFormula, Microsoft Excel, and Google | EDATE function | =EDATE(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | | EOMONTH function | =EOMONTH(DATE(2019, 7, 31), 1) | Type of the returned value: `CellValueDetailedType.NUMBER_DATE`. This is non-compliant with the [OpenDocument](https://docs.oasis-open.org/office/OpenDocument/v1.3/os/part4-formula/OpenDocument-v1.3-os-part4-formula.html) standard, which defines the return type as a Number, while describing it as a Date serial number through the function summary. | Cell auto-formatted as **date** | Cell auto-formatted as **regular number** | | Empty cells in lookup search | =XLOOKUP(3, A1:A4, A1:A4, "NF", 0, 2)
where A1:A4 = 1, 2, (empty), 3 | Empty cells are skipped during the search. A value that is present is found even when it sits past an interspersed empty cell (exact match is gap-independent), and approximate `MATCH`/`VLOOKUP`/`HLOOKUP`/`XLOOKUP` skip empty cells — but not empty strings — when finding the lower/upper bound. Returns `3`. On an all-empty range in a binary search mode, HyperFormula returns the `if_not_found` result (never row 1). | Skips empty cells in approximate search (parity with HyperFormula). | With binary search modes (`search_mode` ±2), a range with interspersed empty cells is not strictly sorted; per Excel's documentation the result may be invalid, so a value past an empty cell is not reliably found. On an all-empty range in a binary mode, Excel returns the first row's value. | -| Approximate lookup matching across value types | =XLOOKUP("a", A1:A2, B1:B2, "NF", -1, 1)
where A1:A2 = 10, 20 and B1:B2 = "x", "y" | Approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` ignore candidates whose type differs from the lookup value. Approximate `XLOOKUP` follows Microsoft Excel's total ordering across value types in every linear and binary search mode. Returns `"y"` for the example. | Approximate lookups ignore candidates whose type differs from the lookup value. Returns `"NF"` for the example. | Same as HyperFormula. | +| Approximate lookup matching across value types | =XLOOKUP("a", A1:A2, B1:B2, "NF", -1, 1)
where A1:A2 = 10, 20 and B1:B2 = "x", "y" | Approximate `MATCH`, `VLOOKUP`, and `HLOOKUP` skip candidates whose type differs from the lookup value, in both linear and binary search. Approximate `XLOOKUP` follows Microsoft Excel's ordering of numbers, text, logical values, and errors; empty cells are skipped (see *Empty cells in lookup search*). Returns `"y"` for the example. | Approximate lookups ignore candidates whose type differs from the lookup value. Returns `"NF"` for the example. | Same as HyperFormula, except that linear approximate `XLOOKUP` ranks an empty cell above every value: `=XLOOKUP("z", A1:A4, B1:B4, "NF", 1, 1)` where A1:A4 = 10, (empty), 20, "b" returns B2. | ## Built-in functions diff --git a/src/Lookup/AdvancedFind.ts b/src/Lookup/AdvancedFind.ts index d2c3f68577..35dad717e0 100644 --- a/src/Lookup/AdvancedFind.ts +++ b/src/Lookup/AdvancedFind.ts @@ -12,7 +12,7 @@ import { RawNoErrorScalarValue } from '../interpreter/InterpreterValue' import {SimpleRangeValue} from '../SimpleRangeValue' -import {AdvancedFindOptions, SearchOptions} from './SearchStrategy' +import {AdvancedFindOptions, ApproximateMatchPolicy, SearchOptions} from './SearchStrategy' import {forceNormalizeString} from '../interpreter/ArithmeticHelper' import {compare, findLastOccurrenceInOrderedRange} from '../interpreter/binarySearch' @@ -42,7 +42,7 @@ export abstract class AdvancedFind { return NOT_FOUND } - protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, approximateMatchPolicy, returnOccurrence }: SearchOptions): number { + protected basicFind(searchKey: RawNoErrorScalarValue, rangeValue: SimpleRangeValue, searchCoordinate: 'col' | 'row', { ordering, ifNoMatch, approximateMatchPolicy = 'sameType', returnOccurrence }: SearchOptions): number { const normalizedSearchKey = typeof searchKey === 'string' ? forceNormalizeString(searchKey) : searchKey const range = rangeValue.range @@ -73,7 +73,7 @@ export abstract class AdvancedFind { * @param {RawNoErrorScalarValue} searchKey - Normalized lookup value. * @param {InternalScalarValue[]} searchArray - Values to search from the top-left corner of the lookup array. * @param {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Whether an absent exact value requests a lower bound, upper bound, or no result. - * @param {SearchOptions['approximateMatchPolicy']} approximateMatchPolicy - Cross-type candidate policy for approximate bounds. + * @param {ApproximateMatchPolicy} approximateMatchPolicy - Cross-type candidate policy for approximate bounds. * @param {('first'|'last')} returnOccurrence - Which exact duplicate to return and the direction used to scan candidates. * @returns {number} The zero-based index into `searchArray`, or `NOT_FOUND` (-1) when nothing matches. * @@ -87,7 +87,7 @@ export abstract class AdvancedFind { * * @internal */ - protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: SearchOptions['approximateMatchPolicy'], returnOccurrence: 'first' | 'last' = 'first'): number { + protected findNormalizedValue(searchKey: RawNoErrorScalarValue, searchArray: InternalScalarValue[], ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', approximateMatchPolicy: ApproximateMatchPolicy, returnOccurrence: 'first' | 'last' = 'first'): number { const normalizedArray = searchArray .map(getRawValue) .map(val => typeof val === 'string' ? forceNormalizeString(val) : val) diff --git a/src/Lookup/SearchStrategy.ts b/src/Lookup/SearchStrategy.ts index eb6c7ac06e..b0a672c66f 100644 --- a/src/Lookup/SearchStrategy.ts +++ b/src/Lookup/SearchStrategy.ts @@ -24,8 +24,10 @@ import {ColumnIndex} from './ColumnIndex' * `MATCH`, `VLOOKUP`, and `HLOOKUP` use this policy. * - `'totalOrder'` accepts cross-type candidates and orders them with the lookup comparator. * `XLOOKUP` uses this policy to match Microsoft Excel. - * - Exact matches are unaffected. The policy is required so every search strategy receives an - * explicit choice and linear, indexed, and binary paths cannot silently diverge. + * - Exact matches are unaffected. Every search strategy resolves the policy in one place + * (`AdvancedFind.basicFind`), so linear, indexed, and binary paths cannot diverge. + * + * When `SearchOptions.approximateMatchPolicy` is omitted, `'sameType'` is used. * * @internal */ @@ -36,14 +38,14 @@ export type ApproximateMatchPolicy = 'sameType' | 'totalOrder' * * @property {('asc'|'desc'|'none')} ordering - Ordering assumed by the selected search algorithm. * @property {('returnLowerBound'|'returnUpperBound'|'returnNotFound')} ifNoMatch - Result requested when an exact value is absent. - * @property {ApproximateMatchPolicy} approximateMatchPolicy - Cross-type candidate policy for approximate bounds; exact matches ignore it. + * @property {ApproximateMatchPolicy} [approximateMatchPolicy='sameType'] - Cross-type candidate policy for approximate bounds; exact matches ignore it. * @property {('first'|'last')} [returnOccurrence] - Which exact duplicate to return. * @internal */ export interface SearchOptions { ordering: 'asc' | 'desc' | 'none', ifNoMatch: 'returnLowerBound' | 'returnUpperBound' | 'returnNotFound', - approximateMatchPolicy: ApproximateMatchPolicy, + approximateMatchPolicy?: ApproximateMatchPolicy, returnOccurrence?: 'first' | 'last', } diff --git a/src/interpreter/binarySearch.ts b/src/interpreter/binarySearch.ts index 7f215a73a6..99c896181a 100644 --- a/src/interpreter/binarySearch.ts +++ b/src/interpreter/binarySearch.ts @@ -26,12 +26,15 @@ const NOT_FOUND = -1 * ignore genuinely empty cells (but not empty strings) when looking for the lower/upper bound; * for Excel's explicit binary search modes (XLOOKUP search_mode ±2) the result on a range with * interspersed empty cells is unspecified — see docs/guide/list-of-differences.md. - * The returned offset is always relative to the original range, so empty cells keep their slots - * and the position of the matched non-empty cell is reported unchanged. + * Under the 'sameType' policy, cells whose type differs from the searchKey's are skipped the same + * way, as Excel's MATCH/VLOOKUP/HLOOKUP drop them before searching. + * The returned offset is always relative to the original range, so skipped cells keep their slots + * and the position of the matched cell is reported unchanged. * - * Complexity: O(log n) as long as the binary-search descent probes no empty cell — in particular - * for ranges without empty cells. Only when the descent touches an empty cell does the search fall - * back to an O(n) scan that compacts the non-empty indices and re-runs the binary search over them. + * Complexity: O(log n) as long as the binary-search descent probes no skipped cell — in particular + * for ranges without empty cells (and, under 'sameType', without cross-type cells). Only when the + * descent touches a skipped cell does the search fall back to an O(n) scan that compacts the + * eligible indices and re-runs the binary search over them. * * Note: this function does not normalize input strings. * @@ -47,9 +50,11 @@ const NOT_FOUND = -1 * * Approximate result invariant: * - * Exact equality is resolved before approximate policy validation. Every approximate exit path, - * including candidates reached by stepping over empty cells, is finalized through the same policy - * check so linear and binary lookup routes cannot assign different cross-type semantics. + * Exact equality is resolved before approximate policy validation. Under 'sameType', cross-type + * cells are excluded from the descent itself — not only from the landing cell — so the binary + * route selects among the same candidates as the linear route, which skips them before comparing. + * Every approximate exit path, including candidates reached by stepping over skipped cells, is + * finalized through the same policy check. * * @internal */ @@ -71,13 +76,20 @@ export function findLastOccurrenceInOrderedRange( : (left: RawNoErrorScalarValue, right: RawInterpreterValue) => -compare(left, right) /* - * Returns the original index of the first non-empty cell at or after fromIndex, or undefined if - * all the remaining cells are empty. Costs O(gap length), which the search pays only when it - * actually needs to step over empty slots. + * A cell is skipped by the approximate search when it is empty or, under the 'sameType' policy, + * holds a value of a different type than the searchKey. Every other cell is eligible. */ - const findNextNonEmptyIndex = (fromIndex: number): number | undefined => { + const isSkipped = (value: RawInterpreterValue): boolean => + value === EmptyValue || (approximateMatchPolicy === 'sameType' && typeof value !== typeof searchKey) + + /* + * Returns the original index of the first eligible cell at or after fromIndex, or undefined if + * no eligible cell remains. Costs O(gap length), which the search pays only when it actually + * needs to step over skipped slots. + */ + const findNextEligibleIndex = (fromIndex: number): number | undefined => { for (let index = fromIndex; index <= end; index++) { - if (getValueFromIndexFn(index) !== EmptyValue) { + if (!isSkipped(getValueFromIndexFn(index))) { return index } } @@ -85,7 +97,8 @@ export function findLastOccurrenceInOrderedRange( } // Fast path: binary search directly over the original range, tracking whether the descent ever - // probed an empty cell. Within the sorted-input contract, empty cells are the only source of + // probed a skipped cell (empty, or cross-type under 'sameType'; the reasoning below for empty + // cells applies to both). Within the sorted-input contract, empty cells are the only source of // non-monotonicity in the search predicate: compare() ranks EmptyValue below every non-empty // value, and genuinely empty cells surface as the EmptyValue sentinel — empty strings and 0 do // not. (Error values also break the ordering, but a range containing errors is outside the @@ -94,39 +107,39 @@ export function findLastOccurrenceInOrderedRange( // justified by the monotonicity of the non-empty values, and the landing index — which is always // probed — is the same one the compacted search below would return: the result can be trusted // as-is. This keeps the search O(log n) unless an empty cell actually interferes. - let probedEmptyCell = false + let probedSkippedCell = false const directIndex = findLastMatchingIndex(index => { const value = getValueFromIndexFn(index) - if (value === EmptyValue) { - probedEmptyCell = true + if (isSkipped(value)) { + probedSkippedCell = true } return compareFn(searchKey, value) >= 0 }, start, end) let foundIndex: number - if (!probedEmptyCell) { + if (!probedSkippedCell) { foundIndex = directIndex } else if (ifNoMatch === 'returnNotFound' && directIndex !== NOT_FOUND && getValueFromIndexFn(directIndex) === searchKey) { // Exact-match mode: a misdirected descent cannot produce a false positive, because the landing // value is re-checked for equality here. Accept the hit and skip the O(n) fallback. return directIndex - start } else { - // The descent probed an empty cell, so its result cannot be trusted (HF-223): collect the - // original indices of the non-empty cells (O(n)) and re-run the binary search over the - // compacted, empty-free index list. The result maps back to the original index space, so empty - // cells keep their slots and the matched non-empty cell's original position is reported - // unchanged. On an all-empty range the compacted list has no elements and the search reports - // NOT_FOUND, which the ifNoMatch branches below preserve. - const nonEmptyIndices: number[] = [] + // The descent probed a skipped cell, so its result cannot be trusted (HF-223): collect the + // original indices of the eligible cells (O(n)) and re-run the binary search over the + // compacted index list. The result maps back to the original index space, so skipped cells + // keep their slots and the matched cell's original position is reported unchanged. When no + // cell is eligible the compacted list has no elements and the search reports NOT_FOUND, which + // the ifNoMatch branches below preserve. + const eligibleIndices: number[] = [] for (let index = start; index <= end; index++) { - if (getValueFromIndexFn(index) !== EmptyValue) { - nonEmptyIndices.push(index) + if (!isSkipped(getValueFromIndexFn(index))) { + eligibleIndices.push(index) } } - const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(nonEmptyIndices[compactedIndex])) >= 0, 0, nonEmptyIndices.length - 1) - foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : nonEmptyIndices[foundCompactedIndex] + const foundCompactedIndex = findLastMatchingIndex(compactedIndex => compareFn(searchKey, getValueFromIndexFn(eligibleIndices[compactedIndex])) >= 0, 0, eligibleIndices.length - 1) + foundIndex = foundCompactedIndex === NOT_FOUND ? NOT_FOUND : eligibleIndices[foundCompactedIndex] } const foundValue = foundIndex === NOT_FOUND ? EmptyValue : getValueFromIndexFn(foundIndex) @@ -134,7 +147,7 @@ export function findLastOccurrenceInOrderedRange( /** * Validates and converts an absolute approximate candidate index into a range-relative result. * - * @param {(number|undefined)} index - Absolute candidate index, or `undefined` when no non-empty candidate exists. + * @param {(number|undefined)} index - Absolute candidate index, or `undefined` when no eligible candidate exists. * @returns {number} The range-relative index, or `NOT_FOUND` when the candidate is absent or disallowed. * * @internal @@ -144,6 +157,8 @@ export function findLastOccurrenceInOrderedRange( return NOT_FOUND } + // Safety net: every candidate reaching this point is already eligible, because the descent, + // the compaction, and findNextEligibleIndex all skip cross-type cells under 'sameType'. if (approximateMatchPolicy === 'sameType' && typeof getValueFromIndexFn(index) !== typeof searchKey) { return NOT_FOUND } @@ -162,10 +177,10 @@ export function findLastOccurrenceInOrderedRange( } // orderingDirection === 'desc': the key exceeds every value in the range, so the lower bound - // is the first (largest) non-empty value — never an empty leading cell, and NOT_FOUND on an - // all-empty range. - const firstNonEmptyIndex = findNextNonEmptyIndex(start) - return returnApproximateResult(firstNonEmptyIndex) + // is the first (largest) eligible value — never a skipped leading cell, and NOT_FOUND when no + // cell is eligible. + const firstEligibleIndex = findNextEligibleIndex(start) + return returnApproximateResult(firstEligibleIndex) } // here: foundValue !== searchKey @@ -173,9 +188,9 @@ export function findLastOccurrenceInOrderedRange( return returnApproximateResult(foundIndex) } - // orderingDirection === 'desc': step to the next non-empty cell, so skipped empty slots never - // shift the reported position. - const nextIndex = findNextNonEmptyIndex(foundIndex + 1) + // orderingDirection === 'desc': step to the next eligible cell, so skipped slots never shift + // the reported position. + const nextIndex = findNextEligibleIndex(foundIndex + 1) return returnApproximateResult(nextIndex) } @@ -186,10 +201,10 @@ export function findLastOccurrenceInOrderedRange( } // orderingDirection === 'asc': the key precedes every value in the range, so the upper bound - // is the first (smallest) non-empty value — never an empty leading cell, and NOT_FOUND on an - // all-empty range. - const firstNonEmptyIndex = findNextNonEmptyIndex(start) - return returnApproximateResult(firstNonEmptyIndex) + // is the first (smallest) eligible value — never a skipped leading cell, and NOT_FOUND when no + // cell is eligible. + const firstEligibleIndex = findNextEligibleIndex(start) + return returnApproximateResult(firstEligibleIndex) } // here: foundValue !== searchKey @@ -197,9 +212,9 @@ export function findLastOccurrenceInOrderedRange( return returnApproximateResult(foundIndex) } - // orderingDirection === 'asc': step to the next non-empty cell, so skipped empty slots never - // shift the reported position. - const nextIndex = findNextNonEmptyIndex(foundIndex + 1) + // orderingDirection === 'asc': step to the next eligible cell, so skipped slots never shift + // the reported position. + const nextIndex = findNextEligibleIndex(foundIndex + 1) return returnApproximateResult(nextIndex) }