thumbgate 1.29.2 → 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 (111) 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 +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  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 +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -0,0 +1,178 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Production request envelope — one schema for LLM + retrieval observability.
5
+ *
6
+ * Every dashboard chat / routed generation path should create an envelope at
7
+ * start and finalize it before return so latency, cost, retrieval, and
8
+ * structured-output status are greppable from one object.
9
+ *
10
+ * Privacy: never store full prompts/tool payloads here; use redacted previews only.
11
+ */
12
+
13
+ const crypto = require('node:crypto');
14
+ const {
15
+ buildAgentAuditSpan,
16
+ evaluateAgentAuditTrace,
17
+ } = require('./agent-audit-trace');
18
+
19
+ const ENVELOPE_VERSION = '2026-07-31.p0.1';
20
+
21
+ function newTraceId() {
22
+ if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
23
+ return `tr_${Date.now().toString(36)}_${crypto.randomBytes(6).toString('hex')}`;
24
+ }
25
+
26
+ function hashSensitiveText(text) {
27
+ return crypto.createHash('sha256').update(String(text || '')).digest('hex');
28
+ }
29
+
30
+ /**
31
+ * @param {object} [seed]
32
+ * @returns {object}
33
+ */
34
+ function createRequestEnvelope(seed = {}) {
35
+ const startedAt = Number.isFinite(seed.startedAt) ? seed.startedAt : Date.now();
36
+ const traceId = seed.traceId || newTraceId();
37
+ const auditTrace = seed.promptHash
38
+ ? {
39
+ runId: traceId,
40
+ spans: [buildAgentAuditSpan({
41
+ runId: traceId,
42
+ spanId: `${traceId}:input`,
43
+ stage: 'input',
44
+ promptHash: seed.promptHash,
45
+ model: seed.model || null,
46
+ })],
47
+ }
48
+ : null;
49
+ return {
50
+ envelopeVersion: ENVELOPE_VERSION,
51
+ traceId,
52
+ startedAt,
53
+ endedAt: null,
54
+ latencyMs: null,
55
+ surface: seed.surface || 'unknown',
56
+ model: seed.model || null,
57
+ tier: seed.tier || null,
58
+ provider: seed.provider || null,
59
+ inputTokens: seed.inputTokens ?? null,
60
+ outputTokens: seed.outputTokens ?? null,
61
+ estimatedCostCents: seed.estimatedCostCents ?? null,
62
+ budget: seed.budget || null,
63
+ retrieval: seed.retrieval || null,
64
+ structured: seed.structured || null,
65
+ qualityTier: seed.qualityTier || null,
66
+ outcome: seed.outcome || 'pending',
67
+ error: seed.error || null,
68
+ auditTrace,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Finalize timing + optional fields. Pure-ish: returns a new object.
74
+ * @param {object} envelope
75
+ * @param {object} [patch]
76
+ */
77
+ function finalizeRequestEnvelope(envelope, patch = {}) {
78
+ const endedAt = Number.isFinite(patch.endedAt) ? patch.endedAt : Date.now();
79
+ const startedAt = Number(envelope?.startedAt) || endedAt;
80
+ const finalized = {
81
+ ...envelope,
82
+ ...patch,
83
+ endedAt,
84
+ latencyMs: Math.max(0, endedAt - startedAt),
85
+ outcome: patch.outcome || envelope?.outcome || 'ok',
86
+ };
87
+
88
+ if (envelope?.auditTrace?.runId && Array.isArray(envelope.auditTrace.spans)) {
89
+ const evidenceIds = (patch.retrieval?.top || [])
90
+ .map((row) => row?.id)
91
+ .filter(Boolean);
92
+ const priorSpans = envelope.auditTrace.spans
93
+ .filter((span) => span?.stage !== 'decision');
94
+ const decisionSpan = buildAgentAuditSpan({
95
+ runId: envelope.auditTrace.runId,
96
+ spanId: `${envelope.auditTrace.runId}:decision`,
97
+ parentSpanId: priorSpans[0]?.spanId || null,
98
+ stage: 'decision',
99
+ model: finalized.model,
100
+ decision: finalized.outcome,
101
+ dataAccessed: evidenceIds.length ? ['retrieved_lessons'] : [],
102
+ evidenceIds,
103
+ safetyEvents: finalized.error ? [finalized.error] : [],
104
+ inputTokens: finalized.inputTokens,
105
+ outputTokens: finalized.outputTokens,
106
+ latencyMs: finalized.latencyMs,
107
+ });
108
+ const auditTrace = {
109
+ runId: envelope.auditTrace.runId,
110
+ spans: [...priorSpans, decisionSpan],
111
+ };
112
+ finalized.auditTrace = {
113
+ ...auditTrace,
114
+ evaluation: evaluateAgentAuditTrace(auditTrace),
115
+ };
116
+ }
117
+
118
+ return finalized;
119
+ }
120
+
121
+ /**
122
+ * Compact retrieval summary for the envelope (no lesson bodies).
123
+ * @param {Array<object>} rows
124
+ * @param {object} [meta]
125
+ */
126
+ function summarizeRetrieval(rows = [], meta = {}) {
127
+ const top = (rows || []).slice(0, 8).map((r, i) => {
128
+ const rawScore = r.rerankedScore ?? r.relevanceScore ?? r.score;
129
+ const numericScore = Number(rawScore);
130
+ return {
131
+ rank: i + 1,
132
+ id: r.id || r.memoryId || null,
133
+ score: rawScore == null || !Number.isFinite(numericScore) ? null : numericScore,
134
+ signal: r.signal || null,
135
+ };
136
+ });
137
+ return {
138
+ strategy: meta.strategy || meta.retrievalStrategy || null,
139
+ count: Array.isArray(rows) ? rows.length : 0,
140
+ qualityTier: meta.qualityTier || null,
141
+ degradedReasons: meta.degradedReasons || [],
142
+ top,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Estimate tokens from text length (rough, offline-safe).
148
+ * @param {string} text
149
+ */
150
+ function estimateTokensFromText(text) {
151
+ const s = String(text || '');
152
+ if (!s) return 0;
153
+ return Math.max(1, Math.ceil(s.length / 4));
154
+ }
155
+
156
+ /**
157
+ * Rough USD cents from token counts using Sonnet-ish defaults (conservative).
158
+ * @param {{ inputTokens?: number, outputTokens?: number, inputPerM?: number, outputPerM?: number }} opts
159
+ */
160
+ function estimateCostCents(opts = {}) {
161
+ const input = Number(opts.inputTokens) || 0;
162
+ const output = Number(opts.outputTokens) || 0;
163
+ const inputPerM = Number(opts.inputPerM) || 3;
164
+ const outputPerM = Number(opts.outputPerM) || 15;
165
+ const usd = (input / 1e6) * inputPerM + (output / 1e6) * outputPerM;
166
+ return Number((usd * 100).toFixed(4));
167
+ }
168
+
169
+ module.exports = {
170
+ ENVELOPE_VERSION,
171
+ newTraceId,
172
+ hashSensitiveText,
173
+ createRequestEnvelope,
174
+ finalizeRequestEnvelope,
175
+ summarizeRetrieval,
176
+ estimateTokensFromText,
177
+ estimateCostCents,
178
+ };
@@ -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 };