Files
zeekay 986c2710af brain: clean up stale refs + tighten typecheck
- README rust line drops hanzo-libs/ path (chain crates live in node workspace)
- packages/memory: drop "fortemi parity sweep" framing + PARITY.md pointer; comments are now about the algorithms themselves
- packages/memory/eval.ts: reciprocalRank no longer trips strict tsc on the QueryEval union narrow
- packages/memory/README.md: relative ../../../vector/ etc. links rewritten to github.com/hanzoai/* canonical URLs; Tests section updated to reflect 109-test surface in the canonical brain repo path
- packages/graph-links/README.md: Tests path updated to canonical brain repo
2026-05-11 12:14:37 -07:00

101 lines
3.3 KiB
TypeScript

/**
* Retrieval evaluation metrics.
*
* - Reciprocal Rank (RR) and Mean Reciprocal Rank (MRR)
* - Recall@k
* - Normalized Discounted Cumulative Gain (NDCG@k, binary or graded)
* - Precision@k
*
* Each takes a per-query golden set and the predicted ranking.
*/
export interface QueryEval {
/** Predicted slugs in ranked order. */
predicted: string[];
/** Ground-truth slugs (binary relevance) or relevance map (graded). */
relevant: string[] | Record<string, number>;
}
function isGraded(r: QueryEval["relevant"]): r is Record<string, number> {
return !Array.isArray(r);
}
export function reciprocalRank(q: QueryEval): number {
const rel = isGraded(q.relevant)
? new Set(Object.entries(q.relevant).filter(([, v]) => v > 0).map(([k]) => k))
: new Set(q.relevant);
for (let i = 0; i < q.predicted.length; i++) {
if (rel.has(q.predicted[i])) return 1 / (i + 1);
}
return 0;
}
export function meanReciprocalRank(queries: QueryEval[]): number {
if (queries.length === 0) return 0;
let s = 0;
for (const q of queries) s += reciprocalRank(q);
return s / queries.length;
}
export function recallAtK(q: QueryEval, k: number): number {
const rel = isGraded(q.relevant) ? new Set(Object.keys(q.relevant).filter((slug) => (q.relevant as Record<string, number>)[slug] > 0)) : new Set(q.relevant);
if (rel.size === 0) return 0;
const head = q.predicted.slice(0, k);
let hits = 0;
for (const p of head) if (rel.has(p)) hits++;
return hits / rel.size;
}
export function precisionAtK(q: QueryEval, k: number): number {
const rel = isGraded(q.relevant) ? new Set(Object.keys(q.relevant).filter((slug) => (q.relevant as Record<string, number>)[slug] > 0)) : new Set(q.relevant);
const head = q.predicted.slice(0, k);
if (head.length === 0) return 0;
let hits = 0;
for (const p of head) if (rel.has(p)) hits++;
return hits / head.length;
}
export function ndcgAtK(q: QueryEval, k: number): number {
const grades = isGraded(q.relevant)
? q.relevant
: Object.fromEntries(q.relevant.map((s) => [s, 1] as const));
const dcg = (slugs: string[]): number => {
let s = 0;
for (let i = 0; i < slugs.length; i++) {
const g = grades[slugs[i]] ?? 0;
s += (Math.pow(2, g) - 1) / Math.log2(i + 2);
}
return s;
};
const ideal = Object.entries(grades).map(([s, g]) => ({ s, g })).sort((a, b) => b.g - a.g).slice(0, k).map((x) => x.s);
const idealDcg = dcg(ideal);
const actual = dcg(q.predicted.slice(0, k));
return idealDcg > 0 ? actual / idealDcg : 0;
}
export interface BenchmarkResult {
mrr: number;
recall: Record<number, number>;
precision: Record<number, number>;
ndcg: Record<number, number>;
}
export function benchmark(queries: QueryEval[], ks: number[] = [1, 5, 10, 20]): BenchmarkResult {
const recall: Record<number, number> = {};
const precision: Record<number, number> = {};
const ndcg: Record<number, number> = {};
for (const k of ks) {
let r = 0, p = 0, n = 0;
for (const q of queries) {
r += recallAtK(q, k);
p += precisionAtK(q, k);
n += ndcgAtK(q, k);
}
const denom = Math.max(queries.length, 1);
recall[k] = r / denom;
precision[k] = p / denom;
ndcg[k] = n / denom;
}
return { mrr: meanReciprocalRank(queries), recall, precision, ndcg };
}