thumbgate 1.29.2 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -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 || DEFAULT_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
+ };