thumbgate 1.29.2 → 1.30.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/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +78 -7
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +7 -5
- package/config/mcp-allowlists.json +26 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/package.json +38 -7
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +21 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/loop.svg +34 -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/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +4 -3
- package/public/numbers.html +2 -2
- package/public/whitepaper.html +189 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/cross-encoder-reranker.js +3 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +244 -182
- package/scripts/gates-engine.js +81 -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/jsonl-watcher.js +1 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-retrieval.js +71 -4
- package/scripts/lesson-search.js +26 -3
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/published-cli.js +11 -1
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +49 -1
- package/src/api/server.js +230 -86
package/scripts/lesson-search.js
CHANGED
|
@@ -4,6 +4,7 @@ const path = require('node:path');
|
|
|
4
4
|
const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
|
|
5
5
|
const { buildMemoryLifecycleView, scoreHybridMemoryMatch } = require('./agent-memory-lifecycle');
|
|
6
6
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
7
|
+
const { selectRetrievalMemories } = require('./lesson-retrieval');
|
|
7
8
|
|
|
8
9
|
const HIGH_RISK_TAGS = new Set([
|
|
9
10
|
'billing',
|
|
@@ -481,7 +482,8 @@ function searchLessons(query = '', options = {}) {
|
|
|
481
482
|
const sqliteResults = tryFts5Search(query, options);
|
|
482
483
|
if (sqliteResults) return sqliteResults;
|
|
483
484
|
|
|
484
|
-
const
|
|
485
|
+
const allMemories = readJSONL(MEMORY_LOG_PATH);
|
|
486
|
+
const memories = selectRetrievalMemories(allMemories, options);
|
|
485
487
|
const feedbackEntries = readJSONL(FEEDBACK_LOG_PATH);
|
|
486
488
|
const feedbackById = new Map(feedbackEntries.map((entry) => [entry.id, entry]));
|
|
487
489
|
const parsedLimit = Number(options.limit || 10);
|
|
@@ -534,9 +536,12 @@ function searchLessons(query = '', options = {}) {
|
|
|
534
536
|
filters: {
|
|
535
537
|
category: category || null,
|
|
536
538
|
tags: requiredTags,
|
|
539
|
+
scope: options.scope || null,
|
|
540
|
+
requireScope: options.requireScope === true,
|
|
537
541
|
},
|
|
538
542
|
feedbackDir: FEEDBACK_DIR,
|
|
539
543
|
totalLessons: memories.length,
|
|
544
|
+
excludedLessons: allMemories.length - memories.length,
|
|
540
545
|
returned: Math.min(limit, results.length),
|
|
541
546
|
results: results.slice(0, limit),
|
|
542
547
|
backend: 'jsonl-jaccard',
|
|
@@ -548,6 +553,10 @@ function searchLessons(query = '', options = {}) {
|
|
|
548
553
|
* or not opted in. Set LESSON_DB_SEARCH=1 to enable FTS5 as primary backend.
|
|
549
554
|
*/
|
|
550
555
|
function tryFts5Search(query, options) {
|
|
556
|
+
// The SQLite index does not currently carry the complete four-field scope
|
|
557
|
+
// contract. Fall back to JSONL whenever isolation is requested rather than
|
|
558
|
+
// silently searching across tenants or sessions.
|
|
559
|
+
if (options.scope || options.requireScope) return null;
|
|
551
560
|
if (!process.env.LESSON_DB_SEARCH && !options.useFts5) return null;
|
|
552
561
|
try {
|
|
553
562
|
const { initDB, searchLessons: fts5Search, getStats } = require('./lesson-db');
|
|
@@ -567,11 +576,24 @@ function tryFts5Search(query, options) {
|
|
|
567
576
|
.map((tag) => tag.trim())
|
|
568
577
|
.filter(Boolean);
|
|
569
578
|
|
|
570
|
-
const
|
|
571
|
-
limit,
|
|
579
|
+
const candidateRows = fts5Search(db, query || '', {
|
|
580
|
+
limit: Math.max(limit * 5, 50),
|
|
572
581
|
signal,
|
|
573
582
|
tags: requiredTags.length > 0 ? requiredTags : undefined,
|
|
574
583
|
});
|
|
584
|
+
const retrievableRows = selectRetrievalMemories(
|
|
585
|
+
candidateRows.map((row) => ({
|
|
586
|
+
...row,
|
|
587
|
+
title: row.context || '',
|
|
588
|
+
content: [
|
|
589
|
+
row.whatWentWrong,
|
|
590
|
+
row.whatToChange,
|
|
591
|
+
row.whatWorked,
|
|
592
|
+
].filter(Boolean).join('\n'),
|
|
593
|
+
})),
|
|
594
|
+
options,
|
|
595
|
+
);
|
|
596
|
+
const rows = retrievableRows.slice(0, limit);
|
|
575
597
|
|
|
576
598
|
return {
|
|
577
599
|
query: String(query || ''),
|
|
@@ -581,6 +603,7 @@ function tryFts5Search(query, options) {
|
|
|
581
603
|
tags: requiredTags,
|
|
582
604
|
},
|
|
583
605
|
totalLessons: stats.total,
|
|
606
|
+
excludedLessons: candidateRows.length - retrievableRows.length,
|
|
584
607
|
returned: rows.length,
|
|
585
608
|
results: rows.map((row) => ({
|
|
586
609
|
id: row.id,
|
package/scripts/mcp-config.js
CHANGED
|
@@ -193,17 +193,37 @@ function publishedCliAvailable(pkgVersion) {
|
|
|
193
193
|
return cliAvailabilityCache.get(pkgVersion);
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Project-scope entries land in COMMITTED, SHARED config (.mcp.json / .cursor/mcp.json —
|
|
198
|
+
* init's own banner says the file serves every agent on the repo). A machine-absolute path
|
|
199
|
+
* there is a bug by construction: run init on machine A (or a Cowork sandbox with a home
|
|
200
|
+
* like /Users/busy-clever-newton) and the committed config breaks for every other machine,
|
|
201
|
+
* teammate, and CI runner. Observed for real on 2026-07-29.
|
|
202
|
+
*
|
|
203
|
+
* So: absolute paths may only ever go to HOME-scope config (machine-local by definition).
|
|
204
|
+
* Project scope gets a repo-relative path when the project IS the ThumbGate checkout
|
|
205
|
+
* (dogfooding unpublished source still works — project MCP servers launch with cwd at the
|
|
206
|
+
* project root), and the portable npx launcher otherwise.
|
|
207
|
+
*/
|
|
208
|
+
function relativeLocalMcpEntry(pkgRoot, targetDir) {
|
|
209
|
+
const rel = path.relative(targetDir, resolveLocalServerPath(pkgRoot, 'project'));
|
|
210
|
+
// Committed config must be separator-portable too.
|
|
211
|
+
return { command: 'node', args: [rel.split(path.sep).join('/')] };
|
|
212
|
+
}
|
|
213
|
+
|
|
196
214
|
function resolveMcpEntry({ pkgRoot, pkgVersion, scope = 'project', targetDir = pkgRoot }) {
|
|
197
215
|
if (!isSourceCheckout(pkgRoot)) {
|
|
198
216
|
return codexAutoUpdateMcpEntry();
|
|
199
217
|
}
|
|
200
|
-
if (scope === 'home'
|
|
201
|
-
return codexAutoUpdateMcpEntry();
|
|
218
|
+
if (scope === 'home') {
|
|
219
|
+
if (publishedCliAvailable(pkgVersion)) return codexAutoUpdateMcpEntry();
|
|
220
|
+
return localMcpEntry(pkgRoot, scope);
|
|
202
221
|
}
|
|
203
|
-
|
|
204
|
-
|
|
222
|
+
// scope === 'project': this is going into shared, committed config.
|
|
223
|
+
if (isSameCheckoutFamily(pkgRoot, targetDir)) {
|
|
224
|
+
return relativeLocalMcpEntry(pkgRoot, targetDir);
|
|
205
225
|
}
|
|
206
|
-
return
|
|
226
|
+
return codexAutoUpdateMcpEntry();
|
|
207
227
|
}
|
|
208
228
|
|
|
209
229
|
module.exports = {
|
|
@@ -214,6 +234,7 @@ module.exports = {
|
|
|
214
234
|
localMcpEntry,
|
|
215
235
|
parseWorktreePaths,
|
|
216
236
|
portableMcpEntry,
|
|
237
|
+
relativeLocalMcpEntry,
|
|
217
238
|
resolveGitCommonDir,
|
|
218
239
|
resolveLocalServerPath,
|
|
219
240
|
resolveMcpEntry,
|
package/scripts/mcp-oauth.js
CHANGED
|
@@ -29,6 +29,7 @@ const crypto = require('crypto');
|
|
|
29
29
|
const AUTH_CODE_TTL_MS = 60 * 1000; // 1 minute
|
|
30
30
|
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
31
31
|
const DEFAULT_SCOPE = 'mcp:read mcp:write';
|
|
32
|
+
const SUPPORTED_SCOPES = Object.freeze(['mcp:read', 'mcp:write']);
|
|
32
33
|
|
|
33
34
|
// Upper bounds on the in-memory store. The registration and authorization
|
|
34
35
|
// endpoints are reachable pre-auth, so without a cap a malicious caller could
|
|
@@ -160,6 +161,27 @@ function getClient(store, clientId) {
|
|
|
160
161
|
return store.clients.get(clientId) || null;
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
function normalizeScopes(scope = DEFAULT_SCOPE, allowedScopes = SUPPORTED_SCOPES) {
|
|
165
|
+
const requested = [...new Set(String(scope || DEFAULT_SCOPE).split(/\s+/).filter(Boolean))];
|
|
166
|
+
const supported = new Set(SUPPORTED_SCOPES);
|
|
167
|
+
const allowed = new Set(allowedScopes || SUPPORTED_SCOPES);
|
|
168
|
+
const invalid = requested.filter((candidate) => !supported.has(candidate));
|
|
169
|
+
const disallowed = requested.filter((candidate) => supported.has(candidate) && !allowed.has(candidate));
|
|
170
|
+
return {
|
|
171
|
+
valid: requested.length > 0 && invalid.length === 0 && disallowed.length === 0,
|
|
172
|
+
scopes: requested,
|
|
173
|
+
scope: requested.join(' '),
|
|
174
|
+
invalid,
|
|
175
|
+
disallowed,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function scopeAllows(session, requiredScope) {
|
|
180
|
+
if (!session || !requiredScope) return false;
|
|
181
|
+
const normalized = normalizeScopes(session.scope, SUPPORTED_SCOPES);
|
|
182
|
+
return normalized.valid && normalized.scopes.includes(requiredScope);
|
|
183
|
+
}
|
|
184
|
+
|
|
163
185
|
// ---------------------------------------------------------------------------
|
|
164
186
|
// Authorization code (PKCE S256)
|
|
165
187
|
// ---------------------------------------------------------------------------
|
|
@@ -169,20 +191,30 @@ function getClient(store, clientId) {
|
|
|
169
191
|
* token will act as (resolved by the authorize step once the user consents).
|
|
170
192
|
*/
|
|
171
193
|
function createAuthorizationCode(store, {
|
|
172
|
-
clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, boundKey, state, resource,
|
|
194
|
+
clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, allowedScopes, boundKey, state, resource,
|
|
173
195
|
} = {}) {
|
|
174
196
|
const client = getClient(store, clientId);
|
|
175
197
|
if (!client) return { error: 'invalid_client' };
|
|
176
198
|
if (!client.redirect_uris.includes(redirectUri)) return { error: 'invalid_request', error_description: 'redirect_uri mismatch' };
|
|
177
199
|
if (codeChallengeMethod !== 'S256') return { error: 'invalid_request', error_description: 'code_challenge_method must be S256' };
|
|
178
200
|
if (!codeChallenge || String(codeChallenge).length < 16) return { error: 'invalid_request', error_description: 'code_challenge required' };
|
|
201
|
+
const normalizedScopes = normalizeScopes(scope, allowedScopes || SUPPORTED_SCOPES);
|
|
202
|
+
if (!normalizedScopes.valid) {
|
|
203
|
+
return {
|
|
204
|
+
error: 'invalid_scope',
|
|
205
|
+
error_description: [
|
|
206
|
+
normalizedScopes.invalid.length > 0 ? `unsupported: ${normalizedScopes.invalid.join(', ')}` : '',
|
|
207
|
+
normalizedScopes.disallowed.length > 0 ? `not permitted: ${normalizedScopes.disallowed.join(', ')}` : '',
|
|
208
|
+
].filter(Boolean).join('; ') || 'scope is required',
|
|
209
|
+
};
|
|
210
|
+
}
|
|
179
211
|
|
|
180
212
|
const code = randomToken(24);
|
|
181
213
|
capInsert(store.codes, code, {
|
|
182
214
|
clientId,
|
|
183
215
|
redirectUri,
|
|
184
216
|
codeChallenge,
|
|
185
|
-
scope: scope
|
|
217
|
+
scope: normalizedScopes.scope,
|
|
186
218
|
boundKey: boundKey || '',
|
|
187
219
|
resource: resource || '', // RFC 8707 resource indicator (the MCP server URL)
|
|
188
220
|
expiresAt: now() + AUTH_CODE_TTL_MS,
|
|
@@ -287,6 +319,9 @@ module.exports = {
|
|
|
287
319
|
AUTH_CODE_TTL_MS,
|
|
288
320
|
ACCESS_TOKEN_TTL_MS,
|
|
289
321
|
DEFAULT_SCOPE,
|
|
322
|
+
SUPPORTED_SCOPES,
|
|
323
|
+
normalizeScopes,
|
|
324
|
+
scopeAllows,
|
|
290
325
|
MAX_CLIENTS,
|
|
291
326
|
MAX_CODES,
|
|
292
327
|
MAX_TOKENS,
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* model-eval.js — honest evaluation primitives for ThumbGate's learned models.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS
|
|
8
|
+
*
|
|
9
|
+
* The risk scorer reported exactly one number: `trainingAccuracy`, measured on the same rows
|
|
10
|
+
* it trained on. On 2026-07-28 that number was 0.820 — which sounds good until you notice the
|
|
11
|
+
* base rate was 0.711. A model that answers "high-risk" unconditionally scores 71.1%, so the
|
|
12
|
+
* headline figure was measuring an 11-point in-sample lift and presenting it as quality.
|
|
13
|
+
* Nothing anywhere compared against that trivial baseline, and no split existed, so the
|
|
14
|
+
* generalization number was not merely bad — it was unknown.
|
|
15
|
+
*
|
|
16
|
+
* Accuracy is also the wrong summary for a 71/29 split. These primitives therefore report the
|
|
17
|
+
* metrics that survive class imbalance (precision/recall/F1/MCC/ROC-AUC) and the ones that say
|
|
18
|
+
* whether a probability means anything (Brier score, expected calibration error).
|
|
19
|
+
*
|
|
20
|
+
* DETERMINISM IS A REQUIREMENT, NOT A PREFERENCE.
|
|
21
|
+
* Splits are derived from a content hash, never from Math.random(). The same corpus must
|
|
22
|
+
* produce the same split on every machine and every run, or a "quality regression" is
|
|
23
|
+
* indistinguishable from a reshuffle and the CI gate built on top is noise.
|
|
24
|
+
*
|
|
25
|
+
* Everything here is pure: no I/O, no clock, no global state. That is what makes it testable.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** FNV-1a. Small, fast, and stable across platforms — the only properties we need. */
|
|
29
|
+
function hashString(text) {
|
|
30
|
+
let hash = 0x811c9dc5;
|
|
31
|
+
const value = String(text);
|
|
32
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
33
|
+
hash ^= value.charCodeAt(index);
|
|
34
|
+
// 32-bit FNV prime multiply via shifts; Math.imul keeps this exact in JS.
|
|
35
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
36
|
+
}
|
|
37
|
+
return hash >>> 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Labels arrive as 1/-1 (AdaBoost) or 1/0 (everything else). Normalize to 1/0. */
|
|
41
|
+
function toBinaryLabel(label) {
|
|
42
|
+
return Number(label) === 1 ? 1 : 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Deterministic stratified split.
|
|
47
|
+
*
|
|
48
|
+
* Stratified because a random split of a 71/29 corpus can hand back a test fold with a
|
|
49
|
+
* materially different base rate, which moves the very number we are trying to measure.
|
|
50
|
+
* Splitting inside each class keeps the test fold's base rate close to the corpus.
|
|
51
|
+
*
|
|
52
|
+
* Returns { train, test }. Both are always non-empty when the input supports it; when a class
|
|
53
|
+
* is too small to contribute a test row, the split degrades to "everything is training" rather
|
|
54
|
+
* than silently producing a test fold with one class in it (an AUC of NaN dressed up as data).
|
|
55
|
+
*/
|
|
56
|
+
function stratifiedSplit(examples, options = {}) {
|
|
57
|
+
const testFraction = Number(options.testFraction || 0.25);
|
|
58
|
+
const keyFn = options.keyFn || ((example, index) => JSON.stringify(example.features || example) + index);
|
|
59
|
+
|
|
60
|
+
// Group ACROSS classes, then assign whole groups — StratifiedGroupKFold semantics.
|
|
61
|
+
//
|
|
62
|
+
// Two bugs led here, both caught by tests/risk-model-quality.test.js:
|
|
63
|
+
// 1. Assigning individual rows split tied blocks, so an identical row could sit in both
|
|
64
|
+
// folds and the test fold scored rows the model had memorized.
|
|
65
|
+
// 2. Grouping per class still split duplicates, because with label noise the SAME feature
|
|
66
|
+
// vector can carry both labels — the two copies then landed in different class buckets
|
|
67
|
+
// and different folds.
|
|
68
|
+
// Grouping globally is the only version where "this input is in exactly one fold" holds.
|
|
69
|
+
const classTotals = new Map();
|
|
70
|
+
const groups = new Map();
|
|
71
|
+
examples.forEach((example, index) => {
|
|
72
|
+
const label = toBinaryLabel(example.label);
|
|
73
|
+
classTotals.set(label, (classTotals.get(label) || 0) + 1);
|
|
74
|
+
const hash = hashString(keyFn(example, index));
|
|
75
|
+
if (!groups.has(hash)) groups.set(hash, { members: [], counts: new Map() });
|
|
76
|
+
const group = groups.get(hash);
|
|
77
|
+
group.members.push(example);
|
|
78
|
+
group.counts.set(label, (group.counts.get(label) || 0) + 1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// A single-class corpus has nothing to measure; there is no honest split of it.
|
|
82
|
+
if (classTotals.size < 2) return { train: examples.slice(), test: [] };
|
|
83
|
+
|
|
84
|
+
// Per-class quotas keep the test fold's base rate near the corpus even though whole groups
|
|
85
|
+
// move together. Without quotas, one large group could swamp the fold and shift the very
|
|
86
|
+
// base rate the lift is measured against.
|
|
87
|
+
const quotas = new Map();
|
|
88
|
+
for (const [label, total] of classTotals) quotas.set(label, Math.floor(total * testFraction));
|
|
89
|
+
|
|
90
|
+
const train = [];
|
|
91
|
+
const test = [];
|
|
92
|
+
const taken = new Map();
|
|
93
|
+
// Order by hash, not by position: input order in a JSONL log is chronological, so a
|
|
94
|
+
// positional split would put all recent rows in one fold and measure drift, not skill.
|
|
95
|
+
const ordered = [...groups.entries()].sort((left, right) => left[0] - right[0]);
|
|
96
|
+
|
|
97
|
+
for (const [, group] of ordered) {
|
|
98
|
+
// Take the group only if it fits ENTIRELY within the remaining quota of every class it
|
|
99
|
+
// contains. Checking merely "is this class still under quota" admitted the whole group and
|
|
100
|
+
// let it overshoot by hundreds of rows — the real corpus has a 655-row content group, so a
|
|
101
|
+
// nominal 25% fold could be swamped by one category and the base rate the lift is measured
|
|
102
|
+
// against would shift underneath it.
|
|
103
|
+
let fits = true;
|
|
104
|
+
for (const [label, count] of group.counts) {
|
|
105
|
+
if ((taken.get(label) || 0) + count > (quotas.get(label) || 0)) { fits = false; break; }
|
|
106
|
+
}
|
|
107
|
+
if (fits) {
|
|
108
|
+
test.push(...group.members);
|
|
109
|
+
for (const [label, count] of group.counts) taken.set(label, (taken.get(label) || 0) + count);
|
|
110
|
+
} else {
|
|
111
|
+
train.push(...group.members);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Both classes must appear in the test fold. With a small minority class the floor()'d quota
|
|
116
|
+
// can be 0, which would leave every minority group in training and produce a single-class
|
|
117
|
+
// test fold: ROC-AUC is undefined there and precision/recall/MCC are degenerate, yet the
|
|
118
|
+
// report would still be marked available. Refusing to split is the honest outcome.
|
|
119
|
+
const testClasses = new Set(test.map((example) => toBinaryLabel(example.label)));
|
|
120
|
+
if (testClasses.size < 2) return { train: examples.slice(), test: [] };
|
|
121
|
+
|
|
122
|
+
// If either side collapsed, report no test fold instead of a meaningless one.
|
|
123
|
+
if (test.length === 0 || train.length === 0) return { train: examples.slice(), test: [] };
|
|
124
|
+
return { train, test };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Threshold metrics. `pairs` is [{ probability, label }].
|
|
129
|
+
* Precision/recall are reported for the positive (high-risk) class.
|
|
130
|
+
*/
|
|
131
|
+
function classificationMetrics(pairs, threshold = 0.5) {
|
|
132
|
+
let truePositive = 0;
|
|
133
|
+
let falsePositive = 0;
|
|
134
|
+
let trueNegative = 0;
|
|
135
|
+
let falseNegative = 0;
|
|
136
|
+
|
|
137
|
+
for (const pair of pairs) {
|
|
138
|
+
const actual = toBinaryLabel(pair.label);
|
|
139
|
+
const predicted = Number(pair.probability) >= threshold ? 1 : 0;
|
|
140
|
+
if (predicted === 1 && actual === 1) truePositive += 1;
|
|
141
|
+
else if (predicted === 1 && actual === 0) falsePositive += 1;
|
|
142
|
+
else if (predicted === 0 && actual === 0) trueNegative += 1;
|
|
143
|
+
else falseNegative += 1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const total = pairs.length || 1;
|
|
147
|
+
const precision = truePositive + falsePositive > 0 ? truePositive / (truePositive + falsePositive) : 0;
|
|
148
|
+
const recall = truePositive + falseNegative > 0 ? truePositive / (truePositive + falseNegative) : 0;
|
|
149
|
+
const specificity = trueNegative + falsePositive > 0 ? trueNegative / (trueNegative + falsePositive) : 0;
|
|
150
|
+
const f1 = precision + recall > 0 ? (2 * precision * recall) / (precision + recall) : 0;
|
|
151
|
+
|
|
152
|
+
// Matthews correlation: the summary that does not flatter a majority-class predictor.
|
|
153
|
+
// A constant classifier scores 0 here no matter how skewed the corpus is.
|
|
154
|
+
const mccDenominator = Math.sqrt(
|
|
155
|
+
(truePositive + falsePositive)
|
|
156
|
+
* (truePositive + falseNegative)
|
|
157
|
+
* (trueNegative + falsePositive)
|
|
158
|
+
* (trueNegative + falseNegative),
|
|
159
|
+
);
|
|
160
|
+
const mcc = mccDenominator > 0
|
|
161
|
+
? ((truePositive * trueNegative) - (falsePositive * falseNegative)) / mccDenominator
|
|
162
|
+
: 0;
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
accuracy: (truePositive + trueNegative) / total,
|
|
166
|
+
precision,
|
|
167
|
+
recall,
|
|
168
|
+
specificity,
|
|
169
|
+
f1,
|
|
170
|
+
mcc,
|
|
171
|
+
confusion: { truePositive, falsePositive, trueNegative, falseNegative },
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* ROC-AUC by the rank method, with tied scores sharing an average rank.
|
|
177
|
+
*
|
|
178
|
+
* Tie handling matters here specifically: a stump ensemble emits a small set of discrete
|
|
179
|
+
* scores, so ties are the common case rather than an edge case. Treating them by input order
|
|
180
|
+
* would make AUC depend on row order — a number that changes when you sort your log.
|
|
181
|
+
*/
|
|
182
|
+
function rocAuc(pairs) {
|
|
183
|
+
const positives = pairs.filter((pair) => toBinaryLabel(pair.label) === 1).length;
|
|
184
|
+
const negatives = pairs.length - positives;
|
|
185
|
+
if (positives === 0 || negatives === 0) return null; // undefined, and saying so beats guessing
|
|
186
|
+
|
|
187
|
+
const ordered = pairs
|
|
188
|
+
.map((pair) => ({ score: Number(pair.probability), label: toBinaryLabel(pair.label) }))
|
|
189
|
+
.sort((left, right) => left.score - right.score);
|
|
190
|
+
|
|
191
|
+
const ranks = new Array(ordered.length);
|
|
192
|
+
let index = 0;
|
|
193
|
+
while (index < ordered.length) {
|
|
194
|
+
let end = index;
|
|
195
|
+
while (end + 1 < ordered.length && ordered[end + 1].score === ordered[index].score) end += 1;
|
|
196
|
+
// Ranks are 1-based; tied entries all take the midpoint of the block they occupy.
|
|
197
|
+
const averageRank = ((index + 1) + (end + 1)) / 2;
|
|
198
|
+
for (let position = index; position <= end; position += 1) ranks[position] = averageRank;
|
|
199
|
+
index = end + 1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let positiveRankSum = 0;
|
|
203
|
+
ordered.forEach((entry, position) => {
|
|
204
|
+
if (entry.label === 1) positiveRankSum += ranks[position];
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
return (positiveRankSum - (positives * (positives + 1)) / 2) / (positives * negatives);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Mean squared error of the probability itself. Rewards being uncertain when uncertain. */
|
|
211
|
+
function brierScore(pairs) {
|
|
212
|
+
if (pairs.length === 0) return null;
|
|
213
|
+
const total = pairs.reduce((sum, pair) => {
|
|
214
|
+
const actual = toBinaryLabel(pair.label);
|
|
215
|
+
const probability = Number(pair.probability);
|
|
216
|
+
return sum + ((probability - actual) ** 2);
|
|
217
|
+
}, 0);
|
|
218
|
+
return total / pairs.length;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Expected calibration error: |predicted - observed| averaged over equal-width bins,
|
|
223
|
+
* weighted by bin population.
|
|
224
|
+
*
|
|
225
|
+
* This is the metric that catches a model whose ranking is fine but whose probabilities are
|
|
226
|
+
* meaningless — which matters because downstream gates threshold on the probability, not on
|
|
227
|
+
* the rank.
|
|
228
|
+
*/
|
|
229
|
+
function calibration(pairs, binCount = 10) {
|
|
230
|
+
if (pairs.length === 0) return { expectedCalibrationError: null, bins: [] };
|
|
231
|
+
const bins = Array.from({ length: binCount }, () => ({ count: 0, predicted: 0, observed: 0 }));
|
|
232
|
+
|
|
233
|
+
for (const pair of pairs) {
|
|
234
|
+
const probability = Math.min(0.999999, Math.max(0, Number(pair.probability)));
|
|
235
|
+
const slot = Math.min(binCount - 1, Math.floor(probability * binCount));
|
|
236
|
+
bins[slot].count += 1;
|
|
237
|
+
bins[slot].predicted += probability;
|
|
238
|
+
bins[slot].observed += toBinaryLabel(pair.label);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let error = 0;
|
|
242
|
+
const report = bins.map((bin, slot) => {
|
|
243
|
+
if (bin.count === 0) return { bin: slot, count: 0, meanPredicted: null, meanObserved: null };
|
|
244
|
+
const meanPredicted = bin.predicted / bin.count;
|
|
245
|
+
const meanObserved = bin.observed / bin.count;
|
|
246
|
+
error += (bin.count / pairs.length) * Math.abs(meanPredicted - meanObserved);
|
|
247
|
+
return { bin: slot, count: bin.count, meanPredicted, meanObserved };
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
return { expectedCalibrationError: error, bins: report };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Full report for a set of (probability, label) pairs.
|
|
255
|
+
*
|
|
256
|
+
* `baselineAccuracy` and `lift` are the point of this whole module: a model is only worth
|
|
257
|
+
* running if it beats the constant classifier, and that comparison must appear next to the
|
|
258
|
+
* accuracy every single time so it can never again be quoted without it.
|
|
259
|
+
*/
|
|
260
|
+
function evaluate(pairs, options = {}) {
|
|
261
|
+
const threshold = Number(options.threshold ?? 0.5);
|
|
262
|
+
const positives = pairs.filter((pair) => toBinaryLabel(pair.label) === 1).length;
|
|
263
|
+
const baseRate = pairs.length > 0 ? positives / pairs.length : 0;
|
|
264
|
+
// The trivial classifier always answers with the majority class.
|
|
265
|
+
const baselineAccuracy = Math.max(baseRate, 1 - baseRate);
|
|
266
|
+
const metrics = classificationMetrics(pairs, threshold);
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
sampleCount: pairs.length,
|
|
270
|
+
positiveCount: positives,
|
|
271
|
+
baseRate,
|
|
272
|
+
baselineAccuracy,
|
|
273
|
+
accuracy: metrics.accuracy,
|
|
274
|
+
lift: metrics.accuracy - baselineAccuracy,
|
|
275
|
+
precision: metrics.precision,
|
|
276
|
+
recall: metrics.recall,
|
|
277
|
+
specificity: metrics.specificity,
|
|
278
|
+
f1: metrics.f1,
|
|
279
|
+
mcc: metrics.mcc,
|
|
280
|
+
rocAuc: rocAuc(pairs),
|
|
281
|
+
brierScore: brierScore(pairs),
|
|
282
|
+
expectedCalibrationError: calibration(pairs, options.calibrationBins || 10).expectedCalibrationError,
|
|
283
|
+
confusion: metrics.confusion,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Round every float in a report so persisted artifacts diff cleanly. */
|
|
288
|
+
function roundReport(report, digits = 4) {
|
|
289
|
+
const factor = 10 ** digits;
|
|
290
|
+
const rounded = {};
|
|
291
|
+
for (const [key, value] of Object.entries(report || {})) {
|
|
292
|
+
if (typeof value === 'number' && Number.isFinite(value)) rounded[key] = Math.round(value * factor) / factor;
|
|
293
|
+
else if (value && typeof value === 'object' && !Array.isArray(value)) rounded[key] = roundReport(value, digits);
|
|
294
|
+
else rounded[key] = value;
|
|
295
|
+
}
|
|
296
|
+
return rounded;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
module.exports = {
|
|
300
|
+
hashString,
|
|
301
|
+
stratifiedSplit,
|
|
302
|
+
classificationMetrics,
|
|
303
|
+
rocAuc,
|
|
304
|
+
brierScore,
|
|
305
|
+
calibration,
|
|
306
|
+
evaluate,
|
|
307
|
+
roundReport,
|
|
308
|
+
};
|