import { describe, expect, it } from "vitest"; import { RRF_K, fuseHybrid } from "./rrf.js"; import { DEFAULT_HYBRID_WEIGHTS, buildHybridFusion, normalizeHybridWeights, } from "./hybrid.js"; describe("normalizeHybridWeights", () => { it("defaults to 0.5/0.5", () => { expect(normalizeHybridWeights()).toEqual({ kw: 0.5, vec: 0.5 }); expect(DEFAULT_HYBRID_WEIGHTS).toEqual({ kw: 0.5, vec: 0.5 }); }); it("normalizes asymmetric weights to a unit blend", () => { expect(normalizeHybridWeights({ kw: 3, vec: 1 })).toEqual({ kw: 0.75, vec: 0.25 }); expect(normalizeHybridWeights({ kw: 0.75, vec: 0.25 })).toEqual({ kw: 0.75, vec: 0.25 }); }); it("fills a missing side with the 0.5 default", () => { expect(normalizeHybridWeights({ kw: 1 })).toEqual({ kw: 0.6666666666666666, vec: 0.3333333333333333 }); }); it("rejects zero / negative / non-finite blends", () => { expect(() => normalizeHybridWeights({ kw: 0, vec: 0 })).toThrow(/both be zero/); expect(() => normalizeHybridWeights({ kw: -1, vec: 1 })).toThrow(/invalid hybrid weights/); expect(() => normalizeHybridWeights({ kw: Number.NaN, vec: 1 })).toThrow(/invalid hybrid weights/); }); }); describe("buildHybridFusion", () => { it("uses RRF k=60 per list (default weights: 0.5/0.5 blend)", () => { const fused = buildHybridFusion(["a", "b"], ["a", "c"]); // a: 0.5*(1/61) + 0.5*(1/61) ; b: 0.5*(1/62) ; c: 0.5*(1/62) expect(fused[0]!.docId).toBe("a"); expect(fused[0]!.score).toBeCloseTo(0.5 * (2 / 61), 12); expect(fused[0]!.sources).toEqual(["bm25", "vec"]); expect(fused[1]!.score).toBeCloseTo(0.5 / 62, 12); expect(fused[2]!.score).toBeCloseTo(0.5 / 62, 12); }); it("matches fuseHybrid's ORDERING at the default blend (scores = ½ × unweighted RRF)", () => { const kw = ["d1", "d2", "d3", "d9"]; const knn = ["d3", "d1", "d8", "d2"]; const weighted = buildHybridFusion(kw, knn); const plain = fuseHybrid(kw, knn); // Same order (a global ½ scale never re-ranks) — and the spec's weighted // blend scales each per-list RRF term, so at 0.5/0.5 scores are exactly // half of `fuseHybrid`'s. expect(weighted.map((f) => f.docId)).toEqual(plain.map((f) => f.docId)); for (let i = 0; i < plain.length; i++) { expect(weighted[i]!.score).toBeCloseTo(0.5 * plain[i]!.score, 12); expect(weighted[i]!.sources).toEqual(plain[i]!.sources); } }); it("honours an explicit k", () => { const fused = buildHybridFusion(["a"], [], {}, 10); expect(fused[0]!.score).toBeCloseTo(0.5 / 11, 12); expect(RRF_K).toBe(60); }); it("asymmetric weights rescale the per-list RRF terms before summing", () => { // kw=3/vec=1 → 0.75/0.25. "a" is rank 1 in both lists. const fused = buildHybridFusion(["a", "x"], ["a", "y"], { kw: 3, vec: 1 }); expect(fused[0]!.score).toBeCloseTo(0.75 / 61 + 0.25 / 61, 12); // A keyword-only doc beats a vector-only doc when kw is dominant: // x = 0.75/62 ≈ 0.01210 ; y = 0.25/62 ≈ 0.00403 const byId = new Map(fused.map((f) => [f.docId, f])); expect(byId.get("x")!.score).toBeGreaterThan(byId.get("y")!.score); expect(fused.map((f) => f.docId)).toEqual(["a", "x", "y"]); }); it("a dominant vector weight can re-order keyword-only docs below vector-only docs", () => { // kw-only doc "k1" at rank 1; vec-only doc "v1" at rank 1. // With vec = 0.9/0.1: v1 = 0.9/61 > k1 = 0.1/61. const fused = buildHybridFusion(["k1"], ["v1"], { kw: 0.1, vec: 0.9 }); expect(fused.map((f) => f.docId)).toEqual(["v1", "k1"]); }); it("breaks score ties deterministically by docId (same rule as rrf)", () => { // z in kw at rank 1 (0.5/61), m in vec at rank 1 (0.5/61) → tie. const fused = buildHybridFusion(["z"], ["m"]); expect(fused.map((f) => f.docId)).toEqual(["m", "z"]); }); it("names the sources bm25/vec (drop-in for SearchResult.source)", () => { const fused = buildHybridFusion(["a", "b"], ["b", "c"]); expect(fused.find((f) => f.docId === "b")!.sources).toEqual(["bm25", "vec"]); expect(fused.find((f) => f.docId === "a")!.sources).toEqual(["bm25"]); expect(fused.find((f) => f.docId === "c")!.sources).toEqual(["vec"]); }); it("handles empty lists and respects limit", () => { expect(buildHybridFusion([], [])).toEqual([]); expect(buildHybridFusion(["a", "b", "c"], ["c"], {}, 60, 2)).toHaveLength(2); }); });