Files
ECC/scripts/lib/context-retrieval.js
T

187 lines
8.6 KiB
JavaScript

'use strict';
// Hybrid skill retrieval for ECC-029 auto selection.
//
// Two deterministic, dependency-free legs fused by reciprocal rank fusion:
// 1. BM25F-style weighted fields (name, description, owning module) over the
// canonical registry metadata. Captures exact and token-overlap recall.
// 2. A hashed character n-gram vector leg over name + description. Adds
// morphological tolerance (navigate/navigation, performance/faster is NOT
// covered — true synonyms need the pinned-embedder upgrade path, which
// must keep this interface and the registry embedding manifest).
//
// Everything runs in-process with no model weights and no network, so receipts
// and registry digests stay reproducible. Indexing 292 entries costs well
// under a millisecond, keeping the plan's in-process latency target.
const STOP_WORDS = new Set('a an and are for from help i in is it me my of on please the to with'.split(' '));
const K1 = 1.2;
const B = 0.75;
const RRF_K = 60;
const DENSE_DIM = 2048;
const FIELD_WEIGHTS = { name: 3.0, triggers: 2.5, description: 2.0, module: 1.0 };
// A dense-leg hit this strong means morphology matched even without BM25
// tokens; below it, sparse hash collisions are more likely than intent.
const DENSE_ADMIT_COSINE = 0.35;
function tokenize(text) {
// Split camelCase and snake_case identifiers so code-heavy task prose
// (buildFindUserQuery, node-postgres) matches skill vocabulary token by token.
return text.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ')
.toLowerCase().split(/[^a-z0-9]+/).filter(word => word.length > 1 && !STOP_WORDS.has(word));
}
function normalizedName(text) { return text.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); }
// FNV-1a 32-bit: stable, platform-independent feature hashing.
function hash32(text) {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash;
}
function addFeature(vector, feature, weight = 1) {
vector[hash32(feature) % DENSE_DIM] += weight;
}
function denseVector(tokensForFields) {
const vector = new Array(DENSE_DIM).fill(0);
for (const tokens of tokensForFields) {
const seen = new Map();
for (const token of tokens) {
seen.set(token, (seen.get(token) || 0) + 1);
if (token.length >= 4) {
for (let n = 3; n <= Math.min(4, token.length); n += 1) {
for (let index = 0; index <= token.length - n; index += 1) {
seen.set(`#${n}:${token.slice(index, index + n)}`, (seen.get(`#${n}:${token.slice(index, index + n)}`) || 0) + 0.5);
}
}
}
}
for (const [feature, count] of seen) addFeature(vector, feature, 1 + Math.log(count));
}
let norm = 0;
for (const value of vector) norm += value * value;
norm = Math.sqrt(norm) || 1;
return vector.map(value => value / norm);
}
function dot(left, right) {
let total = 0;
for (let index = 0; index < left.length; index += 1) total += left[index] * right[index];
return total;
}
function fieldTokens(entry, field) {
if (field === 'name') return tokenize(`${entry.id.slice('skill:'.length)} ${entry.name || ''}`);
if (field === 'triggers') return tokenize((entry.triggers || []).join(' '));
if (field === 'description') return tokenize(entry.description || '');
return tokenize(`${entry.ownerModuleId || ''} ${entry.packId || ''}`);
}
/** Build a reusable retrieval index over registry-shaped entries. Entries may
* carry a `triggers` array (from the checked-in skill-triggers manifest) that
* is weighted between name and description. */
function buildRetrievalIndex(entries) {
const documents = entries.map(entry => {
const fields = {};
let docLength = 0;
const weighted = new Map();
for (const field of Object.keys(FIELD_WEIGHTS)) {
const tokens = fieldTokens(entry, field);
fields[field] = tokens;
for (const token of tokens) {
const contribution = FIELD_WEIGHTS[field];
weighted.set(token, (weighted.get(token) || 0) + contribution);
docLength += contribution;
}
}
return { entry, fields, weighted, docLength,
dense: denseVector([fields.name, fields.description]),
aliases: [...new Set([entry.id.slice('skill:'.length), entry.name].filter(Boolean).map(normalizedName))] };
});
const documentFrequency = new Map();
for (const document of documents) {
for (const term of document.weighted.keys()) {
documentFrequency.set(term, (documentFrequency.get(term) || 0) + 1);
}
}
const averageLength = documents.reduce((total, document) => total + document.docLength, 0) / (documents.length || 1);
const idf = term => Math.log(1 + (documents.length - documentFrequency.get(term) + 0.5) / (documentFrequency.get(term) + 0.5));
return { documents, documentFrequency, averageLength: averageLength || 1, idf, entryCount: documents.length };
}
/** Rank entries for a free-text query. Returns candidates sorted by fused score. */
function searchRetrieval(index, query, { limit = 5 } = {}) {
const queryTokens = tokenize(query || '');
const normalizedQuery = ` ${normalizedName(query || '')} `;
if (!queryTokens.length) return [];
const queryDense = denseVector([queryTokens]);
const bm25 = new Map();
const dense = new Map();
for (const document of index.documents) {
let score = 0;
for (const term of new Set(queryTokens)) {
const tf = document.weighted.get(term);
if (!tf) continue;
const denominator = tf + K1 * (1 - B + B * document.docLength / index.averageLength);
score += index.idf(term) * (tf * (K1 + 1)) / denominator;
}
if (score > 0) bm25.set(document, score);
const cosine = dot(queryDense, document.dense);
if (cosine >= DENSE_ADMIT_COSINE) dense.set(document, cosine);
}
const bm25Ranked = [...bm25.entries()].sort((a, b) => b[1] - a[1] || (a[0].entry.id < b[0].entry.id ? -1 : 1));
const denseRanked = [...dense.entries()].sort((a, b) => b[1] - a[1] || (a[0].entry.id < b[0].entry.id ? -1 : 1));
// Query-coverage floor: a single incidental token (e.g. "capital" of
// "capital of Japan") is not evidence of relevance. Short queries need two
// matched terms; longer technical queries carry signal in one strong domain
// term. Exact names and strong morphology matches anchor regardless.
const uniqueTerms = new Set(queryTokens);
const minimumCoverage = Math.min(2, uniqueTerms.size);
const eligible = new Set();
for (const [document] of bm25Ranked) {
const matchedCount = [...uniqueTerms].filter(term => document.weighted.has(term)).length;
if (matchedCount >= minimumCoverage || (matchedCount >= 1 && uniqueTerms.size >= 4)) eligible.add(document);
}
for (const [document, cosine] of denseRanked) if (cosine >= DENSE_ADMIT_COSINE) eligible.add(document);
const fused = new Map();
const addRank = (ranked, weight) => ranked.forEach(([document], rank) => {
if (!eligible.has(document)) return;
fused.set(document, (fused.get(document) || 0) + weight / (RRF_K + rank + 1));
});
addRank(bm25Ranked, 1);
addRank(denseRanked, 0.8);
// A complete canonical/native name in the query anchors that skill first,
// matching the previous contract and how agents cite skills.
const exactAnchors = index.documents.map(document => ({ document,
alias: document.aliases.filter(alias => alias && normalizedQuery.includes(` ${alias} `))
.sort((a, b) => b.length - a.length)[0] || null }))
.filter(anchor => anchor.alias);
for (const { document } of exactAnchors) fused.set(document, (fused.get(document) || 0) + 1);
if (!fused.size) return [];
const anchored = new Map(exactAnchors.map(anchor => [anchor.document, anchor.alias]));
return [...fused.entries()]
.sort((a, b) => b[1] - a[1] || (a[0].entry.id < b[0].entry.id ? -1 : 1))
.slice(0, limit)
.map(([document, score]) => {
const matched = [...new Set(queryTokens)].filter(term => document.weighted.has(term));
const exact = anchored.has(document);
return { id: document.entry.id, score: Math.round(score * 10000) / 10000, exact,
exactAlias: exact ? anchored.get(document) : undefined,
dense: Math.round((dense.get(document) || 0) * 10000) / 10000,
bm25: Math.round((bm25.get(document) || 0) * 10000) / 10000,
matchedTerms: matched,
description: document.entry.description.slice(0, 2048),
descriptionTruncated: document.entry.description.length > 2048 };
});
}
module.exports = { buildRetrievalIndex, searchRetrieval, tokenize,
internals: { denseVector, dot, DENSE_ADMIT_COSINE, DENSE_DIM } };