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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +143 -14
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +154 -36
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +215 -185
- package/config/model-tiers.json +7 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/glama.json +6 -0
- package/package.json +94 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +22 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +40 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +143 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/public/whitepaper.html +189 -0
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +359 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +359 -189
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +98 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +375 -32
- package/scripts/lesson-search.js +48 -11
- package/scripts/llm-client.js +304 -15
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/model-tier-router.js +593 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/published-cli.js +11 -1
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +95 -1
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- 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 };
|