Skip to content

fix: keep the correct top-N under limit when scores tie#835

Closed
spokodev wants to merge 1 commit into
krisk:mainfrom
spokodev:fix/limit-topn-tie-ordering
Closed

fix: keep the correct top-N under limit when scores tie#835
spokodev wants to merge 1 commit into
krisk:mainfrom
spokodev:fix/limit-topn-tie-ordering

Conversation

@spokodev

@spokodev spokodev commented Jul 2, 2026

Copy link
Copy Markdown

search(query, { limit: N }) can return a different, worse-ordered top-N than search(query).slice(0, N) when scores tie at the cutoff. The heap that selects the top-N orders and evicts by raw score only, so it ignores the tie-break and any custom sortFn.

The documented contract, and the repo's own limit results match top-N of unlimited results test, is that a limited search returns the same items in the same order as an unlimited search truncated to N. The default sortFn breaks a score tie by ascending refIndex, but the heap evicts whatever sits at its root, which on a tie can be the lowest-refIndex (best) item, so the correct item is dropped. A custom sortFn is never consulted during eviction at all.

Repro

const fuse = new Fuse(['matchX', 'matchY', 'matchZ', 'match'], { threshold: 0.6 })
fuse.search('match').map(r => r.refIndex)              // [3, 0, 1, 2]
fuse.search('match', { limit: 2 }).map(r => r.refIndex)
// before: [3, 1]   (refIndex 0 dropped)
// after:  [3, 0]   (equals search().slice(0, 2))

Fix

Give MaxHeap the sort comparator and order and evict by it, so the worst retained item is the one that sorts last. The retained top-N is then identical to a full sort followed by a slice, and a custom sortFn is honored. src/ is the source of truth; dist/ is rebuilt with npm run build.

Added a tie-break case to test/optimizations.test.js. It fails before the change; the full suite (372) stays green, and typecheck, lint and format:check pass.

@spokodev spokodev force-pushed the fix/limit-topn-tie-ordering branch from 9d899fc to 572811d Compare July 3, 2026 19:05
search(query, { limit: N }) could return a different, worse-ordered top-N
than search(query).slice(0, N) when scores tie at the cutoff. The heap that
selects the top-N ordered and evicted by raw score only, ignoring the
tie-break and any custom sortFn, so on a tie the heap could drop the item
the sort order says to keep.

Give MaxHeap the sort comparator and order and evict by it, so the retained
top-N matches a full sort followed by a slice and a custom sortFn is honored.
dist regenerated with npm run build.
@spokodev spokodev force-pushed the fix/limit-topn-tie-ordering branch from 572811d to a3a2af7 Compare July 4, 2026 11:35
krisk added a commit that referenced this pull request Jul 13, 2026
search(query, { limit: N }) selected the top-N with a max-heap that
ordered and evicted by raw score alone, ignoring the sortFn tie-break
and any custom sortFn. On a score tie at the cutoff it could drop the
item a full sort would keep, so the result differed from
search(query).slice(0, N).

Order the heap by the sort comparator and break comparator ties by item
index, applying the same tie-break to the full-sort path. The retained
top-N is now identical to a full sort followed by a slice for every
index, including reordered parseIndex records, and a custom sortFn is
honored. Gate the heap on shouldSort so shouldSort:false keeps
collection order.

Remove the now-redundant MaxHeap.shouldInsert (insert already guards)
and consolidate the sortFn casts into one typed assertion.

Reported in #835.
@krisk

krisk commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Your diagnosis was right: the heap ordered and evicted by raw score, so on a tie at the cutoff it ignored the tie-break and dropped the item a full sort would keep. The repro made it easy to confirm.

I've extended it a fair bit past your branch, so I'm landing it as a separate commit rather than merging directly. The same root cause turned up in a few more spots:

  • A custom sortFn never got consulted during eviction, only at the final sort, so the retained set could be wrong, not just the order.
  • limit ignored shouldSort, so search(q, { limit }) used the heap even under shouldSort: false and returned top-N by score when it should have kept collection order.
  • Records can arrive out of idx order via Fuse.parseIndex, so I made idx the canonical tie-break on both the heap and the full sort.

Also dropped MaxHeap.shouldInsert (insert already guards), collapsed the sortFn casts, and added a regression test per case.

Closing in favor of that commit.

@krisk krisk closed this Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants