From 88fb6b5a1f32a078c950c1d2bd99be44f0d544da Mon Sep 17 00:00:00 2001 From: "Sharad." Date: Sat, 12 Sep 2026 14:25:12 +0000 Subject: [PATCH] fix: reject out-of-range embedding-similarity thresholds Fixes #34 Co-authored-by: Sharad. --- src/scorers/embedding-similarity.ts | 11 +++++++++-- src/scorers/util.ts | 7 +++++++ tests/scorers.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/scorers/embedding-similarity.ts b/src/scorers/embedding-similarity.ts index 4e3752e..93c334e 100644 --- a/src/scorers/embedding-similarity.ts +++ b/src/scorers/embedding-similarity.ts @@ -1,6 +1,6 @@ import type { Scorer, ScoreContext, ScorerSpec } from "../types.js"; import { localEmbedding } from "../providers/mock.js"; -import { result } from "./util.js"; +import { parseThreshold, result } from "./util.js"; /** Cosine similarity between two equal-length vectors. */ export function cosineSimilarity(a: number[], b: number[]): number { @@ -38,7 +38,14 @@ export const embeddingSimilarityScorer: Scorer = { if (expected === undefined) { return result(spec, { score: 0, passed: false, reason: "no expected reference provided" }); } - const threshold = typeof spec.threshold === "number" ? spec.threshold : 0.8; + const threshold = parseThreshold(spec, 0.8); + if (threshold === null) { + return result(spec, { + score: 0, + passed: false, + reason: "invalid threshold (must be a finite number in [0, 1])", + }); + } const embed = ctx.provider.embed ? (t: string) => ctx.provider.embed!(t) diff --git a/src/scorers/util.ts b/src/scorers/util.ts index 4a82776..8652606 100644 --- a/src/scorers/util.ts +++ b/src/scorers/util.ts @@ -6,6 +6,13 @@ export function clamp01(n: number): number { return Math.min(1, Math.max(0, n)); } +/** Parse a pass threshold in [0, 1], or null when missing/invalid. */ +export function parseThreshold(spec: ScorerSpec, defaultVal: number): number | null { + const t = typeof spec.threshold === "number" ? spec.threshold : defaultVal; + if (!Number.isFinite(t) || t < 0 || t > 1) return null; + return t; +} + /** Read the weight from a spec, defaulting to 1 and rejecting negatives. */ export function specWeight(spec: ScorerSpec): number { const w = typeof spec.weight === "number" ? spec.weight : 1; diff --git a/tests/scorers.test.ts b/tests/scorers.test.ts index 78bf373..71e06c2 100644 --- a/tests/scorers.test.ts +++ b/tests/scorers.test.ts @@ -145,6 +145,33 @@ describe("embedding-similarity", () => { it("cosineSimilarity of orthogonal vectors is 0", () => { expect(cosineSimilarity([1, 0], [0, 1])).toBe(0); }); + it("rejects NaN threshold", async () => { + const r = await run( + embeddingSimilarityScorer, + { type: "embedding-similarity", expected: "the quick brown fox", threshold: NaN }, + ctx("the quick brown fox"), + ); + expect(r.passed).toBe(false); + expect(r.reason).toMatch(/invalid threshold/i); + }); + it("rejects negative threshold", async () => { + const r = await run( + embeddingSimilarityScorer, + { type: "embedding-similarity", expected: "x", threshold: -5 }, + ctx("totally unrelated garbage"), + ); + expect(r.passed).toBe(false); + expect(r.reason).toMatch(/invalid threshold/i); + }); + it("rejects threshold above 1", async () => { + const r = await run( + embeddingSimilarityScorer, + { type: "embedding-similarity", expected: "the quick brown fox", threshold: 2 }, + ctx("the quick brown fox"), + ); + expect(r.passed).toBe(false); + expect(r.reason).toMatch(/invalid threshold/i); + }); }); describe("llm-judge (mock)", () => {