@claude-flow/cli 3.10.20 → 3.10.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claude-flow/cli",
3
- "version": "3.10.20",
3
+ "version": "3.10.22",
4
4
  "type": "module",
5
5
  "description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
6
6
  "main": "dist/src/index.js",
@@ -20,23 +20,86 @@ const CLI_ROOT = resolve(SCRIPT_DIR, '..');
20
20
  const REPO_ROOT = resolve(SCRIPT_DIR, '../../../..');
21
21
  const RUNS_DIR = join(REPO_ROOT, 'docs', 'benchmarks', 'runs');
22
22
 
23
- // Real ruflo-history-shaped queries. Each one targets a concept that should
24
- // have been seen during pretrain. `expect` is a regex matched against the
25
- // result's `name` field (commit subject or issue title) to score relevance.
26
- // A result is "relevant" if its name matches the expect regex (case-insensitive).
23
+ // ADR-081 labelled held-out corpus. Each query has hand-curated
24
+ // `expectedSubstrings` case-insensitive substring matches against the
25
+ // pattern's name. A result is "relevant" if its name contains ANY of the
26
+ // substrings. Tighter than the regex proxy used in ADRs 077-080 (which
27
+ // matched related-but-not-exact commits).
28
+ //
29
+ // `expect` (regex) is kept for backwards compatibility with the older
30
+ // numbers in ADRs 077-080. New metrics use expectedSubstrings.
27
31
  const QUERIES = [
28
- { q: 'how was the Opus model alias fixed', expect: /opus|2232|model.*alias|4\.8/i },
29
- { q: 'self-learning wiring task-completed pretrain', expect: /self.?learning|task.?completed|pretrain|2245|074/i },
30
- { q: 'deterministic codemod engine var-to-const', expect: /codemod|var.?to.?const|143|deterministic/i },
31
- { q: 'MCP server orphan leak parent-death', expect: /mcp.*orphan|orphan.*mcp|parent.?death|leak/i },
32
- { q: 'unified learning stats aggregator', expect: /unified|stats|aggregator|075/i },
33
- { q: 'structured distillation 4-field schema', expect: /distillation|structured|076|4.?field/i },
34
- { q: 'SQL injection migrate.ts table identifier', expect: /sql.?injection|migrate|table|identifier/i },
35
- { q: 'recall@k HNSW benchmark harness', expect: /recall|hnsw|benchmark|harness/i },
36
- { q: 'Q-learning encoder keyword block', expect: /q.?learning|encoder|keyword|2239/i },
37
- { q: 'security hardening crypto random IDs', expect: /security|hardening|crypto|random/i },
32
+ {
33
+ q: 'how was the Opus model alias fixed',
34
+ expect: /opus|2232|model.*alias|4\.8/i,
35
+ expectedSubstrings: ['opus 4.8', 'opus alias', 'opus model alias', '#2232'],
36
+ },
37
+ {
38
+ q: 'self-learning wiring task-completed pretrain',
39
+ expect: /self.?learning|task.?completed|pretrain|2245|074/i,
40
+ expectedSubstrings: ['self-learning', 'adr-074', 'self learning', '#2245', 'task-completed'],
41
+ },
42
+ {
43
+ q: 'deterministic codemod engine var-to-const',
44
+ expect: /codemod|var.?to.?const|143|deterministic/i,
45
+ expectedSubstrings: ['deterministic tier-1 codemod', 'adr-143', 'codemod', 'var-to-const'],
46
+ },
47
+ {
48
+ q: 'MCP server orphan leak parent-death',
49
+ expect: /mcp.*orphan|orphan.*mcp|parent.?death|leak/i,
50
+ expectedSubstrings: ['mcp orphan', 'mcp servers orphan', 'parent-death', '#2234', 'orphan on every claude'],
51
+ },
52
+ {
53
+ q: 'unified learning stats aggregator',
54
+ expect: /unified|stats|aggregator|075/i,
55
+ expectedSubstrings: ['unified learning-stats', 'adr-075', 'unified learning stats'],
56
+ },
57
+ {
58
+ q: 'structured distillation 4-field schema',
59
+ expect: /distillation|structured|076|4.?field/i,
60
+ expectedSubstrings: ['structured distillation', 'adr-076', '4-field schema'],
61
+ },
62
+ {
63
+ q: 'SQL injection migrate.ts table identifier',
64
+ expect: /sql.?injection|migrate|table|identifier/i,
65
+ expectedSubstrings: ['sql injection', 'shell injection', 'migrate.ts', 'agentdb', 'cve'],
66
+ },
67
+ {
68
+ q: 'recall@k HNSW benchmark harness',
69
+ expect: /recall|hnsw|benchmark|harness/i,
70
+ expectedSubstrings: ['hnsw', 'memory-recall', 'benchmark suite', 'recall@k', 'benchmark intelligence'],
71
+ },
72
+ {
73
+ q: 'Q-learning encoder keyword block',
74
+ expect: /q.?learning|encoder|keyword|2239/i,
75
+ expectedSubstrings: ['q-state encoder', 'route q-state', 'keyword block', '#2239', 'q-encoder'],
76
+ },
77
+ {
78
+ q: 'security hardening crypto random IDs',
79
+ expect: /security|hardening|crypto|random/i,
80
+ expectedSubstrings: ['cwe-347', 'crypto.randomuuid', 'security fix', 'random id', 'crypto random'],
81
+ },
38
82
  ];
39
83
 
84
+ /** Returns true if name contains ANY of the labelled substrings (case-insensitive). */
85
+ function isRelevant(name, expectedSubstrings) {
86
+ if (!name || !expectedSubstrings?.length) return false;
87
+ const lower = String(name).toLowerCase();
88
+ return expectedSubstrings.some((s) => lower.includes(s.toLowerCase()));
89
+ }
90
+
91
+ /** nDCG@k with binary relevance (each relevant item contributes 1 / log2(i+1)). */
92
+ function ndcgAtK(rankedRelevance, k) {
93
+ const arr = rankedRelevance.slice(0, k);
94
+ const dcg = arr.reduce((acc, rel, i) => acc + (rel ? 1 / Math.log2(i + 2) : 0), 0);
95
+ const numRelevant = arr.filter(Boolean).length;
96
+ if (numRelevant === 0) return 0;
97
+ // Ideal: all relevant items packed at positions 1..numRelevant
98
+ let idcg = 0;
99
+ for (let i = 0; i < numRelevant; i++) idcg += 1 / Math.log2(i + 2);
100
+ return idcg > 0 ? dcg / idcg : 0;
101
+ }
102
+
40
103
  const TOP_K = 5;
41
104
 
42
105
  async function main() {
@@ -66,32 +129,45 @@ async function main() {
66
129
 
67
130
  const tQuery0 = performance.now();
68
131
  const results = [];
69
- for (const { q, expect } of QUERIES) {
132
+ for (const { q, expect, expectedSubstrings } of QUERIES) {
70
133
  const r = await listTool.handler({ action: 'search', query: q, mode, limit: TOP_K, rerank: useRerank });
71
134
  const matches = (r.patterns || r.results || r.matches || []).slice(0, TOP_K);
72
135
  if (matches.length > 0) {
73
136
  const top1 = matches[0].id;
74
137
  top1Ids.set(top1, (top1Ids.get(top1) ?? 0) + 1);
75
138
  }
76
- // ADR-078 relevance: does the top-1/top-3 name match the expect regex?
77
- const top1Name = matches[0]?.name ?? '';
78
- const top1Relevant = expect.test(top1Name);
79
- let top3Relevant = false;
80
- let firstRelevantRank = -1;
81
- for (let i = 0; i < Math.min(matches.length, 3); i++) {
82
- if (expect.test(matches[i]?.name ?? '')) {
83
- top3Relevant = true;
84
- if (firstRelevantRank === -1) firstRelevantRank = i + 1;
85
- break;
86
- }
139
+
140
+ // Two relevance signals per result:
141
+ // regexRel old proxy (kept for back-compat with ADR-077-080 numbers)
142
+ // labelRel ADR-081 hand-curated label match
143
+ const regexRel = matches.map((m) => expect.test(m?.name ?? ''));
144
+ const labelRel = matches.map((m) => isRelevant(m?.name, expectedSubstrings));
145
+
146
+ // First rank where a labelled-relevant doc appears.
147
+ let firstLabelRank = -1;
148
+ for (let i = 0; i < matches.length; i++) {
149
+ if (labelRel[i]) { firstLabelRank = i + 1; break; }
87
150
  }
151
+ let firstRegexRank = -1;
152
+ for (let i = 0; i < matches.length; i++) {
153
+ if (regexRel[i]) { firstRegexRank = i + 1; break; }
154
+ }
155
+
88
156
  results.push({
89
157
  query: q,
90
158
  matched: matches.length > 0,
91
- top1Relevant,
92
- top3Relevant,
93
- firstRelevantRank,
94
- topK: matches.map((m) => ({
159
+ // Regex-proxy metrics (back-compat with ADR-077-080)
160
+ top1Relevant: regexRel[0] || false,
161
+ top3Relevant: regexRel.slice(0, 3).some(Boolean),
162
+ firstRelevantRank: firstRegexRank,
163
+ // ADR-081 labelled metrics
164
+ label_top1: labelRel[0] || false,
165
+ label_top3_count: labelRel.slice(0, 3).filter(Boolean).length,
166
+ label_top5_count: labelRel.slice(0, 5).filter(Boolean).length,
167
+ label_firstRank: firstLabelRank,
168
+ label_ndcg3: ndcgAtK(labelRel, 3),
169
+ label_ndcg5: ndcgAtK(labelRel, 5),
170
+ topK: matches.map((m, i) => ({
95
171
  id: m.id,
96
172
  name: m.name?.slice(0, 100),
97
173
  type: m.type,
@@ -99,20 +175,38 @@ async function main() {
99
175
  cosineScore: m.cosineScore,
100
176
  bm25Score: m.bm25Score,
101
177
  mmrScore: m.mmrScore,
178
+ labelRelevant: labelRel[i],
102
179
  })),
103
180
  });
104
181
  }
105
182
  const queryMs = performance.now() - tQuery0;
106
183
 
107
184
  const matchedQueries = results.filter((r) => r.matched).length;
185
+ // Regex-proxy metrics (back-compat)
108
186
  const top1Hits = results.filter((r) => r.top1Relevant).length;
109
187
  const top3Hits = results.filter((r) => r.top3Relevant).length;
110
188
  const ranks = results.filter((r) => r.firstRelevantRank > 0).map((r) => r.firstRelevantRank);
111
- // MRR over the top-3 window (rank-of-first-relevant). Queries with no
112
- // relevant result in top-3 contribute 0 to the mean.
113
189
  const mrr3 = QUERIES.length > 0
114
190
  ? Number((ranks.reduce((s, r) => s + 1 / r, 0) / QUERIES.length).toFixed(4))
115
191
  : 0;
192
+
193
+ // ADR-081 labelled metrics — tighter ground truth than regex proxy.
194
+ const label_top1Hits = results.filter((r) => r.label_top1).length;
195
+ const label_top3HitsBinary = results.filter((r) => r.label_top3_count > 0).length;
196
+ const label_ranks = results.filter((r) => r.label_firstRank > 0).map((r) => r.label_firstRank);
197
+ const label_mrr3 = QUERIES.length > 0
198
+ ? Number((label_ranks.reduce((s, r) => s + 1 / r, 0) / QUERIES.length).toFixed(4))
199
+ : 0;
200
+ const label_ndcg3_mean = QUERIES.length > 0
201
+ ? Number((results.reduce((s, r) => s + r.label_ndcg3, 0) / QUERIES.length).toFixed(4))
202
+ : 0;
203
+ const label_ndcg5_mean = QUERIES.length > 0
204
+ ? Number((results.reduce((s, r) => s + r.label_ndcg5, 0) / QUERIES.length).toFixed(4))
205
+ : 0;
206
+ // Mean precision@3 — fraction of top-3 that's relevant per query, averaged.
207
+ const label_precision3 = QUERIES.length > 0
208
+ ? Number((results.reduce((s, r) => s + r.label_top3_count / 3, 0) / QUERIES.length).toFixed(4))
209
+ : 0;
116
210
  // top-1 collision: number of distinct top-1 IDs over the query count.
117
211
  const uniqueTop1 = top1Ids.size;
118
212
  const top1Diversity = Number((uniqueTop1 / QUERIES.length).toFixed(4));
@@ -141,9 +235,17 @@ async function main() {
141
235
  queries: QUERIES.length,
142
236
  matchedQueries,
143
237
  matchRate: Number((matchedQueries / QUERIES.length).toFixed(4)),
144
- top1HitRate: Number((top1Hits / QUERIES.length).toFixed(4)), // ADR-078: relevance, not just any-match
238
+ // Regex-proxy metrics (ADR-077-080 back-compat)
239
+ top1HitRate: Number((top1Hits / QUERIES.length).toFixed(4)),
145
240
  top3HitRate: Number((top3Hits / QUERIES.length).toFixed(4)),
146
- mrr3, // mean reciprocal rank over top-3
241
+ mrr3,
242
+ // ADR-081 labelled metrics — tighter ground truth
243
+ label_top1HitRate: Number((label_top1Hits / QUERIES.length).toFixed(4)),
244
+ label_top3HitRate: Number((label_top3HitsBinary / QUERIES.length).toFixed(4)),
245
+ label_mrr3,
246
+ label_precision3,
247
+ label_ndcg3: label_ndcg3_mean,
248
+ label_ndcg5: label_ndcg5_mean,
147
249
  top1Diversity, // 1.0 = every query gets a distinct top-1
148
250
  top3DupRate, // 0.0 = no duplicate IDs inside any top-3
149
251
  avgQueryLatencyMs: Number((queryMs / QUERIES.length).toFixed(2)),
@@ -160,9 +262,16 @@ async function main() {
160
262
  console.log(`Store size: ${total} patterns`);
161
263
  console.log(`Queries: ${QUERIES.length}`);
162
264
  console.log(`Match rate: ${(summary.matchRate * 100).toFixed(0)}% (${matchedQueries}/${QUERIES.length})`);
163
- console.log(`Top-1 hit rate (RELEVANCE): ${(summary.top1HitRate * 100).toFixed(0)}% (${top1Hits}/${QUERIES.length})`);
164
- console.log(`Top-3 hit rate (RELEVANCE): ${(summary.top3HitRate * 100).toFixed(0)}% (${top3Hits}/${QUERIES.length})`);
165
- console.log(`MRR@3: ${summary.mrr3}`);
265
+ console.log(`Top-1 hit rate (regex proxy): ${(summary.top1HitRate * 100).toFixed(0)}% (${top1Hits}/${QUERIES.length})`);
266
+ console.log(`Top-3 hit rate (regex proxy): ${(summary.top3HitRate * 100).toFixed(0)}% (${top3Hits}/${QUERIES.length})`);
267
+ console.log(`MRR@3 (regex proxy): ${summary.mrr3}`);
268
+ console.log('');
269
+ console.log(`Top-1 hit rate (ADR-081 labelled): ${(summary.label_top1HitRate * 100).toFixed(0)}% (${label_top1Hits}/${QUERIES.length})`);
270
+ console.log(`Top-3 hit rate (ADR-081 labelled): ${(summary.label_top3HitRate * 100).toFixed(0)}% (${label_top3HitsBinary}/${QUERIES.length})`);
271
+ console.log(`MRR@3 (labelled): ${summary.label_mrr3}`);
272
+ console.log(`Precision@3 (labelled, mean): ${summary.label_precision3}`);
273
+ console.log(`nDCG@3 (labelled, mean): ${summary.label_ndcg3}`);
274
+ console.log(`nDCG@5 (labelled, mean): ${summary.label_ndcg5}`);
166
275
  console.log(`Top-1 diversity: ${(summary.top1Diversity * 100).toFixed(0)}% (${uniqueTop1} distinct top-1 IDs across ${QUERIES.length} queries)`);
167
276
  console.log(`Top-3 dup rate: ${(summary.top3DupRate * 100).toFixed(0)}%`);
168
277
  console.log(`Avg query latency: ${summary.avgQueryLatencyMs} ms`);
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ // grid-search-retrieval.mjs — sweep retrieval hyperparameters against the
3
+ // ADR-081 labelled corpus. Reports label nDCG@3, top-1, top-3, precision@3,
4
+ // MRR@3 per configuration. Identifies the best non-rerank and best rerank
5
+ // configs by nDCG@3.
6
+ //
7
+ // Run prerequisites: scripts/pretrain-from-github.mjs first.
8
+ //
9
+ // Usage:
10
+ // node scripts/grid-search-retrieval.mjs # default grid
11
+ // node scripts/grid-search-retrieval.mjs --quick # smaller grid
12
+ // BENCH_JSON=1 node scripts/grid-search-retrieval.mjs # machine-readable
13
+ //
14
+ // Output also goes to docs/benchmarks/runs/grid-search-retrieval-<ts>.json
15
+
16
+ import { writeFileSync, mkdirSync } from 'node:fs';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { dirname, join, resolve } from 'node:path';
19
+ import { performance } from 'node:perf_hooks';
20
+
21
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
22
+ const CLI_ROOT = resolve(SCRIPT_DIR, '..');
23
+ const REPO_ROOT = resolve(SCRIPT_DIR, '../../../..');
24
+ const RUNS_DIR = join(REPO_ROOT, 'docs', 'benchmarks', 'runs');
25
+
26
+ // Same labelled corpus as benchmark-pretrained-retrieval.mjs (ADR-081).
27
+ const QUERIES = [
28
+ { q: 'how was the Opus model alias fixed',
29
+ labels: ['opus 4.8', 'opus alias', 'opus model alias', '#2232'] },
30
+ { q: 'self-learning wiring task-completed pretrain',
31
+ labels: ['self-learning', 'adr-074', 'self learning', '#2245', 'task-completed'] },
32
+ { q: 'deterministic codemod engine var-to-const',
33
+ labels: ['deterministic tier-1 codemod', 'adr-143', 'codemod', 'var-to-const'] },
34
+ { q: 'MCP server orphan leak parent-death',
35
+ labels: ['mcp orphan', 'mcp servers orphan', 'parent-death', '#2234', 'orphan on every claude'] },
36
+ { q: 'unified learning stats aggregator',
37
+ labels: ['unified learning-stats', 'adr-075', 'unified learning stats'] },
38
+ { q: 'structured distillation 4-field schema',
39
+ labels: ['structured distillation', 'adr-076', '4-field schema'] },
40
+ { q: 'SQL injection migrate.ts table identifier',
41
+ labels: ['sql injection', 'shell injection', 'migrate.ts', 'agentdb', 'cve'] },
42
+ { q: 'recall@k HNSW benchmark harness',
43
+ labels: ['hnsw', 'memory-recall', 'benchmark suite', 'recall@k', 'benchmark intelligence'] },
44
+ { q: 'Q-learning encoder keyword block',
45
+ labels: ['q-state encoder', 'route q-state', 'keyword block', '#2239', 'q-encoder'] },
46
+ { q: 'security hardening crypto random IDs',
47
+ labels: ['cwe-347', 'crypto.randomuuid', 'security fix', 'random id', 'crypto random'] },
48
+ ];
49
+
50
+ function isRelevant(name, labels) {
51
+ if (!name || !labels?.length) return false;
52
+ const lower = String(name).toLowerCase();
53
+ return labels.some((s) => lower.includes(s.toLowerCase()));
54
+ }
55
+
56
+ function ndcgAtK(rankedRel, k) {
57
+ const arr = rankedRel.slice(0, k);
58
+ const dcg = arr.reduce((acc, rel, i) => acc + (rel ? 1 / Math.log2(i + 2) : 0), 0);
59
+ const numRelevant = arr.filter(Boolean).length;
60
+ if (numRelevant === 0) return 0;
61
+ let idcg = 0;
62
+ for (let i = 0; i < numRelevant; i++) idcg += 1 / Math.log2(i + 2);
63
+ return idcg > 0 ? dcg / idcg : 0;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Grid definitions
68
+ // ---------------------------------------------------------------------------
69
+
70
+ const QUICK = process.argv.includes('--quick');
71
+
72
+ // Non-rerank grid: alpha × subjectWeight × mmrLambda
73
+ const ALPHA_GRID = QUICK ? [0.5, 0.7] : [0.3, 0.5, 0.7];
74
+ const SUBJ_GRID = QUICK ? [3.0, 5.0] : [2.0, 3.0, 5.0];
75
+ const MMR_GRID = QUICK ? [0.5] : [0.3, 0.5, 0.7];
76
+
77
+ // Rerank grid: hybridWeight × ceWeight (mutually constrained — they sum to 1)
78
+ const RERANK_GRID = QUICK
79
+ ? [[0.5, 0.5]]
80
+ : [[0.3, 0.7], [0.4, 0.6], [0.5, 0.5], [0.6, 0.4], [0.7, 0.3]];
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Eval
84
+ // ---------------------------------------------------------------------------
85
+
86
+ async function evalConfig(tool, params) {
87
+ let top1Hits = 0, top3HitsBin = 0, ranks = [], ndcg3Sum = 0, prec3Sum = 0;
88
+ const tStart = performance.now();
89
+ for (const { q, labels } of QUERIES) {
90
+ const r = await tool.handler({ action: 'search', query: q, mode: 'hybrid', limit: 5, ...params });
91
+ const matches = (r.results || []).slice(0, 5);
92
+ const rel = matches.map((m) => isRelevant(m?.name, labels));
93
+ if (rel[0]) top1Hits++;
94
+ if (rel.slice(0, 3).some(Boolean)) top3HitsBin++;
95
+ const firstRank = rel.findIndex(Boolean);
96
+ if (firstRank >= 0) ranks.push(firstRank + 1);
97
+ ndcg3Sum += ndcgAtK(rel, 3);
98
+ prec3Sum += rel.slice(0, 3).filter(Boolean).length / 3;
99
+ }
100
+ const elapsed = performance.now() - tStart;
101
+ return {
102
+ label_top1HitRate: top1Hits / QUERIES.length,
103
+ label_top3HitRate: top3HitsBin / QUERIES.length,
104
+ label_mrr3: ranks.reduce((s, r) => s + 1 / r, 0) / QUERIES.length,
105
+ label_precision3: prec3Sum / QUERIES.length,
106
+ label_ndcg3: ndcg3Sum / QUERIES.length,
107
+ avgLatencyMs: elapsed / QUERIES.length,
108
+ };
109
+ }
110
+
111
+ async function main() {
112
+ const neural = await import(join(CLI_ROOT, 'dist/src/mcp-tools/neural-tools.js'));
113
+ const tool = neural.neuralTools.find((t) => t.name === 'neural_patterns');
114
+ if (!tool) throw new Error('neural_patterns tool not found');
115
+
116
+ const configs = [];
117
+
118
+ // §A — non-rerank grid (fast, ~3*3*3 = 27 configs at default)
119
+ for (const alpha of ALPHA_GRID) {
120
+ for (const subjectWeight of SUBJ_GRID) {
121
+ for (const mmrLambda of MMR_GRID) {
122
+ configs.push({
123
+ name: `hybrid α=${alpha} sw=${subjectWeight} mmr=${mmrLambda}`,
124
+ rerank: false,
125
+ params: { rerank: false, alpha, subjectWeight, mmrLambda, bodyWeight: 1.0, typePenaltyFactor: 1.0 },
126
+ });
127
+ }
128
+ }
129
+ }
130
+
131
+ // §B — rerank grid (slow, ~5 configs — each query takes ~1s with CE)
132
+ for (const [hybridWeight, ceWeight] of RERANK_GRID) {
133
+ configs.push({
134
+ name: `rerank hw=${hybridWeight} cw=${ceWeight}`,
135
+ rerank: true,
136
+ params: { rerank: true, alpha: 0.6, subjectWeight: 3.0, hybridWeight, ceWeight },
137
+ });
138
+ }
139
+
140
+ console.log(`Grid-search: ${configs.length} configs across ${QUERIES.length} queries\n`);
141
+ const results = [];
142
+ for (const cfg of configs) {
143
+ process.stdout.write(` ${cfg.name.padEnd(40)} … `);
144
+ const m = await evalConfig(tool, cfg.params);
145
+ results.push({ ...cfg, metrics: m });
146
+ 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`);
147
+ }
148
+
149
+ // Rank by label nDCG@3 (the canonical relevance metric).
150
+ const byNdcg = [...results].sort((a, b) => b.metrics.label_ndcg3 - a.metrics.label_ndcg3);
151
+ const byTop1 = [...results].sort((a, b) => b.metrics.label_top1HitRate - a.metrics.label_top1HitRate || b.metrics.label_ndcg3 - a.metrics.label_ndcg3);
152
+ const byPrec3 = [...results].sort((a, b) => b.metrics.label_precision3 - a.metrics.label_precision3 || b.metrics.label_ndcg3 - a.metrics.label_ndcg3);
153
+
154
+ const bestNonRerankByNdcg = byNdcg.find((r) => !r.rerank);
155
+ const bestRerankByNdcg = byNdcg.find((r) => r.rerank);
156
+
157
+ console.log('\n=== Top 5 by label nDCG@3 ===');
158
+ for (const r of byNdcg.slice(0, 5)) {
159
+ 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}`);
160
+ }
161
+
162
+ console.log('\n=== Top 5 by label top-1 (ties broken by nDCG) ===');
163
+ for (const r of byTop1.slice(0, 5)) {
164
+ 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}`);
165
+ }
166
+
167
+ console.log('\n=== Top 5 by label precision@3 ===');
168
+ for (const r of byPrec3.slice(0, 5)) {
169
+ 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}`);
170
+ }
171
+
172
+ console.log(`\n=== WINNERS ===`);
173
+ 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)}`);
174
+ console.log(`Best rerank (by nDCG@3): ${bestRerankByNdcg.name} → top1=${(bestRerankByNdcg.metrics.label_top1HitRate * 100).toFixed(0)}% nDCG=${bestRerankByNdcg.metrics.label_ndcg3.toFixed(3)}`);
175
+
176
+ const summary = {
177
+ runAt: new Date().toISOString(),
178
+ benchmark: 'grid-search-retrieval',
179
+ queries: QUERIES.length,
180
+ configsEvaluated: configs.length,
181
+ grid: { ALPHA_GRID, SUBJ_GRID, MMR_GRID, RERANK_GRID },
182
+ results: results.map((r) => ({ name: r.name, rerank: r.rerank, params: r.params, metrics: r.metrics })),
183
+ winners: {
184
+ nDcg3: byNdcg[0].name,
185
+ top1: byTop1[0].name,
186
+ precision3: byPrec3[0].name,
187
+ bestNonRerank: bestNonRerankByNdcg.name,
188
+ bestRerank: bestRerankByNdcg.name,
189
+ },
190
+ };
191
+
192
+ if (process.env.BENCH_JSON) {
193
+ console.log(JSON.stringify(summary, null, 2));
194
+ } else {
195
+ mkdirSync(RUNS_DIR, { recursive: true });
196
+ const stamp = summary.runAt.replace(/[:.]/g, '-');
197
+ writeFileSync(join(RUNS_DIR, `grid-search-retrieval-${stamp}.json`), JSON.stringify(summary, null, 2));
198
+ writeFileSync(join(RUNS_DIR, 'grid-search-retrieval-latest.json'), JSON.stringify(summary, null, 2));
199
+ console.log(`\nWrote ${join(RUNS_DIR, `grid-search-retrieval-${stamp}.json`)}`);
200
+ }
201
+
202
+ process.exit(0);
203
+ }
204
+
205
+ main().catch((err) => { console.error(err); process.exit(1); });