Files
openproject-mcp/tests/search/score.test.ts
T
Lucas WintherandClaude Opus 5 926e241c7b feat(search): add bounded levenshtein and coverage-weighted field scoring
Adds boundedLevenshtein with row-minimum early exit, token scoring
(exact / prefix / contains / shared-prefix / edit distance), and
coverage-weighted field aggregation scaled by BASE_SCALE so that only a
verbatim phrase match reaches 1.0.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-17 10:46:58 +02:00

125 lines
4.7 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { boundedLevenshtein, scoreField, scoreToken } from "../../src/search/score.ts";
import { tokenize } from "../../src/search/tokenize.ts";
describe("boundedLevenshtein", () => {
test("returns 0 for identical strings", () => {
expect(boundedLevenshtein("budget", "budget", 2)).toBe(0);
});
test("computes small distances within budget", () => {
expect(boundedLevenshtein("budget", "budgt", 2)).toBe(1);
expect(boundedLevenshtein("approval", "aproval", 2)).toBe(1);
});
test("returns max + 1 when the distance exceeds the budget", () => {
expect(boundedLevenshtein("cat", "dog", 1)).toBe(2);
expect(boundedLevenshtein("meeting", "xyz", 2)).toBe(3);
});
test("short-circuits on length difference alone", () => {
expect(boundedLevenshtein("a", "abcdefgh", 2)).toBe(3);
});
});
describe("scoreToken", () => {
test("scores an exact match at 1", () => {
expect(scoreToken("budget", "budget")).toBe(1);
});
test("scores a prefix match at 0.9 when the query token is at least 3 chars", () => {
expect(scoreToken("bud", "budget")).toBe(0.9);
});
test("does not prefix-match tokens shorter than 3 characters", () => {
expect(scoreToken("bu", "budget")).toBe(0);
});
test("scores a containment match at 0.75 when the query token is at least 4 chars", () => {
expect(scoreToken("prov", "approval")).toBe(0.75);
});
test("scores a single-typo match below an exact match but above zero", () => {
const score = scoreToken("budgt", "budget");
expect(score).toBeGreaterThan(0);
expect(score).toBeLessThan(1);
});
test("does NOT match unrelated short tokens", () => {
expect(scoreToken("cat", "dog")).toBe(0);
expect(scoreToken("red", "car")).toBe(0);
});
test("does not fuzzy-match tokens shorter than 3 characters", () => {
expect(scoreToken("ab", "ac")).toBe(0);
});
test("matches morphological variants via a shared prefix", () => {
// Neither token is a true prefix of the other, so these exercise the
// shared-prefix rule rather than being intercepted by the startsWith rule.
expect(scoreToken("decide", "decision")).toBeGreaterThan(0.5);
expect(scoreToken("deployed", "deployment")).toBeGreaterThan(0.5);
});
test("does not shared-prefix-match on fewer than 4 common characters", () => {
// "cars"/"carton" share only "car" (3 < MIN_SHARED_PREFIX) and neither is
// a true prefix of the other, so every rule falls through to zero.
expect(scoreToken("cars", "carton")).toBe(0);
expect(scoreToken("bees", "beetle")).toBe(0);
});
test("ranks a shared-prefix match below a true prefix match", () => {
expect(scoreToken("decide", "decision")).toBeLessThan(scoreToken("deci", "decision"));
});
});
describe("scoreField", () => {
test("scores a full phrase match highest", () => {
const query = tokenize("budget approval");
const both = scoreField(query, "Approval of the budget");
const one = scoreField(query, "Approval of the agenda");
expect(both.score).toBeGreaterThan(one.score);
});
test("coverage penalises documents matching only one query token", () => {
const query = tokenize("hiring decision");
const partial = scoreField(query, "Decision log");
expect(partial.score).toBeLessThan(0.6);
});
test("gives a verbatim phrase a bonus over scattered tokens", () => {
const query = tokenize("budget approval");
const verbatim = scoreField(query, "budget approval meeting");
const scattered = scoreField(query, "approval of the yearly budget");
expect(verbatim.score).toBeGreaterThan(scattered.score);
});
test("tolerates a typo in the query", () => {
const query = tokenize("budgt aproval");
const result = scoreField(query, "Approval of Q3 budget");
expect(result.score).toBeGreaterThan(0.5);
});
test("reports matched offsets that index into the original field text", () => {
const field = "Approval of Q3 budget";
const result = scoreField(tokenize("budget"), field);
expect(result.matchedOffsets.length).toBeGreaterThan(0);
expect(field.slice(result.matchedOffsets[0]!, result.matchedOffsets[0]! + 6)).toBe("budget");
});
test("returns zero for empty input", () => {
expect(scoreField([], "anything").score).toBe(0);
expect(scoreField(tokenize("budget"), "").score).toBe(0);
});
test("an all-tokens match without the phrase tops out below 1.0", () => {
const result = scoreField(tokenize("budget approval"), "approval of the budget");
expect(result.score).toBeCloseTo(0.85, 5);
});
test("only a verbatim phrase reaches 1.0", () => {
const result = scoreField(tokenize("budget approval"), "the budget approval doc");
expect(result.score).toBeCloseTo(1, 5);
});
});