Files
ruvnet__ruflo/v3/@claude-flow/cli/scripts/grid-search-retrieval.mjs
ruv d3223dc3d9 feat(intelligence): joint rerank re-grid + conditional defaults (ADR-083, 3.10.23)
ADR-082 left the rerank path at hw=0.5/cw=0.5 because the original rerank
grid was tested against OLD α=0.6/sw=3.0 baselines. Joint re-grid (28 configs:
hw/cw × α × sw) reveals the rerank path wants DIFFERENT hybrid sub-params
than non-rerank.

Finding: when the cross-encoder is doing semantic understanding downstream,
the hybrid stage can be MORE keyword-focused (higher subjectWeight). When
hybrid is the final stage, lower subjectWeight gives the body tokens room
to contribute relevance.

Implementation: subjectWeight default is now CONDITIONAL on useRerank
(3.0 when reranking, 2.0 otherwise). Explicit param still overrides.

New rerank defaults:
  - subjectWeight: 2.0 → 3.0 (when rerank=true)
  - hybridWeight:  0.5 → 0.7
  - ceWeight:      0.5 → 0.3

Measured (rerank path, labelled, this checkout):

| Metric            | 3.10.22 | 3.10.23 |     Δ |
|-------------------|--------:|--------:|------:|
| Label top-1       |    90%  |    90%  |   tie |
| Label top-3       |    90%  |   100%  | +10pp |
| Label MRR@3       |  0.925  |  0.950  | +0.03 |
| Label precision@3 |  0.700  |  0.700  |   tie |
| Label nDCG@3      |  0.900  |  0.963  | +0.06 |
| Label nDCG@5      |  0.904  |  0.944  | +0.04 |

Both retrieval paths now hit nDCG@3 = 0.963 on this corpus — the choice
between them is purely cost vs richness:
  - Hybrid  (39 ms/query) — leaner top-3 (P3=0.533), use for hot paths
  - Rerank (1000 ms/query) — denser top-3 (P3=0.700), use for richness-first

Cumulative since cosine baseline (3.10.17 → 3.10.23): nDCG@3 0.000 → 0.963
on BOTH paths.

Pending for next iteration: cross-repo generalisation test (pretrain on
agentdb/agentic-flow, run a similar labelled bench, see if nDCG@3 holds).

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-30 14:59:18 -04:00

217 lines
9.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// grid-search-retrieval.mjs — sweep retrieval hyperparameters against the
// ADR-081 labelled corpus. Reports label nDCG@3, top-1, top-3, precision@3,
// MRR@3 per configuration. Identifies the best non-rerank and best rerank
// configs by nDCG@3.
//
// Run prerequisites: scripts/pretrain-from-github.mjs first.
//
// Usage:
// node scripts/grid-search-retrieval.mjs # default grid
// node scripts/grid-search-retrieval.mjs --quick # smaller grid
// BENCH_JSON=1 node scripts/grid-search-retrieval.mjs # machine-readable
//
// Output also goes to docs/benchmarks/runs/grid-search-retrieval-<ts>.json
import { writeFileSync, mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import { performance } from 'node:perf_hooks';
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
const CLI_ROOT = resolve(SCRIPT_DIR, '..');
const REPO_ROOT = resolve(SCRIPT_DIR, '../../../..');
const RUNS_DIR = join(REPO_ROOT, 'docs', 'benchmarks', 'runs');
// Same labelled corpus as benchmark-pretrained-retrieval.mjs (ADR-081).
const QUERIES = [
{ q: 'how was the Opus model alias fixed',
labels: ['opus 4.8', 'opus alias', 'opus model alias', '#2232'] },
{ q: 'self-learning wiring task-completed pretrain',
labels: ['self-learning', 'adr-074', 'self learning', '#2245', 'task-completed'] },
{ q: 'deterministic codemod engine var-to-const',
labels: ['deterministic tier-1 codemod', 'adr-143', 'codemod', 'var-to-const'] },
{ q: 'MCP server orphan leak parent-death',
labels: ['mcp orphan', 'mcp servers orphan', 'parent-death', '#2234', 'orphan on every claude'] },
{ q: 'unified learning stats aggregator',
labels: ['unified learning-stats', 'adr-075', 'unified learning stats'] },
{ q: 'structured distillation 4-field schema',
labels: ['structured distillation', 'adr-076', '4-field schema'] },
{ q: 'SQL injection migrate.ts table identifier',
labels: ['sql injection', 'shell injection', 'migrate.ts', 'agentdb', 'cve'] },
{ q: 'recall@k HNSW benchmark harness',
labels: ['hnsw', 'memory-recall', 'benchmark suite', 'recall@k', 'benchmark intelligence'] },
{ q: 'Q-learning encoder keyword block',
labels: ['q-state encoder', 'route q-state', 'keyword block', '#2239', 'q-encoder'] },
{ q: 'security hardening crypto random IDs',
labels: ['cwe-347', 'crypto.randomuuid', 'security fix', 'random id', 'crypto random'] },
];
function isRelevant(name, labels) {
if (!name || !labels?.length) return false;
const lower = String(name).toLowerCase();
return labels.some((s) => lower.includes(s.toLowerCase()));
}
function ndcgAtK(rankedRel, k) {
const arr = rankedRel.slice(0, k);
const dcg = arr.reduce((acc, rel, i) => acc + (rel ? 1 / Math.log2(i + 2) : 0), 0);
const numRelevant = arr.filter(Boolean).length;
if (numRelevant === 0) return 0;
let idcg = 0;
for (let i = 0; i < numRelevant; i++) idcg += 1 / Math.log2(i + 2);
return idcg > 0 ? dcg / idcg : 0;
}
// ---------------------------------------------------------------------------
// Grid definitions
// ---------------------------------------------------------------------------
const QUICK = process.argv.includes('--quick');
// Non-rerank grid: alpha × subjectWeight × mmrLambda
const ALPHA_GRID = QUICK ? [0.5, 0.7] : [0.3, 0.5, 0.7];
const SUBJ_GRID = QUICK ? [3.0, 5.0] : [2.0, 3.0, 5.0];
const MMR_GRID = QUICK ? [0.5] : [0.3, 0.5, 0.7];
// Rerank grid: hybridWeight × ceWeight (sum to 1).
const RERANK_GRID = QUICK
? [[0.5, 0.5]]
: [[0.2, 0.8], [0.3, 0.7], [0.4, 0.6], [0.5, 0.5], [0.6, 0.4], [0.7, 0.3], [0.8, 0.2]];
// ADR-083: when re-gridding rerank, sweep BOTH the hybrid sub-params (alpha, sw)
// AND the rerank weights. This catches joint-optima the per-axis grids miss.
const RERANK_HYBRID_ALPHA = QUICK ? [0.5] : [0.3, 0.5];
const RERANK_HYBRID_SW = QUICK ? [2.0] : [2.0, 3.0];
// ---------------------------------------------------------------------------
// Eval
// ---------------------------------------------------------------------------
async function evalConfig(tool, params) {
let top1Hits = 0, top3HitsBin = 0, ranks = [], ndcg3Sum = 0, prec3Sum = 0;
const tStart = performance.now();
for (const { q, labels } of QUERIES) {
const r = await tool.handler({ action: 'search', query: q, mode: 'hybrid', limit: 5, ...params });
const matches = (r.results || []).slice(0, 5);
const rel = matches.map((m) => isRelevant(m?.name, labels));
if (rel[0]) top1Hits++;
if (rel.slice(0, 3).some(Boolean)) top3HitsBin++;
const firstRank = rel.findIndex(Boolean);
if (firstRank >= 0) ranks.push(firstRank + 1);
ndcg3Sum += ndcgAtK(rel, 3);
prec3Sum += rel.slice(0, 3).filter(Boolean).length / 3;
}
const elapsed = performance.now() - tStart;
return {
label_top1HitRate: top1Hits / QUERIES.length,
label_top3HitRate: top3HitsBin / QUERIES.length,
label_mrr3: ranks.reduce((s, r) => s + 1 / r, 0) / QUERIES.length,
label_precision3: prec3Sum / QUERIES.length,
label_ndcg3: ndcg3Sum / QUERIES.length,
avgLatencyMs: elapsed / QUERIES.length,
};
}
async function main() {
const neural = await import(join(CLI_ROOT, 'dist/src/mcp-tools/neural-tools.js'));
const tool = neural.neuralTools.find((t) => t.name === 'neural_patterns');
if (!tool) throw new Error('neural_patterns tool not found');
const configs = [];
// §A — non-rerank grid (fast, ~3*3*3 = 27 configs at default)
for (const alpha of ALPHA_GRID) {
for (const subjectWeight of SUBJ_GRID) {
for (const mmrLambda of MMR_GRID) {
configs.push({
name: `hybrid α=${alpha} sw=${subjectWeight} mmr=${mmrLambda}`,
rerank: false,
params: { rerank: false, alpha, subjectWeight, mmrLambda, bodyWeight: 1.0, typePenaltyFactor: 1.0 },
});
}
}
}
// §B — rerank joint grid (ADR-083): hybridWeight × ceWeight × alpha × subjectWeight.
// Each query takes ~1s with cross-encoder, so default full grid = 28 configs × 10
// queries × 1s ≈ 5 minutes. Use --quick for 1 config (smoke).
for (const [hybridWeight, ceWeight] of RERANK_GRID) {
for (const alpha of RERANK_HYBRID_ALPHA) {
for (const subjectWeight of RERANK_HYBRID_SW) {
configs.push({
name: `rerank hw=${hybridWeight} cw=${ceWeight} α=${alpha} sw=${subjectWeight}`,
rerank: true,
params: { rerank: true, alpha, subjectWeight, mmrLambda: 0.7, hybridWeight, ceWeight },
});
}
}
}
console.log(`Grid-search: ${configs.length} configs across ${QUERIES.length} queries\n`);
const results = [];
for (const cfg of configs) {
process.stdout.write(` ${cfg.name.padEnd(40)} … `);
const m = await evalConfig(tool, cfg.params);
results.push({ ...cfg, metrics: m });
console.log(`top1=${(m.label_top1HitRate * 100).toFixed(0)}% top3=${(m.label_top3HitRate * 100).toFixed(0)}% nDCG3=${m.label_ndcg3.toFixed(3)} P3=${m.label_precision3.toFixed(3)} MRR3=${m.label_mrr3.toFixed(3)} ${m.avgLatencyMs.toFixed(0)}ms`);
}
// Rank by label nDCG@3 (the canonical relevance metric).
const byNdcg = [...results].sort((a, b) => b.metrics.label_ndcg3 - a.metrics.label_ndcg3);
const byTop1 = [...results].sort((a, b) => b.metrics.label_top1HitRate - a.metrics.label_top1HitRate || b.metrics.label_ndcg3 - a.metrics.label_ndcg3);
const byPrec3 = [...results].sort((a, b) => b.metrics.label_precision3 - a.metrics.label_precision3 || b.metrics.label_ndcg3 - a.metrics.label_ndcg3);
const bestNonRerankByNdcg = byNdcg.find((r) => !r.rerank);
const bestRerankByNdcg = byNdcg.find((r) => r.rerank);
console.log('\n=== Top 5 by label nDCG@3 ===');
for (const r of byNdcg.slice(0, 5)) {
console.log(` nDCG=${r.metrics.label_ndcg3.toFixed(3)} top1=${(r.metrics.label_top1HitRate * 100).toFixed(0)}% P3=${r.metrics.label_precision3.toFixed(3)} ${r.name}`);
}
console.log('\n=== Top 5 by label top-1 (ties broken by nDCG) ===');
for (const r of byTop1.slice(0, 5)) {
console.log(` top1=${(r.metrics.label_top1HitRate * 100).toFixed(0)}% nDCG=${r.metrics.label_ndcg3.toFixed(3)} P3=${r.metrics.label_precision3.toFixed(3)} ${r.name}`);
}
console.log('\n=== Top 5 by label precision@3 ===');
for (const r of byPrec3.slice(0, 5)) {
console.log(` P3=${r.metrics.label_precision3.toFixed(3)} top1=${(r.metrics.label_top1HitRate * 100).toFixed(0)}% nDCG=${r.metrics.label_ndcg3.toFixed(3)} ${r.name}`);
}
console.log(`\n=== WINNERS ===`);
console.log(`Best non-rerank (by nDCG@3): ${bestNonRerankByNdcg.name} → top1=${(bestNonRerankByNdcg.metrics.label_top1HitRate * 100).toFixed(0)}% nDCG=${bestNonRerankByNdcg.metrics.label_ndcg3.toFixed(3)}`);
console.log(`Best rerank (by nDCG@3): ${bestRerankByNdcg.name} → top1=${(bestRerankByNdcg.metrics.label_top1HitRate * 100).toFixed(0)}% nDCG=${bestRerankByNdcg.metrics.label_ndcg3.toFixed(3)}`);
const summary = {
runAt: new Date().toISOString(),
benchmark: 'grid-search-retrieval',
queries: QUERIES.length,
configsEvaluated: configs.length,
grid: { ALPHA_GRID, SUBJ_GRID, MMR_GRID, RERANK_GRID },
results: results.map((r) => ({ name: r.name, rerank: r.rerank, params: r.params, metrics: r.metrics })),
winners: {
nDcg3: byNdcg[0].name,
top1: byTop1[0].name,
precision3: byPrec3[0].name,
bestNonRerank: bestNonRerankByNdcg.name,
bestRerank: bestRerankByNdcg.name,
},
};
if (process.env.BENCH_JSON) {
console.log(JSON.stringify(summary, null, 2));
} else {
mkdirSync(RUNS_DIR, { recursive: true });
const stamp = summary.runAt.replace(/[:.]/g, '-');
writeFileSync(join(RUNS_DIR, `grid-search-retrieval-${stamp}.json`), JSON.stringify(summary, null, 2));
writeFileSync(join(RUNS_DIR, 'grid-search-retrieval-latest.json'), JSON.stringify(summary, null, 2));
console.log(`\nWrote ${join(RUNS_DIR, `grid-search-retrieval-${stamp}.json`)}`);
}
process.exit(0);
}
main().catch((err) => { console.error(err); process.exit(1); });