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
@@ -3,11 +3,19 @@
3
3
 
4
4
  const fs = require('node:fs');
5
5
  const path = require('node:path');
6
+ const {
7
+ claimSupportScore,
8
+ evaluateAnswerQuality,
9
+ queryCoverage,
10
+ splitAnswerClaims,
11
+ } = require('./rag-structured-output');
6
12
 
7
13
  const DEFAULT_THRESHOLDS = {
8
14
  faithfulness: 0.72,
9
15
  answerRelevance: 0.45,
10
16
  contextPrecision: 0.5,
17
+ groundedness: 0.75,
18
+ citationPrecision: 1,
11
19
  };
12
20
 
13
21
  function tokenize(value) {
@@ -30,10 +38,7 @@ function overlapScore(left, right) {
30
38
  }
31
39
 
32
40
  function splitClaims(response) {
33
- return String(response || '')
34
- .split(/(?:[.!?]\s+|\n+)/)
35
- .map((claim) => claim.trim())
36
- .filter((claim) => claim.length > 0);
41
+ return splitAnswerClaims(response);
37
42
  }
38
43
 
39
44
  function normalizeContexts(contexts) {
@@ -44,11 +49,10 @@ function normalizeContexts(contexts) {
44
49
 
45
50
  function scoreFaithfulness(response, contexts) {
46
51
  const claims = splitClaims(response);
47
- const contextText = normalizeContexts(contexts).join('\n');
52
+ const contextItems = normalizeContexts(contexts);
48
53
  if (claims.length === 0) return { score: 0, supportedClaims: 0, totalClaims: 0 };
49
54
  const supportedClaims = claims.filter((claim) => {
50
- const normalized = claim.toLowerCase();
51
- return contextText.toLowerCase().includes(normalized) || overlapScore(claim, contextText) >= 0.58;
55
+ return contextItems.some((context) => claimSupportScore(claim, context) >= 0.4);
52
56
  }).length;
53
57
  return {
54
58
  score: Number((supportedClaims / claims.length).toFixed(4)),
@@ -58,7 +62,7 @@ function scoreFaithfulness(response, contexts) {
58
62
  }
59
63
 
60
64
  function scoreAnswerRelevance(question, response) {
61
- const score = overlapScore(question, response);
65
+ const score = queryCoverage(question, response);
62
66
  return {
63
67
  score: Number(score.toFixed(4)),
64
68
  matchedQuestionTerms: unique(tokenize(question).filter((token) => tokenize(response).includes(token))),
@@ -97,14 +101,29 @@ function evaluateGeneration(testCase, options = {}) {
97
101
  contexts,
98
102
  testCase.reference || testCase.groundTruth || ''
99
103
  );
104
+ const answerQuality = evaluateAnswerQuality({
105
+ query: testCase.question || testCase.user_input,
106
+ answer: testCase.response || testCase.answer,
107
+ referenceAnswer: testCase.reference || testCase.groundTruth || '',
108
+ citations: testCase.citations,
109
+ contexts: contexts.map((text, index) => ({ id: `context-${index + 1}`, text })),
110
+ }, {
111
+ minFaithfulness: thresholds.faithfulness,
112
+ minGroundedness: thresholds.groundedness,
113
+ minAnswerRelevance: thresholds.answerRelevance,
114
+ });
100
115
  const scores = {
101
116
  faithfulness: faithfulness.score,
102
117
  answerRelevance: answerRelevance.score,
103
118
  contextPrecision: contextPrecision.score,
119
+ groundedness: answerQuality.metrics.groundedness,
120
+ citationPrecision: answerQuality.metrics.citationPrecision,
104
121
  };
105
122
  const passed = scores.faithfulness >= thresholds.faithfulness
106
123
  && scores.answerRelevance >= thresholds.answerRelevance
107
- && scores.contextPrecision >= thresholds.contextPrecision;
124
+ && scores.contextPrecision >= thresholds.contextPrecision
125
+ && scores.groundedness >= thresholds.groundedness
126
+ && scores.citationPrecision >= thresholds.citationPrecision;
108
127
 
109
128
  return {
110
129
  id: String(testCase.id || testCase.traceId || 'case'),
@@ -116,6 +135,7 @@ function evaluateGeneration(testCase, options = {}) {
116
135
  faithfulness,
117
136
  answerRelevance,
118
137
  contextPrecision,
138
+ answerQuality,
119
139
  },
120
140
  };
121
141
  }
@@ -155,6 +175,8 @@ function buildEvalReport(cases, options = {}) {
155
175
  faithfulness: average(results.map((result) => result.scores.faithfulness)),
156
176
  answerRelevance: average(results.map((result) => result.scores.answerRelevance)),
157
177
  contextPrecision: average(results.map((result) => result.scores.contextPrecision)),
178
+ groundedness: average(results.map((result) => result.scores.groundedness)),
179
+ citationPrecision: average(results.map((result) => result.scores.citationPrecision)),
158
180
  };
159
181
 
160
182
  return {
@@ -166,7 +188,7 @@ function buildEvalReport(cases, options = {}) {
166
188
  passRate: results.length === 0 ? 0 : Number(((passed / results.length) * 100).toFixed(2)),
167
189
  aggregate,
168
190
  passedThreshold: failed === 0,
169
- metrics: ['faithfulness', 'answerRelevance', 'contextPrecision'],
191
+ metrics: ['faithfulness', 'answerRelevance', 'contextPrecision', 'groundedness', 'citationPrecision'],
170
192
  sinks: {
171
193
  ci: true,
172
194
  langsmithCompatible: true,
@@ -228,7 +250,10 @@ module.exports = {
228
250
  scoreFaithfulness,
229
251
  };
230
252
 
231
- if (require.main === module) {
253
+ const invokedDirectly = Boolean(process.argv[1])
254
+ && path.resolve(process.argv[1]) === __filename;
255
+
256
+ if (invokedDirectly) {
232
257
  main().catch((err) => {
233
258
  console.error(err.stack || err.message);
234
259
  process.exitCode = 1;
@@ -61,7 +61,18 @@ function recordAuditEvent(params = {}) {
61
61
  source: params.source || 'gates-engine',
62
62
  };
63
63
 
64
- fs.appendFileSync(logPath, JSON.stringify(record) + '\n');
64
+ // Safe stringify: never let circular/toxic tool inputs crash the gate path
65
+ // (Antithesis-style invariant: evaluation + audit must not throw).
66
+ let line;
67
+ try {
68
+ line = JSON.stringify(record);
69
+ } catch (err) {
70
+ line = JSON.stringify({
71
+ ...record,
72
+ toolInput: { _unserializable: true, reason: String(err.message || err).slice(0, 120) },
73
+ });
74
+ }
75
+ fs.appendFileSync(logPath, `${line}\n`);
65
76
  try {
66
77
  const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
67
78
  trainAndPersistInterventionPolicy(path.dirname(logPath));
@@ -73,10 +84,13 @@ function recordAuditEvent(params = {}) {
73
84
 
74
85
  /**
75
86
  * Strip secrets and large payloads from tool input before audit storage.
87
+ * Drop circular references so JSON.stringify never throws mid-gate evaluation.
76
88
  */
77
89
  function sanitizeToolInput(toolInput) {
90
+ if (!toolInput || typeof toolInput !== 'object') return {};
78
91
  const safe = {};
79
92
  const MAX_VALUE_LEN = 200;
93
+ const seen = new WeakSet();
80
94
 
81
95
  for (const [key, value] of Object.entries(toolInput)) {
82
96
  if (typeof value === 'string') {
@@ -88,6 +102,17 @@ function sanitizeToolInput(toolInput) {
88
102
  ? value.slice(0, MAX_VALUE_LEN) + '...'
89
103
  : value;
90
104
  }
105
+ } else if (value && typeof value === 'object') {
106
+ if (seen.has(value)) {
107
+ safe[key] = '[Circular]';
108
+ continue;
109
+ }
110
+ seen.add(value);
111
+ try {
112
+ safe[key] = JSON.parse(JSON.stringify(value, getCircularReplacer()));
113
+ } catch {
114
+ safe[key] = `[unserializable:${typeof value}]`;
115
+ }
91
116
  } else {
92
117
  safe[key] = value;
93
118
  }
@@ -95,6 +120,17 @@ function sanitizeToolInput(toolInput) {
95
120
  return safe;
96
121
  }
97
122
 
123
+ function getCircularReplacer() {
124
+ const seen = new WeakSet();
125
+ return function circularReplacer(_key, value) {
126
+ if (value && typeof value === 'object') {
127
+ if (seen.has(value)) return '[Circular]';
128
+ seen.add(value);
129
+ }
130
+ return value;
131
+ };
132
+ }
133
+
98
134
  // ---------------------------------------------------------------------------
99
135
  // Auto-feedback from audit events
100
136
  // ---------------------------------------------------------------------------
@@ -84,8 +84,32 @@ function regressionCheck(gate, options = {}) {
84
84
  let matchesGate;
85
85
  try { ({ matchesGate } = require('./gates-engine')); } catch { return null; }
86
86
  if (typeof matchesGate !== 'function') return null;
87
- const allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
87
+ let allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
88
88
  if (!allowed.length) return null;
89
+
90
+ // The command we just learned to block was, by definition, ALLOWED before we
91
+ // learned it — that prior allow IS the incident the operator thumbs-downed.
92
+ // Counting it as a false block quarantines every gate learned from a real
93
+ // failure, which is the normal path (run it, get burned, 👎 it). Exclude the
94
+ // originating contexts so the check only measures collateral damage to
95
+ // genuinely unrelated actions.
96
+ // Match on normalized command EQUALITY, not substring containment: a longer,
97
+ // genuinely different command that merely quotes the incident text (e.g.
98
+ // `notify-team --dry-run "<incident>"`) is real collateral damage and must
99
+ // still count toward quarantine.
100
+ const incidentSignatures = new Set(
101
+ (options.incidentContexts || [])
102
+ .map((c) => normalizeCommandSignature(String(c || '')))
103
+ .filter(Boolean),
104
+ );
105
+ if (incidentSignatures.size > 0) {
106
+ allowed = allowed.filter((e) => {
107
+ const cmd = (e.toolInput && (e.toolInput.command || e.toolInput.pattern)) || '';
108
+ return !incidentSignatures.has(normalizeCommandSignature(String(cmd)));
109
+ });
110
+ if (!allowed.length) return { falseBlocks: 0, allowSampleSize: 0 };
111
+ }
112
+
89
113
  let falseBlocks = 0;
90
114
  for (const e of allowed) {
91
115
  try {
@@ -163,14 +187,43 @@ function normalizeCommandSignature(input) {
163
187
  return tokens.join(' ').trim();
164
188
  }
165
189
 
166
- function extractPatternKey(entry) {
167
- // Use tags as primary grouping key; fall back to context normalization
168
- const tags = (entry.tags || []).filter((t) => !['feedback', 'negative', 'positive'].includes(t));
169
- if (tags.length > 0) return tags.sort().join('+');
190
+ /**
191
+ * Prefer an executable action we can match at PreToolUse time.
192
+ * Tag-only or pure prose feedback is useful memory — not an enforcement pattern.
193
+ */
194
+ function extractExecutableAction(entry) {
195
+ if (!entry || typeof entry !== 'object') return null;
196
+ const fromTool =
197
+ (entry.toolInput && (entry.toolInput.command || entry.toolInput.pattern))
198
+ || (entry.tool_input && (entry.tool_input.command || entry.tool_input.pattern))
199
+ || entry.command
200
+ || entry.failedCommand
201
+ || null;
202
+ if (fromTool && String(fromTool).trim().length >= 4) {
203
+ return String(fromTool).trim();
204
+ }
170
205
 
171
- const ctx = (entry.context || entry.whatWentWrong || '').trim();
172
- if (ctx.length < 10) return null;
173
- return normalizeCommandSignature(ctx).slice(0, 100);
206
+ const ctx = String(entry.context || entry.whatWentWrong || '').trim();
207
+ if (ctx.length < 4) return null;
208
+
209
+ // Looks like a shell / CLI invocation (not free-form prose).
210
+ const looksExecutable = /^(?:sudo\s+)?(?:~\/|\.\/|\/)?(?:[A-Za-z0-9._+-]+\/)*[A-Za-z0-9._+-]+(?:\s|$)/.test(ctx)
211
+ && /\s|^[a-z0-9._+-]+(?:\s|$)/i.test(ctx)
212
+ && !/\s+(?:broke|failed|wrong|should|never|please|the agent)\b/i.test(ctx.slice(0, 80));
213
+ // Strong signal: known tool prefixes
214
+ const known = /^(?:sudo\s+)?(?:kubectl|git|npm|npx|yarn|pnpm|python|python3|node|curl|wget|docker|podman|rm|mv|cp|chmod|chown|psql|mysql|mongo|terraform|pulumi|aws|gcloud|az|helm|ssh|scp|rsync|make|cargo|go|ruby|perl|bash|sh|zsh)\b/i.test(ctx);
215
+ if (known || (looksExecutable && /[\s-]/.test(ctx) && ctx.length <= 200)) {
216
+ return ctx;
217
+ }
218
+ return null;
219
+ }
220
+
221
+ function extractPatternKey(entry) {
222
+ // Enforcement groups by executable action only. Tags remain diagnostic metadata
223
+ // and must not create hard-block thresholds for a single unrelated latest command.
224
+ const action = extractExecutableAction(entry);
225
+ if (!action) return null;
226
+ return normalizeCommandSignature(action).slice(0, 100);
174
227
  }
175
228
 
176
229
  function extractDiagnosticKeys(entry) {
@@ -203,27 +256,29 @@ function groupNegativeFeedback(entries, windowDays) {
203
256
  const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
204
257
  if (ts < cutoff) continue;
205
258
 
206
- const keys = [extractPatternKey(entry), ...extractDiagnosticKeys(entry)]
207
- .filter(Boolean)
208
- .filter((value, index, values) => values.indexOf(value) === index);
209
- if (keys.length === 0) continue;
210
-
211
- for (const key of keys) {
212
- if (!groups[key]) {
213
- groups[key] = {
214
- key,
215
- count: 0,
216
- entries: [],
217
- latestContext: '',
218
- latestTimestamp: '',
219
- };
220
- }
221
- groups[key].count++;
222
- groups[key].entries.push(entry);
223
- if (!groups[key].latestTimestamp || (entry.timestamp && entry.timestamp > groups[key].latestTimestamp)) {
224
- groups[key].latestTimestamp = entry.timestamp || '';
225
- groups[key].latestContext = entry.context || entry.whatWentWrong || '';
226
- }
259
+ // Enforcement groups ONLY by executable action. Tag/diagnosis metadata is
260
+ // useful for memory and dashboards, but must not create hard-block thresholds
261
+ // that attach to an unrelated latest command.
262
+ const key = extractPatternKey(entry);
263
+ if (!key) continue;
264
+
265
+ if (!groups[key]) {
266
+ groups[key] = {
267
+ key,
268
+ count: 0,
269
+ entries: [],
270
+ latestContext: '',
271
+ latestTimestamp: '',
272
+ latestExecutable: '',
273
+ };
274
+ }
275
+ groups[key].count++;
276
+ groups[key].entries.push(entry);
277
+ if (!groups[key].latestTimestamp || (entry.timestamp && entry.timestamp > groups[key].latestTimestamp)) {
278
+ groups[key].latestTimestamp = entry.timestamp || '';
279
+ const action = extractExecutableAction(entry);
280
+ groups[key].latestContext = action || entry.context || entry.whatWentWrong || '';
281
+ groups[key].latestExecutable = action || '';
227
282
  }
228
283
  }
229
284
 
@@ -234,15 +289,48 @@ function patternToGateId(key) {
234
289
  return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
235
290
  }
236
291
 
292
+ /**
293
+ * Turn a captured context string into a pattern the gates engine can actually
294
+ * match. `gates-engine.js` compiles `gate.pattern` with `new RegExp(...)` and
295
+ * tests it against the tool-call text, so the pattern MUST be regex-safe text
296
+ * drawn from the command itself.
297
+ *
298
+ * It must NOT be the group key: keys are frequently tag-derived
299
+ * ("entity:Customer+entity:Funnel"), which is both meaningless against a command
300
+ * string and actively hazardous as a regex ('+' is a quantifier). Grouping by tag
301
+ * is correct — reusing that key as the match pattern is not.
302
+ */
303
+ function contextToPattern(context) {
304
+ const raw = String(context || '').trim();
305
+ if (raw.length < 4) return null;
306
+ // Escape every regex metacharacter: the captured command is literal text.
307
+ return raw.slice(0, 120).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
308
+ }
309
+
310
+ /**
311
+ * A gate that cannot match the very context that produced it is inert — it
312
+ * shows up in the dashboard as an active blocking rule while enforcing nothing.
313
+ * That failure mode is worse than no gate at all, so callers drop these.
314
+ */
315
+ function gateMatchesOwnContext(gate, context) {
316
+ if (!gate || !gate.pattern) return false;
317
+ try {
318
+ return new RegExp(gate.pattern).test(String(context || ''));
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
237
324
  function buildGateRule(group, actionOverride) {
238
325
  const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
239
326
  const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
240
- const context = group.latestContext.slice(0, 120);
327
+ const executable = (group.latestExecutable || extractExecutableAction({ context: group.latestContext }) || group.latestContext || '').slice(0, 120);
328
+ const context = executable;
241
329
  const kind = group.key.startsWith('diagnosis:')
242
330
  ? 'repeated diagnosis'
243
331
  : group.key.startsWith('constraint:')
244
332
  ? 'repeated constraint violation'
245
- : 'repeated pattern';
333
+ : 'repeated executable action';
246
334
 
247
335
  const occurrencesText = group.count === 'MANUAL' ? 'manual' : `${group.count} occurrences`;
248
336
  const suggestedMessage = `Auto-promoted ${kind}: "${context}" (${occurrencesText} in ${WINDOW_DAYS} days)`;
@@ -257,7 +345,8 @@ function buildGateRule(group, actionOverride) {
257
345
  return {
258
346
  id: patternToGateId(group.key),
259
347
  trigger: `auto:${group.key}`,
260
- pattern: group.key.replace(/^diagnosis:|constraint:/, ''),
348
+ // Derived from the executable action, NOT from tag keys — see contextToPattern.
349
+ pattern: contextToPattern(executable),
261
350
  action,
262
351
  message: suggestedMessage,
263
352
  severity,
@@ -393,6 +482,16 @@ function promote(feedbackLogPath, options) {
393
482
 
394
483
  const gateId = patternToGateId(group.key);
395
484
 
485
+ // Contexts that produced this gate. Their prior "allow" decisions are the
486
+ // incident the operator thumbs-downed, not false positives — both the
487
+ // new-gate and the warn->block upgrade path must exclude them from the
488
+ // regression check, or every gate learned from a real failure is held at warn.
489
+ const incidentContexts = [
490
+ group.latestContext,
491
+ ...(group.entries || []).map((e) => e && (e.context || e.whatWentWrong)),
492
+ ].filter(Boolean);
493
+ const regressionOpts = { ...opts, incidentContexts };
494
+
396
495
  // Check for existing gate — possibly upgrade
397
496
  const existingIdx = data.gates.findIndex((g) => g.id === gateId);
398
497
  if (existingIdx !== -1) {
@@ -400,7 +499,7 @@ function promote(feedbackLogPath, options) {
400
499
  const newAction = group.count >= BLOCK_THRESHOLD ? 'block' : 'warn';
401
500
  if (existing.action !== newAction && newAction === 'block') {
402
501
  // Self-Harness stage 3: regression-test before upgrading warn -> block.
403
- const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), opts);
502
+ const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), regressionOpts);
404
503
  if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
405
504
  // Would block prior safe actions — hold at warn instead of upgrading.
406
505
  promotions.push({ type: 'upgrade-quarantined', gateId, from: existing.action, occurrences: group.count, falseBlocks: regression.falseBlocks });
@@ -418,12 +517,25 @@ function promote(feedbackLogPath, options) {
418
517
  // New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
419
518
  const gate = buildGateRule(group, opts.gateAction);
420
519
 
520
+ // Never persist a gate that cannot match the context that produced it. Such a
521
+ // gate renders in the dashboard as an active blocking rule while enforcing
522
+ // nothing, which reads as "the agent learned" when it did not.
523
+ if (!gateMatchesOwnContext(gate, group.latestContext)) {
524
+ promotions.push({
525
+ type: 'skipped-unmatchable',
526
+ gateId: gate.id,
527
+ reason: 'derived pattern does not match originating context',
528
+ occurrences: group.count,
529
+ });
530
+ continue;
531
+ }
532
+
421
533
  // Self-Harness stage 3: before a feedback rule goes live as a hard block,
422
534
  // regression-test it against prior allowed actions. If it would have blocked
423
535
  // safe actions, quarantine it to `warn` instead of `block`.
424
536
  let regression = null;
425
537
  if (gate.action === 'block' && !opts.gateAction && !opts.skipRegression) {
426
- regression = safeRegressionCheck(gate, opts);
538
+ regression = safeRegressionCheck(gate, regressionOpts);
427
539
  if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
428
540
  gate.action = 'warn';
429
541
  gate.severity = 'medium';
@@ -506,10 +618,13 @@ module.exports = {
506
618
  groupNegativeFeedback,
507
619
  patternToGateId,
508
620
  buildGateRule,
621
+ contextToPattern,
622
+ gateMatchesOwnContext,
509
623
  regressionCheck,
510
624
  getAuditTrailPath,
511
625
  REGRESSION_FALSE_BLOCK_LIMIT,
512
626
  extractPatternKey,
627
+ extractExecutableAction,
513
628
  normalizeCommandSignature,
514
629
  isNegative,
515
630
  expireGates,
@@ -0,0 +1,236 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ColBERT-style late interaction (MaxSim) for ThumbGate lesson reranking.
5
+ *
6
+ * This is NOT a pretrained ColBERT neural model. It implements the *interaction
7
+ * pattern* ColBERT made famous:
8
+ * - encode query and document as multi-vector bags (one vector per token)
9
+ * - score with MaxSim: sum over query tokens of max cosine vs any doc token
10
+ *
11
+ * Token vectors are deterministic hashed character-n-gram projections (local-only,
12
+ * no GPU, no network). That gives late interaction without shipping a 100MB model
13
+ * in the npm package — while remaining honest about model provenance.
14
+ *
15
+ * For true neural ColBERT, operators can plug vectors via `tokenEmbedder`.
16
+ *
17
+ * @see https://arxiv.org/abs/2004.12832 (ColBERT MaxSim)
18
+ */
19
+
20
+ const DEFAULT_DIM = 32;
21
+ const DEFAULT_NGRAM = 3;
22
+
23
+ /**
24
+ * Deterministic 32-bit hash (FNV-1a style) for seedable projections.
25
+ * @param {string} str
26
+ * @returns {number}
27
+ */
28
+ function hash32(str) {
29
+ let h = 0x811c9dc5;
30
+ for (let i = 0; i < str.length; i += 1) {
31
+ h ^= str.charCodeAt(i);
32
+ h = Math.imul(h, 0x01000193);
33
+ }
34
+ return h >>> 0;
35
+ }
36
+
37
+ /**
38
+ * Tokenize into lowercase word tokens (length >= 2).
39
+ * @param {string} text
40
+ * @returns {string[]}
41
+ */
42
+ function tokenize(text) {
43
+ if (!text) return [];
44
+ return String(text)
45
+ .toLowerCase()
46
+ .replace(/[^\w\s]/g, ' ')
47
+ .split(/[\s_]+/)
48
+ .filter((t) => t.length >= 2);
49
+ }
50
+
51
+ /**
52
+ * Character n-grams for a token (with edge markers).
53
+ * @param {string} token
54
+ * @param {number} n
55
+ * @returns {string[]}
56
+ */
57
+ function charNgrams(token, n = DEFAULT_NGRAM) {
58
+ const padded = `#${token}#`;
59
+ if (padded.length < n) return [padded];
60
+ const grams = [];
61
+ for (let i = 0; i <= padded.length - n; i += 1) {
62
+ grams.push(padded.slice(i, i + n));
63
+ }
64
+ return grams;
65
+ }
66
+
67
+ /**
68
+ * Build a unit-length multi-dim embedding for one token via hashed n-grams.
69
+ * @param {string} token
70
+ * @param {{ dim?: number, ngram?: number }} [opts]
71
+ * @returns {Float64Array}
72
+ */
73
+ function embedToken(token, opts = {}) {
74
+ const dim = opts.dim ?? DEFAULT_DIM;
75
+ const ngram = opts.ngram ?? DEFAULT_NGRAM;
76
+ const vec = new Float64Array(dim);
77
+ for (const gram of charNgrams(token, ngram)) {
78
+ const h = hash32(gram);
79
+ const idx = h % dim;
80
+ const sign = (h & 1) === 0 ? 1 : -1;
81
+ vec[idx] += sign;
82
+ // Second hash for denser projection (locality-sensitive bag)
83
+ const h2 = hash32(`${gram}:2`);
84
+ vec[h2 % dim] += ((h2 >> 1) & 1) === 0 ? 0.5 : -0.5;
85
+ }
86
+ // L2 normalize
87
+ let norm = 0;
88
+ for (let i = 0; i < dim; i += 1) norm += vec[i] * vec[i];
89
+ norm = Math.sqrt(norm) || 1;
90
+ for (let i = 0; i < dim; i += 1) vec[i] /= norm;
91
+ return vec;
92
+ }
93
+
94
+ /**
95
+ * Cosine similarity for unit vectors (dot product).
96
+ * @param {Float64Array|number[]} a
97
+ * @param {Float64Array|number[]} b
98
+ * @returns {number}
99
+ */
100
+ function cosine(a, b) {
101
+ const n = Math.min(a.length, b.length);
102
+ let s = 0;
103
+ for (let i = 0; i < n; i += 1) s += a[i] * b[i];
104
+ return s;
105
+ }
106
+
107
+ /**
108
+ * Encode text as a bag of token vectors (ColBERT multi-vector representation).
109
+ * @param {string} text
110
+ * @param {{ dim?: number, ngram?: number, maxTokens?: number, tokenEmbedder?: (t: string) => Float64Array|number[] }} [opts]
111
+ * @returns {{ tokens: string[], vectors: Array<Float64Array|number[]> }}
112
+ */
113
+ function encodeMultiVector(text, opts = {}) {
114
+ const maxTokens = opts.maxTokens ?? 64;
115
+ const tokens = tokenize(text).slice(0, maxTokens);
116
+ const embedder = opts.tokenEmbedder || ((t) => embedToken(t, opts));
117
+ const vectors = tokens.map((t) => embedder(t));
118
+ return { tokens, vectors };
119
+ }
120
+
121
+ /**
122
+ * ColBERT MaxSim: Σ_i max_j cos(q_i, d_j), normalized by |Q|.
123
+ * @param {Array<Float64Array|number[]>} queryVectors
124
+ * @param {Array<Float64Array|number[]>} docVectors
125
+ * @returns {number} score in [0, 1] approximately
126
+ */
127
+ function maxSim(queryVectors, docVectors) {
128
+ if (!queryVectors.length || !docVectors.length) return 0;
129
+ let total = 0;
130
+ for (const q of queryVectors) {
131
+ let best = -1;
132
+ for (const d of docVectors) {
133
+ const c = cosine(q, d);
134
+ if (c > best) best = c;
135
+ }
136
+ total += Math.max(0, best);
137
+ }
138
+ // Normalize by query length so longer queries don't dominate
139
+ return Math.min(1, total / queryVectors.length);
140
+ }
141
+
142
+ /**
143
+ * Score a (query, document) pair with ColBERT-style late interaction.
144
+ * @param {string} query
145
+ * @param {string} document
146
+ * @param {object} [opts]
147
+ * @returns {{ score: number, queryTokens: string[], docTokens: string[], mode: string }}
148
+ */
149
+ function scoreLateInteraction(query, document, opts = {}) {
150
+ const q = encodeMultiVector(query, opts);
151
+ const d = encodeMultiVector(document, opts);
152
+ const score = maxSim(q.vectors, d.vectors);
153
+ return {
154
+ score: Number(score.toFixed(6)),
155
+ queryTokens: q.tokens,
156
+ docTokens: d.tokens,
157
+ mode: opts.tokenEmbedder ? 'colbert-style-external' : 'colbert-style-hash',
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Rerank candidates by MaxSim late interaction.
163
+ * @param {string} query
164
+ * @param {Array<object>} candidates
165
+ * @param {{ topK?: number, textOf?: (c: object) => string, blendWeight?: number }} [options]
166
+ * @returns {Array<object>} candidates with maxSimScore + optional blend into rerankedScore
167
+ */
168
+ function rerankWithMaxSim(query, candidates, options = {}) {
169
+ const {
170
+ topK = 5,
171
+ textOf = defaultTextOf,
172
+ blendWeight = 0.55,
173
+ dim,
174
+ ngram,
175
+ maxTokens,
176
+ tokenEmbedder,
177
+ } = options;
178
+
179
+ if (!candidates || candidates.length === 0) return [];
180
+ if (candidates.length === 1) {
181
+ const only = candidates[0];
182
+ return [{
183
+ ...only,
184
+ maxSimScore: 1,
185
+ rerankedScore: only.rerankedScore ?? only.relevanceScore ?? 1,
186
+ lateInteractionMode: 'trivial',
187
+ }].slice(0, topK);
188
+ }
189
+
190
+ const qEnc = encodeMultiVector(query, { dim, ngram, maxTokens, tokenEmbedder });
191
+ const scored = candidates.map((c) => {
192
+ const docText = textOf(c);
193
+ const dEnc = encodeMultiVector(docText, { dim, ngram, maxTokens, tokenEmbedder });
194
+ const ms = maxSim(qEnc.vectors, dEnc.vectors);
195
+ const orig = Number(c.rerankedScore ?? c.relevanceScore ?? c.score ?? 0);
196
+ const blended = blendWeight * ms + (1 - blendWeight) * Math.max(0, Math.min(1, orig));
197
+ return {
198
+ ...c,
199
+ maxSimScore: Number(ms.toFixed(6)),
200
+ rerankedScore: Number(blended.toFixed(6)),
201
+ lateInteractionMode: tokenEmbedder ? 'colbert-style-external' : 'colbert-style-hash',
202
+ };
203
+ });
204
+
205
+ return scored
206
+ .sort((a, b) => (b.rerankedScore || 0) - (a.rerankedScore || 0))
207
+ .slice(0, topK);
208
+ }
209
+
210
+ function defaultTextOf(candidate) {
211
+ if (!candidate || typeof candidate !== 'object') return String(candidate || '');
212
+ return [
213
+ candidate.title,
214
+ candidate.whatWentWrong,
215
+ candidate.whatToChange,
216
+ candidate.howToAvoid,
217
+ candidate.summary,
218
+ candidate.content,
219
+ candidate.context,
220
+ Array.isArray(candidate.tags) ? candidate.tags.join(' ') : '',
221
+ ].filter(Boolean).join(' ');
222
+ }
223
+
224
+ module.exports = {
225
+ hash32,
226
+ tokenize,
227
+ charNgrams,
228
+ embedToken,
229
+ cosine,
230
+ encodeMultiVector,
231
+ maxSim,
232
+ scoreLateInteraction,
233
+ rerankWithMaxSim,
234
+ DEFAULT_DIM,
235
+ DEFAULT_NGRAM,
236
+ };