fix(lib): guard Unlimiformer attentionScore against non-finite embeddings

Return NaN when vectors contain NaN, Infinity, or non-numbers instead of throwing from cosineSimilarity. Skip non-finite query and item embeddings in rankItemsByAttentionTopK. Add tests.
This commit is contained in:
kayo09 2026-04-04 18:24:12 +05:30
parent 33f47db175
commit e0eccbdd7e
2 changed files with 58 additions and 4 deletions

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import {
attentionScore,
attentionScores,
rankItemsByAttentionTopK,
topKAttentionKeys,
@ -60,6 +61,28 @@ describe("topKAttentionKeys", () => {
),
).toEqual([])
})
test("skips keys with NaN components without throwing", () => {
const q = unit(1, 0, 0)
const keys = [unit(1, 0, 0), [1, Number.NaN, 0], unit(0, 1, 0)]
const top = topKAttentionKeys(q, keys, 5)
expect(top.map((t) => t.index)).toEqual([0, 2])
})
})
describe("attentionScore", () => {
test("returns NaN instead of throwing when a vector contains NaN", () => {
const k = unit(1, 0, 0)
expect(attentionScore([1, Number.NaN, 0], k)).toBeNaN()
expect(attentionScore(k, [1, Number.NaN, 0])).toBeNaN()
})
test("returns NaN for non-number or non-finite components", () => {
const k = unit(1, 0, 0)
const stringSlot = [1, "x", 0] as unknown as number[]
expect(attentionScore(stringSlot, k)).toBeNaN()
expect(attentionScore([Number.POSITIVE_INFINITY, 0, 0], k)).toBeNaN()
})
})
describe("attentionScores", () => {
@ -113,4 +136,22 @@ describe("rankItemsByAttentionTopK", () => {
expect(ranked).toHaveLength(1)
expect(ranked[0]?.item.id).toBe("ok")
})
test("returns empty when query embedding is non-finite", () => {
const items = [{ id: "a", e: unit(1, 0, 0) }]
expect(
rankItemsByAttentionTopK([Number.NaN, 0, 0], items, (x) => x.e, 2),
).toEqual([])
})
test("skips items with non-finite embeddings", () => {
const items = [
{ id: "bad", e: [1, Number.NaN, 0] },
{ id: "ok", e: unit(0, 1, 0) },
]
const q = unit(0, 1, 0)
const ranked = rankItemsByAttentionTopK(q, items, (x) => x.e, 2)
expect(ranked).toHaveLength(1)
expect(ranked[0]?.item.id).toBe("ok")
})
})

View file

@ -9,6 +9,10 @@
import { cosineSimilarity } from "./similarity"
/** True when every entry is a finite number (empty arrays allowed). */
const isFiniteEmbeddingVector = (v: number[]): boolean =>
v.every((x) => typeof x === "number" && Number.isFinite(x))
export type AttentionTopK = {
index: number
score: number
@ -19,19 +23,23 @@ export type AttentionTopK = {
* For normalized embeddings this matches cosine similarity.
*
* Returns `NaN` when `query` and `key` have different lengths (e.g. mixed embedding
* models) so callers can avoid throwing from `cosineSimilarity`.
* models), or when either vector contains non-finite values (`NaN`, `±Infinity`), so
* callers avoid throwing from `cosineSimilarity`.
*/
export const attentionScore = (query: number[], key: number[]): number => {
if (query.length !== key.length) {
return Number.NaN
}
if (!isFiniteEmbeddingVector(query) || !isFiniteEmbeddingVector(key)) {
return Number.NaN
}
return cosineSimilarity(query, key)
}
/**
* Attention scores for `query` against every row in `keys`, aligned by index.
* Entries are `NaN` when a key length does not match the query (same embedding model
* is required for a meaningful score).
* Entries are `NaN` when a key length does not match the query, or when either vector
* has non-finite components.
*/
export const attentionScores = (query: number[], keys: number[][]): number[] =>
keys.map((key) => attentionScore(query, key))
@ -97,6 +105,10 @@ export const rankItemsByAttentionTopK = <T>(
return []
}
if (!isFiniteEmbeddingVector(queryEmbedding)) {
return []
}
const packed: Array<{ item: T; originalIndex: number; embedding: number[] }> =
[]
@ -107,7 +119,8 @@ export const rankItemsByAttentionTopK = <T>(
if (
embedding &&
embedding.length > 0 &&
embedding.length === queryEmbedding.length
embedding.length === queryEmbedding.length &&
isFiniteEmbeddingVector(embedding)
) {
packed.push({ item, originalIndex: i, embedding })
}