thumbgate 1.29.2 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +143 -14
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +154 -36
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +215 -185
  16. package/config/model-tiers.json +7 -2
  17. package/config/post-deploy-marketing-pages.json +26 -1
  18. package/glama.json +6 -0
  19. package/package.json +94 -11
  20. package/public/architecture.html +130 -0
  21. package/public/assets/diagrams/agent-integration.png +0 -0
  22. package/public/assets/diagrams/before-after.svg +22 -0
  23. package/public/assets/diagrams/decision.svg +36 -0
  24. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  25. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  26. package/public/assets/diagrams/loop.svg +40 -0
  27. package/public/assets/diagrams/plugin-topology.png +0 -0
  28. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  29. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/compare.html +1 -0
  34. package/public/dashboard.html +126 -28
  35. package/public/eval-scorecard.html +195 -0
  36. package/public/eval-scorecard.json +18 -0
  37. package/public/evaluations.html +168 -0
  38. package/public/index.html +143 -13
  39. package/public/numbers.html +3 -2
  40. package/public/pricing.html +143 -30
  41. package/public/whitepaper.html +189 -0
  42. package/scripts/a-plus-evidence-scorecard.js +303 -0
  43. package/scripts/activation-quickstart.js +1 -0
  44. package/scripts/agent-outcome-monitor.js +71 -1
  45. package/scripts/async-eval-observability.js +36 -11
  46. package/scripts/audit-trail.js +37 -1
  47. package/scripts/auto-promote-gates.js +149 -34
  48. package/scripts/billing.js +3 -1
  49. package/scripts/claude-feedback-sync.js +3 -2
  50. package/scripts/cli-feedback.js +13 -7
  51. package/scripts/colbert-style-maxsim.js +236 -0
  52. package/scripts/cross-encoder-reranker.js +359 -126
  53. package/scripts/dashboard-chat.js +350 -17
  54. package/scripts/document-intake.js +283 -7
  55. package/scripts/eval-quality-suite.js +204 -0
  56. package/scripts/feedback-aggregate.js +5 -2
  57. package/scripts/feedback-loop.js +359 -189
  58. package/scripts/feedback-paths.js +32 -13
  59. package/scripts/feedback-quality.js +53 -0
  60. package/scripts/filesystem-search.js +17 -7
  61. package/scripts/gates-engine.js +98 -4
  62. package/scripts/generate-case-study-outreach.js +253 -0
  63. package/scripts/generate-eval-scorecard.js +276 -0
  64. package/scripts/growth-campaigns.js +183 -0
  65. package/scripts/harness-tool-names.js +70 -0
  66. package/scripts/hook-runtime.js +10 -3
  67. package/scripts/jsonl-watcher.js +1 -0
  68. package/scripts/lesson-db.js +16 -5
  69. package/scripts/lesson-embedding-index.js +67 -20
  70. package/scripts/lesson-embedding-maintenance.js +177 -0
  71. package/scripts/lesson-inference.js +23 -4
  72. package/scripts/lesson-reranker.js +55 -9
  73. package/scripts/lesson-retrieval.js +375 -32
  74. package/scripts/lesson-search.js +48 -11
  75. package/scripts/llm-client.js +304 -15
  76. package/scripts/mcp-config.js +26 -5
  77. package/scripts/mcp-oauth.js +37 -2
  78. package/scripts/model-eval.js +308 -0
  79. package/scripts/model-tier-router.js +593 -0
  80. package/scripts/parallel-workflow-orchestrator.js +86 -22
  81. package/scripts/pragmatic-hybrid-search.js +379 -0
  82. package/scripts/published-cli.js +11 -1
  83. package/scripts/rag-document-pipeline.js +461 -0
  84. package/scripts/rag-structured-output.js +441 -0
  85. package/scripts/ragas-style-metrics.js +351 -0
  86. package/scripts/refresh-proof-pack.js +261 -0
  87. package/scripts/request-envelope.js +178 -0
  88. package/scripts/rerank-pipeline.js +370 -0
  89. package/scripts/rerank-quality-eval.js +155 -0
  90. package/scripts/retrieval-hybrid-ablation.js +120 -0
  91. package/scripts/retrieval-quality-tier.js +118 -0
  92. package/scripts/risk-scorer.js +144 -15
  93. package/scripts/secret-scanner.js +395 -4
  94. package/scripts/self-distill-agent.js +7 -1
  95. package/scripts/self-healing-check.js +25 -0
  96. package/scripts/skill-packs.js +183 -0
  97. package/scripts/slow-loop.js +72 -0
  98. package/scripts/statusline-links.js +1 -1
  99. package/scripts/statusline-local-stats.js +1 -1
  100. package/scripts/statusline.sh +8 -1
  101. package/scripts/telemetry-analytics.js +13 -1
  102. package/scripts/thumbgate-bench.js +13 -0
  103. package/scripts/thumbgate-search.js +98 -6
  104. package/scripts/tier-budget-guard.js +186 -0
  105. package/scripts/tool-kpi-tracker.js +124 -0
  106. package/scripts/tool-registry.js +95 -1
  107. package/scripts/vector-store.js +108 -4
  108. package/scripts/verify-marketing-pages-deployed.js +85 -3
  109. package/server.json +44 -0
  110. package/smithery.yaml +17 -0
  111. package/src/api/server.js +424 -99
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const {
8
+ retrieveRelevantLessons,
9
+ retrieveRelevantLessonsAsync,
10
+ } = require('./lesson-retrieval');
11
+ const { scoreRanking, aggregateRankingScores } = require('./ir-metrics');
12
+
13
+ const DEFAULT_FIXTURE_PATH = path.join(
14
+ __dirname,
15
+ '..',
16
+ 'config',
17
+ 'evals',
18
+ 'retrieval-hybrid-ablation.json',
19
+ );
20
+
21
+ function createFixtureEmbedder() {
22
+ const concepts = [
23
+ /(erase|directory|tree|rm|delete|folder|snapshot)/i,
24
+ /(overwrite|remote|history|force|push|clobber|trunk|repository)/i,
25
+ /(replay|purchase|twice|duplicate|payment|idempotency|charge)/i,
26
+ /(phone|expired|conversation|session|identifier|mobile|resume)/i,
27
+ /(dashboard|color|design|palette|visual|ui)/i,
28
+ ];
29
+ return async (text) => {
30
+ const vector = concepts.map((pattern) => (pattern.test(String(text || '')) ? 1 : 0));
31
+ if (vector.every((value) => value === 0)) vector.push(0.001);
32
+ else vector.push(0);
33
+ return vector;
34
+ };
35
+ }
36
+
37
+ async function evaluateHybridAblation(options = {}) {
38
+ const fixturePath = options.fixturePath || DEFAULT_FIXTURE_PATH;
39
+ const fixture = options.fixture || JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
40
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'thumbgate-hybrid-ablation-'));
41
+ try {
42
+ fs.writeFileSync(
43
+ path.join(tempDir, 'memory-log.jsonl'),
44
+ `${fixture.corpus.map((row) => JSON.stringify({
45
+ ...row,
46
+ timestamp: row.timestamp || new Date().toISOString(),
47
+ })).join('\n')}\n`,
48
+ );
49
+ const kValues = fixture.kValues || [1, 3];
50
+ const embedder = options.embedder || createFixtureEmbedder();
51
+ const lexicalRows = [];
52
+ const hybridRows = [];
53
+ for (const queryCase of fixture.queries) {
54
+ const lexical = retrieveRelevantLessons(queryCase.toolName, queryCase.query, {
55
+ feedbackDir: tempDir,
56
+ maxResults: Math.max(...kValues),
57
+ pragmatic: true,
58
+ });
59
+ const hybrid = await retrieveRelevantLessonsAsync(queryCase.toolName, queryCase.query, {
60
+ feedbackDir: tempDir,
61
+ maxResults: Math.max(...kValues),
62
+ embedder,
63
+ embedderId: options.embedderId || 'deterministic-semantic-fixture',
64
+ includeRetrievalMeta: true,
65
+ });
66
+ lexicalRows.push({
67
+ id: queryCase.id,
68
+ rankedIds: lexical.map((row) => row.id),
69
+ metrics: scoreRanking(lexical, queryCase.qrels, { kValues }),
70
+ });
71
+ hybridRows.push({
72
+ id: queryCase.id,
73
+ rankedIds: hybrid.map((row) => row.id),
74
+ retrieval: hybrid[0]?.retrieval || null,
75
+ metrics: scoreRanking(hybrid, queryCase.qrels, { kValues }),
76
+ });
77
+ }
78
+ const lexical = aggregateRankingScores(lexicalRows.map((row) => row.metrics), { kValues });
79
+ const hybrid = aggregateRankingScores(hybridRows.map((row) => row.metrics), { kValues });
80
+ const summary = {
81
+ mode: options.embedderId || 'deterministic-semantic-fixture',
82
+ queries: fixture.queries.length,
83
+ lexical,
84
+ hybrid,
85
+ lift: {
86
+ mrr: hybrid.mrr - lexical.mrr,
87
+ recallAt3: hybrid['recall@3'] - lexical['recall@3'],
88
+ ndcgAt3: hybrid['ndcg@3'] - lexical['ndcg@3'],
89
+ },
90
+ };
91
+ return {
92
+ passed: hybrid['recall@3'] >= 0.9
93
+ && hybrid.mrr >= lexical.mrr
94
+ && hybrid['ndcg@3'] >= lexical['ndcg@3']
95
+ && hybridRows.every((row) => row.retrieval?.densePool > 0),
96
+ fixturePath,
97
+ summary,
98
+ lexicalRows,
99
+ hybridRows,
100
+ };
101
+ } finally {
102
+ fs.rmSync(tempDir, { recursive: true, force: true });
103
+ }
104
+ }
105
+
106
+ if (!module.parent) {
107
+ evaluateHybridAblation().then((result) => {
108
+ console.log(JSON.stringify(result, null, 2));
109
+ if (!result.passed) process.exitCode = 1;
110
+ }).catch((error) => {
111
+ console.error(error.message);
112
+ process.exitCode = 1;
113
+ });
114
+ }
115
+
116
+ module.exports = {
117
+ DEFAULT_FIXTURE_PATH,
118
+ createFixtureEmbedder,
119
+ evaluateHybridAblation,
120
+ };
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Retrieval quality tier — surface degraded/stale semantic paths honestly.
5
+ *
6
+ * When embeddings are feature-hash, missing, or the lesson index is older than
7
+ * the freshness window, callers must not claim production semantic quality.
8
+ */
9
+
10
+ const DEFAULT_MAX_INDEX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
11
+
12
+ /**
13
+ * @param {object} [input]
14
+ * @param {object|null} [input.embeddingProfile] — from vector-store getLastEmbeddingProfile()
15
+ * @param {boolean} [input.embedderAvailable]
16
+ * @param {number|null} [input.indexUpdatedAtMs]
17
+ * @param {number} [input.nowMs]
18
+ * @param {number} [input.maxIndexAgeMs]
19
+ * @returns {{
20
+ * qualityTier: 'production'|'degraded'|'unavailable',
21
+ * semanticClaimsAllowed: boolean,
22
+ * degradedReasons: string[],
23
+ * indexAgeMs: number|null,
24
+ * }}
25
+ */
26
+ function assessRetrievalQualityTier(input = {}) {
27
+ const now = Number.isFinite(input.nowMs) ? input.nowMs : Date.now();
28
+ const maxAge = Number.isFinite(input.maxIndexAgeMs)
29
+ ? input.maxIndexAgeMs
30
+ : Number(process.env.THUMBGATE_MAX_INDEX_AGE_MS) || DEFAULT_MAX_INDEX_AGE_MS;
31
+
32
+ const reasons = [];
33
+ const profile = input.embeddingProfile || null;
34
+ const profileId = String(profile?.activeProfile?.id || profile?.id || profile?.source || '');
35
+ const profileTier = String(profile?.activeProfile?.qualityTier || profile?.qualityTier || '');
36
+
37
+ let qualityTier = 'production';
38
+
39
+ if (input.embedderAvailable === false) {
40
+ qualityTier = 'unavailable';
41
+ reasons.push('embedder_unavailable');
42
+ } else if (!profile) {
43
+ qualityTier = 'degraded';
44
+ reasons.push('embedding_profile_missing');
45
+ } else if (/feature-hash|built-in|stub/i.test(profileId) || /degraded|test_stub/i.test(profileTier)) {
46
+ qualityTier = 'degraded';
47
+ reasons.push('embedding_tier_degraded');
48
+ } else if (profileTier && /production/i.test(profileTier) === false && profileTier !== '') {
49
+ qualityTier = 'degraded';
50
+ reasons.push(`embedding_quality_tier:${profileTier}`);
51
+ }
52
+
53
+ let indexAgeMs = null;
54
+ if (qualityTier !== 'unavailable' && !Number.isFinite(input.indexUpdatedAtMs)) {
55
+ if (qualityTier === 'production') qualityTier = 'degraded';
56
+ reasons.push('index_freshness_unknown');
57
+ } else if (Number.isFinite(input.indexUpdatedAtMs)) {
58
+ indexAgeMs = Math.max(0, now - Number(input.indexUpdatedAtMs));
59
+ if (indexAgeMs > maxAge) {
60
+ if (qualityTier === 'production') qualityTier = 'degraded';
61
+ reasons.push('index_stale');
62
+ }
63
+ }
64
+
65
+ if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') {
66
+ qualityTier = 'degraded';
67
+ if (!reasons.includes('embedding_tier_degraded')) reasons.push('vector_stub_embed');
68
+ }
69
+
70
+ return {
71
+ qualityTier,
72
+ semanticClaimsAllowed: qualityTier === 'production',
73
+ degradedReasons: reasons,
74
+ indexAgeMs,
75
+ maxIndexAgeMs: maxAge,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Probe live embedding profile when vector-store is available.
81
+ */
82
+ function probeEmbeddingQuality(options = {}) {
83
+ try {
84
+ const vectorStore = require('./vector-store');
85
+ const profile = typeof vectorStore.getLastEmbeddingProfile === 'function'
86
+ ? vectorStore.getLastEmbeddingProfile()
87
+ : null;
88
+ let embedderAvailable = true;
89
+ try {
90
+ const idx = require('./lesson-embedding-index');
91
+ if (typeof idx.isEmbedderAvailable === 'function') {
92
+ embedderAvailable = idx.isEmbedderAvailable();
93
+ }
94
+ } catch {
95
+ embedderAvailable = false;
96
+ }
97
+ return assessRetrievalQualityTier({
98
+ embeddingProfile: profile,
99
+ embedderAvailable,
100
+ indexUpdatedAtMs: options.indexUpdatedAtMs ?? null,
101
+ nowMs: options.nowMs,
102
+ maxIndexAgeMs: options.maxIndexAgeMs,
103
+ });
104
+ } catch {
105
+ return assessRetrievalQualityTier({
106
+ embedderAvailable: false,
107
+ indexUpdatedAtMs: options.indexUpdatedAtMs ?? null,
108
+ nowMs: options.nowMs,
109
+ maxIndexAgeMs: options.maxIndexAgeMs,
110
+ });
111
+ }
112
+ }
113
+
114
+ module.exports = {
115
+ DEFAULT_MAX_INDEX_AGE_MS,
116
+ assessRetrievalQualityTier,
117
+ probeEmbeddingQuality,
118
+ };
@@ -5,6 +5,7 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { resolveFeedbackDir: resolveSharedFeedbackDir } = require('./feedback-paths');
7
7
  const { requireLearnedModelsEntitlement } = require('./entitlement');
8
+ const { stratifiedSplit, evaluate, roundReport } = require('./model-eval');
8
9
 
9
10
  const PROJECT_ROOT = path.join(__dirname, '..');
10
11
  const DEFAULT_FEEDBACK_DIR = resolveSharedFeedbackDir();
@@ -187,10 +188,16 @@ function stumpPredict(value, threshold, polarity) {
187
188
  return decision * polarity;
188
189
  }
189
190
 
190
- function findBestWeakLearner(examples, weights, featureNames) {
191
+ function findBestWeakLearner(examples, weights, featureNames, options = {}) {
192
+ const usage = options.usage || null;
193
+ const maxPerFeature = Number(options.maxPerFeature || Infinity);
191
194
  let best = null;
192
195
 
193
196
  featureNames.forEach((feature) => {
197
+ // Diversity bound: once a feature has been split on maxPerFeature times, later rounds must
198
+ // find signal elsewhere or stop. Off by default (Infinity) so existing behaviour is bit
199
+ // identical unless a caller opts in.
200
+ if (usage && Number.isFinite(maxPerFeature) && (usage.get(feature) || 0) >= maxPerFeature) return;
194
201
  const values = examples.map((example) => example.features[feature]);
195
202
  const thresholds = candidateThresholds(values);
196
203
  thresholds.forEach((threshold) => {
@@ -268,18 +275,14 @@ function buildPatternSummary(rows) {
268
275
  };
269
276
  }
270
277
 
271
- function trainRiskModel(rows, options = {}) {
272
- requireLearnedModelsEntitlement({
273
- ...(options.entitlement || {}),
274
- label: 'risk-scorer AdaBoost training',
275
- });
276
- const registry = buildFeatureRegistry(rows, options);
277
- const examples = rows.map((row) => ({
278
- row,
279
- label: deriveTargetRisk(row) === 1 ? 1 : -1,
280
- features: extractFeatureMap(row, registry),
281
- }));
282
-
278
+ /**
279
+ * Fit the ensemble on a given set of examples.
280
+ *
281
+ * Extracted from trainRiskModel so that the held-out probe below is fitted by IDENTICAL code.
282
+ * If the probe were trained by a separate path, its score would describe a model we do not
283
+ * ship, and the resulting "generalization" number would be fiction.
284
+ */
285
+ function fitBoostedModel(examples, registry, options = {}) {
283
286
  const model = {
284
287
  version: 1,
285
288
  algorithm: 'adaboost-stumps',
@@ -292,7 +295,7 @@ function trainRiskModel(rows, options = {}) {
292
295
  featureRegistry: registry,
293
296
  featureNames: examples[0] ? Object.keys(examples[0].features) : [],
294
297
  learners: [],
295
- patterns: buildPatternSummary(rows),
298
+ patterns: options.patterns || { tags: [], domains: [], skills: [] },
296
299
  metrics: {
297
300
  trainingAccuracy: 0,
298
301
  rounds: 0,
@@ -307,9 +310,16 @@ function trainRiskModel(rows, options = {}) {
307
310
 
308
311
  let weights = normalizeWeights(Array(examples.length).fill(1));
309
312
  const rounds = Math.max(1, Math.min(12, Number(options.rounds || 8)));
313
+ // Boosting is free to pick the same feature every round. On the real corpus it did exactly
314
+ // that — six of eight stumps split on `recentTrend`, so an "ensemble" was in practice a
315
+ // one-feature model of how the session had been going lately. maxPerFeature bounds that.
316
+ // Default is Infinity (historical behaviour); enabling it is an evidence-based decision made
317
+ // by comparing held-out lift, not an assumption. See docs/ML-EVALUATION.md.
318
+ const maxPerFeature = Number(options.maxPerFeature || Infinity);
319
+ const usage = new Map();
310
320
 
311
321
  for (let round = 0; round < rounds; round += 1) {
312
- const learner = findBestWeakLearner(examples, weights, model.featureNames);
322
+ const learner = findBestWeakLearner(examples, weights, model.featureNames, { usage, maxPerFeature });
313
323
  if (!learner) break;
314
324
 
315
325
  const clippedError = Math.min(Math.max(learner.error, 1e-6), 1 - 1e-6);
@@ -322,6 +332,7 @@ function trainRiskModel(rows, options = {}) {
322
332
  polarity: learner.polarity,
323
333
  alpha: Math.round(alpha * 1000) / 1000,
324
334
  });
335
+ usage.set(learner.feature, (usage.get(learner.feature) || 0) + 1);
325
336
 
326
337
  weights = normalizeWeights(weights.map((weight, index) => (
327
338
  weight * Math.exp(-alpha * examples[index].label * learner.predictions[index])
@@ -334,6 +345,119 @@ function trainRiskModel(rows, options = {}) {
334
345
  return model;
335
346
  }
336
347
 
348
+ /** Score a fitted model over examples as (probability, label) pairs for model-eval. */
349
+ function scorePairs(model, examples) {
350
+ return examples.map((example) => ({
351
+ probability: predictRisk(model, example.row).probability,
352
+ label: example.label === 1 ? 1 : 0,
353
+ }));
354
+ }
355
+
356
+ /**
357
+ * Held-out estimate of what this training procedure generalizes to.
358
+ *
359
+ * Split on CONTENT, not position or an RNG. Content hashing gives two properties we need:
360
+ * the split is reproducible on every machine, and near-duplicate rows (which this corpus has
361
+ * plenty of, since similar actions recur) land in the SAME fold instead of leaking an answer
362
+ * from train into test and inflating the score.
363
+ */
364
+ function holdoutEvaluation(examples, registry, options = {}) {
365
+ const { train, test } = stratifiedSplit(examples, {
366
+ testFraction: Number(options.testFraction || 0.25),
367
+ // splitSalt exists so the SAME corpus can be re-split many ways for repeated-resampling
368
+ // validation. A single split of a few hundred rows cannot distinguish a real improvement
369
+ // from sampling noise, and picking a configuration on one split is how you overfit the
370
+ // validation set itself.
371
+ // Key on the extracted feature vector ALONE. The model observes nothing else, so two rows
372
+ // with identical features are the same input to it and must share a fold. An earlier
373
+ // version also mixed in the raw `context` string, which split rows whose different prose
374
+ // maps to identical features — recreating the very leakage this splitter exists to stop.
375
+ keyFn: options.groupKeyFn
376
+ ? (example) => JSON.stringify([options.splitSalt || '', options.groupKeyFn(example)])
377
+ : (example) => JSON.stringify([options.splitSalt || '', example.features]),
378
+ });
379
+
380
+ // Saying "not measurable" is the honest output for a corpus too small or too one-sided to
381
+ // hold anything out. Reporting a number here would be worse than reporting nothing.
382
+ if (test.length === 0 || train.length < 6) {
383
+ return { available: false, reason: 'corpus-too-small-or-single-class' };
384
+ }
385
+
386
+ // REBUILD THE VOCABULARY FROM THE TRAINING FOLD ONLY.
387
+ //
388
+ // buildFeatureRegistry picks top tags and skills by frequency. Deriving it from the whole
389
+ // corpus lets held-out rows decide which features exist — a transductive fit. The probe would
390
+ // see vocabulary chosen with knowledge of the test fold, and the "held-out" number would then
391
+ // describe a procedure we never run in production. Registry and features come from train only.
392
+ const foldRegistry = buildFeatureRegistry(train.map((example) => example.row), options);
393
+ const trainRefit = train.map((example) => ({
394
+ row: example.row,
395
+ label: example.label,
396
+ features: extractFeatureMap(example.row, foldRegistry),
397
+ }));
398
+
399
+ const probe = fitBoostedModel(trainRefit, foldRegistry, { ...options, patterns: undefined });
400
+ // Test rows are scored via predictRisk, which extracts features using the probe's OWN
401
+ // registry — so the test fold is judged under exactly the vocabulary the probe learned.
402
+ const report = evaluate(scorePairs(probe, test));
403
+ return {
404
+ available: true,
405
+ trainCount: train.length,
406
+ testCount: test.length,
407
+ ...roundReport(report),
408
+ };
409
+ }
410
+
411
+ function trainRiskModel(rows, options = {}) {
412
+ requireLearnedModelsEntitlement({
413
+ ...(options.entitlement || {}),
414
+ label: 'risk-scorer AdaBoost training',
415
+ });
416
+ const registry = buildFeatureRegistry(rows, options);
417
+ const examples = rows.map((row) => ({
418
+ row,
419
+ label: deriveTargetRisk(row) === 1 ? 1 : -1,
420
+ features: extractFeatureMap(row, registry),
421
+ }));
422
+
423
+ const model = fitBoostedModel(examples, registry, {
424
+ ...options,
425
+ patterns: buildPatternSummary(rows),
426
+ });
427
+
428
+ // The shipped model is fitted on everything — that is the right thing to deploy. The probe
429
+ // above estimates what that procedure generalizes to. Both numbers are recorded, and
430
+ // `inSample` is labelled as such so it can never again be quoted as if it were quality.
431
+ model.metrics.inSample = roundReport(evaluate(scorePairs(model, examples)));
432
+ if (options.skipHoldout !== true) {
433
+ // TWO held-out estimates, because they answer different questions and only reporting the
434
+ // friendlier one would repeat the original sin of this file.
435
+ //
436
+ // holdout — IID split on the full feature vector. "Does this work on new
437
+ // rows of the kinds we have seen?" Measured 2026-07-28: +0.091
438
+ // lift, AUC 0.884, 12/12 resamples beat baseline.
439
+ //
440
+ // holdoutNovelContext — split by coarse content group, so whole action categories are
441
+ // absent from training. "Does this work on kinds of actions we
442
+ // have never seen?" Measured: NEGATIVE lift.
443
+ //
444
+ // For a firewall the second question is the one that matters most — novel attacks are by
445
+ // definition unfamiliar — so the pessimistic number is recorded next to the optimistic one
446
+ // permanently, and docs/ML-EVALUATION.md explains the gap.
447
+ model.metrics.holdout = holdoutEvaluation(examples, registry, options);
448
+ model.metrics.holdoutNovelContext = holdoutEvaluation(examples, registry, {
449
+ ...options,
450
+ groupKeyFn: (example) => JSON.stringify([
451
+ example.row && example.row.context,
452
+ example.row && example.row.domain,
453
+ example.row && example.row.skill,
454
+ example.row && example.row.targetTags,
455
+ ]),
456
+ });
457
+ }
458
+ return model;
459
+ }
460
+
337
461
  function rawScore(model, row) {
338
462
  if (!model || !model.featureRegistry) {
339
463
  return 0;
@@ -457,6 +581,11 @@ module.exports = {
457
581
  trainAndPersistRiskModel,
458
582
  trainRiskModel,
459
583
  getRiskSummary,
584
+ // Exported for the evaluation harness (scripts/eval-risk-model.js) and its tests: measuring
585
+ // this model requires fitting it on a fold, which requires reaching the fit step directly.
586
+ fitBoostedModel,
587
+ holdoutEvaluation,
588
+ scorePairs,
460
589
  };
461
590
 
462
591
  if (require.main === module) {