From 283d7717e3bcdc255bd071fc19a6c4a78abe5ef6 Mon Sep 17 00:00:00 2001 From: JSap0914 Date: Tue, 30 Jun 2026 12:39:10 +0900 Subject: [PATCH] fix(scoring): use normalised key weights in _searchObjectList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _searchObjectList built its key list from this._myIndex.keys, which stores the raw user-specified weights (before the normalisation that KeyStore performs). KeyStore normalises weights so they sum to 1, but _searchObjectList never read from KeyStore — it used the original values directly. This caused two bugs: 1. Score underflow for extreme weight values. A single key always normalises to weight=1.0 (only key = 100%), so 'weight:1' and 'weight:100' must produce the same score. With the raw weight in the exponent the formula Math.pow(Number.EPSILON, 100 * fieldNorm) underflows to exactly 0, whereas the normalised exponent (1) gives ~2.2e-16. 2. Score inconsistency between query forms. Plain string queries went through _searchObjectList (raw weights); logical Expression queries went through _searchLogical which already called this._keyStore.get(keyId) (normalised weights). Identical searches produced different absolute scores depending on how the query was expressed. Fix: replace 'const { keys, records } = this._myIndex' with 'const keys = this._keyStore.keys()' so the keys seen by _findMatches — and therefore by computeScoreSingle — carry the same normalised weights that KeyStore guarantees. Closes #833 --- src/core/index.ts | 10 ++- test/key-weight-normalization.test.js | 117 ++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 test/key-weight-normalization.test.js diff --git a/src/core/index.ts b/src/core/index.ts index 74e42cbfc..12ae56a5c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -434,7 +434,15 @@ export default class Fuse { const searcher = this._getSearcher(query) const requireAllTokens = this.options.useTokenSearch && this.options.tokenMatch === 'all' - const { keys, records } = this._myIndex + const { records } = this._myIndex + // Use KeyStore's normalised keys so that key.weight reflects the + // weight normalisation performed by KeyStore (weights sum to 1). + // Previously this used this._myIndex.keys whose weights are the raw + // user-supplied values; with extreme weights (e.g. 100) the exponent + // Math.pow(Number.EPSILON, weight * norm) underflows to 0, and scores + // become inconsistent with _searchLogical which already reads from + // this._keyStore.get(keyId). + const keys = this._keyStore.keys() const results: InternalResult[] | null = heap ? null : [] // List is Array diff --git a/test/key-weight-normalization.test.js b/test/key-weight-normalization.test.js new file mode 100644 index 000000000..9d6628075 --- /dev/null +++ b/test/key-weight-normalization.test.js @@ -0,0 +1,117 @@ +// Regression tests for https://github.com/krisk/Fuse/issues/833 +// +// _searchObjectList previously used this._myIndex.keys (raw user-specified +// weights) instead of this._keyStore.keys() (normalised weights that sum to +// 1). This caused two observable bugs: +// +// 1. For a single key, weight:N always normalises to 1.0 (only key = 100 %), +// so any two weight values must produce identical scores. With the raw +// weight in the exponent, Math.pow(Number.EPSILON, N * fieldNorm) +// underflows to exactly 0 for large N. +// +// 2. Scores from plain string queries (_searchObjectList, raw weights) differ +// from scores for the same search expressed as an Expression query +// (_searchLogical, normalised KeyStore weights). + +import { describe, test, expect } from 'vitest' + +process.env.EXTENDED_SEARCH_ENABLED = 'true' +process.env.LOGICAL_SEARCH_ENABLED = 'true' + +const { default: Fuse } = await import('../src/entry') + +describe('key weight normalisation in _searchObjectList', () => { + // ── single-key weight invariance ───────────────────────────────────────── + // + // A single key always normalises to weight=1.0 regardless of its raw value, + // so any two raw weight values must produce the same score. + + test('single key weight:1 and weight:2 produce the same score', () => { + const fuseW1 = new Fuse([{ text: 'hello' }], { + keys: [{ name: 'text', weight: 1 }], + includeScore: true + }) + const fuseW2 = new Fuse([{ text: 'hello' }], { + keys: [{ name: 'text', weight: 2 }], + includeScore: true + }) + + const r1 = fuseW1.search('hello') + const r2 = fuseW2.search('hello') + + expect(r1).toHaveLength(1) + expect(r2).toHaveLength(1) + // After normalisation both instances have key.weight == 1.0, so the + // computed score must be identical. + expect(r1[0].score).toBe(r2[0].score) + }) + + test('single key weight:100 does not underflow to 0', () => { + // Math.pow(Number.EPSILON, 100 * fieldNorm) underflows to exactly 0 when + // the raw weight (100) is used; the normalised weight (1.0) does not. + const fuseW1 = new Fuse([{ text: 'hello' }], { + keys: [{ name: 'text', weight: 1 }], + includeScore: true + }) + const fuseW100 = new Fuse([{ text: 'hello' }], { + keys: [{ name: 'text', weight: 100 }], + includeScore: true + }) + + const rW1 = fuseW1.search('hello') + const rW100 = fuseW100.search('hello') + + expect(rW1).toHaveLength(1) + expect(rW100).toHaveLength(1) + + // weight:100 must not underflow to 0 + expect(rW100[0].score).toBeGreaterThan(0) + // and must equal the weight:1 result (both normalise to 1.0) + expect(rW100[0].score).toBe(rW1[0].score) + }) + + // ── multi-key: proportional weight ratio is preserved ───────────────────── + // + // Scaling all weights by the same factor (e.g. ×10) must not change the + // result ordering because normalisation collapses the factor. + + test('scaling all weights by the same factor does not change result ordering', () => { + const docs = [ + { title: 'JavaScript Guide', author: 'Alice' }, + { title: 'Python Cookbook', author: 'JavaScript Bob' } + ] + + const fuseNorm = new Fuse(docs, { + keys: [ + { name: 'title', weight: 0.7 }, + { name: 'author', weight: 0.3 } + ], + includeScore: true, + threshold: 1 + }) + const fuseScaled = new Fuse(docs, { + keys: [ + { name: 'title', weight: 7 }, // ×10 — same ratio + { name: 'author', weight: 3 } + ], + includeScore: true, + threshold: 1 + }) + + const rNorm = fuseNorm.search('javascript') + const rScaled = fuseScaled.search('javascript') + + expect(rNorm.length).toBeGreaterThan(0) + expect(rScaled.length).toBeGreaterThan(0) + + // Ordering must be the same after normalisation + const orderNorm = rNorm.map(r => r.item.title) + const orderScaled = rScaled.map(r => r.item.title) + expect(orderNorm).toEqual(orderScaled) + + // Absolute scores must also match (normalisation collapses the ×10 factor) + rNorm.forEach((result, i) => { + expect(result.score).toBeCloseTo(rScaled[i].score, 10) + }) + }) +})