@hraness/kb 0.17.1

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 (200) hide show
  1. package/DISCLOSURE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +545 -0
  4. package/dist/agent-context.js +35 -0
  5. package/dist/agent-guide-audit.js +18 -0
  6. package/dist/attachments.js +19 -0
  7. package/dist/authoring.js +33 -0
  8. package/dist/benchmark.js +19 -0
  9. package/dist/capture.js +49 -0
  10. package/dist/cli.js +3646 -0
  11. package/dist/clip/acquire.js +57 -0
  12. package/dist/clip/args.js +21 -0
  13. package/dist/clip/bounded-byte-buffer.js +9 -0
  14. package/dist/clip/browser-profiles.js +17 -0
  15. package/dist/clip/bundle-reader.js +11 -0
  16. package/dist/clip/cli.js +27 -0
  17. package/dist/clip/cookies.js +21 -0
  18. package/dist/clip/defuddle-worker.js +84 -0
  19. package/dist/clip/doctor.js +32 -0
  20. package/dist/clip/jobs.js +784 -0
  21. package/dist/clip/network-proxy.js +9 -0
  22. package/dist/clip/network.js +28 -0
  23. package/dist/clip/persist.js +30 -0
  24. package/dist/clip/refresh.js +11 -0
  25. package/dist/clip/terminal.js +11 -0
  26. package/dist/evaluation-builder.js +12238 -0
  27. package/dist/evaluation-kb.js +26 -0
  28. package/dist/evaluation.js +35 -0
  29. package/dist/git.js +39 -0
  30. package/dist/graph.js +39 -0
  31. package/dist/index-01jj6rbv.js +1103 -0
  32. package/dist/index-06c9ctr6.js +738 -0
  33. package/dist/index-0kavxzqj.js +2480 -0
  34. package/dist/index-1gwbassd.js +743 -0
  35. package/dist/index-1n418kb9.js +827 -0
  36. package/dist/index-1vrd1rmn.js +56 -0
  37. package/dist/index-1xxnjn0d.js +171 -0
  38. package/dist/index-3rm7cz6h.js +373 -0
  39. package/dist/index-3v2z4f0q.js +328 -0
  40. package/dist/index-48pz4jpc.js +392 -0
  41. package/dist/index-4j3tt0c3.js +234 -0
  42. package/dist/index-5n05se68.js +307 -0
  43. package/dist/index-5vwpzb5a.js +764 -0
  44. package/dist/index-6g2pv9d2.js +370 -0
  45. package/dist/index-7fzc32gf.js +712 -0
  46. package/dist/index-84x0vjjp.js +432 -0
  47. package/dist/index-adx6khj5.js +488 -0
  48. package/dist/index-b88v3vtm.js +805 -0
  49. package/dist/index-cv6fh7z5.js +462 -0
  50. package/dist/index-cxfrakt7.js +1067 -0
  51. package/dist/index-d13v9ckt.js +150 -0
  52. package/dist/index-dycjbhw2.js +1943 -0
  53. package/dist/index-dyqwejk5.js +531 -0
  54. package/dist/index-e5fbsywq.js +658 -0
  55. package/dist/index-f984hw45.js +927 -0
  56. package/dist/index-gh719d91.js +63 -0
  57. package/dist/index-hgve9rh2.js +2046 -0
  58. package/dist/index-hya40gb2.js +474 -0
  59. package/dist/index-j4zgmzjr.js +152 -0
  60. package/dist/index-jsmvyyvf.js +1166 -0
  61. package/dist/index-kh0pwvdd.js +1974 -0
  62. package/dist/index-mqx4nd6v.js +119 -0
  63. package/dist/index-mxxxytys.js +1254 -0
  64. package/dist/index-n5dd7r0v.js +711 -0
  65. package/dist/index-npg9z1a4.js +431 -0
  66. package/dist/index-pj501bh1.js +140 -0
  67. package/dist/index-qry4vhxk.js +1041 -0
  68. package/dist/index-s2gw5aw9.js +199 -0
  69. package/dist/index-vvdj6kca.js +70 -0
  70. package/dist/index-w2zc0vwa.js +571 -0
  71. package/dist/index-x3fthpsc.js +585 -0
  72. package/dist/index-zr53sf63.js +44 -0
  73. package/dist/index-zxdy5pby.js +2728 -0
  74. package/dist/index-zzhgcwyt.js +966 -0
  75. package/dist/index.js +453 -0
  76. package/dist/navigation.js +13 -0
  77. package/dist/pdf.js +85 -0
  78. package/dist/percolate.js +24 -0
  79. package/dist/portfolio.js +77 -0
  80. package/dist/query.js +29 -0
  81. package/dist/repository-memory.js +60 -0
  82. package/dist/sdk.js +36 -0
  83. package/dist/search-rules.js +34 -0
  84. package/dist/search.js +22 -0
  85. package/dist/semantic.js +39 -0
  86. package/dist/source-inbox.js +17 -0
  87. package/dist/untrusted-content.js +32 -0
  88. package/dist/url-intelligence.js +74 -0
  89. package/dist/workflow.js +23 -0
  90. package/dist/workflows/decision-context.js +20 -0
  91. package/dist/workflows/explain-change.js +8 -0
  92. package/dist/workflows/index.js +28 -0
  93. package/dist/workflows/plan-radar.js +10 -0
  94. package/package.json +409 -0
  95. package/skills/kb/AGENTS.md +19 -0
  96. package/skills/kb/SKILL.md +82 -0
  97. package/skills/kb/agents/openai.yaml +4 -0
  98. package/skills/kb/references/pdf-review.md +51 -0
  99. package/skills/kb/references/percolate.md +144 -0
  100. package/skills/kb/references/plan-structure.md +105 -0
  101. package/skills/kb/references/plan.md +115 -0
  102. package/skills/kb/references/query.md +231 -0
  103. package/skills/kb/references/refresh.md +133 -0
  104. package/skills/kb/references/save-pdf.md +125 -0
  105. package/skills/kb/references/save-url.md +178 -0
  106. package/skills/kb/references/url-authentication.md +75 -0
  107. package/skills/kb/references/url-platforms.md +25 -0
  108. package/src/agent-context.ts +1241 -0
  109. package/src/agent-guide-audit.ts +750 -0
  110. package/src/attachments.ts +723 -0
  111. package/src/authoring.ts +1725 -0
  112. package/src/benchmark.ts +362 -0
  113. package/src/capture.ts +68 -0
  114. package/src/cli.ts +3660 -0
  115. package/src/clip/acquire.ts +1273 -0
  116. package/src/clip/archive-today.ts +402 -0
  117. package/src/clip/args.ts +423 -0
  118. package/src/clip/assets.ts +262 -0
  119. package/src/clip/bounded-byte-buffer.ts +75 -0
  120. package/src/clip/browser-profiles.ts +372 -0
  121. package/src/clip/bundle-reader.ts +608 -0
  122. package/src/clip/capture.ts +1054 -0
  123. package/src/clip/cli.ts +181 -0
  124. package/src/clip/cookies.ts +512 -0
  125. package/src/clip/doctor.ts +1110 -0
  126. package/src/clip/extract.ts +868 -0
  127. package/src/clip/jobs.ts +959 -0
  128. package/src/clip/lib.ts +861 -0
  129. package/src/clip/media.ts +1474 -0
  130. package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
  131. package/src/clip/metadata-search-tool/Cargo.toml +18 -0
  132. package/src/clip/metadata-search-tool/runner.ts +685 -0
  133. package/src/clip/metadata-search-tool/src/main.rs +389 -0
  134. package/src/clip/metadata-search.ts +687 -0
  135. package/src/clip/network-proxy.ts +627 -0
  136. package/src/clip/network.ts +950 -0
  137. package/src/clip/package-root.ts +54 -0
  138. package/src/clip/persist.ts +1472 -0
  139. package/src/clip/platforms.ts +1824 -0
  140. package/src/clip/refresh.ts +197 -0
  141. package/src/clip/structured.ts +432 -0
  142. package/src/clip/terminal.ts +197 -0
  143. package/src/clip/url-intelligence.ts +581 -0
  144. package/src/clip/url-metadata-backfill.ts +616 -0
  145. package/src/clip/url-metadata-cli.ts +251 -0
  146. package/src/clip/url-metadata.ts +1397 -0
  147. package/src/evaluation-analysis-v2.ts +1440 -0
  148. package/src/evaluation-builder.ts +2539 -0
  149. package/src/evaluation-corpus-authoring.ts +2052 -0
  150. package/src/evaluation-evidence.ts +1147 -0
  151. package/src/evaluation-execution-v2.ts +426 -0
  152. package/src/evaluation-implementation.ts +131 -0
  153. package/src/evaluation-kb-closure.ts +1430 -0
  154. package/src/evaluation-kb-runner-v2.ts +735 -0
  155. package/src/evaluation-kb-v2.ts +2567 -0
  156. package/src/evaluation-kb.ts +1018 -0
  157. package/src/evaluation-measurement-v2.ts +1462 -0
  158. package/src/evaluation-packing-v2.ts +452 -0
  159. package/src/evaluation-redaction.ts +28 -0
  160. package/src/evaluation-v2.ts +3857 -0
  161. package/src/evaluation.ts +1220 -0
  162. package/src/git.ts +1276 -0
  163. package/src/graph.ts +1487 -0
  164. package/src/index.ts +20 -0
  165. package/src/init.ts +123 -0
  166. package/src/navigation.ts +277 -0
  167. package/src/note-lock.ts +529 -0
  168. package/src/pdf/args.ts +202 -0
  169. package/src/pdf/capture.ts +298 -0
  170. package/src/pdf/cli.ts +159 -0
  171. package/src/pdf/extract.ts +303 -0
  172. package/src/pdf/layout.ts +618 -0
  173. package/src/pdf/markdown.ts +154 -0
  174. package/src/pdf/model.ts +234 -0
  175. package/src/pdf/ocr.ts +190 -0
  176. package/src/pdf/persist.ts +314 -0
  177. package/src/pdf/source.ts +135 -0
  178. package/src/pdf/tools.ts +146 -0
  179. package/src/pdf.ts +92 -0
  180. package/src/percolate.ts +877 -0
  181. package/src/portfolio-audit.ts +574 -0
  182. package/src/portfolio-identity.ts +162 -0
  183. package/src/portfolio-registry.ts +473 -0
  184. package/src/portfolio.ts +765 -0
  185. package/src/query.ts +646 -0
  186. package/src/repository-memory.ts +1150 -0
  187. package/src/sdk.ts +1452 -0
  188. package/src/search-rules.ts +712 -0
  189. package/src/search.ts +710 -0
  190. package/src/semantic-runtime.ts +1139 -0
  191. package/src/semantic.ts +2480 -0
  192. package/src/source-inbox.ts +232 -0
  193. package/src/untrusted-content.ts +401 -0
  194. package/src/url-intelligence.ts +104 -0
  195. package/src/vault.ts +636 -0
  196. package/src/workflow.ts +653 -0
  197. package/src/workflows/decision-context.ts +81 -0
  198. package/src/workflows/explain-change.ts +73 -0
  199. package/src/workflows/index.ts +3 -0
  200. package/src/workflows/plan-radar.ts +101 -0
@@ -0,0 +1,1220 @@
1
+ import { redactEvaluationMachinePaths } from "./evaluation-redaction.js";
2
+
3
+ export const RETRIEVAL_EVALUATION_SCHEMA_VERSION = 1;
4
+ export const RETRIEVAL_EVALUATION_REPORT_VERSION = 1;
5
+ export const MAX_EVALUATION_QUERIES = 2_000;
6
+ export const MAX_EVALUATION_QRELS_PER_QUERY = 2_000;
7
+ export const MAX_EVALUATION_RETRIEVERS = 32;
8
+ export const MAX_EVALUATION_RESULTS_PER_QUERY = 1_000;
9
+ export const MAX_EVALUATION_TEXT_BYTES = 16 * 1_024;
10
+ export const MAX_EVALUATION_EVIDENCE_BYTES = 64 * 1_024;
11
+ export const MAX_EVALUATION_DIAGNOSTICS = 100;
12
+ export const MAX_EVALUATION_TIMEOUT_MS = 5 * 60_000;
13
+ export const MAX_BOOTSTRAP_RESAMPLES = 100_000;
14
+
15
+ const objectIdPattern = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
16
+ const sha256Pattern = /^[0-9a-f]{64}$/u;
17
+ const windowsAbsolutePattern = /^[a-z]:[\\/]/iu;
18
+
19
+ export type EvaluationQueryClass =
20
+ | "active-plan"
21
+ | "code-path-context"
22
+ | "conceptual-recall"
23
+ | "current-decision"
24
+ | "exact-identifier"
25
+ | "historical-rationale"
26
+ | "no-answer"
27
+ | "source-evidence"
28
+ | "stale-current-conflict";
29
+
30
+ export type EvaluationSplit = "development" | "test";
31
+
32
+ export type EvaluationAssessor = {
33
+ readonly id: string;
34
+ readonly displayName?: string;
35
+ readonly affiliation?: string;
36
+ };
37
+
38
+ export type EvaluationAdjudication = {
39
+ readonly status: "not-required" | "resolved";
40
+ readonly adjudicatorId?: string;
41
+ readonly note?: string;
42
+ };
43
+
44
+ export type EvaluationQrel = {
45
+ readonly documentId: string;
46
+ readonly relevance: 0 | 1 | 2 | 3;
47
+ };
48
+
49
+ export type EvaluationMetadataFilter =
50
+ | { readonly kind: "exists"; readonly path: string }
51
+ | {
52
+ readonly kind: "equals";
53
+ readonly path: string;
54
+ readonly value: string | number | boolean | null;
55
+ };
56
+
57
+ export type EvaluationRetrievalInputs = {
58
+ /** Executable text for exact, keyword, semantic, or fused text lanes. */
59
+ readonly text?: string;
60
+ /** Canonical note identity for direct read or identity fixtures. */
61
+ readonly noteId?: string;
62
+ readonly metadata?: {
63
+ readonly filters: readonly EvaluationMetadataFilter[];
64
+ readonly tags: readonly string[];
65
+ };
66
+ readonly graph?: {
67
+ readonly seeds: readonly string[];
68
+ readonly depth: 1 | 2;
69
+ };
70
+ readonly context?: {
71
+ readonly repositoryPath: string;
72
+ };
73
+ readonly history?: {
74
+ readonly query: string;
75
+ readonly noteIds: readonly string[];
76
+ };
77
+ };
78
+
79
+ export type EvaluationQuery = {
80
+ readonly id: string;
81
+ readonly text: string;
82
+ readonly class: EvaluationQueryClass;
83
+ readonly split: EvaluationSplit;
84
+ readonly answer: "answerable" | "no-answer";
85
+ /** Structured lane inputs keep execution independent from human prose parsing. */
86
+ readonly inputs: EvaluationRetrievalInputs;
87
+ readonly qrels: readonly EvaluationQrel[];
88
+ readonly assessorIds: readonly string[];
89
+ readonly adjudication: EvaluationAdjudication;
90
+ };
91
+
92
+ export type RetrievalEvaluationCorpus = {
93
+ readonly schemaVersion: 1;
94
+ readonly id: string;
95
+ readonly description: string;
96
+ readonly frozen: {
97
+ readonly repositoryCommit: string;
98
+ readonly vaultTree: string;
99
+ readonly vaultRoot: string;
100
+ };
101
+ readonly assessment: {
102
+ readonly rubricVersion: string;
103
+ readonly assessors: readonly EvaluationAssessor[];
104
+ };
105
+ readonly queries: readonly EvaluationQuery[];
106
+ };
107
+
108
+ function record(value: unknown, label: string): Readonly<Record<string, unknown>> {
109
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
110
+ throw new TypeError(`${label} must be an object.`);
111
+ }
112
+ return value as Readonly<Record<string, unknown>>;
113
+ }
114
+
115
+ function strictKeys(
116
+ value: Readonly<Record<string, unknown>>,
117
+ allowed: readonly string[],
118
+ label: string,
119
+ ): void {
120
+ const allowedSet = new Set(allowed);
121
+ const extra = Object.keys(value).filter((key) => !allowedSet.has(key));
122
+ if (extra.length > 0) throw new TypeError(`${label} has unknown fields: ${extra.toSorted().join(", ")}.`);
123
+ }
124
+
125
+ function boundedString(value: unknown, label: string, maximumBytes = MAX_EVALUATION_TEXT_BYTES): string {
126
+ if (
127
+ typeof value !== "string"
128
+ || value.trim() === ""
129
+ || /[\0\r\n]/u.test(value)
130
+ || Buffer.byteLength(value, "utf8") > maximumBytes
131
+ ) {
132
+ throw new TypeError(`${label} must be a non-empty, single-line string of at most ${maximumBytes} UTF-8 bytes.`);
133
+ }
134
+ return value.normalize("NFC");
135
+ }
136
+
137
+ function optionalBoundedString(value: unknown, label: string): string | undefined {
138
+ return value === undefined ? undefined : boundedString(value, label);
139
+ }
140
+
141
+ function stringArray(value: unknown, label: string, maximum = 100): readonly string[] {
142
+ if (!Array.isArray(value) || value.length < 1 || value.length > maximum) {
143
+ throw new TypeError(`${label} must be a non-empty array with at most ${maximum} entries.`);
144
+ }
145
+ const strings = value.map((entry, index) => boundedString(entry, `${label}[${index}]`));
146
+ if (new Set(strings).size !== strings.length) throw new TypeError(`${label} must not contain duplicates.`);
147
+ return Object.freeze(strings);
148
+ }
149
+
150
+ function frozenVaultRoot(value: unknown): string {
151
+ const path = boundedString(value, "frozen.vaultRoot").replaceAll("\\", "/");
152
+ if (
153
+ path.startsWith("/")
154
+ || windowsAbsolutePattern.test(path)
155
+ || path.split("/").some((part) => part === "" || part === "..")
156
+ ) throw new TypeError("frozen.vaultRoot must be a confined repository-relative path.");
157
+ return path.replace(/^\.\//u, "");
158
+ }
159
+
160
+ function confinedRelativePath(value: unknown, label: string, allowRoot = false): string {
161
+ const path = boundedString(value, label).replaceAll("\\", "/").replace(/^\.\//u, "");
162
+ if (allowRoot && path === ".") return path;
163
+ if (
164
+ path.startsWith("/")
165
+ || windowsAbsolutePattern.test(path)
166
+ || path.split("/").some((part) => part === "" || part === "." || part === "..")
167
+ ) throw new TypeError(`${label} must be a confined repository-relative path.`);
168
+ return path;
169
+ }
170
+
171
+ const queryClasses = new Set<EvaluationQueryClass>([
172
+ "active-plan",
173
+ "code-path-context",
174
+ "conceptual-recall",
175
+ "current-decision",
176
+ "exact-identifier",
177
+ "historical-rationale",
178
+ "no-answer",
179
+ "source-evidence",
180
+ "stale-current-conflict",
181
+ ]);
182
+
183
+ function parseAssessor(value: unknown, index: number): EvaluationAssessor {
184
+ const input = record(value, `assessment.assessors[${index}]`);
185
+ strictKeys(input, ["id", "displayName", "affiliation"], `assessment.assessors[${index}]`);
186
+ const displayName = optionalBoundedString(input.displayName, `assessment.assessors[${index}].displayName`);
187
+ const affiliation = optionalBoundedString(input.affiliation, `assessment.assessors[${index}].affiliation`);
188
+ return Object.freeze({
189
+ id: boundedString(input.id, `assessment.assessors[${index}].id`, 256),
190
+ ...(displayName === undefined ? {} : { displayName }),
191
+ ...(affiliation === undefined ? {} : { affiliation }),
192
+ });
193
+ }
194
+
195
+ function parseQrel(value: unknown, queryIndex: number, index: number): EvaluationQrel {
196
+ const label = `queries[${queryIndex}].qrels[${index}]`;
197
+ const input = record(value, label);
198
+ strictKeys(input, ["documentId", "relevance"], label);
199
+ if (
200
+ input.relevance !== 0
201
+ && input.relevance !== 1
202
+ && input.relevance !== 2
203
+ && input.relevance !== 3
204
+ ) throw new TypeError(`${label}.relevance must be an integer from 0 through 3.`);
205
+ return Object.freeze({
206
+ documentId: boundedString(input.documentId, `${label}.documentId`),
207
+ relevance: input.relevance,
208
+ });
209
+ }
210
+
211
+ function parseAdjudication(
212
+ value: unknown,
213
+ queryIndex: number,
214
+ assessors: ReadonlySet<string>,
215
+ ): EvaluationAdjudication {
216
+ const label = `queries[${queryIndex}].adjudication`;
217
+ const input = record(value, label);
218
+ strictKeys(input, ["status", "adjudicatorId", "note"], label);
219
+ if (input.status !== "not-required" && input.status !== "resolved") {
220
+ throw new TypeError(`${label}.status must be not-required or resolved.`);
221
+ }
222
+ const adjudicatorId = optionalBoundedString(input.adjudicatorId, `${label}.adjudicatorId`);
223
+ const note = optionalBoundedString(input.note, `${label}.note`);
224
+ if (input.status === "resolved" && adjudicatorId === undefined) {
225
+ throw new TypeError(`${label}.adjudicatorId is required for resolved judgments.`);
226
+ }
227
+ if (adjudicatorId !== undefined && !assessors.has(adjudicatorId)) {
228
+ throw new TypeError(`${label}.adjudicatorId must name a declared assessor.`);
229
+ }
230
+ return Object.freeze({
231
+ status: input.status,
232
+ ...(adjudicatorId === undefined ? {} : { adjudicatorId }),
233
+ ...(note === undefined ? {} : { note }),
234
+ });
235
+ }
236
+
237
+ function parseMetadataFilter(
238
+ value: unknown,
239
+ queryIndex: number,
240
+ index: number,
241
+ ): EvaluationMetadataFilter {
242
+ const label = `queries[${queryIndex}].inputs.metadata.filters[${index}]`;
243
+ const input = record(value, label);
244
+ strictKeys(input, ["kind", "path", "value"], label);
245
+ const path = boundedString(input.path, `${label}.path`, 2_048);
246
+ if (input.kind === "exists") {
247
+ if (input.value !== undefined) throw new TypeError(`${label}.value is not allowed for an exists filter.`);
248
+ return Object.freeze({ kind: "exists", path });
249
+ }
250
+ if (input.kind !== "equals") throw new TypeError(`${label}.kind must be exists or equals.`);
251
+ const filterValue = input.value;
252
+ if (
253
+ filterValue !== null
254
+ && typeof filterValue !== "string"
255
+ && typeof filterValue !== "number"
256
+ && typeof filterValue !== "boolean"
257
+ ) throw new TypeError(`${label}.value must be a scalar.`);
258
+ if (typeof filterValue === "number" && !Number.isFinite(filterValue)) {
259
+ throw new TypeError(`${label}.value must be finite.`);
260
+ }
261
+ return Object.freeze({
262
+ kind: "equals",
263
+ path,
264
+ value: typeof filterValue === "string"
265
+ ? boundedString(filterValue, `${label}.value`)
266
+ : filterValue,
267
+ });
268
+ }
269
+
270
+ function optionalStringList(
271
+ value: unknown,
272
+ label: string,
273
+ maximum: number,
274
+ ): readonly string[] {
275
+ if (value === undefined) return Object.freeze([]);
276
+ if (!Array.isArray(value) || value.length > maximum) {
277
+ throw new TypeError(`${label} must be an array with at most ${maximum} entries.`);
278
+ }
279
+ const strings = value.map((entry, index) => boundedString(entry, `${label}[${index}]`));
280
+ if (new Set(strings).size !== strings.length) throw new TypeError(`${label} must not contain duplicates.`);
281
+ return Object.freeze(strings);
282
+ }
283
+
284
+ function parseRetrievalInputs(value: unknown, queryIndex: number): EvaluationRetrievalInputs {
285
+ const label = `queries[${queryIndex}].inputs`;
286
+ const input = record(value, label);
287
+ strictKeys(input, ["text", "noteId", "metadata", "graph", "context", "history"], label);
288
+ if (Object.keys(input).length === 0) throw new TypeError(`${label} must define at least one retrieval lane input.`);
289
+ const text = optionalBoundedString(input.text, `${label}.text`);
290
+ const noteId = input.noteId === undefined
291
+ ? undefined
292
+ : confinedRelativePath(input.noteId, `${label}.noteId`);
293
+
294
+ let metadata: EvaluationRetrievalInputs["metadata"];
295
+ if (input.metadata !== undefined) {
296
+ const metadataInput = record(input.metadata, `${label}.metadata`);
297
+ strictKeys(metadataInput, ["filters", "tags"], `${label}.metadata`);
298
+ const filtersInput = metadataInput.filters ?? [];
299
+ if (!Array.isArray(filtersInput) || filtersInput.length > 32) {
300
+ throw new TypeError(`${label}.metadata.filters must have at most 32 entries.`);
301
+ }
302
+ const filters = filtersInput.map((entry, index) =>
303
+ parseMetadataFilter(entry, queryIndex, index));
304
+ const tags = optionalStringList(metadataInput.tags, `${label}.metadata.tags`, 32);
305
+ if (filters.length === 0 && tags.length === 0) {
306
+ throw new TypeError(`${label}.metadata must contain a filter or tag.`);
307
+ }
308
+ metadata = Object.freeze({ filters: Object.freeze(filters), tags });
309
+ }
310
+
311
+ let graph: EvaluationRetrievalInputs["graph"];
312
+ if (input.graph !== undefined) {
313
+ const graphInput = record(input.graph, `${label}.graph`);
314
+ strictKeys(graphInput, ["seeds", "depth"], `${label}.graph`);
315
+ const seeds = stringArray(graphInput.seeds, `${label}.graph.seeds`, 10).map((seed, index) =>
316
+ confinedRelativePath(seed, `${label}.graph.seeds[${index}]`));
317
+ if (graphInput.depth !== 1 && graphInput.depth !== 2) {
318
+ throw new TypeError(`${label}.graph.depth must be 1 or 2.`);
319
+ }
320
+ graph = Object.freeze({ seeds: Object.freeze(seeds), depth: graphInput.depth });
321
+ }
322
+
323
+ let context: EvaluationRetrievalInputs["context"];
324
+ if (input.context !== undefined) {
325
+ const contextInput = record(input.context, `${label}.context`);
326
+ strictKeys(contextInput, ["repositoryPath"], `${label}.context`);
327
+ context = Object.freeze({
328
+ repositoryPath: confinedRelativePath(
329
+ contextInput.repositoryPath,
330
+ `${label}.context.repositoryPath`,
331
+ true,
332
+ ),
333
+ });
334
+ }
335
+
336
+ let history: EvaluationRetrievalInputs["history"];
337
+ if (input.history !== undefined) {
338
+ const historyInput = record(input.history, `${label}.history`);
339
+ strictKeys(historyInput, ["query", "noteIds"], `${label}.history`);
340
+ const noteIds = optionalStringList(historyInput.noteIds, `${label}.history.noteIds`, 100)
341
+ .map((id, index) => confinedRelativePath(id, `${label}.history.noteIds[${index}]`));
342
+ history = Object.freeze({
343
+ query: boundedString(historyInput.query, `${label}.history.query`, 2_048),
344
+ noteIds: Object.freeze(noteIds),
345
+ });
346
+ }
347
+ const parsed = {
348
+ ...(text === undefined ? {} : { text }),
349
+ ...(noteId === undefined ? {} : { noteId }),
350
+ ...(metadata === undefined ? {} : { metadata }),
351
+ ...(graph === undefined ? {} : { graph }),
352
+ ...(context === undefined ? {} : { context }),
353
+ ...(history === undefined ? {} : { history }),
354
+ };
355
+ if (Object.keys(parsed).length === 0) {
356
+ throw new TypeError(`${label} must define at least one retrieval lane input.`);
357
+ }
358
+ return Object.freeze(parsed);
359
+ }
360
+
361
+ function parseQuery(
362
+ value: unknown,
363
+ index: number,
364
+ assessorIds: ReadonlySet<string>,
365
+ ): EvaluationQuery {
366
+ const label = `queries[${index}]`;
367
+ const input = record(value, label);
368
+ strictKeys(
369
+ input,
370
+ ["id", "text", "class", "split", "answer", "inputs", "qrels", "assessorIds", "adjudication"],
371
+ label,
372
+ );
373
+ if (typeof input.class !== "string" || !queryClasses.has(input.class as EvaluationQueryClass)) {
374
+ throw new TypeError(`${label}.class is not a supported query class.`);
375
+ }
376
+ if (input.split !== "development" && input.split !== "test") {
377
+ throw new TypeError(`${label}.split must be development or test.`);
378
+ }
379
+ if (input.answer !== "answerable" && input.answer !== "no-answer") {
380
+ throw new TypeError(`${label}.answer must be answerable or no-answer.`);
381
+ }
382
+ if (!Array.isArray(input.qrels) || input.qrels.length > MAX_EVALUATION_QRELS_PER_QUERY) {
383
+ throw new TypeError(`${label}.qrels must have at most ${MAX_EVALUATION_QRELS_PER_QUERY} entries.`);
384
+ }
385
+ const qrels = input.qrels.map((entry, qrelIndex) => parseQrel(entry, index, qrelIndex));
386
+ const documentIds = qrels.map(({ documentId }) => documentId);
387
+ if (new Set(documentIds).size !== documentIds.length) {
388
+ throw new TypeError(`${label}.qrels must not repeat a document ID.`);
389
+ }
390
+ const queryAssessorIds = stringArray(input.assessorIds, `${label}.assessorIds`);
391
+ for (const id of queryAssessorIds) {
392
+ if (!assessorIds.has(id)) throw new TypeError(`${label}.assessorIds names undeclared assessor ${id}.`);
393
+ }
394
+ if (input.answer === "answerable" && !qrels.some(({ relevance }) => relevance > 0)) {
395
+ throw new TypeError(`${label} is answerable but has no positive relevance judgment.`);
396
+ }
397
+ if (input.answer === "no-answer" && qrels.some(({ relevance }) => relevance > 0)) {
398
+ throw new TypeError(`${label} is no-answer but has a positive relevance judgment.`);
399
+ }
400
+ return Object.freeze({
401
+ id: boundedString(input.id, `${label}.id`, 256),
402
+ text: boundedString(input.text, `${label}.text`),
403
+ class: input.class as EvaluationQueryClass,
404
+ split: input.split,
405
+ answer: input.answer,
406
+ inputs: parseRetrievalInputs(input.inputs, index),
407
+ qrels: Object.freeze(qrels),
408
+ assessorIds: queryAssessorIds,
409
+ adjudication: parseAdjudication(input.adjudication, index, assessorIds),
410
+ });
411
+ }
412
+
413
+ /** Parse a strict, versioned real-corpus evaluation definition from unknown input. */
414
+ export function parseRetrievalEvaluationCorpus(inputValue: unknown): RetrievalEvaluationCorpus {
415
+ const input = record(inputValue, "evaluation corpus");
416
+ strictKeys(input, ["schemaVersion", "id", "description", "frozen", "assessment", "queries"], "evaluation corpus");
417
+ if (input.schemaVersion !== RETRIEVAL_EVALUATION_SCHEMA_VERSION) {
418
+ throw new TypeError(`evaluation corpus schemaVersion must be ${RETRIEVAL_EVALUATION_SCHEMA_VERSION}.`);
419
+ }
420
+ const frozenInput = record(input.frozen, "frozen");
421
+ strictKeys(frozenInput, ["repositoryCommit", "vaultTree", "vaultRoot"], "frozen");
422
+ if (typeof frozenInput.repositoryCommit !== "string" || !objectIdPattern.test(frozenInput.repositoryCommit)) {
423
+ throw new TypeError("frozen.repositoryCommit must be a lowercase Git object ID.");
424
+ }
425
+ if (typeof frozenInput.vaultTree !== "string" || !objectIdPattern.test(frozenInput.vaultTree)) {
426
+ throw new TypeError("frozen.vaultTree must be a lowercase Git object ID.");
427
+ }
428
+ const assessmentInput = record(input.assessment, "assessment");
429
+ strictKeys(assessmentInput, ["rubricVersion", "assessors"], "assessment");
430
+ if (!Array.isArray(assessmentInput.assessors) || assessmentInput.assessors.length < 1 || assessmentInput.assessors.length > 100) {
431
+ throw new TypeError("assessment.assessors must have from 1 through 100 entries.");
432
+ }
433
+ const assessors = assessmentInput.assessors.map(parseAssessor);
434
+ const assessorIdList = assessors.map(({ id }) => id);
435
+ if (new Set(assessorIdList).size !== assessorIdList.length) {
436
+ throw new TypeError("assessment.assessors must not repeat an ID.");
437
+ }
438
+ if (!Array.isArray(input.queries) || input.queries.length < 1 || input.queries.length > MAX_EVALUATION_QUERIES) {
439
+ throw new TypeError(`queries must have from 1 through ${MAX_EVALUATION_QUERIES} entries.`);
440
+ }
441
+ const assessorIds = new Set(assessorIdList);
442
+ const queries = input.queries.map((entry, index) => parseQuery(entry, index, assessorIds));
443
+ const queryIds = queries.map(({ id }) => id);
444
+ if (new Set(queryIds).size !== queryIds.length) throw new TypeError("queries must not repeat an ID.");
445
+ if (!queries.some(({ split }) => split === "development") || !queries.some(({ split }) => split === "test")) {
446
+ throw new TypeError("evaluation corpus must contain both development and test queries.");
447
+ }
448
+ return Object.freeze({
449
+ schemaVersion: 1,
450
+ id: boundedString(input.id, "id", 256),
451
+ description: boundedString(input.description, "description"),
452
+ frozen: Object.freeze({
453
+ repositoryCommit: frozenInput.repositoryCommit,
454
+ vaultTree: frozenInput.vaultTree,
455
+ vaultRoot: frozenVaultRoot(frozenInput.vaultRoot),
456
+ }),
457
+ assessment: Object.freeze({
458
+ rubricVersion: boundedString(assessmentInput.rubricVersion, "assessment.rubricVersion", 256),
459
+ assessors: Object.freeze(assessors),
460
+ }),
461
+ queries: Object.freeze(queries),
462
+ });
463
+ }
464
+
465
+ export type EvaluationRawHit = {
466
+ readonly documentId: string;
467
+ /** Raw rank assigned by the retriever before KB evaluation. */
468
+ readonly rank: number;
469
+ readonly score?: number;
470
+ readonly evidence?: unknown;
471
+ };
472
+
473
+ export type EvaluationDiagnostic = {
474
+ readonly lane: string;
475
+ readonly status: "degraded" | "ready" | "unavailable";
476
+ readonly message?: string;
477
+ };
478
+
479
+ export type EvaluationRetrieverResult = {
480
+ readonly status: "degraded" | "ready" | "unavailable";
481
+ readonly hits: readonly EvaluationRawHit[];
482
+ readonly diagnostics?: readonly EvaluationDiagnostic[];
483
+ readonly timings?: Readonly<Record<string, number>>;
484
+ /** Optional raw resource counters. Units belong in the key, such as cpuUserMs. */
485
+ readonly resources?: Readonly<Record<string, number>>;
486
+ };
487
+
488
+ export type EvaluationRetriever = {
489
+ readonly id: string;
490
+ readonly retrieve: (request: {
491
+ readonly corpus: RetrievalEvaluationCorpus["frozen"];
492
+ readonly query: EvaluationQuery;
493
+ readonly limit: number;
494
+ readonly signal: AbortSignal;
495
+ }) => Promise<unknown>;
496
+ };
497
+
498
+ export type EvaluationRunFailure = {
499
+ readonly kind: "exception" | "invalid-result" | "timeout";
500
+ readonly message: string;
501
+ };
502
+
503
+ export type EvaluationRun = {
504
+ readonly retrieverId: string;
505
+ readonly queryId: string;
506
+ readonly queryClass: EvaluationQueryClass;
507
+ readonly split: EvaluationSplit;
508
+ readonly status: "degraded" | "failed" | "ready" | "unavailable";
509
+ readonly hits: readonly EvaluationRawHit[];
510
+ readonly diagnostics: readonly EvaluationDiagnostic[];
511
+ readonly timing: {
512
+ readonly elapsedMs: number;
513
+ readonly backend: Readonly<Record<string, number>>;
514
+ };
515
+ /** Raw bounded counters only; reports do not aggregate unlike units. */
516
+ readonly resources: Readonly<Record<string, number>>;
517
+ readonly failure?: EvaluationRunFailure;
518
+ };
519
+
520
+ function nonnegativeNumberMap(
521
+ value: unknown,
522
+ label: string,
523
+ ): Readonly<Record<string, number>> {
524
+ const input = value === undefined ? {} : record(value, label);
525
+ if (Object.keys(input).length > 32) throw new TypeError(`${label} may have at most 32 fields.`);
526
+ const output: Record<string, number> = {};
527
+ for (const [rawKey, candidate] of Object.entries(input).toSorted(([left], [right]) =>
528
+ left.localeCompare(right))) {
529
+ if (typeof candidate !== "number" || !Number.isFinite(candidate) || candidate < 0) {
530
+ throw new TypeError(`${label} ${rawKey} must be a non-negative finite number.`);
531
+ }
532
+ output[boundedString(rawKey, `${label} key`, 256)] = candidate;
533
+ }
534
+ return Object.freeze(output);
535
+ }
536
+
537
+ function jsonEvidence(value: unknown, label: string): unknown {
538
+ let serialized: string | undefined;
539
+ try {
540
+ serialized = JSON.stringify(value, (_key, candidate: unknown) =>
541
+ typeof candidate === "string" ? redactEvaluationMachinePaths(candidate) : candidate);
542
+ } catch (error: unknown) {
543
+ throw new TypeError(`${label} must be JSON-serializable.`, { cause: error });
544
+ }
545
+ if (serialized === undefined || Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_EVIDENCE_BYTES) {
546
+ throw new TypeError(`${label} must serialize to at most ${MAX_EVALUATION_EVIDENCE_BYTES} UTF-8 bytes.`);
547
+ }
548
+ return JSON.parse(serialized) as unknown;
549
+ }
550
+
551
+ function parseRetrieverResult(value: unknown, limit: number): EvaluationRetrieverResult {
552
+ const input = record(value, "retriever result");
553
+ strictKeys(input, ["status", "hits", "diagnostics", "timings", "resources"], "retriever result");
554
+ if (input.status !== "ready" && input.status !== "degraded" && input.status !== "unavailable") {
555
+ throw new TypeError("retriever result status must be ready, degraded, or unavailable.");
556
+ }
557
+ if (!Array.isArray(input.hits) || input.hits.length > limit) {
558
+ throw new TypeError(`retriever result must have at most ${limit} hits.`);
559
+ }
560
+ const hits = input.hits.map((entry, index): EvaluationRawHit => {
561
+ const label = `retriever result hits[${index}]`;
562
+ const hit = record(entry, label);
563
+ strictKeys(hit, ["documentId", "rank", "score", "evidence"], label);
564
+ if (!Number.isSafeInteger(hit.rank) || (hit.rank as number) < 1 || (hit.rank as number) > MAX_EVALUATION_RESULTS_PER_QUERY) {
565
+ throw new TypeError(`${label}.rank must be an integer from 1 through ${MAX_EVALUATION_RESULTS_PER_QUERY}.`);
566
+ }
567
+ if (hit.score !== undefined && (typeof hit.score !== "number" || !Number.isFinite(hit.score))) {
568
+ throw new TypeError(`${label}.score must be a finite number.`);
569
+ }
570
+ return Object.freeze({
571
+ documentId: boundedString(hit.documentId, `${label}.documentId`),
572
+ rank: hit.rank as number,
573
+ ...(hit.score === undefined ? {} : { score: hit.score }),
574
+ ...(hit.evidence === undefined ? {} : { evidence: jsonEvidence(hit.evidence, `${label}.evidence`) }),
575
+ });
576
+ });
577
+ if (input.status === "unavailable" && hits.length > 0) {
578
+ throw new TypeError("unavailable retriever results may not contain hits.");
579
+ }
580
+ if (new Set(hits.map(({ rank }) => rank)).size !== hits.length) {
581
+ throw new TypeError("retriever result ranks must be unique.");
582
+ }
583
+ if (new Set(hits.map(({ documentId }) => documentId)).size !== hits.length) {
584
+ throw new TypeError("retriever result document IDs must be unique.");
585
+ }
586
+ const diagnosticsInput = input.diagnostics ?? [];
587
+ if (!Array.isArray(diagnosticsInput) || diagnosticsInput.length > MAX_EVALUATION_DIAGNOSTICS) {
588
+ throw new TypeError(`retriever diagnostics must have at most ${MAX_EVALUATION_DIAGNOSTICS} entries.`);
589
+ }
590
+ const diagnostics = diagnosticsInput.map((entry, index): EvaluationDiagnostic => {
591
+ const label = `retriever result diagnostics[${index}]`;
592
+ const diagnostic = record(entry, label);
593
+ strictKeys(diagnostic, ["lane", "status", "message"], label);
594
+ if (diagnostic.status !== "ready" && diagnostic.status !== "degraded" && diagnostic.status !== "unavailable") {
595
+ throw new TypeError(`${label}.status must be ready, degraded, or unavailable.`);
596
+ }
597
+ const rawMessage = optionalBoundedString(diagnostic.message, `${label}.message`);
598
+ const message = rawMessage === undefined
599
+ ? undefined
600
+ : redactEvaluationMachinePaths(rawMessage);
601
+ return Object.freeze({
602
+ lane: boundedString(diagnostic.lane, `${label}.lane`, 256),
603
+ status: diagnostic.status,
604
+ ...(message === undefined ? {} : { message }),
605
+ });
606
+ });
607
+ const timings = nonnegativeNumberMap(input.timings, "retriever result timings");
608
+ const resources = nonnegativeNumberMap(input.resources, "retriever result resources");
609
+ return Object.freeze({
610
+ status: input.status,
611
+ hits: Object.freeze(hits.toSorted((left, right) => left.rank - right.rank)),
612
+ diagnostics: Object.freeze(diagnostics),
613
+ timings,
614
+ resources,
615
+ });
616
+ }
617
+
618
+ function shortError(error: unknown): string {
619
+ const message = error instanceof Error ? error.message : String(error);
620
+ return redactEvaluationMachinePaths(
621
+ message.normalize("NFC").replace(/[\0\r\n]/gu, " "),
622
+ ).slice(0, 2_000);
623
+ }
624
+
625
+ async function evaluateOne(
626
+ corpus: RetrievalEvaluationCorpus,
627
+ query: EvaluationQuery,
628
+ retriever: EvaluationRetriever,
629
+ limit: number,
630
+ timeoutMs: number,
631
+ now: () => number,
632
+ ): Promise<EvaluationRun> {
633
+ const controller = new AbortController();
634
+ const startedAt = now();
635
+ let timer: ReturnType<typeof setTimeout> | undefined;
636
+ try {
637
+ const retrieval = Promise.resolve().then(() => retriever.retrieve({
638
+ corpus: corpus.frozen,
639
+ query,
640
+ limit,
641
+ signal: controller.signal,
642
+ }));
643
+ const timed = new Promise<never>((_resolve, reject) => {
644
+ timer = setTimeout(() => {
645
+ controller.abort(new Error(`Retriever exceeded the ${timeoutMs}-millisecond limit.`));
646
+ reject(new Error(`Retriever exceeded the ${timeoutMs}-millisecond limit.`));
647
+ }, timeoutMs);
648
+ });
649
+ const raw = await Promise.race([retrieval, timed]);
650
+ const result = parseRetrieverResult(raw, limit);
651
+ return Object.freeze({
652
+ retrieverId: retriever.id,
653
+ queryId: query.id,
654
+ queryClass: query.class,
655
+ split: query.split,
656
+ status: result.status,
657
+ hits: result.hits,
658
+ diagnostics: result.diagnostics ?? Object.freeze([]),
659
+ timing: Object.freeze({
660
+ elapsedMs: Math.max(0, now() - startedAt),
661
+ backend: result.timings ?? Object.freeze({}),
662
+ }),
663
+ resources: result.resources ?? Object.freeze({}),
664
+ });
665
+ } catch (error: unknown) {
666
+ const timeout = controller.signal.aborted;
667
+ return Object.freeze({
668
+ retrieverId: retriever.id,
669
+ queryId: query.id,
670
+ queryClass: query.class,
671
+ split: query.split,
672
+ status: "failed",
673
+ hits: Object.freeze([]),
674
+ diagnostics: Object.freeze([]),
675
+ timing: Object.freeze({ elapsedMs: Math.max(0, now() - startedAt), backend: Object.freeze({}) }),
676
+ resources: Object.freeze({}),
677
+ failure: Object.freeze({
678
+ kind: timeout
679
+ ? "timeout"
680
+ : error instanceof TypeError
681
+ ? "invalid-result"
682
+ : "exception",
683
+ message: shortError(error),
684
+ }),
685
+ });
686
+ } finally {
687
+ if (timer !== undefined) clearTimeout(timer);
688
+ }
689
+ }
690
+
691
+ /** Execute real injected retrievers serially so local model work stays bounded. */
692
+ export async function runRetrievalEvaluation(options: {
693
+ readonly corpus: RetrievalEvaluationCorpus;
694
+ readonly retrievers: readonly EvaluationRetriever[];
695
+ readonly split?: EvaluationSplit | "all";
696
+ readonly limit?: number;
697
+ readonly timeoutMs?: number;
698
+ readonly now?: () => number;
699
+ }): Promise<readonly EvaluationRun[]> {
700
+ if (options.retrievers.length < 1 || options.retrievers.length > MAX_EVALUATION_RETRIEVERS) {
701
+ throw new RangeError(`Evaluation accepts from 1 through ${MAX_EVALUATION_RETRIEVERS} retrievers.`);
702
+ }
703
+ const retrieverIds = options.retrievers.map(({ id }) => boundedString(id, "retriever id", 256));
704
+ if (new Set(retrieverIds).size !== retrieverIds.length) throw new TypeError("Evaluation retriever IDs must be unique.");
705
+ const limit = options.limit ?? 20;
706
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_EVALUATION_RESULTS_PER_QUERY) {
707
+ throw new RangeError(`Evaluation result limit must be from 1 through ${MAX_EVALUATION_RESULTS_PER_QUERY}.`);
708
+ }
709
+ const timeoutMs = options.timeoutMs ?? 30_000;
710
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_EVALUATION_TIMEOUT_MS) {
711
+ throw new RangeError(`Evaluation timeout must be from 1 through ${MAX_EVALUATION_TIMEOUT_MS} milliseconds.`);
712
+ }
713
+ const split = options.split ?? "test";
714
+ const queries = options.corpus.queries.filter((query) => split === "all" || query.split === split);
715
+ const runs: EvaluationRun[] = [];
716
+ for (const retriever of options.retrievers) {
717
+ for (const query of queries) {
718
+ runs.push(await evaluateOne(
719
+ options.corpus,
720
+ query,
721
+ retriever,
722
+ limit,
723
+ timeoutMs,
724
+ options.now ?? performance.now.bind(performance),
725
+ ));
726
+ }
727
+ }
728
+ return Object.freeze(runs);
729
+ }
730
+
731
+ export type EvaluationMetrics = {
732
+ readonly recall: number | null;
733
+ readonly reciprocalRank: number | null;
734
+ readonly ndcg: number | null;
735
+ readonly noAnswerAccuracy: number | null;
736
+ };
737
+
738
+ function metricsFor(query: EvaluationQuery, hits: readonly EvaluationRawHit[], cutoff: number): EvaluationMetrics {
739
+ const ranked = hits.filter(({ rank }) => rank <= cutoff).toSorted((left, right) => left.rank - right.rank);
740
+ if (query.answer === "no-answer") {
741
+ return Object.freeze({
742
+ recall: null,
743
+ reciprocalRank: null,
744
+ ndcg: null,
745
+ noAnswerAccuracy: ranked.length === 0 ? 1 : 0,
746
+ });
747
+ }
748
+ const relevance = new Map(query.qrels.map((qrel) => [qrel.documentId, qrel.relevance]));
749
+ const relevantIds = new Set(query.qrels.filter(({ relevance: value }) => value > 0).map(({ documentId }) => documentId));
750
+ const retrievedRelevant = new Set(ranked.filter(({ documentId }) => relevantIds.has(documentId)).map(({ documentId }) => documentId));
751
+ const firstRelevant = ranked.find(({ documentId }) => relevantIds.has(documentId));
752
+ const dcg = ranked.reduce((total, hit) => {
753
+ const grade = relevance.get(hit.documentId) ?? 0;
754
+ return total + ((2 ** grade) - 1) / Math.log2(hit.rank + 1);
755
+ }, 0);
756
+ const ideal = query.qrels
757
+ .map(({ relevance: value }) => value)
758
+ .toSorted((left, right) => right - left)
759
+ .slice(0, cutoff)
760
+ .reduce<number>((total, grade, index) =>
761
+ total + ((2 ** grade) - 1) / Math.log2(index + 2), 0);
762
+ return Object.freeze({
763
+ recall: retrievedRelevant.size / relevantIds.size,
764
+ reciprocalRank: firstRelevant === undefined ? 0 : 1 / firstRelevant.rank,
765
+ ndcg: ideal === 0 ? 0 : dcg / ideal,
766
+ noAnswerAccuracy: null,
767
+ });
768
+ }
769
+
770
+ function average(values: readonly number[]): number | null {
771
+ return values.length === 0 ? null : values.reduce((sum, value) => sum + value, 0) / values.length;
772
+ }
773
+
774
+ function percentile(values: readonly number[], proportion: number): number | null {
775
+ if (values.length === 0) return null;
776
+ const sorted = values.toSorted((left, right) => left - right);
777
+ const index = Math.max(0, Math.ceil(proportion * sorted.length) - 1);
778
+ return sorted[index] ?? null;
779
+ }
780
+
781
+ export type PairedBootstrapInterval = {
782
+ readonly pairs: number;
783
+ readonly seed: number;
784
+ readonly resamples: number;
785
+ readonly confidence: number;
786
+ readonly observedDifference: number;
787
+ readonly lower: number;
788
+ readonly upper: number;
789
+ };
790
+
791
+ function seededRandom(seed: number): () => number {
792
+ let state = seed >>> 0;
793
+ return () => {
794
+ state = (state + 0x6d2b79f5) >>> 0;
795
+ let value = state;
796
+ value = Math.imul(value ^ (value >>> 15), value | 1);
797
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
798
+ return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
799
+ };
800
+ }
801
+
802
+ /** Query-level paired percentile bootstrap with a reproducible integer seed. */
803
+ export function pairedBootstrapConfidenceInterval(
804
+ pairs: readonly { readonly baseline: number; readonly candidate: number }[],
805
+ options: {
806
+ readonly seed: number;
807
+ readonly resamples?: number;
808
+ readonly confidence?: number;
809
+ },
810
+ ): PairedBootstrapInterval {
811
+ if (pairs.length < 1 || pairs.length > MAX_EVALUATION_QUERIES) {
812
+ throw new RangeError(`Paired bootstrap accepts from 1 through ${MAX_EVALUATION_QUERIES} pairs.`);
813
+ }
814
+ if (!Number.isSafeInteger(options.seed) || options.seed < 0 || options.seed > 0xffff_ffff) {
815
+ throw new RangeError("Bootstrap seed must be an unsigned 32-bit integer.");
816
+ }
817
+ const resamples = options.resamples ?? 10_000;
818
+ if (!Number.isSafeInteger(resamples) || resamples < 100 || resamples > MAX_BOOTSTRAP_RESAMPLES) {
819
+ throw new RangeError(`Bootstrap resamples must be from 100 through ${MAX_BOOTSTRAP_RESAMPLES}.`);
820
+ }
821
+ const confidence = options.confidence ?? 0.95;
822
+ if (!Number.isFinite(confidence) || confidence <= 0 || confidence >= 1) {
823
+ throw new RangeError("Bootstrap confidence must be between 0 and 1.");
824
+ }
825
+ for (const pair of pairs) {
826
+ if (!Number.isFinite(pair.baseline) || !Number.isFinite(pair.candidate)) {
827
+ throw new TypeError("Bootstrap pairs must contain finite values.");
828
+ }
829
+ }
830
+ const random = seededRandom(options.seed);
831
+ const samples: number[] = [];
832
+ for (let sample = 0; sample < resamples; sample += 1) {
833
+ let difference = 0;
834
+ for (let index = 0; index < pairs.length; index += 1) {
835
+ const pair = pairs[Math.floor(random() * pairs.length)];
836
+ if (pair !== undefined) difference += pair.candidate - pair.baseline;
837
+ }
838
+ samples.push(difference / pairs.length);
839
+ }
840
+ samples.sort((left, right) => left - right);
841
+ const alpha = (1 - confidence) / 2;
842
+ const lowerIndex = Math.floor(alpha * resamples);
843
+ const upperIndex = Math.min(resamples - 1, Math.ceil((1 - alpha) * resamples) - 1);
844
+ return Object.freeze({
845
+ pairs: pairs.length,
846
+ seed: options.seed,
847
+ resamples,
848
+ confidence,
849
+ observedDifference: pairs.reduce((sum, pair) => sum + pair.candidate - pair.baseline, 0) / pairs.length,
850
+ lower: samples[lowerIndex] ?? 0,
851
+ upper: samples[upperIndex] ?? 0,
852
+ });
853
+ }
854
+
855
+ export type EvaluationEnvironment = {
856
+ readonly generatedAt: string;
857
+ readonly runtime: {
858
+ readonly bun: string;
859
+ readonly node: string;
860
+ readonly os: string;
861
+ readonly arch: string;
862
+ readonly hardware: string;
863
+ };
864
+ readonly model:
865
+ | { readonly kind: "none"; readonly reason: string }
866
+ | {
867
+ readonly kind: "local";
868
+ readonly id: string;
869
+ readonly revision: string;
870
+ readonly sha256: string;
871
+ };
872
+ readonly cache: {
873
+ readonly state: "cold" | "mixed" | "not-applicable" | "warm";
874
+ readonly fingerprint?: string;
875
+ };
876
+ readonly retrievers: readonly {
877
+ readonly id: string;
878
+ readonly version: string;
879
+ readonly configuration: Readonly<Record<string, string | number | boolean | null>>;
880
+ }[];
881
+ };
882
+
883
+ export type EvaluationRetrieverSummary = {
884
+ readonly retrieverId: string;
885
+ readonly runs: number;
886
+ readonly ready: number;
887
+ readonly degraded: number;
888
+ readonly unavailable: number;
889
+ readonly failed: number;
890
+ readonly metrics: EvaluationMetrics;
891
+ readonly byClass: readonly {
892
+ readonly class: EvaluationQueryClass;
893
+ readonly queries: number;
894
+ readonly metrics: EvaluationMetrics;
895
+ }[];
896
+ readonly latencyMs: {
897
+ readonly p50: number | null;
898
+ readonly p90: number | null;
899
+ readonly p95: number | null;
900
+ readonly p99: number | null;
901
+ };
902
+ };
903
+
904
+ export type EvaluationComparison = {
905
+ readonly baselineRetrieverId: string;
906
+ readonly candidateRetrieverId: string;
907
+ readonly metric: keyof EvaluationMetrics;
908
+ readonly interval: PairedBootstrapInterval;
909
+ };
910
+
911
+ export type RetrievalEvaluationReport = {
912
+ readonly schemaVersion: 1;
913
+ readonly corpus: RetrievalEvaluationCorpus;
914
+ readonly environment: EvaluationEnvironment;
915
+ /** Exact query partition represented by every retriever in this report. */
916
+ readonly split: EvaluationSplit | "all";
917
+ readonly queryCount: number;
918
+ readonly cutoff: number;
919
+ readonly summaries: readonly EvaluationRetrieverSummary[];
920
+ readonly comparisons: readonly EvaluationComparison[];
921
+ readonly runs: readonly (EvaluationRun & { readonly metrics: EvaluationMetrics })[];
922
+ };
923
+
924
+ function validateEnvironment(
925
+ environment: EvaluationEnvironment,
926
+ expectedRetrieverIds: readonly string[],
927
+ ): EvaluationEnvironment {
928
+ const timestamp = new Date(environment.generatedAt);
929
+ if (Number.isNaN(timestamp.valueOf()) || timestamp.toISOString() !== environment.generatedAt) {
930
+ throw new TypeError("Evaluation environment generatedAt must be a canonical ISO timestamp.");
931
+ }
932
+ if (environment.model.kind === "local" && !sha256Pattern.test(environment.model.sha256)) {
933
+ throw new TypeError("Evaluation model sha256 must be 64 lowercase hexadecimal characters.");
934
+ }
935
+ const runtime = Object.freeze({
936
+ bun: boundedString(environment.runtime.bun, "environment.runtime.bun", 256),
937
+ node: boundedString(environment.runtime.node, "environment.runtime.node", 256),
938
+ os: boundedString(environment.runtime.os, "environment.runtime.os", 256),
939
+ arch: boundedString(environment.runtime.arch, "environment.runtime.arch", 256),
940
+ hardware: boundedString(environment.runtime.hardware, "environment.runtime.hardware"),
941
+ });
942
+ const model = environment.model.kind === "none"
943
+ ? Object.freeze({
944
+ kind: "none" as const,
945
+ reason: boundedString(environment.model.reason, "environment.model.reason"),
946
+ })
947
+ : Object.freeze({
948
+ kind: "local" as const,
949
+ id: boundedString(environment.model.id, "environment.model.id", 256),
950
+ revision: boundedString(environment.model.revision, "environment.model.revision", 512),
951
+ sha256: environment.model.sha256,
952
+ });
953
+ const fingerprint = environment.cache.fingerprint === undefined
954
+ ? undefined
955
+ : boundedString(environment.cache.fingerprint, "environment.cache.fingerprint", 512);
956
+ if (
957
+ environment.cache.state !== "cold"
958
+ && environment.cache.state !== "mixed"
959
+ && environment.cache.state !== "not-applicable"
960
+ && environment.cache.state !== "warm"
961
+ ) throw new TypeError("environment.cache.state is invalid.");
962
+ if (
963
+ environment.retrievers.length < 1
964
+ || environment.retrievers.length > MAX_EVALUATION_RETRIEVERS
965
+ ) throw new TypeError(`environment.retrievers must have from 1 through ${MAX_EVALUATION_RETRIEVERS} entries.`);
966
+ const retrievers = environment.retrievers.map((retriever, index) => {
967
+ const id = boundedString(retriever.id, `environment.retrievers[${index}].id`, 256);
968
+ const entries = Object.entries(retriever.configuration);
969
+ if (entries.length > 64) {
970
+ throw new TypeError(`environment.retrievers[${index}].configuration may have at most 64 fields.`);
971
+ }
972
+ const configuration: Record<string, string | number | boolean | null> = {};
973
+ for (const [rawKey, value] of entries.toSorted(([left], [right]) => left.localeCompare(right))) {
974
+ const key = boundedString(rawKey, `environment.retrievers[${index}] configuration key`, 256);
975
+ if (
976
+ value !== null
977
+ && typeof value !== "string"
978
+ && typeof value !== "number"
979
+ && typeof value !== "boolean"
980
+ ) throw new TypeError(`environment.retrievers[${index}].configuration.${key} is not scalar.`);
981
+ if (typeof value === "number" && !Number.isFinite(value)) {
982
+ throw new TypeError(`environment.retrievers[${index}].configuration.${key} must be finite.`);
983
+ }
984
+ configuration[key] = typeof value === "string"
985
+ ? boundedString(value, `environment.retrievers[${index}].configuration.${key}`)
986
+ : value;
987
+ }
988
+ return Object.freeze({
989
+ id,
990
+ version: boundedString(retriever.version, `environment.retrievers[${index}].version`, 512),
991
+ configuration: Object.freeze(configuration),
992
+ });
993
+ }).toSorted((left, right) => left.id.localeCompare(right.id));
994
+ const environmentIds = retrievers.map(({ id }) => id);
995
+ if (new Set(environmentIds).size !== environmentIds.length) {
996
+ throw new TypeError("environment.retrievers must not repeat an ID.");
997
+ }
998
+ if (
999
+ environmentIds.length !== expectedRetrieverIds.length
1000
+ || environmentIds.some((id, index) => id !== expectedRetrieverIds[index])
1001
+ ) throw new TypeError("environment.retrievers must exactly describe the report retrievers.");
1002
+ return Object.freeze({
1003
+ generatedAt: environment.generatedAt,
1004
+ runtime,
1005
+ model,
1006
+ cache: Object.freeze({
1007
+ state: environment.cache.state,
1008
+ ...(fingerprint === undefined ? {} : { fingerprint }),
1009
+ }),
1010
+ retrievers: Object.freeze(retrievers),
1011
+ });
1012
+ }
1013
+
1014
+ function hashSeed(value: string): number {
1015
+ let hash = 2_166_136_261;
1016
+ for (const character of value) {
1017
+ hash ^= character.codePointAt(0) ?? 0;
1018
+ hash = Math.imul(hash, 16_777_619);
1019
+ }
1020
+ return hash >>> 0;
1021
+ }
1022
+
1023
+ function metricValues(
1024
+ rows: readonly { readonly metrics: EvaluationMetrics }[],
1025
+ ): EvaluationMetrics {
1026
+ const values = <K extends keyof EvaluationMetrics>(key: K): number[] =>
1027
+ rows.flatMap(({ metrics }) => {
1028
+ const value = metrics[key];
1029
+ return value === null ? [] : [value];
1030
+ });
1031
+ return Object.freeze({
1032
+ recall: average(values("recall")),
1033
+ reciprocalRank: average(values("reciprocalRank")),
1034
+ ndcg: average(values("ndcg")),
1035
+ noAnswerAccuracy: average(values("noAnswerAccuracy")),
1036
+ });
1037
+ }
1038
+
1039
+ function failedMetricsFor(query: EvaluationQuery): EvaluationMetrics {
1040
+ return query.answer === "no-answer"
1041
+ ? Object.freeze({ recall: null, reciprocalRank: null, ndcg: null, noAnswerAccuracy: 0 })
1042
+ : Object.freeze({ recall: 0, reciprocalRank: 0, ndcg: 0, noAnswerAccuracy: null });
1043
+ }
1044
+
1045
+ /** Build the canonical raw-and-aggregate report without executing a model. */
1046
+ export function buildRetrievalEvaluationReport(options: {
1047
+ readonly corpus: RetrievalEvaluationCorpus;
1048
+ readonly runs: readonly EvaluationRun[];
1049
+ readonly environment: EvaluationEnvironment;
1050
+ readonly cutoff?: number;
1051
+ readonly baselineRetrieverId?: string;
1052
+ readonly bootstrapSeed?: number;
1053
+ readonly bootstrapResamples?: number;
1054
+ }): RetrievalEvaluationReport {
1055
+ const cutoff = options.cutoff ?? 10;
1056
+ if (!Number.isSafeInteger(cutoff) || cutoff < 1 || cutoff > MAX_EVALUATION_RESULTS_PER_QUERY) {
1057
+ throw new RangeError(`Evaluation cutoff must be from 1 through ${MAX_EVALUATION_RESULTS_PER_QUERY}.`);
1058
+ }
1059
+ const queryById = new Map(options.corpus.queries.map((query) => [query.id, query]));
1060
+ if (options.runs.length < 1) throw new TypeError("Evaluation report requires at least one run.");
1061
+ const runSplits = [...new Set(options.runs.map(({ split }) => split))].toSorted();
1062
+ const split: EvaluationSplit | "all" = runSplits.length === 1
1063
+ ? runSplits[0] ?? "all"
1064
+ : "all";
1065
+ const expectedQueries = options.corpus.queries.filter((query) =>
1066
+ split === "all" || query.split === split);
1067
+ const expectedQueryIds = expectedQueries.map(({ id }) => id);
1068
+ const seen = new Set<string>();
1069
+ const measured = options.runs.map((run) => {
1070
+ const query = queryById.get(run.queryId);
1071
+ if (query === undefined) throw new TypeError(`Evaluation run names unknown query ${run.queryId}.`);
1072
+ if (run.queryClass !== query.class || run.split !== query.split) {
1073
+ throw new TypeError(`Evaluation run metadata does not match query ${run.queryId}.`);
1074
+ }
1075
+ if (!Number.isFinite(run.timing.elapsedMs) || run.timing.elapsedMs < 0) {
1076
+ throw new TypeError(`Evaluation run ${run.retrieverId}/${run.queryId} has invalid elapsed time.`);
1077
+ }
1078
+ const backend = nonnegativeNumberMap(
1079
+ run.timing.backend,
1080
+ `Evaluation run ${run.retrieverId}/${run.queryId} backend timings`,
1081
+ );
1082
+ const resources = nonnegativeNumberMap(
1083
+ run.resources,
1084
+ `Evaluation run ${run.retrieverId}/${run.queryId} resources`,
1085
+ );
1086
+ const hits = Object.freeze(run.hits.map((hit, index) => Object.freeze({
1087
+ ...hit,
1088
+ ...(hit.evidence === undefined
1089
+ ? {}
1090
+ : {
1091
+ evidence: jsonEvidence(
1092
+ hit.evidence,
1093
+ `Evaluation run ${run.retrieverId}/${run.queryId} hits[${index}].evidence`,
1094
+ ),
1095
+ }),
1096
+ })));
1097
+ const diagnostics = Object.freeze(run.diagnostics.map((diagnostic) => Object.freeze({
1098
+ ...diagnostic,
1099
+ ...(diagnostic.message === undefined
1100
+ ? {}
1101
+ : { message: redactEvaluationMachinePaths(diagnostic.message) }),
1102
+ })));
1103
+ const failure = run.failure === undefined
1104
+ ? undefined
1105
+ : Object.freeze({
1106
+ ...run.failure,
1107
+ message: redactEvaluationMachinePaths(run.failure.message),
1108
+ });
1109
+ const key = `${run.retrieverId}\0${run.queryId}`;
1110
+ if (seen.has(key)) throw new TypeError(`Evaluation runs repeat ${run.retrieverId}/${run.queryId}.`);
1111
+ seen.add(key);
1112
+ return Object.freeze({
1113
+ ...run,
1114
+ hits,
1115
+ diagnostics,
1116
+ timing: Object.freeze({ elapsedMs: run.timing.elapsedMs, backend }),
1117
+ resources,
1118
+ ...(failure === undefined ? {} : { failure }),
1119
+ metrics: run.status === "failed" || run.status === "unavailable"
1120
+ ? failedMetricsFor(query)
1121
+ : metricsFor(query, hits, cutoff),
1122
+ });
1123
+ }).toSorted((left, right) =>
1124
+ left.retrieverId.localeCompare(right.retrieverId)
1125
+ || options.corpus.queries.findIndex(({ id }) => id === left.queryId)
1126
+ - options.corpus.queries.findIndex(({ id }) => id === right.queryId));
1127
+ const retrieverIds = [...new Set(measured.map(({ retrieverId }) => retrieverId))].toSorted();
1128
+ for (const retrieverId of retrieverIds) {
1129
+ const actualQueryIds = measured
1130
+ .filter((run) => run.retrieverId === retrieverId)
1131
+ .map(({ queryId }) => queryId);
1132
+ if (
1133
+ actualQueryIds.length !== expectedQueryIds.length
1134
+ || actualQueryIds.some((id, index) => id !== expectedQueryIds[index])
1135
+ ) {
1136
+ throw new TypeError(
1137
+ `Evaluation retriever ${retrieverId} must contain exactly the ${expectedQueryIds.length} ${split} query runs.`,
1138
+ );
1139
+ }
1140
+ }
1141
+ const summaries = retrieverIds.map((retrieverId): EvaluationRetrieverSummary => {
1142
+ const rows = measured.filter((run) => run.retrieverId === retrieverId);
1143
+ const classes = [...new Set(rows.map(({ queryClass }) => queryClass))].toSorted();
1144
+ const latencies = rows.map(({ timing }) => timing.elapsedMs);
1145
+ return Object.freeze({
1146
+ retrieverId,
1147
+ runs: rows.length,
1148
+ ready: rows.filter(({ status }) => status === "ready").length,
1149
+ degraded: rows.filter(({ status }) => status === "degraded").length,
1150
+ unavailable: rows.filter(({ status }) => status === "unavailable").length,
1151
+ failed: rows.filter(({ status }) => status === "failed").length,
1152
+ metrics: metricValues(rows),
1153
+ byClass: Object.freeze(classes.map((queryClass) => {
1154
+ const classRows = rows.filter((row) => row.queryClass === queryClass);
1155
+ return Object.freeze({
1156
+ class: queryClass,
1157
+ queries: classRows.length,
1158
+ metrics: metricValues(classRows),
1159
+ });
1160
+ })),
1161
+ latencyMs: Object.freeze({
1162
+ p50: percentile(latencies, 0.5),
1163
+ p90: percentile(latencies, 0.9),
1164
+ p95: percentile(latencies, 0.95),
1165
+ p99: percentile(latencies, 0.99),
1166
+ }),
1167
+ });
1168
+ });
1169
+ const comparisons: EvaluationComparison[] = [];
1170
+ const baseline = options.baselineRetrieverId;
1171
+ if (baseline !== undefined) {
1172
+ if (!retrieverIds.includes(baseline)) throw new TypeError(`Unknown baseline retriever ${baseline}.`);
1173
+ const metricNames: readonly (keyof EvaluationMetrics)[] = [
1174
+ "recall",
1175
+ "reciprocalRank",
1176
+ "ndcg",
1177
+ "noAnswerAccuracy",
1178
+ ];
1179
+ for (const candidate of retrieverIds.filter((id) => id !== baseline)) {
1180
+ for (const metric of metricNames) {
1181
+ const pairs = expectedQueries.flatMap(({ id }) => {
1182
+ const baselineRow = measured.find((row) => row.retrieverId === baseline && row.queryId === id);
1183
+ const candidateRow = measured.find((row) => row.retrieverId === candidate && row.queryId === id);
1184
+ const baselineValue = baselineRow?.metrics[metric];
1185
+ const candidateValue = candidateRow?.metrics[metric];
1186
+ return baselineValue === null
1187
+ || baselineValue === undefined
1188
+ || candidateValue === null
1189
+ || candidateValue === undefined
1190
+ ? []
1191
+ : [{ baseline: baselineValue, candidate: candidateValue }];
1192
+ });
1193
+ if (pairs.length === 0) continue;
1194
+ const seed = ((options.bootstrapSeed ?? 1) ^ hashSeed(`${candidate}\0${metric}`)) >>> 0;
1195
+ comparisons.push(Object.freeze({
1196
+ baselineRetrieverId: baseline,
1197
+ candidateRetrieverId: candidate,
1198
+ metric,
1199
+ interval: pairedBootstrapConfidenceInterval(pairs, {
1200
+ seed,
1201
+ ...(options.bootstrapResamples === undefined
1202
+ ? {}
1203
+ : { resamples: options.bootstrapResamples }),
1204
+ }),
1205
+ }));
1206
+ }
1207
+ }
1208
+ }
1209
+ return Object.freeze({
1210
+ schemaVersion: 1,
1211
+ corpus: options.corpus,
1212
+ environment: validateEnvironment(options.environment, retrieverIds),
1213
+ split,
1214
+ queryCount: expectedQueries.length,
1215
+ cutoff,
1216
+ summaries: Object.freeze(summaries),
1217
+ comparisons: Object.freeze(comparisons),
1218
+ runs: Object.freeze(measured),
1219
+ });
1220
+ }