thumbgate 1.30.0 → 1.34.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 (92) 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 +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -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/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -169,7 +169,7 @@ function writeJsonl(filePath, records) {
169
169
  }
170
170
 
171
171
  function normalizeText(value) {
172
- const withoutBom = String(value || '').split('\uFEFF').join('');
172
+ const withoutBom = String(value || '').replaceAll('\uFEFF', '');
173
173
  const normalizedNewlines = normalizeNewlines(withoutBom);
174
174
  const trimmedLines = normalizedNewlines
175
175
  .split('\n')
@@ -189,6 +189,70 @@ function normalizeTags(tags) {
189
189
  .filter(Boolean)));
190
190
  }
191
191
 
192
+ function normalizeAccessIdentifier(value) {
193
+ const normalized = String(value || '').trim();
194
+ if (!normalized) return null;
195
+ return normalized.slice(0, 160);
196
+ }
197
+
198
+ function normalizeAccessContext(context = {}) {
199
+ const source = context && typeof context === 'object' ? context : {};
200
+ return {
201
+ tenantId: normalizeAccessIdentifier(source.tenantId),
202
+ principalId: normalizeAccessIdentifier(source.principalId),
203
+ isAdmin: source.isAdmin === true,
204
+ };
205
+ }
206
+
207
+ function buildDocumentAccess(options = {}) {
208
+ const context = normalizeAccessContext(options.accessContext);
209
+ if (!context.tenantId && !context.principalId) return null;
210
+ const visibility = options.visibility === 'private' ? 'private' : 'tenant';
211
+ const allowedPrincipals = safeArray(options.allowedPrincipals)
212
+ .map(normalizeAccessIdentifier)
213
+ .filter(Boolean);
214
+ return {
215
+ tenantId: context.tenantId,
216
+ ownerId: context.principalId,
217
+ visibility,
218
+ allowedPrincipals: [...new Set(allowedPrincipals)],
219
+ };
220
+ }
221
+
222
+ /**
223
+ * Fail-closed authorization for protected imported documents.
224
+ *
225
+ * Legacy local documents without access metadata stay readable for backwards
226
+ * compatibility. Once a document carries a tenant, owner, or principal ACL,
227
+ * callers must present a matching authorization context on every read path.
228
+ */
229
+ function documentAccessAllowed(document = {}, accessContext = {}) {
230
+ const access = document.access;
231
+ const protectedDocument = access && typeof access === 'object' && (
232
+ normalizeAccessIdentifier(access.tenantId)
233
+ || normalizeAccessIdentifier(access.ownerId)
234
+ || safeArray(access.allowedPrincipals).length > 0
235
+ );
236
+ if (!protectedDocument) return true;
237
+
238
+ const caller = normalizeAccessContext(accessContext);
239
+ if (caller.isAdmin) return true;
240
+
241
+ const tenantId = normalizeAccessIdentifier(access.tenantId);
242
+ if (tenantId && (!caller.tenantId || caller.tenantId !== tenantId)) return false;
243
+
244
+ if (access.visibility === 'tenant') {
245
+ return Boolean(caller.tenantId && caller.tenantId === tenantId);
246
+ }
247
+
248
+ if (!caller.principalId) return false;
249
+ const allowed = new Set([
250
+ normalizeAccessIdentifier(access.ownerId),
251
+ ...safeArray(access.allowedPrincipals).map(normalizeAccessIdentifier),
252
+ ].filter(Boolean));
253
+ return allowed.has(caller.principalId);
254
+ }
255
+
192
256
  function normalizeNewlines(value) {
193
257
  let result = '';
194
258
  const text = String(value || '');
@@ -716,6 +780,7 @@ function buildDocumentSummary(document) {
716
780
  proposalCount: safeArray(document.proposals).length,
717
781
  matchedTemplateIds: safeArray(document.matchedTemplateIds),
718
782
  fingerprint: document.fingerprint,
783
+ access: document.access || null,
719
784
  };
720
785
  }
721
786
 
@@ -723,7 +788,8 @@ function readImportedDocument(documentId, options = {}) {
723
788
  const filePath = getDocumentPath(String(documentId || '').trim(), options);
724
789
  if (!fs.existsSync(filePath)) return null;
725
790
  try {
726
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
791
+ const document = JSON.parse(fs.readFileSync(filePath, 'utf8'));
792
+ return documentAccessAllowed(document, options.accessContext) ? document : null;
727
793
  } catch {
728
794
  return null;
729
795
  }
@@ -739,6 +805,7 @@ function listImportedDocuments(options = {}) {
739
805
  const documents = readJsonl(catalogPath);
740
806
 
741
807
  const filtered = documents.filter((document) => {
808
+ if (!documentAccessAllowed(document, options.accessContext)) return false;
742
809
  const tags = safeArray(document.tags).map((tag) => String(tag).toLowerCase());
743
810
  const matchedTemplateIds = safeArray(document.matchedTemplateIds).map((tag) => String(tag).toLowerCase());
744
811
  if (requestedTag && !tags.includes(requestedTag) && !matchedTemplateIds.includes(requestedTag)) {
@@ -765,10 +832,11 @@ function persistDocument(document, options = {}) {
765
832
  const paths = getDocumentStorePaths(options);
766
833
  ensureDir(paths.documentsDir);
767
834
  writeJson(getDocumentPath(document.documentId, options), document);
768
- const summaries = listImportedDocuments({
769
- ...options,
770
- limit: MAX_SEARCH_SCAN,
771
- }).documents.filter((entry) => entry.documentId !== document.documentId);
835
+ // Persistence must retain entries the current principal cannot read; using
836
+ // the public list function here would silently delete other principals'
837
+ // catalog rows during an import.
838
+ const summaries = readJsonl(paths.catalogPath)
839
+ .filter((entry) => entry.documentId !== document.documentId);
772
840
  const nextSummaries = [
773
841
  buildDocumentSummary(document),
774
842
  ...summaries,
@@ -849,6 +917,188 @@ function searchImportedDocuments(options = {}) {
849
917
  return docs.slice(0, limit);
850
918
  }
851
919
 
920
+ function importedDocumentMatchesFilters(document, filters = {}) {
921
+ if (!filters || typeof filters !== 'object') return true;
922
+ const normalize = (value) => (
923
+ (Array.isArray(value) ? value : value == null ? [] : [value])
924
+ .map((item) => String(item).trim().toLowerCase())
925
+ .filter(Boolean)
926
+ );
927
+ for (const [field, actualValue] of [
928
+ ['sourceFormat', document.sourceFormat],
929
+ ['sourceType', document.sourceType],
930
+ ]) {
931
+ const required = normalize(filters[field]);
932
+ if (required.length > 0 && !required.includes(String(actualValue || '').toLowerCase())) {
933
+ return false;
934
+ }
935
+ }
936
+ const requiredTags = normalize(filters.tags);
937
+ if (requiredTags.length > 0) {
938
+ const actualTags = normalize(document.tags);
939
+ const matches = filters.requireAllTags === false
940
+ ? requiredTags.some((tag) => actualTags.includes(tag))
941
+ : requiredTags.every((tag) => actualTags.includes(tag));
942
+ if (!matches) return false;
943
+ }
944
+ return true;
945
+ }
946
+
947
+ /**
948
+ * Production document retrieval: rank child chunks, then hydrate their parent
949
+ * documents. Whole-file results stay bounded while evidence can come from any
950
+ * position in a long document.
951
+ */
952
+ async function searchImportedDocumentsAsync(options = {}) {
953
+ const query = String(options.query || '').trim();
954
+ if (!query) throw new Error('query is required');
955
+ const limit = Number.isFinite(Number(options.limit))
956
+ ? Math.max(1, Math.min(50, Number(options.limit)))
957
+ : 10;
958
+ const documents = listImportedDocuments({
959
+ ...options,
960
+ limit: MAX_SEARCH_SCAN,
961
+ query: '',
962
+ }).documents
963
+ .map((summary) => readImportedDocument(summary.documentId, options))
964
+ .filter(Boolean)
965
+ .filter((document) => importedDocumentMatchesFilters(
966
+ document,
967
+ options.metadataFilters || options.filters,
968
+ ));
969
+ if (documents.length === 0) return [];
970
+
971
+ const { runDocumentPipeline } = require('./rag-document-pipeline');
972
+ const { pragmaticHybridSearch } = require('./pragmatic-hybrid-search');
973
+ const { buildQueryPlan, reciprocalRankFusion } = require('./lesson-retrieval');
974
+ const pipeline = runDocumentPipeline(documents.map((document) => ({
975
+ type: 'text',
976
+ id: document.documentId,
977
+ title: document.title,
978
+ content: document.content,
979
+ tags: document.tags,
980
+ source: 'imported_document',
981
+ metadata: {
982
+ documentId: document.documentId,
983
+ sourceFormat: document.sourceFormat,
984
+ sourceType: document.sourceType,
985
+ importedAt: document.importedAt,
986
+ },
987
+ })), {
988
+ maxChars: options.maxChunkChars || 900,
989
+ overlap: options.chunkOverlap || 120,
990
+ });
991
+ const chunks = pipeline.chunks;
992
+ if (chunks.length === 0) return [];
993
+
994
+ const queryPlan = await buildQueryPlan(query, options);
995
+ const queryVariants = queryPlan.variants;
996
+ let denseRankedIds = [];
997
+ let semanticProvider = null;
998
+ try {
999
+ const embeddingIndex = require('./lesson-embedding-index');
1000
+ if (options.embedder || embeddingIndex.isEmbedderAvailable()) {
1001
+ const denseLists = [];
1002
+ for (const variant of queryVariants) {
1003
+ const dense = await embeddingIndex.semanticRank(variant, chunks, {
1004
+ feedbackDir: options.feedbackDir,
1005
+ embedder: options.embedder,
1006
+ embedderId: options.embedderId,
1007
+ cacheFile: 'document-chunk-embeddings.json',
1008
+ // `chunks` is metadata-filtered. Pruning this shared cache against a
1009
+ // subset would make alternating filters evict and re-embed each other.
1010
+ pruneCache: false,
1011
+ });
1012
+ const topScore = dense[0]?.score ?? 0;
1013
+ const minimum = Math.max(
1014
+ Number(options.minSemanticScore) || 0.15,
1015
+ topScore - (Number(options.semanticScoreWindow) || 0.2),
1016
+ );
1017
+ denseLists.push(
1018
+ dense.filter((entry) => entry.score >= minimum).map((entry) => entry.id),
1019
+ );
1020
+ }
1021
+ denseRankedIds = reciprocalRankFusion(denseLists).map((entry) => entry.id);
1022
+ semanticProvider = options.embedderId || 'configured';
1023
+ }
1024
+ } catch {
1025
+ denseRankedIds = [];
1026
+ }
1027
+
1028
+ const { results: rankedChunks, meta } = pragmaticHybridSearch({
1029
+ corpus: chunks,
1030
+ query,
1031
+ toolName: options.toolName || 'Read',
1032
+ options: {
1033
+ topK: Math.min(chunks.length, Math.max(limit * 4, 20)),
1034
+ pool: Math.min(chunks.length, Math.max(limit * 10, 50)),
1035
+ queryVariants,
1036
+ denseRankedIds,
1037
+ diversify: false,
1038
+ },
1039
+ });
1040
+ const lexicalEvidenceTokens = Array.from(new Set(
1041
+ queryVariants.flatMap((variant) => tokenize(variant)),
1042
+ ));
1043
+ const denseEvidenceIds = new Set(denseRankedIds);
1044
+ const byDocumentId = new Map(documents.map((document) => [document.documentId, document]));
1045
+ const grouped = new Map();
1046
+ for (const chunk of rankedChunks) {
1047
+ const documentId = chunk.metadata?.documentId;
1048
+ if (!documentId || !byDocumentId.has(documentId)) continue;
1049
+ const lexicalHaystack = [
1050
+ chunk.title,
1051
+ chunk.content,
1052
+ safeArray(chunk.tags).join(' '),
1053
+ ].join(' ').toLowerCase();
1054
+ const hasQueryOverlap = lexicalEvidenceTokens.some((token) => lexicalHaystack.includes(token));
1055
+ const hasDenseEvidence = denseEvidenceIds.has(chunk.id);
1056
+ // A synthetic tool name such as `Read` can contribute to scoreRelevance,
1057
+ // but it is not query evidence. Require lexical overlap or a dense hit.
1058
+ if (!hasQueryOverlap && !hasDenseEvidence) continue;
1059
+ const score = Number(chunk.rerankedScore ?? chunk.relevanceScore ?? 0);
1060
+ const group = grouped.get(documentId) || { score, chunks: [] };
1061
+ group.score = Math.max(group.score, score);
1062
+ if (group.chunks.length < 3) {
1063
+ group.chunks.push({
1064
+ chunkId: chunk.id,
1065
+ chunkIndex: chunk.metadata?.chunkIndex ?? 0,
1066
+ startChar: chunk.metadata?.startChar ?? null,
1067
+ endChar: chunk.metadata?.endChar ?? null,
1068
+ content: String(chunk.content || '').slice(0, 700),
1069
+ score: Number(score.toFixed(4)),
1070
+ });
1071
+ }
1072
+ grouped.set(documentId, group);
1073
+ }
1074
+
1075
+ const queryTokens = tokenize(query);
1076
+ return [...grouped.entries()]
1077
+ .sort((left, right) => right[1].score - left[1].score)
1078
+ .slice(0, limit)
1079
+ .map(([documentId, group]) => {
1080
+ const document = byDocumentId.get(documentId);
1081
+ return {
1082
+ ...document,
1083
+ excerpt: group.chunks[0]?.content || document.excerpt,
1084
+ _score: Number(group.score.toFixed(4)),
1085
+ _matchedTokens: queryTokens.filter((token) => (
1086
+ group.chunks.some((chunk) => chunk.content.toLowerCase().includes(token))
1087
+ )),
1088
+ _matchedChunks: group.chunks,
1089
+ _retrieval: {
1090
+ strategy: meta.strategy,
1091
+ parentChild: true,
1092
+ chunkCount: chunks.length,
1093
+ queryVariants,
1094
+ queryTransformation: queryPlan,
1095
+ densePool: meta.densePool,
1096
+ semanticProvider,
1097
+ },
1098
+ };
1099
+ });
1100
+ }
1101
+
852
1102
  function importDocument(options = {}) {
853
1103
  const hasFilePath = Boolean(options.filePath);
854
1104
  const hasContent = typeof options.content === 'string' && options.content.trim().length > 0;
@@ -881,10 +1131,30 @@ function importDocument(options = {}) {
881
1131
  normalizedContent,
882
1132
  sourceFormat,
883
1133
  });
1134
+ const access = buildDocumentAccess(options);
884
1135
  const fingerprint = sha256(`${title}\n${normalizedContent}`);
1136
+ // Protected documents need a storage identity that includes their owning
1137
+ // scope. Otherwise identical content imported by two private principals (or
1138
+ // as both tenant-visible and private) would overwrite the first document and
1139
+ // silently replace its ACL. Keep legacy local IDs unchanged.
1140
+ const storageScope = !access
1141
+ ? null
1142
+ : access.visibility === 'private'
1143
+ ? JSON.stringify({
1144
+ tenantId: access.tenantId,
1145
+ visibility: 'private',
1146
+ ownerId: access.ownerId,
1147
+ })
1148
+ : JSON.stringify({
1149
+ tenantId: access.tenantId,
1150
+ visibility: 'tenant',
1151
+ });
1152
+ const storageFingerprint = storageScope
1153
+ ? sha256(`${fingerprint}\n${storageScope}`)
1154
+ : fingerprint;
885
1155
  const importedAt = nowIso();
886
1156
  const sourceName = sourcePath ? path.basename(sourcePath) : null;
887
- const documentId = `doc_${slugify(title || sourceName || 'document').slice(0, 24) || 'document'}_${fingerprint.slice(0, 12)}`;
1157
+ const documentId = `doc_${slugify(title || sourceName || 'document').slice(0, 24) || 'document'}_${storageFingerprint.slice(0, 12)}`;
888
1158
  const document = {
889
1159
  documentId,
890
1160
  title,
@@ -902,6 +1172,7 @@ function importDocument(options = {}) {
902
1172
  lineCount: normalizedContent.split('\n').filter(Boolean).length,
903
1173
  headings: extractHeadings(normalizedContent),
904
1174
  };
1175
+ if (access) document.access = access;
905
1176
  document.proposals = options.proposeGates === false
906
1177
  ? []
907
1178
  : proposeGatesFromDocument(document, options);
@@ -924,4 +1195,9 @@ module.exports = {
924
1195
  proposeGatesFromDocument,
925
1196
  readImportedDocument,
926
1197
  searchImportedDocuments,
1198
+ searchImportedDocumentsAsync,
1199
+ importedDocumentMatchesFilters,
1200
+ buildDocumentAccess,
1201
+ documentAccessAllowed,
1202
+ normalizeAccessContext,
927
1203
  };
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Unified bounded offline evaluation suite:
6
+ * 1) IR ranking — Recall@k / MRR / nDCG / Precision@k (retrieval-ranking-golden)
7
+ * 2) Generation quality — faithfulness / groundedness / answer_relevance (offline)
8
+ *
9
+ * Exit 0 only when both floors pass. Offline by default (no API key required).
10
+ *
11
+ * npm run eval:quality
12
+ * node scripts/eval-quality-suite.js --json
13
+ */
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const {
18
+ evaluateRankingGolden,
19
+ formatRankingReport,
20
+ } = require('./retrieval-ranking-eval');
21
+ const {
22
+ evaluateGenerationGolden,
23
+ METRICS_VERSION,
24
+ } = require('./ragas-style-metrics');
25
+
26
+ const ROOT = path.join(__dirname, '..');
27
+ const GEN_GOLDEN = path.join(ROOT, 'config', 'evals', 'generation-quality-golden.json');
28
+ const REPORT_MD = path.join(ROOT, 'reports', 'eval-quality-suite.md');
29
+ const REPORT_JSON = path.join(ROOT, 'reports', 'eval-quality-suite.json');
30
+ const SUITE_VERSION = '2026-07-31.a-plus.1';
31
+
32
+ function loadGenerationGolden() {
33
+ return JSON.parse(fs.readFileSync(GEN_GOLDEN, 'utf8'));
34
+ }
35
+
36
+ /**
37
+ * Drop expectFail / known-bad demos from floor means (still reported in rows).
38
+ */
39
+ function filterGenerationCases(golden) {
40
+ const cases = (golden.cases || []).filter((c) => !c.expectFail);
41
+ return { ...golden, cases };
42
+ }
43
+
44
+ function runSuite(options = {}) {
45
+ const ranking = evaluateRankingGolden({
46
+ goldenPath: options.rankingGoldenPath,
47
+ thresholds: options.rankingThresholds,
48
+ });
49
+
50
+ const genGoldenRaw = options.generationGolden || loadGenerationGolden();
51
+ const genGolden = filterGenerationCases(genGoldenRaw);
52
+ const generation = evaluateGenerationGolden(genGolden, {
53
+ thresholds: options.generationThresholds,
54
+ });
55
+
56
+ // Also score known-bad cases for diagnostics (must score worse on faithfulness)
57
+ const badCases = (genGoldenRaw.cases || []).filter((c) => c.expectFail || c.id === 'ungrounded-contradiction');
58
+ const badScores = badCases.map((c) => require('./ragas-style-metrics').scoreGenerationCase(c));
59
+
60
+ const failures = [
61
+ ...(ranking.passed ? [] : ranking.failures.map((f) => `ranking: ${f}`)),
62
+ ...(generation.passed ? [] : generation.failures.map((f) => `generation: ${f}`)),
63
+ ];
64
+
65
+ // Integrity: bad contradiction sample should not beat good faithfulness mean
66
+ if (badScores.length > 0 && generation.summary.faithfulness > 0) {
67
+ const badFaith = badScores.reduce((s, r) => s + r.faithfulness, 0) / badScores.length;
68
+ if (badFaith >= generation.summary.faithfulness) {
69
+ failures.push(
70
+ `integrity: bad-case faithfulness ${badFaith.toFixed(3)} >= good mean ${generation.summary.faithfulness}`,
71
+ );
72
+ }
73
+ }
74
+
75
+ const report = {
76
+ suiteVersion: SUITE_VERSION,
77
+ metricsVersion: METRICS_VERSION,
78
+ passed: failures.length === 0,
79
+ failures,
80
+ ranking: {
81
+ passed: ranking.passed,
82
+ failures: ranking.failures,
83
+ summary: ranking.summary,
84
+ thresholds: ranking.thresholds,
85
+ queryCount: ranking.perQuery?.length || 0,
86
+ },
87
+ generation: {
88
+ passed: generation.passed,
89
+ failures: generation.failures,
90
+ summary: generation.summary,
91
+ thresholds: generation.thresholds,
92
+ caseCount: generation.rows?.length || 0,
93
+ badCaseDiagnostics: badScores,
94
+ },
95
+ grades: {
96
+ recallAtK: ranking.passed ? 'A+' : 'fail',
97
+ mrr: ranking.passed ? 'A+' : 'fail',
98
+ ndcg: ranking.passed ? 'A+' : 'fail',
99
+ precisionAtK: ranking.passed ? 'A+' : 'fail',
100
+ faithfulness: generation.passed ? 'A+' : 'fail',
101
+ groundedness: generation.passed ? 'A+' : 'fail',
102
+ answerRelevance: generation.passed ? 'A+' : 'fail',
103
+ overall: failures.length === 0 ? 'A+' : 'fail',
104
+ },
105
+ };
106
+
107
+ return { report, ranking, generation };
108
+ }
109
+
110
+ function formatMarkdown(report) {
111
+ const r = report.ranking.summary || {};
112
+ const g = report.generation.summary || {};
113
+ const lines = [
114
+ '# Evaluation quality suite (bounded offline)',
115
+ '',
116
+ `Suite version: \`${report.suiteVersion}\``,
117
+ `Overall: **${report.passed ? 'PASS' : 'FAIL'}** · grade **${report.grades.overall}**`,
118
+ '',
119
+ '## IR ranking (Recall@k / MRR / nDCG / Precision@k)',
120
+ '',
121
+ `| Metric | Value |`,
122
+ `|--------|------:|`,
123
+ `| Queries | ${report.ranking.queryCount} |`,
124
+ `| MRR | ${((r.mrr || 0) * 100).toFixed(1)}% |`,
125
+ `| Recall@5 | ${((r['recall@5'] || 0) * 100).toFixed(1)}% |`,
126
+ `| Precision@5 | ${((r['precision@5'] || 0) * 100).toFixed(1)}% |`,
127
+ `| nDCG@5 | ${((r['ndcg@5'] || 0) * 100).toFixed(1)}% |`,
128
+ `| Ranking gate | ${report.ranking.passed ? 'PASS' : 'FAIL'} |`,
129
+ '',
130
+ '## Generation quality (offline Ragas-style)',
131
+ '',
132
+ `| Metric | Value |`,
133
+ `|--------|------:|`,
134
+ `| Cases | ${report.generation.caseCount} |`,
135
+ `| Faithfulness | ${((g.faithfulness || 0) * 100).toFixed(1)}% |`,
136
+ `| Groundedness | ${((g.groundedness || 0) * 100).toFixed(1)}% |`,
137
+ `| Answer relevance | ${((g.answer_relevance || 0) * 100).toFixed(1)}% |`,
138
+ `| Context recall | ${((g.context_recall || 0) * 100).toFixed(1)}% |`,
139
+ `| Context precision | ${((g.context_precision || 0) * 100).toFixed(1)}% |`,
140
+ `| Generation gate | ${report.generation.passed ? 'PASS' : 'FAIL'} |`,
141
+ '',
142
+ '## Grades',
143
+ '',
144
+ Object.entries(report.grades).map(([k, v]) => `- **${k}**: ${v}`).join('\n'),
145
+ '',
146
+ ];
147
+ if (report.failures.length) {
148
+ lines.push('## Failures', '', ...report.failures.map((f) => `- ${f}`), '');
149
+ }
150
+ lines.push(
151
+ '## Honesty',
152
+ '',
153
+ '- IR metrics use graded qrels on a self-contained golden corpus.',
154
+ '- Generation metrics are offline lexical/claim proxies (not neural Ragas).',
155
+ '- Optional LLM judges may refine scores but cannot invent a pass when floors fail.',
156
+ '',
157
+ );
158
+ return lines.join('\n');
159
+ }
160
+
161
+ function writeReports(report) {
162
+ fs.mkdirSync(path.dirname(REPORT_MD), { recursive: true });
163
+ fs.writeFileSync(REPORT_MD, formatMarkdown(report));
164
+ fs.writeFileSync(REPORT_JSON, JSON.stringify(report, null, 2) + '\n');
165
+ }
166
+
167
+ function main() {
168
+ const json = process.argv.includes('--json');
169
+ const { report, ranking } = runSuite();
170
+ writeReports(report);
171
+
172
+ if (json) {
173
+ console.log(JSON.stringify(report, null, 2));
174
+ } else {
175
+ console.log(formatMarkdown(report));
176
+ // Also print ranking detail when verbose
177
+ if (process.argv.includes('--verbose') && ranking) {
178
+ console.log('\n' + formatRankingReport(ranking));
179
+ }
180
+ console.error(report.passed
181
+ ? 'eval-quality-suite: PASS (bounded offline floors met)'
182
+ : `eval-quality-suite: FAIL — ${report.failures.join('; ')}`);
183
+ }
184
+ process.exit(report.passed ? 0 : 1);
185
+ }
186
+
187
+ function isCliEntrypoint(argv = process.argv) {
188
+ return Boolean(argv[1]) && path.resolve(argv[1]) === path.resolve(__filename);
189
+ }
190
+
191
+ if (isCliEntrypoint()) {
192
+ main();
193
+ }
194
+
195
+ module.exports = {
196
+ SUITE_VERSION,
197
+ runSuite,
198
+ formatMarkdown,
199
+ loadGenerationGolden,
200
+ GEN_GOLDEN,
201
+ REPORT_MD,
202
+ REPORT_JSON,
203
+ isCliEntrypoint,
204
+ };