Files
self-directed-learning/src/rrf.js
T
Krystie dc5dc94d79 Initial commit: Adaptive Recall sidecar for UMP (Phase 5)
Multi-channel retrieval sidecar over Universal Memory Protocol:
- 3-channel RRF (UMP FTS5 + Qdrant vector + knowledge graph)
- ACT-R re-ranking (Anderson 1983) with access tracking
- Co-occurrence graph edges (Phase 6) for dense traversal
- Memory lifecycle decay (Phase 4) with per-kind confidence
- MCP shim routes recall through sidecar, falls back to canonical UMP

Architecture:
- src/server.js      HTTP sidecar on port 4380
- src/graph.js       2592-node / 111-edge graph from UMP (or +cooccur: 13k+)
- src/actr.js        A_i = -d*ln(age) + beta*log1p(freq) + epsilon*conf
- src/access_log.js  per-URN counter + last_accessed_at
- src/ump-recall-mcp.js  MCP shim (recall via sidecar, others passthrough)

Eval results (851-record UMP corpus):
- 2ch RRF over baseline: +50pp recall@10
- 3ch RRF (+graph): +60pp, 12 unique wins
- ACT-R re-rank: 4/20 #1 changes, 84% top-5 retention

Tests: 76/76 passing across graph (27), actr (27), access_log (28),
decay (20), mcp-shim (sidecar + fallback). Run with: npm test

Inspired by AIAppsAPI/adaptive-recall but built from scratch against
existing DNS2 infrastructure (UMP at :4317, Qdrant at :6333,
Ollama at :11434). No paid SaaS, MIT-licensed.
2026-07-12 19:24:47 -07:00

104 lines
3.7 KiB
JavaScript

// RRF (Reciprocal Rank Fusion) ranker for ump-recall sidecar.
//
// Combines multiple retrieval channels into a single ranked list. The
// standard RRF formula is:
// score(d) = Σ_i 1 / (k + rank_i(d))
// where rank_i(d) is d's position in channel i's result list (1-indexed).
// k=60 is the conventional constant from the original Cormack et al. 2009
// paper and the Qdrant docs.
//
// Why RRF (vs weighted linear combination of scores):
// - Channel scores live in different ranges (UMP: composite 0..1,
// Qdrant: cosine 0..1, FTS5: BM25 unbounded). Linear fusion needs
// per-channel normalization that's brittle to score-distribution drift.
// - RRF only needs ranks, which are stable as long as the underlying
// channel keeps roughly the same top-N. Insensitive to scale.
// - O(1) per candidate per channel. Tunable via `channel_weight` per
// channel for soft boosting (e.g. trust the vector channel more).
//
// Adding a new channel:
// 1. Add a `run*Channel(query, topN)` function below that returns
// [{ urn, rank, channel, score?, debug? }, ...]
// 2. Push its result into `channels` array in the `fuse()` caller.
// No changes needed to rrfFuse() itself.
const RRF_K = 60; // standard constant
/**
* rrfFuse(channelResults, weights) -> ranked list of { urn, score, byChannel: {} }
*
* @param {Array<{name: string, results: Array<{urn: string, score?: number, debug?: any}>}>} channelResults
* @param {Object<string, number>} weights optional, default 1.0 per channel
* @returns {Array<{urn: string, score: number, rank: number, byChannel: Record<string, {rank:number, score?:number}>}>}
*/
export function rrfFuse(channelResults, weights = {}) {
const candidates = new Map(); // urn -> { score, byChannel }
const allUrns = new Set();
for (const ch of channelResults) {
const w = weights[ch.name] ?? 1.0;
ch.results.forEach((hit, idx) => {
if (!hit?.urn) return;
allUrns.add(hit.urn);
const rank = idx + 1; // 1-indexed
const contribution = w / (RRF_K + rank);
const entry = candidates.get(hit.urn) || { score: 0, byChannel: {} };
entry.score += contribution;
entry.byChannel[ch.name] = {
rank,
score: hit.score, // raw channel score if provided
debug: hit.debug,
};
candidates.set(hit.urn, entry);
});
}
// Sort by RRF score descending. Tie-break by sum of raw scores (better
// channels win ties), then by urn alphabetically (deterministic).
const ranked = Array.from(candidates.entries())
.map(([urn, v]) => ({
urn,
score: v.score,
byChannel: v.byChannel,
}))
.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
const aSum = Object.values(a.byChannel).reduce(
(acc, x) => acc + (x.score ?? 0),
0,
);
const bSum = Object.values(b.byChannel).reduce(
(acc, x) => acc + (x.score ?? 0),
0,
);
if (bSum !== aSum) return bSum - aSum;
return a.urn.localeCompare(b.urn);
})
.map((entry, idx) => ({ ...entry, rank: idx + 1 }));
return ranked;
}
/**
* Reciprocal Rank @ K — for a single channel's top-K and a known-relevant urn,
* returns 1/K if the urn appears in top-K, else 0.
*/
export function rrAtK(channelHits, relevantUrn, k) {
const idx = channelHits.findIndex((h) => h.urn === relevantUrn);
if (idx < 0 || idx >= k) return 0;
return 1 / (idx + 1);
}
/**
* MRR (Mean Reciprocal Rank) over a list of {hits, relevant}.
*/
export function mrr(perQuery) {
if (!perQuery.length) return 0;
const sum = perQuery.reduce(
(acc, q) => acc + rrAtK(q.hits, q.relevant, q.hits.length),
0,
);
return sum / perQuery.length;
}
export const RRF_CONSTANT = RRF_K;