@jcode.labs/ragmir 2.17.2 → 2.18.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 (203) hide show
  1. package/README.md +53 -6
  2. package/dist/access-log.d.ts +10 -0
  3. package/dist/access-log.d.ts.map +1 -1
  4. package/dist/access-log.js +119 -49
  5. package/dist/access-log.js.map +1 -1
  6. package/dist/chunking.d.ts +5 -1
  7. package/dist/chunking.d.ts.map +1 -1
  8. package/dist/chunking.js +102 -16
  9. package/dist/chunking.js.map +1 -1
  10. package/dist/citation.d.ts +18 -0
  11. package/dist/citation.d.ts.map +1 -0
  12. package/dist/citation.js +54 -0
  13. package/dist/citation.js.map +1 -0
  14. package/dist/cli-entry.d.ts +3 -0
  15. package/dist/cli-entry.d.ts.map +1 -0
  16. package/dist/cli-entry.js +16 -0
  17. package/dist/cli-entry.js.map +1 -0
  18. package/dist/cli-fast.d.ts +13 -0
  19. package/dist/cli-fast.d.ts.map +1 -0
  20. package/dist/cli-fast.js +145 -0
  21. package/dist/cli-fast.js.map +1 -0
  22. package/dist/cli-options.d.ts +3 -0
  23. package/dist/cli-options.d.ts.map +1 -1
  24. package/dist/cli-options.js +7 -0
  25. package/dist/cli-options.js.map +1 -1
  26. package/dist/cli-stdin.d.ts +3 -0
  27. package/dist/cli-stdin.d.ts.map +1 -0
  28. package/dist/cli-stdin.js +15 -0
  29. package/dist/cli-stdin.js.map +1 -0
  30. package/dist/cli.js +251 -44
  31. package/dist/cli.js.map +1 -1
  32. package/dist/client.d.ts +13 -2
  33. package/dist/client.d.ts.map +1 -1
  34. package/dist/client.js +155 -10
  35. package/dist/client.js.map +1 -1
  36. package/dist/config.d.ts +6 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +227 -49
  39. package/dist/config.js.map +1 -1
  40. package/dist/context-resources.d.ts +4 -2
  41. package/dist/context-resources.d.ts.map +1 -1
  42. package/dist/context-resources.js +55 -24
  43. package/dist/context-resources.js.map +1 -1
  44. package/dist/defaults.d.ts +27 -0
  45. package/dist/defaults.d.ts.map +1 -1
  46. package/dist/defaults.js +42 -2
  47. package/dist/defaults.js.map +1 -1
  48. package/dist/destroy.d.ts +2 -2
  49. package/dist/destroy.d.ts.map +1 -1
  50. package/dist/destroy.js +34 -20
  51. package/dist/destroy.js.map +1 -1
  52. package/dist/doctor.d.ts +3 -2
  53. package/dist/doctor.d.ts.map +1 -1
  54. package/dist/doctor.js +86 -41
  55. package/dist/doctor.js.map +1 -1
  56. package/dist/durable-file.d.ts +13 -0
  57. package/dist/durable-file.d.ts.map +1 -0
  58. package/dist/durable-file.js +56 -0
  59. package/dist/durable-file.js.map +1 -0
  60. package/dist/embeddings.d.ts +14 -3
  61. package/dist/embeddings.d.ts.map +1 -1
  62. package/dist/embeddings.js +233 -95
  63. package/dist/embeddings.js.map +1 -1
  64. package/dist/errors.d.ts +1 -1
  65. package/dist/errors.d.ts.map +1 -1
  66. package/dist/errors.js.map +1 -1
  67. package/dist/evaluate.d.ts +4 -3
  68. package/dist/evaluate.d.ts.map +1 -1
  69. package/dist/evaluate.js +611 -110
  70. package/dist/evaluate.js.map +1 -1
  71. package/dist/files.d.ts +4 -1
  72. package/dist/files.d.ts.map +1 -1
  73. package/dist/files.js +119 -33
  74. package/dist/files.js.map +1 -1
  75. package/dist/generation-retention.d.ts +52 -0
  76. package/dist/generation-retention.d.ts.map +1 -0
  77. package/dist/generation-retention.js +318 -0
  78. package/dist/generation-retention.js.map +1 -0
  79. package/dist/index-diagnostics.d.ts +3 -2
  80. package/dist/index-diagnostics.d.ts.map +1 -1
  81. package/dist/index-diagnostics.js +32 -3
  82. package/dist/index-diagnostics.js.map +1 -1
  83. package/dist/index-policy.d.ts.map +1 -1
  84. package/dist/index-policy.js +3 -0
  85. package/dist/index-policy.js.map +1 -1
  86. package/dist/index-write-lock.d.ts +16 -1
  87. package/dist/index-write-lock.d.ts.map +1 -1
  88. package/dist/index-write-lock.js +392 -13
  89. package/dist/index-write-lock.js.map +1 -1
  90. package/dist/index.d.ts +12 -4
  91. package/dist/index.d.ts.map +1 -1
  92. package/dist/index.js +5 -2
  93. package/dist/index.js.map +1 -1
  94. package/dist/ingest.d.ts +11 -2
  95. package/dist/ingest.d.ts.map +1 -1
  96. package/dist/ingest.js +680 -165
  97. package/dist/ingest.js.map +1 -1
  98. package/dist/ingestion-metrics.d.ts +58 -0
  99. package/dist/ingestion-metrics.d.ts.map +1 -0
  100. package/dist/ingestion-metrics.js +216 -0
  101. package/dist/ingestion-metrics.js.map +1 -0
  102. package/dist/ingestion-state.d.ts +11 -2
  103. package/dist/ingestion-state.d.ts.map +1 -1
  104. package/dist/ingestion-state.js +480 -49
  105. package/dist/ingestion-state.js.map +1 -1
  106. package/dist/limits.d.ts.map +1 -1
  107. package/dist/limits.js +11 -1
  108. package/dist/limits.js.map +1 -1
  109. package/dist/mcp-output.d.ts +44 -4
  110. package/dist/mcp-output.d.ts.map +1 -1
  111. package/dist/mcp-output.js +132 -159
  112. package/dist/mcp-output.js.map +1 -1
  113. package/dist/mcp-summaries.d.ts +32 -0
  114. package/dist/mcp-summaries.d.ts.map +1 -0
  115. package/dist/mcp-summaries.js +227 -0
  116. package/dist/mcp-summaries.js.map +1 -0
  117. package/dist/mcp.d.ts +1 -1
  118. package/dist/mcp.d.ts.map +1 -1
  119. package/dist/mcp.js +85 -46
  120. package/dist/mcp.js.map +1 -1
  121. package/dist/ocr-cache.d.ts +15 -0
  122. package/dist/ocr-cache.d.ts.map +1 -0
  123. package/dist/ocr-cache.js +90 -0
  124. package/dist/ocr-cache.js.map +1 -0
  125. package/dist/ocr.d.ts +31 -0
  126. package/dist/ocr.d.ts.map +1 -1
  127. package/dist/ocr.js +193 -17
  128. package/dist/ocr.js.map +1 -1
  129. package/dist/parsing.d.ts.map +1 -1
  130. package/dist/parsing.js +482 -85
  131. package/dist/parsing.js.map +1 -1
  132. package/dist/permissions.js +2 -2
  133. package/dist/permissions.js.map +1 -1
  134. package/dist/preview.d.ts.map +1 -1
  135. package/dist/preview.js +10 -11
  136. package/dist/preview.js.map +1 -1
  137. package/dist/quality-report.d.ts +12 -0
  138. package/dist/quality-report.d.ts.map +1 -0
  139. package/dist/quality-report.js +146 -0
  140. package/dist/quality-report.js.map +1 -0
  141. package/dist/query.d.ts +7 -4
  142. package/dist/query.d.ts.map +1 -1
  143. package/dist/query.js +371 -284
  144. package/dist/query.js.map +1 -1
  145. package/dist/ranking.d.ts +38 -0
  146. package/dist/ranking.d.ts.map +1 -0
  147. package/dist/ranking.js +278 -0
  148. package/dist/ranking.js.map +1 -0
  149. package/dist/redaction.d.ts +6 -1
  150. package/dist/redaction.d.ts.map +1 -1
  151. package/dist/redaction.js +118 -4
  152. package/dist/redaction.js.map +1 -1
  153. package/dist/research.d.ts +6 -1
  154. package/dist/research.d.ts.map +1 -1
  155. package/dist/research.js +330 -130
  156. package/dist/research.js.map +1 -1
  157. package/dist/runtime-info.d.ts +3 -0
  158. package/dist/runtime-info.d.ts.map +1 -0
  159. package/dist/runtime-info.js +81 -0
  160. package/dist/runtime-info.js.map +1 -0
  161. package/dist/security.d.ts +3 -2
  162. package/dist/security.d.ts.map +1 -1
  163. package/dist/security.js +242 -45
  164. package/dist/security.js.map +1 -1
  165. package/dist/semantic-config.d.ts +7 -1
  166. package/dist/semantic-config.d.ts.map +1 -1
  167. package/dist/semantic-config.js +30 -4
  168. package/dist/semantic-config.js.map +1 -1
  169. package/dist/setup.js +6 -3
  170. package/dist/setup.js.map +1 -1
  171. package/dist/skill.js +34 -12
  172. package/dist/skill.js.map +1 -1
  173. package/dist/source-fingerprint-cache.d.ts +20 -0
  174. package/dist/source-fingerprint-cache.d.ts.map +1 -0
  175. package/dist/source-fingerprint-cache.js +133 -0
  176. package/dist/source-fingerprint-cache.js.map +1 -0
  177. package/dist/storage-maintenance.d.ts +46 -0
  178. package/dist/storage-maintenance.d.ts.map +1 -0
  179. package/dist/storage-maintenance.js +329 -0
  180. package/dist/storage-maintenance.js.map +1 -0
  181. package/dist/store.d.ts +39 -2
  182. package/dist/store.d.ts.map +1 -1
  183. package/dist/store.js +559 -24
  184. package/dist/store.js.map +1 -1
  185. package/dist/text.d.ts +1 -1
  186. package/dist/text.d.ts.map +1 -1
  187. package/dist/text.js +12 -2
  188. package/dist/text.js.map +1 -1
  189. package/dist/types.d.ts +414 -5
  190. package/dist/types.d.ts.map +1 -1
  191. package/dist/vector-index.d.ts +42 -0
  192. package/dist/vector-index.d.ts.map +1 -0
  193. package/dist/vector-index.js +249 -0
  194. package/dist/vector-index.js.map +1 -0
  195. package/dist/version.d.ts +1 -1
  196. package/dist/version.js +1 -1
  197. package/dist/workload.d.ts +22 -0
  198. package/dist/workload.d.ts.map +1 -0
  199. package/dist/workload.js +181 -0
  200. package/dist/workload.js.map +1 -0
  201. package/examples/document-evidence-benchmark/README.md +3 -2
  202. package/examples/document-evidence-benchmark/golden-queries.json +1 -1
  203. package/package.json +36 -11
package/dist/evaluate.js CHANGED
@@ -1,145 +1,332 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile, stat } from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import { z } from "zod";
4
- import { recordAccess } from "./access-log.js";
5
+ import { flushAccessLog, recordAccess } from "./access-log.js";
5
6
  import { loadConfig } from "./config.js";
7
+ import { retainEmbeddingModel } from "./embeddings.js";
6
8
  import { RagmirError } from "./errors.js";
9
+ import { acquireGenerationReadLease } from "./generation-retention.js";
10
+ import { withIndexWriteLock } from "./index-write-lock.js";
7
11
  import { operationSignal, throwIfAborted } from "./operation.js";
8
- import { search } from "./query.js";
9
- export const MAX_GOLDEN_FILE_BYTES = 1_048_576;
10
- export const MAX_GOLDEN_CASES = 100;
12
+ import { fingerprintIndexManifest, fingerprintQualityReport } from "./quality-report.js";
13
+ import { searchWithConfig } from "./query.js";
14
+ import { rankingPolicyFingerprint, rankingPolicyFor } from "./ranking.js";
15
+ import { closeIndexReadSnapshot, closeStoreConnection, connectStore, indexReadSnapshotCurrent, loadIndexReadSnapshot, readIndexManifest, writeIndexManifest, } from "./store.js";
16
+ export const MAX_GOLDEN_FILE_BYTES = 16_777_216;
17
+ export const MAX_GOLDEN_CASES = 1_000;
11
18
  export const MAX_GOLDEN_QUERY_CHARACTERS = 20_000;
12
19
  export const MAX_GOLDEN_EXPECTED_VALUES = 100;
13
20
  export const MAX_GOLDEN_EXPECTED_VALUE_CHARACTERS = 500;
21
+ const EVALUATION_DEPTH = 10;
22
+ const DEFAULT_MINIMUM_VERIFICATION_CASES = 100;
14
23
  const expectedValueSchema = z.string().min(1).max(MAX_GOLDEN_EXPECTED_VALUE_CHARACTERS);
24
+ const unitMetricSchema = z.number().min(0).max(1);
25
+ const relevanceJudgmentSchema = z
26
+ .object({
27
+ kind: z.enum(["path", "citation"]),
28
+ value: expectedValueSchema,
29
+ relevance: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]),
30
+ })
31
+ .strict();
32
+ const qualityThresholdSchema = z
33
+ .object({
34
+ recallAt1: unitMetricSchema.optional(),
35
+ recallAt3: unitMetricSchema.optional(),
36
+ recallAt5: unitMetricSchema.optional(),
37
+ recallAt10: unitMetricSchema.optional(),
38
+ precisionAt5: unitMetricSchema.optional(),
39
+ meanReciprocalRankAt10: unitMetricSchema.optional(),
40
+ ndcgAt10: unitMetricSchema.optional(),
41
+ exactCitationRate: unitMetricSchema.optional(),
42
+ maximumFalsePositiveRate: unitMetricSchema.optional(),
43
+ })
44
+ .strict();
15
45
  const goldenQuerySchema = z
16
46
  .object({
17
47
  id: z.string().min(1).optional(),
18
48
  query: z.string().min(1).max(MAX_GOLDEN_QUERY_CHARACTERS),
19
- expectedPaths: z.array(expectedValueSchema).min(1).max(MAX_GOLDEN_EXPECTED_VALUES),
49
+ expectedPaths: z.array(expectedValueSchema).max(MAX_GOLDEN_EXPECTED_VALUES).default([]),
20
50
  expectedCitations: z
21
51
  .array(expectedValueSchema)
22
52
  .min(1)
23
53
  .max(MAX_GOLDEN_EXPECTED_VALUES)
24
54
  .optional(),
55
+ answerable: z.boolean().optional(),
56
+ category: z.string().min(1).max(100).optional(),
57
+ locale: z.string().min(2).max(50).optional(),
58
+ relevanceJudgments: z.array(relevanceJudgmentSchema).max(MAX_GOLDEN_EXPECTED_VALUES).optional(),
59
+ maximumVectorDistance: z.number().nonnegative().optional(),
25
60
  includePaths: z.array(z.string().min(1).max(500)).max(20).optional(),
26
61
  excludePaths: z.array(z.string().min(1).max(500)).max(20).optional(),
27
62
  contextPaths: z.array(z.string().min(1).max(500)).max(20).optional(),
28
63
  topK: z.number().int().positive().optional(),
64
+ })
65
+ .strict()
66
+ .superRefine((value, context) => {
67
+ const answerable = value.answerable ?? true;
68
+ const positiveJudgments = (value.relevanceJudgments ?? []).filter((judgment) => judgment.relevance > 0);
69
+ if (answerable &&
70
+ value.expectedPaths.length === 0 &&
71
+ value.expectedCitations === undefined &&
72
+ positiveJudgments.length === 0) {
73
+ context.addIssue({
74
+ code: "custom",
75
+ message: "Answerable golden queries require at least one relevant path or citation.",
76
+ path: ["expectedPaths"],
77
+ });
78
+ }
79
+ if (!answerable && (value.expectedPaths.length > 0 || positiveJudgments.length > 0)) {
80
+ context.addIssue({
81
+ code: "custom",
82
+ message: "Unanswerable golden queries cannot declare relevant evidence.",
83
+ path: ["answerable"],
84
+ });
85
+ }
86
+ });
87
+ const wrappedGoldenFileSchema = z
88
+ .object({
89
+ topK: z.number().int().positive().optional(),
90
+ minimumCasesForVerification: z.number().int().positive().max(MAX_GOLDEN_CASES).optional(),
91
+ thresholds: qualityThresholdSchema.optional(),
92
+ queries: z.array(goldenQuerySchema).min(1).max(MAX_GOLDEN_CASES),
29
93
  })
30
94
  .strict();
31
95
  const goldenFileSchema = z.union([
32
96
  z.array(goldenQuerySchema).min(1).max(MAX_GOLDEN_CASES),
33
- z
34
- .object({
35
- topK: z.number().int().positive().optional(),
36
- queries: z.array(goldenQuerySchema).min(1).max(MAX_GOLDEN_CASES),
37
- })
38
- .strict(),
97
+ wrappedGoldenFileSchema,
39
98
  ]);
99
+ async function createEvaluationClient(config, signal) {
100
+ const connection = await connectStore(config);
101
+ const releaseEmbeddingModel = retainEmbeddingModel(config);
102
+ let snapshot;
103
+ try {
104
+ snapshot = await loadEvaluationSnapshot(config, connection);
105
+ const generationLease = await acquireGenerationReadLease(snapshot.tableName, config);
106
+ return evaluationClient(config, connection, snapshot, generationLease, signal, () => releaseEmbeddingModel());
107
+ }
108
+ catch (error) {
109
+ if (snapshot) {
110
+ closeIndexReadSnapshot(snapshot, config);
111
+ }
112
+ closeStoreConnection(connection, config);
113
+ await releaseEmbeddingModel();
114
+ throw error;
115
+ }
116
+ }
117
+ function evaluationClient(config, connection, snapshot, generationLease, signal, releaseEmbeddingModel) {
118
+ let closed = false;
119
+ return {
120
+ snapshot,
121
+ search(query, options) {
122
+ return searchWithConfig(query, options, config, connection, signal, snapshot, true);
123
+ },
124
+ async close() {
125
+ if (closed) {
126
+ return;
127
+ }
128
+ closed = true;
129
+ try {
130
+ closeIndexReadSnapshot(snapshot, config);
131
+ }
132
+ finally {
133
+ try {
134
+ await generationLease.release();
135
+ }
136
+ finally {
137
+ try {
138
+ closeStoreConnection(connection, config);
139
+ }
140
+ finally {
141
+ await releaseEmbeddingModel();
142
+ }
143
+ }
144
+ }
145
+ },
146
+ };
147
+ }
148
+ async function loadEvaluationSnapshot(config, connection) {
149
+ for (let attempt = 0; attempt < 3; attempt += 1) {
150
+ const snapshot = await loadIndexReadSnapshot(config, connection);
151
+ const manifest = await readIndexManifest(config);
152
+ const current = await indexReadSnapshotCurrent(snapshot, config);
153
+ const matchingPresence = (snapshot.manifest === null) === (manifest === null);
154
+ const manifestTableName = manifest?.tableName ?? config.tableName;
155
+ if (current && matchingPresence && manifestTableName === snapshot.tableName) {
156
+ return { ...snapshot, manifest: manifest ? Object.freeze({ ...manifest }) : null };
157
+ }
158
+ closeIndexReadSnapshot(snapshot, config);
159
+ }
160
+ throw new Error("Index generation changed repeatedly while opening an evaluation snapshot. Retry evaluation.");
161
+ }
40
162
  export async function evaluateGoldenQueries(options) {
41
163
  const signal = operationSignal(options);
42
164
  throwIfAborted(signal);
43
165
  const cwd = path.resolve(String(options.cwd ?? process.cwd()));
44
166
  const config = await loadConfig(cwd);
167
+ return evaluateGoldenQueriesWithConfig(options, config);
168
+ }
169
+ export async function evaluateGoldenQueriesWithConfig(options, config) {
170
+ const signal = operationSignal(options);
171
+ throwIfAborted(signal);
172
+ const cwd = path.resolve(String(options.cwd ?? config.projectRoot));
173
+ const activeRankingPolicyFingerprint = rankingPolicyFingerprint(rankingPolicyFor(config.embeddingProvider, config.retrievalProfile));
45
174
  throwIfAborted(signal);
46
175
  const goldenPath = path.resolve(cwd, String(options.goldenPath));
47
176
  const goldenFile = await readGoldenFile(goldenPath, signal);
48
- throwIfAborted(signal);
49
177
  const defaultTopK = boundedTopK(options.topK ?? goldenFile.topK ?? 3, options.maxTopK);
178
+ const caseDetailLimit = evaluationCaseDetailLimit(options.caseDetailLimit);
50
179
  const cases = [];
51
- for (const goldenQuery of goldenFile.queries) {
52
- throwIfAborted(signal);
53
- const topK = boundedTopK(goldenQuery.topK ?? defaultTopK, options.maxTopK);
54
- const startedAt = performance.now();
55
- const results = await search(goldenQuery.query, {
56
- cwd,
57
- topK,
58
- ...(signal === undefined ? {} : { signal }),
59
- ...(goldenQuery.includePaths === undefined ? {} : { includePaths: goldenQuery.includePaths }),
60
- ...(goldenQuery.excludePaths === undefined ? {} : { excludePaths: goldenQuery.excludePaths }),
61
- ...(goldenQuery.contextPaths === undefined ? {} : { contextPaths: goldenQuery.contextPaths }),
62
- });
180
+ const evaluationConcurrency = Math.max(1, Math.min(config.workloadLimits.search.concurrency, config.workloadLimits.embedding.concurrency + config.workloadLimits.embedding.maxQueue));
181
+ const evaluationClient = await createEvaluationClient(config, signal);
182
+ const initialManifest = evaluationClient.snapshot.manifest;
183
+ const indexFingerprint = initialManifest
184
+ ? fingerprintIndexManifest(initialManifest)
185
+ : "missing-index";
186
+ try {
63
187
  throwIfAborted(signal);
64
- const latencyMs = performance.now() - startedAt;
65
- const returnedPaths = results.map((result) => result.relativePath);
66
- const returnedCitations = results.map(citationForResult);
67
- const expectedCitations = goldenQuery.expectedCitations ?? [];
68
- const requiresExactCitation = expectedCitations.length > 0;
69
- const expectedValues = requiresExactCitation ? expectedCitations : goldenQuery.expectedPaths;
70
- const matchedPaths = returnedPaths.filter((resultPath) => goldenQuery.expectedPaths.includes(resultPath));
71
- const matchedCitations = returnedCitations.filter((citation) => expectedCitations.includes(citation));
72
- const bestRank = requiresExactCitation
73
- ? returnedCitations.findIndex((citation) => expectedCitations.includes(citation)) + 1
74
- : returnedPaths.findIndex((resultPath) => goldenQuery.expectedPaths.includes(resultPath)) + 1;
75
- const reciprocalRank = bestRank > 0 ? 1 / bestRank : 0;
76
- const returnedValues = requiresExactCitation ? returnedCitations : returnedPaths;
77
- const uniqueReturnedValues = [...new Set(returnedValues.slice(0, topK))];
78
- const expectedSet = new Set(expectedValues);
79
- const matchedRelevant = uniqueReturnedValues.filter((value) => expectedSet.has(value));
80
- const recall = expectedSet.size === 0 ? 0 : matchedRelevant.length / expectedSet.size;
81
- const precision = uniqueReturnedValues.length === 0 ? 0 : matchedRelevant.length / uniqueReturnedValues.length;
82
- const ndcg = ndcgAtK(returnedValues, expectedValues, topK);
83
- const result = {
84
- query: goldenQuery.query,
85
- expectedPaths: goldenQuery.expectedPaths,
86
- topK,
87
- returnedPaths,
88
- returnedCitations,
89
- matchedPaths,
90
- matchedCitations,
91
- hit: requiresExactCitation ? matchedCitations.length > 0 : matchedPaths.length > 0,
92
- bestRank: bestRank > 0 ? bestRank : null,
93
- reciprocalRank,
94
- recall,
95
- precision,
96
- ndcg,
97
- latencyMs,
98
- };
99
- if (goldenQuery.id !== undefined) {
100
- result.id = goldenQuery.id;
188
+ for (let offset = 0; offset < goldenFile.queries.length; offset += evaluationConcurrency) {
189
+ throwIfAborted(signal);
190
+ const batch = goldenFile.queries.slice(offset, offset + evaluationConcurrency);
191
+ cases.push(...(await Promise.all(batch.map(async (goldenQuery) => {
192
+ const topK = boundedTopK(goldenQuery.topK ?? defaultTopK, options.maxTopK);
193
+ const evaluationDepth = boundedTopK(Math.max(topK, EVALUATION_DEPTH), options.maxTopK);
194
+ const startedAt = performance.now();
195
+ const results = await evaluationClient.search(goldenQuery.query, {
196
+ cwd,
197
+ topK: evaluationDepth,
198
+ ...(signal === undefined ? {} : { signal }),
199
+ ...(goldenQuery.includePaths === undefined
200
+ ? {}
201
+ : { includePaths: goldenQuery.includePaths }),
202
+ ...(goldenQuery.excludePaths === undefined
203
+ ? {}
204
+ : { excludePaths: goldenQuery.excludePaths }),
205
+ ...(goldenQuery.contextPaths === undefined
206
+ ? {}
207
+ : { contextPaths: goldenQuery.contextPaths }),
208
+ });
209
+ throwIfAborted(signal);
210
+ return evaluateCase(goldenQuery, applyVectorDistancePolicy(results, goldenQuery.maximumVectorDistance), topK, performance.now() - startedAt);
211
+ }))));
101
212
  }
102
- if (goldenQuery.expectedCitations !== undefined) {
103
- result.expectedCitations = goldenQuery.expectedCitations;
104
- }
105
- if (goldenQuery.includePaths !== undefined) {
106
- result.includePaths = goldenQuery.includePaths;
107
- }
108
- if (goldenQuery.excludePaths !== undefined) {
109
- result.excludePaths = goldenQuery.excludePaths;
110
- }
111
- if (goldenQuery.contextPaths !== undefined) {
112
- result.contextPaths = goldenQuery.contextPaths;
113
- }
114
- cases.push(result);
115
213
  }
214
+ finally {
215
+ await evaluationClient.close();
216
+ }
217
+ const answerableCases = cases.filter((result) => result.answerable);
218
+ const unanswerableCases = cases.filter((result) => !result.answerable);
219
+ const citationCases = answerableCases.filter((result) => result.exactCitationHit !== null);
220
+ const gradedCases = answerableCases.filter((result) => result.relevanceJudgments.some((judgment) => judgment.relevance > 1));
221
+ const metrics = evaluationMetrics(answerableCases, unanswerableCases, citationCases);
222
+ const thresholds = { ...goldenFile.thresholds, ...options.thresholds };
223
+ const gates = qualityGates(metrics, thresholds);
224
+ const passed = gates.every((gate) => gate.passed);
225
+ const requiredThresholdSet = completeThresholdSet(thresholds);
226
+ const relativeGoldenPath = projectRelativePath(config.projectRoot, goldenPath);
227
+ const verificationEligible = initialManifest !== null &&
228
+ (initialManifest.staleFiles?.length ?? 0) === 0 &&
229
+ relativeGoldenPath !== null &&
230
+ cases.length >= goldenFile.minimumCasesForVerification &&
231
+ answerableCases.length > 0 &&
232
+ unanswerableCases.length > 0 &&
233
+ citationCases.length > 0 &&
234
+ gradedCases.length > 0 &&
235
+ (options.maxTopK === undefined || options.maxTopK >= EVALUATION_DEPTH) &&
236
+ requiredThresholdSet !== null;
237
+ const storedReport = verificationEligible &&
238
+ passed &&
239
+ options.persistCompatibleReport !== false &&
240
+ initialManifest !== null &&
241
+ relativeGoldenPath !== null &&
242
+ requiredThresholdSet !== null
243
+ ? await persistQualityReport({
244
+ config,
245
+ indexFingerprint,
246
+ goldenPath: relativeGoldenPath,
247
+ goldenFingerprint: goldenFile.fingerprint,
248
+ rankingPolicyFingerprint: activeRankingPolicyFingerprint,
249
+ metrics,
250
+ thresholds: requiredThresholdSet,
251
+ total: cases.length,
252
+ signal,
253
+ })
254
+ : null;
116
255
  const hits = cases.filter((result) => result.hit).length;
117
256
  const latencies = cases.map((result) => result.latencyMs).sort((a, b) => a - b);
257
+ const recall = mean(answerableCases.map((result) => result.recall));
258
+ const precision = mean(answerableCases.map((result) => result.precision));
259
+ const meanReciprocalRank = mean(answerableCases.map((result) => result.reciprocalRank));
260
+ const ndcg = mean(answerableCases.map((result) => result.ndcg));
261
+ const abstentionAccuracy = unanswerableCases.length === 0
262
+ ? null
263
+ : unanswerableCases.filter((result) => result.abstained).length / unanswerableCases.length;
118
264
  throwIfAborted(signal);
119
265
  await recordAccess(config, {
120
266
  action: "evaluate",
121
267
  topK: defaultTopK,
122
268
  resultCount: cases.length,
123
269
  });
270
+ await flushAccessLog(config);
124
271
  throwIfAborted(signal);
272
+ const returnedCases = caseDetailLimit === undefined ? cases : cases.slice(0, caseDetailLimit);
273
+ const omittedCases = cases.length - returnedCases.length;
125
274
  return {
126
275
  goldenPath,
127
276
  embeddingProvider: config.embeddingProvider,
128
277
  embeddingModel: config.embeddingModel,
278
+ embeddingModelRevision: config.embeddingModelRevision,
279
+ embeddingModelDigest: config.embeddingModelDigest,
280
+ retrievalProfile: config.retrievalProfile,
281
+ rankingPolicyFingerprint: activeRankingPolicyFingerprint,
282
+ indexFingerprint,
283
+ goldenFingerprint: goldenFile.fingerprint,
129
284
  topK: defaultTopK,
130
285
  total: cases.length,
131
286
  hits,
132
287
  misses: cases.length - hits,
133
288
  hitRate: hits / cases.length,
134
- recall: mean(cases.map((result) => result.recall)),
135
- precision: mean(cases.map((result) => result.precision)),
136
- meanReciprocalRank: mean(cases.map((result) => result.reciprocalRank)),
137
- ndcg: mean(cases.map((result) => result.ndcg)),
289
+ recall,
290
+ precision,
291
+ meanReciprocalRank,
292
+ ndcg,
293
+ recallAt: {
294
+ 1: metrics.recallAt1,
295
+ 3: metrics.recallAt3,
296
+ 5: metrics.recallAt5,
297
+ 10: metrics.recallAt10,
298
+ },
299
+ precisionAt5: metrics.precisionAt5,
300
+ meanReciprocalRankAt10: metrics.meanReciprocalRankAt10,
301
+ ndcgAt10: metrics.ndcgAt10,
302
+ exactCitationRate: metrics.exactCitationRate,
303
+ falsePositiveRate: metrics.falsePositiveRate,
304
+ abstentionAccuracy,
305
+ thresholds,
306
+ gates,
307
+ passed,
308
+ verificationEligible,
309
+ reportStored: storedReport !== null,
310
+ qualityReportFingerprint: storedReport?.qualityReportFingerprint ?? null,
311
+ groups: {
312
+ categories: groupResults(cases, (result) => result.category),
313
+ locales: groupResults(cases, (result) => result.locale),
314
+ },
138
315
  p50LatencyMs: percentile(latencies, 0.5),
139
316
  p95LatencyMs: percentile(latencies, 0.95),
140
- cases,
317
+ cases: returnedCases,
318
+ ...(omittedCases === 0 ? {} : { omittedCases }),
141
319
  };
142
320
  }
321
+ function evaluationCaseDetailLimit(value) {
322
+ if (value === undefined) {
323
+ return undefined;
324
+ }
325
+ if (!Number.isSafeInteger(value) || value < 0) {
326
+ throw new Error("Evaluation case detail limit must be a non-negative integer.");
327
+ }
328
+ return value;
329
+ }
143
330
  async function readGoldenFile(goldenPath, signal) {
144
331
  let size;
145
332
  try {
@@ -162,62 +349,376 @@ async function readGoldenFile(goldenPath, signal) {
162
349
  throw error;
163
350
  }
164
351
  throwIfAborted(signal);
352
+ const fingerprint = createHash("sha256").update(raw).digest("hex");
165
353
  const parsed = goldenFileSchema.parse(JSON.parse(raw));
166
354
  if (Array.isArray(parsed)) {
167
- return { queries: parsed.map(normalizeGoldenQuery) };
168
- }
169
- const result = { queries: parsed.queries.map(normalizeGoldenQuery) };
170
- if (parsed.topK !== undefined) {
171
- return { ...result, topK: parsed.topK };
355
+ return {
356
+ minimumCasesForVerification: DEFAULT_MINIMUM_VERIFICATION_CASES,
357
+ thresholds: {},
358
+ queries: parsed.map(normalizeGoldenQuery),
359
+ fingerprint,
360
+ };
172
361
  }
173
- return result;
362
+ return {
363
+ ...(parsed.topK === undefined ? {} : { topK: parsed.topK }),
364
+ minimumCasesForVerification: parsed.minimumCasesForVerification ?? DEFAULT_MINIMUM_VERIFICATION_CASES,
365
+ thresholds: normalizeThresholds(parsed.thresholds),
366
+ queries: parsed.queries.map(normalizeGoldenQuery),
367
+ fingerprint,
368
+ };
174
369
  }
175
370
  function normalizeGoldenQuery(value) {
176
- const result = {
371
+ return {
177
372
  query: value.query,
178
373
  expectedPaths: value.expectedPaths,
374
+ answerable: value.answerable ?? true,
375
+ relevanceJudgments: value.relevanceJudgments ?? [],
376
+ ...(value.expectedCitations === undefined
377
+ ? {}
378
+ : { expectedCitations: value.expectedCitations }),
379
+ ...(value.id === undefined ? {} : { id: value.id }),
380
+ ...(value.category === undefined ? {} : { category: value.category }),
381
+ ...(value.locale === undefined ? {} : { locale: value.locale }),
382
+ ...(value.maximumVectorDistance === undefined
383
+ ? {}
384
+ : { maximumVectorDistance: value.maximumVectorDistance }),
385
+ ...(value.topK === undefined ? {} : { topK: value.topK }),
386
+ ...(value.includePaths === undefined ? {} : { includePaths: value.includePaths }),
387
+ ...(value.excludePaths === undefined ? {} : { excludePaths: value.excludePaths }),
388
+ ...(value.contextPaths === undefined ? {} : { contextPaths: value.contextPaths }),
389
+ };
390
+ }
391
+ function evaluateCase(goldenQuery, results, topK, latencyMs) {
392
+ const answerable = goldenQuery.answerable ?? true;
393
+ const returnedPaths = results.map((result) => result.relativePath);
394
+ const returnedCitations = results.map(citationForResult);
395
+ const pathJudgments = judgmentsFor("path", goldenQuery.relevanceJudgments ?? [], goldenQuery.expectedPaths);
396
+ const citationJudgments = judgmentsFor("citation", goldenQuery.relevanceJudgments ?? [], goldenQuery.expectedCitations ?? []);
397
+ const requiresExactCitation = citationJudgments.some((judgment) => judgment.relevance > 0);
398
+ const primaryJudgments = requiresExactCitation ? citationJudgments : pathJudgments;
399
+ const returnedValues = requiresExactCitation ? returnedCitations : returnedPaths;
400
+ const matchedPaths = uniqueAtK(returnedPaths, returnedPaths.length).filter((resultPath) => pathJudgments.some((judgment) => judgment.value === resultPath && judgment.relevance > 0));
401
+ const matchedCitations = uniqueAtK(returnedCitations, returnedCitations.length).filter((citation) => citationJudgments.some((judgment) => judgment.value === citation && judgment.relevance > 0));
402
+ const bestRank = firstRelevantRank(returnedValues, primaryJudgments, topK);
403
+ const reciprocalRank = bestRank === null ? 0 : 1 / bestRank;
404
+ const recallAt = {
405
+ 1: recallAtK(returnedValues, primaryJudgments, 1),
406
+ 3: recallAtK(returnedValues, primaryJudgments, 3),
407
+ 5: recallAtK(returnedValues, primaryJudgments, 5),
408
+ 10: recallAtK(returnedValues, primaryJudgments, 10),
179
409
  };
180
- if (value.expectedCitations !== undefined) {
181
- result.expectedCitations = value.expectedCitations;
410
+ const abstained = returnedValues.length === 0;
411
+ const falsePositive = !answerable && !abstained;
412
+ const pathHit = matchedPaths.length > 0;
413
+ const exactCitationHit = requiresExactCitation ? matchedCitations.length > 0 : null;
414
+ const hit = answerable ? (requiresExactCitation ? exactCitationHit === true : pathHit) : abstained;
415
+ const relevanceJudgments = [...pathJudgments, ...citationJudgments];
416
+ const result = {
417
+ query: goldenQuery.query,
418
+ expectedPaths: goldenQuery.expectedPaths,
419
+ topK,
420
+ returnedPaths,
421
+ returnedCitations,
422
+ matchedPaths,
423
+ matchedCitations,
424
+ answerable,
425
+ relevanceJudgments,
426
+ abstained,
427
+ falsePositive,
428
+ pathHit,
429
+ exactCitationHit,
430
+ hit,
431
+ bestRank,
432
+ reciprocalRank,
433
+ recall: answerable ? recallAtK(returnedValues, primaryJudgments, topK) : Number(abstained),
434
+ precision: answerable ? precisionAtK(returnedValues, primaryJudgments, topK, false) : 0,
435
+ ndcg: answerable ? gradedNdcgAtK(returnedValues, primaryJudgments, topK) : Number(abstained),
436
+ recallAt,
437
+ precisionAt5: answerable ? precisionAtK(returnedValues, primaryJudgments, 5, true) : 0,
438
+ reciprocalRankAt10: answerable ? reciprocalRankAtK(returnedValues, primaryJudgments, 10) : 0,
439
+ ndcgAt10: answerable ? gradedNdcgAtK(returnedValues, primaryJudgments, 10) : 0,
440
+ latencyMs,
441
+ };
442
+ if (goldenQuery.id !== undefined) {
443
+ result.id = goldenQuery.id;
444
+ }
445
+ if (goldenQuery.category !== undefined) {
446
+ result.category = goldenQuery.category;
182
447
  }
183
- if (value.id !== undefined) {
184
- result.id = value.id;
448
+ if (goldenQuery.locale !== undefined) {
449
+ result.locale = goldenQuery.locale;
185
450
  }
186
- if (value.topK !== undefined) {
187
- result.topK = value.topK;
451
+ if (goldenQuery.expectedCitations !== undefined) {
452
+ result.expectedCitations = goldenQuery.expectedCitations;
188
453
  }
189
- if (value.includePaths !== undefined) {
190
- result.includePaths = value.includePaths;
454
+ if (goldenQuery.includePaths !== undefined) {
455
+ result.includePaths = goldenQuery.includePaths;
191
456
  }
192
- if (value.excludePaths !== undefined) {
193
- result.excludePaths = value.excludePaths;
457
+ if (goldenQuery.excludePaths !== undefined) {
458
+ result.excludePaths = goldenQuery.excludePaths;
194
459
  }
195
- if (value.contextPaths !== undefined) {
196
- result.contextPaths = value.contextPaths;
460
+ if (goldenQuery.contextPaths !== undefined) {
461
+ result.contextPaths = goldenQuery.contextPaths;
197
462
  }
198
463
  return result;
199
464
  }
200
- function boundedTopK(topK, maxTopK) {
201
- return maxTopK === undefined ? topK : Math.min(topK, maxTopK);
465
+ function judgmentsFor(kind, declared, legacyExpectedValues) {
466
+ const explicit = declared.filter((judgment) => judgment.kind === kind);
467
+ if (explicit.length > 0) {
468
+ return explicit;
469
+ }
470
+ return legacyExpectedValues.map((value) => ({ kind, value, relevance: 1 }));
202
471
  }
203
- function citationForResult(result) {
204
- return result.citation;
472
+ function applyVectorDistancePolicy(results, maximumVectorDistance) {
473
+ if (maximumVectorDistance === undefined) {
474
+ return results;
475
+ }
476
+ return results.filter((result) => result.distance !== null && result.distance <= maximumVectorDistance);
477
+ }
478
+ function evaluationMetrics(answerableCases, unanswerableCases, citationCases) {
479
+ return {
480
+ recallAt1: mean(answerableCases.map((result) => result.recallAt[1])),
481
+ recallAt3: mean(answerableCases.map((result) => result.recallAt[3])),
482
+ recallAt5: mean(answerableCases.map((result) => result.recallAt[5])),
483
+ recallAt10: mean(answerableCases.map((result) => result.recallAt[10])),
484
+ precisionAt5: mean(answerableCases.map((result) => result.precisionAt5)),
485
+ meanReciprocalRankAt10: mean(answerableCases.map((result) => result.reciprocalRankAt10)),
486
+ ndcgAt10: mean(answerableCases.map((result) => result.ndcgAt10)),
487
+ exactCitationRate: citationCases.length === 0
488
+ ? null
489
+ : citationCases.filter((result) => result.exactCitationHit === true).length /
490
+ citationCases.length,
491
+ falsePositiveRate: unanswerableCases.length === 0
492
+ ? null
493
+ : unanswerableCases.filter((result) => result.falsePositive).length /
494
+ unanswerableCases.length,
495
+ };
205
496
  }
206
- function ndcgAtK(returned, expected, topK) {
207
- const expectedSet = new Set(expected);
208
- const returnedAtK = returned.slice(0, topK);
497
+ function qualityGates(metrics, thresholds) {
498
+ const gates = [];
499
+ addMinimumGate(gates, "recallAt1", thresholds.recallAt1, metrics.recallAt1);
500
+ addMinimumGate(gates, "recallAt3", thresholds.recallAt3, metrics.recallAt3);
501
+ addMinimumGate(gates, "recallAt5", thresholds.recallAt5, metrics.recallAt5);
502
+ addMinimumGate(gates, "recallAt10", thresholds.recallAt10, metrics.recallAt10);
503
+ addMinimumGate(gates, "precisionAt5", thresholds.precisionAt5, metrics.precisionAt5);
504
+ addMinimumGate(gates, "meanReciprocalRankAt10", thresholds.meanReciprocalRankAt10, metrics.meanReciprocalRankAt10);
505
+ addMinimumGate(gates, "ndcgAt10", thresholds.ndcgAt10, metrics.ndcgAt10);
506
+ addMinimumGate(gates, "exactCitationRate", thresholds.exactCitationRate, metrics.exactCitationRate);
507
+ addMaximumGate(gates, "maximumFalsePositiveRate", thresholds.maximumFalsePositiveRate, metrics.falsePositiveRate);
508
+ return gates;
509
+ }
510
+ function addMinimumGate(gates, metric, threshold, actual) {
511
+ if (threshold === undefined) {
512
+ return;
513
+ }
514
+ gates.push({
515
+ metric,
516
+ direction: "minimum",
517
+ threshold,
518
+ actual,
519
+ applicable: actual !== null,
520
+ passed: actual !== null && actual >= threshold,
521
+ });
522
+ }
523
+ function addMaximumGate(gates, metric, threshold, actual) {
524
+ if (threshold === undefined) {
525
+ return;
526
+ }
527
+ gates.push({
528
+ metric,
529
+ direction: "maximum",
530
+ threshold,
531
+ actual,
532
+ applicable: actual !== null,
533
+ passed: actual !== null && actual <= threshold,
534
+ });
535
+ }
536
+ function completeThresholdSet(thresholds) {
537
+ if (thresholds.recallAt1 === undefined ||
538
+ thresholds.recallAt3 === undefined ||
539
+ thresholds.recallAt5 === undefined ||
540
+ thresholds.recallAt10 === undefined ||
541
+ thresholds.precisionAt5 === undefined ||
542
+ thresholds.meanReciprocalRankAt10 === undefined ||
543
+ thresholds.ndcgAt10 === undefined ||
544
+ thresholds.exactCitationRate === undefined ||
545
+ thresholds.maximumFalsePositiveRate === undefined) {
546
+ return null;
547
+ }
548
+ return {
549
+ recallAt1: thresholds.recallAt1,
550
+ recallAt3: thresholds.recallAt3,
551
+ recallAt5: thresholds.recallAt5,
552
+ recallAt10: thresholds.recallAt10,
553
+ precisionAt5: thresholds.precisionAt5,
554
+ meanReciprocalRankAt10: thresholds.meanReciprocalRankAt10,
555
+ ndcgAt10: thresholds.ndcgAt10,
556
+ exactCitationRate: thresholds.exactCitationRate,
557
+ maximumFalsePositiveRate: thresholds.maximumFalsePositiveRate,
558
+ };
559
+ }
560
+ async function persistQualityReport(input) {
561
+ return withIndexWriteLock(input.config.storageDir, input.signal, async () => {
562
+ const activeManifest = await readIndexManifest(input.config);
563
+ if (!activeManifest ||
564
+ fingerprintIndexManifest(activeManifest) !== input.indexFingerprint ||
565
+ input.metrics.exactCitationRate === null ||
566
+ input.metrics.falsePositiveRate === null) {
567
+ return null;
568
+ }
569
+ const unsignedReport = {
570
+ schemaVersion: 3,
571
+ createdAt: new Date().toISOString(),
572
+ goldenPath: input.goldenPath,
573
+ goldenFingerprint: input.goldenFingerprint,
574
+ indexFingerprint: input.indexFingerprint,
575
+ indexPolicyFingerprint: activeManifest.indexPolicyFingerprint ?? "",
576
+ embeddingProvider: input.config.embeddingProvider,
577
+ embeddingModel: input.config.embeddingModel,
578
+ embeddingModelRevision: input.config.embeddingModelRevision,
579
+ embeddingModelDigest: input.config.embeddingModelDigest,
580
+ retrievalProfile: input.config.retrievalProfile,
581
+ rankingPolicyFingerprint: input.rankingPolicyFingerprint,
582
+ total: input.total,
583
+ metrics: {
584
+ recallAt1: input.metrics.recallAt1,
585
+ recallAt3: input.metrics.recallAt3,
586
+ recallAt5: input.metrics.recallAt5,
587
+ recallAt10: input.metrics.recallAt10,
588
+ precisionAt5: input.metrics.precisionAt5,
589
+ meanReciprocalRankAt10: input.metrics.meanReciprocalRankAt10,
590
+ ndcgAt10: input.metrics.ndcgAt10,
591
+ exactCitationRate: input.metrics.exactCitationRate,
592
+ falsePositiveRate: input.metrics.falsePositiveRate,
593
+ },
594
+ thresholds: input.thresholds,
595
+ passed: true,
596
+ verificationEligible: true,
597
+ };
598
+ const qualityReport = {
599
+ ...unsignedReport,
600
+ qualityReportFingerprint: fingerprintQualityReport(unsignedReport),
601
+ };
602
+ await writeIndexManifest({ ...activeManifest, qualityReport }, input.config);
603
+ return qualityReport;
604
+ });
605
+ }
606
+ function groupResults(cases, groupFor) {
607
+ const grouped = new Map();
608
+ for (const result of cases) {
609
+ const group = groupFor(result);
610
+ if (group === undefined) {
611
+ continue;
612
+ }
613
+ const members = grouped.get(group) ?? [];
614
+ members.push(result);
615
+ grouped.set(group, members);
616
+ }
617
+ return Object.fromEntries([...grouped.entries()]
618
+ .sort(([left], [right]) => left.localeCompare(right))
619
+ .map(([group, members]) => [group, summarizeGroup(members)]));
620
+ }
621
+ function summarizeGroup(cases) {
622
+ const answerable = cases.filter((result) => result.answerable);
623
+ const unanswerable = cases.filter((result) => !result.answerable);
624
+ return {
625
+ total: cases.length,
626
+ answerable: answerable.length,
627
+ unanswerable: unanswerable.length,
628
+ recallAt10: mean(answerable.map((result) => result.recallAt[10])),
629
+ precisionAt5: mean(answerable.map((result) => result.precisionAt5)),
630
+ meanReciprocalRankAt10: mean(answerable.map((result) => result.reciprocalRankAt10)),
631
+ ndcgAt10: mean(answerable.map((result) => result.ndcgAt10)),
632
+ falsePositiveRate: unanswerable.length === 0
633
+ ? 0
634
+ : unanswerable.filter((result) => result.falsePositive).length / unanswerable.length,
635
+ };
636
+ }
637
+ function projectRelativePath(projectRoot, targetPath) {
638
+ const relativePath = path.relative(projectRoot, targetPath);
639
+ if (relativePath === ".." ||
640
+ relativePath.startsWith(`..${path.sep}`) ||
641
+ path.isAbsolute(relativePath)) {
642
+ return null;
643
+ }
644
+ return relativePath.split(path.sep).join("/");
645
+ }
646
+ function recallAtK(returned, judgments, topK) {
647
+ const relevant = new Set(judgments.filter((judgment) => judgment.relevance > 0).map((judgment) => judgment.value));
648
+ if (relevant.size === 0) {
649
+ return 0;
650
+ }
651
+ return uniqueAtK(returned, topK).filter((value) => relevant.has(value)).length / relevant.size;
652
+ }
653
+ function precisionAtK(returned, judgments, topK, fixedDenominator) {
654
+ const values = uniqueAtK(returned, topK);
655
+ const relevant = new Set(judgments.filter((judgment) => judgment.relevance > 0).map((judgment) => judgment.value));
656
+ const denominator = fixedDenominator ? topK : values.length;
657
+ if (denominator === 0) {
658
+ return 0;
659
+ }
660
+ return values.filter((value) => relevant.has(value)).length / denominator;
661
+ }
662
+ function reciprocalRankAtK(returned, judgments, topK) {
663
+ const rank = firstRelevantRank(returned, judgments, topK);
664
+ return rank === null ? 0 : 1 / rank;
665
+ }
666
+ function firstRelevantRank(returned, judgments, topK) {
667
+ const relevant = new Set(judgments.filter((judgment) => judgment.relevance > 0).map((judgment) => judgment.value));
668
+ const index = returned.slice(0, topK).findIndex((value) => relevant.has(value));
669
+ return index < 0 ? null : index + 1;
670
+ }
671
+ function gradedNdcgAtK(returned, judgments, topK) {
672
+ const relevance = new Map(judgments.map((judgment) => [judgment.value, judgment.relevance]));
209
673
  const seen = new Set();
210
- const dcg = returnedAtK.reduce((score, value, index) => {
211
- if (!expectedSet.has(value) || seen.has(value)) {
674
+ const dcg = returned.slice(0, topK).reduce((score, value, index) => {
675
+ if (seen.has(value)) {
212
676
  return score;
213
677
  }
214
678
  seen.add(value);
215
- return score + 1 / Math.log2(index + 2);
679
+ const grade = relevance.get(value) ?? 0;
680
+ return score + (2 ** grade - 1) / Math.log2(index + 2);
216
681
  }, 0);
217
- const idealMatches = Math.min(expectedSet.size, topK);
218
- const idealDcg = Array.from({ length: idealMatches }, (_value, index) => 1 / Math.log2(index + 2)).reduce((score, gain) => score + gain, 0);
682
+ const idealGrades = [
683
+ ...new Map(judgments.map((judgment) => [judgment.value, judgment.relevance])).values(),
684
+ ]
685
+ .filter((grade) => grade > 0)
686
+ .sort((left, right) => right - left)
687
+ .slice(0, topK);
688
+ const idealDcg = idealGrades.reduce((score, grade, index) => score + (2 ** grade - 1) / Math.log2(index + 2), 0);
219
689
  return idealDcg === 0 ? 0 : dcg / idealDcg;
220
690
  }
691
+ function normalizeThresholds(value) {
692
+ if (value === undefined) {
693
+ return {};
694
+ }
695
+ return {
696
+ ...(value.recallAt1 === undefined ? {} : { recallAt1: value.recallAt1 }),
697
+ ...(value.recallAt3 === undefined ? {} : { recallAt3: value.recallAt3 }),
698
+ ...(value.recallAt5 === undefined ? {} : { recallAt5: value.recallAt5 }),
699
+ ...(value.recallAt10 === undefined ? {} : { recallAt10: value.recallAt10 }),
700
+ ...(value.precisionAt5 === undefined ? {} : { precisionAt5: value.precisionAt5 }),
701
+ ...(value.meanReciprocalRankAt10 === undefined
702
+ ? {}
703
+ : { meanReciprocalRankAt10: value.meanReciprocalRankAt10 }),
704
+ ...(value.ndcgAt10 === undefined ? {} : { ndcgAt10: value.ndcgAt10 }),
705
+ ...(value.exactCitationRate === undefined
706
+ ? {}
707
+ : { exactCitationRate: value.exactCitationRate }),
708
+ ...(value.maximumFalsePositiveRate === undefined
709
+ ? {}
710
+ : { maximumFalsePositiveRate: value.maximumFalsePositiveRate }),
711
+ };
712
+ }
713
+ function uniqueAtK(values, topK) {
714
+ return [...new Set(values.slice(0, topK))];
715
+ }
716
+ function boundedTopK(topK, maxTopK) {
717
+ return maxTopK === undefined ? topK : Math.min(topK, maxTopK);
718
+ }
719
+ function citationForResult(result) {
720
+ return result.citation;
721
+ }
221
722
  function percentile(sortedValues, quantile) {
222
723
  if (sortedValues.length === 0) {
223
724
  return 0;