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) <noreply@anthropic.com>
This commit is contained in:
1 parent
c4e303c8b7
commit
926e241c7b
2 files changed
+337
No files matched your search
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user