thumbgate 1.30.0 → 1.31.0

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.
Files changed (77) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +66 -8
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +147 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +212 -206
  16. package/config/model-tiers.json +7 -2
  17. package/glama.json +6 -0
  18. package/package.json +60 -8
  19. package/public/assets/diagrams/before-after.svg +17 -16
  20. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  21. package/public/assets/diagrams/loop.svg +19 -13
  22. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  23. package/public/compare.html +1 -0
  24. package/public/dashboard.html +126 -28
  25. package/public/evaluations.html +1 -1
  26. package/public/index.html +142 -13
  27. package/public/numbers.html +3 -2
  28. package/public/pricing.html +143 -30
  29. package/scripts/a-plus-evidence-scorecard.js +303 -0
  30. package/scripts/async-eval-observability.js +36 -11
  31. package/scripts/audit-trail.js +37 -1
  32. package/scripts/auto-promote-gates.js +149 -34
  33. package/scripts/colbert-style-maxsim.js +236 -0
  34. package/scripts/cross-encoder-reranker.js +356 -126
  35. package/scripts/dashboard-chat.js +350 -17
  36. package/scripts/document-intake.js +283 -7
  37. package/scripts/eval-quality-suite.js +204 -0
  38. package/scripts/feedback-loop.js +115 -7
  39. package/scripts/feedback-paths.js +32 -13
  40. package/scripts/feedback-quality.js +53 -0
  41. package/scripts/filesystem-search.js +17 -7
  42. package/scripts/gates-engine.js +17 -0
  43. package/scripts/harness-tool-names.js +70 -0
  44. package/scripts/hook-runtime.js +10 -3
  45. package/scripts/lesson-db.js +16 -5
  46. package/scripts/lesson-embedding-index.js +67 -20
  47. package/scripts/lesson-embedding-maintenance.js +177 -0
  48. package/scripts/lesson-reranker.js +55 -9
  49. package/scripts/lesson-retrieval.js +305 -29
  50. package/scripts/lesson-search.js +22 -8
  51. package/scripts/llm-client.js +304 -15
  52. package/scripts/model-tier-router.js +593 -0
  53. package/scripts/pragmatic-hybrid-search.js +379 -0
  54. package/scripts/rag-document-pipeline.js +461 -0
  55. package/scripts/rag-structured-output.js +441 -0
  56. package/scripts/ragas-style-metrics.js +351 -0
  57. package/scripts/request-envelope.js +178 -0
  58. package/scripts/rerank-pipeline.js +370 -0
  59. package/scripts/rerank-quality-eval.js +155 -0
  60. package/scripts/retrieval-hybrid-ablation.js +120 -0
  61. package/scripts/retrieval-quality-tier.js +118 -0
  62. package/scripts/secret-scanner.js +395 -4
  63. package/scripts/self-distill-agent.js +7 -1
  64. package/scripts/self-healing-check.js +25 -0
  65. package/scripts/skill-packs.js +183 -0
  66. package/scripts/slow-loop.js +72 -0
  67. package/scripts/statusline-links.js +1 -1
  68. package/scripts/statusline.sh +8 -1
  69. package/scripts/telemetry-analytics.js +13 -1
  70. package/scripts/thumbgate-search.js +98 -6
  71. package/scripts/tier-budget-guard.js +186 -0
  72. package/scripts/tool-registry.js +46 -0
  73. package/scripts/vector-store.js +108 -4
  74. package/scripts/verify-marketing-pages-deployed.js +85 -3
  75. package/server.json +44 -0
  76. package/smithery.yaml +17 -0
  77. package/src/api/server.js +194 -13
@@ -0,0 +1,370 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Multi-stage rerank pipeline for ThumbGate's A+ target.
5
+ *
6
+ * Stages (always local-first; LLM optional and last):
7
+ * 1) Field-weighted BM25F pair scoring (lesson-reranker)
8
+ * 2) ColBERT-style MaxSim late interaction (colbert-style-maxsim)
9
+ * 3) Heuristic joint pair scorer (cross-encoder-reranker.heuristicCrossEncode)
10
+ * 4) Optional listwise LLM rerank on the final shortlist (useLLM / env)
11
+ *
12
+ * Honesty contract:
13
+ * - Stage 3 is a *heuristic* cross-encoder, not a neural CE checkpoint
14
+ * - Stage 2 is ColBERT-*style* MaxSim over hashed multi-vectors unless
15
+ * a tokenEmbedder is supplied
16
+ * - LLM stage is off by default; enable with useLLM:true or THUMBGATE_RERANK_LLM=1
17
+ *
18
+ * Pipeline version is exported so evals and statuslines can pin provenance.
19
+ */
20
+
21
+ const PIPELINE_VERSION = '2026-07-31.a-plus.1';
22
+
23
+ const { rerankLessons } = require('./lesson-reranker');
24
+ const { rerankWithMaxSim, scoreLateInteraction } = require('./colbert-style-maxsim');
25
+ const { heuristicCrossEncode, llmCrossEncode } = require('./cross-encoder-reranker');
26
+
27
+ /**
28
+ * @typedef {object} RerankPipelineOptions
29
+ * @property {number} [topK=5]
30
+ * @property {string} [toolName]
31
+ * @property {boolean} [useLLM=false]
32
+ * @property {boolean} [useMaxSim=true]
33
+ * @property {boolean} [useHeuristicCe=true]
34
+ * @property {number} [bm25Pool=50] candidates to keep after BM25 before MaxSim
35
+ * @property {number} [llmShortlist=8] max docs sent to LLM listwise scorer
36
+ * @property {number} [wBm25=0.30]
37
+ * @property {number} [wMaxSim=0.35]
38
+ * @property {number} [wHeuristic=0.25]
39
+ * @property {number} [wOriginal=0.10]
40
+ * @property {(c: object) => string} [textOf]
41
+ */
42
+
43
+ function defaultTextOf(c) {
44
+ if (!c || typeof c !== 'object') return String(c || '');
45
+ return [
46
+ c.title,
47
+ c.whatWentWrong,
48
+ c.whatToChange,
49
+ c.howToAvoid,
50
+ c.summary,
51
+ c.content,
52
+ c.context,
53
+ Array.isArray(c.tags) ? c.tags.join(' ') : '',
54
+ ].filter(Boolean).join(' ');
55
+ }
56
+
57
+ function envFlag(name) {
58
+ const v = String(process.env[name] || '').trim().toLowerCase();
59
+ return v === '1' || v === 'true' || v === 'yes' || v === 'on';
60
+ }
61
+
62
+ /**
63
+ * Fuse multi-stage scores into a final ranking.
64
+ * @param {string} query
65
+ * @param {Array<object>} candidates
66
+ * @param {RerankPipelineOptions} [options]
67
+ * @returns {Promise<{ results: Array<object>, meta: object }>}
68
+ */
69
+ async function rerankPipeline(query, candidates, options = {}) {
70
+ const topK = options.topK ?? 5;
71
+ const toolName = options.toolName || '';
72
+ const useMaxSim = options.useMaxSim !== false;
73
+ const useHeuristicCe = options.useHeuristicCe !== false;
74
+ const useLLM = options.useLLM === true || envFlag('THUMBGATE_RERANK_LLM');
75
+ const bm25Pool = Math.max(topK, options.bm25Pool ?? 50);
76
+ const llmShortlist = Math.max(topK, options.llmShortlist ?? 8);
77
+ const textOf = options.textOf || defaultTextOf;
78
+ const wBm25 = options.wBm25 ?? 0.30;
79
+ const wMaxSim = options.wMaxSim ?? 0.35;
80
+ const wHeuristic = options.wHeuristic ?? 0.25;
81
+ const wOriginal = options.wOriginal ?? 0.10;
82
+
83
+ const meta = {
84
+ pipelineVersion: PIPELINE_VERSION,
85
+ stages: [],
86
+ useLLM,
87
+ useMaxSim,
88
+ useHeuristicCe,
89
+ inputCount: candidates?.length || 0,
90
+ };
91
+
92
+ if (!candidates || candidates.length === 0) {
93
+ return { results: [], meta: { ...meta, stages: ['empty'] } };
94
+ }
95
+
96
+ // --- Stage 1: BM25F ---
97
+ let pool = rerankLessons(query, candidates, {
98
+ topK: Math.min(bm25Pool, candidates.length),
99
+ toolName,
100
+ blendWeight: 0.7,
101
+ });
102
+ meta.stages.push('bm25f');
103
+
104
+ // Capture BM25 scores before MaxSim overwrites rerankedScore
105
+ pool = pool.map((c) => ({
106
+ ...c,
107
+ bm25Score: Number(c.rerankedScore ?? 0),
108
+ originalScore: Number(c.relevanceScore ?? c.score ?? 0),
109
+ }));
110
+
111
+ // --- Stage 2: ColBERT-style MaxSim ---
112
+ if (useMaxSim && pool.length > 1) {
113
+ pool = rerankWithMaxSim(query, pool, {
114
+ topK: pool.length,
115
+ textOf,
116
+ blendWeight: 1, // pure MaxSim into maxSimScore; we fuse ourselves
117
+ dim: options.dim,
118
+ ngram: options.ngram,
119
+ maxTokens: options.maxTokens,
120
+ tokenEmbedder: options.tokenEmbedder,
121
+ }).map((c) => ({
122
+ ...c,
123
+ // restore bm25 from previous map (rerankWithMaxSim spreads candidate)
124
+ bm25Score: c.bm25Score,
125
+ maxSimScore: Number(c.maxSimScore ?? 0),
126
+ }));
127
+ meta.stages.push('colbert-style-maxsim');
128
+ } else {
129
+ pool = pool.map((c) => ({ ...c, maxSimScore: 0 }));
130
+ }
131
+
132
+ // --- Stage 3: Heuristic joint pair scorer ---
133
+ if (useHeuristicCe) {
134
+ pool = pool.map((c) => {
135
+ const he = heuristicCrossEncode(
136
+ `${toolName} ${query}`.trim(),
137
+ textOf(c),
138
+ );
139
+ return { ...c, heuristicCeScore: he };
140
+ });
141
+ meta.stages.push('heuristic-pair-ce');
142
+ } else {
143
+ pool = pool.map((c) => ({ ...c, heuristicCeScore: 0 }));
144
+ }
145
+
146
+ // --- Fuse ---
147
+ // Normalize bm25 within pool for fair blend
148
+ const maxBm25 = Math.max(...pool.map((c) => c.bm25Score || 0), 1e-9);
149
+ pool = pool.map((c) => {
150
+ const nBm25 = (c.bm25Score || 0) / maxBm25;
151
+ const nOrig = Math.max(0, Math.min(1, c.originalScore || 0));
152
+ const nMs = Math.max(0, Math.min(1, c.maxSimScore || 0));
153
+ const nHe = Math.max(0, Math.min(1, c.heuristicCeScore || 0));
154
+ const fused =
155
+ wBm25 * nBm25 +
156
+ wMaxSim * nMs +
157
+ wHeuristic * nHe +
158
+ wOriginal * nOrig;
159
+ return {
160
+ ...c,
161
+ fusedScore: Number(fused.toFixed(6)),
162
+ rerankedScore: Number(fused.toFixed(6)),
163
+ pairwiseHeuristicScore: nHe,
164
+ lateInteractionScore: nMs,
165
+ crossEncoderScore: null,
166
+ combinedScore: Number(fused.toFixed(6)),
167
+ reranker: {
168
+ stages: [
169
+ 'first-stage',
170
+ 'bm25f',
171
+ 'colbert-style-maxsim-hashed',
172
+ 'pairwise-heuristic',
173
+ 'score-fusion',
174
+ ],
175
+ fallbacks: ['neural-cross-encoder-not-configured'],
176
+ },
177
+ };
178
+ }).sort((a, b) => b.fusedScore - a.fusedScore);
179
+
180
+ meta.stages.push('score-fusion');
181
+
182
+ // --- Stage 4: optional LLM listwise on shortlist ---
183
+ let shortlist = pool.slice(0, Math.min(llmShortlist, pool.length));
184
+ if (useLLM && shortlist.length > 1) {
185
+ const llmScores = await llmCrossEncode(
186
+ `${toolName} ${query}`.trim(),
187
+ shortlist.map((c) => ({
188
+ title: c.title || '',
189
+ content: textOf(c).slice(0, 400),
190
+ })),
191
+ );
192
+ if (llmScores) {
193
+ shortlist = shortlist.map((c, i) => {
194
+ const llm = Math.max(0, Math.min(1, Number(llmScores[i]) || 0));
195
+ // Blend LLM lightly so a bad model cannot erase local signal
196
+ const final = 0.55 * llm + 0.45 * (c.fusedScore || 0);
197
+ return {
198
+ ...c,
199
+ llmRerankScore: llm,
200
+ fusedScore: Number(final.toFixed(6)),
201
+ rerankedScore: Number(final.toFixed(6)),
202
+ combinedScore: Number(final.toFixed(6)),
203
+ };
204
+ }).sort((a, b) => b.fusedScore - a.fusedScore);
205
+ meta.stages.push('llm-listwise');
206
+ meta.llmApplied = true;
207
+ } else {
208
+ meta.llmApplied = false;
209
+ meta.stages.push('llm-fallback');
210
+ }
211
+ } else {
212
+ meta.llmApplied = false;
213
+ }
214
+
215
+ const results = shortlist.slice(0, topK).map((c) => ({
216
+ ...c,
217
+ rerankPipelineVersion: PIPELINE_VERSION,
218
+ }));
219
+
220
+ meta.outputCount = results.length;
221
+ meta.rankDelta = computeRankDelta(candidates, results);
222
+
223
+ return { results, meta };
224
+ }
225
+
226
+ /**
227
+ * Sync path for PreToolUse hooks (no LLM).
228
+ */
229
+ function rerankPipelineSync(query, candidates, options = {}) {
230
+ return rerankPipelineSyncImpl(query, candidates, options);
231
+ }
232
+
233
+ function rerankPipelineSyncImpl(query, candidates, options = {}) {
234
+ const topK = options.topK ?? 5;
235
+ const toolName = options.toolName || '';
236
+ const useMaxSim = options.useMaxSim !== false;
237
+ const useHeuristicCe = options.useHeuristicCe !== false;
238
+ const bm25Pool = Math.max(topK, options.bm25Pool ?? 50);
239
+ const textOf = options.textOf || defaultTextOf;
240
+ const wBm25 = options.wBm25 ?? 0.30;
241
+ const wMaxSim = options.wMaxSim ?? 0.35;
242
+ const wHeuristic = options.wHeuristic ?? 0.25;
243
+ const wOriginal = options.wOriginal ?? 0.10;
244
+
245
+ const meta = {
246
+ pipelineVersion: PIPELINE_VERSION,
247
+ stages: [],
248
+ useLLM: false,
249
+ useMaxSim,
250
+ useHeuristicCe,
251
+ inputCount: candidates?.length || 0,
252
+ llmApplied: false,
253
+ };
254
+
255
+ if (!candidates || candidates.length === 0) {
256
+ return { results: [], meta: { ...meta, stages: ['empty'] } };
257
+ }
258
+
259
+ let pool = rerankLessons(query, candidates, {
260
+ topK: Math.min(bm25Pool, candidates.length),
261
+ toolName,
262
+ blendWeight: 0.7,
263
+ }).map((c) => ({
264
+ ...c,
265
+ bm25Score: Number(c.rerankedScore ?? 0),
266
+ originalScore: Number(c.relevanceScore ?? c.score ?? 0),
267
+ }));
268
+ meta.stages.push('bm25f');
269
+
270
+ if (useMaxSim && pool.length > 1) {
271
+ pool = rerankWithMaxSim(query, pool, {
272
+ topK: pool.length,
273
+ textOf,
274
+ blendWeight: 1,
275
+ }).map((c) => ({
276
+ ...c,
277
+ bm25Score: c.bm25Score,
278
+ maxSimScore: Number(c.maxSimScore ?? 0),
279
+ }));
280
+ meta.stages.push('colbert-style-maxsim');
281
+ } else {
282
+ pool = pool.map((c) => ({ ...c, maxSimScore: 0 }));
283
+ }
284
+
285
+ if (useHeuristicCe) {
286
+ pool = pool.map((c) => ({
287
+ ...c,
288
+ heuristicCeScore: heuristicCrossEncode(`${toolName} ${query}`.trim(), textOf(c)),
289
+ }));
290
+ meta.stages.push('heuristic-pair-ce');
291
+ } else {
292
+ pool = pool.map((c) => ({ ...c, heuristicCeScore: 0 }));
293
+ }
294
+
295
+ const maxBm25 = Math.max(...pool.map((c) => c.bm25Score || 0), 1e-9);
296
+ pool = pool.map((c) => {
297
+ const nBm25 = (c.bm25Score || 0) / maxBm25;
298
+ const nOrig = Math.max(0, Math.min(1, c.originalScore || 0));
299
+ const nMs = Math.max(0, Math.min(1, c.maxSimScore || 0));
300
+ const nHe = Math.max(0, Math.min(1, c.heuristicCeScore || 0));
301
+ const fused =
302
+ wBm25 * nBm25 + wMaxSim * nMs + wHeuristic * nHe + wOriginal * nOrig;
303
+ return {
304
+ ...c,
305
+ fusedScore: Number(fused.toFixed(6)),
306
+ rerankedScore: Number(fused.toFixed(6)),
307
+ pairwiseHeuristicScore: nHe,
308
+ lateInteractionScore: nMs,
309
+ crossEncoderScore: null,
310
+ combinedScore: Number(fused.toFixed(6)),
311
+ rerankPipelineVersion: PIPELINE_VERSION,
312
+ reranker: {
313
+ stages: [
314
+ 'first-stage',
315
+ 'bm25f',
316
+ 'colbert-style-maxsim-hashed',
317
+ 'pairwise-heuristic',
318
+ 'score-fusion',
319
+ ],
320
+ fallbacks: ['neural-cross-encoder-not-configured'],
321
+ },
322
+ };
323
+ }).sort((a, b) => b.fusedScore - a.fusedScore);
324
+
325
+ meta.stages.push('score-fusion');
326
+ const results = pool.slice(0, topK);
327
+ meta.outputCount = results.length;
328
+ meta.rankDelta = computeRankDelta(candidates, results);
329
+ return { results, meta };
330
+ }
331
+
332
+ /**
333
+ * Whether top-1 id changed vs original order (rank-delta signal).
334
+ * @param {Array<object>} original
335
+ * @param {Array<object>} reranked
336
+ * @returns {{ flipped: boolean, originalTopId: string|null, rerankedTopId: string|null }}
337
+ */
338
+ function computeRankDelta(original, reranked) {
339
+ const idOf = (c) => c?.id || c?.lessonId || c?.title || null;
340
+ const originalTopId = original?.[0] ? idOf(original[0]) : null;
341
+ const rerankedTopId = reranked?.[0] ? idOf(reranked[0]) : null;
342
+ return {
343
+ flipped: Boolean(originalTopId && rerankedTopId && originalTopId !== rerankedTopId),
344
+ originalTopId,
345
+ rerankedTopId,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Pair-level scores for diagnostics / evals.
351
+ */
352
+ function scorePair(query, document, opts = {}) {
353
+ const late = scoreLateInteraction(query, document, opts);
354
+ const he = heuristicCrossEncode(query, document);
355
+ return {
356
+ maxSim: late.score,
357
+ heuristicCe: he,
358
+ mode: late.mode,
359
+ pipelineVersion: PIPELINE_VERSION,
360
+ };
361
+ }
362
+
363
+ module.exports = {
364
+ PIPELINE_VERSION,
365
+ rerankPipeline,
366
+ rerankPipelineSync,
367
+ computeRankDelta,
368
+ scorePair,
369
+ defaultTextOf,
370
+ };
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+
6
+ /**
7
+ * Golden-set regression eval for the rerank pipeline.
8
+ *
9
+ * Measures:
10
+ * - Precision@1 / MRR on planted force-push / secret / deploy cases
11
+ * - Rank-delta rate (how often #1 flips vs first-stage order)
12
+ * - Stage presence (BM25, MaxSim, heuristic CE)
13
+ *
14
+ * Exit 0 when bounded deterministic floors are met. Provider holdouts and live
15
+ * traces are separate A+ requirements.
16
+ */
17
+
18
+ const { rerankPipelineSync, PIPELINE_VERSION } = require('./rerank-pipeline');
19
+
20
+ const GOLDEN = [
21
+ {
22
+ id: 'force-push',
23
+ query: 'git push --force to main',
24
+ toolName: 'Bash',
25
+ // first-stage order intentionally wrong (decoy first)
26
+ candidates: [
27
+ { id: 'decoy-deploy', title: 'Friday deploys', content: 'Ship every Friday', relevanceScore: 0.99 },
28
+ {
29
+ id: 'gold-force',
30
+ title: 'Force push blocked',
31
+ whatWentWrong: 'force push to main wiped history',
32
+ tags: ['git', 'force-push', 'negative'],
33
+ signal: 'negative',
34
+ relevanceScore: 0.2,
35
+ metadata: { toolsUsed: ['Bash'] },
36
+ },
37
+ { id: 'noise-weather', title: 'Weather', content: 'Paris is rainy', relevanceScore: 0.5 },
38
+ ],
39
+ relevantIds: ['gold-force'],
40
+ },
41
+ {
42
+ id: 'secrets-env',
43
+ query: 'commit .env with API keys',
44
+ toolName: 'Bash',
45
+ candidates: [
46
+ { id: 'decoy-readme', title: 'Update README', content: 'docs only', relevanceScore: 0.9 },
47
+ {
48
+ id: 'gold-secret',
49
+ title: 'Never commit secrets',
50
+ whatWentWrong: 'committed .env with api key token credential',
51
+ tags: ['secret', 'env', 'security'],
52
+ signal: 'negative',
53
+ relevanceScore: 0.25,
54
+ },
55
+ ],
56
+ relevantIds: ['gold-secret'],
57
+ },
58
+ {
59
+ id: 'rm-rf',
60
+ query: 'rm -rf production data',
61
+ toolName: 'Bash',
62
+ candidates: [
63
+ { id: 'decoy-test', title: 'Run unit tests', content: 'npm test passes', relevanceScore: 0.85 },
64
+ {
65
+ id: 'gold-rm',
66
+ title: 'Destructive delete blocked',
67
+ whatWentWrong: 'rm -rf wiped production database directory',
68
+ tags: ['delete', 'destructive'],
69
+ signal: 'negative',
70
+ relevanceScore: 0.3,
71
+ },
72
+ ],
73
+ relevantIds: ['gold-rm'],
74
+ },
75
+ ];
76
+
77
+ function evaluate() {
78
+ let hitsAt1 = 0;
79
+ let mrr = 0;
80
+ let flips = 0;
81
+ const rows = [];
82
+
83
+ for (const caseRow of GOLDEN) {
84
+ const firstStageTop = caseRow.candidates[0]?.id;
85
+ const { results, meta } = rerankPipelineSync(caseRow.query, caseRow.candidates, {
86
+ topK: 3,
87
+ toolName: caseRow.toolName,
88
+ });
89
+ const topId = results[0]?.id;
90
+ const relevant = new Set(caseRow.relevantIds);
91
+ const hit1 = relevant.has(topId);
92
+ if (hit1) hitsAt1 += 1;
93
+
94
+ let rr = 0;
95
+ for (let i = 0; i < results.length; i += 1) {
96
+ if (relevant.has(results[i].id)) {
97
+ rr = 1 / (i + 1);
98
+ break;
99
+ }
100
+ }
101
+ mrr += rr;
102
+ if (meta.rankDelta?.flipped || (firstStageTop && topId && firstStageTop !== topId)) flips += 1;
103
+
104
+ rows.push({
105
+ id: caseRow.id,
106
+ topId,
107
+ hitAt1: hit1,
108
+ mrr: rr,
109
+ stages: meta.stages,
110
+ flipped: Boolean(meta.rankDelta?.flipped || (firstStageTop !== topId)),
111
+ });
112
+ }
113
+
114
+ const n = GOLDEN.length;
115
+ const report = {
116
+ pipelineVersion: PIPELINE_VERSION,
117
+ cases: n,
118
+ precisionAt1: hitsAt1 / n,
119
+ mrr: mrr / n,
120
+ rankDeltaRate: flips / n,
121
+ floors: {
122
+ precisionAt1: 1.0,
123
+ mrr: 1.0,
124
+ rankDeltaRateMin: 0.5,
125
+ },
126
+ rows,
127
+ };
128
+
129
+ report.pass =
130
+ report.precisionAt1 >= report.floors.precisionAt1 &&
131
+ report.mrr >= report.floors.mrr &&
132
+ report.rankDeltaRate >= report.floors.rankDeltaRateMin;
133
+
134
+ return report;
135
+ }
136
+
137
+ function main() {
138
+ const report = evaluate();
139
+ console.log(JSON.stringify(report, null, 2));
140
+ if (!report.pass) {
141
+ console.error('rerank-quality-eval: FAILED floors');
142
+ process.exit(1);
143
+ }
144
+ console.error('rerank-quality-eval: PASS (bounded golden floors met)');
145
+ }
146
+
147
+ function isCliEntrypoint(argv = process.argv) {
148
+ return Boolean(argv[1]) && path.resolve(argv[1]) === path.resolve(__filename);
149
+ }
150
+
151
+ if (isCliEntrypoint()) {
152
+ main();
153
+ }
154
+
155
+ module.exports = { evaluate, GOLDEN, isCliEntrypoint };
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const {
8
+ retrieveRelevantLessons,
9
+ retrieveRelevantLessonsAsync,
10
+ } = require('./lesson-retrieval');
11
+ const { scoreRanking, aggregateRankingScores } = require('./ir-metrics');
12
+
13
+ const DEFAULT_FIXTURE_PATH = path.join(
14
+ __dirname,
15
+ '..',
16
+ 'config',
17
+ 'evals',
18
+ 'retrieval-hybrid-ablation.json',
19
+ );
20
+
21
+ function createFixtureEmbedder() {
22
+ const concepts = [
23
+ /(erase|directory|tree|rm|delete|folder|snapshot)/i,
24
+ /(overwrite|remote|history|force|push|clobber|trunk|repository)/i,
25
+ /(replay|purchase|twice|duplicate|payment|idempotency|charge)/i,
26
+ /(phone|expired|conversation|session|identifier|mobile|resume)/i,
27
+ /(dashboard|color|design|palette|visual|ui)/i,
28
+ ];
29
+ return async (text) => {
30
+ const vector = concepts.map((pattern) => (pattern.test(String(text || '')) ? 1 : 0));
31
+ if (vector.every((value) => value === 0)) vector.push(0.001);
32
+ else vector.push(0);
33
+ return vector;
34
+ };
35
+ }
36
+
37
+ async function evaluateHybridAblation(options = {}) {
38
+ const fixturePath = options.fixturePath || DEFAULT_FIXTURE_PATH;
39
+ const fixture = options.fixture || JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
40
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thumbgate-hybrid-ablation-'));
41
+ try {
42
+ fs.writeFileSync(
43
+ path.join(tempDir, 'memory-log.jsonl'),
44
+ `${fixture.corpus.map((row) => JSON.stringify({
45
+ ...row,
46
+ timestamp: row.timestamp || new Date().toISOString(),
47
+ })).join('\n')}\n`,
48
+ );
49
+ const kValues = fixture.kValues || [1, 3];
50
+ const embedder = options.embedder || createFixtureEmbedder();
51
+ const lexicalRows = [];
52
+ const hybridRows = [];
53
+ for (const queryCase of fixture.queries) {
54
+ const lexical = retrieveRelevantLessons(queryCase.toolName, queryCase.query, {
55
+ feedbackDir: tempDir,
56
+ maxResults: Math.max(...kValues),
57
+ pragmatic: true,
58
+ });
59
+ const hybrid = await retrieveRelevantLessonsAsync(queryCase.toolName, queryCase.query, {
60
+ feedbackDir: tempDir,
61
+ maxResults: Math.max(...kValues),
62
+ embedder,
63
+ embedderId: options.embedderId || 'deterministic-semantic-fixture',
64
+ includeRetrievalMeta: true,
65
+ });
66
+ lexicalRows.push({
67
+ id: queryCase.id,
68
+ rankedIds: lexical.map((row) => row.id),
69
+ metrics: scoreRanking(lexical, queryCase.qrels, { kValues }),
70
+ });
71
+ hybridRows.push({
72
+ id: queryCase.id,
73
+ rankedIds: hybrid.map((row) => row.id),
74
+ retrieval: hybrid[0]?.retrieval || null,
75
+ metrics: scoreRanking(hybrid, queryCase.qrels, { kValues }),
76
+ });
77
+ }
78
+ const lexical = aggregateRankingScores(lexicalRows.map((row) => row.metrics), { kValues });
79
+ const hybrid = aggregateRankingScores(hybridRows.map((row) => row.metrics), { kValues });
80
+ const summary = {
81
+ mode: options.embedderId || 'deterministic-semantic-fixture',
82
+ queries: fixture.queries.length,
83
+ lexical,
84
+ hybrid,
85
+ lift: {
86
+ mrr: hybrid.mrr - lexical.mrr,
87
+ recallAt3: hybrid['recall@3'] - lexical['recall@3'],
88
+ ndcgAt3: hybrid['ndcg@3'] - lexical['ndcg@3'],
89
+ },
90
+ };
91
+ return {
92
+ passed: hybrid['recall@3'] >= 0.9
93
+ && hybrid.mrr >= lexical.mrr
94
+ && hybrid['ndcg@3'] >= lexical['ndcg@3']
95
+ && hybridRows.every((row) => row.retrieval?.densePool > 0),
96
+ fixturePath,
97
+ summary,
98
+ lexicalRows,
99
+ hybridRows,
100
+ };
101
+ } finally {
102
+ fs.rmSync(tempDir, { recursive: true, force: true });
103
+ }
104
+ }
105
+
106
+ if (!module.parent) {
107
+ evaluateHybridAblation().then((result) => {
108
+ console.log(JSON.stringify(result, null, 2));
109
+ if (!result.passed) process.exitCode = 1;
110
+ }).catch((error) => {
111
+ console.error(error.message);
112
+ process.exitCode = 1;
113
+ });
114
+ }
115
+
116
+ module.exports = {
117
+ DEFAULT_FIXTURE_PATH,
118
+ createFixtureEmbedder,
119
+ evaluateHybridAblation,
120
+ };