@gmickel/gno 1.18.0 → 1.20.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 (153) hide show
  1. package/README.md +14 -7
  2. package/assets/skill/SKILL.md +54 -12
  3. package/assets/skill/mcp-reference.md +7 -2
  4. package/assets/skill/recipes/citation-and-provenance.md +32 -9
  5. package/package.json +2 -1
  6. package/spec/AGENTS.md +83 -0
  7. package/spec/CLAUDE.md +83 -0
  8. package/spec/bench-fixture.schema.json +137 -0
  9. package/spec/cli.md +2919 -0
  10. package/spec/db/schema.sql +442 -0
  11. package/spec/evals-agentic.md +592 -0
  12. package/spec/evals.md +1106 -0
  13. package/spec/mcp.md +2279 -0
  14. package/spec/output-schemas/activation-verification.schema.json +515 -0
  15. package/spec/output-schemas/ask.schema.json +564 -0
  16. package/spec/output-schemas/backlinks.schema.json +131 -0
  17. package/spec/output-schemas/bench-result.schema.json +120 -0
  18. package/spec/output-schemas/capture-receipt.schema.json +143 -0
  19. package/spec/output-schemas/claim-verification.schema.json +291 -0
  20. package/spec/output-schemas/collection-list.schema.json +45 -0
  21. package/spec/output-schemas/context-capsule-v1.schema.json +726 -0
  22. package/spec/output-schemas/context-capsule-verification.schema.json +1338 -0
  23. package/spec/output-schemas/context-list.schema.json +21 -0
  24. package/spec/output-schemas/doctor.schema.json +313 -0
  25. package/spec/output-schemas/error.schema.json +30 -0
  26. package/spec/output-schemas/expansion.schema.json +37 -0
  27. package/spec/output-schemas/get.schema.json +140 -0
  28. package/spec/output-schemas/graph-query.schema.json +99 -0
  29. package/spec/output-schemas/graph.schema.json +371 -0
  30. package/spec/output-schemas/links-list.schema.json +186 -0
  31. package/spec/output-schemas/mcp-add-collection-result.schema.json +23 -0
  32. package/spec/output-schemas/mcp-capture-result.schema.json +152 -0
  33. package/spec/output-schemas/mcp-http-error.schema.json +30 -0
  34. package/spec/output-schemas/mcp-job-list.schema.json +58 -0
  35. package/spec/output-schemas/mcp-job-status.schema.json +224 -0
  36. package/spec/output-schemas/mcp-remove-result.schema.json +39 -0
  37. package/spec/output-schemas/mcp-sync-result.schema.json +41 -0
  38. package/spec/output-schemas/mcp-tag-result.schema.json +33 -0
  39. package/spec/output-schemas/models-list.schema.json +93 -0
  40. package/spec/output-schemas/multi-get.schema.json +103 -0
  41. package/spec/output-schemas/process-status.schema.json +119 -0
  42. package/spec/output-schemas/query-diagnose.schema.json +123 -0
  43. package/spec/output-schemas/resident-status.schema.json +154 -0
  44. package/spec/output-schemas/retrieval-trace-common.schema.json +492 -0
  45. package/spec/output-schemas/retrieval-trace-delete.schema.json +16 -0
  46. package/spec/output-schemas/retrieval-trace-export.schema.json +61 -0
  47. package/spec/output-schemas/retrieval-trace-filters.schema.json +139 -0
  48. package/spec/output-schemas/retrieval-trace-judgment.schema.json +15 -0
  49. package/spec/output-schemas/retrieval-trace-list.schema.json +18 -0
  50. package/spec/output-schemas/retrieval-trace-payloads.schema.json +178 -0
  51. package/spec/output-schemas/retrieval-trace-purge.schema.json +31 -0
  52. package/spec/output-schemas/retrieval-trace-qrels.schema.json +303 -0
  53. package/spec/output-schemas/retrieval-trace-replay.schema.json +286 -0
  54. package/spec/output-schemas/retrieval-trace-show.schema.json +69 -0
  55. package/spec/output-schemas/retrieval-trace-summary.schema.json +65 -0
  56. package/spec/output-schemas/search-result.schema.json +154 -0
  57. package/spec/output-schemas/search-results.schema.json +338 -0
  58. package/spec/output-schemas/similar.schema.json +84 -0
  59. package/spec/output-schemas/status.schema.json +676 -0
  60. package/spec/output-schemas/tags-list.schema.json +48 -0
  61. package/src/app/context-runtime-contract.ts +10 -5
  62. package/src/app/context-runtime-input.ts +29 -1
  63. package/src/app/context-runtime-types.ts +7 -0
  64. package/src/app/context-runtime.ts +20 -2
  65. package/src/app/context-surface.ts +4 -0
  66. package/src/app/verified-ask.ts +291 -0
  67. package/src/cli/commands/ask-format.ts +255 -0
  68. package/src/cli/commands/ask.ts +144 -183
  69. package/src/cli/commands/context-build.ts +56 -9
  70. package/src/cli/commands/get.ts +64 -3
  71. package/src/cli/commands/query.ts +62 -23
  72. package/src/cli/commands/replay.ts +140 -0
  73. package/src/cli/commands/search.ts +48 -3
  74. package/src/cli/commands/shared.ts +3 -1
  75. package/src/cli/commands/trace.ts +200 -0
  76. package/src/cli/commands/vsearch.ts +75 -53
  77. package/src/cli/program.ts +287 -1
  78. package/src/config/index.ts +9 -0
  79. package/src/config/retrieval-traces.ts +56 -0
  80. package/src/config/types.ts +4 -0
  81. package/src/core/context-budget.ts +6 -0
  82. package/src/core/context-capsule-retrieval-schema.ts +4 -0
  83. package/src/core/context-capsule-schema.ts +17 -0
  84. package/src/core/context-capsule-validation.ts +3 -2
  85. package/src/core/context-capsule.ts +18 -0
  86. package/src/core/context-compiler.ts +44 -25
  87. package/src/core/context-evidence.ts +6 -0
  88. package/src/core/retrieval-qrels.ts +405 -0
  89. package/src/core/retrieval-replay-candidate.ts +368 -0
  90. package/src/core/retrieval-replay-types.ts +109 -0
  91. package/src/core/retrieval-replay-validation.ts +89 -0
  92. package/src/core/retrieval-replay.ts +441 -0
  93. package/src/core/retrieval-trace-evidence-origin.ts +178 -0
  94. package/src/core/retrieval-trace-export.ts +113 -0
  95. package/src/core/retrieval-trace-filter-normalization.ts +27 -0
  96. package/src/core/retrieval-trace-filters.ts +19 -0
  97. package/src/core/retrieval-trace-management-helpers.ts +247 -0
  98. package/src/core/retrieval-trace-management-types.ts +132 -0
  99. package/src/core/retrieval-trace-management.ts +422 -0
  100. package/src/core/retrieval-trace-request.ts +141 -0
  101. package/src/core/retrieval-trace-session.ts +507 -0
  102. package/src/core/retrieval-trace.ts +472 -0
  103. package/src/llm/errors.ts +10 -1
  104. package/src/llm/httpGeneration.ts +11 -1
  105. package/src/llm/nodeLlamaCpp/generation.ts +54 -10
  106. package/src/llm/types.ts +6 -0
  107. package/src/mcp/tools/ask.ts +228 -0
  108. package/src/mcp/tools/context.ts +87 -15
  109. package/src/mcp/tools/get.ts +35 -1
  110. package/src/mcp/tools/index.ts +83 -0
  111. package/src/mcp/tools/query.ts +95 -64
  112. package/src/mcp/tools/search.ts +36 -13
  113. package/src/mcp/tools/trace.ts +143 -0
  114. package/src/mcp/tools/vsearch.ts +71 -38
  115. package/src/pipeline/answer.ts +167 -26
  116. package/src/pipeline/claim-verification-schema.ts +235 -0
  117. package/src/pipeline/claim-verification.ts +487 -0
  118. package/src/pipeline/claim-verifier.ts +474 -0
  119. package/src/pipeline/graph-retrieval.ts +15 -1
  120. package/src/pipeline/hybrid.ts +151 -43
  121. package/src/pipeline/search.ts +36 -3
  122. package/src/pipeline/trace-metadata.ts +47 -0
  123. package/src/pipeline/types.ts +68 -0
  124. package/src/pipeline/vsearch.ts +101 -38
  125. package/src/sdk/client.ts +415 -73
  126. package/src/sdk/documents.ts +48 -1
  127. package/src/sdk/index.ts +17 -0
  128. package/src/sdk/types.ts +28 -0
  129. package/src/serve/context-capsule.ts +67 -8
  130. package/src/serve/public/app.tsx +12 -1
  131. package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
  132. package/src/serve/public/globals.built.css +1 -1
  133. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  134. package/src/serve/public/pages/Ask.tsx +42 -4
  135. package/src/serve/public/pages/Dashboard.tsx +10 -0
  136. package/src/serve/public/pages/TraceHistory.tsx +478 -0
  137. package/src/serve/public/pages/trace-history-detail.tsx +224 -0
  138. package/src/serve/retrieval-trace.ts +28 -0
  139. package/src/serve/routes/api.ts +508 -68
  140. package/src/serve/routes/traces.ts +156 -0
  141. package/src/serve/server.ts +87 -2
  142. package/src/store/index.ts +31 -0
  143. package/src/store/migrations/014-retrieval-traces.ts +303 -0
  144. package/src/store/migrations/index.ts +2 -0
  145. package/src/store/retrieval-trace-codec.ts +384 -0
  146. package/src/store/sqlite/adapter.ts +153 -1
  147. package/src/store/sqlite/retrieval-trace-management-store.ts +341 -0
  148. package/src/store/sqlite/retrieval-trace-retention.ts +349 -0
  149. package/src/store/sqlite/retrieval-trace-rows.ts +267 -0
  150. package/src/store/sqlite/retrieval-trace-store.ts +515 -0
  151. package/src/store/types.ts +297 -0
  152. package/src/store/vector/sqlite-vec.ts +76 -1
  153. package/src/store/vector/types.ts +1 -1
@@ -0,0 +1,441 @@
1
+ /** Read-only comparison of persisted retrieval baselines with one candidate. */
2
+
3
+ import type { SearchResult } from "../pipeline/types";
4
+ import type { DocumentRow, StoreResult } from "../store/types";
5
+ import type {
6
+ RetrievalQrelsCase,
7
+ RetrievalQrel,
8
+ RetrievalQrelsEvidence,
9
+ } from "./retrieval-qrels";
10
+ import type { RetrievalReplayDeps } from "./retrieval-replay-candidate";
11
+ import type {
12
+ ReplayRetrievalTraceInput,
13
+ ReplayRetrievalTraceResult,
14
+ RetrievalReplayCandidate,
15
+ RetrievalReplayCaseResult,
16
+ RetrievalReplayQrelResult,
17
+ RetrievalReplayReason,
18
+ RetrievalReplaySourceState,
19
+ } from "./retrieval-replay-types";
20
+
21
+ import { parseUri } from "../app/constants";
22
+ import { computeRetrievalMetrics } from "../bench/metrics";
23
+ import { diagnoseQueryTarget } from "../pipeline/diagnose";
24
+ import { hashTraceCanonical } from "../store/retrieval-trace-codec";
25
+ import { ok } from "../store/types";
26
+ import { buildRetrievalQrelsArtifact } from "./retrieval-qrels";
27
+ import {
28
+ buildRetrievalReplaySearchOptions,
29
+ retrievalReplayLimit,
30
+ retrievalReplayTraceMetadata,
31
+ runRetrievalReplayCandidate,
32
+ } from "./retrieval-replay-candidate";
33
+ import { parseReplayRetrievalTraceInput } from "./retrieval-replay-validation";
34
+ import { buildRetrievalTraceFingerprints } from "./retrieval-trace-request";
35
+
36
+ export type { RetrievalReplayDeps } from "./retrieval-replay-candidate";
37
+ export { buildRetrievalReplaySearchOptions } from "./retrieval-replay-candidate";
38
+
39
+ const failedCandidateMetrics = () => ({
40
+ precisionAtK: 0,
41
+ recallAtK: 0,
42
+ f1AtK: 0,
43
+ mrr: 0,
44
+ ndcgAtK: 0,
45
+ });
46
+
47
+ const unreplayable = (
48
+ input: ReplayRetrievalTraceInput,
49
+ reason: RetrievalReplayReason
50
+ ): ReplayRetrievalTraceResult => ({
51
+ schemaVersion: "1.0",
52
+ exportId: input.exportId,
53
+ candidate: input.candidate,
54
+ verdict: "unreplayable",
55
+ reason,
56
+ recommendation: "manual_review",
57
+ applied: false,
58
+ cases: [],
59
+ });
60
+
61
+ const RECONSTRUCTION_REASONS = [
62
+ "redaction_incompatible",
63
+ "query_missing",
64
+ "filters_incomplete",
65
+ "no_retrieval_run",
66
+ "ambiguous_missing_expected_run",
67
+ ] as const satisfies readonly RetrievalReplayReason[];
68
+
69
+ const reconstructionReason = (message: string): RetrievalReplayReason | null =>
70
+ RECONSTRUCTION_REASONS.find(
71
+ (reason) =>
72
+ message === reason ||
73
+ message.startsWith(`${reason}:`) ||
74
+ message.includes(`${reason}:`)
75
+ ) ?? null;
76
+
77
+ const targetMatchesResult = (
78
+ qrel: RetrievalQrel,
79
+ result: SearchResult
80
+ ): boolean => {
81
+ if (qrel.target.sourceHash) {
82
+ return qrel.target.sourceHash === result.source.sourceHash;
83
+ }
84
+ if (qrel.target.docid) return qrel.target.docid === result.docid;
85
+ return qrel.target.uri === result.uri;
86
+ };
87
+
88
+ const targetMatchesEvidence = (
89
+ qrel: RetrievalQrel,
90
+ evidence: RetrievalQrelsEvidence
91
+ ): boolean =>
92
+ qrel.target.sourceHash
93
+ ? qrel.target.sourceHash === evidence.sourceHash
94
+ : qrel.target.docid
95
+ ? qrel.target.docid === evidence.docid
96
+ : qrel.target.uri === evidence.uri;
97
+
98
+ const resolveDocument = async (
99
+ deps: RetrievalReplayDeps,
100
+ qrel: RetrievalQrel,
101
+ documents: DocumentRow[]
102
+ ): Promise<StoreResult<DocumentRow | null>> => {
103
+ if (qrel.target.sourceHash) {
104
+ const matching = documents.filter(
105
+ (document) => document.sourceHash === qrel.target.sourceHash
106
+ );
107
+ const active = matching.find((document) => document.active);
108
+ if (active) return ok(active);
109
+ if (matching[0]) return ok(matching[0]);
110
+ }
111
+ if (qrel.target.docid) {
112
+ const byDocid = await deps.store.getDocumentByDocid(qrel.target.docid);
113
+ if (!byDocid.ok) return byDocid;
114
+ if (
115
+ byDocid.value &&
116
+ (!qrel.target.sourceHash ||
117
+ byDocid.value.sourceHash === qrel.target.sourceHash)
118
+ ) {
119
+ return byDocid;
120
+ }
121
+ }
122
+ if (qrel.target.uri) return deps.store.getDocumentByUri(qrel.target.uri);
123
+ return ok(null);
124
+ };
125
+
126
+ const sourceState = (
127
+ qrel: RetrievalQrel,
128
+ document: DocumentRow | null
129
+ ): RetrievalReplaySourceState => {
130
+ if (!document) return "missing";
131
+ if (!document.active) return "inactive";
132
+ if (!document.mirrorHash) return "no_indexed_content";
133
+ if (
134
+ (qrel.target.sourceHash &&
135
+ qrel.target.sourceHash !== document.sourceHash) ||
136
+ (qrel.target.mirrorHash && qrel.target.mirrorHash !== document.mirrorHash)
137
+ ) {
138
+ return "stale";
139
+ }
140
+ return "unchanged";
141
+ };
142
+
143
+ const rankedIds = (
144
+ qrels: RetrievalQrel[],
145
+ ranked: Array<SearchResult | RetrievalQrelsEvidence>
146
+ ): string[] =>
147
+ ranked.map((entry) => {
148
+ const match = qrels.find((qrel) =>
149
+ "source" in entry
150
+ ? targetMatchesResult(qrel, entry)
151
+ : targetMatchesEvidence(qrel, entry)
152
+ );
153
+ return match?.qrelId ?? `unjudged:${"docid" in entry ? entry.docid : ""}`;
154
+ });
155
+
156
+ const metricsFor = (qrels: RetrievalQrel[], output: string[], k: number) => {
157
+ const expected = qrels
158
+ .filter((qrel) => qrel.relevance > 0)
159
+ .map((qrel) => qrel.qrelId);
160
+ return computeRetrievalMetrics({
161
+ output,
162
+ expected,
163
+ judgments: qrels.map((qrel) => ({
164
+ docid: qrel.qrelId,
165
+ relevance: qrel.relevance,
166
+ })),
167
+ k,
168
+ });
169
+ };
170
+
171
+ const compareMetrics = (
172
+ baseline: ReturnType<typeof metricsFor>,
173
+ candidate: ReturnType<typeof metricsFor>
174
+ ): "improved" | "unchanged" | "regressed" => {
175
+ const left = [candidate.recallAtK, candidate.ndcgAtK, candidate.mrr];
176
+ const right = [baseline.recallAtK, baseline.ndcgAtK, baseline.mrr];
177
+ for (const [index, value] of left.entries()) {
178
+ if (value > right[index]!) return "improved";
179
+ if (value < right[index]!) return "regressed";
180
+ }
181
+ return "unchanged";
182
+ };
183
+
184
+ const diagnoseMissing = async (
185
+ deps: RetrievalReplayDeps,
186
+ source: RetrievalQrelsCase,
187
+ qrel: RetrievalQrel
188
+ ): Promise<RetrievalReplayQrelResult["diagnostic"]> => {
189
+ const target = qrel.target.uri ?? qrel.target.docid;
190
+ if (!target) return null;
191
+ const targetCollection = qrel.target.uri
192
+ ? parseUri(qrel.target.uri)?.collection
193
+ : undefined;
194
+ const diagnosed = await diagnoseQueryTarget(deps, source.query.text, {
195
+ ...buildRetrievalReplaySearchOptions(
196
+ source,
197
+ { id: "diagnose", type: "hybrid" },
198
+ targetCollection
199
+ ),
200
+ target,
201
+ });
202
+ if (!diagnosed.ok) return null;
203
+ return {
204
+ status: diagnosed.value.target.status,
205
+ filterReasons: diagnosed.value.target.filterReasons,
206
+ stageDropReasons: diagnosed.value.stages.flatMap((stage) =>
207
+ stage.dropReason ? [stage.dropReason] : []
208
+ ),
209
+ };
210
+ };
211
+
212
+ const replayCase = async (
213
+ deps: RetrievalReplayDeps,
214
+ source: RetrievalQrelsCase,
215
+ candidate: RetrievalReplayCandidate,
216
+ currentFingerprints: Awaited<
217
+ ReturnType<typeof buildRetrievalTraceFingerprints>
218
+ >,
219
+ candidateFingerprints: Awaited<
220
+ ReturnType<typeof buildRetrievalTraceFingerprints>
221
+ >
222
+ ): Promise<RetrievalReplayCaseResult> => {
223
+ const documents = await deps.store.listDocuments();
224
+ const candidateResult = await runRetrievalReplayCandidate(
225
+ deps,
226
+ source,
227
+ candidate
228
+ );
229
+ const k = retrievalReplayLimit(source, candidate);
230
+ const baselineMetrics = metricsFor(
231
+ source.qrels,
232
+ rankedIds(source.qrels, source.baseline.ranked),
233
+ k
234
+ );
235
+ if (!documents.ok || !candidateResult.ok) {
236
+ return {
237
+ caseId: source.caseId,
238
+ traceId: source.traceId,
239
+ terminalStatus: source.terminalStatus,
240
+ verdict: "unreplayable",
241
+ reason: "candidate_failed",
242
+ metrics: {
243
+ baseline: baselineMetrics,
244
+ candidate: failedCandidateMetrics(),
245
+ baselineCoverage: baselineMetrics.recallAtK,
246
+ candidateCoverage: 0,
247
+ },
248
+ fingerprints: {
249
+ original: source.fingerprints,
250
+ current: currentFingerprints,
251
+ candidate: candidateFingerprints,
252
+ },
253
+ capabilityOutcomes: {
254
+ baseline: source.baseline.capabilityOutcomes,
255
+ candidate: [],
256
+ },
257
+ fallbackCodes: {
258
+ baseline: source.baseline.fallbackCodes,
259
+ candidate: [],
260
+ },
261
+ qrels: [],
262
+ };
263
+ }
264
+ const results = candidateResult.value.results;
265
+ const candidateMetrics = metricsFor(
266
+ source.qrels,
267
+ rankedIds(source.qrels, results),
268
+ k
269
+ );
270
+ const qrelResults: RetrievalReplayQrelResult[] = [];
271
+ let unreplayableReason: RetrievalReplayReason | null = null;
272
+ for (const qrel of source.qrels) {
273
+ const document = await resolveDocument(deps, qrel, documents.value);
274
+ const state = document.ok ? sourceState(qrel, document.value) : "missing";
275
+ if (!qrel.baselineMissing && qrel.relevance > 0) {
276
+ if (state === "stale") unreplayableReason = "source_stale";
277
+ if (state === "missing") unreplayableReason = "source_missing";
278
+ }
279
+ const candidateIndex = results.findIndex((result) =>
280
+ targetMatchesResult(qrel, result)
281
+ );
282
+ const candidateRank = candidateIndex < 0 ? null : candidateIndex + 1;
283
+ const baselineRank = qrel.evidence?.rank ?? null;
284
+ const hasOutcome = (kind: "opened" | "cited" | "pinned") =>
285
+ source.baseline.outcomes[kind].some((item) =>
286
+ targetMatchesEvidence(qrel, item)
287
+ );
288
+ qrelResults.push({
289
+ qrelId: qrel.qrelId,
290
+ label: qrel.label,
291
+ relevance: qrel.relevance,
292
+ baselineRank,
293
+ plannerRank: qrel.evidence?.plannerRank ?? null,
294
+ candidateRank,
295
+ rankDelta:
296
+ baselineRank === null || candidateRank === null
297
+ ? null
298
+ : baselineRank - candidateRank,
299
+ opened: hasOutcome("opened"),
300
+ cited: hasOutcome("cited"),
301
+ pinned: hasOutcome("pinned"),
302
+ sourceState: state,
303
+ diagnostic:
304
+ qrel.baselineMissing && candidateRank === null
305
+ ? await diagnoseMissing(deps, source, qrel)
306
+ : null,
307
+ });
308
+ }
309
+ const metadata = retrievalReplayTraceMetadata(
310
+ candidate,
311
+ candidateResult.value
312
+ );
313
+ const verdict = unreplayableReason
314
+ ? "unreplayable"
315
+ : compareMetrics(baselineMetrics, candidateMetrics);
316
+ return {
317
+ caseId: source.caseId,
318
+ traceId: source.traceId,
319
+ terminalStatus: source.terminalStatus,
320
+ verdict,
321
+ reason: unreplayableReason,
322
+ metrics: {
323
+ baseline: baselineMetrics,
324
+ candidate: candidateMetrics,
325
+ baselineCoverage: baselineMetrics.recallAtK,
326
+ candidateCoverage: candidateMetrics.recallAtK,
327
+ },
328
+ fingerprints: {
329
+ original: source.fingerprints,
330
+ current: currentFingerprints,
331
+ candidate: candidateFingerprints,
332
+ },
333
+ capabilityOutcomes: {
334
+ baseline: source.baseline.capabilityOutcomes,
335
+ candidate:
336
+ metadata?.capabilityOutcomes.map((outcome) => ({
337
+ ...outcome,
338
+ reasonCode: outcome.reasonCode ?? null,
339
+ })) ?? [],
340
+ },
341
+ fallbackCodes: {
342
+ baseline: source.baseline.fallbackCodes,
343
+ candidate: metadata?.fallbackCodes ?? [],
344
+ },
345
+ qrels: qrelResults,
346
+ };
347
+ };
348
+
349
+ export const replayRetrievalTraces = async (
350
+ deps: RetrievalReplayDeps,
351
+ input: ReplayRetrievalTraceInput
352
+ ): Promise<StoreResult<ReplayRetrievalTraceResult>> => {
353
+ const parsed = parseReplayRetrievalTraceInput(input);
354
+ if (!parsed.ok) return parsed;
355
+ const validatedInput = parsed.value;
356
+ const stored = await deps.store.getRetrievalTraceExportBundle(
357
+ validatedInput.exportId
358
+ );
359
+ if (!stored.ok) {
360
+ if (stored.error.message.includes("missing retrieval trace")) {
361
+ return ok(unreplayable(validatedInput, "trace_missing"));
362
+ }
363
+ return stored;
364
+ }
365
+ if (!stored.value)
366
+ return ok(unreplayable(validatedInput, "manifest_missing"));
367
+ if (stored.value.manifest.format !== "qrels") {
368
+ return ok(unreplayable(validatedInput, "redaction_incompatible"));
369
+ }
370
+ const artifact = buildRetrievalQrelsArtifact(stored.value.traces);
371
+ if (!artifact.ok) {
372
+ return ok(
373
+ unreplayable(
374
+ validatedInput,
375
+ reconstructionReason(artifact.error.message) ?? "trace_missing"
376
+ )
377
+ );
378
+ }
379
+ if (
380
+ hashTraceCanonical(artifact.value) !== stored.value.manifest.artifactHash
381
+ ) {
382
+ return ok(unreplayable(validatedInput, "manifest_hash_mismatch"));
383
+ }
384
+ let currentFingerprints;
385
+ let candidateFingerprints;
386
+ try {
387
+ currentFingerprints = await buildRetrievalTraceFingerprints({
388
+ store: deps.store,
389
+ config: deps.config,
390
+ pipeline: "retrieval-replay-current",
391
+ indexName: deps.indexName,
392
+ modelUris: deps.modelUris,
393
+ });
394
+ candidateFingerprints = await buildRetrievalTraceFingerprints({
395
+ store: deps.store,
396
+ config: deps.config,
397
+ pipeline: "retrieval-replay-candidate",
398
+ pipelineOptions: { ...validatedInput.candidate },
399
+ indexName: deps.indexName,
400
+ modelUris: deps.modelUris,
401
+ });
402
+ } catch {
403
+ return ok(unreplayable(validatedInput, "candidate_failed"));
404
+ }
405
+ const cases = [];
406
+ for (const source of artifact.value.cases) {
407
+ cases.push(
408
+ await replayCase(
409
+ deps,
410
+ source,
411
+ validatedInput.candidate,
412
+ currentFingerprints,
413
+ candidateFingerprints
414
+ )
415
+ );
416
+ }
417
+ const verdict = cases.some((item) => item.verdict === "unreplayable")
418
+ ? "unreplayable"
419
+ : cases.some((item) => item.verdict === "regressed")
420
+ ? "regressed"
421
+ : cases.some((item) => item.verdict === "improved")
422
+ ? "improved"
423
+ : "unchanged";
424
+ const reason =
425
+ cases.find((item) => item.verdict === "unreplayable")?.reason ?? null;
426
+ return ok({
427
+ schemaVersion: "1.0",
428
+ exportId: validatedInput.exportId,
429
+ candidate: validatedInput.candidate,
430
+ verdict,
431
+ reason,
432
+ recommendation:
433
+ verdict === "improved"
434
+ ? "promote"
435
+ : verdict === "regressed"
436
+ ? "keep_baseline"
437
+ : "manual_review",
438
+ applied: false,
439
+ cases,
440
+ });
441
+ };
@@ -0,0 +1,178 @@
1
+ /** Exact retrieval-run provenance for later evidence outcomes. */
2
+
3
+ import type { StoreResult } from "../store/types";
4
+
5
+ import { err, ok } from "../store/types";
6
+
7
+ export interface RetrievalTraceEvidence {
8
+ docid: string;
9
+ sourceHash: string;
10
+ mirrorHash: string;
11
+ uri: string;
12
+ seq?: number;
13
+ startLine: number;
14
+ endLine: number;
15
+ passageHash: string;
16
+ score?: number;
17
+ rank?: number;
18
+ plannerRank?: number;
19
+ sources?: string[];
20
+ graphExpanded?: boolean;
21
+ }
22
+
23
+ const documentIdentity = (evidence: RetrievalTraceEvidence): string =>
24
+ [evidence.docid, evidence.sourceHash, evidence.mirrorHash, evidence.uri].join(
25
+ "\0"
26
+ );
27
+
28
+ const passageIdentity = (evidence: RetrievalTraceEvidence): string =>
29
+ [
30
+ documentIdentity(evidence),
31
+ evidence.startLine,
32
+ evidence.endLine,
33
+ evidence.passageHash,
34
+ ].join("\0");
35
+
36
+ const addRun = (
37
+ index: Map<string, Set<string>>,
38
+ key: string,
39
+ runId: string
40
+ ): void => {
41
+ const runs = index.get(key) ?? new Set<string>();
42
+ runs.add(runId);
43
+ index.set(key, runs);
44
+ };
45
+
46
+ const storedEvidence = (value: unknown): RetrievalTraceEvidence | null => {
47
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
48
+ const item = value as Record<string, unknown>;
49
+ if (
50
+ typeof item.docid !== "string" ||
51
+ typeof item.sourceHash !== "string" ||
52
+ typeof item.mirrorHash !== "string" ||
53
+ typeof item.uri !== "string" ||
54
+ typeof item.startLine !== "number" ||
55
+ typeof item.endLine !== "number" ||
56
+ typeof item.passageHash !== "string"
57
+ ) {
58
+ return null;
59
+ }
60
+ return {
61
+ docid: item.docid,
62
+ sourceHash: item.sourceHash,
63
+ mirrorHash: item.mirrorHash,
64
+ uri: item.uri,
65
+ startLine: item.startLine,
66
+ endLine: item.endLine,
67
+ passageHash: item.passageHash,
68
+ ...(typeof item.seq === "number" ? { seq: item.seq } : {}),
69
+ };
70
+ };
71
+
72
+ export class RetrievalTraceEvidenceOrigins {
73
+ private readonly documents = new Map<string, Set<string>>();
74
+ private readonly passages = new Map<string, Set<string>>();
75
+
76
+ add(runId: string, evidence: RetrievalTraceEvidence[]): void {
77
+ for (const item of evidence) {
78
+ addRun(this.documents, documentIdentity(item), runId);
79
+ addRun(this.passages, passageIdentity(item), runId);
80
+ }
81
+ }
82
+
83
+ addFallback(runId: string, evidence: RetrievalTraceEvidence[]): void {
84
+ for (const item of evidence) {
85
+ const passageRuns = this.passages.get(passageIdentity(item));
86
+ const documentRuns = this.documents.get(documentIdentity(item));
87
+ if (
88
+ (passageRuns && passageRuns.size > 0) ||
89
+ (documentRuns && documentRuns.size > 0)
90
+ ) {
91
+ continue;
92
+ }
93
+ addRun(this.documents, documentIdentity(item), runId);
94
+ addRun(this.passages, passageIdentity(item), runId);
95
+ }
96
+ }
97
+
98
+ addStored(runId: string, payload: Record<string, unknown>): void {
99
+ const ranked = Array.isArray(payload.ranked) ? payload.ranked : [];
100
+ this.add(
101
+ runId,
102
+ ranked.flatMap((value) => {
103
+ const parsed = storedEvidence(value);
104
+ return parsed ? [parsed] : [];
105
+ })
106
+ );
107
+ }
108
+
109
+ addStoredFallback(runId: string, payload: Record<string, unknown>): void {
110
+ const evidence = Array.isArray(payload.evidence) ? payload.evidence : [];
111
+ this.addFallback(
112
+ runId,
113
+ evidence.flatMap((value) => {
114
+ const parsed = storedEvidence(value);
115
+ return parsed ? [parsed] : [];
116
+ })
117
+ );
118
+ }
119
+
120
+ addStoredRuns(
121
+ runs: Array<{
122
+ runId: string;
123
+ kind: string;
124
+ payload: Record<string, unknown>;
125
+ }>
126
+ ): void {
127
+ for (const run of runs.filter(({ kind }) => kind === "retrieval")) {
128
+ this.addStored(run.runId, run.payload);
129
+ }
130
+ for (const run of runs.filter(({ kind }) => kind === "get")) {
131
+ this.addStoredFallback(run.runId, run.payload);
132
+ }
133
+ for (const run of runs.filter(({ kind }) => kind === "context")) {
134
+ this.addStoredFallback(run.runId, run.payload);
135
+ }
136
+ }
137
+
138
+ group(
139
+ evidence: RetrievalTraceEvidence[]
140
+ ): StoreResult<Map<string, RetrievalTraceEvidence[]>> {
141
+ if (evidence.length === 0) {
142
+ return err(
143
+ "INVALID_INPUT",
144
+ "outcome_evidence_unmatched: evidence outcomes cannot be empty"
145
+ );
146
+ }
147
+ const groups = new Map<string, RetrievalTraceEvidence[]>();
148
+ for (const item of evidence) {
149
+ const passageRuns = this.passages.get(passageIdentity(item));
150
+ const documentRuns = this.documents.get(documentIdentity(item));
151
+ const candidates =
152
+ passageRuns && passageRuns.size > 0 ? passageRuns : documentRuns;
153
+ if (!candidates || candidates.size === 0) {
154
+ return err(
155
+ "INVALID_INPUT",
156
+ `outcome_evidence_unmatched: ${item.uri} was not produced by a retrieval run`
157
+ );
158
+ }
159
+ if (candidates.size !== 1) {
160
+ return err(
161
+ "INVALID_INPUT",
162
+ `outcome_evidence_ambiguous: ${item.uri} belongs to multiple retrieval runs`
163
+ );
164
+ }
165
+ const runId = candidates.values().next().value;
166
+ if (typeof runId !== "string") {
167
+ return err(
168
+ "INVALID_INPUT",
169
+ `outcome_evidence_unmatched: ${item.uri} has no retrieval run`
170
+ );
171
+ }
172
+ const current = groups.get(runId) ?? [];
173
+ current.push(item);
174
+ groups.set(runId, current);
175
+ }
176
+ return ok(groups);
177
+ }
178
+ }