1import { describe, expect, it } from "vitest";
2
3import { RRF_K, fuseHybrid } from "./rrf.js";
4import {
5 DEFAULT_HYBRID_WEIGHTS,
6 buildHybridFusion,
7 normalizeHybridWeights,
8} from "./hybrid.js";
9
10describe("normalizeHybridWeights", () => {
11 it("defaults to 0.5/0.5", () => {
12 expect(normalizeHybridWeights()).toEqual({ kw: 0.5, vec: 0.5 });
13 expect(DEFAULT_HYBRID_WEIGHTS).toEqual({ kw: 0.5, vec: 0.5 });
14 });
15
16 it("normalizes asymmetric weights to a unit blend", () => {
17 expect(normalizeHybridWeights({ kw: 3, vec: 1 })).toEqual({ kw: 0.75, vec: 0.25 });
18 expect(normalizeHybridWeights({ kw: 0.75, vec: 0.25 })).toEqual({ kw: 0.75, vec: 0.25 });
19 });
20
21 it("fills a missing side with the 0.5 default", () => {
22 expect(normalizeHybridWeights({ kw: 1 })).toEqual({ kw: 0.6666666666666666, vec: 0.3333333333333333 });
23 });
24
25 it("rejects zero / negative / non-finite blends", () => {
26 expect(() => normalizeHybridWeights({ kw: 0, vec: 0 })).toThrow(/both be zero/);
27 expect(() => normalizeHybridWeights({ kw: -1, vec: 1 })).toThrow(/invalid hybrid weights/);
28 expect(() => normalizeHybridWeights({ kw: Number.NaN, vec: 1 })).toThrow(/invalid hybrid weights/);
29 });
30});
31
32describe("buildHybridFusion", () => {
33 it("uses RRF k=60 per list (default weights: 0.5/0.5 blend)", () => {
34 const fused = buildHybridFusion(["a", "b"], ["a", "c"]);
35
36 expect(fused[0]!.docId).toBe("a");
37 expect(fused[0]!.score).toBeCloseTo(0.5 * (2 / 61), 12);
38 expect(fused[0]!.sources).toEqual(["bm25", "vec"]);
39 expect(fused[1]!.score).toBeCloseTo(0.5 / 62, 12);
40 expect(fused[2]!.score).toBeCloseTo(0.5 / 62, 12);
41 });
42
43 it("matches fuseHybrid's ORDERING at the default blend (scores = ½ × unweighted RRF)", () => {
44 const kw = ["d1", "d2", "d3", "d9"];
45 const knn = ["d3", "d1", "d8", "d2"];
46 const weighted = buildHybridFusion(kw, knn);
47 const plain = fuseHybrid(kw, knn);
48
49
50
51 expect(weighted.map((f) => f.docId)).toEqual(plain.map((f) => f.docId));
52 for (let i = 0; i < plain.length; i++) {
53 expect(weighted[i]!.score).toBeCloseTo(0.5 * plain[i]!.score, 12);
54 expect(weighted[i]!.sources).toEqual(plain[i]!.sources);
55 }
56 });
57
58 it("honours an explicit k", () => {
59 const fused = buildHybridFusion(["a"], [], {}, 10);
60 expect(fused[0]!.score).toBeCloseTo(0.5 / 11, 12);
61 expect(RRF_K).toBe(60);
62 });
63
64 it("asymmetric weights rescale the per-list RRF terms before summing", () => {
65
66 const fused = buildHybridFusion(["a", "x"], ["a", "y"], { kw: 3, vec: 1 });
67 expect(fused[0]!.score).toBeCloseTo(0.75 / 61 + 0.25 / 61, 12);
68
69
70 const byId = new Map(fused.map((f) => [f.docId, f]));
71 expect(byId.get("x")!.score).toBeGreaterThan(byId.get("y")!.score);
72 expect(fused.map((f) => f.docId)).toEqual(["a", "x", "y"]);
73 });
74
75 it("a dominant vector weight can re-order keyword-only docs below vector-only docs", () => {
76
77
78 const fused = buildHybridFusion(["k1"], ["v1"], { kw: 0.1, vec: 0.9 });
79 expect(fused.map((f) => f.docId)).toEqual(["v1", "k1"]);
80 });
81
82 it("breaks score ties deterministically by docId (same rule as rrf)", () => {
83
84 const fused = buildHybridFusion(["z"], ["m"]);
85 expect(fused.map((f) => f.docId)).toEqual(["m", "z"]);
86 });
87
88 it("names the sources bm25/vec (drop-in for SearchResult.source)", () => {
89 const fused = buildHybridFusion(["a", "b"], ["b", "c"]);
90 expect(fused.find((f) => f.docId === "b")!.sources).toEqual(["bm25", "vec"]);
91 expect(fused.find((f) => f.docId === "a")!.sources).toEqual(["bm25"]);
92 expect(fused.find((f) => f.docId === "c")!.sources).toEqual(["vec"]);
93 });
94
95 it("handles empty lists and respects limit", () => {
96 expect(buildHybridFusion([], [])).toEqual([]);
97 expect(buildHybridFusion(["a", "b", "c"], ["c"], {}, 60, 2)).toHaveLength(2);
98 });
99});
100