UNPKG

@claude-flow/embeddings

Version:

V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking

214 lines (183 loc) • 6.74 kB
/** * ADR-121 Phase 13 — IR metrics tests. * * Coverage uses hand-computed values from standard IR textbook examples * so the math is auditable. */ import { describe, it, expect } from 'vitest'; import { recallAtK, precisionAtK, reciprocalRank, meanReciprocalRank, dcgAtK, idcgAtK, ndcgAtK, compareRankings, } from '../ir-metrics.js'; describe('recallAtK', () => { it('1.0 when all relevant items are in top-k', () => { expect(recallAtK(['a', 'b', 'c'], ['a', 'b'], 3)).toBe(1); }); it('partial recall when only some relevant items are in top-k', () => { // 1 of 2 relevant found → 0.5 expect(recallAtK(['a', 'x', 'y'], ['a', 'b'], 3)).toBe(0.5); }); it('0 when no relevant items in top-k', () => { expect(recallAtK(['x', 'y', 'z'], ['a', 'b'], 3)).toBe(0); }); it('0 when relevant set is empty (no signal to recall)', () => { expect(recallAtK(['a', 'b'], [], 2)).toBe(0); }); it('only counts up to k', () => { // 'b' is at position 4, k=3, so we miss it expect(recallAtK(['a', 'x', 'y', 'b'], ['a', 'b'], 3)).toBe(0.5); }); it('accepts a Set as the relevance input', () => { expect(recallAtK(['a', 'b'], new Set(['a']), 2)).toBe(1); }); }); describe('precisionAtK', () => { it('1.0 when every top-k item is relevant', () => { expect(precisionAtK(['a', 'b'], ['a', 'b', 'c'], 2)).toBe(1); }); it('0.5 when half of top-k is relevant', () => { expect(precisionAtK(['a', 'x'], ['a'], 2)).toBe(0.5); }); it('returns 0 for k <= 0', () => { expect(precisionAtK(['a'], ['a'], 0)).toBe(0); expect(precisionAtK(['a'], ['a'], -1)).toBe(0); }); }); describe('reciprocalRank', () => { it('1.0 when first item is relevant', () => { expect(reciprocalRank(['a', 'b'], ['a'])).toBe(1); }); it('0.5 when second item is the first relevant', () => { expect(reciprocalRank(['x', 'a', 'b'], ['a'])).toBe(0.5); }); it('1/3 when third item is the first relevant', () => { expect(reciprocalRank(['x', 'y', 'a'], ['a'])).toBeCloseTo(1 / 3, 8); }); it('0 when no relevant item appears', () => { expect(reciprocalRank(['x', 'y', 'z'], ['a'])).toBe(0); }); }); describe('meanReciprocalRank', () => { it('averages RR across a query set', () => { // q1: RR = 1 (first item) // q2: RR = 0.5 (second item) // q3: RR = 0 (no relevant) // mean = (1 + 0.5 + 0) / 3 = 0.5 const mrr = meanReciprocalRank([ { retrieved: ['a', 'b'], relevant: ['a'] }, { retrieved: ['x', 'b'], relevant: ['b'] }, { retrieved: ['x', 'y'], relevant: ['z'] }, ]); expect(mrr).toBeCloseTo(0.5, 6); }); it('returns 0 on empty query set', () => { expect(meanReciprocalRank([])).toBe(0); }); }); describe('dcgAtK', () => { it('matches hand-computed DCG on binary judgements', () => { // top-3 = [a(1), x(0), b(1)] → DCG = 1/log2(2) + 0 + 1/log2(4) = 1 + 0.5 = 1.5 const dcg = dcgAtK(['a', 'x', 'b'], { a: 1, b: 1 }, 3); expect(dcg).toBeCloseTo(1.5, 6); }); it('handles graded relevance', () => { // top-2 = [a(3), b(2)] → DCG = 3/log2(2) + 2/log2(3) = 3 + 2/1.585 ≈ 4.262 const dcg = dcgAtK(['a', 'b'], { a: 3, b: 2 }, 2); expect(dcg).toBeCloseTo(3 + 2 / Math.log2(3), 6); }); it('treats missing ids as relevance 0', () => { expect(dcgAtK(['unknown'], { a: 1 }, 1)).toBe(0); }); }); describe('idcgAtK', () => { it('orders judgements by grade descending for the ideal', () => { // Grades = [3, 2, 1] → ideal DCG@3 = 3/log2(2) + 2/log2(3) + 1/log2(4) const idcg = idcgAtK({ a: 1, b: 3, c: 2 }, 3); const expected = 3 / Math.log2(2) + 2 / Math.log2(3) + 1 / Math.log2(4); expect(idcg).toBeCloseTo(expected, 6); }); it('truncates to k', () => { // Grades sorted = [3, 2], k=1 → IDCG@1 = 3 expect(idcgAtK({ a: 1, b: 3, c: 2 }, 1)).toBeCloseTo(3, 6); }); }); describe('ndcgAtK', () => { it('1.0 when the retrieved ordering matches the ideal', () => { // Ideal order = a(3), b(2), c(1) const n = ndcgAtK(['a', 'b', 'c'], { a: 3, b: 2, c: 1 }, 3); expect(n).toBeCloseTo(1, 6); }); it('< 1 when the ordering inverts grades', () => { // Retrieved = c(1), b(2), a(3) — worst possible order const n = ndcgAtK(['c', 'b', 'a'], { a: 3, b: 2, c: 1 }, 3); expect(n).toBeGreaterThan(0); expect(n).toBeLessThan(1); }); it('0 when no relevant items exist', () => { expect(ndcgAtK(['a'], {}, 1)).toBe(0); }); it('accepts binary judgements (Set or Array)', () => { // Same as graded with grade=1 each. const bin = ndcgAtK(['a', 'b'], new Set(['a', 'b']), 2); const graded = ndcgAtK(['a', 'b'], { a: 1, b: 1 }, 2); expect(bin).toBeCloseTo(graded, 6); expect(bin).toBeCloseTo(1, 6); }); it('clamps to [0, 1]', () => { // Manufacture a pathological case — retrieved=[unknown], grades=[a:1]. // DCG=0, IDCG=1 → nDCG=0 (not negative). expect(ndcgAtK(['unknown'], { a: 1 }, 1)).toBe(0); }); }); describe('compareRankings', () => { it('computes recall/precision/ndcg at all requested k values + MRR in one pass', () => { // retrieved = [a, b, x, y, c] — relevant = {a, b, c} // At k=1: recall = 1/3, precision = 1 // At k=3: recall = 2/3, precision = 2/3 // At k=5: recall = 3/3, precision = 3/5 // MRR = 1 (first item is relevant) const cmp = compareRankings( ['a', 'b', 'x', 'y', 'c'], new Set(['a', 'b', 'c']), [1, 3, 5], ); expect(cmp.recall[1]).toBeCloseTo(1 / 3, 6); expect(cmp.recall[3]).toBeCloseTo(2 / 3, 6); expect(cmp.recall[5]).toBeCloseTo(1, 6); expect(cmp.precision[1]).toBeCloseTo(1, 6); expect(cmp.precision[3]).toBeCloseTo(2 / 3, 6); expect(cmp.precision[5]).toBeCloseTo(3 / 5, 6); expect(cmp.mrr).toBeCloseTo(1, 6); // All three k values should produce a valid nDCG in [0, 1]. for (const k of [1, 3, 5]) { expect(cmp.ndcg[k]).toBeGreaterThanOrEqual(0); expect(cmp.ndcg[k]).toBeLessThanOrEqual(1); } }); it('accepts graded judgements (Map)', () => { const cmp = compareRankings( ['a', 'b', 'c'], new Map([['a', 3], ['b', 2], ['c', 1]]), [3], ); expect(cmp.ndcg[3]).toBeCloseTo(1, 6); // ideal order → 1.0 expect(cmp.recall[3]).toBeCloseTo(1, 6); }); it('accepts graded judgements (Record)', () => { const cmp = compareRankings(['a'], { a: 1 }, [1]); expect(cmp.recall[1]).toBe(1); }); it('returns 0 for all metrics on empty judgements', () => { const cmp = compareRankings(['a', 'b'], new Set<string>(), [1, 2]); expect(cmp.recall[1]).toBe(0); expect(cmp.recall[2]).toBe(0); expect(cmp.ndcg[1]).toBe(0); expect(cmp.mrr).toBe(0); }); });